cartethyia 2.5.2 → 2.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-hfCzi-Ux.js} +1 -1
- package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolDetailPage-BO9nKg9w.js → CliToolDetailPage-DLGqYEwI.js} +1 -1
- package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
- package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-DEDyKBlC.js} +1 -1
- package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
- package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-CfuYiQ8Z.js} +1 -1
- package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
- package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-bsLuZmYH.js} +1 -1
- package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
- package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-_UWhwziJ.js} +1 -1
- package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
- package/dist/dashboard/assets/{HealthEventsModal-qomXThxX.js → HealthEventsModal-Cjfc5WFS.js} +1 -1
- package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
- package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-DDVIfZGW.js} +1 -1
- package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
- package/dist/dashboard/assets/{OverviewPage-C-ExCy1V.js → OverviewPage-B53YYMng.js} +1 -1
- package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +3 -0
- package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
- package/dist/dashboard/assets/{ProvidersPage-BS9IEpvv.js → ProvidersPage-Dx31DgIO.js} +1 -1
- package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
- package/dist/dashboard/assets/{ProxyPage-Rn_qXScb.js → ProxyPage-BpnR4kXT.js} +1 -1
- package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
- package/dist/dashboard/assets/{QuotaPage-BihkgQSq.js → QuotaPage-BpOQCnun.js} +1 -1
- package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +1 -0
- package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
- package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-D6pmQ86L.js} +1 -1
- package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
- package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-DWvKiuSl.js} +1 -1
- package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
- package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-CmkUrgdj.js} +1 -1
- package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
- package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-B47uOydr.js} +1 -1
- package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
- package/dist/dashboard/assets/{entry-Dj9AR0gY.js → entry-CkryrIea.js} +3 -3
- package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
- package/dist/dashboard/assets/{index-DCBtROoK.js → index-BydyKaDz.js} +2 -2
- package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
- package/dist/dashboard/assets/{select-DsX5QT8K.js → select-BO6h8yNv.js} +1 -1
- package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
- package/dist/dashboard/index.html +1 -1
- package/dist/dashboard/index.html.gz +0 -0
- package/dist/dashboard/precompressed-assets.json +21 -21
- package/package.json +1 -1
- package/scripts/cli/command/console-menu.ts +248 -246
- package/scripts/cli/command/tray.ts +208 -204
- package/src/config/spec.ts +26 -2
- package/src/console/backup/routes.ts +52 -1
- package/src/console/backup/service.ts +60 -4
- package/src/console/share/share-router.ts +6 -9
- package/src/observability/payload-capture.ts +366 -344
- package/src/observability/payload-store.ts +12 -2
- package/src/observability/telemetry-status.ts +28 -16
- package/src/persistence/stores/api-key-store.ts +6 -0
- package/src/persistence/stores/share-store.ts +583 -556
- package/src/persistence/stores/telemetry-store.ts +1 -1
- package/src/protocol/response/gemini.ts +18 -4
- package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
- package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
- package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
- package/src/providers/integrations/cline/cline-quota.ts +8 -11
- package/src/providers/integrations/opencode/provider.ts +532 -532
- package/src/providers/integrations/tokenharbor.ts +2 -2
- package/src/providers/integrations/trae/trae-catalog.ts +5 -3
- package/src/providers/operations/account-health-service.ts +23 -0
- package/src/providers/operations/client-versions.ts +9 -2
- package/src/providers/provider-metadata.ts +7 -1
- package/src/transport/dispatch/attempt-finalize.ts +46 -5
- package/src/transport/dispatch/proxy-request.ts +9 -0
- package/src/transport/dispatch/streaming/stream.ts +16 -2
- package/src/transport/routing/route-catalog.ts +806 -799
- package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
- package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
- package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
- package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
- package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
- package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
- package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
- package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
- package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
- package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
- package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
- package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
- package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
- package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
- package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
- package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
- package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
- package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
- package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
- package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
- package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
- package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
- package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
|
@@ -1,532 +1,532 @@
|
|
|
1
|
-
import { completeRequiredSchema } from "../../../protocol/codecs";
|
|
2
|
-
import type { ModelDefinition } from "../../provider-model-definition";
|
|
3
|
-
import type { CanonicalRequest, ToolDefinition } from "../../../protocol/canonical-model";
|
|
4
|
-
import { isRecord } from "../../../protocol/codecs";
|
|
5
|
-
import type { ProviderDispatchContext } from "../../provider-contracts";
|
|
6
|
-
import { PROVIDER_COMPATIBILITY_PROFILES } from "../../provider-metadata";
|
|
7
|
-
import { defineModel } from "../../provider-model-definition";
|
|
8
|
-
import type { ApiKeyProviderSpec } from "../../configured-provider";
|
|
9
|
-
import { resolveOpenCodeUnavailableFree } from "../../../config/providers";
|
|
10
|
-
import { buildOpenCodeHeaders } from "./fingerprint";
|
|
11
|
-
import { OPENCODE_TOOL_DEFINITIONS } from "./tool-definitions";
|
|
12
|
-
/**
|
|
13
|
-
* OpenCode's three API-key tiers, all served from `opencode.ai`:
|
|
14
|
-
*
|
|
15
|
-
* - `opencode` (OpenCode Free): unauthenticated public route — no
|
|
16
|
-
* `Authorization` header is ever sent, even if a credential were somehow
|
|
17
|
-
* configured. Serves the free-tier model set.
|
|
18
|
-
* - `opencode-zen` (OpenCode Zen): billed API-key route.
|
|
19
|
-
* - `opencode-go` (OpenCode Go): billed API-key route on the separate
|
|
20
|
-
* `/zen/go/v1` base — a bundled, higher-tier model set.
|
|
21
|
-
*
|
|
22
|
-
* Free and Zen dispatch on `/inference/v1`, NOT `/zen/v1`. The catalog is the
|
|
23
|
-
* other way round: `/zen/v1/models` publishes the listing while
|
|
24
|
-
* `/inference/v1/models` answers 404. A replay that hit `/inference/v1` directly
|
|
25
|
-
* returned 200 while the gateway kept failing, because every model row carried
|
|
26
|
-
* an explicit `/zen/v1` endpoint — which outranks the per-provider wire-family
|
|
27
|
-
* table at dispatch.
|
|
28
|
-
*
|
|
29
|
-
* Free and Zen use the OpenCode desktop-client header fingerprint
|
|
30
|
-
* (`x-opencode-*` correlation IDs, `user-agent: opencode`). Go is
|
|
31
|
-
* intentionally API-key-only.
|
|
32
|
-
*/
|
|
33
|
-
/** Dispatch base for the Free and Zen tiers. */
|
|
34
|
-
const ZEN_PATH_PREFIX = "/inference/v1";
|
|
35
|
-
const GO_PATH_PREFIX = "/zen/go/v1";
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Free-tier ids that do not carry the `-free` suffix the rest of the tier uses.
|
|
39
|
-
*
|
|
40
|
-
* `/zen/v1/models` publishes the whole catalog — 81 ids, of which the billed
|
|
41
|
-
* ones (`claude-opus-5`, `gpt-6-astra`, …) are not routable for this provider.
|
|
42
|
-
* The tier marker is normally the suffix, but this one id has none, so a
|
|
43
|
-
* suffix-only rule would drop a served model. Kept as an explicit list rather
|
|
44
|
-
* than a guess from the listing, which states no tier field at all.
|
|
45
|
-
*
|
|
46
|
-
* One listing id is deliberately not served even though it carries the suffix:
|
|
47
|
-
* `deepseek-v4-flash-free` is still advertised but answers 400 "Model is
|
|
48
|
-
* unavailable" to a real free-tier dispatch (verified with this adapter's own
|
|
49
|
-
* fingerprint headers, stream + agent tools), so it is not a free model this
|
|
50
|
-
* provider can route to and is excluded by `isFreeTierZenModel`.
|
|
51
|
-
*/
|
|
52
|
-
const KNOWN_FREE_ZEN_IDS: readonly string[] = ["big-pickle"];
|
|
53
|
-
|
|
54
|
-
/** Free-tier ids that the listing still advertises but upstream refuses. */
|
|
55
|
-
const UNAVAILABLE_FREE_ZEN_IDS: readonly string[] = [
|
|
56
|
-
"deepseek-v4-flash-free",
|
|
57
|
-
// Advertised by `/zen/v1/models` and carrying the free-tier suffix, but a
|
|
58
|
-
// real free-tier dispatch (this adapter's own fingerprint headers, agent
|
|
59
|
-
// tools) answers 400 "Endpoint is unavailable". Keeping it would re-add a row
|
|
60
|
-
// that can only fail a probe.
|
|
61
|
-
"ling-3.1-flash-free",
|
|
62
|
-
// Advertised by `/zen/v1/models` and carrying the free-tier suffix, but
|
|
63
|
-
// upstream refuses it for this adapter's free-tier dispatch, so discovery
|
|
64
|
-
// must not re-add the row.
|
|
65
|
-
"mimo-v2.5-free",
|
|
66
|
-
"space-bunny-free",
|
|
67
|
-
];
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* The unavailable list, overridable without a code change.
|
|
71
|
-
*
|
|
72
|
-
* These ids are what upstream *currently* refuses, measured 2026-10-14 — and
|
|
73
|
-
* the reference gateway takes the opposite approach entirely: it trusts
|
|
74
|
-
* metadata (`pricing.go` marks a model free when its cost is 0 and it is not
|
|
75
|
-
* deprecated) and hardcodes no ids at all. Ours is more precise while the
|
|
76
|
-
* measurements hold, and worse the moment upstream changes, because a new
|
|
77
|
-
* refusal needs a deploy.
|
|
78
|
-
*
|
|
79
|
-
* `CARTETHYIA_OPENCODE_UNAVAILABLE_FREE` lets an operator correct that
|
|
80
|
-
* immediately: a comma-separated list that *replaces* the bundled one, or the
|
|
81
|
-
* single token `none` to trust the listing completely. The bundled ids remain
|
|
82
|
-
* the default so an unconfigured install behaves exactly as it does today.
|
|
83
|
-
*/
|
|
84
|
-
function unavailableFreeZenIds(): readonly string[] {
|
|
85
|
-
const override = resolveOpenCodeUnavailableFree();
|
|
86
|
-
if (override === undefined) return UNAVAILABLE_FREE_ZEN_IDS;
|
|
87
|
-
if (override === "none") return [];
|
|
88
|
-
return override;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Zen ids served by the native System One decision endpoint, not the chat wire.
|
|
93
|
-
*
|
|
94
|
-
* These are the `kind: "systemone"` models of the Zen catalog. They carry the
|
|
95
|
-
* `-free` suffix convention (or the bare `jev-1.13`), so the free-tier filter
|
|
96
|
-
* would otherwise admit them as chat models — and a decision body sent to
|
|
97
|
-
* `/zen/v1/chat/completions` answers `500 Internal server error`, while a chat
|
|
98
|
-
* body sent to `/zen/v1/systemone` is meaningless. Classifying them here keeps
|
|
99
|
-
* them out of the chat surface and onto the native route.
|
|
100
|
-
*/
|
|
101
|
-
const SYSTEMONE_ZEN_IDS: readonly string[] = ["jev-1.13", "jev-1.13-free"];
|
|
102
|
-
|
|
103
|
-
/** Whether a Zen id is served by the System One decision endpoint. */
|
|
104
|
-
export function isSystemoneZenModel(modelId: string): boolean {
|
|
105
|
-
return SYSTEMONE_ZEN_IDS.includes(modelId);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Whether a `/zen/v1/models` id belongs to the free tier this provider serves.
|
|
110
|
-
*
|
|
111
|
-
* The listing states no tier field, so the tier is read from the id convention
|
|
112
|
-
* the provider itself uses. Two failure directions are handled explicitly: an
|
|
113
|
-
* id outside the convention that is still free (`KNOWN_FREE_ZEN_IDS`), and an
|
|
114
|
-
* id inside it that upstream no longer serves (`UNAVAILABLE_FREE_ZEN_IDS`) —
|
|
115
|
-
* without the latter, "Fetch models" would keep re-adding a row that can only
|
|
116
|
-
* fail a probe.
|
|
117
|
-
*/
|
|
118
|
-
export function isFreeTierZenModel(modelId: string): boolean {
|
|
119
|
-
if (unavailableFreeZenIds().includes(modelId)) return false;
|
|
120
|
-
return modelId.endsWith("-free") || KNOWN_FREE_ZEN_IDS.includes(modelId);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* The anonymous credential the free tier accepts.
|
|
125
|
-
*
|
|
126
|
-
* Captured from a real client dispatch and confirmed live 2026-10-14: without
|
|
127
|
-
* it a free-model request is refused with 403 `FreeTierError` even when every
|
|
128
|
-
* other field is byte-exact, and with it the same request returns 200. It is a
|
|
129
|
-
* sentinel the upstream recognizes, not a secret — but it is a credential
|
|
130
|
-
* value, so it lives here rather than in the shared fingerprint.
|
|
131
|
-
*/
|
|
132
|
-
const ANONYMOUS_ZEN_BEARER = "public";
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* The `Accept` a free-tier dispatch advertises.
|
|
136
|
-
*
|
|
137
|
-
* What OpenCode 2.x's AI SDK puts on the wire, and what the reference gateway
|
|
138
|
-
* sends (`upstream.go:1293`). The gate has only ever been seen accepting the
|
|
139
|
-
* narrower `text/event-stream`, so nothing here fixes a live failure — this is
|
|
140
|
-
* the value a real client produces, which is the only defensible choice when
|
|
141
|
-
* the gate tightens.
|
|
142
|
-
*/
|
|
143
|
-
const FREE_TIER_ACCEPT = "application/json, text/event-stream";
|
|
144
|
-
|
|
145
|
-
function opencodeDesktopHeaders(
|
|
146
|
-
context: ProviderDispatchContext,
|
|
147
|
-
request?: CanonicalRequest,
|
|
148
|
-
): Record<string, string> {
|
|
149
|
-
const freeModel = request !== undefined && isFreeTierZenModel(request.model);
|
|
150
|
-
return {
|
|
151
|
-
...buildOpenCodeHeaders(
|
|
152
|
-
undefined,
|
|
153
|
-
context.conversation_affinity,
|
|
154
|
-
context.request_headers?.["x-opencode-project"],
|
|
155
|
-
),
|
|
156
|
-
// The gate reads the anonymous sentinel: a free dispatch with no
|
|
157
|
-
// Authorization is refused regardless of headers, tools, or session shape.
|
|
158
|
-
...(freeModel ? { authorization: `Bearer ${ANONYMOUS_ZEN_BEARER}` } : {}),
|
|
159
|
-
// The free-tier gate refuses non-streaming dispatches with 403
|
|
160
|
-
// FreeTierError, so free models always advertise SSE regardless of the
|
|
161
|
-
// client's own Accept; billed models keep the JSON default. The value is
|
|
162
|
-
// what OpenCode 2.x's AI SDK sends, not the narrower
|
|
163
|
-
// `text/event-stream` we used to claim — the gate always accepted the
|
|
164
|
-
// narrow one, so this is alignment with the real client rather than a
|
|
165
|
-
// fix for a live failure. It costs nothing: `stream: true` is forced on
|
|
166
|
-
// the body either way, so the upstream answers SSE regardless.
|
|
167
|
-
...(freeModel ? { accept: FREE_TIER_ACCEPT } : {}),
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
/**
|
|
171
|
-
* Canonical agent-tool fingerprint the OpenCode free tier requires on every
|
|
172
|
-
* dispatch, captured verbatim from a real 2.0.26 client dispatch.
|
|
173
|
-
*
|
|
174
|
-
* Upstream rejects tool-less or *partially* agent-shaped requests with
|
|
175
|
-
* `FreeTierError` (upstream 403). Verified live 2026-10-14: the full set here
|
|
176
|
-
* returns 200, and so does one with `execute` dropped, while a five-tool subset
|
|
177
|
-
* (`bash, edit, glob, grep, read`) — and any smaller set — still returns 403.
|
|
178
|
-
* The gate reads the tool set, not any single name, so backfilling "the core
|
|
179
|
-
* tools" is not enough: backfill the whole set. They are plain
|
|
180
|
-
* `ToolDefinition`s — every wire codec serializes them, no per-wire patching.
|
|
181
|
-
*/
|
|
182
|
-
/**
|
|
183
|
-
* Every tool the free-tier gate recognises, in the order the client declares
|
|
184
|
-
* them, with the client's own description and schema.
|
|
185
|
-
*
|
|
186
|
-
* The gate compares a recognised tool's definition, not just its name: a stub
|
|
187
|
-
* carrying the right name is refused. These are therefore verbatim captures —
|
|
188
|
-
* see `./tool-definitions` for provenance.
|
|
189
|
-
*/
|
|
190
|
-
const FREE_AGENT_TOOL_NAMES_ORDERED: readonly string[] = [
|
|
191
|
-
"shell",
|
|
192
|
-
"glob",
|
|
193
|
-
"read",
|
|
194
|
-
"grep",
|
|
195
|
-
"webfetch",
|
|
196
|
-
"websearch",
|
|
197
|
-
"write",
|
|
198
|
-
"edit",
|
|
199
|
-
"subagent",
|
|
200
|
-
"execute",
|
|
201
|
-
"patch",
|
|
202
|
-
"question",
|
|
203
|
-
"skill",
|
|
204
|
-
];
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* The agent-tool names the free tier expects, exported so tests assert against
|
|
208
|
-
* the same list the adapter sends instead of a copy that can drift.
|
|
209
|
-
*/
|
|
210
|
-
export const FREE_AGENT_TOOL_NAMES: readonly string[] = FREE_AGENT_TOOL_NAMES_ORDERED;
|
|
211
|
-
/**
|
|
212
|
-
* Fallback schema for a tool name the capture does not carry.
|
|
213
|
-
*
|
|
214
|
-
* An empty `properties` object is refused: the gate reads the schema, and a
|
|
215
|
-
* tool with no parameters at all is not agent-shaped — an otherwise-correct
|
|
216
|
-
* dispatch carrying eleven such tools returned 403 `FreeTierError`, while the
|
|
217
|
-
* same dispatch with one declared property returned 200 (verified live
|
|
218
|
-
* 2026-10-15). Every name in `FREE_AGENT_TOOL_NAMES_ORDERED` has a captured
|
|
219
|
-
* definition, so this is a safety net rather than the normal path.
|
|
220
|
-
*/
|
|
221
|
-
const FREE_AGENT_TOOL_SCHEMA = {
|
|
222
|
-
type: "object",
|
|
223
|
-
properties: {
|
|
224
|
-
input: { type: "string", description: "Input for the tool." },
|
|
225
|
-
},
|
|
226
|
-
required: [] as string[],
|
|
227
|
-
} as const;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
function ensureFreeAgentRequest(request: CanonicalRequest): CanonicalRequest {
|
|
231
|
-
const present: Record<string, true> = {};
|
|
232
|
-
for (const tool of request.tools ?? []) present[tool.name] = true;
|
|
233
|
-
const missing: ToolDefinition[] = FREE_AGENT_TOOL_NAMES_ORDERED.filter(
|
|
234
|
-
(name) => !present[name],
|
|
235
|
-
).map((name) => {
|
|
236
|
-
const definition = OPENCODE_TOOL_DEFINITIONS[name];
|
|
237
|
-
return {
|
|
238
|
-
name,
|
|
239
|
-
description: definition?.description ?? name,
|
|
240
|
-
jsonSchema: (definition?.parameters ?? FREE_AGENT_TOOL_SCHEMA) as ToolDefinition["jsonSchema"],
|
|
241
|
-
};
|
|
242
|
-
});
|
|
243
|
-
if (missing.length === 0) return request;
|
|
244
|
-
return {
|
|
245
|
-
...request,
|
|
246
|
-
tools: [...(request.tools ?? []), ...missing],
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
function completeOpenCodeToolSchemas(payload: Record<string, unknown>): void {
|
|
253
|
-
if (!Array.isArray(payload.tools)) return;
|
|
254
|
-
payload.tools = payload.tools.map((tool) => {
|
|
255
|
-
if (!isRecord(tool)) return tool;
|
|
256
|
-
const functionValue = tool.function;
|
|
257
|
-
if (isRecord(functionValue) && "parameters" in functionValue) {
|
|
258
|
-
return {
|
|
259
|
-
...tool,
|
|
260
|
-
function: { ...functionValue, parameters: completeRequiredSchema(functionValue.parameters) },
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
if ("parameters" in tool) return { ...tool, parameters: completeRequiredSchema(tool.parameters) };
|
|
264
|
-
return tool;
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* The most tool definitions one OpenCode dispatch accepts.
|
|
270
|
-
*
|
|
271
|
-
* A live dispatch carrying a whole agent catalog was refused with
|
|
272
|
-
* `400 invalid_request_error`: `Invalid value for 'tools': maximum number of
|
|
273
|
-
* items is 128`. The refusal names the array length, so the clamp counts
|
|
274
|
-
* entries, not names.
|
|
275
|
-
*/
|
|
276
|
-
const OPENCODE_MAX_TOOLS = 128;
|
|
277
|
-
|
|
278
|
-
/** The function name a serialized tool declares, on either wire shape. */
|
|
279
|
-
function wireToolName(tool: unknown): string | undefined {
|
|
280
|
-
if (!isRecord(tool)) return undefined;
|
|
281
|
-
// Responses puts the name on the tool itself; chat nests it under function.
|
|
282
|
-
if (typeof tool["name"] === "string") return tool["name"];
|
|
283
|
-
const fn = tool["function"];
|
|
284
|
-
if (isRecord(fn) && typeof fn["name"] === "string") return fn["name"];
|
|
285
|
-
const custom = tool["custom"];
|
|
286
|
-
if (isRecord(custom) && typeof custom["name"] === "string") return custom["name"];
|
|
287
|
-
return undefined;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
/**
|
|
291
|
-
* Drops tool definitions past the upstream's limit.
|
|
292
|
-
*
|
|
293
|
-
* A definition the conversation already called is kept first: dropping it
|
|
294
|
-
* leaves a tool result in the history with no matching definition, which the
|
|
295
|
-
* upstream rejects on its own. Everything else keeps its original order and
|
|
296
|
-
* fills whatever slots remain. A conversation that already called more than
|
|
297
|
-
* the limit cannot be saved by dropping anything, so it is left untouched and
|
|
298
|
-
* fails upstream with the real reason instead of a silently broken history.
|
|
299
|
-
*/
|
|
300
|
-
function clampOpenCodeTools(payload: Record<string, unknown>, request: CanonicalRequest): void {
|
|
301
|
-
const tools = payload["tools"];
|
|
302
|
-
if (!Array.isArray(tools) || tools.length <= OPENCODE_MAX_TOOLS) return;
|
|
303
|
-
// Two sets must never be trimmed away, for different reasons:
|
|
304
|
-
// - a tool the conversation already called, or its result stays in the
|
|
305
|
-
// history with no definition;
|
|
306
|
-
// - the free-tier fingerprint tool set, which the gate reads on its own. A
|
|
307
|
-
// conversation that arrives carrying its own (larger) agent catalog fills
|
|
308
|
-
// the wire first, so a clamp that keeps only called tools drops the
|
|
309
|
-
// fingerprint tools and the dispatch is refused with 403 FreeTierError —
|
|
310
|
-
// reproduced live on 2026-10-16 from a 128-tool Claude Code session routed
|
|
311
|
-
// to a free model.
|
|
312
|
-
const protectedNames = new Set<string>(FREE_AGENT_TOOL_NAMES);
|
|
313
|
-
const called = new Set<string>();
|
|
314
|
-
for (const message of request.messages) {
|
|
315
|
-
for (const part of message.content) {
|
|
316
|
-
if (part.kind === "toolCall") called.add(part.name);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
const kept: unknown[] = [];
|
|
320
|
-
const rest: unknown[] = [];
|
|
321
|
-
for (const tool of tools) {
|
|
322
|
-
const name = wireToolName(tool);
|
|
323
|
-
if (name !== undefined && (called.has(name) || protectedNames.has(name))) kept.push(tool);
|
|
324
|
-
else rest.push(tool);
|
|
325
|
-
}
|
|
326
|
-
if (kept.length > OPENCODE_MAX_TOOLS) return;
|
|
327
|
-
payload["tools"] = [...kept, ...rest.slice(0, OPENCODE_MAX_TOOLS - kept.length)];
|
|
328
|
-
}
|
|
329
|
-
function opencodeSpec(providerId: "opencode" | "opencode-zen" | "opencode-go"): ApiKeyProviderSpec {
|
|
330
|
-
// Free-tier gate facts (verified live 2026-10-14 against opencode.ai/zen/v1):
|
|
331
|
-
// a request for a free model with stream:false is ALWAYS refused with 403
|
|
332
|
-
// FreeTierError — regardless of headers, tools, or session shape — while
|
|
333
|
-
// the same body with stream:true passes. The gate keys on the model's free
|
|
334
|
-
// status, not the provider row: every tier (free, Zen, Go) carries free
|
|
335
|
-
// models, so the workarounds below apply per MODEL via isFreeTierZenModel,
|
|
336
|
-
// never per provider id. The response layer aggregates the forced SSE back
|
|
337
|
-
// to JSON for non-stream clients.
|
|
338
|
-
return {
|
|
339
|
-
provider_id: providerId,
|
|
340
|
-
endpoint_paths_by_wire_family: {
|
|
341
|
-
...PROVIDER_COMPATIBILITY_PROFILES[providerId]?.endpoint_paths_by_wire_family,
|
|
342
|
-
},
|
|
343
|
-
chatTokenLimitField: "max_completion_tokens",
|
|
344
|
-
// The OpenCode client identity fingerprint (x-opencode-* correlation ids,
|
|
345
|
-
// user-agent, request id) is the provider's own request identity: it goes
|
|
346
|
-
// on every dispatch of every tier, gated or not — billed traffic included.
|
|
347
|
-
buildExtraHeaders: opencodeDesktopHeaders,
|
|
348
|
-
prePayload: (payload, request, candidate) => {
|
|
349
|
-
completeOpenCodeToolSchemas(payload);
|
|
350
|
-
clampOpenCodeTools(payload, request);
|
|
351
|
-
if (candidate.wire_family === "responses") {
|
|
352
|
-
payload.store = false;
|
|
353
|
-
}
|
|
354
|
-
if (candidate.wire_family === "chat" && request.stream) {
|
|
355
|
-
payload.stream_options = {
|
|
356
|
-
...(typeof payload.stream_options === "object" && payload.stream_options !== null
|
|
357
|
-
? (payload.stream_options as Record<string, unknown>)
|
|
358
|
-
: {}),
|
|
359
|
-
include_usage: true,
|
|
360
|
-
};
|
|
361
|
-
}
|
|
362
|
-
// Free model: force the upstream stream flag regardless of what the
|
|
363
|
-
// caller asked for. The gate refuses stream:false on free models with
|
|
364
|
-
// 403 FreeTierError; here it only patches the wire body, never the
|
|
365
|
-
// client-visible stream contract.
|
|
366
|
-
if (isFreeTierZenModel(candidate.model_id)) payload.stream = true;
|
|
367
|
-
},
|
|
368
|
-
// OpenCode Free is a genuinely public, unauthenticated endpoint: no
|
|
369
|
-
// credential is ever forwarded, whatever the account configuration says.
|
|
370
|
-
...(providerId === "opencode" ? { credential_forwarding: "never" as const } : {}),
|
|
371
|
-
// Free models on any tier need the agent-tool fingerprint and the forced
|
|
372
|
-
// upstream stream (the gate refuses tool-less or stream:false free
|
|
373
|
-
// dispatches with 403 FreeTierError). Billed models dispatch untouched.
|
|
374
|
-
prepareRequest: (request) =>
|
|
375
|
-
isFreeTierZenModel(request.model) ? ensureFreeAgentRequest(request) : request,
|
|
376
|
-
};
|
|
377
|
-
}
|
|
378
|
-
/** OpenCode Free — unauthenticated public route. No credential is ever forwarded. */
|
|
379
|
-
export const OPENCODE_FREE_SPEC: ApiKeyProviderSpec = opencodeSpec("opencode");
|
|
380
|
-
/** OpenCode Zen — billed API-key route on `/zen/v1`. */
|
|
381
|
-
export const OPENCODE_ZEN_SPEC: ApiKeyProviderSpec = opencodeSpec("opencode-zen");
|
|
382
|
-
/** OpenCode Go — billed API-key route on the separate `/zen/go/v1` base. */
|
|
383
|
-
export const OPENCODE_GO_SPEC: ApiKeyProviderSpec = opencodeSpec("opencode-go");
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
* The free-tier subset of `/zen/v1/models`, as this provider's own discovery.
|
|
387
|
-
*
|
|
388
|
-
* The listing is the shared Zen catalog and states no tier: it returns all 81
|
|
389
|
-
* ids, most of which are billed and not routable without a credential. This
|
|
390
|
-
* provider serves the free tier only, so the listing is filtered to that tier
|
|
391
|
-
* and every surviving row is marked `freeTier`, which is what the model list
|
|
392
|
-
* groups as "Free models (auto)".
|
|
393
|
-
*
|
|
394
|
-
* The rows are the fetcher's own definitions, spread rather than rebuilt: it has
|
|
395
|
-
* already resolved limits, pricing, and the wire family from the id (a
|
|
396
|
-
* Responses-native id lands on `responses`, not pinned to chat), and
|
|
397
|
-
* re-deriving that here would discard the resolution and let the two drift.
|
|
398
|
-
*/
|
|
399
|
-
export async function discoverOpenCodeFreeModels(options: {
|
|
400
|
-
readonly baseUrl: string;
|
|
401
|
-
readonly fetcher?: typeof fetch;
|
|
402
|
-
}): Promise<readonly ModelDefinition[] | null> {
|
|
403
|
-
const { fetchOpenAICompatibleModels } = await import("../../discovery/openai-model-discovery");
|
|
404
|
-
const listed = await fetchOpenAICompatibleModels({
|
|
405
|
-
baseUrl: options.baseUrl,
|
|
406
|
-
providerId: "opencode",
|
|
407
|
-
...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }),
|
|
408
|
-
});
|
|
409
|
-
if (listed === null) return null;
|
|
410
|
-
return listed
|
|
411
|
-
.filter((model) => isFreeTierZenModel(model.modelId))
|
|
412
|
-
.map((model) => {
|
|
413
|
-
if (!isSystemoneZenModel(model.modelId)) return { ...model, freeTier: true } as const;
|
|
414
|
-
// A System One id survives the free-tier filter (it carries the `-free`
|
|
415
|
-
// suffix) but is not a chat model: reclassify it onto the native decision
|
|
416
|
-
// endpoint so the chat surface never sees it, and carry the endpoint's
|
|
417
|
-
// own limits — the Zen listing publishes none, so this row otherwise
|
|
418
|
-
// advertised the invented chat window for a decision endpoint.
|
|
419
|
-
return {
|
|
420
|
-
...model,
|
|
421
|
-
...systemoneLimits(),
|
|
422
|
-
freeTier: true,
|
|
423
|
-
serviceKind: "systemone" as const,
|
|
424
|
-
endpointPath: `${ZEN_PATH_PREFIX}/systemone`,
|
|
425
|
-
};
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
// Limits and pricing resolve from the committed packed catalog
|
|
430
|
-
// (`discovery/catalog/`) via `providerId`; the catalog is keyed per provider,
|
|
431
|
-
// so passing it is what makes the lookup answer with OpenCode's own entry
|
|
432
|
-
// instead of a same-named model from another reseller. The catalog's shared
|
|
433
|
-
// OpenCode row understates this model's 1M context limit, so keep the corrected
|
|
434
|
-
// limit explicit for both tiers that serve it.
|
|
435
|
-
const sharedZenChat: readonly ModelDefinition[] = [
|
|
436
|
-
defineModel({ id: "big-pickle", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, vision: true, reasoning: true }),
|
|
437
|
-
defineModel({
|
|
438
|
-
id: "mimo-v2.6-flash-free",
|
|
439
|
-
providerId: "opencode",
|
|
440
|
-
wireFamily: "chat",
|
|
441
|
-
endpoint: `${ZEN_PATH_PREFIX}/chat/completions`,
|
|
442
|
-
ctx: 1_000_000,
|
|
443
|
-
out: 32_000,
|
|
444
|
-
vision: true,
|
|
445
|
-
document: true,
|
|
446
|
-
audio: true,
|
|
447
|
-
reasoning: true,
|
|
448
|
-
free: true,
|
|
449
|
-
}),
|
|
450
|
-
];
|
|
451
|
-
|
|
452
|
-
const MUSE_SPARK_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"] as const;
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Limits for a System One decision model, as a `(context, output)` pair.
|
|
456
|
-
*
|
|
457
|
-
* Not a chat window: the native route posts the caller's `{state, questions}`
|
|
458
|
-
* body untouched. Kept in one place because the discovery path reclassifies a
|
|
459
|
-
* fetched System One id onto this endpoint, and the Zen dispatch listing
|
|
460
|
-
* publishes no limits at all — without this it inherited the invented chat
|
|
461
|
-
* default instead of the row's own values.
|
|
462
|
-
*/
|
|
463
|
-
const SYSTEMONE_LIMITS = { context: 200_000, output: 8_192 } as const;
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* System One decision models served from the Zen `/systemone` endpoint.
|
|
467
|
-
*
|
|
468
|
-
* Not chat models: a `serviceKind: "systemone"` row is dispatched by the native
|
|
469
|
-
* System One route. Both the free and billed Zen tiers front the same endpoint;
|
|
470
|
-
* `jev-1.13-free` is the free-tier spelling.
|
|
471
|
-
*/
|
|
472
|
-
const systemoneZen = (id: string, free: boolean): ModelDefinition =>
|
|
473
|
-
defineModel({
|
|
474
|
-
id,
|
|
475
|
-
providerId: "opencode",
|
|
476
|
-
serviceKind: "systemone",
|
|
477
|
-
wireFamily: "chat",
|
|
478
|
-
endpoint: `${ZEN_PATH_PREFIX}/systemone`,
|
|
479
|
-
ctx: SYSTEMONE_LIMITS.context,
|
|
480
|
-
out: SYSTEMONE_LIMITS.output,
|
|
481
|
-
toolCall: false,
|
|
482
|
-
...(free ? { free: true } : {}),
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* The System One limits in `ModelDefinition`'s field names, so the discovery
|
|
487
|
-
* path can reclassify a fetched id with this row's own values instead of the
|
|
488
|
-
* chat default. `defineModel` renames them; a `ModelDefinition` does not.
|
|
489
|
-
*/
|
|
490
|
-
export function systemoneLimits(): Pick<ModelDefinition, "contextLimit" | "outputLimit"> {
|
|
491
|
-
return { contextLimit: SYSTEMONE_LIMITS.context, outputLimit: SYSTEMONE_LIMITS.output };
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
export const OPENCODE_FREE_MODELS: readonly ModelDefinition[] = [
|
|
495
|
-
...sharedZenChat,
|
|
496
|
-
// Free-tier chat models the shared Zen listing advertises with the `-free`
|
|
497
|
-
// convention. Each was dispatched through this adapter (its own fingerprint
|
|
498
|
-
// headers, agent tools) and answered, so the row is routable, not merely
|
|
499
|
-
// listed. Limits come from the models.dev snapshot under `opencode`; a row
|
|
500
|
-
// the snapshot does not file keeps an explicit limit rather than the generic
|
|
501
|
-
// default.
|
|
502
|
-
defineModel({ id: "nemotron-3-ultra-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, reasoning: true, free: true }),
|
|
503
|
-
defineModel({ id: "nemotron-3.5-lightning-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, reasoning: true, free: true }),
|
|
504
|
-
// Absent from the snapshot: keep declared limits rather than the generic default.
|
|
505
|
-
defineModel({ id: "longcat-2.5-preview-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, ctx: 1_000_000, out: 131_072, reasoning: true, free: true }),
|
|
506
|
-
systemoneZen("jev-1.13-free", true),
|
|
507
|
-
];
|
|
508
|
-
|
|
509
|
-
export const OPENCODE_ZEN_MODELS: readonly ModelDefinition[] = [
|
|
510
|
-
...sharedZenChat,
|
|
511
|
-
defineModel({ id: "nemotron-3-ultra-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
512
|
-
defineModel({ id: "muse-spark-1.2", providerId: "opencode", wireFamily: "responses", endpoint: `${ZEN_PATH_PREFIX}/responses`, vision: true, reasoning: true, reasoningEfforts: MUSE_SPARK_REASONING_EFFORTS }),
|
|
513
|
-
defineModel({ id: "muse-spark-1.3", providerId: "opencode", wireFamily: "responses", endpoint: `${ZEN_PATH_PREFIX}/responses`, vision: true, reasoning: true, reasoningEfforts: MUSE_SPARK_REASONING_EFFORTS }),
|
|
514
|
-
systemoneZen("jev-1.13", false),
|
|
515
|
-
];
|
|
516
|
-
|
|
517
|
-
export const OPENCODE_GO_MODELS: readonly ModelDefinition[] = [
|
|
518
|
-
// `muse-spark-1.2` is retired upstream; the catalog files
|
|
519
|
-
// `muse-spark-1.2-contributor` at 1048576/131072, which is what this row
|
|
520
|
-
// serves.
|
|
521
|
-
defineModel({ id: "muse-spark-1.2-contributor", providerId: "opencode-go", wireFamily: "responses", endpoint: `${GO_PATH_PREFIX}/responses`, ctx: 1048576, out: 131072, vision: true, reasoning: true }),
|
|
522
|
-
defineModel({ id: "grok-4.7", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, ctx: 500000, out: 500000, reasoning: true }),
|
|
523
|
-
defineModel({ id: "glm-5.2", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
524
|
-
defineModel({ id: "kimi-k3", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, vision: true, reasoning: true }),
|
|
525
|
-
defineModel({ id: "kimi-k2.7-code", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
526
|
-
defineModel({ id: "mimo-v2.5-pro", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
527
|
-
defineModel({ id: "qwen3.8-max", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, ctx: 1000000, out: 131072, reasoning: true }),
|
|
528
|
-
defineModel({ id: "minimax-m3", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
529
|
-
defineModel({ id: "deepseek-v4-pro", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
530
|
-
defineModel({ id: "deepseek-v4.1-flash", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, ctx: 1000000, out: 384000, reasoning: true }),
|
|
531
|
-
defineModel({ id: "hy3", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
532
|
-
];
|
|
1
|
+
import { completeRequiredSchema } from "../../../protocol/codecs";
|
|
2
|
+
import type { ModelDefinition } from "../../provider-model-definition";
|
|
3
|
+
import type { CanonicalRequest, ToolDefinition } from "../../../protocol/canonical-model";
|
|
4
|
+
import { isRecord } from "../../../protocol/codecs";
|
|
5
|
+
import type { ProviderDispatchContext } from "../../provider-contracts";
|
|
6
|
+
import { PROVIDER_COMPATIBILITY_PROFILES } from "../../provider-metadata";
|
|
7
|
+
import { defineModel } from "../../provider-model-definition";
|
|
8
|
+
import type { ApiKeyProviderSpec } from "../../configured-provider";
|
|
9
|
+
import { resolveOpenCodeUnavailableFree } from "../../../config/providers";
|
|
10
|
+
import { buildOpenCodeHeaders } from "./fingerprint";
|
|
11
|
+
import { OPENCODE_TOOL_DEFINITIONS } from "./tool-definitions";
|
|
12
|
+
/**
|
|
13
|
+
* OpenCode's three API-key tiers, all served from `opencode.ai`:
|
|
14
|
+
*
|
|
15
|
+
* - `opencode` (OpenCode Free): unauthenticated public route — no
|
|
16
|
+
* `Authorization` header is ever sent, even if a credential were somehow
|
|
17
|
+
* configured. Serves the free-tier model set.
|
|
18
|
+
* - `opencode-zen` (OpenCode Zen): billed API-key route.
|
|
19
|
+
* - `opencode-go` (OpenCode Go): billed API-key route on the separate
|
|
20
|
+
* `/zen/go/v1` base — a bundled, higher-tier model set.
|
|
21
|
+
*
|
|
22
|
+
* Free and Zen dispatch on `/inference/v1`, NOT `/zen/v1`. The catalog is the
|
|
23
|
+
* other way round: `/zen/v1/models` publishes the listing while
|
|
24
|
+
* `/inference/v1/models` answers 404. A replay that hit `/inference/v1` directly
|
|
25
|
+
* returned 200 while the gateway kept failing, because every model row carried
|
|
26
|
+
* an explicit `/zen/v1` endpoint — which outranks the per-provider wire-family
|
|
27
|
+
* table at dispatch.
|
|
28
|
+
*
|
|
29
|
+
* Free and Zen use the OpenCode desktop-client header fingerprint
|
|
30
|
+
* (`x-opencode-*` correlation IDs, `user-agent: opencode`). Go is
|
|
31
|
+
* intentionally API-key-only.
|
|
32
|
+
*/
|
|
33
|
+
/** Dispatch base for the Free and Zen tiers. */
|
|
34
|
+
const ZEN_PATH_PREFIX = "/inference/v1";
|
|
35
|
+
const GO_PATH_PREFIX = "/zen/go/v1";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Free-tier ids that do not carry the `-free` suffix the rest of the tier uses.
|
|
39
|
+
*
|
|
40
|
+
* `/zen/v1/models` publishes the whole catalog — 81 ids, of which the billed
|
|
41
|
+
* ones (`claude-opus-5`, `gpt-6-astra`, …) are not routable for this provider.
|
|
42
|
+
* The tier marker is normally the suffix, but this one id has none, so a
|
|
43
|
+
* suffix-only rule would drop a served model. Kept as an explicit list rather
|
|
44
|
+
* than a guess from the listing, which states no tier field at all.
|
|
45
|
+
*
|
|
46
|
+
* One listing id is deliberately not served even though it carries the suffix:
|
|
47
|
+
* `deepseek-v4-flash-free` is still advertised but answers 400 "Model is
|
|
48
|
+
* unavailable" to a real free-tier dispatch (verified with this adapter's own
|
|
49
|
+
* fingerprint headers, stream + agent tools), so it is not a free model this
|
|
50
|
+
* provider can route to and is excluded by `isFreeTierZenModel`.
|
|
51
|
+
*/
|
|
52
|
+
const KNOWN_FREE_ZEN_IDS: readonly string[] = ["big-pickle"];
|
|
53
|
+
|
|
54
|
+
/** Free-tier ids that the listing still advertises but upstream refuses. */
|
|
55
|
+
const UNAVAILABLE_FREE_ZEN_IDS: readonly string[] = [
|
|
56
|
+
"deepseek-v4-flash-free",
|
|
57
|
+
// Advertised by `/zen/v1/models` and carrying the free-tier suffix, but a
|
|
58
|
+
// real free-tier dispatch (this adapter's own fingerprint headers, agent
|
|
59
|
+
// tools) answers 400 "Endpoint is unavailable". Keeping it would re-add a row
|
|
60
|
+
// that can only fail a probe.
|
|
61
|
+
"ling-3.1-flash-free",
|
|
62
|
+
// Advertised by `/zen/v1/models` and carrying the free-tier suffix, but
|
|
63
|
+
// upstream refuses it for this adapter's free-tier dispatch, so discovery
|
|
64
|
+
// must not re-add the row.
|
|
65
|
+
"mimo-v2.5-free",
|
|
66
|
+
"space-bunny-free",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The unavailable list, overridable without a code change.
|
|
71
|
+
*
|
|
72
|
+
* These ids are what upstream *currently* refuses, measured 2026-10-14 — and
|
|
73
|
+
* the reference gateway takes the opposite approach entirely: it trusts
|
|
74
|
+
* metadata (`pricing.go` marks a model free when its cost is 0 and it is not
|
|
75
|
+
* deprecated) and hardcodes no ids at all. Ours is more precise while the
|
|
76
|
+
* measurements hold, and worse the moment upstream changes, because a new
|
|
77
|
+
* refusal needs a deploy.
|
|
78
|
+
*
|
|
79
|
+
* `CARTETHYIA_OPENCODE_UNAVAILABLE_FREE` lets an operator correct that
|
|
80
|
+
* immediately: a comma-separated list that *replaces* the bundled one, or the
|
|
81
|
+
* single token `none` to trust the listing completely. The bundled ids remain
|
|
82
|
+
* the default so an unconfigured install behaves exactly as it does today.
|
|
83
|
+
*/
|
|
84
|
+
function unavailableFreeZenIds(): readonly string[] {
|
|
85
|
+
const override = resolveOpenCodeUnavailableFree();
|
|
86
|
+
if (override === undefined) return UNAVAILABLE_FREE_ZEN_IDS;
|
|
87
|
+
if (override === "none") return [];
|
|
88
|
+
return override;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Zen ids served by the native System One decision endpoint, not the chat wire.
|
|
93
|
+
*
|
|
94
|
+
* These are the `kind: "systemone"` models of the Zen catalog. They carry the
|
|
95
|
+
* `-free` suffix convention (or the bare `jev-1.13`), so the free-tier filter
|
|
96
|
+
* would otherwise admit them as chat models — and a decision body sent to
|
|
97
|
+
* `/zen/v1/chat/completions` answers `500 Internal server error`, while a chat
|
|
98
|
+
* body sent to `/zen/v1/systemone` is meaningless. Classifying them here keeps
|
|
99
|
+
* them out of the chat surface and onto the native route.
|
|
100
|
+
*/
|
|
101
|
+
const SYSTEMONE_ZEN_IDS: readonly string[] = ["jev-1.13", "jev-1.13-free"];
|
|
102
|
+
|
|
103
|
+
/** Whether a Zen id is served by the System One decision endpoint. */
|
|
104
|
+
export function isSystemoneZenModel(modelId: string): boolean {
|
|
105
|
+
return SYSTEMONE_ZEN_IDS.includes(modelId);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Whether a `/zen/v1/models` id belongs to the free tier this provider serves.
|
|
110
|
+
*
|
|
111
|
+
* The listing states no tier field, so the tier is read from the id convention
|
|
112
|
+
* the provider itself uses. Two failure directions are handled explicitly: an
|
|
113
|
+
* id outside the convention that is still free (`KNOWN_FREE_ZEN_IDS`), and an
|
|
114
|
+
* id inside it that upstream no longer serves (`UNAVAILABLE_FREE_ZEN_IDS`) —
|
|
115
|
+
* without the latter, "Fetch models" would keep re-adding a row that can only
|
|
116
|
+
* fail a probe.
|
|
117
|
+
*/
|
|
118
|
+
export function isFreeTierZenModel(modelId: string): boolean {
|
|
119
|
+
if (unavailableFreeZenIds().includes(modelId)) return false;
|
|
120
|
+
return modelId.endsWith("-free") || KNOWN_FREE_ZEN_IDS.includes(modelId);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The anonymous credential the free tier accepts.
|
|
125
|
+
*
|
|
126
|
+
* Captured from a real client dispatch and confirmed live 2026-10-14: without
|
|
127
|
+
* it a free-model request is refused with 403 `FreeTierError` even when every
|
|
128
|
+
* other field is byte-exact, and with it the same request returns 200. It is a
|
|
129
|
+
* sentinel the upstream recognizes, not a secret — but it is a credential
|
|
130
|
+
* value, so it lives here rather than in the shared fingerprint.
|
|
131
|
+
*/
|
|
132
|
+
const ANONYMOUS_ZEN_BEARER = "public";
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The `Accept` a free-tier dispatch advertises.
|
|
136
|
+
*
|
|
137
|
+
* What OpenCode 2.x's AI SDK puts on the wire, and what the reference gateway
|
|
138
|
+
* sends (`upstream.go:1293`). The gate has only ever been seen accepting the
|
|
139
|
+
* narrower `text/event-stream`, so nothing here fixes a live failure — this is
|
|
140
|
+
* the value a real client produces, which is the only defensible choice when
|
|
141
|
+
* the gate tightens.
|
|
142
|
+
*/
|
|
143
|
+
const FREE_TIER_ACCEPT = "application/json, text/event-stream";
|
|
144
|
+
|
|
145
|
+
function opencodeDesktopHeaders(
|
|
146
|
+
context: ProviderDispatchContext,
|
|
147
|
+
request?: CanonicalRequest,
|
|
148
|
+
): Record<string, string> {
|
|
149
|
+
const freeModel = request !== undefined && isFreeTierZenModel(request.model);
|
|
150
|
+
return {
|
|
151
|
+
...buildOpenCodeHeaders(
|
|
152
|
+
undefined,
|
|
153
|
+
context.conversation_affinity,
|
|
154
|
+
context.request_headers?.["x-opencode-project"],
|
|
155
|
+
),
|
|
156
|
+
// The gate reads the anonymous sentinel: a free dispatch with no
|
|
157
|
+
// Authorization is refused regardless of headers, tools, or session shape.
|
|
158
|
+
...(freeModel ? { authorization: `Bearer ${ANONYMOUS_ZEN_BEARER}` } : {}),
|
|
159
|
+
// The free-tier gate refuses non-streaming dispatches with 403
|
|
160
|
+
// FreeTierError, so free models always advertise SSE regardless of the
|
|
161
|
+
// client's own Accept; billed models keep the JSON default. The value is
|
|
162
|
+
// what OpenCode 2.x's AI SDK sends, not the narrower
|
|
163
|
+
// `text/event-stream` we used to claim — the gate always accepted the
|
|
164
|
+
// narrow one, so this is alignment with the real client rather than a
|
|
165
|
+
// fix for a live failure. It costs nothing: `stream: true` is forced on
|
|
166
|
+
// the body either way, so the upstream answers SSE regardless.
|
|
167
|
+
...(freeModel ? { accept: FREE_TIER_ACCEPT } : {}),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Canonical agent-tool fingerprint the OpenCode free tier requires on every
|
|
172
|
+
* dispatch, captured verbatim from a real 2.0.26 client dispatch.
|
|
173
|
+
*
|
|
174
|
+
* Upstream rejects tool-less or *partially* agent-shaped requests with
|
|
175
|
+
* `FreeTierError` (upstream 403). Verified live 2026-10-14: the full set here
|
|
176
|
+
* returns 200, and so does one with `execute` dropped, while a five-tool subset
|
|
177
|
+
* (`bash, edit, glob, grep, read`) — and any smaller set — still returns 403.
|
|
178
|
+
* The gate reads the tool set, not any single name, so backfilling "the core
|
|
179
|
+
* tools" is not enough: backfill the whole set. They are plain
|
|
180
|
+
* `ToolDefinition`s — every wire codec serializes them, no per-wire patching.
|
|
181
|
+
*/
|
|
182
|
+
/**
|
|
183
|
+
* Every tool the free-tier gate recognises, in the order the client declares
|
|
184
|
+
* them, with the client's own description and schema.
|
|
185
|
+
*
|
|
186
|
+
* The gate compares a recognised tool's definition, not just its name: a stub
|
|
187
|
+
* carrying the right name is refused. These are therefore verbatim captures —
|
|
188
|
+
* see `./tool-definitions` for provenance.
|
|
189
|
+
*/
|
|
190
|
+
const FREE_AGENT_TOOL_NAMES_ORDERED: readonly string[] = [
|
|
191
|
+
"shell",
|
|
192
|
+
"glob",
|
|
193
|
+
"read",
|
|
194
|
+
"grep",
|
|
195
|
+
"webfetch",
|
|
196
|
+
"websearch",
|
|
197
|
+
"write",
|
|
198
|
+
"edit",
|
|
199
|
+
"subagent",
|
|
200
|
+
"execute",
|
|
201
|
+
"patch",
|
|
202
|
+
"question",
|
|
203
|
+
"skill",
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The agent-tool names the free tier expects, exported so tests assert against
|
|
208
|
+
* the same list the adapter sends instead of a copy that can drift.
|
|
209
|
+
*/
|
|
210
|
+
export const FREE_AGENT_TOOL_NAMES: readonly string[] = FREE_AGENT_TOOL_NAMES_ORDERED;
|
|
211
|
+
/**
|
|
212
|
+
* Fallback schema for a tool name the capture does not carry.
|
|
213
|
+
*
|
|
214
|
+
* An empty `properties` object is refused: the gate reads the schema, and a
|
|
215
|
+
* tool with no parameters at all is not agent-shaped — an otherwise-correct
|
|
216
|
+
* dispatch carrying eleven such tools returned 403 `FreeTierError`, while the
|
|
217
|
+
* same dispatch with one declared property returned 200 (verified live
|
|
218
|
+
* 2026-10-15). Every name in `FREE_AGENT_TOOL_NAMES_ORDERED` has a captured
|
|
219
|
+
* definition, so this is a safety net rather than the normal path.
|
|
220
|
+
*/
|
|
221
|
+
const FREE_AGENT_TOOL_SCHEMA = {
|
|
222
|
+
type: "object",
|
|
223
|
+
properties: {
|
|
224
|
+
input: { type: "string", description: "Input for the tool." },
|
|
225
|
+
},
|
|
226
|
+
required: [] as string[],
|
|
227
|
+
} as const;
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
function ensureFreeAgentRequest(request: CanonicalRequest): CanonicalRequest {
|
|
231
|
+
const present: Record<string, true> = {};
|
|
232
|
+
for (const tool of request.tools ?? []) present[tool.name] = true;
|
|
233
|
+
const missing: ToolDefinition[] = FREE_AGENT_TOOL_NAMES_ORDERED.filter(
|
|
234
|
+
(name) => !present[name],
|
|
235
|
+
).map((name) => {
|
|
236
|
+
const definition = OPENCODE_TOOL_DEFINITIONS[name];
|
|
237
|
+
return {
|
|
238
|
+
name,
|
|
239
|
+
description: definition?.description ?? name,
|
|
240
|
+
jsonSchema: (definition?.parameters ?? FREE_AGENT_TOOL_SCHEMA) as ToolDefinition["jsonSchema"],
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
if (missing.length === 0) return request;
|
|
244
|
+
return {
|
|
245
|
+
...request,
|
|
246
|
+
tools: [...(request.tools ?? []), ...missing],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
function completeOpenCodeToolSchemas(payload: Record<string, unknown>): void {
|
|
253
|
+
if (!Array.isArray(payload.tools)) return;
|
|
254
|
+
payload.tools = payload.tools.map((tool) => {
|
|
255
|
+
if (!isRecord(tool)) return tool;
|
|
256
|
+
const functionValue = tool.function;
|
|
257
|
+
if (isRecord(functionValue) && "parameters" in functionValue) {
|
|
258
|
+
return {
|
|
259
|
+
...tool,
|
|
260
|
+
function: { ...functionValue, parameters: completeRequiredSchema(functionValue.parameters) },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
if ("parameters" in tool) return { ...tool, parameters: completeRequiredSchema(tool.parameters) };
|
|
264
|
+
return tool;
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The most tool definitions one OpenCode dispatch accepts.
|
|
270
|
+
*
|
|
271
|
+
* A live dispatch carrying a whole agent catalog was refused with
|
|
272
|
+
* `400 invalid_request_error`: `Invalid value for 'tools': maximum number of
|
|
273
|
+
* items is 128`. The refusal names the array length, so the clamp counts
|
|
274
|
+
* entries, not names.
|
|
275
|
+
*/
|
|
276
|
+
const OPENCODE_MAX_TOOLS = 128;
|
|
277
|
+
|
|
278
|
+
/** The function name a serialized tool declares, on either wire shape. */
|
|
279
|
+
function wireToolName(tool: unknown): string | undefined {
|
|
280
|
+
if (!isRecord(tool)) return undefined;
|
|
281
|
+
// Responses puts the name on the tool itself; chat nests it under function.
|
|
282
|
+
if (typeof tool["name"] === "string") return tool["name"];
|
|
283
|
+
const fn = tool["function"];
|
|
284
|
+
if (isRecord(fn) && typeof fn["name"] === "string") return fn["name"];
|
|
285
|
+
const custom = tool["custom"];
|
|
286
|
+
if (isRecord(custom) && typeof custom["name"] === "string") return custom["name"];
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Drops tool definitions past the upstream's limit.
|
|
292
|
+
*
|
|
293
|
+
* A definition the conversation already called is kept first: dropping it
|
|
294
|
+
* leaves a tool result in the history with no matching definition, which the
|
|
295
|
+
* upstream rejects on its own. Everything else keeps its original order and
|
|
296
|
+
* fills whatever slots remain. A conversation that already called more than
|
|
297
|
+
* the limit cannot be saved by dropping anything, so it is left untouched and
|
|
298
|
+
* fails upstream with the real reason instead of a silently broken history.
|
|
299
|
+
*/
|
|
300
|
+
function clampOpenCodeTools(payload: Record<string, unknown>, request: CanonicalRequest): void {
|
|
301
|
+
const tools = payload["tools"];
|
|
302
|
+
if (!Array.isArray(tools) || tools.length <= OPENCODE_MAX_TOOLS) return;
|
|
303
|
+
// Two sets must never be trimmed away, for different reasons:
|
|
304
|
+
// - a tool the conversation already called, or its result stays in the
|
|
305
|
+
// history with no definition;
|
|
306
|
+
// - the free-tier fingerprint tool set, which the gate reads on its own. A
|
|
307
|
+
// conversation that arrives carrying its own (larger) agent catalog fills
|
|
308
|
+
// the wire first, so a clamp that keeps only called tools drops the
|
|
309
|
+
// fingerprint tools and the dispatch is refused with 403 FreeTierError —
|
|
310
|
+
// reproduced live on 2026-10-16 from a 128-tool Claude Code session routed
|
|
311
|
+
// to a free model.
|
|
312
|
+
const protectedNames = new Set<string>(FREE_AGENT_TOOL_NAMES);
|
|
313
|
+
const called = new Set<string>();
|
|
314
|
+
for (const message of request.messages) {
|
|
315
|
+
for (const part of message.content) {
|
|
316
|
+
if (part.kind === "toolCall") called.add(part.name);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const kept: unknown[] = [];
|
|
320
|
+
const rest: unknown[] = [];
|
|
321
|
+
for (const tool of tools) {
|
|
322
|
+
const name = wireToolName(tool);
|
|
323
|
+
if (name !== undefined && (called.has(name) || protectedNames.has(name))) kept.push(tool);
|
|
324
|
+
else rest.push(tool);
|
|
325
|
+
}
|
|
326
|
+
if (kept.length > OPENCODE_MAX_TOOLS) return;
|
|
327
|
+
payload["tools"] = [...kept, ...rest.slice(0, OPENCODE_MAX_TOOLS - kept.length)];
|
|
328
|
+
}
|
|
329
|
+
function opencodeSpec(providerId: "opencode" | "opencode-zen" | "opencode-go"): ApiKeyProviderSpec {
|
|
330
|
+
// Free-tier gate facts (verified live 2026-10-14 against opencode.ai/zen/v1):
|
|
331
|
+
// a request for a free model with stream:false is ALWAYS refused with 403
|
|
332
|
+
// FreeTierError — regardless of headers, tools, or session shape — while
|
|
333
|
+
// the same body with stream:true passes. The gate keys on the model's free
|
|
334
|
+
// status, not the provider row: every tier (free, Zen, Go) carries free
|
|
335
|
+
// models, so the workarounds below apply per MODEL via isFreeTierZenModel,
|
|
336
|
+
// never per provider id. The response layer aggregates the forced SSE back
|
|
337
|
+
// to JSON for non-stream clients.
|
|
338
|
+
return {
|
|
339
|
+
provider_id: providerId,
|
|
340
|
+
endpoint_paths_by_wire_family: {
|
|
341
|
+
...PROVIDER_COMPATIBILITY_PROFILES[providerId]?.endpoint_paths_by_wire_family,
|
|
342
|
+
},
|
|
343
|
+
chatTokenLimitField: "max_completion_tokens",
|
|
344
|
+
// The OpenCode client identity fingerprint (x-opencode-* correlation ids,
|
|
345
|
+
// user-agent, request id) is the provider's own request identity: it goes
|
|
346
|
+
// on every dispatch of every tier, gated or not — billed traffic included.
|
|
347
|
+
buildExtraHeaders: opencodeDesktopHeaders,
|
|
348
|
+
prePayload: (payload, request, candidate) => {
|
|
349
|
+
completeOpenCodeToolSchemas(payload);
|
|
350
|
+
clampOpenCodeTools(payload, request);
|
|
351
|
+
if (candidate.wire_family === "responses") {
|
|
352
|
+
payload.store = false;
|
|
353
|
+
}
|
|
354
|
+
if (candidate.wire_family === "chat" && request.stream) {
|
|
355
|
+
payload.stream_options = {
|
|
356
|
+
...(typeof payload.stream_options === "object" && payload.stream_options !== null
|
|
357
|
+
? (payload.stream_options as Record<string, unknown>)
|
|
358
|
+
: {}),
|
|
359
|
+
include_usage: true,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
// Free model: force the upstream stream flag regardless of what the
|
|
363
|
+
// caller asked for. The gate refuses stream:false on free models with
|
|
364
|
+
// 403 FreeTierError; here it only patches the wire body, never the
|
|
365
|
+
// client-visible stream contract.
|
|
366
|
+
if (isFreeTierZenModel(candidate.model_id)) payload.stream = true;
|
|
367
|
+
},
|
|
368
|
+
// OpenCode Free is a genuinely public, unauthenticated endpoint: no
|
|
369
|
+
// credential is ever forwarded, whatever the account configuration says.
|
|
370
|
+
...(providerId === "opencode" ? { credential_forwarding: "never" as const } : {}),
|
|
371
|
+
// Free models on any tier need the agent-tool fingerprint and the forced
|
|
372
|
+
// upstream stream (the gate refuses tool-less or stream:false free
|
|
373
|
+
// dispatches with 403 FreeTierError). Billed models dispatch untouched.
|
|
374
|
+
prepareRequest: (request) =>
|
|
375
|
+
isFreeTierZenModel(request.model) ? ensureFreeAgentRequest(request) : request,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
/** OpenCode Free — unauthenticated public route. No credential is ever forwarded. */
|
|
379
|
+
export const OPENCODE_FREE_SPEC: ApiKeyProviderSpec = opencodeSpec("opencode");
|
|
380
|
+
/** OpenCode Zen — billed API-key route on `/zen/v1`. */
|
|
381
|
+
export const OPENCODE_ZEN_SPEC: ApiKeyProviderSpec = opencodeSpec("opencode-zen");
|
|
382
|
+
/** OpenCode Go — billed API-key route on the separate `/zen/go/v1` base. */
|
|
383
|
+
export const OPENCODE_GO_SPEC: ApiKeyProviderSpec = opencodeSpec("opencode-go");
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The free-tier subset of `/zen/v1/models`, as this provider's own discovery.
|
|
387
|
+
*
|
|
388
|
+
* The listing is the shared Zen catalog and states no tier: it returns all 81
|
|
389
|
+
* ids, most of which are billed and not routable without a credential. This
|
|
390
|
+
* provider serves the free tier only, so the listing is filtered to that tier
|
|
391
|
+
* and every surviving row is marked `freeTier`, which is what the model list
|
|
392
|
+
* groups as "Free models (auto)".
|
|
393
|
+
*
|
|
394
|
+
* The rows are the fetcher's own definitions, spread rather than rebuilt: it has
|
|
395
|
+
* already resolved limits, pricing, and the wire family from the id (a
|
|
396
|
+
* Responses-native id lands on `responses`, not pinned to chat), and
|
|
397
|
+
* re-deriving that here would discard the resolution and let the two drift.
|
|
398
|
+
*/
|
|
399
|
+
export async function discoverOpenCodeFreeModels(options: {
|
|
400
|
+
readonly baseUrl: string;
|
|
401
|
+
readonly fetcher?: typeof fetch;
|
|
402
|
+
}): Promise<readonly ModelDefinition[] | null> {
|
|
403
|
+
const { fetchOpenAICompatibleModels } = await import("../../discovery/openai-model-discovery");
|
|
404
|
+
const listed = await fetchOpenAICompatibleModels({
|
|
405
|
+
baseUrl: options.baseUrl,
|
|
406
|
+
providerId: "opencode",
|
|
407
|
+
...(options.fetcher === undefined ? {} : { fetcher: options.fetcher }),
|
|
408
|
+
});
|
|
409
|
+
if (listed === null) return null;
|
|
410
|
+
return listed
|
|
411
|
+
.filter((model) => isFreeTierZenModel(model.modelId))
|
|
412
|
+
.map((model) => {
|
|
413
|
+
if (!isSystemoneZenModel(model.modelId)) return { ...model, freeTier: true } as const;
|
|
414
|
+
// A System One id survives the free-tier filter (it carries the `-free`
|
|
415
|
+
// suffix) but is not a chat model: reclassify it onto the native decision
|
|
416
|
+
// endpoint so the chat surface never sees it, and carry the endpoint's
|
|
417
|
+
// own limits — the Zen listing publishes none, so this row otherwise
|
|
418
|
+
// advertised the invented chat window for a decision endpoint.
|
|
419
|
+
return {
|
|
420
|
+
...model,
|
|
421
|
+
...systemoneLimits(),
|
|
422
|
+
freeTier: true,
|
|
423
|
+
serviceKind: "systemone" as const,
|
|
424
|
+
endpointPath: `${ZEN_PATH_PREFIX}/systemone`,
|
|
425
|
+
};
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// Limits and pricing resolve from the committed packed catalog
|
|
430
|
+
// (`discovery/catalog/`) via `providerId`; the catalog is keyed per provider,
|
|
431
|
+
// so passing it is what makes the lookup answer with OpenCode's own entry
|
|
432
|
+
// instead of a same-named model from another reseller. The catalog's shared
|
|
433
|
+
// OpenCode row understates this model's 1M context limit, so keep the corrected
|
|
434
|
+
// limit explicit for both tiers that serve it.
|
|
435
|
+
const sharedZenChat: readonly ModelDefinition[] = [
|
|
436
|
+
defineModel({ id: "big-pickle", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, vision: true, reasoning: true }),
|
|
437
|
+
defineModel({
|
|
438
|
+
id: "mimo-v2.6-flash-free",
|
|
439
|
+
providerId: "opencode",
|
|
440
|
+
wireFamily: "chat",
|
|
441
|
+
endpoint: `${ZEN_PATH_PREFIX}/chat/completions`,
|
|
442
|
+
ctx: 1_000_000,
|
|
443
|
+
out: 32_000,
|
|
444
|
+
vision: true,
|
|
445
|
+
document: true,
|
|
446
|
+
audio: true,
|
|
447
|
+
reasoning: true,
|
|
448
|
+
free: true,
|
|
449
|
+
}),
|
|
450
|
+
];
|
|
451
|
+
|
|
452
|
+
const MUSE_SPARK_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh"] as const;
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Limits for a System One decision model, as a `(context, output)` pair.
|
|
456
|
+
*
|
|
457
|
+
* Not a chat window: the native route posts the caller's `{state, questions}`
|
|
458
|
+
* body untouched. Kept in one place because the discovery path reclassifies a
|
|
459
|
+
* fetched System One id onto this endpoint, and the Zen dispatch listing
|
|
460
|
+
* publishes no limits at all — without this it inherited the invented chat
|
|
461
|
+
* default instead of the row's own values.
|
|
462
|
+
*/
|
|
463
|
+
const SYSTEMONE_LIMITS = { context: 200_000, output: 8_192 } as const;
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* System One decision models served from the Zen `/systemone` endpoint.
|
|
467
|
+
*
|
|
468
|
+
* Not chat models: a `serviceKind: "systemone"` row is dispatched by the native
|
|
469
|
+
* System One route. Both the free and billed Zen tiers front the same endpoint;
|
|
470
|
+
* `jev-1.13-free` is the free-tier spelling.
|
|
471
|
+
*/
|
|
472
|
+
const systemoneZen = (id: string, free: boolean): ModelDefinition =>
|
|
473
|
+
defineModel({
|
|
474
|
+
id,
|
|
475
|
+
providerId: "opencode",
|
|
476
|
+
serviceKind: "systemone",
|
|
477
|
+
wireFamily: "chat",
|
|
478
|
+
endpoint: `${ZEN_PATH_PREFIX}/systemone`,
|
|
479
|
+
ctx: SYSTEMONE_LIMITS.context,
|
|
480
|
+
out: SYSTEMONE_LIMITS.output,
|
|
481
|
+
toolCall: false,
|
|
482
|
+
...(free ? { free: true } : {}),
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* The System One limits in `ModelDefinition`'s field names, so the discovery
|
|
487
|
+
* path can reclassify a fetched id with this row's own values instead of the
|
|
488
|
+
* chat default. `defineModel` renames them; a `ModelDefinition` does not.
|
|
489
|
+
*/
|
|
490
|
+
export function systemoneLimits(): Pick<ModelDefinition, "contextLimit" | "outputLimit"> {
|
|
491
|
+
return { contextLimit: SYSTEMONE_LIMITS.context, outputLimit: SYSTEMONE_LIMITS.output };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export const OPENCODE_FREE_MODELS: readonly ModelDefinition[] = [
|
|
495
|
+
...sharedZenChat,
|
|
496
|
+
// Free-tier chat models the shared Zen listing advertises with the `-free`
|
|
497
|
+
// convention. Each was dispatched through this adapter (its own fingerprint
|
|
498
|
+
// headers, agent tools) and answered, so the row is routable, not merely
|
|
499
|
+
// listed. Limits come from the models.dev snapshot under `opencode`; a row
|
|
500
|
+
// the snapshot does not file keeps an explicit limit rather than the generic
|
|
501
|
+
// default.
|
|
502
|
+
defineModel({ id: "nemotron-3-ultra-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, reasoning: true, free: true }),
|
|
503
|
+
defineModel({ id: "nemotron-3.5-lightning-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, reasoning: true, free: true }),
|
|
504
|
+
// Absent from the snapshot: keep declared limits rather than the generic default.
|
|
505
|
+
defineModel({ id: "longcat-2.5-preview-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, ctx: 1_000_000, out: 131_072, reasoning: true, free: true }),
|
|
506
|
+
systemoneZen("jev-1.13-free", true),
|
|
507
|
+
];
|
|
508
|
+
|
|
509
|
+
export const OPENCODE_ZEN_MODELS: readonly ModelDefinition[] = [
|
|
510
|
+
...sharedZenChat,
|
|
511
|
+
defineModel({ id: "nemotron-3-ultra-free", providerId: "opencode", wireFamily: "chat", endpoint: `${ZEN_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
512
|
+
defineModel({ id: "muse-spark-1.2", providerId: "opencode", wireFamily: "responses", endpoint: `${ZEN_PATH_PREFIX}/responses`, vision: true, reasoning: true, reasoningEfforts: MUSE_SPARK_REASONING_EFFORTS }),
|
|
513
|
+
defineModel({ id: "muse-spark-1.3", providerId: "opencode", wireFamily: "responses", endpoint: `${ZEN_PATH_PREFIX}/responses`, vision: true, reasoning: true, reasoningEfforts: MUSE_SPARK_REASONING_EFFORTS }),
|
|
514
|
+
systemoneZen("jev-1.13", false),
|
|
515
|
+
];
|
|
516
|
+
|
|
517
|
+
export const OPENCODE_GO_MODELS: readonly ModelDefinition[] = [
|
|
518
|
+
// `muse-spark-1.2` is retired upstream; the catalog files
|
|
519
|
+
// `muse-spark-1.2-contributor` at 1048576/131072, which is what this row
|
|
520
|
+
// serves.
|
|
521
|
+
defineModel({ id: "muse-spark-1.2-contributor", providerId: "opencode-go", wireFamily: "responses", endpoint: `${GO_PATH_PREFIX}/responses`, ctx: 1048576, out: 131072, vision: true, reasoning: true }),
|
|
522
|
+
defineModel({ id: "grok-4.7", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, ctx: 500000, out: 500000, reasoning: true }),
|
|
523
|
+
defineModel({ id: "glm-5.2", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
524
|
+
defineModel({ id: "kimi-k3", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, vision: true, reasoning: true }),
|
|
525
|
+
defineModel({ id: "kimi-k2.7-code", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
526
|
+
defineModel({ id: "mimo-v2.5-pro", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
527
|
+
defineModel({ id: "qwen3.8-max", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, ctx: 1000000, out: 131072, reasoning: true }),
|
|
528
|
+
defineModel({ id: "minimax-m3", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
529
|
+
defineModel({ id: "deepseek-v4-pro", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
530
|
+
defineModel({ id: "deepseek-v4.1-flash", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, ctx: 1000000, out: 384000, reasoning: true, vision: true }),
|
|
531
|
+
defineModel({ id: "hy3", providerId: "opencode-go", wireFamily: "chat", endpoint: `${GO_PATH_PREFIX}/chat/completions`, reasoning: true }),
|
|
532
|
+
];
|