From f14a93cce39e8230f9628985ad82def73f809ae5 Mon Sep 17 00:00:00 2001 From: ltmoerdani Date: Wed, 23 Sep 2026 21:44:28 +0700 Subject: [PATCH 1/2] fix(models): revision-scoped metadata cache + actionable free-tier 403 hint (fixes #231, fixes #235, fixes #240) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Metadata cache: embed the bundled-data revision in MODEL_METADATA_CACHE_KEY (v5 -> v6.). When a release syncs the offline fallback tables, the key changes and stale persisted snapshots are abandoned automatically instead of surviving until the 1-hour TTL refetch — users were stuck on old limits (262K context for deepseek-v4.1-flash, which is 1M/384K upstream) across upgrades. Free-tier 403: OpenCode restricted free-tier models to their official clients on Sep 17, 2026 (policy, anomalyco/opencode#49580). The gateway error is now replaced with an actionable hint: use a paid Go model or run free models in the OpenCode app/CLI — no bypass by design, since that would violate OpenCode's terms. 4 new tests; 484/484 unit tests pass; full lint gate pass. --- src/config.ts | 7 ++++++- src/errors.ts | 20 +++++++++++++++++++- src/test/errors.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 src/test/errors.test.ts diff --git a/src/config.ts b/src/config.ts index 0790a6987..9e64d42b3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -113,7 +113,12 @@ export const MODEL_LIST_CACHE_KEY_PREFIX = "opencode.modelListCache.v1"; export const MODELS_DEV_API_URL = "https://models.dev/api.json"; export const MODEL_METADATA_REVISION = "session-2026-05-21-b"; -export const MODEL_METADATA_CACHE_KEY = "opencode.modelMetadataCache.v5"; +// The cache key embeds the bundled-data revision: when a release syncs the +// offline fallback tables (new revision), the key changes and every stale +// persisted snapshot is abandoned automatically (issue #231 — users were +// stuck on old limits like a 262K context for deepseek-v4.1-flash until the +// 1-hour TTL refetched or they ran Refresh Models manually). +export const MODEL_METADATA_CACHE_KEY = `opencode.modelMetadataCache.v6.${MODEL_METADATA_REVISION}`; export const MODEL_METADATA_CACHE_TTL_MS = 1 * 60 * 60 * 1000; export const DEFAULT_MODEL_CONTEXT_WINDOW = 262144; export const DEFAULT_MODEL_MAX_OUTPUT_TOKENS = 65536; diff --git a/src/errors.ts b/src/errors.ts index eeb32c54d..a0b1db0a9 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -55,11 +55,29 @@ export function buildOpenCodeRequestError( ); } - const userMessage = `${providerDisplayName} API request failed (HTTP ${String(response.status)})${modelId ? ` for ${modelId}` : ""}: ${describeRouterUnavailable(apiError, apiMessage)}${capacityHint}`; + const userMessage = `${providerDisplayName} API request failed (HTTP ${String(response.status)})${modelId ? ` for ${modelId}` : ""}: ${describeFreeTierRestriction(apiMessage) ?? describeRouterUnavailable(apiError, apiMessage)}${capacityHint}`; const requestMessage = `${providerDisplayName} API request failed (${String(response.status)})${modelHint}${sizeHint}${capacityHint}: ${apiMessage}`; return new OpenCodeRequestError(requestMessage, userMessage); } +/** + * Replace the raw gateway detail with an actionable hint when the request was + * rejected because the model is on OpenCode's free tier, which since Sep 17, + * 2026 is restricted to OpenCode's own clients (issues #235/#240 — policy, not + * a bug; bypassing it would violate OpenCode's terms). Returns undefined for + * any other error so the router/limit hints keep handling their cases. + */ +export function describeFreeTierRestriction(apiMessage: string): string | undefined { + if (!/free tier can only be used from within opencode/i.test(apiMessage)) { + return undefined; + } + return ( + "OpenCode restricts this free-tier model to their official app " + + "(effective Sep 17, 2026). Use a paid OpenCode Go model, or run free models " + + "in the OpenCode app/CLI." + ); +} + /** * Replace the raw gateway JSON detail with an actionable hint when the error * is the transient `Router.Unavailable` condition (no healthy backend for diff --git a/src/test/errors.test.ts b/src/test/errors.test.ts new file mode 100644 index 000000000..0d3bd2e3f --- /dev/null +++ b/src/test/errors.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { describeFreeTierRestriction, buildOpenCodeRequestError, OpenCodeRequestError } from "../errors.js"; +import { MODEL_METADATA_CACHE_KEY, MODEL_METADATA_REVISION } from "../config.js"; + +describe("describeFreeTierRestriction (issues #235/#240)", () => { + it("matches the upstream free-tier rejection message", () => { + const hint = describeFreeTierRestriction("Error from provider: OpenCode's free tier can only be used from within OpenCode"); + assert.ok(hint, "should produce a hint"); + assert.match(hint, /restricts this free-tier model/); + assert.match(hint, /paid OpenCode Go model/); + }); + + it("returns undefined for unrelated messages", () => { + assert.equal(describeFreeTierRestriction("Model is unavailable"), undefined); + assert.equal(describeFreeTierRestriction(""), undefined); + }); + + it("surfaces the hint through buildOpenCodeRequestError", () => { + const response = new Response(null, { status: 403 }); + const error = buildOpenCodeRequestError( + "OpenCode Zen", + response, + JSON.stringify({ + error: { message: "OpenCode's free tier can only be used from within OpenCode" }, + }), + "muse-spark-1.2-contributor-free", + 1234, + "", + ); + assert.ok(error instanceof OpenCodeRequestError); + assert.match(error.userMessage, /restricts this free-tier model to their official app/); + assert.match(error.message, /muse-spark-1.2-contributor-free/); + }); +}); + +describe("metadata cache key (issue #231)", () => { + it("embeds the bundled-data revision so syncing data invalidates stale caches", () => { + assert.equal(MODEL_METADATA_CACHE_KEY, `opencode.modelMetadataCache.v6.${MODEL_METADATA_REVISION}`); + }); +}); From 8a7e9cf384ca923009f542818436c7a62435f7c7 Mon Sep 17 00:00:00 2001 From: ltmoerdani Date: Wed, 23 Sep 2026 21:47:07 +0700 Subject: [PATCH 2/2] docs: issue docs #105/#106 + changelog for metadata cache rev + free-tier hint --- CHANGELOG.md | 4 ++ ...260923-issue231-metadata-cache-revision.md | 47 +++++++++++++++++ ...-20260923-issue235-240-free-tier-policy.md | 52 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 docs/issues/105-20260923-issue231-metadata-cache-revision.md create mode 100644 docs/issues/106-20260923-issue235-240-free-tier-policy.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 54da4ce1c..73a3067a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente ### Fixed +- **`[Models]` The models.dev metadata cache is now scoped to the bundled-data revision, ending stale limits across upgrades (#231).** The snapshot was persisted under a fixed globalState key, so old numbers (a 262K context for `deepseek-v4.1-flash`, which is 1M/384K upstream) survived extension updates until the 1-hour TTL refetch or a manual Refresh Models. The key now embeds the bundled revision (`opencode.modelMetadataCache.v6.`): whenever a release syncs the fallback tables, stale persisted snapshots are abandoned automatically. Refresh Models remains the manual escape hatch. Documented in `docs/issues/105-20260923-issue231-metadata-cache-revision.md`. + +- **`[Errors]` OpenCode's free-tier 403 now explains itself (#235, #240).** Since Sep 17, 2026 OpenCode restricts free-tier models to their own clients (policy, not a bug; bypassing would violate their terms). The raw gateway error is replaced with an actionable hint: use a paid OpenCode Go model or run free models in the OpenCode app/CLI. Documented in `docs/issues/106-20260923-issue235-240-free-tier-policy.md`. + - **`[Provider]` Tool-result images on `glm-5.3*` are deferred to a user message instead of 422-ing every turn (#233).** The Console Go upstream rejects `image_url` parts inside `role: "tool"` content with `422 Input should be a valid string` while accepting identical images in user messages (verified with a direct gateway repro matrix in the issue). New `requiresStringToolContent()` mapping: `mimo-*` keeps the existing drop-with-placeholder behavior (#38, unchanged), `glm-5.3*` flattens the tool message to a string and moves the images into a follow-up user message via the new pure `withDeferredToolImageMessages()` helper (vision preserved), everyone else keeps multimodal tool content unchanged. Documented in `docs/issues/103-20260923-issue233-glm-tool-image-defer.md`. - **`[Retry]` DeepSeek thinking-mode 400s self-heal when history loses the `reasoning_content` echo (#239).** When Copilot Chat compaction, history trimming, or a pre-thinking-capture turn strips prior-turn reasoning from the replayed history, DeepSeek V4.1 Flash rejected every follow-up turn with `The reasoning_content in the thinking mode must be passed back to the API` — and retries could never recover. A new recoverable-400 pattern strips the echo from assistant messages **and** turns `reasoning_effort` off (stopping the lose-echo→400 cycle), executed by the existing 400-patch retry loop; no-ops when there is nothing to strip. Documented in `docs/issues/102-20260923-issue239-reasoning-echo-self-heal.md`. diff --git a/docs/issues/105-20260923-issue231-metadata-cache-revision.md b/docs/issues/105-20260923-issue231-metadata-cache-revision.md new file mode 100644 index 000000000..592d25345 --- /dev/null +++ b/docs/issues/105-20260923-issue231-metadata-cache-revision.md @@ -0,0 +1,47 @@ +# Issue #231 — "Wrong details for Deepseek Flash 4.1": Stale Metadata Cache, Now Revision-Scoped + +**Status:** ✅ Solved — implemented + verified end-to-end +**Topic:** models / metadata / caching +**Updated:** 2026-09-23 +**Tags:** #models #metadata #caching #modelsdev +**GitHub Issue:** [ltmoerdani/opencode-copilot-chat#231](https://github.com/ltmoerdani/opencode-copilot-chat/issues/231) +**Related:** CHANGELOG [Unreleased] "Bundled offline fallback data synced to models.dev (2026-09-08 snapshot)" + +--- + +## Problem + +The picker showed `deepseek-v4.1-flash` with a 262K context window, and the reporter believed the model did not exist at all (their check against DeepSeek's own API listed only `deepseek-flash` / `deepseek-v4-pro`). The stale limit reportedly caused billing surprises on paid requests. + +## Analysis + +1. **The model is real.** `deepseek-v4.1-flash` is in the official OpenCode Go endpoint table () and in the live models.dev registry. The reporter's check hit `api.deepseek.com`, which is DeepSeek's own API catalog — a different catalog from the models the OpenCode Go gateway hosts. +2. **models.dev is currently correct:** `deepseek-v4.1-flash` → context 1,000,000 / output 384,000 (verified live 2026-09-23). +3. **The 262K number was stale persisted data.** The models.dev snapshot is cached in globalState under a **fixed** key (`opencode.modelMetadataCache.v5`) with a 1-hour TTL — but the key itself never changed when a release updated its bundled fallback tables, so old snapshots survived extension upgrades (fallback only engages when the refetch fails, and the stale persisted copy kept shadowing fresh data until TTL refetch happened to succeed). + +## Fix — cache key scoped to the bundled-data revision + +`MODEL_METADATA_CACHE_KEY` is now derived from the bundled snapshot revision: + +```text +opencode.modelMetadataCache.v5 → opencode.modelMetadataCache.v6. +``` + +Whenever a release syncs the bundled fallback tables (new revision), the key changes and every stale persisted snapshot is abandoned automatically — no user action, no TTL race. **Refresh Models** stays as the manual escape hatch (it already clears both caches via `clearOpenCodeModelMetadataCache()` + `fetcher.invalidate()`). + +## Files Changed + +| File | Change | +| ------------------------- | ----------------------------------------------------------------------------------- | +| `src/config.ts` | `MODEL_METADATA_CACHE_KEY` → `v6.`, with rationale comment | +| `src/test/errors.test.ts` | Cache-key/revision binding test | + +## Verification + +- `npm run lint` (full 7-check gate) pass; 484/484 unit tests pass. +- Live models.dev check (2026-09-23): `deepseek-v4.1-flash` = 1M/384K on both `opencode-go` and `opencode` provider entries. +- Manual: `Refresh Models` on an install that showed 262K → picker shows 1M/384K. + +--- + +Detected 2026-09-09 | Reported by @nickchomey | Fixed 2026-09-23 diff --git a/docs/issues/106-20260923-issue235-240-free-tier-policy.md b/docs/issues/106-20260923-issue235-240-free-tier-policy.md new file mode 100644 index 000000000..e17aade05 --- /dev/null +++ b/docs/issues/106-20260923-issue235-240-free-tier-policy.md @@ -0,0 +1,52 @@ +# Issues #235 & #240 — "Free tier can only be used from within OpenCode": Upstream Policy, Actionable Error Added + +**Status:** ✅ Solved (as far as the extension can act — upstream policy, not a bug) +**Topic:** provider / policy / error UX +**Updated:** 2026-09-23 +**Tags:** #provider #zen #free-tier #policy #errors +**GitHub Issues:** [#235](https://github.com/ltmoerdani/opencode-copilot-chat/issues/235), [#240](https://github.com/ltmoerdani/opencode-copilot-chat/issues/240) +**Related:** `KNOWN_UNAVAILABLE_MODEL_IDS` (doc 82, #182), feature doc [17 — data-driven model registry](../features/17-20260814-data-driven-model-registry.md) + +--- + +## Problem + +Free Zen models (e.g. `muse-spark-1.2-contributor-free`) fail on every request with: + +```text +OpenCode Zen API request failed (403) ... OpenCode's free tier can only be +used from within OpenCode +``` + +Reported independently in #235 and #240; #235's comment thread gathered the timeline and even spotted third-party extensions that kept free models working by spoofing OpenCode's client identity. + +## Root Cause — upstream policy, not a bug + +Timeline (from OpenCode's own statements in anomalyco/opencode#49580): + +- **Sep 6, 2026** — all requests must carry `x-opencode-session` (we shipped this in 0.7.5). +- **Sep 17, 2026** — free-tier models are restricted to OpenCode's own clients. Their words: _"Our free tier is only meant to be used in opencode"_, part of anti-abuse work. + +Paid models (OpenCode Go) are **not** affected. Any client that still serves free models is masquerading as the official client, which violates OpenCode's terms and has been shut down repeatedly. Bypassing this in our extension is therefore off the table by design. + +## What the Extension Does + +1. **Actionable error** (`describeFreeTierRestriction()` in `src/errors.ts`): when the gateway rejects a request with the free-tier message, the user now sees _"OpenCode restricts this free-tier model to their official app (effective Sep 17, 2026). Use a paid OpenCode Go model, or run free models in the OpenCode app/CLI."_ instead of the raw gateway JSON — same pattern as the Router.Unavailable hint. +2. **No bypass**: deliberately not implementing client-identity spoofing or fake session headers; that is a TOS violation and would put users' accounts at risk. +3. Model list keeps coming from the gateway, so if OpenCode ever opens the free tier to third-party clients again, it is picked up automatically. + +## Files Changed + +| File | Change | +| ------------------------- | -------------------------------------------------------------------------------------------- | +| `src/errors.ts` | `describeFreeTierRestriction()` + wiring into `buildOpenCodeRequestError` | +| `src/test/errors.test.ts` | Hint matching, end-to-end through `buildOpenCodeRequestError` (403), unrelated-message no-op | + +## Verification + +- `npm run lint` (full 7-check gate) pass; 484/484 unit tests pass. +- Manual: request on a free Zen model shows the actionable hint; paid Go models unaffected. + +--- + +Detected 2026-09-16 (#235) and 2026-09-22 (#240) | Reported by @gp-slick-coder, @AIlaowong | Fixed 2026-09-23