coderouter-cli 2.18.0__py3-none-any.whl → 2.19.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coderouter/ingress/app.py +3 -0
- coderouter/ingress/responses_routes.py +269 -0
- coderouter/translation/responses.py +686 -0
- {coderouter_cli-2.18.0.dist-info → coderouter_cli-2.19.0.dist-info}/METADATA +3 -5
- {coderouter_cli-2.18.0.dist-info → coderouter_cli-2.19.0.dist-info}/RECORD +8 -6
- {coderouter_cli-2.18.0.dist-info → coderouter_cli-2.19.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.18.0.dist-info → coderouter_cli-2.19.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.18.0.dist-info → coderouter_cli-2.19.0.dist-info}/licenses/LICENSE +0 -0
coderouter/ingress/app.py
CHANGED
|
@@ -19,6 +19,7 @@ from coderouter.ingress.dashboard_routes import router as dashboard_router
|
|
|
19
19
|
from coderouter.ingress.launcher_routes import router as launcher_router
|
|
20
20
|
from coderouter.ingress.metrics_routes import router as metrics_router
|
|
21
21
|
from coderouter.ingress.openai_routes import router as openai_router
|
|
22
|
+
from coderouter.ingress.responses_routes import router as responses_router
|
|
22
23
|
from coderouter.logging import configure_logging, get_logger
|
|
23
24
|
from coderouter.metrics import install_collector
|
|
24
25
|
from coderouter.plugins import discover_and_load
|
|
@@ -491,6 +492,8 @@ def create_app(config_path: str | None = None) -> FastAPI:
|
|
|
491
492
|
|
|
492
493
|
app.include_router(openai_router, prefix="/v1", tags=["openai-compat"])
|
|
493
494
|
app.include_router(anthropic_router, prefix="/v1", tags=["anthropic-compat"])
|
|
495
|
+
# v2.19.0: OpenAI Responses API (Codex CLI).
|
|
496
|
+
app.include_router(responses_router, prefix="/v1", tags=["openai-responses"])
|
|
494
497
|
# v1.5-A: /metrics.json sits at the root (no /v1 prefix) — metrics are not
|
|
495
498
|
# part of the OpenAI / Anthropic API surface, and Prometheus-style
|
|
496
499
|
# endpoints conventionally live at the root in v1.5-B.
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""OpenAI Responses API route: ``POST /v1/responses`` (v2.19.0).
|
|
2
|
+
|
|
3
|
+
Lets the OpenAI Codex CLI (Responses-only since 2026-02) use CodeRouter as a
|
|
4
|
+
custom provider::
|
|
5
|
+
|
|
6
|
+
[model_providers.coderouter]
|
|
7
|
+
name = "CodeRouter"
|
|
8
|
+
base_url = "http://127.0.0.1:8088/v1"
|
|
9
|
+
wire_api = "responses"
|
|
10
|
+
|
|
11
|
+
The request is translated to an Anthropic Messages request
|
|
12
|
+
(:mod:`coderouter.translation.responses`) and run through the same fallback
|
|
13
|
+
engine as ``/v1/messages`` — tool-call repair, guards, fallback and the
|
|
14
|
+
v2.16.0 error mapping all apply. Profile selection mirrors the other
|
|
15
|
+
routes: body ``profile`` > ``X-CodeRouter-Profile`` > ``X-CodeRouter-Mode``
|
|
16
|
+
> auto router > default.
|
|
17
|
+
|
|
18
|
+
Failures are reported the way Codex reads them:
|
|
19
|
+
|
|
20
|
+
- before the response commits: an OpenAI-style error body with a real
|
|
21
|
+
HTTP status (400 non-retryable / 429 with ``Retry-After`` / 502);
|
|
22
|
+
- a context overflow on a streaming request: ``response.failed`` with code
|
|
23
|
+
``context_length_exceeded`` — the only overflow signal Codex recognizes
|
|
24
|
+
(an HTTP 400 is treated as a plain invalid request);
|
|
25
|
+
- after the stream started: ``response.failed`` (``server_error``), then end
|
|
26
|
+
of stream, which Codex retries.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import contextlib
|
|
33
|
+
import json
|
|
34
|
+
from collections.abc import AsyncIterator
|
|
35
|
+
|
|
36
|
+
from fastapi import APIRouter, Header, Request
|
|
37
|
+
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
|
38
|
+
|
|
39
|
+
from coderouter.adapters.slot_affinity import affinity_key
|
|
40
|
+
from coderouter.guards.context_budget import ContextBudgetRejectError
|
|
41
|
+
from coderouter.guards.tool_loop import ToolCountExceededError, ToolLoopBreakError
|
|
42
|
+
from coderouter.ingress.anthropic_errors import (
|
|
43
|
+
PROMPT_TOO_LONG_PREFIX,
|
|
44
|
+
AnthropicErrorResponse,
|
|
45
|
+
map_no_providers_error,
|
|
46
|
+
)
|
|
47
|
+
from coderouter.ingress.anthropic_routes import (
|
|
48
|
+
_DisconnectWatcher,
|
|
49
|
+
_resolve_stream_timeout_s,
|
|
50
|
+
)
|
|
51
|
+
from coderouter.logging import get_logger
|
|
52
|
+
from coderouter.routing import FallbackEngine, MidStreamError, NoProvidersAvailableError
|
|
53
|
+
from coderouter.routing.auto_router import (
|
|
54
|
+
RESERVED_PROFILE_NAME,
|
|
55
|
+
classify,
|
|
56
|
+
extract_gateway_hints,
|
|
57
|
+
)
|
|
58
|
+
from coderouter.translation import AnthropicStreamEvent
|
|
59
|
+
from coderouter.translation.responses import (
|
|
60
|
+
ResponsesStreamTranslator,
|
|
61
|
+
anthropic_response_to_responses,
|
|
62
|
+
format_sse,
|
|
63
|
+
responses_to_anthropic,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
router = APIRouter()
|
|
67
|
+
logger = get_logger(__name__)
|
|
68
|
+
|
|
69
|
+
_PROFILE_HEADER = "x-coderouter-profile"
|
|
70
|
+
_MODE_HEADER = "x-coderouter-mode"
|
|
71
|
+
|
|
72
|
+
_OPENAI_TYPE = {
|
|
73
|
+
"invalid_request_error": "invalid_request_error",
|
|
74
|
+
"request_too_large": "invalid_request_error",
|
|
75
|
+
"rate_limit_error": "rate_limit_error",
|
|
76
|
+
"timeout_error": "server_error",
|
|
77
|
+
"api_error": "server_error",
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _openai_error(
|
|
82
|
+
status: int,
|
|
83
|
+
message: str,
|
|
84
|
+
*,
|
|
85
|
+
etype: str,
|
|
86
|
+
code: str | None = None,
|
|
87
|
+
headers: dict[str, str] | None = None,
|
|
88
|
+
) -> JSONResponse:
|
|
89
|
+
return JSONResponse(
|
|
90
|
+
status_code=status,
|
|
91
|
+
content={"error": {"message": message, "type": etype, "param": None, "code": code}},
|
|
92
|
+
headers=headers or None,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _is_overflow(mapped: AnthropicErrorResponse) -> bool:
|
|
97
|
+
return mapped.status_code == 400 and mapped.message.lower().startswith(PROMPT_TOO_LONG_PREFIX)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _mapped_to_http(mapped: AnthropicErrorResponse) -> JSONResponse:
|
|
101
|
+
code = None
|
|
102
|
+
if _is_overflow(mapped):
|
|
103
|
+
code = "context_length_exceeded"
|
|
104
|
+
elif mapped.status_code == 429:
|
|
105
|
+
code = "rate_limit_exceeded"
|
|
106
|
+
return _openai_error(
|
|
107
|
+
mapped.status_code,
|
|
108
|
+
mapped.message,
|
|
109
|
+
etype=_OPENAI_TYPE.get(mapped.error_type, "server_error"),
|
|
110
|
+
code=code,
|
|
111
|
+
headers=dict(mapped.headers),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _failed_stream(translator: ResponsesStreamTranslator, code: str, message: str) -> StreamingResponse:
|
|
116
|
+
async def gen() -> AsyncIterator[str]:
|
|
117
|
+
for ev in translator.start():
|
|
118
|
+
yield format_sse(ev)
|
|
119
|
+
for ev in translator.failed(code, message):
|
|
120
|
+
yield format_sse(ev)
|
|
121
|
+
|
|
122
|
+
return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@router.post("/responses", response_model=None)
|
|
126
|
+
async def responses(
|
|
127
|
+
request: Request,
|
|
128
|
+
x_coderouter_profile: str | None = Header(default=None, alias=_PROFILE_HEADER),
|
|
129
|
+
x_coderouter_mode: str | None = Header(default=None, alias=_MODE_HEADER),
|
|
130
|
+
) -> Response:
|
|
131
|
+
"""OpenAI Responses API endpoint (streaming and non-streaming)."""
|
|
132
|
+
engine: FallbackEngine = request.app.state.engine
|
|
133
|
+
config = request.app.state.config
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
body = await request.json()
|
|
137
|
+
except (json.JSONDecodeError, ValueError):
|
|
138
|
+
return _openai_error(400, "request body must be JSON", etype="invalid_request_error")
|
|
139
|
+
if not isinstance(body, dict):
|
|
140
|
+
return _openai_error(400, "request body must be a JSON object", etype="invalid_request_error")
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
anth_req, tool_map = responses_to_anthropic(body)
|
|
144
|
+
except Exception as exc: # pydantic ValidationError and shape errors
|
|
145
|
+
return _openai_error(400, f"could not translate request: {exc}", etype="invalid_request_error")
|
|
146
|
+
|
|
147
|
+
model = body.get("model") if isinstance(body.get("model"), str) else "coderouter"
|
|
148
|
+
|
|
149
|
+
# Conversation key for llama-server slot affinity: Claude-style hint
|
|
150
|
+
# headers when present, else Codex's own ``session-id`` header.
|
|
151
|
+
hints = extract_gateway_hints(request.headers)
|
|
152
|
+
anth_req.session_key = affinity_key(hints) or request.headers.get("session-id") or None
|
|
153
|
+
|
|
154
|
+
if anth_req.profile is None and x_coderouter_profile:
|
|
155
|
+
anth_req.profile = x_coderouter_profile
|
|
156
|
+
if anth_req.profile is None and x_coderouter_mode:
|
|
157
|
+
try:
|
|
158
|
+
anth_req.profile = config.resolve_mode(x_coderouter_mode)
|
|
159
|
+
except KeyError:
|
|
160
|
+
return _openai_error(
|
|
161
|
+
400, f"unknown mode {x_coderouter_mode!r}", etype="invalid_request_error"
|
|
162
|
+
)
|
|
163
|
+
if anth_req.profile is None and config.default_profile == RESERVED_PROFILE_NAME:
|
|
164
|
+
anth_req.profile = classify(anth_req.model_dump(), config, hints)
|
|
165
|
+
if anth_req.profile is not None:
|
|
166
|
+
try:
|
|
167
|
+
config.profile_by_name(anth_req.profile)
|
|
168
|
+
except KeyError:
|
|
169
|
+
return _openai_error(
|
|
170
|
+
400, f"unknown profile {anth_req.profile!r}", etype="invalid_request_error"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
translator = ResponsesStreamTranslator(tool_map, model=model)
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
anth_req, _ctx = engine.apply_context_budget(anth_req)
|
|
177
|
+
except ContextBudgetRejectError as exc:
|
|
178
|
+
if anth_req.stream:
|
|
179
|
+
return _failed_stream(translator, "context_length_exceeded", str(exc))
|
|
180
|
+
return _openai_error(400, str(exc), etype="invalid_request_error", code="context_length_exceeded")
|
|
181
|
+
|
|
182
|
+
if not anth_req.stream:
|
|
183
|
+
try:
|
|
184
|
+
resp = await engine.generate_anthropic(anth_req)
|
|
185
|
+
except NoProvidersAvailableError as exc:
|
|
186
|
+
return _mapped_to_http(map_no_providers_error(exc))
|
|
187
|
+
except ContextBudgetRejectError as exc:
|
|
188
|
+
return _openai_error(400, str(exc), etype="invalid_request_error", code="context_length_exceeded")
|
|
189
|
+
except (ToolLoopBreakError, ToolCountExceededError) as exc:
|
|
190
|
+
return _openai_error(400, str(exc), etype="invalid_request_error")
|
|
191
|
+
return JSONResponse(
|
|
192
|
+
anthropic_response_to_responses(resp.model_dump(exclude_none=True), tool_map, model=model)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# Streaming: pull the first engine event before committing, like
|
|
196
|
+
# /v1/messages (v2.16.0), so pre-content failures get a real status.
|
|
197
|
+
timeout_s = _resolve_stream_timeout_s(engine, anth_req.profile)
|
|
198
|
+
deadline = asyncio.get_running_loop().time() + timeout_s
|
|
199
|
+
source = engine.stream_anthropic(anth_req)
|
|
200
|
+
first: AnthropicStreamEvent | None = None
|
|
201
|
+
watcher = _DisconnectWatcher(request)
|
|
202
|
+
watcher.start()
|
|
203
|
+
|
|
204
|
+
async def _close() -> None:
|
|
205
|
+
with contextlib.suppress(Exception):
|
|
206
|
+
await source.aclose() # type: ignore[attr-defined]
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
async with asyncio.timeout_at(deadline) as scope:
|
|
210
|
+
first = await anext(source)
|
|
211
|
+
except StopAsyncIteration:
|
|
212
|
+
first = None
|
|
213
|
+
except NoProvidersAvailableError as exc:
|
|
214
|
+
await _close()
|
|
215
|
+
mapped = map_no_providers_error(exc)
|
|
216
|
+
if _is_overflow(mapped):
|
|
217
|
+
return _failed_stream(translator, "context_length_exceeded", mapped.message)
|
|
218
|
+
return _mapped_to_http(mapped)
|
|
219
|
+
except ContextBudgetRejectError as exc:
|
|
220
|
+
await _close()
|
|
221
|
+
return _failed_stream(translator, "context_length_exceeded", str(exc))
|
|
222
|
+
except (ToolLoopBreakError, ToolCountExceededError) as exc:
|
|
223
|
+
await _close()
|
|
224
|
+
return _openai_error(400, str(exc), etype="invalid_request_error")
|
|
225
|
+
except MidStreamError as exc:
|
|
226
|
+
await _close()
|
|
227
|
+
return _failed_stream(translator, "server_error", str(exc))
|
|
228
|
+
except TimeoutError:
|
|
229
|
+
await _close()
|
|
230
|
+
if not scope.expired():
|
|
231
|
+
raise
|
|
232
|
+
return _openai_error(504, f"no upstream event within {timeout_s:.0f}s", etype="server_error")
|
|
233
|
+
except asyncio.CancelledError:
|
|
234
|
+
await _close()
|
|
235
|
+
if watcher.fired:
|
|
236
|
+
watcher.uncancel()
|
|
237
|
+
return Response(status_code=499)
|
|
238
|
+
raise
|
|
239
|
+
except BaseException:
|
|
240
|
+
await _close()
|
|
241
|
+
raise
|
|
242
|
+
finally:
|
|
243
|
+
await watcher.stop()
|
|
244
|
+
|
|
245
|
+
async def gen() -> AsyncIterator[str]:
|
|
246
|
+
try:
|
|
247
|
+
async with asyncio.timeout_at(deadline):
|
|
248
|
+
for ev in translator.start():
|
|
249
|
+
yield format_sse(ev)
|
|
250
|
+
if first is not None:
|
|
251
|
+
for out in translator.feed(first):
|
|
252
|
+
yield format_sse(out)
|
|
253
|
+
async for ev in source:
|
|
254
|
+
for out in translator.feed(ev):
|
|
255
|
+
yield format_sse(out)
|
|
256
|
+
for out in translator.finish():
|
|
257
|
+
yield format_sse(out)
|
|
258
|
+
except TimeoutError:
|
|
259
|
+
logger.warning("responses-stream-timeout", extra={"timeout_s": timeout_s})
|
|
260
|
+
for out in translator.failed("server_error", f"stream exceeded {timeout_s:.0f}s ceiling"):
|
|
261
|
+
yield format_sse(out)
|
|
262
|
+
except (MidStreamError, NoProvidersAvailableError) as exc:
|
|
263
|
+
logger.warning("responses-stream-failed", extra={"error": str(exc)[:300]})
|
|
264
|
+
for out in translator.failed("server_error", str(exc)):
|
|
265
|
+
yield format_sse(out)
|
|
266
|
+
finally:
|
|
267
|
+
await _close()
|
|
268
|
+
|
|
269
|
+
return StreamingResponse(gen(), media_type="text/event-stream", headers={"Cache-Control": "no-cache"})
|
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
"""OpenAI Responses API ⇄ Anthropic Messages translation (v2.19.0).
|
|
2
|
+
|
|
3
|
+
The ``POST /v1/responses`` ingress exists for the OpenAI Codex CLI, which
|
|
4
|
+
since 2026-02 speaks only the Responses API. CodeRouter keeps Anthropic
|
|
5
|
+
Messages as its internal pivot, so a Responses request is translated to an
|
|
6
|
+
:class:`AnthropicRequest`, run through the fallback engine (tool-call
|
|
7
|
+
repair, guards, fallback all apply), and the engine's Anthropic events are
|
|
8
|
+
translated back into Responses SSE events.
|
|
9
|
+
|
|
10
|
+
The wire contract below was taken from openai/codex ``970111f``
|
|
11
|
+
(2026-10-23); see ``docs/start/quickstart.md`` Pattern B for the client
|
|
12
|
+
config. Key points:
|
|
13
|
+
|
|
14
|
+
- Codex sends no ``instructions`` field; the system prompt is the first
|
|
15
|
+
``input`` item, a ``developer`` message. Every ``developer`` / ``system``
|
|
16
|
+
message becomes Anthropic ``system`` text, in order.
|
|
17
|
+
- Tool kinds: ``function`` (answer: ``function_call`` with a JSON-string
|
|
18
|
+
``arguments``), ``custom`` (freeform grammar tools such as
|
|
19
|
+
``apply_patch``; answer: ``custom_tool_call`` with a raw ``input``
|
|
20
|
+
string — a ``function_call`` is rejected), and ``namespace`` (MCP; answer
|
|
21
|
+
must carry ``namespace`` plus the inner ``name``). Hosted tools
|
|
22
|
+
(``web_search``, …) are dropped: only OpenAI runs them.
|
|
23
|
+
- Codex acts on ``response.output_item.done``; a text delta needs an open
|
|
24
|
+
item; ``response.completed`` must carry ``response.id``; usage detail
|
|
25
|
+
objects must be complete or absent.
|
|
26
|
+
- In-stream failure is ``response.failed`` followed by end of stream, and
|
|
27
|
+
``context_length_exceeded`` there is the only context-overflow signal
|
|
28
|
+
Codex recognizes.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
import uuid
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
from coderouter.translation.anthropic import AnthropicRequest, AnthropicStreamEvent
|
|
41
|
+
|
|
42
|
+
#: Default ``max_tokens`` when the Responses request has no
|
|
43
|
+
#: ``max_output_tokens`` (Codex never sends one).
|
|
44
|
+
DEFAULT_MAX_OUTPUT_TOKENS = 16384
|
|
45
|
+
|
|
46
|
+
_NAME_SAFE = re.compile(r"[^a-zA-Z0-9_-]")
|
|
47
|
+
_HOSTED_TOOL_TYPES = frozenset(
|
|
48
|
+
{"web_search", "web_search_preview", "file_search", "image_generation", "code_interpreter",
|
|
49
|
+
"computer_use_preview", "local_shell", "mcp", "tool_search"}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_MAX_TOOL_NAME = 64
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _safe_name(raw: str) -> str:
|
|
57
|
+
name = _NAME_SAFE.sub("_", raw) or "tool"
|
|
58
|
+
if len(name) <= _MAX_TOOL_NAME:
|
|
59
|
+
return name
|
|
60
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:8]
|
|
61
|
+
return f"{name[: _MAX_TOOL_NAME - 9]}_{digest}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _new_id(prefix: str) -> str:
|
|
65
|
+
return f"{prefix}_{uuid.uuid4().hex[:24]}"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Tool mapping
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class ToolRef:
|
|
75
|
+
"""How an Anthropic tool name maps back to a Responses tool."""
|
|
76
|
+
|
|
77
|
+
kind: str # "function" | "custom"
|
|
78
|
+
name: str
|
|
79
|
+
namespace: str | None = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class ToolMap:
|
|
84
|
+
"""Reversible per-request mapping between flattened Anthropic tool
|
|
85
|
+
names and Responses tools (namespaces are flattened)."""
|
|
86
|
+
|
|
87
|
+
by_flat: dict[str, ToolRef] = field(default_factory=dict)
|
|
88
|
+
|
|
89
|
+
def flat_name(self, name: str, namespace: str | None) -> str:
|
|
90
|
+
"""Provider-safe name for ``(namespace, name)``; unique per request.
|
|
91
|
+
|
|
92
|
+
Capped at 64 chars (OpenAI-compatible servers reject longer function
|
|
93
|
+
names) with a short hash suffix so long ``mcp__server__tool`` names
|
|
94
|
+
stay distinct.
|
|
95
|
+
"""
|
|
96
|
+
base = _safe_name(f"{namespace}__{name}" if namespace else name)
|
|
97
|
+
flat, i = base, 2
|
|
98
|
+
while flat in self.by_flat and (
|
|
99
|
+
self.by_flat[flat].name,
|
|
100
|
+
self.by_flat[flat].namespace,
|
|
101
|
+
) != (name, namespace):
|
|
102
|
+
flat = f"{base[:56]}_{i}"
|
|
103
|
+
i += 1
|
|
104
|
+
return flat
|
|
105
|
+
|
|
106
|
+
def add(self, kind: str, name: str, namespace: str | None) -> str:
|
|
107
|
+
flat = self.flat_name(name, namespace)
|
|
108
|
+
self.by_flat[flat] = ToolRef(kind=kind, name=name, namespace=namespace)
|
|
109
|
+
return flat
|
|
110
|
+
|
|
111
|
+
def lookup(self, flat: str) -> ToolRef:
|
|
112
|
+
return self.by_flat.get(flat, ToolRef(kind="function", name=flat))
|
|
113
|
+
|
|
114
|
+
def flat_for(self, name: str, namespace: str | None) -> str:
|
|
115
|
+
for flat, ref in self.by_flat.items():
|
|
116
|
+
if ref.name == name and ref.namespace == namespace:
|
|
117
|
+
return flat
|
|
118
|
+
return _safe_name(f"{namespace}__{name}" if namespace else name)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _custom_tool_schema(tool: dict[str, Any]) -> dict[str, Any]:
|
|
122
|
+
fmt = tool.get("format") or {}
|
|
123
|
+
hint = ""
|
|
124
|
+
if isinstance(fmt, dict) and fmt.get("definition"):
|
|
125
|
+
hint = (
|
|
126
|
+
f" The text must follow this {fmt.get('syntax', 'grammar')} grammar:\n"
|
|
127
|
+
f"{fmt['definition']}"
|
|
128
|
+
)
|
|
129
|
+
return {
|
|
130
|
+
"type": "object",
|
|
131
|
+
"properties": {
|
|
132
|
+
"input": {
|
|
133
|
+
"type": "string",
|
|
134
|
+
"description": "The raw text input for this tool, passed verbatim." + hint,
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
"required": ["input"],
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _convert_tools(tools: list[Any] | None, tool_map: ToolMap) -> list[dict[str, Any]]:
|
|
142
|
+
out: list[dict[str, Any]] = []
|
|
143
|
+
for tool in tools or []:
|
|
144
|
+
if not isinstance(tool, dict):
|
|
145
|
+
continue
|
|
146
|
+
kind = tool.get("type")
|
|
147
|
+
if kind == "namespace":
|
|
148
|
+
ns = tool.get("name")
|
|
149
|
+
for inner in tool.get("tools") or []:
|
|
150
|
+
out.extend(_convert_one_tool(inner, tool_map, namespace=ns))
|
|
151
|
+
continue
|
|
152
|
+
out.extend(_convert_one_tool(tool, tool_map, namespace=None))
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _convert_one_tool(
|
|
157
|
+
tool: dict[str, Any], tool_map: ToolMap, *, namespace: str | None
|
|
158
|
+
) -> list[dict[str, Any]]:
|
|
159
|
+
kind = tool.get("type")
|
|
160
|
+
name = tool.get("name")
|
|
161
|
+
if not isinstance(name, str) or not name:
|
|
162
|
+
return []
|
|
163
|
+
if kind == "function":
|
|
164
|
+
flat = tool_map.add("function", name, namespace)
|
|
165
|
+
return [
|
|
166
|
+
{
|
|
167
|
+
"name": flat,
|
|
168
|
+
"description": tool.get("description") or "",
|
|
169
|
+
"input_schema": tool.get("parameters") or {"type": "object", "properties": {}},
|
|
170
|
+
}
|
|
171
|
+
]
|
|
172
|
+
if kind == "custom":
|
|
173
|
+
flat = tool_map.add("custom", name, namespace)
|
|
174
|
+
return [
|
|
175
|
+
{
|
|
176
|
+
"name": flat,
|
|
177
|
+
"description": tool.get("description") or "",
|
|
178
|
+
"input_schema": _custom_tool_schema(tool),
|
|
179
|
+
}
|
|
180
|
+
]
|
|
181
|
+
# Hosted / unknown tool types only run on OpenAI's side.
|
|
182
|
+
return []
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
# Input → Anthropic messages
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _text_of(content: Any) -> str:
|
|
191
|
+
if isinstance(content, str):
|
|
192
|
+
return content
|
|
193
|
+
parts: list[str] = []
|
|
194
|
+
for part in content or []:
|
|
195
|
+
if isinstance(part, dict) and isinstance(part.get("text"), str):
|
|
196
|
+
parts.append(part["text"])
|
|
197
|
+
return "\n".join(parts)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _image_block(part: dict[str, Any]) -> dict[str, Any] | None:
|
|
201
|
+
url = part.get("image_url")
|
|
202
|
+
if not isinstance(url, str) or not url:
|
|
203
|
+
return None
|
|
204
|
+
m = re.match(r"data:([^;]+);base64,(.*)", url, re.DOTALL)
|
|
205
|
+
if m:
|
|
206
|
+
return {"type": "image", "source": {"type": "base64", "media_type": m.group(1), "data": m.group(2)}}
|
|
207
|
+
return {"type": "image", "source": {"type": "url", "url": url}}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _user_blocks(content: Any) -> list[dict[str, Any]]:
|
|
211
|
+
if isinstance(content, str):
|
|
212
|
+
return [{"type": "text", "text": content}] if content else []
|
|
213
|
+
blocks: list[dict[str, Any]] = []
|
|
214
|
+
for part in content or []:
|
|
215
|
+
if not isinstance(part, dict):
|
|
216
|
+
continue
|
|
217
|
+
ptype = part.get("type")
|
|
218
|
+
if ptype in ("input_text", "output_text", "text") and part.get("text"):
|
|
219
|
+
blocks.append({"type": "text", "text": part["text"]})
|
|
220
|
+
elif ptype == "input_image":
|
|
221
|
+
img = _image_block(part)
|
|
222
|
+
if img is not None:
|
|
223
|
+
blocks.append(img)
|
|
224
|
+
return blocks
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _tool_output_content(output: Any) -> str | list[dict[str, Any]]:
|
|
228
|
+
"""``*_call_output.output`` → Anthropic ``tool_result`` content.
|
|
229
|
+
|
|
230
|
+
Strings pass through. Arrays keep their text parts as text blocks and
|
|
231
|
+
their images as image blocks (Codex's ``view_image`` and MCP image
|
|
232
|
+
results), instead of dumping base64 into the prompt as text. Backends
|
|
233
|
+
that cannot take images get a placeholder from the translation layer.
|
|
234
|
+
"""
|
|
235
|
+
if isinstance(output, str):
|
|
236
|
+
return output
|
|
237
|
+
if not isinstance(output, list):
|
|
238
|
+
return json.dumps(output, ensure_ascii=False)
|
|
239
|
+
blocks: list[dict[str, Any]] = []
|
|
240
|
+
for part in output:
|
|
241
|
+
if not isinstance(part, dict):
|
|
242
|
+
continue
|
|
243
|
+
ptype = part.get("type")
|
|
244
|
+
if ptype in ("input_text", "output_text", "text") and isinstance(part.get("text"), str):
|
|
245
|
+
blocks.append({"type": "text", "text": part["text"]})
|
|
246
|
+
elif ptype == "input_image":
|
|
247
|
+
img = _image_block(part)
|
|
248
|
+
if img is not None:
|
|
249
|
+
blocks.append(img)
|
|
250
|
+
return blocks or ""
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _append(messages: list[dict[str, Any]], role: str, blocks: list[dict[str, Any]]) -> None:
|
|
254
|
+
if not blocks:
|
|
255
|
+
return
|
|
256
|
+
if messages and messages[-1]["role"] == role:
|
|
257
|
+
messages[-1]["content"].extend(blocks)
|
|
258
|
+
else:
|
|
259
|
+
messages.append({"role": role, "content": list(blocks)})
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _repair_tool_pairing(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
263
|
+
"""Make tool_use / tool_result pairing valid for Anthropic.
|
|
264
|
+
|
|
265
|
+
- a tool_result whose tool_use is unknown is dropped;
|
|
266
|
+
- a tool_use with no tool_result in the next user turn gets a
|
|
267
|
+
synthetic ``"aborted"`` result (Codex does the same normalization,
|
|
268
|
+
but not necessarily before every request).
|
|
269
|
+
"""
|
|
270
|
+
seen_ids: set[str] = set()
|
|
271
|
+
out: list[dict[str, Any]] = []
|
|
272
|
+
for i, msg in enumerate(messages):
|
|
273
|
+
if msg["role"] == "assistant":
|
|
274
|
+
ids = [b["id"] for b in msg["content"] if b.get("type") == "tool_use"]
|
|
275
|
+
seen_ids.update(ids)
|
|
276
|
+
out.append(msg)
|
|
277
|
+
nxt = messages[i + 1] if i + 1 < len(messages) else None
|
|
278
|
+
answered = {
|
|
279
|
+
b.get("tool_use_id")
|
|
280
|
+
for b in (nxt["content"] if nxt and nxt["role"] == "user" else [])
|
|
281
|
+
if b.get("type") == "tool_result"
|
|
282
|
+
}
|
|
283
|
+
missing = [tid for tid in ids if tid not in answered]
|
|
284
|
+
if missing and nxt is not None:
|
|
285
|
+
synth = [
|
|
286
|
+
{"type": "tool_result", "tool_use_id": tid, "content": "aborted"} for tid in missing
|
|
287
|
+
]
|
|
288
|
+
if nxt["role"] == "user":
|
|
289
|
+
nxt["content"][:0] = synth
|
|
290
|
+
else:
|
|
291
|
+
out.append({"role": "user", "content": synth})
|
|
292
|
+
continue
|
|
293
|
+
msg = {
|
|
294
|
+
"role": msg["role"],
|
|
295
|
+
"content": [
|
|
296
|
+
b
|
|
297
|
+
for b in msg["content"]
|
|
298
|
+
if b.get("type") != "tool_result" or b.get("tool_use_id") in seen_ids
|
|
299
|
+
],
|
|
300
|
+
}
|
|
301
|
+
if msg["content"]:
|
|
302
|
+
# tool_results must come first in a user turn.
|
|
303
|
+
msg["content"].sort(key=lambda b: 0 if b.get("type") == "tool_result" else 1)
|
|
304
|
+
if out and out[-1]["role"] == "user":
|
|
305
|
+
out[-1]["content"].extend(msg["content"])
|
|
306
|
+
else:
|
|
307
|
+
out.append(msg)
|
|
308
|
+
return out
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def responses_to_anthropic(body: dict[str, Any]) -> tuple[AnthropicRequest, ToolMap]:
|
|
312
|
+
"""Translate a Responses request body into an :class:`AnthropicRequest`."""
|
|
313
|
+
tool_map = ToolMap()
|
|
314
|
+
tools = _convert_tools(body.get("tools"), tool_map)
|
|
315
|
+
|
|
316
|
+
system_parts: list[str] = []
|
|
317
|
+
instructions = body.get("instructions")
|
|
318
|
+
if isinstance(instructions, str) and instructions.strip():
|
|
319
|
+
system_parts.append(instructions)
|
|
320
|
+
|
|
321
|
+
messages: list[dict[str, Any]] = []
|
|
322
|
+
raw_input = body.get("input")
|
|
323
|
+
items: list[Any] = (
|
|
324
|
+
[{"type": "message", "role": "user", "content": raw_input}]
|
|
325
|
+
if isinstance(raw_input, str)
|
|
326
|
+
else list(raw_input or [])
|
|
327
|
+
)
|
|
328
|
+
for item in items:
|
|
329
|
+
if not isinstance(item, dict):
|
|
330
|
+
continue
|
|
331
|
+
itype = item.get("type", "message" if "role" in item else None)
|
|
332
|
+
if itype == "message":
|
|
333
|
+
role = item.get("role")
|
|
334
|
+
if role in ("developer", "system"):
|
|
335
|
+
text = _text_of(item.get("content"))
|
|
336
|
+
if text.strip():
|
|
337
|
+
system_parts.append(text)
|
|
338
|
+
elif role == "assistant":
|
|
339
|
+
text = _text_of(item.get("content"))
|
|
340
|
+
if text:
|
|
341
|
+
_append(messages, "assistant", [{"type": "text", "text": text}])
|
|
342
|
+
else:
|
|
343
|
+
_append(messages, "user", _user_blocks(item.get("content")))
|
|
344
|
+
elif itype in ("function_call", "custom_tool_call"):
|
|
345
|
+
call_id = item.get("call_id")
|
|
346
|
+
name = item.get("name")
|
|
347
|
+
if not call_id or not name:
|
|
348
|
+
continue
|
|
349
|
+
ns = item.get("namespace")
|
|
350
|
+
if itype == "function_call":
|
|
351
|
+
try:
|
|
352
|
+
args = json.loads(item.get("arguments") or "{}")
|
|
353
|
+
except (TypeError, ValueError):
|
|
354
|
+
args = {"_raw_arguments": item.get("arguments")}
|
|
355
|
+
if not isinstance(args, dict):
|
|
356
|
+
args = {"value": args}
|
|
357
|
+
else:
|
|
358
|
+
args = {"input": item.get("input") or ""}
|
|
359
|
+
_append(
|
|
360
|
+
messages,
|
|
361
|
+
"assistant",
|
|
362
|
+
[{"type": "tool_use", "id": call_id, "name": tool_map.flat_for(name, ns), "input": args}],
|
|
363
|
+
)
|
|
364
|
+
elif itype in ("function_call_output", "custom_tool_call_output"):
|
|
365
|
+
call_id = item.get("call_id")
|
|
366
|
+
if not call_id:
|
|
367
|
+
continue
|
|
368
|
+
_append(
|
|
369
|
+
messages,
|
|
370
|
+
"user",
|
|
371
|
+
[{"type": "tool_result", "tool_use_id": call_id, "content": _tool_output_content(item.get("output"))}],
|
|
372
|
+
)
|
|
373
|
+
# reasoning / hosted-tool / compaction items: not representable for a
|
|
374
|
+
# non-OpenAI backend — dropped.
|
|
375
|
+
|
|
376
|
+
messages = _repair_tool_pairing(messages)
|
|
377
|
+
if not messages or messages[0]["role"] != "user":
|
|
378
|
+
messages.insert(0, {"role": "user", "content": [{"type": "text", "text": "(continue)"}]})
|
|
379
|
+
|
|
380
|
+
payload: dict[str, Any] = {
|
|
381
|
+
"model": body.get("model"),
|
|
382
|
+
"max_tokens": int(body.get("max_output_tokens") or DEFAULT_MAX_OUTPUT_TOKENS),
|
|
383
|
+
"messages": messages,
|
|
384
|
+
"stream": bool(body.get("stream", False)),
|
|
385
|
+
}
|
|
386
|
+
if system_parts:
|
|
387
|
+
payload["system"] = "\n\n".join(system_parts)
|
|
388
|
+
if tools:
|
|
389
|
+
payload["tools"] = tools
|
|
390
|
+
for key in ("temperature", "top_p"):
|
|
391
|
+
if body.get(key) is not None:
|
|
392
|
+
payload[key] = body[key]
|
|
393
|
+
if isinstance(body.get("profile"), str):
|
|
394
|
+
payload["profile"] = body["profile"]
|
|
395
|
+
return AnthropicRequest.model_validate(payload), tool_map
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# ---------------------------------------------------------------------------
|
|
399
|
+
# Anthropic output → Responses items
|
|
400
|
+
# ---------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _tool_item(block_id: str, flat_name: str, args: Any, tool_map: ToolMap) -> dict[str, Any]:
|
|
404
|
+
ref = tool_map.lookup(flat_name)
|
|
405
|
+
if ref.kind == "custom":
|
|
406
|
+
raw: str | None = None
|
|
407
|
+
if isinstance(args, dict):
|
|
408
|
+
if isinstance(args.get("input"), str):
|
|
409
|
+
raw = args["input"]
|
|
410
|
+
else:
|
|
411
|
+
# A repaired / local call may name the field differently
|
|
412
|
+
# ({"patch": "..."}); a lone string value is the input.
|
|
413
|
+
strings = [v for v in args.values() if isinstance(v, str)]
|
|
414
|
+
if len(args) == 1 and len(strings) == 1:
|
|
415
|
+
raw = strings[0]
|
|
416
|
+
elif isinstance(args, str):
|
|
417
|
+
raw = args
|
|
418
|
+
item: dict[str, Any] = {
|
|
419
|
+
"type": "custom_tool_call",
|
|
420
|
+
"id": _new_id("ctc"),
|
|
421
|
+
"call_id": block_id,
|
|
422
|
+
"name": ref.name,
|
|
423
|
+
"input": raw if raw is not None else json.dumps(args, ensure_ascii=False),
|
|
424
|
+
"status": "completed",
|
|
425
|
+
}
|
|
426
|
+
else:
|
|
427
|
+
item = {
|
|
428
|
+
"type": "function_call",
|
|
429
|
+
"id": _new_id("fc"),
|
|
430
|
+
"call_id": block_id,
|
|
431
|
+
"name": ref.name,
|
|
432
|
+
"arguments": json.dumps(args if args is not None else {}, ensure_ascii=False),
|
|
433
|
+
"status": "completed",
|
|
434
|
+
}
|
|
435
|
+
if ref.namespace:
|
|
436
|
+
item["namespace"] = ref.namespace
|
|
437
|
+
return item
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _usage(
|
|
441
|
+
input_tokens: int, output_tokens: int, cached: int, cache_write: int = 0
|
|
442
|
+
) -> dict[str, Any]:
|
|
443
|
+
"""OpenAI-style usage from Anthropic counts.
|
|
444
|
+
|
|
445
|
+
Anthropic's ``input_tokens`` excludes cache reads and writes; OpenAI's
|
|
446
|
+
includes them, and Codex uses ``total_tokens`` as context-window usage
|
|
447
|
+
to decide when to compact. So the cache counts are added back in.
|
|
448
|
+
"""
|
|
449
|
+
input_tokens = input_tokens + cached + cache_write
|
|
450
|
+
details: dict[str, Any] = {"cached_tokens": cached}
|
|
451
|
+
if cache_write:
|
|
452
|
+
details["cache_write_tokens"] = cache_write
|
|
453
|
+
return {
|
|
454
|
+
"input_tokens": input_tokens,
|
|
455
|
+
"input_tokens_details": details,
|
|
456
|
+
"output_tokens": output_tokens,
|
|
457
|
+
"output_tokens_details": {"reasoning_tokens": 0},
|
|
458
|
+
"total_tokens": input_tokens + output_tokens,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _response_obj(
|
|
463
|
+
resp_id: str, model: str, status: str, output: list[dict[str, Any]], usage: dict[str, Any] | None
|
|
464
|
+
) -> dict[str, Any]:
|
|
465
|
+
obj: dict[str, Any] = {
|
|
466
|
+
"id": resp_id,
|
|
467
|
+
"object": "response",
|
|
468
|
+
"created_at": 0,
|
|
469
|
+
"status": status,
|
|
470
|
+
"model": model,
|
|
471
|
+
"output": output,
|
|
472
|
+
}
|
|
473
|
+
if usage is not None:
|
|
474
|
+
obj["usage"] = usage
|
|
475
|
+
return obj
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def anthropic_response_to_responses(
|
|
479
|
+
resp: dict[str, Any], tool_map: ToolMap, *, model: str
|
|
480
|
+
) -> dict[str, Any]:
|
|
481
|
+
"""Non-streaming: an Anthropic response dict → a Responses ``response``."""
|
|
482
|
+
output: list[dict[str, Any]] = []
|
|
483
|
+
for block in resp.get("content") or []:
|
|
484
|
+
btype = block.get("type")
|
|
485
|
+
if btype == "text" and block.get("text"):
|
|
486
|
+
output.append(
|
|
487
|
+
{
|
|
488
|
+
"type": "message",
|
|
489
|
+
"id": _new_id("msg"),
|
|
490
|
+
"role": "assistant",
|
|
491
|
+
"status": "completed",
|
|
492
|
+
"content": [{"type": "output_text", "text": block["text"], "annotations": []}],
|
|
493
|
+
}
|
|
494
|
+
)
|
|
495
|
+
elif btype == "tool_use":
|
|
496
|
+
output.append(_tool_item(block.get("id") or _new_id("call"), block.get("name", ""), block.get("input"), tool_map))
|
|
497
|
+
usage = resp.get("usage") or {}
|
|
498
|
+
status = "incomplete" if resp.get("stop_reason") == "max_tokens" else "completed"
|
|
499
|
+
obj = _response_obj(
|
|
500
|
+
_new_id("resp"),
|
|
501
|
+
model,
|
|
502
|
+
status,
|
|
503
|
+
output,
|
|
504
|
+
_usage(
|
|
505
|
+
int(usage.get("input_tokens") or 0),
|
|
506
|
+
int(usage.get("output_tokens") or 0),
|
|
507
|
+
int(usage.get("cache_read_input_tokens") or 0),
|
|
508
|
+
int(usage.get("cache_creation_input_tokens") or 0),
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
if status == "incomplete":
|
|
512
|
+
obj["incomplete_details"] = {"reason": "max_output_tokens"}
|
|
513
|
+
return obj
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
class ResponsesStreamTranslator:
|
|
517
|
+
"""Anthropic stream events → Responses SSE events (stateful, one request)."""
|
|
518
|
+
|
|
519
|
+
def __init__(self, tool_map: ToolMap, *, model: str) -> None:
|
|
520
|
+
self.tool_map = tool_map
|
|
521
|
+
self.model = model
|
|
522
|
+
self.resp_id = _new_id("resp")
|
|
523
|
+
self.output: list[dict[str, Any]] = []
|
|
524
|
+
self._blocks: dict[int, dict[str, Any]] = {}
|
|
525
|
+
self._seq = 0
|
|
526
|
+
self._input_tokens = 0
|
|
527
|
+
self._output_tokens = 0
|
|
528
|
+
self._cached = 0
|
|
529
|
+
self._cache_write = 0
|
|
530
|
+
self._stop_reason: str | None = None
|
|
531
|
+
self._stopped = False
|
|
532
|
+
self.started = False
|
|
533
|
+
|
|
534
|
+
def _ev(self, etype: str, **data: Any) -> dict[str, Any]:
|
|
535
|
+
self._seq += 1
|
|
536
|
+
return {"type": etype, "sequence_number": self._seq, **data}
|
|
537
|
+
|
|
538
|
+
def start(self) -> list[dict[str, Any]]:
|
|
539
|
+
self.started = True
|
|
540
|
+
return [
|
|
541
|
+
self._ev(
|
|
542
|
+
"response.created",
|
|
543
|
+
response=_response_obj(self.resp_id, self.model, "in_progress", [], None),
|
|
544
|
+
)
|
|
545
|
+
]
|
|
546
|
+
|
|
547
|
+
def feed(self, ev: AnthropicStreamEvent) -> list[dict[str, Any]]:
|
|
548
|
+
data = ev.data or {}
|
|
549
|
+
etype = ev.type
|
|
550
|
+
out: list[dict[str, Any]] = []
|
|
551
|
+
if etype == "message_start":
|
|
552
|
+
usage = (data.get("message") or {}).get("usage") or {}
|
|
553
|
+
self._input_tokens = int(usage.get("input_tokens") or 0)
|
|
554
|
+
self._cached = int(usage.get("cache_read_input_tokens") or 0)
|
|
555
|
+
self._cache_write = int(usage.get("cache_creation_input_tokens") or 0)
|
|
556
|
+
elif etype == "content_block_start":
|
|
557
|
+
idx = int(data.get("index", 0))
|
|
558
|
+
cb = data.get("content_block") or {}
|
|
559
|
+
ctype = cb.get("type")
|
|
560
|
+
if ctype == "text":
|
|
561
|
+
item = {
|
|
562
|
+
"type": "message",
|
|
563
|
+
"id": _new_id("msg"),
|
|
564
|
+
"role": "assistant",
|
|
565
|
+
"status": "in_progress",
|
|
566
|
+
"content": [],
|
|
567
|
+
}
|
|
568
|
+
self._blocks[idx] = {"kind": "text", "item": item, "text": "", "out_index": len(self.output)}
|
|
569
|
+
self.output.append(item)
|
|
570
|
+
out.append(self._ev("response.output_item.added", output_index=self._blocks[idx]["out_index"], item=item))
|
|
571
|
+
out.append(
|
|
572
|
+
self._ev(
|
|
573
|
+
"response.content_part.added",
|
|
574
|
+
item_id=item["id"],
|
|
575
|
+
output_index=self._blocks[idx]["out_index"],
|
|
576
|
+
content_index=0,
|
|
577
|
+
part={"type": "output_text", "text": "", "annotations": []},
|
|
578
|
+
)
|
|
579
|
+
)
|
|
580
|
+
if cb.get("text"):
|
|
581
|
+
out.extend(self._text_delta(idx, cb["text"]))
|
|
582
|
+
elif ctype == "tool_use":
|
|
583
|
+
self._blocks[idx] = {
|
|
584
|
+
"kind": "tool",
|
|
585
|
+
"id": cb.get("id") or _new_id("call"),
|
|
586
|
+
"name": cb.get("name", ""),
|
|
587
|
+
"json": "",
|
|
588
|
+
"input": cb.get("input") if cb.get("input") else None,
|
|
589
|
+
}
|
|
590
|
+
else:
|
|
591
|
+
self._blocks[idx] = {"kind": "skip"}
|
|
592
|
+
elif etype == "content_block_delta":
|
|
593
|
+
idx = int(data.get("index", 0))
|
|
594
|
+
delta = data.get("delta") or {}
|
|
595
|
+
block = self._blocks.get(idx)
|
|
596
|
+
if block is None:
|
|
597
|
+
return out
|
|
598
|
+
if block["kind"] == "text" and delta.get("type") == "text_delta":
|
|
599
|
+
out.extend(self._text_delta(idx, delta.get("text", "")))
|
|
600
|
+
elif block["kind"] == "tool" and delta.get("type") == "input_json_delta":
|
|
601
|
+
block["json"] += delta.get("partial_json", "")
|
|
602
|
+
elif etype == "content_block_stop":
|
|
603
|
+
idx = int(data.get("index", 0))
|
|
604
|
+
block = self._blocks.pop(idx, None)
|
|
605
|
+
if block is None:
|
|
606
|
+
return out
|
|
607
|
+
if block["kind"] == "text":
|
|
608
|
+
item = block["item"]
|
|
609
|
+
item["status"] = "completed"
|
|
610
|
+
item["content"] = [{"type": "output_text", "text": block["text"], "annotations": []}]
|
|
611
|
+
out.append(
|
|
612
|
+
self._ev(
|
|
613
|
+
"response.output_text.done",
|
|
614
|
+
item_id=item["id"],
|
|
615
|
+
output_index=block["out_index"],
|
|
616
|
+
content_index=0,
|
|
617
|
+
text=block["text"],
|
|
618
|
+
)
|
|
619
|
+
)
|
|
620
|
+
out.append(self._ev("response.output_item.done", output_index=block["out_index"], item=item))
|
|
621
|
+
elif block["kind"] == "tool":
|
|
622
|
+
if block["json"]:
|
|
623
|
+
try:
|
|
624
|
+
args = json.loads(block["json"])
|
|
625
|
+
except ValueError:
|
|
626
|
+
args = {"_raw_arguments": block["json"]}
|
|
627
|
+
else:
|
|
628
|
+
args = block["input"] or {}
|
|
629
|
+
item = _tool_item(block["id"], block["name"], args, self.tool_map)
|
|
630
|
+
out_index = len(self.output)
|
|
631
|
+
self.output.append(item)
|
|
632
|
+
out.append(self._ev("response.output_item.added", output_index=out_index, item=item))
|
|
633
|
+
out.append(self._ev("response.output_item.done", output_index=out_index, item=item))
|
|
634
|
+
elif etype == "message_delta":
|
|
635
|
+
usage = data.get("usage") or {}
|
|
636
|
+
if usage.get("output_tokens") is not None:
|
|
637
|
+
self._output_tokens = int(usage["output_tokens"])
|
|
638
|
+
if usage.get("input_tokens"):
|
|
639
|
+
self._input_tokens = int(usage["input_tokens"])
|
|
640
|
+
stop = (data.get("delta") or {}).get("stop_reason")
|
|
641
|
+
if stop:
|
|
642
|
+
self._stop_reason = stop
|
|
643
|
+
elif etype == "message_stop":
|
|
644
|
+
self._stopped = True
|
|
645
|
+
return out
|
|
646
|
+
|
|
647
|
+
def _text_delta(self, idx: int, text: str) -> list[dict[str, Any]]:
|
|
648
|
+
if not text:
|
|
649
|
+
return []
|
|
650
|
+
block = self._blocks[idx]
|
|
651
|
+
block["text"] += text
|
|
652
|
+
return [
|
|
653
|
+
self._ev(
|
|
654
|
+
"response.output_text.delta",
|
|
655
|
+
item_id=block["item"]["id"],
|
|
656
|
+
output_index=block["out_index"],
|
|
657
|
+
content_index=0,
|
|
658
|
+
delta=text,
|
|
659
|
+
)
|
|
660
|
+
]
|
|
661
|
+
|
|
662
|
+
def finish(self) -> list[dict[str, Any]]:
|
|
663
|
+
# A stream that ended without ``message_stop``, or with a block
|
|
664
|
+
# still open, was cut off. Reporting it as completed would make
|
|
665
|
+
# Codex drop the partial item and end the turn as a success; a
|
|
666
|
+
# ``response.failed`` (retryable ``server_error``) makes it retry.
|
|
667
|
+
if not self._stopped or self._blocks:
|
|
668
|
+
return self.failed(
|
|
669
|
+
"server_error", "upstream stream ended before the message was complete"
|
|
670
|
+
)
|
|
671
|
+
usage = _usage(self._input_tokens, self._output_tokens, self._cached, self._cache_write)
|
|
672
|
+
if self._stop_reason == "max_tokens":
|
|
673
|
+
obj = _response_obj(self.resp_id, self.model, "incomplete", self.output, usage)
|
|
674
|
+
obj["incomplete_details"] = {"reason": "max_output_tokens"}
|
|
675
|
+
return [self._ev("response.incomplete", response=obj)]
|
|
676
|
+
obj = _response_obj(self.resp_id, self.model, "completed", self.output, usage)
|
|
677
|
+
return [self._ev("response.completed", response=obj)]
|
|
678
|
+
|
|
679
|
+
def failed(self, code: str, message: str) -> list[dict[str, Any]]:
|
|
680
|
+
obj = _response_obj(self.resp_id, self.model, "failed", self.output, None)
|
|
681
|
+
obj["error"] = {"code": code, "message": message}
|
|
682
|
+
return [self._ev("response.failed", response=obj)]
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def format_sse(event: dict[str, Any]) -> str:
|
|
686
|
+
return f"event: {event['type']}\ndata: {json.dumps(event, ensure_ascii=False)}\n\n"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: coderouter-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.19.0
|
|
4
4
|
Summary: Local-first, free-first, fallback-built-in LLM router. Claude Code / OpenAI compatible.
|
|
5
5
|
Project-URL: Homepage, https://github.com/zephel01/CodeRouter
|
|
6
6
|
Project-URL: Repository, https://github.com/zephel01/CodeRouter
|
|
@@ -66,7 +66,7 @@ Description-Content-Type: text/markdown
|
|
|
66
66
|
## 何ができるか — 30 秒で
|
|
67
67
|
|
|
68
68
|
```
|
|
69
|
-
あなたのエージェント (Claude Code /
|
|
69
|
+
あなたのエージェント (Claude Code / codex / agy)
|
|
70
70
|
│
|
|
71
71
|
▼
|
|
72
72
|
┌─ CodeRouter ─┐
|
|
@@ -76,8 +76,6 @@ Description-Content-Type: text/markdown
|
|
|
76
76
|
└──────────────┘
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
-
※ 現行の codex CLI は Responses API(`/v1/responses`)専用になったため、CodeRouter 経由ではまだ使えません(対応予定)。直結の代替手順 → [quickstart Pattern B](./docs/start/quickstart.md#pattern-b-codex-cli-で使う)
|
|
80
|
-
|
|
81
79
|
**やってくれること:**
|
|
82
80
|
|
|
83
81
|
- ローカルモデルが壊した tool calling を Claude Code に届く前に修復する
|
|
@@ -147,7 +145,7 @@ ANTHROPIC_BASE_URL=http://localhost:8088 ANTHROPIC_AUTH_TOKEN=dummy claude
|
|
|
147
145
|
| Claude Code + ローカル Ollama で tool calling が壊れる | **必須** — tool 修復 (+ 必要なら wire 変換) |
|
|
148
146
|
| Claude Code + ローカルで長時間回すと止まる | **必須級** — 6 系統ガード + self-healing |
|
|
149
147
|
| Ollama v0.14+ / LM Studio にネイティブ直結で動いてる | **便利** — 直結に無い fallback / ガード / 診断を追加 (passthrough で翻訳ゼロ) |
|
|
150
|
-
| codex / agy + Ollama 直繋ぎで動いてる | オプション — フォールバックが欲しいなら(codex は `/v1/responses`
|
|
148
|
+
| codex / agy + Ollama 直繋ぎで動いてる | オプション — フォールバックが欲しいなら(codex は v2.19.0 の `/v1/responses` 経由) |
|
|
151
149
|
| Claude API を直接叩いてて問題ない | 不要 |
|
|
152
150
|
|
|
153
151
|
詳細は → [要否判定ガイド](./docs/start/when-do-i-need-coderouter.md)
|
|
@@ -49,11 +49,12 @@ coderouter/guards/tool_loop.py,sha256=EzeMcmU7BLeTW2jsRVevU81l5rhWcn1oUr7EpzgXjV
|
|
|
49
49
|
coderouter/ingress/__init__.py,sha256=WQsCH2CGJCAhy0mS6GSEdeYZRkkQu2OHDsP4CJWTLug,155
|
|
50
50
|
coderouter/ingress/anthropic_errors.py,sha256=q8F4LmH57f8Jh2hoV4Mmnvf9sZH32uiXhQXPRBZPjCE,8122
|
|
51
51
|
coderouter/ingress/anthropic_routes.py,sha256=2ADG_JgvmZteTm4rJfjdM5FU4IA9Wdtd9gZXm2-vI8Y,40561
|
|
52
|
-
coderouter/ingress/app.py,sha256=
|
|
52
|
+
coderouter/ingress/app.py,sha256=RVtth9FBmnqlB_siTpP-nrxBectMVKQoQSUhVCMk9uE,22639
|
|
53
53
|
coderouter/ingress/dashboard_routes.py,sha256=nPW5_pJe27-hzqCs4sqVVn0BrgXB_7Wcd4qZ6cirvWo,26736
|
|
54
54
|
coderouter/ingress/launcher_routes.py,sha256=uP_Otp9JE0fydUt1YP-wYcnS8Kj5RPLNIFf88YpfkPM,141535
|
|
55
55
|
coderouter/ingress/metrics_routes.py,sha256=4s_f9CY8nVIdQBfXxyUMlN7r5_cXHOaAu7PvNbUuoxg,4609
|
|
56
56
|
coderouter/ingress/openai_routes.py,sha256=Nc_Vh8rjLtc88oNbXfU9fwm-XZs0tTHVg-D5GEAuh_I,14484
|
|
57
|
+
coderouter/ingress/responses_routes.py,sha256=Fm2CvFlu2QpsvUQ1DjrWtvdFF_aHGcdN0au50sIrwTw,10490
|
|
57
58
|
coderouter/ingress/tool_search.py,sha256=JYnJ8SUqPmBfZmsl41TdBQNnux0mwbidKdsr5qn15Hk,3753
|
|
58
59
|
coderouter/ingress/ui_auth.py,sha256=uCWB5IaMQRNFYdY39FQ5DDVhsoRQJoqimZNSAu_QK_Q,3742
|
|
59
60
|
coderouter/metrics/__init__.py,sha256=7Es351DPS7yLM0yVF_F0eesmiD83n7Zzhie44chht38,1465
|
|
@@ -79,10 +80,11 @@ coderouter/state/suggest_rules.py,sha256=FvdhEvao5NvdKp9zs8AkcoFKHY4yqqXY2HekvSj
|
|
|
79
80
|
coderouter/translation/__init__.py,sha256=PYXN7XVEwpG1uC8RLy6fvnGbzEZhhrEuUapH8IYOtG8,1788
|
|
80
81
|
coderouter/translation/anthropic.py,sha256=49OMMIKBRZqsWhl0VhlMck8R-W34vxaTgpIlKI0kvVg,13655
|
|
81
82
|
coderouter/translation/convert.py,sha256=9lkfk8ByfbgiVh2WP6Hwh8UorOtrTxZBknvu_dsHnnI,53261
|
|
83
|
+
coderouter/translation/responses.py,sha256=dFdwu6DC95TO9PhTiid-XJ5S6bFReDI_O6lHYjT___g,26944
|
|
82
84
|
coderouter/translation/tool_repair.py,sha256=1p8rmdyyscGX2g0DDOYitMnLv-nKfR1SLuLaRB4z7GM,64244
|
|
83
85
|
coderouter/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
84
|
-
coderouter_cli-2.
|
|
85
|
-
coderouter_cli-2.
|
|
86
|
-
coderouter_cli-2.
|
|
87
|
-
coderouter_cli-2.
|
|
88
|
-
coderouter_cli-2.
|
|
86
|
+
coderouter_cli-2.19.0.dist-info/METADATA,sha256=zrqcHuOfiuqrSrI82GMaAeWm1aRJtxvHBVXnpbTwhWA,17965
|
|
87
|
+
coderouter_cli-2.19.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
88
|
+
coderouter_cli-2.19.0.dist-info/entry_points.txt,sha256=-dnLfD1YZ2WjH2zSdNCvlO65wYltM9bsHt9Fhg3yGss,51
|
|
89
|
+
coderouter_cli-2.19.0.dist-info/licenses/LICENSE,sha256=wkEzoR86jFw33jvfOHjULqmkGEfxTFMgMaJnpR8mPRw,1065
|
|
90
|
+
coderouter_cli-2.19.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|