From 38f5eaf5721cfa748b48838bdf9008d43d35853c Mon Sep 17 00:00:00 2001 From: ltmoerdani Date: Tue, 27 Oct 2026 09:13:54 +0700 Subject: [PATCH] fix(models): key-rejected hint on model-list 401 + refresh bundled catalogs (fixes #269) The /models endpoints are a public catalog (anonymous 200; invalid bearer 401, verified live on both gateways), so a 401 there always means the stored key was rejected - never a list/gateway problem. Log an actionable hint pointing at Manage Provider; 401 stays non-transient (one attempt). Bundled fallbacks refreshed to the live gateway catalogs (2026-10-27): Go 27 -> 38 (adds deepseek-v4.1-flash etc, drops dead deepseek-v4-flash / -vision-exp - plain id still live on Zen, not blocklisted), Zen 42 -> 72 (live minus policy-hidden free tier, non-chat jev-*, junk ids). 2 new tests (561/561). Docs: docs/issues/117-20261027. --- CHANGELOG.md | 2 + ...269-models-401-hint-and-bundled-refresh.md | 49 +++++++ src/provider/definitions.ts | 120 ++++++++++++------ src/provider/modelList.ts | 20 +++ src/test/modelList401Hint.test.ts | 94 ++++++++++++++ 5 files changed, 249 insertions(+), 36 deletions(-) create mode 100644 docs/issues/117-20261027-issue269-models-401-hint-and-bundled-refresh.md create mode 100644 src/test/modelList401Hint.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a06857bf3..36f9f423f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Fixed +- **`[Models]` A 401 on the model-list fetch now logs an actionable key hint, and both bundled fallback catalogs are refreshed to the live gateways (fixes #269).** The reporter's "DeepSeek v4.1 not shown" had two layers: their stored API key was rejected by the gateway — the `/models` endpoints are a public catalog (anonymous requests return 200; an invalid `Authorization: Bearer` gets 401; verified live on both gateways, deterministic) — so the live catalog never loaded and the September-era bundled list took over, which simply didn't contain `deepseek-v4.1-flash`. `ModelListFetcher` now recognizes the `Model list request failed (401)` shape and logs "the stored API key was rejected (401). Update or clear it via `…: Manage Provider`" alongside the fallback notice (401 stays non-transient: exactly one attempt, no retry; the hint fires on the cached branch too). The bundled Go list now mirrors the 38-model live catalog (13 new models incl. `deepseek-v4.1-flash`, `gpt-6-luna`, `grok-4.7`, `kimi-k3.1-pro`, `mimo-v2.6-flash/pro`, `step-5-preview`, `beam-free`, `claude-haiku-5-5`, `gemini-4-argon`; dead `deepseek-v4-flash`/`-vision-exp` dropped — the plain id is still served on Zen, so it is deliberately not blocklisted), and the Zen list mirrors the 84-model live catalog minus the policy-hidden free tier, the non-chat `jev-*` decision API, and junk entries (`test`, `claude-opus-5-5-backup`) → 72 models. Log forensics also showed the reporter's session was running pre-0.7.10 code (their 38-entry filter line matches the old `freeOnly` keep-free filter exactly), so part of the symptom was already resolved by #266 after a reload. 2 new tests (561/561 pass). Documented in `docs/issues/117-20261027-issue269-models-401-hint-and-bundled-refresh.md`. + - **`[Errors]` HTTP 402 `Endpoint is unavailable` (e.g. `longcat-2.5-preview-free`) now renders an actionable hint instead of raw upstream text.** Live probes show the model is still in the Go catalog and passes gateway model validation (dummy-key probe → 401 at the credential check), so the 402 fires at upstream serving time — the `Router.Unavailable` family reached via a different status/message, not the #204 removed-model class; the model is deliberately not blocklisted. New `describeEndpointUnavailable()` hint ("upstream unavailable right now — retry or switch models") joins the free-tier and Router.Unavailable detectors, and `describeRouterUnavailable()` now returns `undefined` on mismatch — it previously always returned the fallback string, which silently short-circuited the hint chain. 3 new tests (559/559 pass). Documented in `docs/issues/116-20261022-longcat-endpoint-unavailable.md`. Upstream research after a premature blocklist attempt (reverted) confirmed the correct reading: `Endpoint is unavailable` is OpenCode's generic per-model incident error (402/429/503 across models — anomalyco/opencode #55607 for this very model, closed fixed; #56796; #57613), tracked on **status.opencode.ai** — a Free-inference upstream incident ran Oct 21 05:54–19:28 UTC and the reported failures sit in its aftermath window. The model stays selectable and the hint now points at the status page. - **`[Tool calling]` `beam-free` no longer fails with HTTP 400 `Invalid value for 'tools[N].function.name': maximum string length is 64` when MCP tools with long names are attached.** The issue #165 truncation (first 55 chars + `_` + 8-char hash) only ran on the Responses API path, but `beam-free` routes to chat-completions where Beam's upstream enforces the same OpenAI 64-char function-name limit — so the whole request 400ed before generation. A new registry `beam` family row plus `needsToolNameTruncation()` (muse + beam) gates truncation on both endpoints, and the chat-completions transport now receives the truncated→original `toolNameMap` so tool calls round-trip to VS Code's real tool names. 5 new tests (556/556 pass). Documented in `docs/issues/115-20261022-beam-tool-name-length.md`. diff --git a/docs/issues/117-20261027-issue269-models-401-hint-and-bundled-refresh.md b/docs/issues/117-20261027-issue269-models-401-hint-and-bundled-refresh.md new file mode 100644 index 000000000..07bc0fcc0 --- /dev/null +++ b/docs/issues/117-20261027-issue269-models-401-hint-and-bundled-refresh.md @@ -0,0 +1,49 @@ +# Model-List 401 Hint + Bundled Catalog Refresh (Issue #269) + +**Status:** ✅ Solved +**Topic:** provider / models / errors / fallback +**Updated:** 2026-10-27 +**Tags:** #provider #models #errors #fallback #fetch-models +**Related:** issue [#269](https://github.com/ltmoerdani/opencode-copilot-chat/issues/269), doc [100 — bundled model data sync](100-20260908-bundled-model-data-sync.md), doc [112 — Zen free-tier policy](112-20261020-zen-free-tier-policy-followup.md), doc [78 — deprecated cross-check #182](78-20260822-issue182-deprecated-model-gateway-crosscheck.md) + +--- + +## Problem + +Reporter (`@aalam95`, ext 0.7.10, both providers): "Deepseek v.4.1 is not shown" in the model picker. Logs showed every `fetchModels` call failing with `Model list request failed (401): Unauthorized` on **both** Go and Zen, falling back to the bundled list — and the Zen log filtering 38 models down to a nearly-empty picker. + +## Evidence (verified live 2026-10-26/27) + +| Probe (×3, deterministic) | Result | +| -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `GET /zen/go/v1/models` + `/zen/v1/models`, anonymous | **200** — the model list is a public catalog; the official docs curl it without auth | +| Same endpoints with `Authorization: Bearer sk-invalid-test` | **401** — the gateway validates the bearer whenever one is sent | +| status.opencode.ai (Console component owns keys/auth) | operational; no auth incident around Oct 23 | +| Upstream issue search `401 unauthorized models` (anomalyco/opencode) | no gateway-side `/models` 401 defect; #46169 confirms the "models list works, chat 401" split | + +**Root cause:** the reporter's stored API key (both vendors) was invalid/expired/revoked. The live catalog never loaded, so the September-era bundled list took over — and it simply didn't contain `deepseek-v4.1-flash` (Go bundled was a 27-model snapshot vs 38 live; Zen 42 vs 84 live). + +**Forensics on the reporter's Zen log:** the 38-entry `Filtered unavailable/deprecated models` line equals the bundled Zen list minus its 4 free-tier ids plus `deepseek-v4-flash-free` (KNOWN_UNAVAILABLE) — exactly the _keep-free-only_ `filterModel` of the pre-#266 era. So the reporting session was running pre-0.7.10 code (VS Code swaps extensions only on reload). On current main, `shouldHideDeprecatedModel` is fail-open without live ids (nothing hidden on fallback), so a fresh 0.7.10 session shows ~37 stale Zen models instead — the stale-bundled problem is the same either way. + +## Changes + +| File | Change | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/provider/modelList.ts` | New `isUnauthorizedModelListError()` (matches the thrown `Model list request failed (401)` prefix so `retry.ts` classification is untouched); on final failure with 401, logs an actionable hint: "the stored API key was rejected (401). Update or clear it via `…: Manage Provider`". 401 remains non-transient — one attempt, no retry | +| `src/provider/definitions.ts` | Go `fallbackModels`: 27 → 38, mirroring the live gateway catalog 2026-10-27 (adds `deepseek-v4.1-flash`, `gpt-6-luna`, `grok-4.7`, `kimi-k3.1-pro`, `mimo-v2.6-*`, `step-5-preview`, `beam-free`, `claude-haiku-5-5`, `gemini-4-argon`, …; drops dead `deepseek-v4-flash`/`-vision-exp`). Zen `fallbackModels`: 42 → 72, live catalog minus the policy-hidden free tier, the non-chat `jev-*` decision API, and junk ids (`test`, `claude-opus-5-5-backup`) | +| `src/test/modelList401Hint.test.ts` | New: 401 → bundled fallback + hint logged + exactly one fetch attempt; non-401 permanent (400) → fallback without the hint | + +## Decisions + +- **No anonymous retry on 401.** The catalog endpoint is public, so retrying without the `Authorization` header would load the live list even with a dead key — but it would mask the key problem until the first chat request. The hint keeps the diagnosis loud at the exact point of failure; Test Connection / chat errors already carry actionable messages. +- **`deepseek-v4-flash` is NOT blocklisted.** It is dead on Go but still served (flakily) on Zen; `KNOWN_UNAVAILABLE_MODEL_IDS` is vendor-agnostic, so blocklisting would hide a live Zen model (same reasoning as the #182 cross-check). Removing it from the Go bundled list is enough. +- **Zen bundled mirrors live minus free tier** rather than models.dev: the gateway is the source of truth (#182), and the officially-deprecated-but-still-served ids (`gpt-5-codex` family, `minimax-m2.5`, `glm-5`, `kimi-k2.5`) stay listed exactly as the live path shows them today. +- **Hint applies to the cached branch too** — a 401 with a fresh (<1 h) cached snapshot has the same root cause, so the hint logs before the cached/bundled choice. + +## Verification + +- `npm run compile` — no errors. +- `npm test` — **561/561 pass** (559 prior + 2 new). +- Live re-probes: both `/models` endpoints anonymous 200 / invalid-bearer 401 (deterministic ×3); Go live catalog 38 ids, Zen 84 ids (2026-10-27). + +Follow-up for the reporter: after updating the key, note that **DeepSeek V4.1 Flash / V4 Pro on Go require the "Global" region** in Console → Settings → Privacy (default is EU+US; upstream [#50155](https://github.com/anomalyco/opencode/issues/50155)) — otherwise chat requests for those models are rejected even with a valid key. diff --git a/src/provider/definitions.ts b/src/provider/definitions.ts index 1b2f7872c..16678a4b6 100644 --- a/src/provider/definitions.ts +++ b/src/provider/definitions.ts @@ -144,27 +144,40 @@ export const PROVIDERS: Record // fails with "Model is unavailable" before ever validating the key. testModelId: "deepseek-v4.1-flash", fallbackModels: [ - // Curated active set from models.dev (2026-09-08) — deprecated/legacy - // models are excluded, mirroring the live catalog's own filtering. - "deepseek-v4-flash", - "deepseek-v4-flash-vision-exp", + // Curated from the live Go gateway catalog (2026-10-27, issue #269). + // Removed vs the 2026-09-08 snapshot: deepseek-v4-flash and + // deepseek-v4-flash-vision-exp (dead upstream, replaced by + // deepseek-v4.1-flash — the plain id is still served on Zen, so it is + // deliberately NOT blocklisted in KNOWN_UNAVAILABLE_MODEL_IDS). + "beam-free", + "claude-haiku-5-5", "deepseek-v4-pro", + "deepseek-v4.1-flash", + "gemini-4-argon", "glm-5.1", "glm-5.2", "glm-5.3", "glm-5.3-flash", "gpt-5.6-luna", + "gpt-6-luna", "grok-4.6", + "grok-4.7", "hy3", "hy4-preview", + "iceman-contributor-free", "kimi-k2.6", "kimi-k2.7-code", "kimi-k3", + "kimi-k3.1-pro", "longcat-2.0", - "minimax-m2.7", - "minimax-m3", "mimo-v2.5", "mimo-v2.5-pro", + "mimo-v2.6-flash", + "mimo-v2.6-pro", + "minimax-m2.5", + "minimax-m2.7", + "minimax-m3", + "minimax-m3.1-flash", "muse-spark-1.2-contributor", "muse-spark-1.3-contributor", "omen-alpha", @@ -173,6 +186,7 @@ export const PROVIDERS: Record "qwen3.7-plus", "qwen3.8-flash", "qwen3.8-max", + "step-5-preview", ], }; const zen: ProviderDefinition = { @@ -191,48 +205,82 @@ export const PROVIDERS: Record // cheapest live model (~$0.30/1M in; max_tokens 10 ≈ negligible). testModelId: "deepseek-v4.1-flash", fallbackModels: [ - "claude-opus-4-7", - "claude-opus-4-6", + // Curated from the live Zen gateway catalog (2026-10-27, issue #269). + // Free-tier ids are excluded (filterModel hides them anyway per the + // 403 policy, doc 112), as are the non-chat jev-* decision API and + // junk catalog entries ("test", "claude-opus-5-5-backup"). + "claude-fable-5", + "claude-fable-5-1", + "claude-haiku-4-5", + "claude-haiku-5-5", "claude-opus-4-5", - "claude-opus-4-1", - "claude-sonnet-4-6", + "claude-opus-4-6", + "claude-opus-4-7", + "claude-opus-4-8", + "claude-opus-5", + "claude-opus-5-5", "claude-sonnet-4-5", - "claude-sonnet-4", - "claude-haiku-4-5", - "deepseek-v4-flash-free", - "gemini-3.5-flash", - "gemini-3.1-pro", + "claude-sonnet-4-6", + "claude-sonnet-5", + "claude-sonnet-5-5", + "deepseek-v4-pro", + "deepseek-v4.1-flash", "gemini-3-flash", - "glm-5.1", + "gemini-3.1-pro", + "gemini-3.5-flash", + "gemini-3.5-flash-lite", + "gemini-3.6-flash", + "gemini-3.7-flash", + "gemini-3.8-flash", + "gemini-4-argon", "glm-5", - "gpt-5.5", - "gpt-5.5-pro", - "gpt-5.4", - "gpt-5.4-pro", - "gpt-5.4-mini", - "gpt-5.4-nano", - "gpt-5.3-codex", - "gpt-5.3-codex-spark", - "gpt-5.2", - "gpt-5.2-codex", + "glm-5.1", + "glm-5.2", + "glm-5.3", + "glm-5.3-flash", + "gpt-5", + "gpt-5-codex", + "gpt-5-nano", "gpt-5.1", "gpt-5.1-codex", "gpt-5.1-codex-max", "gpt-5.1-codex-mini", - "gpt-5", - "gpt-5-codex", - "gpt-5-nano", + "gpt-5.2", + "gpt-5.2-codex", + "gpt-5.3-codex", + "gpt-5.3-codex-spark", + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", + "gpt-5.4-pro", + "gpt-5.5", + "gpt-5.5-pro", + "gpt-5.6-luna", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-6-astra", + "gpt-6-luna", + "gpt-6-sol", + "gpt-6.1-sol", + "grok-4.5", + "grok-4.6", + "grok-4.7", "grok-build-0.1", - "kimi-k2.6", "kimi-k2.5", - "minimax-m2.7", + "kimi-k2.6", + "kimi-k2.7-code", + "kimi-k3", "minimax-m2.5", - "minimax-m2.5-free", - "nemotron-3-super-free", - "qwen3.6-plus", - "qwen3.6-plus-free", + "minimax-m2.7", + "minimax-m3", + "mistral-large-4", + "muse-spark-1.2", + "muse-spark-1.3", + "muse-wonder-1.0", "qwen3.5-plus", - "big-pickle", + "qwen3.6-plus", + "qwen3.8-flash", + "qwen3.8-max", ], // Free-tier Zen models are always hidden: OpenCode gates them to its own // clients since 2026-09-17 (403 FreeTierError, upstream #49580). The diff --git a/src/provider/modelList.ts b/src/provider/modelList.ts index 7b115b2a5..068aba0fb 100644 --- a/src/provider/modelList.ts +++ b/src/provider/modelList.ts @@ -12,6 +12,17 @@ import { getUserAgent, isTransientFetchError, type ModelListEntry, type ModelLis import { auxiliarySessionId } from "../request/headers"; import { resolveBaseVendor } from "../providerTypes"; +/** + * A 401 from the models endpoint means the STORED key was rejected — the + * endpoint itself is a public catalog (the official docs curl it without + * auth), so this is a key problem, never a list/gateway problem (issue + * #269). Matched on the message shape thrown in `fetch()` so the + * `retry.ts` classification (which parses the same prefix) stays untouched. + */ +function isUnauthorizedModelListError(error: unknown): boolean { + return getErrorMessage(error).includes("Model list request failed (401)"); +} + /** * Fetches the live `/models` list with retry/backoff and cancellation support, * caching the last successful snapshot in globalState (TTL-guarded) so @@ -129,6 +140,15 @@ export class ModelListFetcher { } // Final failure: prefer cached snapshot (still fresh), then bundled list. + // Issue #269: 401 = the stored key was rejected; the bare "Unauthorized" + // line reads like a fetch bug, so point at the actual fix. The hint + // applies to the cached branch below too — same root cause. + if (isUnauthorizedModelListError(lastError)) { + this.deps.log( + `[fetchModels] ${this.deps.definition.displayName}: the stored API key was rejected (401). ` + + `Update or clear it via "${this.deps.definition.displayName}: Manage Provider" — requests will keep failing until then.`, + ); + } const cached = this.loadCached(); if (cached) { this.deps.log( diff --git a/src/test/modelList401Hint.test.ts b/src/test/modelList401Hint.test.ts new file mode 100644 index 000000000..0b8396e1c --- /dev/null +++ b/src/test/modelList401Hint.test.ts @@ -0,0 +1,94 @@ +import { describe, it, before, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import Module from "node:module"; +import path from "node:path"; +import fs from "node:fs"; +import os from "node:os"; + +// ── vscode stub ───────────────────────────────────────────────────────────── +// modelList.ts transitively imports vscode-dependent modules (definitions, +// request/headers); redirect require("vscode") to a tiny stub like the +// other vscode-dependent tests do (pattern: routing.test.ts). +// `extensions.getExtension` is needed by getUserAgent(); workspace stays +// absent — configuredApiBaseUrl() is written to tolerate that (structural +// view) so PROVIDERS can be derived in bare test environments. +const vscodeMockPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "vscode-mock-modellist-")), "index.js"); +fs.writeFileSync( + vscodeMockPath, + `"use strict"; +module.exports = { extensions: { getExtension: () => undefined } }; +`, + "utf-8", +); + +type ResolveFilename = (request: string, parent: unknown, ...args: unknown[]) => string; +const moduleResolver = Module as unknown as { _resolveFilename: ResolveFilename }; +const originalResolveFilename = moduleResolver._resolveFilename; +moduleResolver._resolveFilename = function (request: string, parent: unknown, ...args: unknown[]): string { + if (request === "vscode") { + return vscodeMockPath; + } + return originalResolveFilename.call(this, request, parent, ...args); +}; + +type ModelListFetcherLike = new (deps: unknown) => { fetch(apiKey?: string): Promise }; + +let ModelListFetcher: ModelListFetcherLike; +const originalFetch = globalThis.fetch; +let logs: string[] = []; +let fetchCalls = 0; + +before(async () => { + const mod = (await import("../provider/modelList.js")) as unknown as { ModelListFetcher: ModelListFetcherLike }; + ModelListFetcher = mod.ModelListFetcher; +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + logs = []; + fetchCalls = 0; +}); + +function stubFetchStatus(status: number, statusText: string): void { + globalThis.fetch = (): Promise => { + fetchCalls += 1; + return Promise.resolve(new Response(null, { status, statusText })); + }; +} + +function makeFetcher(fallbackModels: string[]): InstanceType { + return new ModelListFetcher({ + context: { globalState: { get: () => undefined, update: () => undefined } }, + definition: { + vendor: "opencodego", + displayName: "OpenCode Go", + modelsUrl: "https://gateway.example/v1/models", + fallbackModels, + }, + log: (message: string) => logs.push(message), + replaceLiveModelMetadata: () => {}, + filterAvailableModels: (modelIds: string[]) => Promise.resolve(modelIds), + }); +} + +describe("ModelListFetcher — 401 handling (issue #269)", () => { + it("falls back to the bundled list and logs an actionable key hint without retrying", async () => { + stubFetchStatus(401, "Unauthorized"); + const models = await makeFetcher(["bundled-fallback"]).fetch("sk-rejected"); + assert.deepEqual(models, ["bundled-fallback"]); + // 401 is permanent — exactly one attempt, no retry/backoff. + assert.equal(fetchCalls, 1); + const hint = logs.find((line) => line.includes("stored API key was rejected")); + assert.ok(hint, `expected a 401 hint line, got: ${logs.join(" | ")}`); + assert.match(hint, /Manage Provider/); + assert.ok(logs.some((line) => line.includes("Using bundled model list"))); + }); + + it("does not log the key hint for non-401 permanent failures", async () => { + stubFetchStatus(400, "Bad Request"); + const models = await makeFetcher(["bundled-fallback"]).fetch("sk-whatever"); + assert.deepEqual(models, ["bundled-fallback"]); + assert.equal(fetchCalls, 1); + assert.ok(!logs.some((line) => line.includes("stored API key was rejected"))); + }); +});