Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "free-claude-code"
version = "6.2.57"
version = "6.2.58"
description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
readme = "README.md"
requires-python = ">=3.14.0"
Expand Down
113 changes: 98 additions & 15 deletions src/free_claude_code/application/web_tools/request.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
"""Recognize the narrow Anthropic web-tool requests FCC can execute locally."""

import json
import re
from dataclasses import dataclass

from free_claude_code.application.errors import InvalidRequestError
from free_claude_code.core.anthropic import MessagesRequest, Tool
from free_claude_code.core.anthropic.models import (
ContentBlockServerToolUse,
ContentBlockText,
ContentBlockWebSearchToolResult,
Message,
)

WEB_SEARCH_TYPE = "web_search_20250305"
WEB_FETCH_TYPE = "web_fetch_20250910"
Expand Down Expand Up @@ -41,6 +48,7 @@ class AutomaticWebSearchPlan:

request: MessagesRequest
domains: WebSearchDomainFilter
max_uses: int | None


def forced_tool_turn_text(request: MessagesRequest) -> str:
Expand Down Expand Up @@ -117,10 +125,6 @@ def plan_automatic_web_search(
"Anthropic web_search is disabled (ENABLE_WEB_SERVER_TOOLS=false). "
"Enable local web tools or remove web_search from the request."
)
if _has_server_tool_history(request):
raise InvalidRequestError(
"Automatic web_search does not accept prior Anthropic server-tool history."
)
if tool.name != "web_search":
raise InvalidRequestError(
f"Server tool type {WEB_SEARCH_TYPE!r} must use name 'web_search'."
Expand All @@ -138,7 +142,7 @@ def plan_automatic_web_search(
+ ", ".join(unsupported)
+ "."
)
_validate_max_uses(options.get("max_uses"))
max_uses = _validate_max_uses(options.get("max_uses"))
allowed = _parse_domains(
options.get("allowed_domains"), field="web_search.allowed_domains"
)
Expand All @@ -161,12 +165,16 @@ def plan_automatic_web_search(
},
)
translated = request.model_copy(
update={"tools": [hidden_tool]},
update={
"tools": [hidden_tool],
"messages": _normalized_search_history(request.messages),
},
deep=True,
)
return AutomaticWebSearchPlan(
request=translated,
domains=WebSearchDomainFilter(allowed=allowed, blocked=blocked),
max_uses=max_uses,
)


