fix(#2748): stop projecting double-/v1 adapter base_url for workspace_override BYOK #2754

Merged
devops-engineer merged 1 commits from fix/2748-adapter-base-double-v1 into main 2026-06-13 18:54:40 +00:00
3 changed files with 118 additions and 5 deletions
@@ -475,8 +475,13 @@ func TestApplyPlatformManagedLLMEnv_BYOKMiniMaxWorkspaceOverrideProjectsCreds(t
if got := envVars["ANTHROPIC_AUTH_TOKEN"]; got != "real-minimax-key" {
t.Fatalf("ANTHROPIC_AUTH_TOKEN = %q, want real-minimax-key", got)
}
if got := envVars["ANTHROPIC_BASE_URL"]; got != "https://api.minimax.io/anthropic/v1" {
t.Fatalf("ANTHROPIC_BASE_URL = %q, want https://api.minimax.io/anthropic/v1", got)
// core#2748: the adapter (claude-code Anthropic SDK) base must NOT carry a
// trailing /v1 — the SDK appends /v1/messages itself. The registry value is
// proxy-shaped (.../anthropic/v1); the projection strips the trailing /v1 so
// the effective endpoint is .../anthropic/v1/messages (HTTP 200), not the
// double-/v1 .../anthropic/v1/v1/messages (HTTP 404) that caused the outage.
if got := envVars["ANTHROPIC_BASE_URL"]; got != "https://api.minimax.io/anthropic" {
t.Fatalf("ANTHROPIC_BASE_URL = %q, want https://api.minimax.io/anthropic (no double /v1, core#2748)", got)
}
if got := envVars["MINIMAX_API_KEY"]; got != "real-minimax-key" {
t.Fatalf("MINIMAX_API_KEY was overwritten: %q", got)
@@ -485,3 +490,93 @@ func TestApplyPlatformManagedLLMEnv_BYOKMiniMaxWorkspaceOverrideProjectsCreds(t
t.Errorf("sqlmock expectations: %v", err)
}
}
// TestApplyPlatformManagedLLMEnv_AdapterBaseHasNoDoubleV1 is core#2748: the
// direct-BYOK adapter path (claude-code Anthropic SDK) must project an
// ANTHROPIC_BASE_URL that does NOT carry a trailing /v1, because the SDK
// appends /v1/messages itself. #2735 introduced a projection that copied the
// PROXY-shaped registry base_url_anthropic verbatim (which DOES end in /v1),
// producing a double /v1 (.../v1/v1/messages -> upstream 404, surfaced as
// "selected model may not exist or no access") and taking the coding engines
// down.
//
// EMPIRICALLY PROVEN endpoint shapes the SDK derives from these bases:
// - minimax base .../anthropic -> .../anthropic/v1/messages HTTP 200
// (vs .../anthropic/v1 -> .../anthropic/v1/v1/messages HTTP 404)
// - kimi base .../coding -> .../coding/v1/messages HTTP 401 (path ok, auth-only)
// (vs .../coding/v1 -> .../coding/v1/v1/messages HTTP 404)
// - anthropic base https://api.anthropic.com -> .../v1/messages (canonical)
//
// Each case is a workspace_override BYOK claude-code provision: the resolver
// returns early on the override with ProviderSelection=nil, so the projection
// derives the provider from the stored effective model (the core#2712 path),
// then injects the normalized adapter base.
func TestApplyPlatformManagedLLMEnv_AdapterBaseHasNoDoubleV1(t *testing.T) {
const messagesSuffix = "/v1/messages" // what the claude-code Anthropic SDK appends
cases := []struct {
name string
wsID string
model string
keyEnv string // the BYOK vendor key env the workspace carries
keyVal string
wantBase string // projected ANTHROPIC_BASE_URL (no double /v1)
wantMessages string // proven-correct effective messages URL
}{
{
name: "minimax",
wsID: "11111111-1111-1111-1111-111111111111",
model: "MiniMax-M3",
keyEnv: "MINIMAX_API_KEY",
keyVal: "mm-key",
wantBase: "https://api.minimax.io/anthropic",
wantMessages: "https://api.minimax.io/anthropic/v1/messages",
},
{
name: "kimi-for-coding",
wsID: "22222222-2222-2222-2222-222222222222",
model: "kimi-for-coding",
keyEnv: "KIMI_API_KEY",
keyVal: "kimi-key",
wantBase: "https://api.kimi.com/coding",
wantMessages: "https://api.kimi.com/coding/v1/messages",
},
{
name: "anthropic",
wsID: "33333333-3333-3333-3333-333333333333",
model: "claude-opus-4-8",
keyEnv: "ANTHROPIC_API_KEY",
keyVal: "sk-ant-key",
wantBase: "https://api.anthropic.com",
wantMessages: "https://api.anthropic.com/v1/messages",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
mock := setupTestDB(t)
expectOverrideQuery(mock, tc.wsID, LLMBillingModeBYOK)
envVars := map[string]string{
"MODEL": tc.model,
tc.keyEnv: tc.keyVal,
}
res := applyPlatformManagedLLMEnv(ctx, envVars, tc.wsID, "claude-code", "", nil)
if res.ResolvedMode != LLMBillingModeBYOK {
t.Fatalf("resolved mode = %q, want byok", res.ResolvedMode)
}
got := envVars["ANTHROPIC_BASE_URL"]
if got != tc.wantBase {
t.Fatalf("ANTHROPIC_BASE_URL = %q, want %q (no double /v1, core#2748)", got, tc.wantBase)
}
if got+messagesSuffix != tc.wantMessages {
t.Fatalf("effective messages URL = %q, want proven-correct %q", got+messagesSuffix, tc.wantMessages)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("sqlmock expectations: %v", err)
}
})
}
}
@@ -1193,7 +1193,20 @@ func applyPlatformManagedLLMEnv(ctx context.Context, envVars map[string]string,
}
}
if _, hasBase := envVars["ANTHROPIC_BASE_URL"]; !hasBase && provider.BaseURLAnthropic != "" {
envVars["ANTHROPIC_BASE_URL"] = provider.BaseURLAnthropic
// core#2748: the registry base_url_anthropic is PROXY-shaped and
// carries a trailing /v1 (providers.yaml follows the routing layer;
// see the minimax "PR-5" reconciliation comment). But THIS is the
// direct-BYOK adapter path: the claude-code Anthropic SDK appends
// /v1/messages to ANTHROPIC_BASE_URL itself. Projecting the proxy
// value verbatim yields a double /v1 (.../anthropic/v1/v1/messages)
// -> upstream 404, surfaced as "selected model may not exist or no
// access" (the #2748 engine outage; #2735 introduced this projection).
// Strip a single trailing /v1 (and any trailing slash) so the SDK
// re-append produces exactly one /v1. Correct for every anthropic-proto
// provider: minimax .../anthropic/v1->.../anthropic, kimi-coding
// .../coding/v1->.../coding, anthropic .../v1->root, moonshot likewise.
adapterBase := strings.TrimSuffix(strings.TrimRight(provider.BaseURLAnthropic, "/"), "/v1")
envVars["ANTHROPIC_BASE_URL"] = adapterBase
}
}
}
@@ -1117,8 +1117,13 @@ func TestApplyPlatformManagedLLMEnv_BYOKMiniMaxProjectsAnthropicAdapterCreds(t *
if got := envVars["ANTHROPIC_AUTH_TOKEN"]; got != "user-minimax-key" {
t.Fatalf("ANTHROPIC_AUTH_TOKEN = %q, want user-minimax-key", got)
}
if got := envVars["ANTHROPIC_BASE_URL"]; got != "https://api.minimax.io/anthropic/v1" {
t.Fatalf("ANTHROPIC_BASE_URL = %q, want https://api.minimax.io/anthropic/v1", got)
// core#2748: adapter base must NOT carry a trailing /v1 — the claude-code
// Anthropic SDK appends /v1/messages itself. The proxy-shaped registry value
// .../anthropic/v1 is normalized to .../anthropic so the effective endpoint is
// .../anthropic/v1/messages (HTTP 200), not the double-/v1 .../v1/v1/messages
// (HTTP 404) that took the coding engines down.
if got := envVars["ANTHROPIC_BASE_URL"]; got != "https://api.minimax.io/anthropic" {
t.Fatalf("ANTHROPIC_BASE_URL = %q, want https://api.minimax.io/anthropic (no double /v1, core#2748)", got)
}
// The original MiniMax key is preserved too.
if got := envVars["MINIMAX_API_KEY"]; got != "user-minimax-key" {