From 6d69514a8d1dd23f8a87495e6104fbba6acc7dac Mon Sep 17 00:00:00 2001 From: "timothy.vang" Date: Sat, 11 Jul 2026 16:27:30 +0000 Subject: [PATCH] Squashed 'engine/' changes from 3d9781b..0e98eac 0e98eac feat(opencode): emit used_fallback from native LLM runtime gate 8bcd306 Merge pull request #18 from TimothyVang/m27-cloud-local git-subtree-dir: engine git-subtree-split: 0e98eac62bdce929d828de7772ba34c096788a79 --- packages/opencode/src/session/llm.ts | 8 ++ .../src/session/llm/native-runtime.ts | 30 ++++-- .../opencode/test/session/llm-native.test.ts | 94 +++++++++++++++++-- 3 files changed, 116 insertions(+), 16 deletions(-) diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 33d10c8..0da0e56 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -246,9 +246,11 @@ const live: Layer.Layer< "llm.runtime": "native", "llm.provider": input.model.providerID, "llm.model": input.model.id, + "llm.used_fallback": native.used_fallback, }) return { type: "native" as const, + used_fallback: native.used_fallback, stream: native.stream, } } @@ -257,6 +259,7 @@ const live: Layer.Layer< "llm.provider": input.model.providerID, "llm.model": input.model.id, "llm.native_unsupported_reason": native.reason, + "llm.used_fallback": native.used_fallback, }) yield* Effect.logInfo("native runtime unavailable; falling back to ai-sdk", { providerID: input.model.providerID, @@ -266,6 +269,7 @@ const live: Layer.Layer< agent: input.agent.name, mode: input.agent.mode, reason: native.reason, + used_fallback: native.used_fallback, }) } @@ -273,11 +277,15 @@ const live: Layer.Layer< "llm.runtime": "ai-sdk", "llm.provider": input.model.providerID, "llm.model": input.model.id, + "llm.used_fallback": true, }) // Default runtime path: AI SDK owns provider execution and tool dispatch; // LLMAISDK.toLLMEvents below normalizes fullStream parts for the processor. + // used_fallback is true whenever this path runs (native gate rejected, or + // native not opted in). Downstream reads this field rather than inventing it. return { type: "ai-sdk" as const, + used_fallback: true as const, result: streamText({ onError(error) { bridge.fork( diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index ad0ed7f..1874708 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -20,11 +20,15 @@ import type { LLMClientShape } from "@opencode-ai/llm/route" import { LLMNative } from "./native-request" export type RuntimeStatus = - | { readonly type: "supported"; readonly apiKey: string; readonly baseURL?: string } - | { readonly type: "unsupported"; readonly reason: string } + | { readonly type: "supported"; readonly apiKey: string; readonly baseURL?: string; readonly used_fallback: false } + | { readonly type: "unsupported"; readonly reason: string; readonly used_fallback: true } export type StreamResult = - | { readonly type: "supported"; readonly stream: Stream.Stream } - | { readonly type: "unsupported"; readonly reason: string } + | { + readonly type: "supported" + readonly stream: Stream.Stream + readonly used_fallback: false + } + | { readonly type: "unsupported"; readonly reason: string; readonly used_fallback: true } type StreamInput = { readonly model: Provider.Model @@ -68,7 +72,7 @@ function statusWithFetch( ): RuntimeStatus { const npm = input.model.api.npm if (!NATIVE_NPM.has(npm)) - return { type: "unsupported", reason: `provider package is not natively supported: ${npm}` } + return unsupported(`provider package is not natively supported: ${npm}`) // OAuth policy: // - OpenAI OAuth + codex plugin `fetch` override can stay on the native path. @@ -76,28 +80,34 @@ function statusWithFetch( // refresh and bearer injection as the AI-SDK contract, not native Auth. // - All other OAuth without an OpenAI fetch override falls back fail-closed. if (input.auth?.type === "oauth") { - if (input.provider.id === "xai") - return { type: "unsupported", reason: "xAI OAuth uses AI SDK plugin fetch override" } + if (input.provider.id === "xai") return unsupported("xAI OAuth uses AI SDK plugin fetch override") if (!(input.provider.id === "openai" && fetch)) - return { type: "unsupported", reason: "OAuth auth requires a provider fetch override" } + return unsupported("OAuth auth requires a provider fetch override") } const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key - if (!apiKey) return { type: "unsupported", reason: "API key is not configured" } + if (!apiKey) return unsupported("API key is not configured") const baseURL = typeof input.provider.options.baseURL === "string" ? input.provider.options.baseURL : input.model.api.url || undefined - if (REQUIRES_BASE_URL.has(npm) && !baseURL) return { type: "unsupported", reason: "base URL is not configured" } + if (REQUIRES_BASE_URL.has(npm) && !baseURL) return unsupported("base URL is not configured") return { type: "supported", apiKey, baseURL, + used_fallback: false, } } +function unsupported(reason: string): RuntimeStatus { + // used_fallback is the runtime source of truth for AI SDK fallback: true only + // when this gate rejects native so the session LLM layer takes the AI SDK path. + return { type: "unsupported", reason, used_fallback: true } +} + export function stream(input: StreamInput): StreamResult { const fetch = providerFetch(input) const current = statusWithFetch(input, fetch) diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index f2be12d..3d27648 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -567,7 +567,7 @@ describe("session.llm-native.request", () => { provider: { ...providerInfo, options: {} }, auth: undefined, }), - ).toEqual({ type: "unsupported", reason: "API key is not configured" }) + ).toEqual({ type: "unsupported", reason: "API key is not configured", used_fallback: true }) expect( LLMNativeRuntime.status({ @@ -580,7 +580,7 @@ describe("session.llm-native.request", () => { provider: catalogProvider({ id: "ollama", apiKey: "ollama" }), auth: undefined, }), - ).toEqual({ type: "unsupported", reason: "base URL is not configured" }) + ).toEqual({ type: "unsupported", reason: "base URL is not configured", used_fallback: true }) expect( LLMNativeRuntime.status({ @@ -588,7 +588,7 @@ describe("session.llm-native.request", () => { provider: catalogProvider({ id: "azure", apiKey: "test-azure-key" }), auth: undefined, }), - ).toEqual({ type: "unsupported", reason: "base URL is not configured" }) + ).toEqual({ type: "unsupported", reason: "base URL is not configured", used_fallback: true }) expect( LLMNativeRuntime.status({ @@ -599,6 +599,80 @@ describe("session.llm-native.request", () => { ).toMatchObject({ type: "unsupported" }) }) + test("run result records used_fallback false for native and true for AI SDK fallback", () => { + // Source of truth for caseforge/downstream: native gate emits used_fallback so + // callers never invent the value. Native path → false; AI SDK fallback → true. + const native = LLMNativeRuntime.status({ + model: baseModel, + provider: providerInfo, + auth: undefined, + }) + expect(native).toMatchObject({ type: "supported", used_fallback: false }) + expect(typeof (native as { used_fallback?: unknown }).used_fallback).toBe("boolean") + + const missingKey = LLMNativeRuntime.status({ + model: baseModel, + provider: { ...providerInfo, options: {} }, + auth: undefined, + }) + expect(missingKey).toEqual({ + type: "unsupported", + reason: "API key is not configured", + used_fallback: true, + }) + + const missingBaseURL = LLMNativeRuntime.status({ + model: catalogModel({ + providerID: "ollama", + npm: "@ai-sdk/openai-compatible", + url: "", + id: "llama3.2", + }), + provider: catalogProvider({ id: "ollama", apiKey: "ollama" }), + auth: undefined, + }) + expect(missingBaseURL).toMatchObject({ type: "unsupported", used_fallback: true }) + + const unknownPackage = LLMNativeRuntime.status({ + model: catalogModel({ providerID: "custom", npm: "unknown-provider" }), + provider: catalogProvider({ id: "custom", apiKey: "key" }), + auth: undefined, + }) + expect(unknownPackage).toMatchObject({ type: "unsupported", used_fallback: true }) + + // stream() must carry the same field on both supported and unsupported results. + const client = { + stream: () => Stream.empty, + } as unknown as LLMClientShape + const supportedStream = LLMNativeRuntime.stream({ + model: baseModel, + provider: providerInfo, + auth: undefined, + llmClient: client, + messages: [], + tools: {}, + headers: {}, + abort: new AbortController().signal, + }) + expect(supportedStream).toMatchObject({ type: "supported", used_fallback: false }) + + const fallbackStream = LLMNativeRuntime.stream({ + model: baseModel, + provider: { ...providerInfo, options: {} }, + auth: undefined, + llmClient: client, + messages: [], + tools: {}, + headers: {}, + abort: new AbortController().signal, + }) + expect(fallbackStream).toMatchObject({ + type: "unsupported", + reason: "API key is not configured", + used_fallback: true, + }) + }) + test("falls back for OAuth and custom-fetch providers that are AI-SDK contracts", () => { expect( LLMNativeRuntime.status({ @@ -606,7 +680,11 @@ describe("session.llm-native.request", () => { provider: providerInfo, auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, }), - ).toEqual({ type: "unsupported", reason: "OAuth auth requires a provider fetch override" }) + ).toEqual({ + type: "unsupported", + reason: "OAuth auth requires a provider fetch override", + used_fallback: true, + }) // OpenAI OAuth with the codex plugin fetch override can stay on the native path. const dummyFetch = Object.assign(async () => new Response(), { @@ -619,7 +697,7 @@ describe("session.llm-native.request", () => { provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: dummyFetch } }, auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, }), - ).toMatchObject({ type: "supported", apiKey: OAUTH_DUMMY_KEY }) + ).toMatchObject({ type: "supported", apiKey: OAUTH_DUMMY_KEY, used_fallback: false }) // xAI OAuth intentionally stays on AI SDK: plugin fetch owns refresh + bearer injection. expect( @@ -632,7 +710,11 @@ describe("session.llm-native.request", () => { }), auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, }), - ).toEqual({ type: "unsupported", reason: "xAI OAuth uses AI SDK plugin fetch override" }) + ).toEqual({ + type: "unsupported", + reason: "xAI OAuth uses AI SDK plugin fetch override", + used_fallback: true, + }) }) test("enables native runtime for Anthropic API-key models", () => {