diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 520161b511b..c967966d0e1 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -765,6 +765,8 @@ headers. That is separate from the keyless desktop quota OpenCode advertises synthetic `Retry-After`; an upstream `Retry-After` still takes precedence. Same-key wait-and-retry remains opt-in via [`retryOn429`](/reference/configuration/). +Gemini models on `opencode-zen` use Google's native API automatically when the provider uses an API key, the default `googleMode` (`ai-studio`), and either `https://opencode.ai/zen/v1` or `https://opencode.ai/inference/v1`. Use the model IDs listed by Zen; OpenCodex preserves them, including Flash IDs without a `-tiered` suffix. Older Gemini Chat/Responses overrides and Responses-only hosted-tool preferences are ignored when reading saved configurations. + **The keyless `opencode-free` tier is currently closed to third-party clients.** Zen refuses any request that arrives without an `x-opencode-session` header, answering with error type `MissingSessionID` and the message "OpenCode's free tier can only be used in OpenCode". Presence diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 50c3520b0df..959f7dd2676 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -339,7 +339,7 @@ Providers can expose a built-in shorthand, such as `agy` for `google-antigravity | `anthropicEofTolerance?` | `boolean` | Let an Anthropic-compatible gateway complete a stream that ends before `message_stop`, only when visible text or a complete JSON-object tool input was received. Off by default. | | `googleMode?` | `"ai-studio" \| "vertex" \| "cloud-code-assist"` | Google transport/auth mode. Default `ai-studio`. | | `googleToolSchemaPolicy?` | `"compatible" \| "reject-lossy"` | Google only. Omitted/`compatible` preserves the compatible tool schema and existing non-direct 400 repair. `reject-lossy` rejects initial schema loss or an indeterminate bounded comparison before dispatch and withholds a Vertex or Cloud Code Assist repair that would open constraints. Direct AI Studio never performs that repair. | -| `directGeminiWireRenames?` | `boolean` | Google only. Applies only to direct AI Studio requests. Omitted or `true` keeps the `-tiered` wire rename for Gemini Flash ids (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`); `false` sends the requested bare ids to the wire unchanged. Vertex preserves the requested model ID, and Cloud Code Assist routing is unchanged. Set `false` when the configured upstream still serves the bare ids. | +| `directGeminiWireRenames?` | `boolean` | Google only. Applies in AI Studio mode, including custom Google-compatible URLs. Omitted or `true` keeps the `-tiered` wire rename for Gemini Flash ids (`gemini-3.7-flash` -> `gemini-3.7-flash-tiered`); `false` sends the requested bare ids to the wire unchanged. Vertex and canonical keyed OpenCode Zen routes preserve the requested model ID regardless of this setting; Cloud Code Assist routing is unchanged. Set `false` when the configured upstream still serves the bare ids. | | `project?` | `string` | Vertex or Antigravity Cloud Code Assist project id. | | — | — | Antigravity account quota probes (`retrieveUserQuota` and `retrieveUserQuotaSummary`) always go to Google's own Cloud Code host through the pinned outbound transport, regardless of a configured `baseUrl`; the account bearer is never sent to an operator-configured endpoint and a redirect aborts the probe. Only the model-list fallback still honors `baseUrl`. | | — | — | If Antigravity quota summary returns 403 for a valid OAuth account, OpenCodex retries that endpoint once with the legacy `antigravity/1.0` User-Agent and the same token and project. A 401 is not retried. Inference and model discovery retain the IDE User-Agent. | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 16227fe5cf1..216767e6b42 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -718,7 +718,7 @@ "config-account-thresholds.test.ts": "config", "config-blocked-model-redirects.test.ts": "config", "config-catalog-auto-refresh.test.ts": "config", - "config-commandcode-claude-pin.test.ts": "config", + "config-commandcode-claude-pin.test.ts": "config", "config-opencode-zen-upgrade.test.ts": "config", "config-load-degrade.test.ts": "config", "protocol-settings.test.ts": "config", "config-mutation-lock.test.ts": "config", @@ -1290,7 +1290,7 @@ "opencode-go-quota.test.ts": "providers", "opencode-go-session-header.test.ts": "providers", "opencode-management-transport.test.ts": "providers", - "opencode-zen-deepseek-reasoning.test.ts": "providers", + "opencode-zen-deepseek-reasoning.test.ts": "providers", "opencode-zen-gemini-wire.test.ts": "providers", "opencode-zen-rate-limit.test.ts": "providers", "opengateway-provider.test.ts": "providers", "opengateway-key-validation.test.ts": "providers", diff --git a/src/adapters/google.ts b/src/adapters/google.ts index a60bf565d67..576b39456de 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -1,3 +1,4 @@ +import { openCodeZenGeminiEndpoint } from "../types/opencode-zen"; import type { AdapterFetchContext, AdapterRequest, ProviderAdapter } from "./base"; import { debugDroppedFrame, debugProviderDiagnosticLazy } from "../lib/debug"; import { createToolCallIdAllocator } from "./tool-call-id"; @@ -983,7 +984,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte mapReasoningEffort(provider, parsed.modelId, parsed.options.reasoning), provider.baseUrl, ).wireModelId - : provider.googleMode === "vertex" + : provider.googleMode === "vertex" || openCodeZenGeminiEndpoint(provider, parsed.modelId) !== undefined ? parsed.modelId : resolveDirectGeminiWireModelId(parsed.modelId, provider.directGeminiWireRenames !== false); returnsThoughtSummaries = provider.googleMode === "cloud-code-assist" @@ -1202,10 +1203,12 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; } - // ai-studio (default): Generative Language API + x-goog-api-key. - const url = `${provider.baseUrl}/v1beta/models/${routedModelId}:${method}${streamParam}`; + // Zen accepts Gemini wire and x-goog-api-key at its own model endpoint. + const zenEndpoint = openCodeZenGeminiEndpoint(provider, routedModelId); + const endpoint = zenEndpoint ?? `${provider.baseUrl}/v1beta/models/${routedModelId}`; + const url = `${endpoint}:${method}${streamParam}`; const apiKey = provider.apiKey?.trim(); - if (!apiKey) throw new Error("google (AI Studio) requires a non-empty API key"); + if (!apiKey) throw new Error(`${zenEndpoint ? "OpenCode Zen" : "google (AI Studio)"} requires a non-empty API key`); headers["x-goog-api-key"] = apiKey; const compiled = compileGoogleWireBody(body, toolSchemaProfile, toolSchemaPolicy); diff --git a/src/config.ts b/src/config.ts index 282fc601947..8fc8841e9f4 100644 --- a/src/config.ts +++ b/src/config.ts @@ -162,6 +162,7 @@ import { persistConfigUnlocked, readRawConfigJson } from "./config/persist-unloc import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } from "./config/mutation-lock"; import { getDefaultConfig } from "./config/proxy-env"; import { configSchema } from "./config/schema/config-schema"; +import { sanitizeOpenCodeZenOverridesForLoad } from "./config/opencode-zen-upgrade"; import { hardenExistingSecret, normalizeApiKeyIds, @@ -222,6 +223,7 @@ export function loadConfig(): OcxConfig { try { const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, ""); const parsed = JSON.parse(raw); + sanitizeOpenCodeZenOverridesForLoad(parsed); sanitizeAliasesForLoad(parsed); sanitizeReasoningPinsForLoad(parsed); sanitizeModelDisplayNamesForLoad(parsed); diff --git a/src/config/diagnostics.ts b/src/config/diagnostics.ts index d91f8bd602b..2d7050e4e2e 100644 --- a/src/config/diagnostics.ts +++ b/src/config/diagnostics.ts @@ -18,6 +18,7 @@ import { isMissingPathError } from "./atomic-write"; import { getConfigDir, getConfigPath } from "./paths"; import { getDefaultConfig } from "./proxy-env"; import { salvageConfigCandidate } from "./salvage"; +import { sanitizeOpenCodeZenOverridesForLoad } from "./opencode-zen-upgrade"; import { sanitizeReasoningPinsForLoad, sanitizeRetryOn429ForLoad, @@ -740,6 +741,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx export function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics { try { const parsed = JSON.parse(raw.replace(/^\uFEFF/, "")); + sanitizeOpenCodeZenOverridesForLoad(parsed); sanitizeReasoningPinsForLoad(parsed); // Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the // schema and send the caller a default-config fallback (the config command could then diff --git a/src/config/opencode-zen-upgrade.ts b/src/config/opencode-zen-upgrade.ts new file mode 100644 index 00000000000..974e69ebe23 --- /dev/null +++ b/src/config/opencode-zen-upgrade.ts @@ -0,0 +1,52 @@ +import { openCodeZenBaseUrl } from "../types/opencode-zen"; +import { declaredUnsupportedHostedTools, isHostedToolUnsupportedForModel } from "../responses/hosted-tool-policy"; + +function record(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? value as Record : undefined; +} + +function isGeminiModel(model: string): boolean { + return model.trim().toLowerCase().startsWith("gemini-"); +} + +/** Repair legacy Zen wire overrides and dependent preferences in file-backed reads only. */ +export function sanitizeOpenCodeZenOverridesForLoad(parsed: unknown): void { + const providers = record(record(parsed)?.providers); + const provider = providers && Object.hasOwn(providers, "opencode-zen") + ? record(providers["opencode-zen"]) : undefined; + if (!provider || !openCodeZenBaseUrl(provider)) return; + const overrides = record(provider.modelAdapters) ?? {}; + const preferences = record(provider.modelPreferHostedTools); + let preferencesChanged = false; + if (preferences) { + const unsupported = declaredUnsupportedHostedTools(provider as { unsupportedHostedTools?: readonly string[] }); + for (const [model, tools] of Object.entries(preferences)) { + // Capture the old wire before deleting overrides. An explicit non-Responses entry + // must not inherit a provider-wide Responses default for this migration. + const oldAdapter = Object.hasOwn(overrides, model) ? overrides[model] : provider.adapter; + if (!isGeminiModel(model) || oldAdapter !== "openai-responses" + || !Array.isArray(tools) || tools.length === 0 || tools.some(tool => tool !== "image_generation") + || isHostedToolUnsupportedForModel(model, "image_generation", undefined, unsupported)) continue; + delete preferences[model]; + preferencesChanged = true; + } + if (preferencesChanged && Object.keys(preferences).length === 0) delete provider.modelPreferHostedTools; + } + let overridesChanged = false; + for (const [model, adapter] of Object.entries(overrides)) { + // Retain invalid preference/wire pairs so repeated normalization cannot admit them. + if (preferences && Object.hasOwn(preferences, model)) continue; + if (isGeminiModel(model) && (adapter === "openai-chat" || adapter === "openai-responses")) { + delete overrides[model]; + overridesChanged = true; + } + } + if (overridesChanged) { + if (Object.keys(overrides).length === 0) delete provider.modelAdapters; + console.warn("OpenCode Zen Gemini models now use the native Google API; obsolete Chat/Responses overrides were ignored while loading config.json."); + } + if (preferencesChanged) { + console.warn("OpenCode Zen Gemini models now use the native Google API; legacy Responses-only hosted-tool preferences were ignored while loading config.json."); + } +} diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index c435da8d6f6..1bb7acc8c4e 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -33,6 +33,7 @@ type ServiceTierCapabilityProvider = Pick< | "modelAdapters" | "baseUrl" | "authMode" + | "googleMode" | "apiKeyTransport" | "chatServiceTier" | "fastWire" diff --git a/src/types/opencode-zen.ts b/src/types/opencode-zen.ts new file mode 100644 index 00000000000..0452a7bc966 --- /dev/null +++ b/src/types/opencode-zen.ts @@ -0,0 +1,29 @@ +/** Fixed-key OpenCode Zen destinations exposing the native Gemini endpoint. */ +export interface OpenCodeZenDestination { + readonly baseUrl?: unknown; + readonly authMode?: unknown; + readonly googleMode?: unknown; +} + +export function openCodeZenBaseUrl(provider: OpenCodeZenDestination | undefined): string | undefined { + if (!provider || typeof provider.baseUrl !== "string") return undefined; + if (provider.authMode !== undefined && provider.authMode !== "key") return undefined; + if (provider.googleMode !== undefined && provider.googleMode !== "ai-studio") return undefined; + try { + const url = new URL(provider.baseUrl.trim()); + const pathname = url.pathname.replace(/\/+$/, ""); + if (url.origin !== "https://opencode.ai" || url.username || url.password || url.search || url.hash) return undefined; + if (pathname !== "/zen/v1" && pathname !== "/inference/v1") return undefined; + return url.origin + pathname; + } catch { + return undefined; + } +} + +/** Keep the selected destination; Zen omits AI Studio's /v1beta segment. */ +export function openCodeZenGeminiEndpoint(provider: OpenCodeZenDestination, modelId: string): string | undefined { + const baseUrl = openCodeZenBaseUrl(provider); + return baseUrl && modelId.toLowerCase().startsWith("gemini-") + ? baseUrl + "/models/" + encodeURIComponent(modelId) + : undefined; +} diff --git a/src/types/wire.ts b/src/types/wire.ts index 59955f5bed1..92280d230dc 100644 --- a/src/types/wire.ts +++ b/src/types/wire.ts @@ -1,3 +1,5 @@ +import { openCodeZenBaseUrl, type OpenCodeZenDestination } from "./opencode-zen"; + /** * Accepted values for the per-provider upstream HTTP-version pin (#1668). Shared by the * zod load schema, the management write boundary (POST/PATCH), and the fetch runtime, so @@ -98,9 +100,7 @@ const WIRE_ADAPTER_PIN_PREFIXES: Readonly> = O }); /** Just enough of a provider config to tell whether it still points at the pinned endpoint. */ -export interface WirePinProvider { - readonly baseUrl?: unknown; -} +export interface WirePinProvider extends OpenCodeZenDestination {} function normalizedEndpoint(value: unknown): string | undefined { if (typeof value !== "string") return undefined; @@ -113,6 +113,7 @@ function normalizedEndpoint(value: unknown): string | undefined { } function prefixPinsFor(providerName: string, provider: WirePinProvider | undefined): Readonly> | undefined { + if (providerName === "opencode-zen" && openCodeZenBaseUrl(provider)) return { "gemini-": "google" }; if (!provider || !Object.hasOwn(WIRE_ADAPTER_PIN_PREFIXES, providerName)) return undefined; const rule = WIRE_ADAPTER_PIN_PREFIXES[providerName]!; return normalizedEndpoint(provider.baseUrl) === rule.endpoint ? rule.prefixes : undefined; diff --git a/structure/config.md b/structure/config.md index f803d408c5c..6a18ea2beea 100644 --- a/structure/config.md +++ b/structure/config.md @@ -434,7 +434,7 @@ capture/route time and explicit false or empty declarations retain their field-s `src/config/provider-validation.ts` owns the pure provider payload checks shared by persisted config, CLI writes, and management DTO validation. `src/config.ts` imports those checks for Zod refinement and re-exports them as a compatibility facade; it must not grow a second copy. Validation error text, -ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. +ordering, and cross-field rules are part of the write/load contract: management requests and hand-edited `config.json` accept and reject the same provider shapes. Before validation, `src/config/opencode-zen-upgrade.ts` normalizes saved Gemini overrides and their Responses-only hosted-tool preferences on canonical keyed OpenCode Zen routes. Runtime and diagnostics-backed reads share this upgrade, preserving unrelated providers and accounts. Reads warn without rewriting the file; explicit edits persist the normalized configuration, and new writes still reject incompatible overrides and preferences. Other destinations, authentication modes and invalid override values retain normal validation. Provider `projectContext` accepts `"off"` or `"on"` only for native `command-code`; `src/config/schema/leaf-validators.ts` rejects other adapters on load, while `src/config/provider-validation.ts` and `src/server/auth-cors.ts` reject them on management writes. This editor-owned outbound-file field follows [provider and adapter selection](providers-and-adapters.md). The Google tool-schema policy uses a closed enum at this boundary. Unknown values fail config load, diff --git a/structure/providers-and-adapters.md b/structure/providers-and-adapters.md index c522806f375..4358c59b26c 100644 --- a/structure/providers-and-adapters.md +++ b/structure/providers-and-adapters.md @@ -564,15 +564,14 @@ destination. The input body, historical tool calls and results, and non-Meta req ## Shared type declarations -`src/types/` holds the declarations every layer imports: config types (`src/types/config.ts`), -provider and account types (`src/types/provider.ts`, `src/types/accounts.ts`), and the internal -request shape (`src/types/request.ts`). Two files also own small resolvers that must agree at every -boundary. `src/types/tools.ts` owns tool-name identity: namespaced and dotted names, declared-name +`src/types/` holds the declarations every layer imports: config types (`src/types/config.ts`), provider and account types (`src/types/provider.ts`, `src/types/accounts.ts`), and the internal request shape (`src/types/request.ts`). Small resolvers must also agree at every boundary. `src/types/tools.ts` owns tool-name identity: namespaced and dotted names, declared-name normalization, and `tool_choice` alias resolution, so every adapter matches a declared tool the same way. `src/types/wire.ts` owns accepted wire enumerations such as the per-provider upstream HTTP-version pin, shared by config load, management writes and fetch, so no boundary accepts a value another rejects. It also hard-pins OpenCode Go's `claude-haiku-5-5`, `minimax-m2.5`, `minimax-m2.7`, `minimax-m3`, and `union-alpha` to Anthropic across direct and captured static policy resolution, even with Chat/Responses overrides. +For canonical `opencode-zen`, `src/types/wire.ts` pins `gemini-*` to `google`, including when a Chat/Responses override is configured. `src/types/opencode-zen.ts` shares the destination check with `src/adapters/google.ts`: only `https://opencode.ai/zen/v1` and `https://opencode.ai/inference/v1` qualify, with key auth and AI Studio mode (both may be unset), no URL credentials, query or fragment, and optional trailing slashes. The adapter appends `/models/:generateContent` or `:streamGenerateContent?alt=sse` to the selected base, sends `x-goog-api-key`, and skips AI Studio's `-tiered` model renames. The pin does not apply to `opencode-free`, OpenCode Go, custom destinations, OAuth/forward auth, Vertex or Cloud Code Assist. + `src/types/config.ts` declares the optional per-phase `memoryModels` setting; `src/types/request.ts` carries the selected phase through combo handoffs without changing the public request model. [Memory phase routing](transports/responses-failover.md#memory-phase-routing) owns the selection rule. diff --git a/tests/config/config-opencode-zen-upgrade.test.ts b/tests/config/config-opencode-zen-upgrade.test.ts new file mode 100644 index 00000000000..bda564bc285 --- /dev/null +++ b/tests/config/config-opencode-zen-upgrade.test.ts @@ -0,0 +1,286 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; +import { getConfigPath, getDefaultConfig, loadConfig, validateConfigCandidate } from "../../src/config"; +import { sanitizeOpenCodeZenOverridesForLoad } from "../../src/config/opencode-zen-upgrade"; +import { readConfigDiagnostics, readConfigFileSnapshot } from "../../src/config/diagnostics"; +import { handleConfigCommand } from "../../src/cli/config-command"; +import { flushConfigDirHardeningAndReaps } from "../../src/config/paths"; +import { createTempHome } from "../helpers/temp-home"; + +const BASES = ["https://opencode.ai/zen/v1", "https://opencode.ai/inference/v1"]; +const WARNING = "OpenCode Zen Gemini models now use the native Google API; obsolete Chat/Responses overrides were ignored while loading config.json."; + +function candidate(modelAdapters: unknown, overrides: Record = {}) { + return { + ...getDefaultConfig(), + defaultProvider: "opencode-zen", + providers: { + "opencode-zen": { + adapter: "openai-chat", baseUrl: BASES[0]!, authMode: "key", googleMode: "ai-studio", + apiKey: "synthetic-zen-upgrade-key", note: "Saved Zen provider", modelAdapters, ...overrides, + }, + "saved-gateway": { + adapter: "openai-chat", baseUrl: "https://gateway.example.test/v1", apiKey: "synthetic-other-key", + note: "Keep this provider", modelAdapters: { "gemini-3.7-flash": "openai-responses" }, + modelPreferHostedTools: { "gemini-3.7-flash": ["image_generation"] }, + }, + }, + codexAccounts: [{ id: "saved-account", email: "fixture@example.test", alias: "Saved account", plan: "plus", isMain: false }], + codexAccountPriorities: { "saved-account": 25 }, + activeCodexAccountPinned: "saved-account", + pausedCodexAccountIds: ["saved-account"], + }; +} + +describe("OpenCode Zen saved-config upgrade", () => { + test.each(BASES)("loads old Gemini overrides at %s without losing providers, accounts or file bytes", baseUrl => { + const home = createTempHome("ocx-zen-config-upgrade-"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const raw = candidate({ + "gemini-3.6-flash": "openai-chat", + "GeMiNi-3.7-Flash": "openai-responses", + " GEMINI-future ": "openai-chat", + "gpt-6.1-sol": "openai-responses", + }, { baseUrl }); + const bytes = Buffer.from(`\uFEFF${JSON.stringify(raw, null, 2)}\r\n`, "utf8"); + writeFileSync(getConfigPath(), bytes); + const filesBefore = readdirSync(home.root).sort(); + const loaded = loadConfig(); + + expect(loaded.defaultProvider).toBe("opencode-zen"); + expect(Object.keys(loaded.providers).sort()).toEqual(["opencode-zen", "saved-gateway"]); + expect(loaded.providers["opencode-zen"]).toMatchObject({ + adapter: "openai-chat", apiKey: raw.providers["opencode-zen"].apiKey, + note: "Saved Zen provider", modelAdapters: { "gpt-6.1-sol": "openai-responses" }, + }); + expect(loaded.providers["opencode-zen"].modelAdapters).toEqual({ "gpt-6.1-sol": "openai-responses" }); + expect(loaded.providers["saved-gateway"]).toMatchObject(raw.providers["saved-gateway"]); + expect(loaded.codexAccounts).toEqual(raw.codexAccounts); + expect(loaded.codexAccountPriorities).toEqual(raw.codexAccountPriorities); + expect(loaded.activeCodexAccountPinned).toBe(raw.activeCodexAccountPinned); + expect(loaded.pausedCodexAccountIds).toEqual(raw.pausedCodexAccountIds); + expect(readFileSync(getConfigPath())).toEqual(bytes); + expect(readdirSync(home.root).sort()).toEqual(filesBefore); + expect(warn.mock.calls.filter(call => call[0] === WARNING)).toEqual([[WARNING]]); + } finally { + warn.mockRestore(); + home.remove(); + } + }); + + test.each(["openai-chat", "openai-responses"])("removes an obsolete-only %s map when loading omitted auth/mode defaults", adapter => { + const home = createTempHome("ocx-zen-config-empty-map-"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const raw = candidate({ " GeMiNi-3.7-flash ": adapter }, { authMode: undefined, googleMode: undefined }); + const bytes = JSON.stringify(raw); + writeFileSync(getConfigPath(), bytes); + const loaded = loadConfig(); + expect(loaded.providers["opencode-zen"]).toBeDefined(); + expect(Object.hasOwn(loaded.providers["opencode-zen"], "modelAdapters")).toBe(false); + expect(loaded.providers["saved-gateway"]).toMatchObject(raw.providers["saved-gateway"]); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + expect(warn.mock.calls).toEqual([[WARNING]]); + } finally { + warn.mockRestore(); + home.remove(); + } + }); + + test.each(BASES)("diagnostics, export and persisted edits preserve saved state at %s", async baseUrl => { + const home = createTempHome("ocx-zen-config-readers-"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + const out = spyOn(console, "log").mockImplementation(() => {}); + const err = spyOn(console, "error").mockImplementation(() => {}); + const previousExit = process.exitCode; + try { + const raw = candidate({ "gemini-3.7-flash": "openai-responses" }, { baseUrl }); + const bytes = "\uFEFF" + JSON.stringify(raw, null, 2) + "\r\n"; + writeFileSync(getConfigPath(), bytes); + const snapshot = readConfigFileSnapshot(); + expect(snapshot.raw).toBe(bytes); + expect(snapshot.diagnostics.source).toBe("file"); + expect(snapshot.diagnostics.error).toBeNull(); + expect(snapshot.diagnostics.config).toEqual(loadConfig()); + const exported = home.path("export.json"); + expect(await handleConfigCommand(["export", exported, "--json"])).toBe(0); + expect(JSON.parse(readFileSync(exported, "utf8"))).toEqual(snapshot.diagnostics.config); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + expect(await handleConfigCommand(["set", "port", "10201", "--json"])).toBe(0); + const persisted = JSON.parse(readFileSync(getConfigPath(), "utf8")); + expect(persisted.port).toBe(10201); + expect(Object.keys(persisted.providers).sort()).toEqual(["opencode-zen", "saved-gateway"]); + expect(persisted.providers["opencode-zen"]).toMatchObject({ + apiKey: raw.providers["opencode-zen"].apiKey, note: "Saved Zen provider", + }); + expect(persisted.providers["opencode-zen"].modelAdapters).toBeUndefined(); + expect(persisted.providers["saved-gateway"]).toEqual(raw.providers["saved-gateway"]); + expect(persisted.codexAccounts).toEqual(raw.codexAccounts); + expect(persisted.codexAccountPriorities).toEqual(raw.codexAccountPriorities); + expect(persisted.activeCodexAccountPinned).toBe(raw.activeCodexAccountPinned); + expect(persisted.pausedCodexAccountIds).toEqual(raw.pausedCodexAccountIds); + expect(readConfigDiagnostics()).toMatchObject({ source: "file", error: null }); + } finally { + warn.mockRestore(); out.mockRestore(); err.mockRestore(); process.exitCode = previousExit; + await flushConfigDirHardeningAndReaps(home.root); + home.remove(); + } + }); + + test.each(["model", "provider"])("migrates only valid legacy Gemini hosted preferences from %s Responses", mode => { + const home = createTempHome("ocx-zen-hosted-upgrade-"); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + const unrelated = mode === "model" ? { "gpt-6.1-sol": ["image_generation"] } : {}; + const raw = candidate(mode === "model" + ? { "gemini-3.7-flash": "openai-responses", "gpt-6.1-sol": "openai-responses" } : undefined, { + adapter: mode === "provider" ? "openai-responses" : "openai-chat", + modelPreferHostedTools: { "gemini-3.7-flash": ["image_generation"], ...unrelated }, + }); + const bytes = JSON.stringify(raw); + writeFileSync(getConfigPath(), bytes); + const loaded = loadConfig(); + expect(Object.keys(loaded.providers).sort()).toEqual(["opencode-zen", "saved-gateway"]); + expect(loaded.codexAccounts).toEqual(raw.codexAccounts); + expect(loaded.providers["opencode-zen"].modelPreferHostedTools).toEqual(mode === "model" ? unrelated : undefined); + expect(loaded.providers["saved-gateway"]).toEqual(raw.providers["saved-gateway"]); + expect(readConfigDiagnostics()).toMatchObject({ source: "file", error: null, config: loaded }); + expect(readFileSync(getConfigPath(), "utf8")).toBe(bytes); + } finally { warn.mockRestore(); home.remove(); } + }); + + test("new writes still reject Google-incompatible hosted preferences without mutating the candidate", () => { + const raw = candidate(undefined, { modelPreferHostedTools: { "gemini-3.7-flash": ["image_generation"] } }); + const original = structuredClone(raw); + const result = validateConfigCandidate(raw); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("modelPreferHostedTools"); + expect(raw).toEqual(original); + }); + + test.each(["openai-chat", "openai-responses"])("strict candidate validation still rejects a new %s override without modifying it", adapter => { + for (const model of ["gemini-3.7-flash", "GeMiNi-future", " GEMINI-3.6-flash "]) { + const raw = candidate({ [model]: adapter }); + const original = structuredClone(raw); + const result = validateConfigCandidate(raw); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("modelAdapters"); + expect(result.error).toContain("only speaks one wire"); + } + expect(raw).toEqual(original); + } + }); +}); + +describe("OpenCode Zen load sanitizer boundaries", () => { + const exclusions: Array<[string, Record]> = [ + ["custom destination", { baseUrl: "https://gateway.example.test/v1" }], + ["Go destination", { baseUrl: "https://opencode.ai/zen/go/v1" }], + ["lookalike host", { baseUrl: "https://opencode.ai.evil.test/zen/v1" }], + ["query-bearing URL", { baseUrl: "https://opencode.ai/zen/v1?route=other" }], + ["OAuth", { authMode: "oauth" }], + ["forward auth", { authMode: "forward" }], + ["no auth", { authMode: "none" }], + ["Vertex", { googleMode: "vertex" }], + ["Cloud Code Assist", { googleMode: "cloud-code-assist" }], + ]; + test.each(exclusions)("preserves overrides and emits no upgrade warning for %s", (_label, overrides) => { + const raw = candidate({ " GeMiNi-3.7-flash ": "openai-responses" }, { + ...overrides, modelPreferHostedTools: { " GeMiNi-3.7-flash ": ["image_generation"] }, + }); + const original = structuredClone(raw); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw).toEqual(original); + expect(warn.mock.calls).toEqual([]); + } finally { warn.mockRestore(); } + }); + + test("only the canonical provider name is migrated", () => { + const raw = { providers: { "my-zen": candidate({ "gemini-3.7-flash": "openai-chat" }).providers["opencode-zen"] } }; + const original = structuredClone(raw); + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw).toEqual(original); + }); + + test("invalid overrides remain visible to strict validation while obsolete allowed entries are removed", () => { + const retained = { "gemini-invalid": "google", "gemini-spaced": " openai-chat", "gemini-number": 42, + "unrelated-model": "bogus", "not-gemini-model": "openai-chat", " ": "openai-responses" }; + const raw = candidate({ ...retained, "gemini-3.7-flash": "openai-responses" }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw.providers["opencode-zen"].modelAdapters).toEqual(retained); + expect(validateConfigCandidate(raw).ok).toBe(false); + expect(warn.mock.calls).toEqual([[WARNING]]); + } finally { warn.mockRestore(); } + }); + + test("malformed, denied and non-Responses Gemini preferences remain subject to validation", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + for (const value of [null, "image_generation", [], ["web_search"], ["image_generation", 42]]) { + const raw = candidate({ "gemini-3.7-flash": "openai-responses" }, { + modelPreferHostedTools: { "gemini-3.7-flash": value }, + }); + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw.providers["opencode-zen"]).toMatchObject({ modelPreferHostedTools: { "gemini-3.7-flash": value } }); + expect(validateConfigCandidate(raw).ok).toBe(false); + } + for (const overrides of [ + { unsupportedHostedTools: ["image_gen"], modelAdapters: { "gemini-3.7-flash": "openai-responses" } }, + { modelAdapters: undefined }, + { adapter: "openai-responses", modelAdapters: { "gemini-3.7-flash": "openai-chat" } }, + ]) { + const prefs = { "gemini-3.7-flash": ["image_generation"] }; + const raw = candidate(undefined, { ...overrides, modelPreferHostedTools: prefs }); + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw.providers["opencode-zen"]).toMatchObject({ modelPreferHostedTools: prefs }); + const once = structuredClone(raw); + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw).toEqual(once); + expect(validateConfigCandidate(raw).ok).toBe(false); + } + } finally { warn.mockRestore(); } + }); + + test("malformed hosted-tool denials do not throw before load degradation", () => { + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + for (const unsupportedHostedTools of [null, 42, "image_gen", {}, [null, 42]]) { + const raw = candidate({ "gemini-3.7-flash": "openai-responses" }, { + unsupportedHostedTools, modelPreferHostedTools: { "gemini-3.7-flash": ["image_generation"] }, + }); + expect(() => sanitizeOpenCodeZenOverridesForLoad(raw)).not.toThrow(); + expect(raw.providers["opencode-zen"]).toMatchObject({ unsupportedHostedTools }); + expect(Object.hasOwn(raw.providers["opencode-zen"], "modelPreferHostedTools")).toBe(false); + } + } finally { warn.mockRestore(); } + }); + test("malformed containers and maps are left untouched", () => { + for (const raw of [undefined, null, false, [], {}, { providers: null }, { providers: [] }, + { providers: { "opencode-zen": null } }, { providers: { "opencode-zen": [] } }, + ...[undefined, null, "openai-chat", 42, ["openai-chat"]].map(map => candidate(map))]) { + const original = structuredClone(raw); + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw).toEqual(original); + } + }); + + test("warns once with fixed text, removes an empty map and is idempotent", () => { + const raw = candidate({ "gemini-sensitive-fixture": "openai-chat", "gemini-other-fixture": "openai-responses" }, + { baseUrl: " https://opencode.ai/zen/v1/// " }); + const warn = spyOn(console, "warn").mockImplementation(() => {}); + try { + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(Object.hasOwn(raw.providers["opencode-zen"], "modelAdapters")).toBe(false); + const sanitized = structuredClone(raw); + sanitizeOpenCodeZenOverridesForLoad(raw); + expect(raw).toEqual(sanitized); + expect(warn.mock.calls).toEqual([[WARNING]]); + } finally { warn.mockRestore(); } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 210221f4d50..032da2c55db 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -517,7 +517,7 @@ "compatibility-manifest.test.ts": "codex-integration", "compatibility-provider-equivalence.test.ts": "routing", "compatibility-version.test.ts": "ci-workflows", "config-account-thresholds.test.ts": "config", "config-blocked-model-redirects.test.ts": "config", "config-catalog-auto-refresh.test.ts": "config", - "config-commandcode-claude-pin.test.ts": "config", "config-load-degrade.test.ts": "config", + "config-commandcode-claude-pin.test.ts": "config", "config-opencode-zen-upgrade.test.ts": "config", "config-load-degrade.test.ts": "config", "protocol-settings.test.ts": "config", "config-mutation-lock.test.ts": "config", "config-non-object-backup.test.ts": "config", "config-ownership-uninstall.test.ts": "config", "config-rebase-provenance-writers.test.ts": "config", "config-save-boundary.test.ts": "config", @@ -873,7 +873,7 @@ "opencode-go-luna-wire.test.ts": "providers", "opencode-go-muse-context.test.ts": "providers", "opencode-go-muse-vision.test.ts": "providers", "opencode-go-quota.test.ts": "providers", "opencode-go-session-header.test.ts": "providers", "opencode-management-transport.test.ts": "providers", - "opencode-zen-deepseek-reasoning.test.ts": "providers", "opencode-zen-rate-limit.test.ts": "providers", + "opencode-zen-deepseek-reasoning.test.ts": "providers", "opencode-zen-gemini-wire.test.ts": "providers", "opencode-zen-rate-limit.test.ts": "providers", "opengateway-provider.test.ts": "providers", "opengateway-key-validation.test.ts": "providers", "openrouter-provider-routing.test.ts": "providers", "openrouter-quota-reset-cooldown-4024.test.ts": "providers", "opper-provider.test.ts": "providers", "tokenlab-protocols.test.ts": "providers", diff --git a/tests/providers/opencode-zen-gemini-wire.test.ts b/tests/providers/opencode-zen-gemini-wire.test.ts new file mode 100644 index 00000000000..fb5dd4cdc59 --- /dev/null +++ b/tests/providers/opencode-zen-gemini-wire.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createGoogleAdapter } from "../../src/adapters/google"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { resolveFastPolicy } from "../../src/providers/fastwire"; +import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../src/providers/registry"; +import { resolveModelPolicy } from "../../src/providers/resolved-model-policy"; +import { captureFastPolicyAuthority, fastPolicyForModel, serviceTierSupportForModel } from "../../src/providers/service-tier"; +import { parseRequest } from "../../src/responses/parser"; +import { resolveWireProtocolOverride } from "../../src/server/adapter-resolve"; +import { handleResponses } from "../../src/server/responses/core"; +import type { OcxConfig, OcxProviderConfig } from "../../src/types"; +import { captureWireAdapterHardPinPrefixes, isWirePinnedModel, pinnedWireAdapter } from "../../src/types/wire"; +import { openCodeZenBaseUrl, openCodeZenGeminiEndpoint } from "../../src/types/opencode-zen"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; + +const BASES = ["https://opencode.ai/zen/v1", "https://opencode.ai/inference/v1"]; +const MODELS = ["gemini-3.6-flash", "gemini-3.7-flash", "gemini-3.8-flash", "gemini-4-argon", "Gemini-future"]; +const INBOUNDS = ["responses", "chat", "anthropic"] as const; + +function zen(overrides: Partial = {}): OcxProviderConfig { + return { ...providerConfigSeed(getProviderRegistryEntry("opencode-zen")!), apiKey: "synthetic-zen-key", ...overrides }; +} + +function staticPolicy(providerName: string, provider: OcxProviderConfig, modelId: string, inboundWire: typeof INBOUNDS[number]) { + return resolveModelPolicy({ + providerName, provider, modelId, inboundWire, + registryEntry: getProviderRegistryEntry(providerName), + transportMatchedRegistry: providerMatchesRegistryTransport(providerName, provider), + }); +} + +function expectWire(providerName: string, provider: OcxProviderConfig, modelId: string, adapter: string) { + for (const inbound of INBOUNDS) { + const runtime = resolveWireProtocolOverride(providerName, modelId, provider, inbound); + const policy = staticPolicy(providerName, provider, modelId, inbound); + expect(runtime.adapter).toBe(adapter); + expect(resolveWireProtocolOverride(providerName, modelId, runtime, inbound).adapter).toBe(adapter); + expect(policy.model.adapter).toBe(adapter); + expect(resolveWireProtocolOverride(providerName, modelId, provider, inbound, policy).adapter).toBe(adapter); + expect(fastPolicyForModel(provider, modelId, providerName, inbound).adapter).toBe(adapter); + const authority = captureFastPolicyAuthority(providerName, provider, providerMatchesRegistryTransport(providerName, provider)); + expect(resolveFastPolicy(authority, modelId, inbound).adapter).toBe(adapter); + } +} + +describe("OpenCode Zen Gemini destination and wire policy", () => { + test.each(BASES)("pins Gemini models at %s across runtime, static and Fast policy", baseUrl => { + for (const model of MODELS) for (const override of ["openai-chat", "openai-responses"]) { + const provider = zen({ baseUrl, modelAdapters: { [model]: override }, supportsServiceTier: true }); + expectWire("opencode-zen", provider, model, "google"); + expect(isWirePinnedModel("opencode-zen", model, provider)).toBe(true); + expect(pinnedWireAdapter("opencode-zen", model, provider)).toBe("google"); + expect(staticPolicy("opencode-zen", provider, model, "responses").provenance.model.adapter).toBe("hard-pin"); + expect(serviceTierSupportForModel(provider, model, "opencode-zen")).toBe(false); + expect(fastPolicyForModel(provider, model, "opencode-zen").fastWire).toBeNull(); + } + }); + + test.each(BASES)("normalizes official URL %s with omitted or explicit key/AI Studio mode", baseUrl => { + for (const explicit of [false, true]) { + const provider = zen({ baseUrl: ` ${baseUrl}/// `, authMode: explicit ? "key" : undefined, + googleMode: explicit ? "ai-studio" : undefined }); + expect(openCodeZenBaseUrl(provider)).toBe(baseUrl); + expect(openCodeZenGeminiEndpoint(provider, "gemini-future/preview")).toBe(`${baseUrl}/models/gemini-future%2Fpreview`); + expectWire("opencode-zen", provider, "gemini-3.7-flash", "google"); + } + }); + + const unrelatedDestinations = [ + "https://custom.example.test/v1", "http://opencode.ai/zen/v1", "https://opencode.ai.evil.test/zen/v1", + "https://opencode.ai:8443/zen/v1", "https://opencode.ai/zen/go/v1", "https://opencode.ai/zen/v1/extra", + "https://opencode.ai/ZEN/v1", "https://opencode.ai/zen/v1?route=other", "https://opencode.ai/zen/v1#other", + "https://user:pass" + "@opencode.ai/zen/v1", "https://opencode.ai@evil.test/zen/v1", "not a URL", + ]; + test.each(unrelatedDestinations)("does not impose the native wire on %s", baseUrl => { + const model = "gemini-3.7-flash"; + const provider = zen({ baseUrl, modelAdapters: { [model]: "openai-responses" } }); + expect(openCodeZenBaseUrl(provider)).toBeUndefined(); + expect(openCodeZenGeminiEndpoint(provider, model)).toBeUndefined(); + expect(captureWireAdapterHardPinPrefixes("opencode-zen", provider)).toEqual({}); + expect(isWirePinnedModel("opencode-zen", model, provider)).toBe(false); + expectWire("opencode-zen", provider, model, "openai-responses"); + }); + + test("non-key auth and non-AI Studio modes do not acquire a Zen pin", () => { + for (const changes of [ + { authMode: "oauth" }, { authMode: "forward" }, { authMode: "local" }, + { googleMode: "vertex" }, { googleMode: "cloud-code-assist" }, + ] as Partial[]) { + const provider = zen({ ...changes, modelAdapters: { "gemini-3.7-flash": "openai-responses" } }); + expect(openCodeZenBaseUrl(provider)).toBeUndefined(); + expect(openCodeZenGeminiEndpoint(provider, "gemini-3.7-flash")).toBeUndefined(); + expectWire("opencode-zen", provider, "gemini-3.7-flash", "openai-responses"); + } + expect(openCodeZenBaseUrl(undefined)).toBeUndefined(); + expect(openCodeZenBaseUrl({ baseUrl: 123 })).toBeUndefined(); + }); + + test("captured prefix authority is detached from later destination changes", () => { + const provider = zen({ modelAdapters: { "gemini-3.7-flash": "openai-responses" } }); + const prefixes = captureWireAdapterHardPinPrefixes("opencode-zen", provider); + const authority = captureFastPolicyAuthority("opencode-zen", provider, true); + provider.baseUrl = "https://custom.example.test/v1"; + expect(Object.isFrozen(prefixes)).toBe(true); + expect(prefixes).toEqual({ "gemini-": "google" }); + expect(resolveFastPolicy(authority, "gemini-3.7-flash").adapter).toBe("google"); + expect(fastPolicyForModel(provider, "gemini-3.7-flash", "opencode-zen").adapter).toBe("openai-responses"); + }); + + test("Zen siblings and other provider identities retain their existing wires", () => { + for (const model of ["claude-sonnet-5-5", "gpt-6.1-sol", "deepseek-v4-flash", "not-gemini-3.7-flash"]) { + expect(openCodeZenGeminiEndpoint(zen(), model)).toBeUndefined(); + expectWire("opencode-zen", zen(), model, "openai-chat"); + expectWire("opencode-zen", zen({ modelAdapters: { [model]: "openai-responses" } }), model, "openai-responses"); + } + for (const name of ["my-zen", "opencode-free", "opencode-go"]) { + expectWire(name, zen({ modelAdapters: { "gemini-3.7-flash": "openai-chat" } }), "gemini-3.7-flash", "openai-chat"); + } + const go = providerConfigSeed(getProviderRegistryEntry("opencode-go")!); + expectWire("opencode-go", go, "gemini-3.7-flash", "openai-chat"); + for (const model of ["claude-haiku-5-5", "minimax-m2.7", "union-alpha"]) { + expectWire("opencode-go", { ...go, modelAdapters: { [model]: "openai-responses" } }, model, "anthropic"); + } + }); +}); + +describe("OpenCode Zen Gemini native request", () => { + for (const baseUrl of BASES) for (const model of MODELS) for (const stream of [false, true]) { + test(`${baseUrl} preserves ${model} in ${stream ? "streaming" : "buffered"} native requests`, async () => { + const provider = resolveWireProtocolOverride("opencode-zen", model, zen({ baseUrl, apiKey: " synthetic-zen-key " })); + const adapter = withTestTranslatorBudget(createGoogleAdapter(provider)); + const request = await adapter.buildRequest(parseRequest({ model, input: "hello", stream, service_tier: "priority" })); + expect(request.url).toBe(`${baseUrl}/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`); + expect(request.method).toBe("POST"); + const headers = new Headers(request.headers); + expect(headers.get("x-goog-api-key")).toBe("synthetic-zen-key"); + expect(headers.has("authorization")).toBe(false); + const body = JSON.parse(request.body); + expect(body.contents).toContainEqual({ role: "user", parts: [{ text: "hello" }] }); + for (const key of ["messages", "input", "model", "stream", "service_tier"]) expect(body[key]).toBeUndefined(); + }); + } + + test("rejects an empty Zen key before any send", async () => { + const adapter = withTestTranslatorBudget(createGoogleAdapter(zen({ adapter: "google", apiKey: " " }))); + await expect(adapter.buildRequest(parseRequest({ model: "gemini-3.7-flash", input: "hello" }))) + .rejects.toThrow("OpenCode Zen requires a non-empty API key"); + }); + + test("direct AI Studio, custom Google and Vertex keep their URL and model rules", async () => { + for (const model of ["gemini-3.6-flash", "gemini-3.7-flash"]) for (const stream of [true, false]) { + for (const baseUrl of ["https://generativelanguage.googleapis.com", "https://custom.example.test/v1"]) { + const adapter = withTestTranslatorBudget(createGoogleAdapter({ adapter: "google", baseUrl, apiKey: "synthetic-google-key" })); + const request = await adapter.buildRequest(parseRequest({ model, input: "hello", stream })); + expect(request.url).toBe(`${baseUrl}/v1beta/models/${model}-tiered:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`); + } + const vertex = withTestTranslatorBudget(createGoogleAdapter(zen({ adapter: "google", googleMode: "vertex" }))); + const request = await vertex.buildRequest(parseRequest({ model, input: "hello", stream })); + expect(request.url).toBe(`https://aiplatform.googleapis.com/v1/publishers/google/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`); + } + }); +}); + +describe("OpenCode Zen Gemini Responses translation", () => { + const originalFetch = globalThis.fetch; + let releaseSpendHome: (() => void) | undefined; + afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + }); + + // Routing canonicalizes the fixed Zen preset to /zen/v1; both accepted endpoint + // spellings are exercised without that registry normalization in the adapter cases above. + for (const baseUrl of [BASES[0]!]) for (const stream of [true, false]) { + test(`${baseUrl} translates text and a two-turn tool continuation (${stream ? "SSE" : "JSON"})`, async () => { + const model = "gemini-3.7-flash"; + const requests: Array<{ url: string; headers: Headers; body: Record }> = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), headers: new Headers(init?.headers), body: JSON.parse(String(init?.body)) }); + const parts = requests.length === 1 + ? [{ text: "Checking weather." }, { functionCall: { id: "zen_weather_call", name: "weather", args: { city: "Paris" } }, thoughtSignature: "synthetic-zen-tool-signature-0123456789" }] + : [{ text: "Paris is sunny." }]; + const payload = { candidates: [{ content: { role: "model", parts }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 } }; + return stream + ? new Response(`data: ${JSON.stringify(payload)}\n\n`, { headers: { "content-type": "text/event-stream" } }) + : Response.json(payload); + }) as typeof fetch; + releaseSpendHome = acquireOwnedSpendHome(); + const config = { providers: { "opencode-zen": zen({ baseUrl, modelAdapters: { [model]: "openai-responses" } }) } } as unknown as OcxConfig; + const drive = async (input: unknown[]) => { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: `opencode-zen/${model}`, input, stream, + tools: [{ type: "function", name: "weather", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } }] }), + }), config, { model: "", provider: "" }, { inboundWire: "responses" }); + const text = await response.text(); + expect(response.status, text).toBe(200); + if (!stream) return JSON.parse(text); + const events = text.split("\n").filter(line => line.startsWith("data: {")).map(line => JSON.parse(line.slice(6))); + const completed = events.find(event => event.type === "response.completed"); + expect(completed).toBeDefined(); + return completed.response; + }; + const initial = { role: "user", content: "Check Paris weather" }; + const first = await drive([initial]); + const call = first.output.find((item: { type: string }) => item.type === "function_call"); + expect(call).toMatchObject({ name: "weather", arguments: '{"city":"Paris"}' }); + expect(typeof call.call_id).toBe("string"); + expect(call.call_id, JSON.stringify(first.output)).not.toBe(""); + expect(JSON.stringify(first.output)).toContain("Checking weather."); + const second = await drive([initial, ...first.output, { type: "function_call_output", call_id: call.call_id, output: "sunny" }]); + expect(second.status).toBe("completed"); + expect(JSON.stringify(second.output)).toContain("Paris is sunny."); + expect(requests).toHaveLength(2); + for (const request of requests) { + expect(request.url).toBe(`${baseUrl}/models/${model}:${stream ? "streamGenerateContent?alt=sse" : "generateContent"}`); + expect(request.headers.get("x-goog-api-key")).toBe("synthetic-zen-key"); + expect(request.headers.has("authorization")).toBe(false); + expect(request.body.tools[0].functionDeclarations[0].name).toBe("weather"); + expect(request.body.messages).toBeUndefined(); + expect(request.body.input).toBeUndefined(); + } + const contents = requests[1]!.body.contents; + const callIndex = contents.findIndex((turn: { parts: Array> }) => turn.parts.some(part => part.functionCall)); + expect(callIndex).toBeGreaterThan(0); + expect(contents[callIndex]).toMatchObject({ role: "model", parts: expect.arrayContaining([ + expect.objectContaining({ functionCall: expect.objectContaining({ name: "weather", args: { city: "Paris" } }), thoughtSignature: "synthetic-zen-tool-signature-0123456789" }), + ]) }); + expect(contents[callIndex + 1]).toMatchObject({ role: "user", parts: expect.arrayContaining([ + expect.objectContaining({ functionResponse: expect.objectContaining({ name: "weather", response: { result: "sunny" } }) }), + ]) }); + }); + } +});