diff --git a/CHANGELOG.md b/CHANGELOG.md index c7d81b44325..8bb278fb8cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ _In development — bullets added per PR; finalized at release._ - **fix(pricing): align Claude Code (`cc`) pricing with current Anthropic per-MTok rates** — the `cc` provider block in the default pricing table had stale numbers across every Claude 4.x family entry — most visibly, `claude-opus-4-5-20251101` was billed at the deprecated Opus 4.1 rate (`input $15` / `output $75`), and `claude-haiku-4-5-20251001` was at half the current Haiku 4.5 rate. The `cached` (cache hit) and `cache_creation` (5-minute cache write) multipliers were also off across Opus 4.6/4.7/4.8, Sonnet 4.5/4.6, Haiku 4.5, and Fable 5. All eight entries now match the rates Anthropic publishes (input, 5m cache write at 1.25x input, cache hit at 0.1x input, output; reasoning billed at the output rate), so cost accounting on the dashboard and per-request usage events stop under- or over-reporting Claude Code spend. (thanks @chulanpro5) - **fix(executors): sanitize Anthropic-shape content parts before GitHub Copilot `/chat/completions`** — Claude models on GitHub Copilot driven from clients like Cursor IDE (e.g. `gh/claude-sonnet-4.6`) failed with `Provider returned error: type has to be either 'image_url' or 'text' (reset after 30s)` because the client passed through Anthropic-shape content parts (`tool_use`, `tool_result`, `thinking`) untouched, and the Copilot chat-completions endpoint only accepts `text`/`image_url`. `GithubExecutor.transformRequest` now serializes any unsupported part type as `text` (preserving the model's context), drops empty parts, and collapses to `null` when an assistant message's only content was tool_calls — `tool_calls` ride alongside untouched. Codex-family models still route through `/responses` unchanged. (thanks @cngznNN) - **fix(sse):** refactor stall detection to reduce false positives on slow but progressing streams. (thanks @zakirkun) +- **fix(providers): restore specialty validator dispatch for web-cookie providers** — the generic `/models` web-cookie probe introduced in #4023 silently took precedence over the per-provider specialty validators (qwen-web, grok-web, chatgpt-web, claude-web, gemini-web, copilot-web, deepseek-web, perplexity-web, blackbox-web, muse-spark-web, t3-web, adapta-web, inner-ai). For qwen-web this regressed the body-check fix from #3958 — the generic probe accepted any 200 as valid, where the specialty validator inspects `data.user` on `/api/v2/user` to detect expired sessions that still return 200. Every other specialty web-cookie validator was equally bypassed. The dispatcher now gates the generic probe on a `WEB_COOKIE_PROVIDERS_WITH_SPECIALTY_VALIDATOR` set so providers with bespoke validators reach them again. A new `tests/unit/web-cookie-specialty-dispatch.test.ts` enforces the invariant (every override must really be a registered web-cookie provider, and qwen-web's specialty path must hit `/api/v2/user`, not `/models`). --- diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 67940a6f6e4..9e2642e11ef 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -86,12 +86,15 @@ "_rebaseline_2026_06_20_1449_1444_test_route": "Re-baseline providers test route.ts 842->887: combined growth of sibling fixes #1449 (bound OAuth connection-test probe with a timeout) + #1444 (label a deactivated account distinctly from a revoked token), both at the same connection-test chokepoint. Cohesive route handler; not extractable without hiding the test flow.", "_rebaseline_2026_06_20_1409_1294_models": "Re-baseline src/lib/db/models.ts 1184->1221: combined growth of sibling fixes #1409 (cascade-delete orphaned model aliases when a provider is removed) + #1294 (persist max_input_tokens/max_output_tokens on custom models), both adding CRUD at the existing models domain module. Cohesive db module; not extractable.", "_rebaseline_2026_06_20_4389_thinking_toolchoice": "Re-baseline base.ts 1387->1399 (#4389): tool_choice-forced thinking guard at the existing Claude wire-image injection chokepoint (effThinking gate avoids the Anthropic 400 when tool_choice forces a tool). Cohesive guard; structural shrink tracked in #3501.", + "_rebaseline_2026_06_20_qwen_web_specialty_dispatch": "Re-baseline validation.ts 4518->4556 (+38) — restore-green dispatch fix for web-cookie providers regressed by #4023. The generic web-cookie /models probe added in #4023 silently took precedence over the per-provider specialty validators (qwen-web/grok-web/chatgpt-web/...), regressing the qwen-web body-check fix from #3958 and the equivalent guards for the other 12 specialty web-cookie providers. Fix gates the generic probe on a Set of providers that have a specialty validator below (WEB_COOKIE_PROVIDERS_WITH_SPECIALTY_VALIDATOR), plus a single test-export const used by the invariant test (tests/unit/web-cookie-specialty-dispatch.test.ts). Cohesive guard at the validateProviderApiKey dispatch chokepoint; not extractable without splitting the dispatcher. Structural shrink for validation.ts tracked in #3501.", + "_rebaseline_2026_06_20_4440_cc_pricing": "Re-baseline pricing.ts 1620->1623 (+3) — drift from PR #4440 (align Claude Code pricing with Anthropic) which was merged with --admin while file-size was already over. Pure pricing data rows; not extractable.", + "_rebaseline_2026_06_20_4443_reasoning_effort": "Re-baseline base.ts 1399->1407 (+8) — drift from PR #4443 (granular reasoning_effort handling for Claude models on Copilot) which was merged with --admin while file-size was already over. Cohesive per-model preserve at the existing serialization chokepoint; not extractable. Structural shrink tracked in #3501.", "cap": 800, "frozen": { "open-sse/translator/request/openai-to-kiro.ts": 807, "open-sse/config/providerRegistry.ts": 4731, "open-sse/executors/antigravity.ts": 1687, - "open-sse/executors/base.ts": 1399, + "open-sse/executors/base.ts": 1407, "open-sse/executors/chatgpt-web.ts": 2870, "open-sse/executors/claude-web.ts": 1057, "open-sse/executors/codex.ts": 1449, @@ -175,7 +178,7 @@ "src/lib/evals/evalRunner.ts": 961, "src/lib/memory/retrieval.ts": 1171, "src/lib/modelsDevSync.ts": 934, - "src/lib/providers/validation.ts": 4518, + "src/lib/providers/validation.ts": 4556, "src/lib/tailscaleTunnel.ts": 1202, "src/lib/usage/callLogs.ts": 975, "src/lib/usage/providerLimits.ts": 949, @@ -184,7 +187,7 @@ "src/shared/components/RequestLoggerV2.tsx": 1287, "src/shared/components/analytics/charts.tsx": 1558, "src/shared/constants/cliTools.ts": 875, - "src/shared/constants/pricing.ts": 1620, + "src/shared/constants/pricing.ts": 1623, "src/shared/constants/providers.ts": 3242, "src/shared/constants/sidebarVisibility.ts": 1100, "src/shared/services/cliRuntime.ts": 1090, diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index c6904d715cc..1c367207fd8 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -3992,6 +3992,33 @@ export async function validateWebCookieProvider({ } } +// Web-cookie providers that ship a per-provider specialty validator below +// (registered in SPECIALTY_VALIDATORS inside validateProviderApiKey). They +// must NOT fall into the generic /models web-cookie probe, which would +// regress per-provider body-check guards (see #3958 for qwen-web). +// +// Keep this set in sync with the SPECIALTY_VALIDATORS keys that are also in +// WEB_COOKIE_PROVIDERS — the test in +// `tests/unit/web-cookie-specialty-dispatch.test.ts` enforces the invariant. +const WEB_COOKIE_PROVIDERS_WITH_SPECIALTY_VALIDATOR = new Set([ + "adapta-web", + "blackbox-web", + "chatgpt-web", + "claude-web", + "copilot-web", + "deepseek-web", + "gemini-web", + "grok-web", + "inner-ai", + "muse-spark-web", + "perplexity-web", + "qwen-web", + "t3-web", +]); + +export const __testing__WEB_COOKIE_PROVIDERS_WITH_SPECIALTY_VALIDATOR = + WEB_COOKIE_PROVIDERS_WITH_SPECIALTY_VALIDATOR; + export async function validateProviderApiKey({ provider, apiKey, providerSpecificData = {} }: any) { const requiresApiKey = !providerAllowsOptionalApiKey(provider); const isLocal = isLocalProvider(provider); @@ -4000,8 +4027,19 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi return { valid: false, error: "Provider and API key required", unsupported: false }; } - // Web-cookie providers (session-based authentication) - if (WEB_COOKIE_PROVIDERS[provider]) { + // Web-cookie providers (session-based authentication). + // + // Skip the generic /models probe when the provider has its own specialty + // validator below (in SPECIALTY_VALIDATORS) — the specialty validator hits + // the provider's real session endpoint (e.g. qwen-web → /api/v2/user) and + // inspects the body for a real user/session marker, which the generic probe + // cannot do. Without this guard, the generic dispatch silently returns + // `valid:true` on any 200 (regressing the qwen-web body-check fix from #3958 + // and the equivalent guards for the other web-cookie providers). + if ( + WEB_COOKIE_PROVIDERS[provider] && + !WEB_COOKIE_PROVIDERS_WITH_SPECIALTY_VALIDATOR.has(provider) + ) { try { return await validateWebCookieProvider({ provider, apiKey, providerSpecificData }); } catch (error: any) { diff --git a/tests/unit/web-cookie-specialty-dispatch.test.ts b/tests/unit/web-cookie-specialty-dispatch.test.ts new file mode 100644 index 00000000000..a81c039c44b --- /dev/null +++ b/tests/unit/web-cookie-specialty-dispatch.test.ts @@ -0,0 +1,64 @@ +// Regression guard for the dispatch order in validateProviderApiKey: +// PR #4023 added a generic web-cookie /models probe that silently took +// precedence over per-provider specialty validators, regressing the qwen-web +// body-check fix from #3958 (and the equivalent guards for every other +// specialty web-cookie provider). The fix gates the generic probe on a Set +// of providers that have a specialty validator. This test enforces the +// invariant: every key in that Set must really be served by the specialty +// dispatch, never the generic probe. +// +// Drift mode (e.g. someone adds a new specialty web-cookie validator): +// → the new key must also be added to +// WEB_COOKIE_PROVIDERS_WITH_SPECIALTY_VALIDATOR — this test will fail +// until it is, surfacing the regression at PR review time. + +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + __testing__WEB_COOKIE_PROVIDERS_WITH_SPECIALTY_VALIDATOR as SPECIALTY_OVERRIDES, + validateProviderApiKey, +} from "../../src/lib/providers/validation.ts"; +import { WEB_COOKIE_PROVIDERS } from "../../src/shared/constants/providers.ts"; + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test("every override is actually a registered web-cookie provider", () => { + for (const provider of SPECIALTY_OVERRIDES) { + assert.ok( + Object.hasOwn(WEB_COOKIE_PROVIDERS, provider), + `override "${provider}" is not in WEB_COOKIE_PROVIDERS — drop it or fix the typo` + ); + } +}); + +test("a specialty web-cookie provider does NOT fall into the generic /models probe", async () => { + // The generic probe hits "/models" and accepts any non-401/403 as + // valid. For qwen-web specifically the specialty validator hits + // /api/v2/user and inspects the body for a real `user` object. We assert + // the fetch URL: if dispatch lands on the generic probe, the path ends in + // /models; the specialty path ends in /api/v2/user. + const seenUrls: string[] = []; + globalThis.fetch = (async (url: any) => { + seenUrls.push(String(url)); + return new Response("{}", { + status: 200, + headers: { "content-type": "application/json" }, + }); + }) as typeof fetch; + + await validateProviderApiKey({ provider: "qwen-web", apiKey: "qwen-token-abc" }); + + assert.ok( + seenUrls.some((u) => u.endsWith("/api/v2/user")), + `expected specialty validator to call /api/v2/user, got: ${seenUrls.join(", ")}` + ); + assert.ok( + !seenUrls.some((u) => u.endsWith("/models")), + `specialty dispatch leaked into the generic /models probe: ${seenUrls.join(", ")}` + ); +});