From fdae37d6f2bce16b91ca0e0520fdc5259ff19d6f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 00:12:55 -0700 Subject: [PATCH 1/4] refactor(provider): collapse duplicate Anthropic session adapters --- src/config/index.ts | 6 +- src/config/inference-sources.test.ts | 2 +- .../anthropic-cache-breakpoint.test.ts | 4 +- src/provider/anthropic-cache-breakpoint.ts | 6 +- .../anthropic-session-adapter.test.ts | 71 +++++++++++++++++++ ...dapter.ts => anthropic-session-adapter.ts} | 17 ++++- src/provider/inference-dependencies.ts | 16 ++--- .../opencode-go-anthropic-adapter.test.ts | 44 ------------ src/provider/zen-anthropic-adapter.ts | 32 --------- 9 files changed, 106 insertions(+), 92 deletions(-) create mode 100644 src/provider/anthropic-session-adapter.test.ts rename src/provider/{opencode-go-anthropic-adapter.ts => anthropic-session-adapter.ts} (71%) delete mode 100644 src/provider/opencode-go-anthropic-adapter.test.ts delete mode 100644 src/provider/zen-anthropic-adapter.ts diff --git a/src/config/index.ts b/src/config/index.ts index 49927e3be..938f05102 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -58,14 +58,16 @@ import { import { BIFROST_PROVIDER } from "../provider/bifrost-adapter.js"; import { isOllamaProviderId, ollamaOpenAIBaseURL } from "../provider/ollama.js"; import { selectableGoModelIds } from "../provider/opencode-go-models.js"; -import { ZEN_MESSAGES_PROVIDER } from "../provider/zen-anthropic-adapter.js"; +import { + OPENCODE_GO_MESSAGES_PROVIDER, + ZEN_MESSAGES_PROVIDER, +} from "../provider/anthropic-session-adapter.js"; import { selectableZenModelIds } from "../provider/zen-models.js"; import { OPENAI_RESPONSES_PROVIDER, OPENAI_SESSION_ID_OPTION, } from "../provider/openai-responses.js"; import { OPENCODE_SESSION_ID_OPTION } from "../provider/opencode-session.js"; -import { OPENCODE_GO_MESSAGES_PROVIDER } from "../provider/opencode-go-anthropic-adapter.js"; import { OPENCODE_GO_BASE_URL, OPENCODE_GO_PROVIDER_ID, diff --git a/src/config/inference-sources.test.ts b/src/config/inference-sources.test.ts index 6001e84a6..a7f0128c7 100644 --- a/src/config/inference-sources.test.ts +++ b/src/config/inference-sources.test.ts @@ -15,7 +15,7 @@ import { createOpenAICompatibleAdapter } from "../provider/openai-compatible-ada import { createInferenceDependencies } from "../provider/inference-dependencies.js"; import { clearSourceCredentials } from "./source-credentials.js"; import { OPENAI_RESPONSES_PROVIDER } from "../provider/openai-responses.js"; -import { ZEN_MESSAGES_PROVIDER } from "../provider/zen-anthropic-adapter.js"; +import { ZEN_MESSAGES_PROVIDER } from "../provider/anthropic-session-adapter.js"; import { firstClassProviderById } from "../../packages/first-class-providers/src/index.js"; import { ZEN_DEFAULT_BASE_URL, diff --git a/src/provider/anthropic-cache-breakpoint.test.ts b/src/provider/anthropic-cache-breakpoint.test.ts index b56e7186d..6e9c9f75c 100644 --- a/src/provider/anthropic-cache-breakpoint.test.ts +++ b/src/provider/anthropic-cache-breakpoint.test.ts @@ -7,8 +7,8 @@ import type { LastCycleSource, } from "@intx/types/runtime"; import { withAnthropicCacheBreakpoint } from "./anthropic-cache-breakpoint.js"; -import { createOpenCodeGoAnthropicAdapter } from "./opencode-go-anthropic-adapter.js"; -import { createZenAnthropicAdapter } from "./zen-anthropic-adapter.js"; +import { createOpenCodeGoAnthropicAdapter } from "./anthropic-session-adapter.js"; +import { createZenAnthropicAdapter } from "./anthropic-session-adapter.js"; function sourceFor(provider: string): LastCycleSource { return { sourceId: `test-${provider}`, provider, model: "test-model" }; diff --git a/src/provider/anthropic-cache-breakpoint.ts b/src/provider/anthropic-cache-breakpoint.ts index 5a3b55fd2..34417b84d 100644 --- a/src/provider/anthropic-cache-breakpoint.ts +++ b/src/provider/anthropic-cache-breakpoint.ts @@ -4,8 +4,10 @@ import type { ExtendedInferenceOptions, ProviderAdapter, } from "@intx/inference"; -import { OPENCODE_GO_MESSAGES_PROVIDER } from "./opencode-go-anthropic-adapter.js"; -import { ZEN_MESSAGES_PROVIDER } from "./zen-anthropic-adapter.js"; +import { + OPENCODE_GO_MESSAGES_PROVIDER, + ZEN_MESSAGES_PROVIDER, +} from "./anthropic-session-adapter.js"; const ANTHROPIC_MESSAGES_PROVIDERS: ReadonlySet = new Set([ "anthropic", diff --git a/src/provider/anthropic-session-adapter.test.ts b/src/provider/anthropic-session-adapter.test.ts new file mode 100644 index 000000000..c1af1afec --- /dev/null +++ b/src/provider/anthropic-session-adapter.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import type { InferenceOptions } from "@intx/types/runtime"; +import { + createOpenCodeGoAnthropicAdapter, + createSessionHeaderAnthropicAdapter, + createZenAnthropicAdapter, +} from "./anthropic-session-adapter.js"; + +const messages = [ + { + role: "user" as const, + timestamp: 0, + content: [{ type: "text" as const, text: "hi" }], + }, +]; + +const factories = { + "opencode-go": createOpenCodeGoAnthropicAdapter, + zen: createZenAnthropicAdapter, +} as const; + +describe("session header Anthropic adapter", () => { + for (const [name, factory] of Object.entries(factories)) { + test(`${name}: delegates Anthropic request construction and adds only the session header`, () => { + const request = factory( + { sourceId: name, provider: `${name}-messages`, model: "minimax-m3" }, + ).buildRequest(messages, "minimax-m3", { + providerOptions: { opencodeSessionId: "sess-1" }, + } as InferenceOptions); + expect(request.headers["x-opencode-session"]).toBe("sess-1"); + expect(JSON.parse(request.body)).not.toHaveProperty("opencodeSessionId"); + expect(JSON.parse(request.body)).toMatchObject({ + model: "minimax-m3", + stream: true, + }); + }); + + test(`${name}: omits the session header when no session is supplied`, () => { + const request = factory({ + sourceId: name, + provider: `${name}-messages`, + model: "minimax-m3", + }).buildRequest(messages, "minimax-m3", {}); + expect(request.headers["x-opencode-session"]).toBeUndefined(); + }); + } + + test("named factories match the shared wrapper byte-for-byte", () => { + const source = { + sourceId: "shared", + provider: "zen-messages", + model: "minimax-m3", + }; + const options = { + providerOptions: { opencodeSessionId: "sess-1" }, + } as InferenceOptions; + const shared = createSessionHeaderAnthropicAdapter(source).buildRequest( + messages, + "minimax-m3", + options, + ); + for (const factory of Object.values(factories)) { + const request = factory(source).buildRequest( + messages, + "minimax-m3", + options, + ); + expect(request).toEqual(shared); + } + }); +}); diff --git a/src/provider/opencode-go-anthropic-adapter.ts b/src/provider/anthropic-session-adapter.ts similarity index 71% rename from src/provider/opencode-go-anthropic-adapter.ts rename to src/provider/anthropic-session-adapter.ts index f505e0956..2549be01b 100644 --- a/src/provider/opencode-go-anthropic-adapter.ts +++ b/src/provider/anthropic-session-adapter.ts @@ -5,11 +5,12 @@ import { optionString, } from "./opencode-session.js"; +export const ZEN_MESSAGES_PROVIDER = "zen-messages"; export const OPENCODE_GO_MESSAGES_PROVIDER = "opencode-go-messages"; type AdapterSource = Parameters[0]; -export function createOpenCodeGoAnthropicAdapter( +export function createSessionHeaderAnthropicAdapter( source: AdapterSource, quirks?: unknown, ): ProviderAdapter { @@ -30,3 +31,17 @@ export function createOpenCodeGoAnthropicAdapter( }; return { ...base, buildRequest }; } + +export function createZenAnthropicAdapter( + source: AdapterSource, + quirks?: unknown, +): ProviderAdapter { + return createSessionHeaderAnthropicAdapter(source, quirks); +} + +export function createOpenCodeGoAnthropicAdapter( + source: AdapterSource, + quirks?: unknown, +): ProviderAdapter { + return createSessionHeaderAnthropicAdapter(source, quirks); +} diff --git a/src/provider/inference-dependencies.ts b/src/provider/inference-dependencies.ts index 95cd82380..9a28ad486 100644 --- a/src/provider/inference-dependencies.ts +++ b/src/provider/inference-dependencies.ts @@ -10,8 +10,7 @@ import * as codexResponses from "./codex-responses.js"; import * as grokResponses from "./grok-responses.js"; import * as bifrostAdapter from "./bifrost-adapter.js"; import * as openaiResponses from "./openai-responses.js"; -import * as opencodeGoAnthropic from "./opencode-go-anthropic-adapter.js"; -import * as zenAnthropic from "./zen-anthropic-adapter.js"; +import * as anthropicSession from "./anthropic-session-adapter.js"; import { CODEX_RESPONSES_PROVIDER, withCodexContentTypeRepair, @@ -23,8 +22,10 @@ import { isPollOnlyPendingBatch } from "../subagent/poll-exempt.js"; import { OPENCODE_GO_PROVIDER_ID } from "../../packages/opencode-go/src/index.js"; import { BIFROST_PROVIDER } from "./bifrost-adapter.js"; import { OPENAI_RESPONSES_PROVIDER } from "./openai-responses.js"; -import { OPENCODE_GO_MESSAGES_PROVIDER } from "./opencode-go-anthropic-adapter.js"; -import { ZEN_MESSAGES_PROVIDER } from "./zen-anthropic-adapter.js"; +import { + OPENCODE_GO_MESSAGES_PROVIDER, + ZEN_MESSAGES_PROVIDER, +} from "./anthropic-session-adapter.js"; // Corbits Code ships first-party adapters on top of the built-in provider set: // openai-compatible and OpenCode Go chat-completions adapters, Codex/Grok @@ -64,12 +65,12 @@ const manifest: AdapterManifest = [ }, { provider: OPENCODE_GO_MESSAGES_PROVIDER, - specifier: "opencode-go-anthropic-adapter", + specifier: "anthropic-session-adapter", export: "createOpenCodeGoAnthropicAdapter", }, { provider: ZEN_MESSAGES_PROVIDER, - specifier: "zen-anthropic-adapter", + specifier: "anthropic-session-adapter", export: "createZenAnthropicAdapter", }, ]; @@ -81,8 +82,7 @@ const localModules: Record = { "grok-responses": grokResponses, "bifrost-adapter": bifrostAdapter, "openai-responses": openaiResponses, - "opencode-go-anthropic-adapter": opencodeGoAnthropic, - "zen-anthropic-adapter": zenAnthropic, + "anthropic-session-adapter": anthropicSession, }; let cached: Promise | undefined; diff --git a/src/provider/opencode-go-anthropic-adapter.test.ts b/src/provider/opencode-go-anthropic-adapter.test.ts deleted file mode 100644 index faff8ccfc..000000000 --- a/src/provider/opencode-go-anthropic-adapter.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { InferenceOptions } from "@intx/types/runtime"; -import { createOpenCodeGoAnthropicAdapter } from "./opencode-go-anthropic-adapter.js"; - -const source = { - sourceId: "opencode-go", - provider: "opencode-go-messages", - model: "minimax-m3", -}; - -const messages = [ - { - role: "user" as const, - timestamp: 0, - content: [{ type: "text" as const, text: "hi" }], - }, -]; - -describe("OpenCode Go Messages adapter", () => { - test("delegates Anthropic request construction and adds only the session header", () => { - const request = createOpenCodeGoAnthropicAdapter(source).buildRequest( - messages, - "minimax-m3", - { - providerOptions: { opencodeSessionId: "sess-1" }, - } as InferenceOptions, - ); - expect(request.headers["x-opencode-session"]).toBe("sess-1"); - expect(JSON.parse(request.body)).not.toHaveProperty("opencodeSessionId"); - expect(JSON.parse(request.body)).toMatchObject({ - model: "minimax-m3", - stream: true, - }); - }); - - test("omits the session header when no session is supplied", () => { - const request = createOpenCodeGoAnthropicAdapter(source).buildRequest( - messages, - "minimax-m3", - {}, - ); - expect(request.headers["x-opencode-session"]).toBeUndefined(); - }); -}); diff --git a/src/provider/zen-anthropic-adapter.ts b/src/provider/zen-anthropic-adapter.ts deleted file mode 100644 index 5262a939e..000000000 --- a/src/provider/zen-anthropic-adapter.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { type BuiltRequest, type ProviderAdapter } from "@intx/inference"; -import { createAnthropicAdapter } from "@intx/inference/providers"; -import { - OPENCODE_SESSION_ID_OPTION, - optionString, -} from "./opencode-session.js"; - -export const ZEN_MESSAGES_PROVIDER = "zen-messages"; - -type AdapterSource = Parameters[0]; - -export function createZenAnthropicAdapter( - source: AdapterSource, - quirks?: unknown, -): ProviderAdapter { - const base = createAnthropicAdapter(source, quirks); - const buildRequest: ProviderAdapter["buildRequest"] = ( - messages, - model, - options, - ) => { - const built = base.buildRequest(messages, model, options); - const sessionId = optionString(options, OPENCODE_SESSION_ID_OPTION); - if (sessionId === undefined) return built; - const headers: BuiltRequest["headers"] = { - ...built.headers, - "x-opencode-session": sessionId, - }; - return { ...built, headers }; - }; - return { ...base, buildRequest }; -} From bcceaa5f3a419d2e826a6c56644e473a6845e0fd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 00:12:58 -0700 Subject: [PATCH 2/4] refactor(provider): share bounded model catalog factory --- src/provider/bounded-model-catalog.ts | 208 ++++++++++++++++++++++++++ src/provider/opencode-go-models.ts | 191 ++--------------------- src/provider/zen-models.ts | 191 ++--------------------- 3 files changed, 240 insertions(+), 350 deletions(-) create mode 100644 src/provider/bounded-model-catalog.ts diff --git a/src/provider/bounded-model-catalog.ts b/src/provider/bounded-model-catalog.ts new file mode 100644 index 000000000..2bd6790d9 --- /dev/null +++ b/src/provider/bounded-model-catalog.ts @@ -0,0 +1,208 @@ +import { type } from "arktype"; + +import { requestModelsEndpoint } from "./models-endpoint.js"; + +const CatalogModelsResponse = type({ + data: type({ id: "string" }).array(), +}); + +export type CatalogDiscoveryState = + | { readonly status: "models"; readonly models: readonly string[] } + | { readonly status: "empty" } + | { readonly status: "unavailable"; readonly message: string } + | { readonly status: "malformed"; readonly message: string }; + +export function createBoundedModelCatalog(args: { + baseURL: string; + seedIds: readonly string[]; + catalogLabel: string; + maxBytes: number; + maxModels: number; +}): { + discoverModels: (args?: { + timeoutMs?: number; + signal?: AbortSignal; + }) => Promise; + selectableModelIds: () => readonly string[]; + prefetchModels: () => Promise; + resetDiscoveryForTests: () => void; +} { + const { baseURL, seedIds, catalogLabel, maxBytes, maxModels } = args; + + let inflight: Promise | undefined; + let snapshot: readonly string[] | undefined; + + function declaredCatalogBytes(response: Response): number | undefined { + const raw = response.headers.get("content-length"); + if (raw === null || raw.length === 0) return undefined; + const n = Number(raw); + if (!Number.isFinite(n) || n < 0) return undefined; + return n; + } + + function oversizeMessage(kind: "bytes" | "models"): string { + if (kind === "bytes") { + return `${catalogLabel} catalog exceeds ${String(maxBytes)} bytes`; + } + return `${catalogLabel} catalog exceeds ${String(maxModels)} models`; + } + + async function readCatalogJson( + response: Response, + ): Promise< + | { readonly ok: true; readonly value: unknown } + | { readonly ok: false; readonly message: string } + > { + const declared = declaredCatalogBytes(response); + if (declared !== undefined && declared > maxBytes) { + await response.body?.cancel().catch(() => undefined); + return { ok: false, message: oversizeMessage("bytes") }; + } + + const body = response.body; + if (body === null) { + try { + const text = await response.text(); + if (new TextEncoder().encode(text).byteLength > maxBytes) { + return { ok: false, message: oversizeMessage("bytes") }; + } + const value: unknown = JSON.parse(text); + return { ok: true, value }; + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (value === undefined) continue; + total += value.byteLength; + if (total > maxBytes) { + await reader.cancel().catch(() => undefined); + return { ok: false, message: oversizeMessage("bytes") }; + } + chunks.push(value); + } + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + + const buffer = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + buffer.set(chunk, offset); + offset += chunk.byteLength; + } + + try { + const value: unknown = JSON.parse(new TextDecoder().decode(buffer)); + return { ok: true, value }; + } catch (error) { + return { + ok: false, + message: error instanceof Error ? error.message : String(error), + }; + } + } + + async function discoverModels(args?: { + timeoutMs?: number; + signal?: AbortSignal; + }): Promise { + let response: Response; + try { + response = await requestModelsEndpoint({ + baseURL, + ...(args?.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}), + ...(args?.signal !== undefined ? { signal: args.signal } : {}), + }); + } catch (error) { + return { + status: "unavailable", + message: error instanceof Error ? error.message : String(error), + }; + } + + if (!response.ok) { + return { + status: "unavailable", + message: `${catalogLabel} returned HTTP ${String(response.status)}`, + }; + } + + const body = await readCatalogJson(response); + if (!body.ok) { + return { status: "malformed", message: body.message }; + } + const parsed = CatalogModelsResponse(body.value); + if (parsed instanceof type.errors) { + return { status: "malformed", message: parsed.summary }; + } + if (parsed.data.length > maxModels) { + return { status: "malformed", message: oversizeMessage("models") }; + } + const models = [ + ...new Set( + parsed.data.map(({ id }) => id.trim()).filter((id) => id.length > 0), + ), + ]; + return models.length > 0 ? { status: "models", models } : { status: "empty" }; + } + + function selectableModelIds(): readonly string[] { + return snapshot ?? seedIds; + } + + async function runPrefetch(): Promise { + const state = await discoverModels(); + // Empty/unavailable/malformed leave a successful snapshot in place: + // stale-but-live beats empty, and a cold failure still falls through + // to the packaged seed. + if (state.status === "models") { + snapshot = state.models; + } + return selectableModelIds(); + } + + function prefetchModels(): Promise { + if (inflight !== undefined) return inflight; + + const pending = runPrefetch(); + inflight = pending; + // Clear inflight on settle so a later prefetch can recover instead of + // replaying the first settlement forever. .then(cleanup, cleanup) + // instead of .finally() avoids an abandoned promise chain whose + // pass-through rejection could become an unhandled rejection — callers + // await the original pending promise. + const cleanup = (): void => { + if (inflight === pending) { + inflight = undefined; + } + }; + pending.then(cleanup, cleanup); + return pending; + } + + function resetDiscoveryForTests(): void { + inflight = undefined; + snapshot = undefined; + } + + return { + discoverModels, + selectableModelIds, + prefetchModels, + resetDiscoveryForTests, + }; +} diff --git a/src/provider/opencode-go-models.ts b/src/provider/opencode-go-models.ts index 597ee1aa6..d7cf252a3 100644 --- a/src/provider/opencode-go-models.ts +++ b/src/provider/opencode-go-models.ts @@ -1,192 +1,33 @@ -import { type } from "arktype"; - import { OPENCODE_GO_BASE_URL, OPENCODE_GO_MODEL_IDS, } from "../../packages/opencode-go/src/index.js"; -import { requestModelsEndpoint } from "./models-endpoint.js"; - -const GoModelsResponse = type({ - data: type({ id: "string" }).array(), -}); +import { + createBoundedModelCatalog, + type CatalogDiscoveryState, +} from "./bounded-model-catalog.js"; // Bound live /models so a huge or hostile catalog cannot blow process memory. export const MAX_GO_CATALOG_BYTES = 256 * 1024; export const MAX_GO_CATALOG_MODELS = 1024; -export type GoDiscoveryState = - | { readonly status: "models"; readonly models: readonly string[] } - | { readonly status: "empty" } - | { readonly status: "unavailable"; readonly message: string } - | { readonly status: "malformed"; readonly message: string }; - -let inflight: Promise | undefined; -let snapshot: readonly string[] | undefined; - -function declaredCatalogBytes(response: Response): number | undefined { - const raw = response.headers.get("content-length"); - if (raw === null || raw.length === 0) return undefined; - const n = Number(raw); - if (!Number.isFinite(n) || n < 0) return undefined; - return n; -} - -function oversizeMessage(kind: "bytes" | "models"): string { - if (kind === "bytes") { - return `OpenCode Go catalog exceeds ${String(MAX_GO_CATALOG_BYTES)} bytes`; - } - return `OpenCode Go catalog exceeds ${String(MAX_GO_CATALOG_MODELS)} models`; -} - -async function readCatalogJson( - response: Response, -): Promise< - | { readonly ok: true; readonly value: unknown } - | { readonly ok: false; readonly message: string } -> { - const declared = declaredCatalogBytes(response); - if (declared !== undefined && declared > MAX_GO_CATALOG_BYTES) { - await response.body?.cancel().catch(() => undefined); - return { ok: false, message: oversizeMessage("bytes") }; - } +export type GoDiscoveryState = CatalogDiscoveryState; - const body = response.body; - if (body === null) { - try { - const text = await response.text(); - if (new TextEncoder().encode(text).byteLength > MAX_GO_CATALOG_BYTES) { - return { ok: false, message: oversizeMessage("bytes") }; - } - const value: unknown = JSON.parse(text); - return { ok: true, value }; - } catch (error) { - return { - ok: false, - message: error instanceof Error ? error.message : String(error), - }; - } - } - - const reader = body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (value === undefined) continue; - total += value.byteLength; - if (total > MAX_GO_CATALOG_BYTES) { - await reader.cancel().catch(() => undefined); - return { ok: false, message: oversizeMessage("bytes") }; - } - chunks.push(value); - } - } catch (error) { - return { - ok: false, - message: error instanceof Error ? error.message : String(error), - }; - } - - const buffer = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - buffer.set(chunk, offset); - offset += chunk.byteLength; - } - - try { - const value: unknown = JSON.parse(new TextDecoder().decode(buffer)); - return { ok: true, value }; - } catch (error) { - return { - ok: false, - message: error instanceof Error ? error.message : String(error), - }; - } -} +const catalog = createBoundedModelCatalog({ + baseURL: OPENCODE_GO_BASE_URL, + seedIds: OPENCODE_GO_MODEL_IDS, + catalogLabel: "OpenCode Go", + maxBytes: MAX_GO_CATALOG_BYTES, + maxModels: MAX_GO_CATALOG_MODELS, +}); /** Discover public OpenCode Go models without leaking transport or parsing failures. */ -export async function discoverGoModels(args?: { - timeoutMs?: number; - signal?: AbortSignal; -}): Promise { - let response: Response; - try { - response = await requestModelsEndpoint({ - baseURL: OPENCODE_GO_BASE_URL, - ...(args?.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}), - ...(args?.signal !== undefined ? { signal: args.signal } : {}), - }); - } catch (error) { - return { - status: "unavailable", - message: error instanceof Error ? error.message : String(error), - }; - } - - if (!response.ok) { - return { - status: "unavailable", - message: `OpenCode Go returned HTTP ${String(response.status)}`, - }; - } - - const body = await readCatalogJson(response); - if (!body.ok) { - return { status: "malformed", message: body.message }; - } - const parsed = GoModelsResponse(body.value); - if (parsed instanceof type.errors) { - return { status: "malformed", message: parsed.summary }; - } - if (parsed.data.length > MAX_GO_CATALOG_MODELS) { - return { status: "malformed", message: oversizeMessage("models") }; - } - const models = [ - ...new Set( - parsed.data.map(({ id }) => id.trim()).filter((id) => id.length > 0), - ), - ]; - return models.length > 0 ? { status: "models", models } : { status: "empty" }; -} +export const discoverGoModels = catalog.discoverModels; /** Sync picker ids: last successful live list, else the packaged seed. Never empty. */ -export function selectableGoModelIds(): readonly string[] { - return snapshot ?? OPENCODE_GO_MODEL_IDS; -} - -async function runPrefetch(): Promise { - const state = await discoverGoModels(); - // Empty/unavailable/malformed leave a successful snapshot in place: stale-but-live - // beats empty, and a cold failure still falls through to the packaged seed. - if (state.status === "models") { - snapshot = state.models; - } - return selectableGoModelIds(); -} +export const selectableGoModelIds = catalog.selectableModelIds; /** Join or start a live fetch; the snapshot is the cache, inflight is only a mutex. */ -export function prefetchGoModels(): Promise { - if (inflight !== undefined) return inflight; - - const pending = runPrefetch(); - inflight = pending; - // Clear inflight on settle so a later prefetch can recover instead of replaying - // the first settlement forever. .then(cleanup, cleanup) instead of .finally() - // avoids an abandoned promise chain whose pass-through rejection could become - // an unhandled rejection — callers await the original pending promise. - const cleanup = (): void => { - if (inflight === pending) { - inflight = undefined; - } - }; - pending.then(cleanup, cleanup); - return pending; -} +export const prefetchGoModels = catalog.prefetchModels; -export function resetGoModelDiscoveryForTests(): void { - inflight = undefined; - snapshot = undefined; -} +export const resetGoModelDiscoveryForTests = catalog.resetDiscoveryForTests; diff --git a/src/provider/zen-models.ts b/src/provider/zen-models.ts index 4e637908e..06e02f8c7 100644 --- a/src/provider/zen-models.ts +++ b/src/provider/zen-models.ts @@ -1,192 +1,33 @@ -import { type } from "arktype"; - import { ZEN_DEFAULT_BASE_URL, ZEN_MODEL_IDS, } from "../../packages/zen/src/index.js"; -import { requestModelsEndpoint } from "./models-endpoint.js"; - -const ZenModelsResponse = type({ - data: type({ id: "string" }).array(), -}); +import { + createBoundedModelCatalog, + type CatalogDiscoveryState, +} from "./bounded-model-catalog.js"; // Bound live /models so a huge or hostile catalog cannot blow process memory. export const MAX_ZEN_CATALOG_BYTES = 256 * 1024; export const MAX_ZEN_CATALOG_MODELS = 1024; -export type ZenDiscoveryState = - | { readonly status: "models"; readonly models: readonly string[] } - | { readonly status: "empty" } - | { readonly status: "unavailable"; readonly message: string } - | { readonly status: "malformed"; readonly message: string }; - -let inflight: Promise | undefined; -let snapshot: readonly string[] | undefined; - -function declaredCatalogBytes(response: Response): number | undefined { - const raw = response.headers.get("content-length"); - if (raw === null || raw.length === 0) return undefined; - const n = Number(raw); - if (!Number.isFinite(n) || n < 0) return undefined; - return n; -} - -function oversizeMessage(kind: "bytes" | "models"): string { - if (kind === "bytes") { - return `OpenCode Zen catalog exceeds ${String(MAX_ZEN_CATALOG_BYTES)} bytes`; - } - return `OpenCode Zen catalog exceeds ${String(MAX_ZEN_CATALOG_MODELS)} models`; -} - -async function readCatalogJson( - response: Response, -): Promise< - | { readonly ok: true; readonly value: unknown } - | { readonly ok: false; readonly message: string } -> { - const declared = declaredCatalogBytes(response); - if (declared !== undefined && declared > MAX_ZEN_CATALOG_BYTES) { - await response.body?.cancel().catch(() => undefined); - return { ok: false, message: oversizeMessage("bytes") }; - } +export type ZenDiscoveryState = CatalogDiscoveryState; - const body = response.body; - if (body === null) { - try { - const text = await response.text(); - if (new TextEncoder().encode(text).byteLength > MAX_ZEN_CATALOG_BYTES) { - return { ok: false, message: oversizeMessage("bytes") }; - } - const value: unknown = JSON.parse(text); - return { ok: true, value }; - } catch (error) { - return { - ok: false, - message: error instanceof Error ? error.message : String(error), - }; - } - } - - const reader = body.getReader(); - const chunks: Uint8Array[] = []; - let total = 0; - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) break; - if (value === undefined) continue; - total += value.byteLength; - if (total > MAX_ZEN_CATALOG_BYTES) { - await reader.cancel().catch(() => undefined); - return { ok: false, message: oversizeMessage("bytes") }; - } - chunks.push(value); - } - } catch (error) { - return { - ok: false, - message: error instanceof Error ? error.message : String(error), - }; - } - - const buffer = new Uint8Array(total); - let offset = 0; - for (const chunk of chunks) { - buffer.set(chunk, offset); - offset += chunk.byteLength; - } - - try { - const value: unknown = JSON.parse(new TextDecoder().decode(buffer)); - return { ok: true, value }; - } catch (error) { - return { - ok: false, - message: error instanceof Error ? error.message : String(error), - }; - } -} +const catalog = createBoundedModelCatalog({ + baseURL: ZEN_DEFAULT_BASE_URL, + seedIds: ZEN_MODEL_IDS, + catalogLabel: "OpenCode Zen", + maxBytes: MAX_ZEN_CATALOG_BYTES, + maxModels: MAX_ZEN_CATALOG_MODELS, +}); /** Discover public OpenCode Zen models without leaking transport or parsing failures. */ -export async function discoverZenModels(args?: { - timeoutMs?: number; - signal?: AbortSignal; -}): Promise { - let response: Response; - try { - response = await requestModelsEndpoint({ - baseURL: ZEN_DEFAULT_BASE_URL, - ...(args?.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}), - ...(args?.signal !== undefined ? { signal: args.signal } : {}), - }); - } catch (error) { - return { - status: "unavailable", - message: error instanceof Error ? error.message : String(error), - }; - } - - if (!response.ok) { - return { - status: "unavailable", - message: `OpenCode Zen returned HTTP ${String(response.status)}`, - }; - } - - const body = await readCatalogJson(response); - if (!body.ok) { - return { status: "malformed", message: body.message }; - } - const parsed = ZenModelsResponse(body.value); - if (parsed instanceof type.errors) { - return { status: "malformed", message: parsed.summary }; - } - if (parsed.data.length > MAX_ZEN_CATALOG_MODELS) { - return { status: "malformed", message: oversizeMessage("models") }; - } - const models = [ - ...new Set( - parsed.data.map(({ id }) => id.trim()).filter((id) => id.length > 0), - ), - ]; - return models.length > 0 ? { status: "models", models } : { status: "empty" }; -} +export const discoverZenModels = catalog.discoverModels; /** Sync picker ids: last successful live list, else the packaged seed. Never empty. */ -export function selectableZenModelIds(): readonly string[] { - return snapshot ?? ZEN_MODEL_IDS; -} - -async function runPrefetch(): Promise { - const state = await discoverZenModels(); - // Empty/unavailable/malformed leave a successful snapshot in place: stale-but-live - // beats empty, and a cold failure still falls through to the packaged seed. - if (state.status === "models") { - snapshot = state.models; - } - return selectableZenModelIds(); -} +export const selectableZenModelIds = catalog.selectableModelIds; /** Join or start a live fetch; the snapshot is the cache, inflight is only a mutex. */ -export function prefetchZenModels(): Promise { - if (inflight !== undefined) return inflight; - - const pending = runPrefetch(); - inflight = pending; - // Clear inflight on settle so a later prefetch can recover instead of replaying - // the first settlement forever. .then(cleanup, cleanup) instead of .finally() - // avoids an abandoned promise chain whose pass-through rejection could become - // an unhandled rejection — callers await the original pending promise. - const cleanup = (): void => { - if (inflight === pending) { - inflight = undefined; - } - }; - pending.then(cleanup, cleanup); - return pending; -} +export const prefetchZenModels = catalog.prefetchModels; -export function resetZenModelDiscoveryForTests(): void { - inflight = undefined; - snapshot = undefined; -} +export const resetZenModelDiscoveryForTests = catalog.resetDiscoveryForTests; From 4fff9b6003fa88fc51d78cb1dae510254e5b8bec Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 00:12:58 -0700 Subject: [PATCH 3/4] refactor(provider): share null delta normalization pass --- src/provider/null-delta-fields.ts | 35 +++++++++++++++++++++++ src/provider/openai-compatible-adapter.ts | 32 +++------------------ src/provider/opencode-go-adapter.ts | 31 +------------------- 3 files changed, 40 insertions(+), 58 deletions(-) create mode 100644 src/provider/null-delta-fields.ts diff --git a/src/provider/null-delta-fields.ts b/src/provider/null-delta-fields.ts new file mode 100644 index 000000000..19f7ad1d0 --- /dev/null +++ b/src/provider/null-delta-fields.ts @@ -0,0 +1,35 @@ +// Some OpenAI-shaped streams send null for delta fields the upstream schema +// requires to be non-null (role: string, tool_calls: array). Fields that +// legitimately accept null (content, reasoning_content, etc.) are left alone. +export const NULL_DELTA_FIELDS = ["role", "tool_calls"] as const; + +export function normalizeNullDeltaFields( + sseData: string, + fields: readonly string[] = NULL_DELTA_FIELDS, +): string { + let parsed: unknown; + try { + parsed = JSON.parse(sseData); + } catch { + return sseData; + } + if (parsed === null || typeof parsed !== "object") return sseData; + + const choices = (parsed as Record)["choices"]; + if (!Array.isArray(choices)) return sseData; + + let normalized = false; + for (const choice of choices) { + if (choice === null || typeof choice !== "object") continue; + const delta = (choice as Record)["delta"]; + if (delta === null || typeof delta !== "object") continue; + for (const field of fields) { + if ((delta as Record)[field] === null) { + Reflect.deleteProperty(delta, field); + normalized = true; + } + } + } + + return normalized ? JSON.stringify(parsed) : sseData; +} diff --git a/src/provider/openai-compatible-adapter.ts b/src/provider/openai-compatible-adapter.ts index 549bd276c..d869af801 100644 --- a/src/provider/openai-compatible-adapter.ts +++ b/src/provider/openai-compatible-adapter.ts @@ -1,5 +1,6 @@ import { type BuiltRequest, type ProviderAdapter } from "@intx/inference"; import { createOpenAIAdapter } from "@intx/inference/providers"; +import { normalizeNullDeltaFields } from "./null-delta-fields.js"; // The stock OpenAI adapter builds the request body from a fixed set of fields // (max_tokens, temperature, tools, messages, response_format) and ignores @@ -63,36 +64,11 @@ export function createOpenAICompatibleAdapter( }; // DeepSeek via NVIDIA NIM sends null for delta fields the upstream schema - // requires to be non-null (role: string, tool_calls: array). Fields that - // legitimately accept null (content, reasoning_content, etc.) are left alone. - const NULL_REJECTED_DELTA_FIELDS = new Set(["role", "tool_calls"]); + // requires to be non-null; every other provider's frames skip the reparse + // and hit base.parseResponse exactly once instead of twice. const parseResponse: ProviderAdapter["parseResponse"] = (sseData: string) => { if (!needsDeepSeekPatch) return base.parseResponse(sseData); - let data = sseData; - try { - const parsed = JSON.parse(sseData) as Record; - const choices = parsed["choices"]; - if (Array.isArray(choices)) { - let patched = false; - for (const choice of choices) { - if (choice !== null && typeof choice === "object") { - const delta = (choice as Record)["delta"]; - if (delta !== null && typeof delta === "object") { - for (const key of NULL_REJECTED_DELTA_FIELDS) { - if ((delta as Record)[key] === null) { - Reflect.deleteProperty(delta as object, key); - patched = true; - } - } - } - } - } - if (patched) data = JSON.stringify(parsed); - } - } catch { - /* not JSON — pass through */ - } - return base.parseResponse(data); + return base.parseResponse(normalizeNullDeltaFields(sseData)); }; return { ...base, buildRequest, parseResponse }; diff --git a/src/provider/opencode-go-adapter.ts b/src/provider/opencode-go-adapter.ts index c44f42fce..130bc7d50 100644 --- a/src/provider/opencode-go-adapter.ts +++ b/src/provider/opencode-go-adapter.ts @@ -1,4 +1,5 @@ import type { BuiltRequest, ProviderAdapter } from "@intx/inference"; +import { normalizeNullDeltaFields } from "./null-delta-fields.js"; import { createOpenAICompatibleAdapter } from "./openai-compatible-adapter.js"; import { OPENCODE_SESSION_ID_OPTION, @@ -7,36 +8,6 @@ import { type AdapterSource = Parameters[0]; -const NULL_DELTA_FIELDS = ["role", "tool_calls"] as const; - -function normalizeNullDeltaFields(sseData: string): string { - let parsed: unknown; - try { - parsed = JSON.parse(sseData); - } catch { - return sseData; - } - if (parsed === null || typeof parsed !== "object") return sseData; - - const choices = (parsed as Record)["choices"]; - if (!Array.isArray(choices)) return sseData; - - let normalized = false; - for (const choice of choices) { - if (choice === null || typeof choice !== "object") continue; - const delta = (choice as Record)["delta"]; - if (delta === null || typeof delta !== "object") continue; - for (const field of NULL_DELTA_FIELDS) { - if ((delta as Record)[field] === null) { - Reflect.deleteProperty(delta, field); - normalized = true; - } - } - } - - return normalized ? JSON.stringify(parsed) : sseData; -} - export function createOpenCodeGoAdapter( source: AdapterSource, quirks?: unknown, From 2e85a05388077197ce9dd4f0c05c5c5f5a40515f Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Mon, 14 Sep 2026 00:13:02 -0700 Subject: [PATCH 4/4] style(provider): format collapsed modules --- src/provider/anthropic-session-adapter.test.ts | 8 +++++--- src/provider/bounded-model-catalog.ts | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/provider/anthropic-session-adapter.test.ts b/src/provider/anthropic-session-adapter.test.ts index c1af1afec..b604d37b5 100644 --- a/src/provider/anthropic-session-adapter.test.ts +++ b/src/provider/anthropic-session-adapter.test.ts @@ -22,9 +22,11 @@ const factories = { describe("session header Anthropic adapter", () => { for (const [name, factory] of Object.entries(factories)) { test(`${name}: delegates Anthropic request construction and adds only the session header`, () => { - const request = factory( - { sourceId: name, provider: `${name}-messages`, model: "minimax-m3" }, - ).buildRequest(messages, "minimax-m3", { + const request = factory({ + sourceId: name, + provider: `${name}-messages`, + model: "minimax-m3", + }).buildRequest(messages, "minimax-m3", { providerOptions: { opencodeSessionId: "sess-1" }, } as InferenceOptions); expect(request.headers["x-opencode-session"]).toBe("sess-1"); diff --git a/src/provider/bounded-model-catalog.ts b/src/provider/bounded-model-catalog.ts index 2bd6790d9..8ea762778 100644 --- a/src/provider/bounded-model-catalog.ts +++ b/src/provider/bounded-model-catalog.ts @@ -157,7 +157,9 @@ export function createBoundedModelCatalog(args: { parsed.data.map(({ id }) => id.trim()).filter((id) => id.length > 0), ), ]; - return models.length > 0 ? { status: "models", models } : { status: "empty" }; + return models.length > 0 + ? { status: "models", models } + : { status: "empty" }; } function selectableModelIds(): readonly string[] {