From facd207396ffa6587916779c7b256a6a485a786c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 20 May 2026 23:29:25 -0400 Subject: [PATCH] fix(opencode): support native OpenAI OAuth fetch (#28571) --- .../llm/src/protocols/openai-responses.ts | 3 + .../llm/src/protocols/utils/openai-options.ts | 5 + packages/opencode/src/session/llm.ts | 2 - .../src/session/llm/native-runtime.ts | 59 +-- packages/opencode/src/session/llm/request.ts | 2 - .../native-openai-oauth-tool-loop.json | 50 +++ .../test/session/llm-native-recorded.test.ts | 346 +++++++++++++----- .../opencode/test/session/llm-native.test.ts | 73 +++- packages/opencode/test/session/llm.test.ts | 39 +- 9 files changed, 418 insertions(+), 161 deletions(-) create mode 100644 packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 00575b4f2a..e92ad0bd80 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -82,6 +82,7 @@ const OpenAIResponsesToolChoice = Schema.Union([ const OpenAIResponsesCoreFields = { model: Schema.String, input: Schema.Array(OpenAIResponsesInputItem), + instructions: Schema.optional(Schema.String), tools: optionalArray(OpenAIResponsesTool), tool_choice: Schema.optional(OpenAIResponsesToolChoice), store: Schema.optional(Schema.Boolean), @@ -270,7 +271,9 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques const summary = OpenAIOptions.reasoningSummary(request) const encryptedState = OpenAIOptions.encryptedReasoning(request) const verbosity = OpenAIOptions.textVerbosity(request) + const instructions = OpenAIOptions.instructions(request) return { + ...(instructions ? { instructions } : {}), ...(store !== undefined ? { store } : {}), ...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}), ...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}), diff --git a/packages/llm/src/protocols/utils/openai-options.ts b/packages/llm/src/protocols/utils/openai-options.ts index 080ef83f50..59e201421d 100644 --- a/packages/llm/src/protocols/utils/openai-options.ts +++ b/packages/llm/src/protocols/utils/openai-options.ts @@ -52,4 +52,9 @@ export const textVerbosity = (request: LLMRequest) => { return isTextVerbosity(value) ? value : undefined } +export const instructions = (request: LLMRequest) => { + const value = options(request)?.instructions + return typeof value === "string" ? value : undefined +} + export * as OpenAIOptions from "./openai-options" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 23cefe1181..8e2759cdb6 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -220,8 +220,6 @@ const live: Layer.Layer< provider: item, auth: info, llmClient, - isOpenaiOauth: prepared.isOpenaiOauth, - system: prepared.system, messages: prepared.messages, tools: prepared.tools, toolChoice: input.toolChoice, diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index b0cc811d4e..8b55af3dd3 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -6,6 +6,7 @@ import { isRecord } from "@/util/record" import { asSchema, type ModelMessage, type Tool } from "ai" import { Effect } from "effect" import * as Stream from "effect/Stream" +import { FetchHttpClient } from "effect/unstable/http" import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm" import type { LLMClientShape } from "@opencode-ai/llm/route" import { LLMNative } from "./native-request" @@ -22,8 +23,6 @@ type StreamInput = { readonly provider: Provider.Info readonly auth: Auth.Info | undefined readonly llmClient: LLMClientShape - readonly isOpenaiOauth: boolean - readonly system: string[] readonly messages: ModelMessage[] readonly tools: Record readonly toolChoice?: "auto" | "required" | "none" @@ -37,13 +36,22 @@ type StreamInput = { } export function status(input: Pick): RuntimeStatus { + return statusWithFetch(input, providerFetch(input)) +} + +function statusWithFetch( + input: Pick, + fetch: typeof globalThis.fetch | undefined, +): RuntimeStatus { const providerID = input.model.providerID if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode")) return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" } const npm = input.model.api.npm if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic") return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" } - if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OAuth auth is not supported" } + if (input.auth?.type === "oauth" && !(input.provider.id === "openai" && fetch)) { + return { type: "unsupported", reason: "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" } @@ -56,33 +64,42 @@ export function status(input: Pick): } export function stream(input: StreamInput): StreamResult { - const current = status(input) + const fetch = providerFetch(input) + const current = statusWithFetch(input, fetch) if (current.type === "unsupported") return current // Integration point with @opencode-ai/llm: native-request lowers session data // into an LLMRequest, then LLMClient handles route selection and transport. + const stream = input.llmClient.stream({ + request: LLMNative.request({ + model: input.model, + apiKey: current.apiKey, + baseURL: current.baseURL, + messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}), + toolChoice: input.toolChoice, + temperature: input.temperature, + topP: input.topP, + topK: input.topK, + maxOutputTokens: input.maxOutputTokens, + providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}), + headers: { ...providerHeaders(input.provider.options.headers), ...input.headers }, + }), + tools: nativeTools(input.tools, input), + }) + return { ...current, - stream: input.llmClient.stream({ - request: LLMNative.request({ - model: input.model, - apiKey: current.apiKey, - baseURL: current.baseURL, - system: input.isOpenaiOauth ? input.system : [], - messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}), - toolChoice: input.toolChoice, - temperature: input.temperature, - topP: input.topP, - topK: input.topK, - maxOutputTokens: input.maxOutputTokens, - providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}), - headers: { ...providerHeaders(input.provider.options.headers), ...input.headers }, - }), - tools: nativeTools(input.tools, input), - }), + stream: fetch ? stream.pipe(Stream.provideService(FetchHttpClient.Fetch, fetch)) : stream, } } +function providerFetch(input: Pick): typeof globalThis.fetch | undefined { + if (input.provider.id !== "openai" || input.auth?.type !== "oauth") return undefined + const value: unknown = input.provider.options.fetch + if (typeof value !== "function") return undefined + return value as typeof globalThis.fetch +} + function providerHeaders(value: unknown): Record | undefined { if (!isRecord(value)) return undefined return Object.fromEntries( diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts index 97a539eadc..3471342405 100644 --- a/packages/opencode/src/session/llm/request.ts +++ b/packages/opencode/src/session/llm/request.ts @@ -34,7 +34,6 @@ type PrepareInput = { } export type Prepared = { - readonly isOpenaiOauth: boolean readonly system: string[] readonly messages: ModelMessage[] readonly tools: Record @@ -161,7 +160,6 @@ export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: Pre : undefined return { - isOpenaiOauth, system, messages, tools: Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))), diff --git a/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json b/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json new file mode 100644 index 0000000000..8af3c2ff99 --- /dev/null +++ b/packages/opencode/test/fixtures/recordings/session/native-openai-oauth-tool-loop.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "metadata": { + "name": "session/native-openai-oauth-tool-loop", + "recordedAt": "2026-05-21T02:54:28.750Z", + "provider": "openai", + "protocol": "openai-responses", + "route": "openai-responses", + "tags": [ + "opencode", + "native", + "oauth", + "tool-loop" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" + }, + "response": { + "status": 200, + "headers": {}, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_078b0cf8bc0b92e3016a0e73e22dfc8197946dc7d2595b6377\",\"object\":\"response\",\"created_at\":1779332066,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-5.5\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":\"user_redacted\",\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get the current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_078b0cf8bc0b92e3016a0e73e22dfc8197946dc7d2595b6377\",\"object\":\"response\",\"created_at\":1779332066,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-5.5\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":\"user_redacted\",\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get the current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_078b0cf8bc0b92e3016a0e73e29f7481978b4a18db2fdc0efb\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_078b0cf8bc0b92e3016a0e73e29f7481978b4a18db2fdc0efb\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_078b0cf8bc0b92e3016a0e73e387f08197ae5e4a3ab1722cfc\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"name\":\"get_weather\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_078b0cf8bc0b92e3016a0e73e387f08197ae5e4a3ab1722cfc\",\"obfuscation\":\"xQbYlQvqTxeStq\",\"output_index\":1,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_078b0cf8bc0b92e3016a0e73e387f08197ae5e4a3ab1722cfc\",\"obfuscation\":\"JrpqT0bok2Uw\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_078b0cf8bc0b92e3016a0e73e387f08197ae5e4a3ab1722cfc\",\"obfuscation\":\"S51bUUcILhMeW\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_078b0cf8bc0b92e3016a0e73e387f08197ae5e4a3ab1722cfc\",\"obfuscation\":\"CPdhriq2qEZ\",\"output_index\":1,\"sequence_number\":8}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_078b0cf8bc0b92e3016a0e73e387f08197ae5e4a3ab1722cfc\",\"obfuscation\":\"TbYinZEgf1yNUm\",\"output_index\":1,\"sequence_number\":9}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_078b0cf8bc0b92e3016a0e73e387f08197ae5e4a3ab1722cfc\",\"output_index\":1,\"sequence_number\":10}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_078b0cf8bc0b92e3016a0e73e387f08197ae5e4a3ab1722cfc\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"name\":\"get_weather\"},\"output_index\":1,\"sequence_number\":11}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_078b0cf8bc0b92e3016a0e73e22dfc8197946dc7d2595b6377\",\"object\":\"response\",\"created_at\":1779332066,\"status\":\"completed\",\"background\":false,\"completed_at\":1779332067,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-5.5\",\"moderation\":null,\"output\":[{\"id\":\"rs_078b0cf8bc0b92e3016a0e73e29f7481978b4a18db2fdc0efb\",\"type\":\"reasoning\",\"summary\":[]},{\"id\":\"fc_078b0cf8bc0b92e3016a0e73e387f08197ae5e4a3ab1722cfc\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":\"user_redacted\",\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get the current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":82,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":43,\"output_tokens_details\":{\"reasoning_tokens\":23},\"total_tokens\":125},\"user\":null,\"metadata\":{}},\"sequence_number\":12}\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"function_call\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":{}}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_uoEsDnHNhxMLpCUy6hqEyHme\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get the current weather for a city.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false}}],\"store\":false,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"stream\":true}" + }, + "response": { + "status": 200, + "headers": {}, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_01964025bf685c56016a0e73e3e5cc8190a75242475dabd71b\",\"object\":\"response\",\"created_at\":1779332067,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-5.5\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":\"user_redacted\",\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get the current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_01964025bf685c56016a0e73e3e5cc8190a75242475dabd71b\",\"object\":\"response\",\"created_at\":1779332067,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-5.5\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":\"user_redacted\",\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get the current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_01964025bf685c56016a0e73e4925c8190a4494954e3a71c52\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_01964025bf685c56016a0e73e4925c8190a4494954e3a71c52\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Paris\",\"item_id\":\"msg_01964025bf685c56016a0e73e4925c8190a4494954e3a71c52\",\"logprobs\":[],\"obfuscation\":\"SegyCJkHJ3z\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" is\",\"item_id\":\"msg_01964025bf685c56016a0e73e4925c8190a4494954e3a71c52\",\"logprobs\":[],\"obfuscation\":\"GVCI2Rf3LYRq5\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" sunny\",\"item_id\":\"msg_01964025bf685c56016a0e73e4925c8190a4494954e3a71c52\",\"logprobs\":[],\"obfuscation\":\"6W6fMLPcd6\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_01964025bf685c56016a0e73e4925c8190a4494954e3a71c52\",\"logprobs\":[],\"obfuscation\":\"SG9U7IAJhqipjOU\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_01964025bf685c56016a0e73e4925c8190a4494954e3a71c52\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":8,\"text\":\"Paris is sunny.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_01964025bf685c56016a0e73e4925c8190a4494954e3a71c52\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_01964025bf685c56016a0e73e4925c8190a4494954e3a71c52\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_01964025bf685c56016a0e73e3e5cc8190a75242475dabd71b\",\"object\":\"response\",\"created_at\":1779332067,\"status\":\"completed\",\"background\":false,\"completed_at\":1779332068,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":\"Answer using tools when appropriate.\\nUse the get_weather tool exactly once to look up Paris, then reply with exactly: Paris is sunny.\",\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-5.5\",\"moderation\":null,\"output\":[{\"id\":\"msg_01964025bf685c56016a0e73e4925c8190a4494954e3a71c52\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"session-recorded-openai-oauth-loop\",\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":\"user_redacted\",\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get the current weather for a city.\",\"name\":\"get_weather\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":121,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":8,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":129},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + } + } + ] +} diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 4fe54dc4f3..75dab5486c 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -1,8 +1,10 @@ import { NodeFileSystem } from "@effect/platform-node" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { ModelsDev } from "@opencode-ai/core/models-dev" import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder" -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { tool, type ModelMessage, type JSONValue } from "ai" -import { Effect, Layer, Stream } from "effect" +import { Effect, Layer, Option, Schema, Stream } from "effect" import { FetchHttpClient } from "effect/unstable/http" import path from "node:path" import z from "zod" @@ -14,12 +16,12 @@ import { ModelID, ProviderID } from "@/provider/schema" import { Filesystem } from "@/util/filesystem" import { LLMEvent, LLMResponse } from "@opencode-ai/llm" import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" +import { Env } from "@/env" import { RuntimeFlags } from "@/effect/runtime-flags" import type { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { MessageV2 } from "../../src/session/message-v2" import { MessageID, SessionID } from "../../src/session/schema" -import type { ModelsDev } from "@opencode-ai/core/models-dev" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -27,106 +29,232 @@ const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings") const zenURL = (connection: string) => `https://console.opencode.ai/proxy/connections/${connection}/v1` -type ProviderSpec = { +const replayOpenAIOAuth = { + type: "oauth", + refresh: "fixture-refresh-token", + access: "fixture-access-token", + expires: Date.now() + 60 * 60 * 1000, + accountId: "fixture-account", +} satisfies Auth.Info + +type RecordedScenario = { + readonly id: string + readonly name: string readonly providerID: ProviderID readonly modelID: string readonly cassette: string readonly protocol: string readonly tags: ReadonlyArray - readonly canRecord: boolean + readonly canRecord: () => boolean + readonly recordAuth?: () => Auth.Info | undefined + readonly replayAuth?: Auth.Info + readonly stableID?: string readonly config: (model: ModelsDev.Provider["models"][string]) => Partial } -const cloneModel = (model: ModelsDev.Provider["models"][string]) => - structuredClone(model) as NonNullable[string]["models"]>[string] +const cloneModel = (model: ModelsDev.Provider["models"][string]) => { + const cloned = structuredClone(model) + const { experimental, ...rest } = cloned + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- The config schema accepts the same model shape except object-valued experimental metadata. + if (typeof experimental === "boolean") + return cloned as NonNullable[string]["models"]>[string] + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Dropping non-boolean experimental metadata makes the fixture model match config input. + return rest as NonNullable[string]["models"]>[string] +} -const PROVIDERS = { - openai: { +const envValue = (...names: string[]) => names.map((name) => process.env[name]).find(Boolean) +const decodeAuth = Schema.decodeUnknownOption(Auth.Info) +const recordOpenAIOAuth = (() => { + let loaded = false + let auth: Auth.Info | undefined + return () => { + if (loaded) return auth + loaded = true + auth = decodeRecordOpenAIOAuth() + return auth + } +})() + +function decodeRecordOpenAIOAuth() { + const value = process.env.OPENCODE_RECORD_OPENAI_AUTH + if (!value) return undefined + try { + const auth = Option.getOrUndefined(decodeAuth(JSON.parse(value))) + return auth?.type === "oauth" ? auth : undefined + } catch { + return undefined + } +} + +const providerConfig = (input: { + readonly providerID: ProviderID + readonly name: string + readonly env: string[] + readonly npm: string + readonly api: string + readonly model: ModelsDev.Provider["models"][string] + readonly options: Record +}): Partial => ({ + enabled_providers: [input.providerID], + provider: { + [input.providerID]: { + name: input.name, + env: input.env, + npm: input.npm, + api: input.api, + models: { [input.model.id]: cloneModel(input.model) }, + options: input.options, + }, + }, +}) + +const RECORDED_SCENARIOS = [ + { + id: "openai-api-key", + name: "OpenAI API key", providerID: ProviderID.openai, modelID: "gpt-4.1-mini", cassette: "session/native-openai-tool-loop", protocol: "openai-responses", tags: ["opencode", "native", "tool-loop"], - canRecord: Boolean(process.env.OPENCODE_RECORD_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY), - config: (model) => ({ - enabled_providers: ["openai"], - provider: { - openai: { - name: "OpenAI", - env: ["OPENAI_API_KEY"], - npm: "@ai-sdk/openai", - api: "https://api.openai.com/v1", - models: { [model.id]: cloneModel(model) }, - options: { - apiKey: process.env.OPENCODE_RECORD_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY ?? "fixture-openai-key", - baseURL: "https://api.openai.com/v1", - }, + canRecord: () => Boolean(envValue("OPENCODE_RECORD_OPENAI_API_KEY", "OPENAI_API_KEY")), + config: (model) => + providerConfig({ + providerID: ProviderID.openai, + name: "OpenAI", + env: ["OPENAI_API_KEY"], + npm: "@ai-sdk/openai", + api: "https://api.openai.com/v1", + model, + options: { + apiKey: envValue("OPENCODE_RECORD_OPENAI_API_KEY", "OPENAI_API_KEY") ?? "fixture-openai-key", + baseURL: "https://api.openai.com/v1", }, - }, - }), + }), }, - opencode: { + { + id: "openai-oauth", + name: "OpenAI OAuth", + providerID: ProviderID.openai, + modelID: "gpt-5.5", + cassette: "session/native-openai-oauth-tool-loop", + protocol: "openai-responses", + tags: ["opencode", "native", "oauth", "tool-loop"], + canRecord: () => recordOpenAIOAuth() !== undefined, + recordAuth: recordOpenAIOAuth, + replayAuth: replayOpenAIOAuth, + stableID: "openai-oauth", + config: (model) => + providerConfig({ + providerID: ProviderID.openai, + name: "OpenAI", + env: ["OPENAI_API_KEY"], + npm: "@ai-sdk/openai", + api: "https://api.openai.com/v1", + model, + options: { baseURL: "https://api.openai.com/v1" }, + }), + }, + { + id: "opencode-proxy", + name: "OpenCode proxy", providerID: ProviderID.opencode, modelID: "gpt-5.2-codex", cassette: "session/native-zen-tool-loop", protocol: "openai-responses", tags: ["opencode", "zen", "native", "tool-loop"], - canRecord: Boolean(process.env.OPENCODE_RECORD_CONSOLE_TOKEN && process.env.OPENCODE_RECORD_ZEN_ORG_ID), - config: (model) => ({ - enabled_providers: ["opencode"], - provider: { - opencode: { - name: "OpenCode Zen", - env: ["OPENCODE_CONSOLE_TOKEN"], - npm: "@ai-sdk/openai-compatible", - // The connection slug is account-specific; the cassette redactor - // normalizes it to {connection} for replay. Set during recording. - api: zenURL(process.env.OPENCODE_RECORD_ZEN_CONNECTION ?? "fixture"), - models: { [model.id]: cloneModel(model) }, - options: { - apiKey: process.env.OPENCODE_RECORD_CONSOLE_TOKEN ?? "fixture-console-token", - headers: { "x-org-id": process.env.OPENCODE_RECORD_ZEN_ORG_ID ?? "fixture-org" }, - }, + canRecord: () => Boolean(process.env.OPENCODE_RECORD_CONSOLE_TOKEN && process.env.OPENCODE_RECORD_ZEN_ORG_ID), + config: (model) => + providerConfig({ + providerID: ProviderID.opencode, + name: "OpenCode Zen", + env: ["OPENCODE_CONSOLE_TOKEN"], + npm: "@ai-sdk/openai-compatible", + api: zenURL(process.env.OPENCODE_RECORD_ZEN_CONNECTION ?? "fixture"), + model, + options: { + apiKey: process.env.OPENCODE_RECORD_CONSOLE_TOKEN ?? "fixture-console-token", + headers: { "x-org-id": process.env.OPENCODE_RECORD_ZEN_ORG_ID ?? "fixture-org" }, }, - }, - }), + }), }, - anthropic: { + { + id: "anthropic-api-key", + name: "Anthropic API key", providerID: ProviderID.anthropic, modelID: "claude-haiku-4-5-20251001", cassette: "session/native-anthropic-tool-loop", protocol: "anthropic-messages", tags: ["opencode", "native", "tool-loop"], - canRecord: Boolean(process.env.OPENCODE_RECORD_ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_API_KEY), - config: (model) => ({ - enabled_providers: ["anthropic"], - provider: { - anthropic: { - name: "Anthropic", - env: ["ANTHROPIC_API_KEY"], - npm: "@ai-sdk/anthropic", - api: "https://api.anthropic.com/v1", - models: { [model.id]: cloneModel(model) }, - options: { - apiKey: - process.env.OPENCODE_RECORD_ANTHROPIC_API_KEY ?? process.env.ANTHROPIC_API_KEY ?? "fixture-anthropic-key", - baseURL: "https://api.anthropic.com/v1", - }, + canRecord: () => Boolean(envValue("OPENCODE_RECORD_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY")), + config: (model) => + providerConfig({ + providerID: ProviderID.anthropic, + name: "Anthropic", + env: ["ANTHROPIC_API_KEY"], + npm: "@ai-sdk/anthropic", + api: "https://api.anthropic.com/v1", + model, + options: { + apiKey: envValue("OPENCODE_RECORD_ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY") ?? "fixture-anthropic-key", + baseURL: "https://api.anthropic.com/v1", }, - }, - }), + }), }, -} satisfies Record +] satisfies ReadonlyArray const shouldRecord = process.env.RECORD === "true" +const selectedScenarios = new Set( + (envValue("OPENCODE_RECORDED_SCENARIO", "RECORDED_PROVIDER") ?? "") + .split(",") + .map((item) => item.trim().toLowerCase()) + .filter(Boolean), +) -const canRun = (spec: ProviderSpec) => - shouldRecord ? spec.canRecord : HttpRecorder.hasCassetteSync(spec.cassette, { directory: FIXTURES_DIR }) +function isSelected(scenario: RecordedScenario) { + if (selectedScenarios.size === 0) return true + return [scenario.id, scenario.name, scenario.providerID, scenario.cassette, ...scenario.tags] + .map((item) => item.toLowerCase()) + .some((item) => selectedScenarios.has(item)) +} + +const canRun = (scenario: RecordedScenario) => + shouldRecord ? scenario.canRecord() : HttpRecorder.hasCassetteSync(scenario.cassette, { directory: FIXTURES_DIR }) + +const recordError = (scenario: RecordedScenario) => + scenario.id === "openai-oauth" + ? "Set OPENCODE_RECORD_OPENAI_AUTH to an OAuth auth JSON object in the recording environment." + : `Missing recording credentials for ${scenario.name}.` + +const redactRecordedBody = (body: string) => + body + .replace(/wrk_[A-Z0-9]+/g, "wrk_redacted") + .replace(/"safety_identifier"\s*:\s*"user-[^"]+"/g, '"safety_identifier":"user_redacted"') + .replace(/"(access|access_token|refresh|refresh_token|accountId|account_id)"\s*:\s*"[^"]+"/g, '"$1":"redacted"') + +const recordingRedactor = Redactor.compose( + Redactor.defaults({ + url: { + transform: (url) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"), + }, + }), + { + request: (snapshot) => ({ ...snapshot, body: redactRecordedBody(snapshot.body) }), + response: (snapshot) => ({ ...snapshot, body: redactRecordedBody(snapshot.body) }), + }, +) + +function authLayer(scenario: RecordedScenario) { + const replayAuth = shouldRecord ? scenario.recordAuth?.() : scenario.replayAuth + if (!replayAuth) return Auth.defaultLayer + return Layer.mock(Auth.Service)({ + get: (providerID) => Effect.succeed(providerID === scenario.providerID ? replayAuth : undefined), + all: () => Effect.succeed({ [scenario.providerID]: replayAuth }), + }) +} async function loadFixture(providerID: string, modelID: string) { - const data = await Filesystem.readJson>( - path.join(import.meta.dir, "../tool/fixtures/models-api.json"), - ) + const data = await modelsFixture const provider = data[providerID] if (!provider) throw new Error(`Missing provider in fixture: ${providerID}`) const model = provider.models[modelID] @@ -134,38 +262,44 @@ async function loadFixture(providerID: string, modelID: string) { return model } -function recordedNativeLLMLayer(spec: ProviderSpec) { +const modelsFixture = Filesystem.readJson>( + path.join(import.meta.dir, "../tool/fixtures/models-api.json"), +) + +function recordedNativeLLMLayer(scenario: RecordedScenario) { + const auth = authLayer(scenario) + const provider = Provider.layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(auth), + Layer.provide(Plugin.defaultLayer), + Layer.provide(ModelsDev.defaultLayer), + Layer.provide(RuntimeFlags.defaultLayer), + ) // Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real. const recordedClient = LLMClient.layer.pipe( Layer.provide(Layer.mergeAll(RequestExecutor.layer, WebSocketExecutor.layer)), Layer.provide( - HttpRecorder.recordingLayer(spec.cassette, { + HttpRecorder.recordingLayer(scenario.cassette, { mode: shouldRecord ? "record" : "replay", - metadata: { provider: spec.providerID, protocol: spec.protocol, route: spec.protocol, tags: spec.tags }, - redactor: Redactor.compose( - Redactor.defaults({ - url: { - transform: (url) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"), - }, - }), - { - response: (snapshot) => ({ ...snapshot, body: snapshot.body.replace(/wrk_[A-Z0-9]+/g, "wrk_redacted") }), - }, - ), + metadata: { + provider: scenario.providerID, + protocol: scenario.protocol, + route: scenario.protocol, + tags: scenario.tags, + }, + redactor: recordingRedactor, }).pipe(Layer.provide(FetchHttpClient.layer)), ), ) return Layer.mergeAll( - Provider.defaultLayer.pipe( - Layer.provide(Auth.defaultLayer), - Layer.provide(Config.defaultLayer), - Layer.provide(Plugin.defaultLayer), - ), + provider, LLM.layer.pipe( - Layer.provide(Auth.defaultLayer), + Layer.provide(auth), Layer.provide(Config.defaultLayer), - Layer.provide(Provider.defaultLayer), + Layer.provide(provider), Layer.provide(Plugin.defaultLayer), Layer.provide(recordedClient), Layer.provide( @@ -176,11 +310,11 @@ function recordedNativeLLMLayer(spec: ProviderSpec) { ) } -const writeConfig = (directory: string, spec: ProviderSpec, model: ModelsDev.Provider["models"][string]) => +const writeConfig = (directory: string, scenario: RecordedScenario, model: ModelsDev.Provider["models"][string]) => Effect.promise(() => Bun.write( path.join(directory, "opencode.json"), - JSON.stringify({ $schema: "https://opencode.ai/config.json", ...spec.config(model) }), + JSON.stringify({ $schema: "https://opencode.ai/config.json", ...scenario.config(model) }), ), ) @@ -214,13 +348,14 @@ const toolRoundtrip = ( }, ] -const driveToolLoop = (spec: ProviderSpec) => +const driveToolLoop = (scenario: RecordedScenario) => Effect.gen(function* () { const test = yield* TestInstance - const model = yield* Effect.promise(() => loadFixture(spec.providerID, spec.modelID)) - yield* writeConfig(test.directory, spec, model) + const model = yield* Effect.promise(() => loadFixture(scenario.providerID, scenario.modelID)) + yield* writeConfig(test.directory, scenario, model) - const sessionID = SessionID.make(`session-recorded-${spec.providerID}-loop`) + const stableID = scenario.stableID ?? scenario.providerID + const sessionID = SessionID.make(`session-recorded-${stableID}-loop`) const modelID = ModelID.make(model.id) const agent = { name: "test", @@ -231,17 +366,17 @@ const driveToolLoop = (spec: ProviderSpec) => temperature: 0, } satisfies Agent.Info const provider = yield* Provider.Service - const resolved = yield* provider.getModel(spec.providerID, modelID) + const resolved = yield* provider.getModel(scenario.providerID, modelID) const userMessage = { role: "user", content: WEATHER_USER } satisfies ModelMessage const base = { user: { - id: MessageID.make(`msg_user-recorded-${spec.providerID}-loop`), + id: MessageID.make(`msg_user-recorded-${stableID}-loop`), sessionID, role: "user", time: { created: 0 }, agent: agent.name, - model: { providerID: spec.providerID, modelID }, + model: { providerID: scenario.providerID, modelID }, } satisfies MessageV2.User, sessionID, model: resolved, @@ -269,9 +404,18 @@ const driveToolLoop = (spec: ProviderSpec) => }) describe("session.llm native recorded", () => { - for (const [name, spec] of Object.entries(PROVIDERS)) { - const it = testEffect(recordedNativeLLMLayer(spec)) - const instance = canRun(spec) ? it.instance : it.instance.skip - instance(`${name}: drives a tool loop to a final text answer`, () => driveToolLoop(spec)) + for (const scenario of RECORDED_SCENARIOS.filter(isSelected)) { + if (!canRun(scenario)) { + if (shouldRecord && scenario.recordAuth && selectedScenarios.size > 0) { + test(`${scenario.name}: drives a tool loop to a final text answer`, () => { + throw new Error(recordError(scenario)) + }) + continue + } + test.skip(`${scenario.name}: drives a tool loop to a final text answer`, () => {}) + continue + } + const it = testEffect(recordedNativeLLMLayer(scenario)) + it.instance(`${scenario.name}: drives a tool loop to a final text answer`, () => driveToolLoop(scenario)) } }) diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 15060ed082..a15d3f92b0 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -2,11 +2,12 @@ import { describe, expect, test } from "bun:test" import { ToolFailure } from "@opencode-ai/llm" import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" import { jsonSchema, tool, type ModelMessage } from "ai" -import { Effect, Layer } from "effect" +import { Effect, Layer, Stream } from "effect" import { LLMNative } from "@/session/llm/native-request" import { LLMNativeRuntime } from "@/session/llm/native-runtime" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" +import { OAUTH_DUMMY_KEY } from "@/auth" const baseModel: Provider.Model = { id: ModelID.make("gpt-5-mini"), @@ -68,6 +69,13 @@ const providerInfo: Provider.Info = { models: {}, } +function responsesStream(chunks: unknown[]) { + return new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}`).join("\n\n") + "\n\n", { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) +} + describe("session.llm-native.request", () => { test("maps normalized stream inputs to a native LLM request", () => { const messages: ModelMessage[] = [ @@ -308,7 +316,14 @@ describe("session.llm-native.request", () => { provider: providerInfo, auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, }), - ).toEqual({ type: "unsupported", reason: "OAuth auth is not supported" }) + ).toEqual({ type: "unsupported", reason: "OAuth auth requires a provider fetch override" }) + expect( + LLMNativeRuntime.status({ + model: baseModel, + provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: async () => new Response() } }, + auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, + }), + ).toMatchObject({ type: "supported", apiKey: OAUTH_DUMMY_KEY }) expect( LLMNativeRuntime.status({ @@ -419,7 +434,7 @@ describe("session.llm-native.request", () => { model: baseModel, apiKey: "test-openai-key", messages: [{ role: "user", content: "hello" }], - providerOptions: { openai: { store: false } }, + providerOptions: { openai: { store: false, instructions: "You are concise." } }, maxOutputTokens: 512, headers: { "x-request": "request-header" }, }), @@ -434,6 +449,7 @@ describe("session.llm-native.request", () => { protocol: "openai-responses", body: { model: "gpt-5-mini", + instructions: "You are concise.", input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }], max_output_tokens: 512, store: false, @@ -441,4 +457,55 @@ describe("session.llm-native.request", () => { }, }) }) + + test("uses provider fetch override for native OpenAI OAuth requests", async () => { + const captures: Array<{ url: string; body: unknown }> = [] + const customFetch = (async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + captures.push({ url: request.url, body: await request.clone().json() }) + return responsesStream([ + { type: "response.output_text.delta", item_id: "msg_1", delta: "Hello" }, + { type: "response.completed", response: { usage: { input_tokens: 1, output_tokens: 1 } } }, + ]) + }) as typeof fetch + + const events = await Effect.runPromise( + Effect.gen(function* () { + const llmClient = yield* LLMClient.Service + const native = LLMNativeRuntime.stream({ + model: baseModel, + provider: { ...providerInfo, options: { apiKey: OAUTH_DUMMY_KEY, fetch: customFetch } }, + auth: { type: "oauth", refresh: "refresh", access: "access", expires: Date.now() + 60_000 }, + llmClient, + messages: [{ role: "user", content: "hello" }], + tools: {}, + providerOptions: { instructions: "You are concise." }, + headers: {}, + abort: new AbortController().signal, + }) + expect(native.type).toBe("supported") + if (native.type === "unsupported") return [] + return yield* native.stream.pipe(Stream.runCollect) + }).pipe( + Effect.provide(LLMClient.layer), + Effect.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer)), + ), + ) + + expect(captures).toHaveLength(1) + expect(captures[0]).toMatchObject({ + url: "https://api.openai.com/v1/responses", + body: { + model: "gpt-5-mini", + instructions: "You are concise.", + input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }], + }, + }) + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: "text-delta", text: "Hello" }), + expect.objectContaining({ type: "finish" }), + ]), + ) + }) }) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 5ad1ae2177..869be6655a 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -979,7 +979,7 @@ describe("session.llm.stream", () => { throw new Error("Server not initialized") } - const source = await loadFixture("openai", "gpt-5.2") + const source = await loadFixture("openai", "gpt-5.5") const model = source.model const responseChunks = [ @@ -1083,7 +1083,7 @@ describe("session.llm.stream", () => { throw new Error("Server not initialized") } - const source = await loadFixture("openai", "gpt-5.2") + const source = await loadFixture("openai", "gpt-5.5") const model = source.model const request = waitRequest( "/responses", @@ -1197,7 +1197,7 @@ describe("session.llm.stream", () => { throw new Error("Server not initialized") } - const source = await loadFixture("openai", "gpt-5.2") + const source = await loadFixture("openai", "gpt-5.5") const model = source.model const chunks = [ { @@ -1279,7 +1279,7 @@ describe("session.llm.stream", () => { }) test("uses injected native request executor for tool calls", async () => { - const source = await loadFixture("openai", "gpt-5.2") + const source = await loadFixture("openai", "gpt-5.5") const model = source.model const chunks = [ { @@ -1390,7 +1390,7 @@ describe("session.llm.stream", () => { throw new Error("Server not initialized") } - const source = await loadFixture("openai", "gpt-5.2") + const source = await loadFixture("openai", "gpt-5.5") const model = source.model const chunks = [ { @@ -1420,32 +1420,7 @@ describe("session.llm.stream", () => { const request = waitRequest("/responses", createEventResponse(chunks, true)) let executed: unknown - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - enabled_providers: ["openai"], - provider: { - openai: { - name: "OpenAI", - env: ["OPENAI_API_KEY"], - npm: "@ai-sdk/openai", - api: "https://api.openai.com/v1", - models: { - [model.id]: model, - }, - options: { - apiKey: "test-openai-key", - baseURL: `${server.url.origin}/v1`, - }, - }, - }, - }), - ) - }, - }) + await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) }) await withTestInstance({ directory: tmp.path, @@ -1515,7 +1490,7 @@ describe("session.llm.stream", () => { throw new Error("Server not initialized") } - const source = await loadFixture("openai", "gpt-5.2") + const source = await loadFixture("openai", "gpt-5.5") const model = source.model const chunks = [ {