mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 19:45:34 +08:00
feat(codex): support native Anthropic Messages protocol as upstream (#5071)
* feat(codex): support native Anthropic Messages protocol as upstream Allow gateways that only expose the native Anthropic Messages protocol (/v1/messages) to be used by Codex: the local proxy performs bidirectional request/response/streaming conversion between Responses and Anthropic. Backend: - Add two conversion modules: transform_codex_anthropic / streaming_codex_anthropic - codex.rs: add routing detection and auth: ANTHROPIC_AUTH_TOKEN→Bearer (default), ANTHROPIC_API_KEY→x-api-key, mutually exclusive - handlers.rs: add handle_codex_anthropic_to_responses_transform - forwarder.rs: support optional Claude Code client fingerprint impersonation (User-Agent / anthropic-beta / x-app / system prompt first-line injection) and /responses→/v1/messages rewriting - codex_config: the anthropic format reuses the NativeResponses profile to strip custom tools - ProviderMeta: add impersonateClaudeCode Frontend: - CodexApiFormat: add "anthropic"; the form adds auth field selection and an impersonation toggle - Add en/ja/zh/zh-TW copy Robustness: - Downgrade when tool history / forced tool_choice conflicts with extended thinking, avoiding upstream 400s - Emit cache_creation_input_tokens in usage and use saturating_add to guard against overflow - Append a unique suffix to non-streaming/streaming output-item ids to avoid multi-segment text/thinking overwriting each other * fix(codex): harden Anthropic bridge against empty text blocks and truncated streams - Drop empty/whitespace-only text content blocks when rebuilding Anthropic messages from Responses history; Anthropic 400s on empty text blocks (e.g. an empty assistant text emitted alongside a tool_use), which broke follow-up and tool-result requests. Also drop messages left without content. - Do not report a truncated Anthropic stream as completed: when the SSE connection ends before message_stop with no stop_reason, emit an incomplete response if partial output exists, or a failed (stream_truncated) response otherwise, mirroring the chat converter's EOF handling. * fix(codex): resolve Anthropic-bridge review findings Blocking: 1. Defer stripping the [1m] long-context marker until after catalog matching and model write-back, re-stripping it on the final Codex→Anthropic body and setting a flag to emit the context-1m-2025-08-07 beta header, so the marker is no longer lost or overridden by the default model. 2. Gate Anthropic thinking on the trailing turn only (via trailing_turn_allows_thinking) instead of scanning full history, so a Codex session that resends history each round no longer permanently loses thinking after the first tool call. 3. Inject 5m ephemeral cache_control on the Codex→Anthropic body by reusing cache_injector (handling the system string→array conversion), so system/tools/history are cached instead of re-sent at full price every round. 4. Add a shared base_url_is_full_endpoint helper (normalizing whitespace/query/fragment/trailing slash) used by both the Anthropic and Chat paths, so a base URL already ending in /v1/messages is treated as a full endpoint instead of double-appending to /v1/messages/v1/messages. 5. Align the catalog tool-profile predicate with the routing predicate so resolve_codex_catalog_tool_profile returns the Anthropic profile whenever the request converts to Anthropic, preventing freeform tools like apply_patch from being silently filtered by a ProxyChat catalog. 6. Explicitly disable native web_search for the Anthropic profile (including the no-catalog branch) via set_codex_native_web_search_field, so Codex no longer treats it as available while the transform silently drops it. 7. Only forward tool_choice when tools survive filtering, dropping it otherwise, to avoid a non-retryable 400 from upstream when tool_choice is sent with no tools. 8. Lower the fallback default to max_tokens=8192 (only when max_output_tokens is omitted) and clamp the thinking budget to max_tokens/2, disabling thinking below the 1024 floor, to avoid hard 400s on low-output-ceiling models/gateways. Minor: 9. Centralize the Codex/OpenAI fingerprint-header denylist in is_codex_client_fingerprint_header so impersonating Claude Code uniformly drops originator/session_id/conversation_id/chatgpt-account-id/x-client-request-id/openai-* and the x-stainless-*/x-codex-* prefixes. 10. Retain content_block_start.input as start_input and fall back to it at block close when no input_json_delta arrived, so a gateway that carries the full tool input on the start event no longer yields empty tool arguments. 11. Extract a shared codex_responses_sse module as the single Responses SSE envelope builder that both the chat and anthropic streaming emitters delegate to, with byte-for-byte-unchanged wire output, so future event-format fixes touch one place instead of two. * fix(codex): add per-provider max_output_tokens override for Codex→Anthropic path Codex does not forward model_max_output_tokens in the request body, causing the proxy to fall back to a conservative 8192 default. This truncates long or thinking-heavy responses (stop_reason=max_tokens). - Add maxOutputTokens field to ProviderMeta (Rust + TypeScript) - Inject the value into the Anthropic request body before transform, taking precedence over request-supplied and default values - Add numeric input in Codex form fields (Anthropic format only) - Add i18n entries for label, placeholder, and hint (en/zh/zh-TW/ja) - Include roundtrip and omission unit tests for the new field * fix(codex): harden Anthropic protocol bridge * fix(codex): address Anthropic bridge review * fix(codex): preserve flattened Anthropic inputs * fix(codex): harden Anthropic recovery paths * fix(ci): satisfy Rust clippy --------- Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
@@ -60,6 +60,7 @@ import {
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||
import {
|
||||
codexApiFormatFromWireApi,
|
||||
extractCodexWireApi,
|
||||
setCodexWireApi,
|
||||
setCodexModelName as setCodexModelNameInConfig,
|
||||
@@ -131,25 +132,6 @@ type PresetEntry = {
|
||||
| HermesProviderPreset;
|
||||
};
|
||||
|
||||
const codexApiFormatFromWireApi = (
|
||||
wireApi: string | undefined,
|
||||
): CodexApiFormat | undefined => {
|
||||
switch (wireApi?.trim().toLowerCase()) {
|
||||
case "chat":
|
||||
case "chat_completions":
|
||||
case "chat-completions":
|
||||
case "openai_chat":
|
||||
case "openai-chat":
|
||||
return "openai_chat";
|
||||
case "responses":
|
||||
case "openai_responses":
|
||||
case "openai-responses":
|
||||
return "openai_responses";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const normalizeCodexCatalogModelsForSave = (
|
||||
models: CodexCatalogModel[],
|
||||
): CodexCatalogModel[] => {
|
||||
@@ -586,19 +568,43 @@ function ProviderFormFull({
|
||||
const initialCodexApiFormat: CodexApiFormat =
|
||||
initialData?.meta?.apiFormat === "openai_chat"
|
||||
? "openai_chat"
|
||||
: initialData?.meta?.apiFormat === "openai_responses"
|
||||
? "openai_responses"
|
||||
: (codexApiFormatFromWireApi(
|
||||
extractCodexWireApi(
|
||||
typeof initialData?.settingsConfig?.config === "string"
|
||||
? initialData.settingsConfig.config
|
||||
: "",
|
||||
),
|
||||
) ?? "openai_responses");
|
||||
: initialData?.meta?.apiFormat === "anthropic"
|
||||
? "anthropic"
|
||||
: initialData?.meta?.apiFormat === "openai_responses"
|
||||
? "openai_responses"
|
||||
: (codexApiFormatFromWireApi(
|
||||
extractCodexWireApi(
|
||||
typeof initialData?.settingsConfig?.config === "string"
|
||||
? initialData.settingsConfig.config
|
||||
: "",
|
||||
),
|
||||
) ?? "openai_responses");
|
||||
|
||||
const [localCodexApiFormat, setLocalCodexApiFormat] =
|
||||
useState<CodexApiFormat>(initialCodexApiFormat);
|
||||
|
||||
// Auth-field choice for the Anthropic Messages upstream (defaults to the Bearer form)
|
||||
const initialCodexAnthropicAuthField: ClaudeApiKeyField =
|
||||
initialData?.meta?.apiKeyField === "ANTHROPIC_API_KEY"
|
||||
? "ANTHROPIC_API_KEY"
|
||||
: "ANTHROPIC_AUTH_TOKEN";
|
||||
const [localCodexAnthropicAuthField, setLocalCodexAnthropicAuthField] =
|
||||
useState<ClaudeApiKeyField>(initialCodexAnthropicAuthField);
|
||||
|
||||
// Emulate the Claude Code client: off by default, enabled only when the user explicitly turns it on (true)
|
||||
const [localCodexImpersonateClaudeCode, setLocalCodexImpersonateClaudeCode] =
|
||||
useState<boolean>(initialData?.meta?.impersonateClaudeCode === true);
|
||||
|
||||
// Codex → Anthropic output ceiling override (empty string = use the 8192 default).
|
||||
// Kept as a string so the numeric input can be cleared; parsed on save.
|
||||
const [localCodexMaxOutputTokens, setLocalCodexMaxOutputTokens] =
|
||||
useState<string>(
|
||||
typeof initialData?.meta?.maxOutputTokens === "number" &&
|
||||
initialData.meta.maxOutputTokens > 0
|
||||
? String(initialData.meta.maxOutputTokens)
|
||||
: "",
|
||||
);
|
||||
|
||||
const { configError: codexConfigError, debouncedValidate } =
|
||||
useCodexTomlValidation();
|
||||
|
||||
@@ -1502,6 +1508,28 @@ function ProviderFormFull({
|
||||
category !== "official" &&
|
||||
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
? localApiKeyField
|
||||
: appId === "codex" &&
|
||||
category !== "official" &&
|
||||
localCodexApiFormat === "anthropic" &&
|
||||
localCodexAnthropicAuthField !== "ANTHROPIC_AUTH_TOKEN"
|
||||
? localCodexAnthropicAuthField
|
||||
: undefined,
|
||||
// Off by default; persist true only for codex+anthropic when the user explicitly enables it
|
||||
impersonateClaudeCode:
|
||||
appId === "codex" &&
|
||||
category !== "official" &&
|
||||
localCodexApiFormat === "anthropic" &&
|
||||
localCodexImpersonateClaudeCode
|
||||
? true
|
||||
: undefined,
|
||||
// Persist only for codex+anthropic when a positive value was entered
|
||||
maxOutputTokens:
|
||||
appId === "codex" &&
|
||||
category !== "official" &&
|
||||
localCodexApiFormat === "anthropic" &&
|
||||
localCodexMaxOutputTokens.trim() !== "" &&
|
||||
Number(localCodexMaxOutputTokens) > 0
|
||||
? Number(localCodexMaxOutputTokens)
|
||||
: undefined,
|
||||
isFullUrl:
|
||||
supportsFullUrl && category !== "official" && localIsFullUrl
|
||||
@@ -2142,6 +2170,12 @@ function ProviderFormFull({
|
||||
onAutoSelectChange={setEndpointAutoSelect}
|
||||
apiFormat={localCodexApiFormat}
|
||||
onApiFormatChange={handleCodexApiFormatChange}
|
||||
anthropicAuthField={localCodexAnthropicAuthField}
|
||||
onAnthropicAuthFieldChange={setLocalCodexAnthropicAuthField}
|
||||
impersonateClaudeCode={localCodexImpersonateClaudeCode}
|
||||
onImpersonateClaudeCodeChange={setLocalCodexImpersonateClaudeCode}
|
||||
maxOutputTokens={localCodexMaxOutputTokens}
|
||||
onMaxOutputTokensChange={setLocalCodexMaxOutputTokens}
|
||||
codexChatReasoning={codexChatReasoning}
|
||||
onCodexChatReasoningChange={setCodexChatReasoning}
|
||||
catalogModels={codexCatalogModels}
|
||||
|
||||
Reference in New Issue
Block a user