Expand All @@ -190,21 +198,96 @@ def unsupported_server_tool_error(
return None


def _has_server_tool_history(request: MessagesRequest) -> bool:
for message in request.messages:
if not isinstance(message.content, list):
_INCOMPLETE_HISTORY = (
"Automatic web_search does not accept incomplete or unsupported prior "
"Anthropic server-tool history."
)


def _normalized_search_history(messages: list[Message]) -> list[Message]:
"""Quote completed local search data for every upstream transport."""
normalized: list[Message] = []
for message in messages:
content = message.content
if not isinstance(content, list) or not any(
getattr(block, "type", None) in _SERVER_HISTORY_TYPES for block in content
):
normalized.append(message)
continue
for block in message.content:
if getattr(block, "type", None) in _SERVER_HISTORY_TYPES:
return True
return False
if message.role != "assistant":
raise InvalidRequestError(_INCOMPLETE_HISTORY)

uses: dict[str, ContentBlockServerToolUse] = {}
results: dict[str, ContentBlockWebSearchToolResult] = {}
for block in content:
if isinstance(block, ContentBlockServerToolUse):
query = block.input.get("query")
if (
block.name != "web_search"
or not block.id
or block.id in uses
or set(block.input) != {"query"}
or not isinstance(query, str)
or not query.strip()
):
raise InvalidRequestError(_INCOMPLETE_HISTORY)
uses[block.id] = block
elif isinstance(block, ContentBlockWebSearchToolResult):
if not block.tool_use_id or block.tool_use_id in results:
raise InvalidRequestError(_INCOMPLETE_HISTORY)
results[block.tool_use_id] = block
elif getattr(block, "type", None) in _SERVER_HISTORY_TYPES:
raise InvalidRequestError(_INCOMPLETE_HISTORY)
if not uses or uses.keys() != results.keys():
raise InvalidRequestError(_INCOMPLETE_HISTORY)

projected = []
for block in content:
if isinstance(block, ContentBlockServerToolUse):
projected.append(_search_history_text(block, results[block.id]))
elif not isinstance(block, ContentBlockWebSearchToolResult):
projected.append(block)
normalized.append(message.model_copy(update={"content": projected}, deep=True))
return normalized


def _search_history_text(
use: ContentBlockServerToolUse, result: ContentBlockWebSearchToolResult
) -> ContentBlockText:
content = result.content
if isinstance(content, list):
hits: list[dict[str, str]] = []
for hit in content:
if (
not isinstance(hit, dict)
or hit.get("type") != "web_search_result"
or not isinstance(hit.get("title"), str)
or not isinstance(hit.get("url"), str)
):
raise InvalidRequestError(_INCOMPLETE_HISTORY)
hits.append({"title": hit["title"], "url": hit["url"]})
record: dict[str, object] = {"query": use.input["query"], "results": hits}
elif (
isinstance(content, dict)
and content.get("type") == "web_search_tool_result_error"
and isinstance(content.get("error_code"), str)
):
record = {"query": use.input["query"], "error_code": content["error_code"]}
else:
raise InvalidRequestError(_INCOMPLETE_HISTORY)
return ContentBlockText(
type="text",
text="[Earlier web search data]\n"
+ json.dumps(record, ensure_ascii=False, separators=(",", ":")),
)


def _validate_max_uses(value: object) -> None:
def _validate_max_uses(value: object) -> int | None:
if value is None:
return
return None
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise InvalidRequestError("web_search.max_uses must be a positive integer.")
return value


def _parse_domains(value: object, *, field: str) -> tuple[str, ...]:
Expand Down
167 changes: 110 additions & 57 deletions src/free_claude_code/application/web_tools/service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Application workflow for supported local Anthropic web-tool requests."""

import asyncio
import sys
import uuid
from collections.abc import AsyncIterator, Mapping
Expand All @@ -20,12 +21,17 @@
from free_claude_code.core.anthropic.server_tool_sse import (
ServerToolResponseContext,
server_tool_completion_frames,
server_tool_finish_frames,
server_tool_message_start_frames,
server_tool_result_frames,
server_tool_start_frames,
server_tool_use_frames,
web_fetch_result_block,
web_search_result_block,
web_tool_error_block,
)
from free_claude_code.core.failures import ExecutionFailure, FailureKind
from free_claude_code.core.json_types import JsonObject
from free_claude_code.core.trace import close_stream_input, trace_event
from free_claude_code.core.web_tools import WebSearchResult

Expand Down Expand Up @@ -163,30 +169,26 @@ async def _stream_automatic_search(
for chunk in chunks:
yield chunk
return
if len(tool_calls) != 1:
raise _protocol_failure(
"Upstream model returned multiple tool calls for automatic WebSearch.",
request_id=request_id,
)
call = tool_calls[0]
if call.get("name") != HIDDEN_WEB_SEARCH_NAME:
raise _protocol_failure(
"Upstream model returned an unexpected tool for automatic WebSearch.",
request_id=request_id,
)
arguments = call.get("input")
if not isinstance(arguments, dict) or set(arguments) != {"query"}:
raise _protocol_failure(
"Upstream model returned malformed arguments for automatic WebSearch.",
request_id=request_id,
)
raw_query = arguments.get("query")
if not isinstance(raw_query, str) or not raw_query.strip():
raise _protocol_failure(
"Upstream model returned an empty query for automatic WebSearch.",
request_id=request_id,
)
query = raw_query.strip()
queries: list[str] = []
for call in tool_calls:
if call.get("name") != HIDDEN_WEB_SEARCH_NAME:
raise _protocol_failure(
"Upstream model returned an unexpected tool for automatic WebSearch.",
request_id=request_id,
)
arguments = call.get("input")
if not isinstance(arguments, dict) or set(arguments) != {"query"}:
raise _protocol_failure(
"Upstream model returned malformed arguments for automatic WebSearch.",
request_id=request_id,
)
raw_query = arguments.get("query")
if not isinstance(raw_query, str) or not raw_query.strip():
raise _protocol_failure(
"Upstream model returned an empty query for automatic WebSearch.",
request_id=request_id,
)
queries.append(raw_query.strip())
provider_usage = _provider_usage(message)
input_tokens = _integer_field(provider_usage, "input_tokens")
if input_tokens is None:
Expand All @@ -199,24 +201,62 @@ async def _stream_automatic_search(
request_id=request_id,
model=routed.resolved.original_model,
)
local_stream = self._stream_local_tool(
tool_name="web_search",
tool_input={"query": query},
input_tokens=input_tokens,
response_model=routed.resolved.original_model,
provider_usage=provider_usage,
domains=plan.domains,
)
try:
async for frame in local_stream:
yield frame
finally:
await close_stream_input(
local_stream,
owner="automatic_web_search",
source="api",
preserved_error=sys.exception(),
message_id = f"msg_{uuid.uuid4()}"
contexts = [
ServerToolResponseContext(
message_id=message_id,
tool_id=f"srvtoolu_{uuid.uuid4().hex}",
model=routed.resolved.original_model,
tool_name="web_search",
tool_input={"query": query},
input_tokens=input_tokens,
provider_usage=provider_usage,
)
for query in queries
]
executed = min(len(contexts), plan.max_uses or len(contexts))
for frame in server_tool_message_start_frames(contexts[0]):
yield frame

limit = asyncio.Semaphore(4)

async def search_one(
context: ServerToolResponseContext,
) -> tuple[JsonObject, str]:
async with limit:
return await self._search_result(context, plan.domains)

summaries: list[str] = []
async with asyncio.TaskGroup() as group:
tasks = [
group.create_task(search_one(context))
for context in contexts[:executed]
]
for index, context in enumerate(contexts):
for frame in server_tool_use_frames(context, index=index * 2):
yield frame
if index < executed:
result_block, summary = await tasks[index]
else:
result_block = web_tool_error_block(
context, error_code="max_uses_exceeded"
)
summary = (
f"Web search limit reached for: {context.tool_input['query']}"
)
for frame in server_tool_result_frames(
result_block, index=index * 2 + 1
):
yield frame
summaries.append(summary)

for frame in server_tool_finish_frames(
contexts[0],
summary="\n\n".join(summaries),
index=len(contexts) * 2,
request_count=executed,
):
yield frame
trace_event(
stage="execution",
event="free_claude_code.api.web_search.automatic_completed",
Expand Down Expand Up @@ -247,35 +287,48 @@ async def _stream_local_tool(
for frame in server_tool_start_frames(context):
yield frame

try:
if tool_name == "web_search":
query = tool_input["query"]
results = await self._client.search(query)
if domains is not None:
results = _filter_results(results, domains)
result_block = web_search_result_block(context, results)
summary = _search_summary(query, results)
else:
if tool_name == "web_search":
result_block, summary = await self._search_result(context, domains)
else:
try:
fetched = await self._client.fetch(
tool_input["url"], egress=self._egress
)
result_block = web_fetch_result_block(
context, fetched, retrieved_at=datetime.now(UTC).isoformat()
)
summary = fetched.data
except Exception as error:
fetch_url = tool_input.get("url") if tool_name == "web_fetch" else None
_log_web_tool_failure(tool_name, error, fetch_url=fetch_url)
result_block = web_tool_error_block(context)
summary = _web_tool_client_error_summary(
tool_name, error, verbose=self._verbose_client_errors
)
except Exception as error:
_log_web_tool_failure(tool_name, error, fetch_url=tool_input["url"])
result_block = web_tool_error_block(context)
summary = _web_tool_client_error_summary(
tool_name, error, verbose=self._verbose_client_errors
)

for frame in server_tool_completion_frames(
context, result_block, summary=summary
):
yield frame

async def _search_result(
self,
context: ServerToolResponseContext,
domains: WebSearchDomainFilter | None,
) -> tuple[JsonObject, str]:
query = context.tool_input["query"]
try:
results = await self._client.search(query)
if domains is not None:
results = _filter_results(results, domains)
return web_search_result_block(context, results), _search_summary(
query, results
)
except Exception as error:
_log_web_tool_failure("web_search", error, fetch_url=None)
return web_tool_error_block(context), _web_tool_client_error_summary(
"web_search", error, verbose=self._verbose_client_errors
)


def _search_summary(query: str, results: list[WebSearchResult]) -> str:
if not results:
Expand Down
Loading
Loading