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: 2 additions & 0 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@

Codex2API 提供兼容 OpenAI 风格的 API 接口,同时包含完整的管理后台 API。

Anthropic `/v1/messages` 仅将官方 `speed:"fast"` 映射为上游 Codex `service_tier:"priority"`;Anthropic 请求侧 `service_tier`(Priority Tier)不在此映射范围内。用量日志的 `service_tier` / `fast` 过滤反映该解析结果。

**Base URL:** `http://localhost:8080` (默认端口)

**请求格式:**
Expand Down
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ func TranslateStreamChunk(data []byte, model, chunkID string) ([]byte, bool)
- `messages` → `input`
- `max_tokens/temperature` → 删除(Codex 不支持)
- `reasoning_effort` → `reasoning.effort`
- Anthropic `/v1/messages` 的 `speed:"fast"` → Codex `service_tier:"priority"`(Anthropic 入参 `service_tier` 为 Priority Tier,不参与 fast mode 映射)
- SSE 事件类型转换

---
Expand Down
11 changes: 11 additions & 0 deletions proxy/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type anthropicRequest struct {
OutputConfig *anthropicOutputConfig `json:"output_config,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
Metadata json.RawMessage `json:"metadata,omitempty"`
Speed string `json:"speed,omitempty"`
}

type anthropicThinking struct {
Expand Down Expand Up @@ -240,6 +241,10 @@ func TranslateAnthropicToCodex(rawJSON []byte, modelMappingJSON string) ([]byte,
return TranslateAnthropicToCodexWithModels(rawJSON, modelMappingJSON, SupportedModels)
}

func shouldUseCodexPriorityForAnthropicSpeed(speed string) bool {
return strings.ToLower(strings.TrimSpace(speed)) == "fast"
}

// TranslateAnthropicToCodexWithModels 将 Anthropic Messages 请求转换为 Codex Responses 格式
// 返回: (codex 请求体, 原始 Anthropic model 名, error)
func TranslateAnthropicToCodexWithModels(rawJSON []byte, modelMappingJSON string, supportedModels []string) ([]byte, string, error) {
Expand Down Expand Up @@ -271,6 +276,12 @@ func TranslateAnthropicToCodexWithModels(rawJSON []byte, modelMappingJSON string
"summary": "auto",
}

if shouldUseCodexPriorityForAnthropicSpeed(req.Speed) {
if upstreamTier, ok := upstreamServiceTier("priority"); ok {
out["service_tier"] = upstreamTier
}
}

// tools
if len(req.Tools) > 0 {
out["tools"] = convertAnthropicTools(req.Tools)
Expand Down
75 changes: 75 additions & 0 deletions proxy/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ func TestTranslateAnthropicToCodex_DefaultsReasoningHighWithSummary(t *testing.T
if summary := gjson.GetBytes(got, "reasoning.summary").String(); summary != "auto" {
t.Fatalf("reasoning.summary = %q, want auto; body=%s", summary, got)
}
if tier := gjson.GetBytes(got, "service_tier"); tier.Exists() {
t.Fatalf("service_tier should be omitted when speed is absent; body=%s", got)
}
}

func TestTranslateAnthropicToCodex_ThinkingBudgetDoesNotControlEffort(t *testing.T) {
Expand All @@ -81,6 +84,78 @@ func TestTranslateAnthropicToCodex_ThinkingBudgetDoesNotControlEffort(t *testing
}
}

func TestTranslateAnthropicToCodex_SpeedFastMapsToCodexPriority(t *testing.T) {
cases := []struct {
name string
field string
wantTier bool
}{
{"absent omits priority", "", false},
{"speed fast maps to priority", `,"speed":"fast"`, true},
{"speed standard omits priority", `,"speed":"standard"`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
raw := []byte(`{
"model":"claude-sonnet-4-5",
"messages":[{"role":"user","content":"hello"}]` + tc.field + `
}`)

got, _, err := TranslateAnthropicToCodexWithModels(raw, "", []string{"gpt-5.4"})
if err != nil {
t.Fatalf("TranslateAnthropicToCodexWithModels returned error: %v", err)
}

tier := gjson.GetBytes(got, "service_tier")
if tc.wantTier {
if tier.String() != "priority" {
t.Fatalf("service_tier = %q, want priority; body=%s", tier.String(), got)
}
if speed := gjson.GetBytes(got, "speed"); speed.Exists() {
t.Fatalf("speed should not be forwarded to Codex body; body=%s", got)
}
return
}
if tier.Exists() {
t.Fatalf("service_tier should be omitted; body=%s", got)
}
if speed := gjson.GetBytes(got, "speed"); speed.Exists() {
t.Fatalf("speed should not be forwarded to Codex body; body=%s", got)
}
})
}
}

func TestAnthropicUsageServiceTierResolution(t *testing.T) {
cases := []struct {
name string
speed string
actual string
want string
}{
{"no fast intent", "", "default", "default"},
{"fast intent upstream default", "fast", "default", "fast"},
{"fast intent upstream priority", "fast", "priority", "fast"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
field := ""
if tc.speed != "" {
field = `,"speed":"` + tc.speed + `"`
}
raw := []byte(`{"model":"claude-opus-4-7","messages":[{"role":"user","content":"hi"}]` + field + `}`)
codexBody, _, err := TranslateAnthropicToCodexWithModels(raw, "", []string{"gpt-5.5"})
if err != nil {
t.Fatalf("TranslateAnthropicToCodexWithModels returned error: %v", err)
}
got := resolveServiceTier(tc.actual, extractServiceTier(codexBody))
if got != tc.want {
t.Fatalf("resolveServiceTier(%q, %q) = %q, want %q", tc.actual, extractServiceTier(codexBody), got, tc.want)
}
})
}
}

func TestTranslateAnthropicToCodexCanonicalizesDynamicMappedModelAlias(t *testing.T) {
raw := []byte(`{
"model":"claude-haiku-4-5-20251001",
Expand Down
13 changes: 13 additions & 0 deletions proxy/handler_anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ func (h *Handler) Messages(c *gin.Context) {

// 提取 reasoning effort(从翻译后的 codex body 中)
reasoningEffort := extractReasoningEffort(codexBody)
serviceTier := extractServiceTier(codexBody)
sessionID := ResolveSessionID(c.Request.Header, codexBody)
apiKeyID := requestAPIKeyID(c)
affinityKey := sessionAffinityKey(sessionID, apiKeyID)
Expand Down Expand Up @@ -225,6 +226,7 @@ func (h *Handler) Messages(c *gin.Context) {
InboundEndpoint: "/v1/messages",
UpstreamEndpoint: "/v1/responses",
Stream: isStream,
ServiceTier: resolveServiceTier("", serviceTier),
IsRetryAttempt: shouldRetry,
AttemptIndex: attempt + 1,
UpstreamErrorKind: upstreamErrorKind(resp.StatusCode, errBody, decision),
Expand Down Expand Up @@ -257,6 +259,7 @@ func (h *Handler) Messages(c *gin.Context) {

var firstTokenMs int
var usage *UsageInfo
var actualServiceTier string
ttftRecorded := false
gotTerminal := false
deltaCharCount := 0
Expand Down Expand Up @@ -301,6 +304,9 @@ func (h *Handler) Messages(c *gin.Context) {
// 提取 usage
if eventType == "response.completed" {
usage = extractUsageFromResult(parsed.Get("response.usage"))
if tier := parsed.Get("response.service_tier").String(); tier != "" {
actualServiceTier = tier
}
gotTerminal = true
}
if eventType == "response.failed" {
Expand Down Expand Up @@ -359,6 +365,9 @@ func (h *Handler) Messages(c *gin.Context) {
}
if eventType == "response.completed" {
usage = extractUsageFromResult(parsed.Get("response.usage"))
if tier := parsed.Get("response.service_tier").String(); tier != "" {
actualServiceTier = tier
}
lastCompletedData = data
gotTerminal = true
return false
Expand Down Expand Up @@ -418,6 +427,9 @@ func (h *Handler) Messages(c *gin.Context) {
}
}

resolvedServiceTier := resolveServiceTier(actualServiceTier, serviceTier)
c.Set("x-service-tier", resolvedServiceTier)

logInput := &database.UsageLogInput{
AccountID: account.ID(),
Endpoint: "/v1/messages",
Expand All @@ -430,6 +442,7 @@ func (h *Handler) Messages(c *gin.Context) {
InboundEndpoint: "/v1/messages",
UpstreamEndpoint: "/v1/responses",
Stream: isStream,
ServiceTier: resolvedServiceTier,
}
if logStatusCode != http.StatusOK {
logInput.ErrorMessage = usageLogErrorMessage(logStatusCode, []byte(outcome.failureMessage))
Expand Down