From d9f18e40067c806944a389afb018c484850dba84 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Sat, 31 Jan 2026 02:53:22 +0100 Subject: feat(opencode): add copilot specific provider to properly handle copilot reasoning tokens (#8900) Co-authored-by: Claude Opus 4.5 Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline --- packages/opencode/src/provider/provider.ts | 2 +- .../opencode/src/provider/sdk/copilot/README.md | 5 + .../convert-to-openai-compatible-chat-messages.ts | 177 ++ .../sdk/copilot/chat/get-response-metadata.ts | 15 + .../chat/map-openai-compatible-finish-reason.ts | 19 + .../copilot/chat/openai-compatible-api-types.ts | 74 + .../chat/openai-compatible-chat-language-model.ts | 832 ++++++++++ .../copilot/chat/openai-compatible-chat-options.ts | 30 + .../chat/openai-compatible-metadata-extractor.ts | 48 + .../chat/openai-compatible-prepare-tools.ts | 92 ++ .../src/provider/sdk/copilot/copilot-provider.ts | 100 ++ .../opencode/src/provider/sdk/copilot/index.ts | 2 + .../sdk/copilot/openai-compatible-error.ts | 30 + .../responses/convert-to-openai-responses-input.ts | 303 ++++ .../map-openai-responses-finish-reason.ts | 22 + .../sdk/copilot/responses/openai-config.ts | 18 + .../provider/sdk/copilot/responses/openai-error.ts | 22 + .../responses/openai-responses-api-types.ts | 207 +++ .../responses/openai-responses-language-model.ts | 1732 ++++++++++++++++++++ .../responses/openai-responses-prepare-tools.ts | 177 ++ .../copilot/responses/openai-responses-settings.ts | 1 + .../sdk/copilot/responses/tool/code-interpreter.ts | 88 + .../sdk/copilot/responses/tool/file-search.ts | 128 ++ .../sdk/copilot/responses/tool/image-generation.ts | 115 ++ .../sdk/copilot/responses/tool/local-shell.ts | 65 + .../copilot/responses/tool/web-search-preview.ts | 104 ++ .../sdk/copilot/responses/tool/web-search.ts | 103 ++ .../provider/sdk/openai-compatible/src/README.md | 5 - .../provider/sdk/openai-compatible/src/index.ts | 2 - .../src/openai-compatible-provider.ts | 100 -- .../responses/convert-to-openai-responses-input.ts | 303 ---- .../map-openai-responses-finish-reason.ts | 22 - .../src/responses/openai-config.ts | 18 - .../src/responses/openai-error.ts | 22 - .../src/responses/openai-responses-api-types.ts | 207 --- .../responses/openai-responses-language-model.ts | 1732 -------------------- .../responses/openai-responses-prepare-tools.ts | 177 -- .../src/responses/openai-responses-settings.ts | 1 - .../src/responses/tool/code-interpreter.ts | 88 - .../src/responses/tool/file-search.ts | 128 -- .../src/responses/tool/image-generation.ts | 115 -- .../src/responses/tool/local-shell.ts | 65 - .../src/responses/tool/web-search-preview.ts | 104 -- .../src/responses/tool/web-search.ts | 103 -- packages/opencode/src/provider/transform.ts | 13 + packages/opencode/src/session/llm.ts | 17 +- .../copilot/convert-to-copilot-messages.test.ts | 478 ++++++ .../provider/copilot/copilot-chat-model.test.ts | 555 +++++++ packages/opencode/test/provider/transform.test.ts | 6 +- 49 files changed, 5568 insertions(+), 3204 deletions(-) create mode 100644 packages/opencode/src/provider/sdk/copilot/README.md create mode 100644 packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/chat/get-response-metadata.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/chat/map-openai-compatible-finish-reason.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-api-types.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-options.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-metadata-extractor.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-prepare-tools.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/copilot-provider.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/index.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/map-openai-responses-finish-reason.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/openai-config.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/openai-responses-api-types.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/openai-responses-prepare-tools.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/openai-responses-settings.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/tool/code-interpreter.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/tool/file-search.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/tool/image-generation.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/tool/local-shell.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/tool/web-search-preview.ts create mode 100644 packages/opencode/src/provider/sdk/copilot/responses/tool/web-search.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/README.md delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/index.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/openai-compatible-provider.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/convert-to-openai-responses-input.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/map-openai-responses-finish-reason.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-config.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-error.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-api-types.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-prepare-tools.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-settings.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/code-interpreter.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/file-search.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/image-generation.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/local-shell.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/web-search-preview.ts delete mode 100644 packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/web-search.ts create mode 100644 packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts create mode 100644 packages/opencode/test/provider/copilot/copilot-chat-model.test.ts diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 128c4f4e6..e79cb1708 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -24,7 +24,7 @@ import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic" import { createOpenAI } from "@ai-sdk/openai" import { createOpenAICompatible } from "@ai-sdk/openai-compatible" import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider" -import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src" +import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot" import { createXai } from "@ai-sdk/xai" import { createMistral } from "@ai-sdk/mistral" import { createGroq } from "@ai-sdk/groq" diff --git a/packages/opencode/src/provider/sdk/copilot/README.md b/packages/opencode/src/provider/sdk/copilot/README.md new file mode 100644 index 000000000..8ce03d614 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/README.md @@ -0,0 +1,5 @@ +This is a temporary package used primarily for GitHub Copilot compatibility. + +Avoid making changes to these files unless you only want to affect the Copilot provider. + +Also, this should ONLY be used for the Copilot provider. diff --git a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts new file mode 100644 index 000000000..30f7cfb05 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts @@ -0,0 +1,177 @@ +import { + type LanguageModelV2Prompt, + type SharedV2ProviderMetadata, + UnsupportedFunctionalityError, +} from '@ai-sdk/provider'; +import type { OpenAICompatibleChatPrompt } from './openai-compatible-api-types'; +import { convertToBase64 } from '@ai-sdk/provider-utils'; + +function getOpenAIMetadata(message: { + providerOptions?: SharedV2ProviderMetadata; +}) { + return message?.providerOptions?.copilot ?? {}; +} + +export function convertToOpenAICompatibleChatMessages( + prompt: LanguageModelV2Prompt, +): OpenAICompatibleChatPrompt { + const messages: OpenAICompatibleChatPrompt = []; + for (const { role, content, ...message } of prompt) { + const metadata = getOpenAIMetadata({ ...message }); + switch (role) { + case 'system': { + messages.push({ + role: 'system', + content: [ + { + type: 'text', + text: content, + }, + ], + ...metadata, + }); + break; + } + + case 'user': { + if (content.length === 1 && content[0].type === 'text') { + messages.push({ + role: 'user', + content: content[0].text, + ...getOpenAIMetadata(content[0]), + }); + break; + } + + messages.push({ + role: 'user', + content: content.map(part => { + const partMetadata = getOpenAIMetadata(part); + switch (part.type) { + case 'text': { + return { type: 'text', text: part.text, ...partMetadata }; + } + case 'file': { + if (part.mediaType.startsWith('image/')) { + const mediaType = + part.mediaType === 'image/*' + ? 'image/jpeg' + : part.mediaType; + + return { + type: 'image_url', + image_url: { + url: + part.data instanceof URL + ? part.data.toString() + : `data:${mediaType};base64,${convertToBase64(part.data)}`, + }, + ...partMetadata, + }; + } else { + throw new UnsupportedFunctionalityError({ + functionality: `file part media type ${part.mediaType}`, + }); + } + } + } + }), + ...metadata, + }); + + break; + } + + case 'assistant': { + let text = ''; + let reasoningText: string | undefined; + let reasoningOpaque: string | undefined; + const toolCalls: Array<{ + id: string; + type: 'function'; + function: { name: string; arguments: string }; + }> = []; + + for (const part of content) { + const partMetadata = getOpenAIMetadata(part); + // Check for reasoningOpaque on any part (may be attached to text/tool-call) + const partOpaque = ( + part.providerOptions as { copilot?: { reasoningOpaque?: string } } + )?.copilot?.reasoningOpaque; + if (partOpaque && !reasoningOpaque) { + reasoningOpaque = partOpaque; + } + + switch (part.type) { + case 'text': { + text += part.text; + break; + } + case 'reasoning': { + reasoningText = part.text; + break; + } + case 'tool-call': { + toolCalls.push({ + id: part.toolCallId, + type: 'function', + function: { + name: part.toolName, + arguments: JSON.stringify(part.input), + }, + ...partMetadata, + }); + break; + } + } + } + + messages.push({ + role: 'assistant', + content: text || null, + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + reasoning_text: reasoningText, + reasoning_opaque: reasoningOpaque, + ...metadata, + }); + + break; + } + + case 'tool': { + for (const toolResponse of content) { + const output = toolResponse.output; + + let contentValue: string; + switch (output.type) { + case 'text': + case 'error-text': + contentValue = output.value; + break; + case 'content': + case 'json': + case 'error-json': + contentValue = JSON.stringify(output.value); + break; + } + + const toolResponseMetadata = getOpenAIMetadata(toolResponse); + messages.push({ + role: 'tool', + tool_call_id: toolResponse.toolCallId, + content: contentValue, + ...toolResponseMetadata, + }); + } + break; + } + + default: { + const _exhaustiveCheck: never = role; + throw new Error(`Unsupported role: ${_exhaustiveCheck}`); + } + } + } + + return messages; +} diff --git a/packages/opencode/src/provider/sdk/copilot/chat/get-response-metadata.ts b/packages/opencode/src/provider/sdk/copilot/chat/get-response-metadata.ts new file mode 100644 index 000000000..bd358b23f --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/chat/get-response-metadata.ts @@ -0,0 +1,15 @@ +export function getResponseMetadata({ + id, + model, + created, +}: { + id?: string | undefined | null; + created?: number | undefined | null; + model?: string | undefined | null; +}) { + return { + id: id ?? undefined, + modelId: model ?? undefined, + timestamp: created != null ? new Date(created * 1000) : undefined, + }; +} diff --git a/packages/opencode/src/provider/sdk/copilot/chat/map-openai-compatible-finish-reason.ts b/packages/opencode/src/provider/sdk/copilot/chat/map-openai-compatible-finish-reason.ts new file mode 100644 index 000000000..b18feae08 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/chat/map-openai-compatible-finish-reason.ts @@ -0,0 +1,19 @@ +import type { LanguageModelV2FinishReason } from '@ai-sdk/provider'; + +export function mapOpenAICompatibleFinishReason( + finishReason: string | null | undefined, +): LanguageModelV2FinishReason { + switch (finishReason) { + case 'stop': + return 'stop'; + case 'length': + return 'length'; + case 'content_filter': + return 'content-filter'; + case 'function_call': + case 'tool_calls': + return 'tool-calls'; + default: + return 'unknown'; + } +} diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-api-types.ts b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-api-types.ts new file mode 100644 index 000000000..cdab188ce --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-api-types.ts @@ -0,0 +1,74 @@ +import type { JSONValue } from '@ai-sdk/provider'; + +export type OpenAICompatibleChatPrompt = Array; + +export type OpenAICompatibleMessage = + | OpenAICompatibleSystemMessage + | OpenAICompatibleUserMessage + | OpenAICompatibleAssistantMessage + | OpenAICompatibleToolMessage; + +// Allow for arbitrary additional properties for general purpose +// provider-metadata-specific extensibility. +type JsonRecord = Record< + string, + JSONValue | JSONValue[] | T | T[] | undefined +>; + +export interface OpenAICompatibleSystemMessage + extends JsonRecord { + role: 'system'; + content: string | Array; +} + +export interface OpenAICompatibleSystemContentPart + extends JsonRecord { + type: 'text'; + text: string; +} + +export interface OpenAICompatibleUserMessage + extends JsonRecord { + role: 'user'; + content: string | Array; +} + +export type OpenAICompatibleContentPart = + | OpenAICompatibleContentPartText + | OpenAICompatibleContentPartImage; + +export interface OpenAICompatibleContentPartImage extends JsonRecord { + type: 'image_url'; + image_url: { url: string }; +} + +export interface OpenAICompatibleContentPartText extends JsonRecord { + type: 'text'; + text: string; +} + +export interface OpenAICompatibleAssistantMessage + extends JsonRecord { + role: 'assistant'; + content?: string | null; + tool_calls?: Array; + // Copilot-specific reasoning fields + reasoning_text?: string; + reasoning_opaque?: string; +} + +export interface OpenAICompatibleMessageToolCall extends JsonRecord { + type: 'function'; + id: string; + function: { + arguments: string; + name: string; + }; +} + +export interface OpenAICompatibleToolMessage + extends JsonRecord { + role: 'tool'; + content: string; + tool_call_id: string; +} diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts new file mode 100644 index 000000000..5337d73c9 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts @@ -0,0 +1,832 @@ +import { + APICallError, + InvalidResponseDataError, + type LanguageModelV2, + type LanguageModelV2CallWarning, + type LanguageModelV2Content, + type LanguageModelV2FinishReason, + type LanguageModelV2StreamPart, + type SharedV2ProviderMetadata, +} from '@ai-sdk/provider'; +import { + combineHeaders, + createEventSourceResponseHandler, + createJsonErrorResponseHandler, + createJsonResponseHandler, + type FetchFunction, + generateId, + isParsableJson, + parseProviderOptions, + type ParseResult, + postJsonToApi, + type ResponseHandler, +} from '@ai-sdk/provider-utils'; +import { z } from 'zod/v4'; +import { convertToOpenAICompatibleChatMessages } from './convert-to-openai-compatible-chat-messages'; +import { getResponseMetadata } from './get-response-metadata'; +import { mapOpenAICompatibleFinishReason } from './map-openai-compatible-finish-reason'; +import { + type OpenAICompatibleChatModelId, + openaiCompatibleProviderOptions, +} from './openai-compatible-chat-options'; +import { + defaultOpenAICompatibleErrorStructure, + type ProviderErrorStructure, +} from '../openai-compatible-error'; +import type { MetadataExtractor } from './openai-compatible-metadata-extractor'; +import { prepareTools } from './openai-compatible-prepare-tools'; + +export type OpenAICompatibleChatConfig = { + provider: string; + headers: () => Record; + url: (options: { modelId: string; path: string }) => string; + fetch?: FetchFunction; + includeUsage?: boolean; + errorStructure?: ProviderErrorStructure; + metadataExtractor?: MetadataExtractor; + + /** + * Whether the model supports structured outputs. + */ + supportsStructuredOutputs?: boolean; + + /** + * The supported URLs for the model. + */ + supportedUrls?: () => LanguageModelV2['supportedUrls']; +}; + +export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { + readonly specificationVersion = 'v2'; + + readonly supportsStructuredOutputs: boolean; + + readonly modelId: OpenAICompatibleChatModelId; + private readonly config: OpenAICompatibleChatConfig; + private readonly failedResponseHandler: ResponseHandler; + private readonly chunkSchema; // type inferred via constructor + + constructor( + modelId: OpenAICompatibleChatModelId, + config: OpenAICompatibleChatConfig, + ) { + this.modelId = modelId; + this.config = config; + + // initialize error handling: + const errorStructure = + config.errorStructure ?? defaultOpenAICompatibleErrorStructure; + this.chunkSchema = createOpenAICompatibleChatChunkSchema( + errorStructure.errorSchema, + ); + this.failedResponseHandler = createJsonErrorResponseHandler(errorStructure); + + this.supportsStructuredOutputs = config.supportsStructuredOutputs ?? false; + } + + get provider(): string { + return this.config.provider; + } + + private get providerOptionsName(): string { + return this.config.provider.split('.')[0].trim(); + } + + get supportedUrls() { + return this.config.supportedUrls?.() ?? {}; + } + + private async getArgs({ + prompt, + maxOutputTokens, + temperature, + topP, + topK, + frequencyPenalty, + presencePenalty, + providerOptions, + stopSequences, + responseFormat, + seed, + toolChoice, + tools, + }: Parameters[0]) { + const warnings: LanguageModelV2CallWarning[] = []; + + // Parse provider options + const compatibleOptions = Object.assign( + (await parseProviderOptions({ + provider: 'copilot', + providerOptions, + schema: openaiCompatibleProviderOptions, + })) ?? {}, + (await parseProviderOptions({ + provider: this.providerOptionsName, + providerOptions, + schema: openaiCompatibleProviderOptions, + })) ?? {}, + ); + + if (topK != null) { + warnings.push({ type: 'unsupported-setting', setting: 'topK' }); + } + + if ( + responseFormat?.type === 'json' && + responseFormat.schema != null && + !this.supportsStructuredOutputs + ) { + warnings.push({ + type: 'unsupported-setting', + setting: 'responseFormat', + details: + 'JSON response format schema is only supported with structuredOutputs', + }); + } + + const { + tools: openaiTools, + toolChoice: openaiToolChoice, + toolWarnings, + } = prepareTools({ + tools, + toolChoice, + }); + + return { + args: { + // model id: + model: this.modelId, + + // model specific settings: + user: compatibleOptions.user, + + // standardized settings: + max_tokens: maxOutputTokens, + temperature, + top_p: topP, + frequency_penalty: frequencyPenalty, + presence_penalty: presencePenalty, + response_format: + responseFormat?.type === 'json' + ? this.supportsStructuredOutputs === true && + responseFormat.schema != null + ? { + type: 'json_schema', + json_schema: { + schema: responseFormat.schema, + name: responseFormat.name ?? 'response', + description: responseFormat.description, + }, + } + : { type: 'json_object' } + : undefined, + + stop: stopSequences, + seed, + ...Object.fromEntries( + Object.entries( + providerOptions?.[this.providerOptionsName] ?? {}, + ).filter( + ([key]) => + !Object.keys(openaiCompatibleProviderOptions.shape).includes(key), + ), + ), + + reasoning_effort: compatibleOptions.reasoningEffort, + verbosity: compatibleOptions.textVerbosity, + + // messages: + messages: convertToOpenAICompatibleChatMessages(prompt), + + // tools: + tools: openaiTools, + tool_choice: openaiToolChoice, + + // thinking_budget + thinking_budget: compatibleOptions.thinking_budget, + }, + warnings: [...warnings, ...toolWarnings], + }; + } + + async doGenerate( + options: Parameters[0], + ): Promise>> { + const { args, warnings } = await this.getArgs({ ...options }); + + const body = JSON.stringify(args); + + const { + responseHeaders, + value: responseBody, + rawValue: rawResponse, + } = await postJsonToApi({ + url: this.config.url({ + path: '/chat/completions', + modelId: this.modelId, + }), + headers: combineHeaders(this.config.headers(), options.headers), + body: args, + failedResponseHandler: this.failedResponseHandler, + successfulResponseHandler: createJsonResponseHandler( + OpenAICompatibleChatResponseSchema, + ), + abortSignal: options.abortSignal, + fetch: this.config.fetch, + }); + + const choice = responseBody.choices[0]; + const content: Array = []; + + // text content: + const text = choice.message.content; + if (text != null && text.length > 0) { + content.push({ type: 'text', text }); + } + + // reasoning content (Copilot uses reasoning_text): + const reasoning = choice.message.reasoning_text; + if (reasoning != null && reasoning.length > 0) { + content.push({ + type: 'reasoning', + text: reasoning, + // Include reasoning_opaque for Copilot multi-turn reasoning + providerMetadata: choice.message.reasoning_opaque + ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } } + : undefined, + }); + } + + // tool calls: + if (choice.message.tool_calls != null) { + for (const toolCall of choice.message.tool_calls) { + content.push({ + type: 'tool-call', + toolCallId: toolCall.id ?? generateId(), + toolName: toolCall.function.name, + input: toolCall.function.arguments!, + }); + } + } + + // provider metadata: + const providerMetadata: SharedV2ProviderMetadata = { + [this.providerOptionsName]: {}, + ...(await this.config.metadataExtractor?.extractMetadata?.({ + parsedBody: rawResponse, + })), + }; + const completionTokenDetails = + responseBody.usage?.completion_tokens_details; + if (completionTokenDetails?.accepted_prediction_tokens != null) { + providerMetadata[this.providerOptionsName].acceptedPredictionTokens = + completionTokenDetails?.accepted_prediction_tokens; + } + if (completionTokenDetails?.rejected_prediction_tokens != null) { + providerMetadata[this.providerOptionsName].rejectedPredictionTokens = + completionTokenDetails?.rejected_prediction_tokens; + } + + return { + content, + finishReason: mapOpenAICompatibleFinishReason(choice.finish_reason), + usage: { + inputTokens: responseBody.usage?.prompt_tokens ?? undefined, + outputTokens: responseBody.usage?.completion_tokens ?? undefined, + totalTokens: responseBody.usage?.total_tokens ?? undefined, + reasoningTokens: + responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? + undefined, + cachedInputTokens: + responseBody.usage?.prompt_tokens_details?.cached_tokens ?? undefined, + }, + providerMetadata, + request: { body }, + response: { + ...getResponseMetadata(responseBody), + headers: responseHeaders, + body: rawResponse, + }, + warnings, + }; + } + + async doStream( + options: Parameters[0], + ): Promise>> { + const { args, warnings } = await this.getArgs({ ...options }); + + const body = { + ...args, + stream: true, + + // only include stream_options when in strict compatibility mode: + stream_options: this.config.includeUsage + ? { include_usage: true } + : undefined, + }; + + const metadataExtractor = + this.config.metadataExtractor?.createStreamExtractor(); + + const { responseHeaders, value: response } = await postJsonToApi({ + url: this.config.url({ + path: '/chat/completions', + modelId: this.modelId, + }), + headers: combineHeaders(this.config.headers(), options.headers), + body, + failedResponseHandler: this.failedResponseHandler, + successfulResponseHandler: createEventSourceResponseHandler( + this.chunkSchema, + ), + abortSignal: options.abortSignal, + fetch: this.config.fetch, + }); + + const toolCalls: Array<{ + id: string; + type: 'function'; + function: { + name: string; + arguments: string; + }; + hasFinished: boolean; + }> = []; + + let finishReason: LanguageModelV2FinishReason = 'unknown'; + const usage: { + completionTokens: number | undefined; + completionTokensDetails: { + reasoningTokens: number | undefined; + acceptedPredictionTokens: number | undefined; + rejectedPredictionTokens: number | undefined; + }; + promptTokens: number | undefined; + promptTokensDetails: { + cachedTokens: number | undefined; + }; + totalTokens: number | undefined; + } = { + completionTokens: undefined, + completionTokensDetails: { + reasoningTokens: undefined, + acceptedPredictionTokens: undefined, + rejectedPredictionTokens: undefined, + }, + promptTokens: undefined, + promptTokensDetails: { + cachedTokens: undefined, + }, + totalTokens: undefined, + }; + let isFirstChunk = true; + const providerOptionsName = this.providerOptionsName; + let isActiveReasoning = false; + let isActiveText = false; + let reasoningOpaque: string | undefined; + + return { + stream: response.pipeThrough( + new TransformStream< + ParseResult>, + LanguageModelV2StreamPart + >({ + start(controller) { + controller.enqueue({ type: 'stream-start', warnings }); + }, + + // TODO we lost type safety on Chunk, most likely due to the error schema. MUST FIX + transform(chunk, controller) { + // Emit raw chunk if requested (before anything else) + if (options.includeRawChunks) { + controller.enqueue({ type: 'raw', rawValue: chunk.rawValue }); + } + + // handle failed chunk parsing / validation: + if (!chunk.success) { + finishReason = 'error'; + controller.enqueue({ type: 'error', error: chunk.error }); + return; + } + const value = chunk.value; + + metadataExtractor?.processChunk(chunk.rawValue); + + // handle error chunks: + if ('error' in value) { + finishReason = 'error'; + controller.enqueue({ type: 'error', error: value.error.message }); + return; + } + + if (isFirstChunk) { + isFirstChunk = false; + + controller.enqueue({ + type: 'response-metadata', + ...getResponseMetadata(value), + }); + } + + if (value.usage != null) { + const { + prompt_tokens, + completion_tokens, + total_tokens, + prompt_tokens_details, + completion_tokens_details, + } = value.usage; + + usage.promptTokens = prompt_tokens ?? undefined; + usage.completionTokens = completion_tokens ?? undefined; + usage.totalTokens = total_tokens ?? undefined; + if (completion_tokens_details?.reasoning_tokens != null) { + usage.completionTokensDetails.reasoningTokens = + completion_tokens_details?.reasoning_tokens; + } + if ( + completion_tokens_details?.accepted_prediction_tokens != null + ) { + usage.completionTokensDetails.acceptedPredictionTokens = + completion_tokens_details?.accepted_prediction_tokens; + } + if ( + completion_tokens_details?.rejected_prediction_tokens != null + ) { + usage.completionTokensDetails.rejectedPredictionTokens = + completion_tokens_details?.rejected_prediction_tokens; + } + if (prompt_tokens_details?.cached_tokens != null) { + usage.promptTokensDetails.cachedTokens = + prompt_tokens_details?.cached_tokens; + } + } + + const choice = value.choices[0]; + + if (choice?.finish_reason != null) { + finishReason = mapOpenAICompatibleFinishReason( + choice.finish_reason, + ); + } + + if (choice?.delta == null) { + return; + } + + const delta = choice.delta; + + // Capture reasoning_opaque for Copilot multi-turn reasoning + if (delta.reasoning_opaque) { + if (reasoningOpaque != null) { + throw new InvalidResponseDataError({ + data: delta, + message: + 'Multiple reasoning_opaque values received in a single response. Only one thinking part per response is supported.', + }); + } + reasoningOpaque = delta.reasoning_opaque; + } + + // enqueue reasoning before text deltas (Copilot uses reasoning_text): + const reasoningContent = delta.reasoning_text; + if (reasoningContent) { + if (!isActiveReasoning) { + controller.enqueue({ + type: 'reasoning-start', + id: 'reasoning-0', + }); + isActiveReasoning = true; + } + + controller.enqueue({ + type: 'reasoning-delta', + id: 'reasoning-0', + delta: reasoningContent, + }); + } + + if (delta.content) { + // If reasoning was active and we're starting text, end reasoning first + // This handles the case where reasoning_opaque and content come in the same chunk + if (isActiveReasoning && !isActiveText) { + controller.enqueue({ + type: 'reasoning-end', + id: 'reasoning-0', + providerMetadata: reasoningOpaque + ? { copilot: { reasoningOpaque } } + : undefined, + }); + isActiveReasoning = false; + } + + if (!isActiveText) { + controller.enqueue({ type: 'text-start', id: 'txt-0' }); + isActiveText = true; + } + + controller.enqueue({ + type: 'text-delta', + id: 'txt-0', + delta: delta.content, + }); + } + + if (delta.tool_calls != null) { + // If reasoning was active and we're starting tool calls, end reasoning first + // This handles the case where reasoning goes directly to tool calls with no content + if (isActiveReasoning) { + controller.enqueue({ + type: 'reasoning-end', + id: 'reasoning-0', + providerMetadata: reasoningOpaque + ? { copilot: { reasoningOpaque } } + : undefined, + }); + isActiveReasoning = false; + } + for (const toolCallDelta of delta.tool_calls) { + const index = toolCallDelta.index; + + if (toolCalls[index] == null) { + if (toolCallDelta.id == null) { + throw new InvalidResponseDataError({ + data: toolCallDelta, + message: `Expected 'id' to be a string.`, + }); + } + + if (toolCallDelta.function?.name == null) { + throw new InvalidResponseDataError({ + data: toolCallDelta, + message: `Expected 'function.name' to be a string.`, + }); + } + + controller.enqueue({ + type: 'tool-input-start', + id: toolCallDelta.id, + toolName: toolCallDelta.function.name, + }); + + toolCalls[index] = { + id: toolCallDelta.id, + type: 'function', + function: { + name: toolCallDelta.function.name, + arguments: toolCallDelta.function.arguments ?? '', + }, + hasFinished: false, + }; + + const toolCall = toolCalls[index]; + + if ( + toolCall.function?.name != null && + toolCall.function?.arguments != null + ) { + // send delta if the argument text has already started: + if (toolCall.function.arguments.length > 0) { + controller.enqueue({ + type: 'tool-input-delta', + id: toolCall.id, + delta: toolCall.function.arguments, + }); + } + + // check if tool call is complete + // (some providers send the full tool call in one chunk): + if (isParsableJson(toolCall.function.arguments)) { + controller.enqueue({ + type: 'tool-input-end', + id: toolCall.id, + }); + + controller.enqueue({ + type: 'tool-call', + toolCallId: toolCall.id ?? generateId(), + toolName: toolCall.function.name, + input: toolCall.function.arguments, + }); + toolCall.hasFinished = true; + } + } + + continue; + } + + // existing tool call, merge if not finished + const toolCall = toolCalls[index]; + + if (toolCall.hasFinished) { + continue; + } + + if (toolCallDelta.function?.arguments != null) { + toolCall.function!.arguments += + toolCallDelta.function?.arguments ?? ''; + } + + // send delta + controller.enqueue({ + type: 'tool-input-delta', + id: toolCall.id, + delta: toolCallDelta.function.arguments ?? '', + }); + + // check if tool call is complete + if ( + toolCall.function?.name != null && + toolCall.function?.arguments != null && + isParsableJson(toolCall.function.arguments) + ) { + controller.enqueue({ + type: 'tool-input-end', + id: toolCall.id, + }); + + controller.enqueue({ + type: 'tool-call', + toolCallId: toolCall.id ?? generateId(), + toolName: toolCall.function.name, + input: toolCall.function.arguments, + }); + toolCall.hasFinished = true; + } + } + } + }, + + flush(controller) { + if (isActiveReasoning) { + controller.enqueue({ + type: 'reasoning-end', + id: 'reasoning-0', + // Include reasoning_opaque for Copilot multi-turn reasoning + providerMetadata: reasoningOpaque + ? { copilot: { reasoningOpaque } } + : undefined, + }); + } + + if (isActiveText) { + controller.enqueue({ type: 'text-end', id: 'txt-0' }); + } + + // go through all tool calls and send the ones that are not finished + for (const toolCall of toolCalls.filter( + toolCall => !toolCall.hasFinished, + )) { + controller.enqueue({ + type: 'tool-input-end', + id: toolCall.id, + }); + + controller.enqueue({ + type: 'tool-call', + toolCallId: toolCall.id ?? generateId(), + toolName: toolCall.function.name, + input: toolCall.function.arguments, + }); + } + + const providerMetadata: SharedV2ProviderMetadata = { + [providerOptionsName]: {}, + // Include reasoning_opaque for Copilot multi-turn reasoning + ...(reasoningOpaque + ? { copilot: { reasoningOpaque } } + : {}), + ...metadataExtractor?.buildMetadata(), + }; + if ( + usage.completionTokensDetails.acceptedPredictionTokens != null + ) { + providerMetadata[providerOptionsName].acceptedPredictionTokens = + usage.completionTokensDetails.acceptedPredictionTokens; + } + if ( + usage.completionTokensDetails.rejectedPredictionTokens != null + ) { + providerMetadata[providerOptionsName].rejectedPredictionTokens = + usage.completionTokensDetails.rejectedPredictionTokens; + } + + controller.enqueue({ + type: 'finish', + finishReason, + usage: { + inputTokens: usage.promptTokens ?? undefined, + outputTokens: usage.completionTokens ?? undefined, + totalTokens: usage.totalTokens ?? undefined, + reasoningTokens: + usage.completionTokensDetails.reasoningTokens ?? undefined, + cachedInputTokens: + usage.promptTokensDetails.cachedTokens ?? undefined, + }, + providerMetadata, + }); + }, + }), + ), + request: { body }, + response: { headers: responseHeaders }, + }; + } +} + +const openaiCompatibleTokenUsageSchema = z + .object({ + prompt_tokens: z.number().nullish(), + completion_tokens: z.number().nullish(), + total_tokens: z.number().nullish(), + prompt_tokens_details: z + .object({ + cached_tokens: z.number().nullish(), + }) + .nullish(), + completion_tokens_details: z + .object({ + reasoning_tokens: z.number().nullish(), + accepted_prediction_tokens: z.number().nullish(), + rejected_prediction_tokens: z.number().nullish(), + }) + .nullish(), + }) + .nullish(); + +// limited version of the schema, focussed on what is needed for the implementation +// this approach limits breakages when the API changes and increases efficiency +const OpenAICompatibleChatResponseSchema = z.object({ + id: z.string().nullish(), + created: z.number().nullish(), + model: z.string().nullish(), + choices: z.array( + z.object({ + message: z.object({ + role: z.literal('assistant').nullish(), + content: z.string().nullish(), + // Copilot-specific reasoning fields + reasoning_text: z.string().nullish(), + reasoning_opaque: z.string().nullish(), + tool_calls: z + .array( + z.object({ + id: z.string().nullish(), + function: z.object({ + name: z.string(), + arguments: z.string(), + }), + }), + ) + .nullish(), + }), + finish_reason: z.string().nullish(), + }), + ), + usage: openaiCompatibleTokenUsageSchema, +}); + +// limited version of the schema, focussed on what is needed for the implementation +// this approach limits breakages when the API changes and increases efficiency +const createOpenAICompatibleChatChunkSchema = < + ERROR_SCHEMA extends z.core.$ZodType, +>( + errorSchema: ERROR_SCHEMA, +) => + z.union([ + z.object({ + id: z.string().nullish(), + created: z.number().nullish(), + model: z.string().nullish(), + choices: z.array( + z.object({ + delta: z + .object({ + role: z.enum(['assistant']).nullish(), + content: z.string().nullish(), + // Copilot-specific reasoning fields + reasoning_text: z.string().nullish(), + reasoning_opaque: z.string().nullish(), + tool_calls: z + .array( + z.object({ + index: z.number(), + id: z.string().nullish(), + function: z.object({ + name: z.string().nullish(), + arguments: z.string().nullish(), + }), + }), + ) + .nullish(), + }) + .nullish(), + finish_reason: z.string().nullish(), + }), + ), + usage: openaiCompatibleTokenUsageSchema, + }), + errorSchema, + ]); diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-options.ts b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-options.ts new file mode 100644 index 000000000..3d16d3a98 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-options.ts @@ -0,0 +1,30 @@ +import { z } from 'zod/v4'; + +export type OpenAICompatibleChatModelId = string; + +export const openaiCompatibleProviderOptions = z.object({ + /** + * A unique identifier representing your end-user, which can help the provider to + * monitor and detect abuse. + */ + user: z.string().optional(), + + /** + * Reasoning effort for reasoning models. Defaults to `medium`. + */ + reasoningEffort: z.string().optional(), + + /** + * Controls the verbosity of the generated text. Defaults to `medium`. + */ + textVerbosity: z.string().optional(), + + /** + * Copilot thinking_budget used for Anthropic models. + */ + thinking_budget: z.number().optional(), +}); + +export type OpenAICompatibleProviderOptions = z.infer< + typeof openaiCompatibleProviderOptions +>; diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-metadata-extractor.ts b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-metadata-extractor.ts new file mode 100644 index 000000000..17c56c7ac --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-metadata-extractor.ts @@ -0,0 +1,48 @@ +import type { SharedV2ProviderMetadata } from '@ai-sdk/provider'; + +/** +Extracts provider-specific metadata from API responses. +Used to standardize metadata handling across different LLM providers while allowing +provider-specific metadata to be captured. +*/ +export type MetadataExtractor = { + /** + * Extracts provider metadata from a complete, non-streaming response. + * + * @param parsedBody - The parsed response JSON body from the provider's API. + * + * @returns Provider-specific metadata or undefined if no metadata is available. + * The metadata should be under a key indicating the provider id. + */ + extractMetadata: ({ + parsedBody, + }: { + parsedBody: unknown; + }) => Promise; + + /** + * Creates an extractor for handling streaming responses. The returned object provides + * methods to process individual chunks and build the final metadata from the accumulated + * stream data. + * + * @returns An object with methods to process chunks and build metadata from a stream + */ + createStreamExtractor: () => { + /** + * Process an individual chunk from the stream. Called for each chunk in the response stream + * to accumulate metadata throughout the streaming process. + * + * @param parsedChunk - The parsed JSON response chunk from the provider's API + */ + processChunk(parsedChunk: unknown): void; + + /** + * Builds the metadata object after all chunks have been processed. + * Called at the end of the stream to generate the complete provider metadata. + * + * @returns Provider-specific metadata or undefined if no metadata is available. + * The metadata should be under a key indicating the provider id. + */ + buildMetadata(): SharedV2ProviderMetadata | undefined; + }; +}; diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-prepare-tools.ts b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-prepare-tools.ts new file mode 100644 index 000000000..5c5f6681e --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-prepare-tools.ts @@ -0,0 +1,92 @@ +import { + type LanguageModelV2CallOptions, + type LanguageModelV2CallWarning, + UnsupportedFunctionalityError, +} from '@ai-sdk/provider'; + +export function prepareTools({ + tools, + toolChoice, +}: { + tools: LanguageModelV2CallOptions['tools']; + toolChoice?: LanguageModelV2CallOptions['toolChoice']; +}): { + tools: + | undefined + | Array<{ + type: 'function'; + function: { + name: string; + description: string | undefined; + parameters: unknown; + }; + }>; + toolChoice: + | { type: 'function'; function: { name: string } } + | 'auto' + | 'none' + | 'required' + | undefined; + toolWarnings: LanguageModelV2CallWarning[]; +} { + // when the tools array is empty, change it to undefined to prevent errors: + tools = tools?.length ? tools : undefined; + + const toolWarnings: LanguageModelV2CallWarning[] = []; + + if (tools == null) { + return { tools: undefined, toolChoice: undefined, toolWarnings }; + } + + const openaiCompatTools: Array<{ + type: 'function'; + function: { + name: string; + description: string | undefined; + parameters: unknown; + }; + }> = []; + + for (const tool of tools) { + if (tool.type === 'provider-defined') { + toolWarnings.push({ type: 'unsupported-tool', tool }); + } else { + openaiCompatTools.push({ + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + }, + }); + } + } + + if (toolChoice == null) { + return { tools: openaiCompatTools, toolChoice: undefined, toolWarnings }; + } + + const type = toolChoice.type; + + switch (type) { + case 'auto': + case 'none': + case 'required': + return { tools: openaiCompatTools, toolChoice: type, toolWarnings }; + case 'tool': + return { + tools: openaiCompatTools, + toolChoice: { + type: 'function', + function: { name: toolChoice.toolName }, + }, + toolWarnings, + }; + default: { + const _exhaustiveCheck: never = type; + throw new UnsupportedFunctionalityError({ + functionality: `tool choice type: ${_exhaustiveCheck}`, + }); + } + } +} diff --git a/packages/opencode/src/provider/sdk/copilot/copilot-provider.ts b/packages/opencode/src/provider/sdk/copilot/copilot-provider.ts new file mode 100644 index 000000000..1dc373ff3 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/copilot-provider.ts @@ -0,0 +1,100 @@ +import type { LanguageModelV2 } from "@ai-sdk/provider" +import { type FetchFunction, withoutTrailingSlash, withUserAgentSuffix } from "@ai-sdk/provider-utils" +import { OpenAICompatibleChatLanguageModel } from "./chat/openai-compatible-chat-language-model" +import { OpenAIResponsesLanguageModel } from "./responses/openai-responses-language-model" + +// Import the version or define it +const VERSION = "0.1.0" + +export type OpenaiCompatibleModelId = string + +export interface OpenaiCompatibleProviderSettings { + /** + * API key for authenticating requests. + */ + apiKey?: string + + /** + * Base URL for the OpenAI Compatible API calls. + */ + baseURL?: string + + /** + * Name of the provider. + */ + name?: string + + /** + * Custom headers to include in the requests. + */ + headers?: Record + + /** + * Custom fetch implementation. + */ + fetch?: FetchFunction +} + +export interface OpenaiCompatibleProvider { + (modelId: OpenaiCompatibleModelId): LanguageModelV2 + chat(modelId: OpenaiCompatibleModelId): LanguageModelV2 + responses(modelId: OpenaiCompatibleModelId): LanguageModelV2 + languageModel(modelId: OpenaiCompatibleModelId): LanguageModelV2 + + // embeddingModel(modelId: any): EmbeddingModelV2 + + // imageModel(modelId: any): ImageModelV2 +} + +/** + * Create an OpenAI Compatible provider instance. + */ +export function createOpenaiCompatible(options: OpenaiCompatibleProviderSettings = {}): OpenaiCompatibleProvider { + const baseURL = withoutTrailingSlash(options.baseURL ?? "https://api.openai.com/v1") + + if (!baseURL) { + throw new Error("baseURL is required") + } + + // Merge headers: defaults first, then user overrides + const headers = { + // Default OpenAI Compatible headers (can be overridden by user) + ...(options.apiKey && { Authorization: `Bearer ${options.apiKey}` }), + ...options.headers, + } + + const getHeaders = () => withUserAgentSuffix(headers, `ai-sdk/openai-compatible/${VERSION}`) + + const createChatModel = (modelId: OpenaiCompatibleModelId) => { + return new OpenAICompatibleChatLanguageModel(modelId, { + provider: `${options.name ?? "openai-compatible"}.chat`, + headers: getHeaders, + url: ({ path }) => `${baseURL}${path}`, + fetch: options.fetch, + }) + } + + const createResponsesModel = (modelId: OpenaiCompatibleModelId) => { + return new OpenAIResponsesLanguageModel(modelId, { + provider: `${options.name ?? "openai-compatible"}.responses`, + headers: getHeaders, + url: ({ path }) => `${baseURL}${path}`, + fetch: options.fetch, + }) + } + + const createLanguageModel = (modelId: OpenaiCompatibleModelId) => createChatModel(modelId) + + const provider = function (modelId: OpenaiCompatibleModelId) { + return createChatModel(modelId) + } + + provider.languageModel = createLanguageModel + provider.chat = createChatModel + provider.responses = createResponsesModel + + return provider as OpenaiCompatibleProvider +} + +// Default OpenAI Compatible provider instance +export const openaiCompatible = createOpenaiCompatible() diff --git a/packages/opencode/src/provider/sdk/copilot/index.ts b/packages/opencode/src/provider/sdk/copilot/index.ts new file mode 100644 index 000000000..4da9cc21f --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/index.ts @@ -0,0 +1,2 @@ +export { createOpenaiCompatible, openaiCompatible } from "./copilot-provider" +export type { OpenaiCompatibleProvider, OpenaiCompatibleProviderSettings } from "./copilot-provider" diff --git a/packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts b/packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts new file mode 100644 index 000000000..edf4b8214 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/openai-compatible-error.ts @@ -0,0 +1,30 @@ +import { z, type ZodType } from 'zod/v4'; + +export const openaiCompatibleErrorDataSchema = z.object({ + error: z.object({ + message: z.string(), + + // The additional information below is handled loosely to support + // OpenAI-compatible providers that have slightly different error + // responses: + type: z.string().nullish(), + param: z.any().nullish(), + code: z.union([z.string(), z.number()]).nullish(), + }), +}); + +export type OpenAICompatibleErrorData = z.infer< + typeof openaiCompatibleErrorDataSchema +>; + +export type ProviderErrorStructure = { + errorSchema: ZodType; + errorToMessage: (error: T) => string; + isRetryable?: (response: Response, error?: T) => boolean; +}; + +export const defaultOpenAICompatibleErrorStructure: ProviderErrorStructure = + { + errorSchema: openaiCompatibleErrorDataSchema, + errorToMessage: data => data.error.message, + }; diff --git a/packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts b/packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts new file mode 100644 index 000000000..807f6ea57 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/convert-to-openai-responses-input.ts @@ -0,0 +1,303 @@ +import { + type LanguageModelV2CallWarning, + type LanguageModelV2Prompt, + type LanguageModelV2ToolCallPart, + UnsupportedFunctionalityError, +} from "@ai-sdk/provider" +import { convertToBase64, parseProviderOptions } from "@ai-sdk/provider-utils" +import { z } from "zod/v4" +import type { OpenAIResponsesInput, OpenAIResponsesReasoning } from "./openai-responses-api-types" +import { localShellInputSchema, localShellOutputSchema } from "./tool/local-shell" + +/** + * Check if a string is a file ID based on the given prefixes + * Returns false if prefixes is undefined (disables file ID detection) + */ +function isFileId(data: string, prefixes?: readonly string[]): boolean { + if (!prefixes) return false + return prefixes.some((prefix) => data.startsWith(prefix)) +} + +export async function convertToOpenAIResponsesInput({ + prompt, + systemMessageMode, + fileIdPrefixes, + store, + hasLocalShellTool = false, +}: { + prompt: LanguageModelV2Prompt + systemMessageMode: "system" | "developer" | "remove" + fileIdPrefixes?: readonly string[] + store: boolean + hasLocalShellTool?: boolean +}): Promise<{ + input: OpenAIResponsesInput + warnings: Array +}> { + const input: OpenAIResponsesInput = [] + const warnings: Array = [] + + for (const { role, content } of prompt) { + switch (role) { + case "system": { + switch (systemMessageMode) { + case "system": { + input.push({ role: "system", content }) + break + } + case "developer": { + input.push({ role: "developer", content }) + break + } + case "remove": { + warnings.push({ + type: "other", + message: "system messages are removed for this model", + }) + break + } + default: { + const _exhaustiveCheck: never = systemMessageMode + throw new Error(`Unsupported system message mode: ${_exhaustiveCheck}`) + } + } + break + } + + case "user": { + input.push({ + role: "user", + content: content.map((part, index) => { + switch (part.type) { + case "text": { + return { type: "input_text", text: part.text } + } + case "file": { + if (part.mediaType.startsWith("image/")) { + const mediaType = part.mediaType === "image/*" ? "image/jpeg" : part.mediaType + + return { + type: "input_image", + ...(part.data instanceof URL + ? { image_url: part.data.toString() } + : typeof part.data === "string" && isFileId(part.data, fileIdPrefixes) + ? { file_id: part.data } + : { + image_url: `data:${mediaType};base64,${convertToBase64(part.data)}`, + }), + detail: part.providerOptions?.openai?.imageDetail, + } + } else if (part.mediaType === "application/pdf") { + if (part.data instanceof URL) { + return { + type: "input_file", + file_url: part.data.toString(), + } + } + return { + type: "input_file", + ...(typeof part.data === "string" && isFileId(part.data, fileIdPrefixes) + ? { file_id: part.data } + : { + filename: part.filename ?? `part-${index}.pdf`, + file_data: `data:application/pdf;base64,${convertToBase64(part.data)}`, + }), + } + } else { + throw new UnsupportedFunctionalityError({ + functionality: `file part media type ${part.mediaType}`, + }) + } + } + } + }), + }) + + break + } + + case "assistant": { + const reasoningMessages: Record = {} + const toolCallParts: Record = {} + + for (const part of content) { + switch (part.type) { + case "text": { + input.push({ + role: "assistant", + content: [{ type: "output_text", text: part.text }], + id: (part.providerOptions?.openai?.itemId as string) ?? undefined, + }) + break + } + case "tool-call": { + toolCallParts[part.toolCallId] = part + + if (part.providerExecuted) { + break + } + + if (hasLocalShellTool && part.toolName === "local_shell") { + const parsedInput = localShellInputSchema.parse(part.input) + input.push({ + type: "local_shell_call", + call_id: part.toolCallId, + id: (part.providerOptions?.openai?.itemId as string) ?? undefined, + action: { + type: "exec", + command: parsedInput.action.command, + timeout_ms: parsedInput.action.timeoutMs, + user: parsedInput.action.user, + working_directory: parsedInput.action.workingDirectory, + env: parsedInput.action.env, + }, + }) + + break + } + + input.push({ + type: "function_call", + call_id: part.toolCallId, + name: part.toolName, + arguments: JSON.stringify(part.input), + id: (part.providerOptions?.openai?.itemId as string) ?? undefined, + }) + break + } + + // assistant tool result parts are from provider-executed tools: + case "tool-result": { + if (store) { + // use item references to refer to tool results from built-in tools + input.push({ type: "item_reference", id: part.toolCallId }) + } else { + warnings.push({ + type: "other", + message: `Results for OpenAI tool ${part.toolName} are not sent to the API when store is false`, + }) + } + + break + } + + case "reasoning": { + const providerOptions = await parseProviderOptions({ + provider: "copilot", + providerOptions: part.providerOptions, + schema: openaiResponsesReasoningProviderOptionsSchema, + }) + + const reasoningId = providerOptions?.itemId + + if (reasoningId != null) { + const reasoningMessage = reasoningMessages[reasoningId] + + if (store) { + if (reasoningMessage === undefined) { + // use item references to refer to reasoning (single reference) + input.push({ type: "item_reference", id: reasoningId }) + + // store unused reasoning message to mark id as used + reasoningMessages[reasoningId] = { + type: "reasoning", + id: reasoningId, + summary: [], + } + } + } else { + const summaryParts: Array<{ + type: "summary_text" + text: string + }> = [] + + if (part.text.length > 0) { + summaryParts.push({ + type: "summary_text", + text: part.text, + }) + } else if (reasoningMessage !== undefined) { + warnings.push({ + type: "other", + message: `Cannot append empty reasoning part to existing reasoning sequence. Skipping reasoning part: ${JSON.stringify(part)}.`, + }) + } + + if (reasoningMessage === undefined) { + reasoningMessages[reasoningId] = { + type: "reasoning", + id: reasoningId, + encrypted_content: providerOptions?.reasoningEncryptedContent, + summary: summaryParts, + } + input.push(reasoningMessages[reasoningId]) + } else { + reasoningMessage.summary.push(...summaryParts) + } + } + } else { + warnings.push({ + type: "other", + message: `Non-OpenAI reasoning parts are not supported. Skipping reasoning part: ${JSON.stringify(part)}.`, + }) + } + break + } + } + } + + break + } + + case "tool": { + for (const part of content) { + const output = part.output + + if (hasLocalShellTool && part.toolName === "local_shell" && output.type === "json") { + input.push({ + type: "local_shell_call_output", + call_id: part.toolCallId, + output: localShellOutputSchema.parse(output.value).output, + }) + break + } + + let contentValue: string + switch (output.type) { + case "text": + case "error-text": + contentValue = output.value + break + case "content": + case "json": + case "error-json": + contentValue = JSON.stringify(output.value) + break + } + + input.push({ + type: "function_call_output", + call_id: part.toolCallId, + output: contentValue, + }) + } + + break + } + + default: { + const _exhaustiveCheck: never = role + throw new Error(`Unsupported role: ${_exhaustiveCheck}`) + } + } + } + + return { input, warnings } +} + +const openaiResponsesReasoningProviderOptionsSchema = z.object({ + itemId: z.string().nullish(), + reasoningEncryptedContent: z.string().nullish(), +}) + +export type OpenAIResponsesReasoningProviderOptions = z.infer diff --git a/packages/opencode/src/provider/sdk/copilot/responses/map-openai-responses-finish-reason.ts b/packages/opencode/src/provider/sdk/copilot/responses/map-openai-responses-finish-reason.ts new file mode 100644 index 000000000..54bb9056d --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/map-openai-responses-finish-reason.ts @@ -0,0 +1,22 @@ +import type { LanguageModelV2FinishReason } from "@ai-sdk/provider" + +export function mapOpenAIResponseFinishReason({ + finishReason, + hasFunctionCall, +}: { + finishReason: string | null | undefined + // flag that checks if there have been client-side tool calls (not executed by openai) + hasFunctionCall: boolean +}): LanguageModelV2FinishReason { + switch (finishReason) { + case undefined: + case null: + return hasFunctionCall ? "tool-calls" : "stop" + case "max_output_tokens": + return "length" + case "content_filter": + return "content-filter" + default: + return hasFunctionCall ? "tool-calls" : "unknown" + } +} diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-config.ts b/packages/opencode/src/provider/sdk/copilot/responses/openai-config.ts new file mode 100644 index 000000000..2241dbb52 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/openai-config.ts @@ -0,0 +1,18 @@ +import type { FetchFunction } from "@ai-sdk/provider-utils" + +export type OpenAIConfig = { + provider: string + url: (options: { modelId: string; path: string }) => string + headers: () => Record + fetch?: FetchFunction + generateId?: () => string + /** + * File ID prefixes used to identify file IDs in Responses API. + * When undefined, all file data is treated as base64 content. + * + * Examples: + * - OpenAI: ['file-'] for IDs like 'file-abc123' + * - Azure OpenAI: ['assistant-'] for IDs like 'assistant-abc123' + */ + fileIdPrefixes?: readonly string[] +} diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts b/packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts new file mode 100644 index 000000000..e78824d36 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/openai-error.ts @@ -0,0 +1,22 @@ +import { z } from "zod/v4" +import { createJsonErrorResponseHandler } from "@ai-sdk/provider-utils" + +export const openaiErrorDataSchema = z.object({ + error: z.object({ + message: z.string(), + + // The additional information below is handled loosely to support + // OpenAI-compatible providers that have slightly different error + // responses: + type: z.string().nullish(), + param: z.any().nullish(), + code: z.union([z.string(), z.number()]).nullish(), + }), +}) + +export type OpenAIErrorData = z.infer + +export const openaiFailedResponseHandler: any = createJsonErrorResponseHandler({ + errorSchema: openaiErrorDataSchema, + errorToMessage: (data) => data.error.message, +}) diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-api-types.ts b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-api-types.ts new file mode 100644 index 000000000..cf1a3ba2f --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-api-types.ts @@ -0,0 +1,207 @@ +import type { JSONSchema7 } from "@ai-sdk/provider" + +export type OpenAIResponsesInput = Array + +export type OpenAIResponsesInputItem = + | OpenAIResponsesSystemMessage + | OpenAIResponsesUserMessage + | OpenAIResponsesAssistantMessage + | OpenAIResponsesFunctionCall + | OpenAIResponsesFunctionCallOutput + | OpenAIResponsesComputerCall + | OpenAIResponsesLocalShellCall + | OpenAIResponsesLocalShellCallOutput + | OpenAIResponsesReasoning + | OpenAIResponsesItemReference + +export type OpenAIResponsesIncludeValue = + | "web_search_call.action.sources" + | "code_interpreter_call.outputs" + | "computer_call_output.output.image_url" + | "file_search_call.results" + | "message.input_image.image_url" + | "message.output_text.logprobs" + | "reasoning.encrypted_content" + +export type OpenAIResponsesIncludeOptions = Array | undefined | null + +export type OpenAIResponsesSystemMessage = { + role: "system" | "developer" + content: string +} + +export type OpenAIResponsesUserMessage = { + role: "user" + content: Array< + | { type: "input_text"; text: string } + | { type: "input_image"; image_url: string } + | { type: "input_image"; file_id: string } + | { type: "input_file"; file_url: string } + | { type: "input_file"; filename: string; file_data: string } + | { type: "input_file"; file_id: string } + > +} + +export type OpenAIResponsesAssistantMessage = { + role: "assistant" + content: Array<{ type: "output_text"; text: string }> + id?: string +} + +export type OpenAIResponsesFunctionCall = { + type: "function_call" + call_id: string + name: string + arguments: string + id?: string +} + +export type OpenAIResponsesFunctionCallOutput = { + type: "function_call_output" + call_id: string + output: string +} + +export type OpenAIResponsesComputerCall = { + type: "computer_call" + id: string + status?: string +} + +export type OpenAIResponsesLocalShellCall = { + type: "local_shell_call" + id: string + call_id: string + action: { + type: "exec" + command: string[] + timeout_ms?: number + user?: string + working_directory?: string + env?: Record + } +} + +export type OpenAIResponsesLocalShellCallOutput = { + type: "local_shell_call_output" + call_id: string + output: string +} + +export type OpenAIResponsesItemReference = { + type: "item_reference" + id: string +} + +/** + * A filter used to compare a specified attribute key to a given value using a defined comparison operation. + */ +export type OpenAIResponsesFileSearchToolComparisonFilter = { + /** + * The key to compare against the value. + */ + key: string + + /** + * Specifies the comparison operator: eq, ne, gt, gte, lt, lte. + */ + type: "eq" | "ne" | "gt" | "gte" | "lt" | "lte" + + /** + * The value to compare against the attribute key; supports string, number, or boolean types. + */ + value: string | number | boolean +} + +/** + * Combine multiple filters using and or or. + */ +export type OpenAIResponsesFileSearchToolCompoundFilter = { + /** + * Type of operation: and or or. + */ + type: "and" | "or" + + /** + * Array of filters to combine. Items can be ComparisonFilter or CompoundFilter. + */ + filters: Array +} + +export type OpenAIResponsesTool = + | { + type: "function" + name: string + description: string | undefined + parameters: JSONSchema7 + strict: boolean | undefined + } + | { + type: "web_search" + filters: { allowed_domains: string[] | undefined } | undefined + search_context_size: "low" | "medium" | "high" | undefined + user_location: + | { + type: "approximate" + city?: string + country?: string + region?: string + timezone?: string + } + | undefined + } + | { + type: "web_search_preview" + search_context_size: "low" | "medium" | "high" | undefined + user_location: + | { + type: "approximate" + city?: string + country?: string + region?: string + timezone?: string + } + | undefined + } + | { + type: "code_interpreter" + container: string | { type: "auto"; file_ids: string[] | undefined } + } + | { + type: "file_search" + vector_store_ids: string[] + max_num_results: number | undefined + ranking_options: { ranker?: string; score_threshold?: number } | undefined + filters: OpenAIResponsesFileSearchToolComparisonFilter | OpenAIResponsesFileSearchToolCompoundFilter | undefined + } + | { + type: "image_generation" + background: "auto" | "opaque" | "transparent" | undefined + input_fidelity: "low" | "high" | undefined + input_image_mask: + | { + file_id: string | undefined + image_url: string | undefined + } + | undefined + model: string | undefined + moderation: "auto" | undefined + output_compression: number | undefined + output_format: "png" | "jpeg" | "webp" | undefined + partial_images: number | undefined + quality: "auto" | "low" | "medium" | "high" | undefined + size: "auto" | "1024x1024" | "1024x1536" | "1536x1024" | undefined + } + | { + type: "local_shell" + } + +export type OpenAIResponsesReasoning = { + type: "reasoning" + id: string + encrypted_content?: string | null + summary: Array<{ + type: "summary_text" + text: string + }> +} diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts new file mode 100644 index 000000000..0a575bc02 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-language-model.ts @@ -0,0 +1,1732 @@ +import { + APICallError, + type LanguageModelV2, + type LanguageModelV2CallWarning, + type LanguageModelV2Content, + type LanguageModelV2FinishReason, + type LanguageModelV2ProviderDefinedTool, + type LanguageModelV2StreamPart, + type LanguageModelV2Usage, + type SharedV2ProviderMetadata, +} from "@ai-sdk/provider" +import { + combineHeaders, + createEventSourceResponseHandler, + createJsonResponseHandler, + generateId, + parseProviderOptions, + type ParseResult, + postJsonToApi, +} from "@ai-sdk/provider-utils" +import { z } from "zod/v4" +import type { OpenAIConfig } from "./openai-config" +import { openaiFailedResponseHandler } from "./openai-error" +import { codeInterpreterInputSchema, codeInterpreterOutputSchema } from "./tool/code-interpreter" +import { fileSearchOutputSchema } from "./tool/file-search" +import { imageGenerationOutputSchema } from "./tool/image-generation" +import { convertToOpenAIResponsesInput } from "./convert-to-openai-responses-input" +import { mapOpenAIResponseFinishReason } from "./map-openai-responses-finish-reason" +import type { OpenAIResponsesIncludeOptions, OpenAIResponsesIncludeValue } from "./openai-responses-api-types" +import { prepareResponsesTools } from "./openai-responses-prepare-tools" +import type { OpenAIResponsesModelId } from "./openai-responses-settings" +import { localShellInputSchema } from "./tool/local-shell" + +const webSearchCallItem = z.object({ + type: z.literal("web_search_call"), + id: z.string(), + status: z.string(), + action: z + .discriminatedUnion("type", [ + z.object({ + type: z.literal("search"), + query: z.string().nullish(), + }), + z.object({ + type: z.literal("open_page"), + url: z.string(), + }), + z.object({ + type: z.literal("find"), + url: z.string(), + pattern: z.string(), + }), + ]) + .nullish(), +}) + +const fileSearchCallItem = z.object({ + type: z.literal("file_search_call"), + id: z.string(), + queries: z.array(z.string()), + results: z + .array( + z.object({ + attributes: z.record(z.string(), z.unknown()), + file_id: z.string(), + filename: z.string(), + score: z.number(), + text: z.string(), + }), + ) + .nullish(), +}) + +const codeInterpreterCallItem = z.object({ + type: z.literal("code_interpreter_call"), + id: z.string(), + code: z.string().nullable(), + container_id: z.string(), + outputs: z + .array( + z.discriminatedUnion("type", [ + z.object({ type: z.literal("logs"), logs: z.string() }), + z.object({ type: z.literal("image"), url: z.string() }), + ]), + ) + .nullable(), +}) + +const localShellCallItem = z.object({ + type: z.literal("local_shell_call"), + id: z.string(), + call_id: z.string(), + action: z.object({ + type: z.literal("exec"), + command: z.array(z.string()), + timeout_ms: z.number().optional(), + user: z.string().optional(), + working_directory: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + }), +}) + +const imageGenerationCallItem = z.object({ + type: z.literal("image_generation_call"), + id: z.string(), + result: z.string(), +}) + +/** + * `top_logprobs` request body argument can be set to an integer between + * 0 and 20 specifying the number of most likely tokens to return at each + * token position, each with an associated log probability. + * + * @see https://platform.openai.com/docs/api-reference/responses/create#responses_create-top_logprobs + */ +const TOP_LOGPROBS_MAX = 20 + +const LOGPROBS_SCHEMA = z.array( + z.object({ + token: z.string(), + logprob: z.number(), + top_logprobs: z.array( + z.object({ + token: z.string(), + logprob: z.number(), + }), + ), + }), +) + +export class OpenAIResponsesLanguageModel implements LanguageModelV2 { + readonly specificationVersion = "v2" + + readonly modelId: OpenAIResponsesModelId + + private readonly config: OpenAIConfig + + constructor(modelId: OpenAIResponsesModelId, config: OpenAIConfig) { + this.modelId = modelId + this.config = config + } + + readonly supportedUrls: Record = { + "image/*": [/^https?:\/\/.*$/], + "application/pdf": [/^https?:\/\/.*$/], + } + + get provider(): string { + return this.config.provider + } + + private async getArgs({ + maxOutputTokens, + temperature, + stopSequences, + topP, + topK, + presencePenalty, + frequencyPenalty, + seed, + prompt, + providerOptions, + tools, + toolChoice, + responseFormat, + }: Parameters[0]) { + const warnings: LanguageModelV2CallWarning[] = [] + const modelConfig = getResponsesModelConfig(this.modelId) + + if (topK != null) { + warnings.push({ type: "unsupported-setting", setting: "topK" }) + } + + if (seed != null) { + warnings.push({ type: "unsupported-setting", setting: "seed" }) + } + + if (presencePenalty != null) { + warnings.push({ + type: "unsupported-setting", + setting: "presencePenalty", + }) + } + + if (frequencyPenalty != null) { + warnings.push({ + type: "unsupported-setting", + setting: "frequencyPenalty", + }) + } + + if (stopSequences != null) { + warnings.push({ type: "unsupported-setting", setting: "stopSequences" }) + } + + const openaiOptions = await parseProviderOptions({ + provider: "copilot", + providerOptions, + schema: openaiResponsesProviderOptionsSchema, + }) + + const { input, warnings: inputWarnings } = await convertToOpenAIResponsesInput({ + prompt, + systemMessageMode: modelConfig.systemMessageMode, + fileIdPrefixes: this.config.fileIdPrefixes, + store: openaiOptions?.store ?? true, + hasLocalShellTool: hasOpenAITool("openai.local_shell"), + }) + + warnings.push(...inputWarnings) + + const strictJsonSchema = openaiOptions?.strictJsonSchema ?? false + + let include: OpenAIResponsesIncludeOptions = openaiOptions?.include + + function addInclude(key: OpenAIResponsesIncludeValue) { + include = include != null ? [...include, key] : [key] + } + + function hasOpenAITool(id: string) { + return tools?.find((tool) => tool.type === "provider-defined" && tool.id === id) != null + } + + // when logprobs are requested, automatically include them: + const topLogprobs = + typeof openaiOptions?.logprobs === "number" + ? openaiOptions?.logprobs + : openaiOptions?.logprobs === true + ? TOP_LOGPROBS_MAX + : undefined + + if (topLogprobs) { + addInclude("message.output_text.logprobs") + } + + // when a web search tool is present, automatically include the sources: + const webSearchToolName = ( + tools?.find( + (tool) => + tool.type === "provider-defined" && + (tool.id === "openai.web_search" || tool.id === "openai.web_search_preview"), + ) as LanguageModelV2ProviderDefinedTool | undefined + )?.name + + if (webSearchToolName) { + addInclude("web_search_call.action.sources") + } + + // when a code interpreter tool is present, automatically include the outputs: + if (hasOpenAITool("openai.code_interpreter")) { + addInclude("code_interpreter_call.outputs") + } + + const baseArgs = { + model: this.modelId, + input, + temperature, + top_p: topP, + max_output_tokens: maxOutputTokens, + + ...((responseFormat?.type === "json" || openaiOptions?.textVerbosity) && { + text: { + ...(responseFormat?.type === "json" && { + format: + responseFormat.schema != null + ? { + type: "json_schema", + strict: strictJsonSchema, + name: responseFormat.name ?? "response", + description: responseFormat.description, + schema: responseFormat.schema, + } + : { type: "json_object" }, + }), + ...(openaiOptions?.textVerbosity && { + verbosity: openaiOptions.textVerbosity, + }), + }, + }), + + // provider options: + max_tool_calls: openaiOptions?.maxToolCalls, + metadata: openaiOptions?.metadata, + parallel_tool_calls: openaiOptions?.parallelToolCalls, + previous_response_id: openaiOptions?.previousResponseId, + store: openaiOptions?.store, + user: openaiOptions?.user, + instructions: openaiOptions?.instructions, + service_tier: openaiOptions?.serviceTier, + include, + prompt_cache_key: openaiOptions?.promptCacheKey, + safety_identifier: openaiOptions?.safetyIdentifier, + top_logprobs: topLogprobs, + + // model-specific settings: + ...(modelConfig.isReasoningModel && + (openaiOptions?.reasoningEffort != null || openaiOptions?.reasoningSummary != null) && { + reasoning: { + ...(openaiOptions?.reasoningEffort != null && { + effort: openaiOptions.reasoningEffort, + }), + ...(openaiOptions?.reasoningSummary != null && { + summary: openaiOptions.reasoningSummary, + }), + }, + }), + ...(modelConfig.requiredAutoTruncation && { + truncation: "auto", + }), + } + + if (modelConfig.isReasoningModel) { + // remove unsupported settings for reasoning models + // see https://platform.openai.com/docs/guides/reasoning#limitations + if (baseArgs.temperature != null) { + baseArgs.temperature = undefined + warnings.push({ + type: "unsupported-setting", + setting: "temperature", + details: "temperature is not supported for reasoning models", + }) + } + + if (baseArgs.top_p != null) { + baseArgs.top_p = undefined + warnings.push({ + type: "unsupported-setting", + setting: "topP", + details: "topP is not supported for reasoning models", + }) + } + } else { + if (openaiOptions?.reasoningEffort != null) { + warnings.push({ + type: "unsupported-setting", + setting: "reasoningEffort", + details: "reasoningEffort is not supported for non-reasoning models", + }) + } + + if (openaiOptions?.reasoningSummary != null) { + warnings.push({ + type: "unsupported-setting", + setting: "reasoningSummary", + details: "reasoningSummary is not supported for non-reasoning models", + }) + } + } + + // Validate flex processing support + if (openaiOptions?.serviceTier === "flex" && !modelConfig.supportsFlexProcessing) { + warnings.push({ + type: "unsupported-setting", + setting: "serviceTier", + details: "flex processing is only available for o3, o4-mini, and gpt-5 models", + }) + // Remove from args if not supported + delete (baseArgs as any).service_tier + } + + // Validate priority processing support + if (openaiOptions?.serviceTier === "priority" && !modelConfig.supportsPriorityProcessing) { + warnings.push({ + type: "unsupported-setting", + setting: "serviceTier", + details: + "priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported", + }) + // Remove from args if not supported + delete (baseArgs as any).service_tier + } + + const { + tools: openaiTools, + toolChoice: openaiToolChoice, + toolWarnings, + } = prepareResponsesTools({ + tools, + toolChoice, + strictJsonSchema, + }) + + return { + webSearchToolName, + args: { + ...baseArgs, + tools: openaiTools, + tool_choice: openaiToolChoice, + }, + warnings: [...warnings, ...toolWarnings], + } + } + + async doGenerate( + options: Parameters[0], + ): Promise>> { + const { args: body, warnings, webSearchToolName } = await this.getArgs(options) + const url = this.config.url({ + path: "/responses", + modelId: this.modelId, + }) + + const { + responseHeaders, + value: response, + rawValue: rawResponse, + } = await postJsonToApi({ + url, + headers: combineHeaders(this.config.headers(), options.headers), + body, + failedResponseHandler: openaiFailedResponseHandler, + successfulResponseHandler: createJsonResponseHandler( + z.object({ + id: z.string(), + created_at: z.number(), + error: z + .object({ + code: z.string(), + message: z.string(), + }) + .nullish(), + model: z.string(), + output: z.array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("message"), + role: z.literal("assistant"), + id: z.string(), + content: z.array( + z.object({ + type: z.literal("output_text"), + text: z.string(), + logprobs: LOGPROBS_SCHEMA.nullish(), + annotations: z.array( + z.discriminatedUnion("type", [ + z.object({ + type: z.literal("url_citation"), + start_index: z.number(), + end_index: z.number(), + url: z.string(), + title: z.string(), + }), + z.object({ + type: z.literal("file_citation"), + file_id: z.string(), + filename: z.string().nullish(), + index: z.number().nullish(), + start_index: z.number().nullish(), + end_index: z.number().nullish(), + quote: z.string().nullish(), + }), + z.object({ + type: z.literal("container_file_citation"), + }), + ]), + ), + }), + ), + }), + webSearchCallItem, + fileSearchCallItem, + codeInterpreterCallItem, + imageGenerationCallItem, + localShellCallItem, + z.object({ + type: z.literal("function_call"), + call_id: z.string(), + name: z.string(), + arguments: z.string(), + id: z.string(), + }), + z.object({ + type: z.literal("computer_call"), + id: z.string(), + status: z.string().optional(), + }), + z.object({ + type: z.literal("reasoning"), + id: z.string(), + encrypted_content: z.string().nullish(), + summary: z.array( + z.object({ + type: z.literal("summary_text"), + text: z.string(), + }), + ), + }), + ]), + ), + service_tier: z.string().nullish(), + incomplete_details: z.object({ reason: z.string() }).nullish(), + usage: usageSchema, + }), + ), + abortSignal: options.abortSignal, + fetch: this.config.fetch, + }) + + if (response.error) { + throw new APICallError({ + message: response.error.message, + url, + requestBodyValues: body, + statusCode: 400, + responseHeaders, + responseBody: rawResponse as string, + isRetryable: false, + }) + } + + const content: Array = [] + const logprobs: Array> = [] + + // flag that checks if there have been client-side tool calls (not executed by openai) + let hasFunctionCall = false + + // map response content to content array + for (const part of response.output) { + switch (part.type) { + case "reasoning": { + // when there are no summary parts, we need to add an empty reasoning part: + if (part.summary.length === 0) { + part.summary.push({ type: "summary_text", text: "" }) + } + + for (const summary of part.summary) { + content.push({ + type: "reasoning" as const, + text: summary.text, + providerMetadata: { + openai: { + itemId: part.id, + reasoningEncryptedContent: part.encrypted_content ?? null, + }, + }, + }) + } + break + } + + case "image_generation_call": { + content.push({ + type: "tool-call", + toolCallId: part.id, + toolName: "image_generation", + input: "{}", + providerExecuted: true, + }) + + content.push({ + type: "tool-result", + toolCallId: part.id, + toolName: "image_generation", + result: { + result: part.result, + } satisfies z.infer, + providerExecuted: true, + }) + + break + } + + case "local_shell_call": { + content.push({ + type: "tool-call", + toolCallId: part.call_id, + toolName: "local_shell", + input: JSON.stringify({ action: part.action } satisfies z.infer), + providerMetadata: { + openai: { + itemId: part.id, + }, + }, + }) + + break + } + + case "message": { + for (const contentPart of part.content) { + if (options.providerOptions?.openai?.logprobs && contentPart.logprobs) { + logprobs.push(contentPart.logprobs) + } + + content.push({ + type: "text", + text: contentPart.text, + providerMetadata: { + openai: { + itemId: part.id, + }, + }, + }) + + for (const annotation of contentPart.annotations) { + if (annotation.type === "url_citation") { + content.push({ + type: "source", + sourceType: "url", + id: this.config.generateId?.() ?? generateId(), + url: annotation.url, + title: annotation.title, + }) + } else if (annotation.type === "file_citation") { + content.push({ + type: "source", + sourceType: "document", + id: this.config.generateId?.() ?? generateId(), + mediaType: "text/plain", + title: annotation.quote ?? annotation.filename ?? "Document", + filename: annotation.filename ?? annotation.file_id, + }) + } + } + } + + break + } + + case "function_call": { + hasFunctionCall = true + + content.push({ + type: "tool-call", + toolCallId: part.call_id, + toolName: part.name, + input: part.arguments, + providerMetadata: { + openai: { + itemId: part.id, + }, + }, + }) + break + } + + case "web_search_call": { + content.push({ + type: "tool-call", + toolCallId: part.id, + toolName: webSearchToolName ?? "web_search", + input: JSON.stringify({ action: part.action }), + providerExecuted: true, + }) + + content.push({ + type: "tool-result", + toolCallId: part.id, + toolName: webSearchToolName ?? "web_search", + result: { status: part.status }, + providerExecuted: true, + }) + + break + } + + case "computer_call": { + content.push({ + type: "tool-call", + toolCallId: part.id, + toolName: "computer_use", + input: "", + providerExecuted: true, + }) + + content.push({ + type: "tool-result", + toolCallId: part.id, + toolName: "computer_use", + result: { + type: "computer_use_tool_result", + status: part.status || "completed", + }, + providerExecuted: true, + }) + break + } + + case "file_search_call": { + content.push({ + type: "tool-call", + toolCallId: part.id, + toolName: "file_search", + input: "{}", + providerExecuted: true, + }) + + content.push({ + type: "tool-result", + toolCallId: part.id, + toolName: "file_search", + result: { + queries: part.queries, + results: + part.results?.map((result) => ({ + attributes: result.attributes, + fileId: result.file_id, + filename: result.filename, + score: result.score, + text: result.text, + })) ?? null, + } satisfies z.infer, + providerExecuted: true, + }) + break + } + + case "code_interpreter_call": { + content.push({ + type: "tool-call", + toolCallId: part.id, + toolName: "code_interpreter", + input: JSON.stringify({ + code: part.code, + containerId: part.container_id, + } satisfies z.infer), + providerExecuted: true, + }) + + content.push({ + type: "tool-result", + toolCallId: part.id, + toolName: "code_interpreter", + result: { + outputs: part.outputs, + } satisfies z.infer, + providerExecuted: true, + }) + break + } + } + } + + const providerMetadata: SharedV2ProviderMetadata = { + openai: { responseId: response.id }, + } + + if (logprobs.length > 0) { + providerMetadata.openai.logprobs = logprobs + } + + if (typeof response.service_tier === "string") { + providerMetadata.openai.serviceTier = response.service_tier + } + + return { + content, + finishReason: mapOpenAIResponseFinishReason({ + finishReason: response.incomplete_details?.reason, + hasFunctionCall, + }), + usage: { + inputTokens: response.usage.input_tokens, + outputTokens: response.usage.output_tokens, + totalTokens: response.usage.input_tokens + response.usage.output_tokens, + reasoningTokens: response.usage.output_tokens_details?.reasoning_tokens ?? undefined, + cachedInputTokens: response.usage.input_tokens_details?.cached_tokens ?? undefined, + }, + request: { body }, + response: { + id: response.id, + timestamp: new Date(response.created_at * 1000), + modelId: response.model, + headers: responseHeaders, + body: rawResponse, + }, + providerMetadata, + warnings, + } + } + + async doStream( + options: Parameters[0], + ): Promise>> { + const { args: body, warnings, webSearchToolName } = await this.getArgs(options) + + const { responseHeaders, value: response } = await postJsonToApi({ + url: this.config.url({ + path: "/responses", + modelId: this.modelId, + }), + headers: combineHeaders(this.config.headers(), options.headers), + body: { + ...body, + stream: true, + }, + failedResponseHandler: openaiFailedResponseHandler, + successfulResponseHandler: createEventSourceResponseHandler(openaiResponsesChunkSchema), + abortSignal: options.abortSignal, + fetch: this.config.fetch, + }) + + const self = this + + let finishReason: LanguageModelV2FinishReason = "unknown" + const usage: LanguageModelV2Usage = { + inputTokens: undefined, + outputTokens: undefined, + totalTokens: undefined, + } + const logprobs: Array> = [] + let responseId: string | null = null + const ongoingToolCalls: Record< + number, + | { + toolName: string + toolCallId: string + codeInterpreter?: { + containerId: string + } + } + | undefined + > = {} + + // flag that checks if there have been client-side tool calls (not executed by openai) + let hasFunctionCall = false + + // Track reasoning by output_index instead of item_id + // GitHub Copilot rotates encrypted item IDs on every event + const activeReasoning: Record< + number, + { + canonicalId: string // the item.id from output_item.added + encryptedContent?: string | null + summaryParts: number[] + } + > = {} + + // Track current active reasoning output_index for correlating summary events + let currentReasoningOutputIndex: number | null = null + + // Track a stable text part id for the current assistant message. + // Copilot may change item_id across text deltas; normalize to one id. + let currentTextId: string | null = null + + let serviceTier: string | undefined + + return { + stream: response.pipeThrough( + new TransformStream>, LanguageModelV2StreamPart>({ + start(controller) { + controller.enqueue({ type: "stream-start", warnings }) + }, + + transform(chunk, controller) { + if (options.includeRawChunks) { + controller.enqueue({ type: "raw", rawValue: chunk.rawValue }) + } + + // handle failed chunk parsing / validation: + if (!chunk.success) { + finishReason = "error" + controller.enqueue({ type: "error", error: chunk.error }) + return + } + + const value = chunk.value + + if (isResponseOutputItemAddedChunk(value)) { + if (value.item.type === "function_call") { + ongoingToolCalls[value.output_index] = { + toolName: value.item.name, + toolCallId: value.item.call_id, + } + + controller.enqueue({ + type: "tool-input-start", + id: value.item.call_id, + toolName: value.item.name, + }) + } else if (value.item.type === "web_search_call") { + ongoingToolCalls[value.output_index] = { + toolName: webSearchToolName ?? "web_search", + toolCallId: value.item.id, + } + + controller.enqueue({ + type: "tool-input-start", + id: value.item.id, + toolName: webSearchToolName ?? "web_search", + }) + } else if (value.item.type === "computer_call") { + ongoingToolCalls[value.output_index] = { + toolName: "computer_use", + toolCallId: value.item.id, + } + + controller.enqueue({ + type: "tool-input-start", + id: value.item.id, + toolName: "computer_use", + }) + } else if (value.item.type === "code_interpreter_call") { + ongoingToolCalls[value.output_index] = { + toolName: "code_interpreter", + toolCallId: value.item.id, + codeInterpreter: { + containerId: value.item.container_id, + }, + } + + controller.enqueue({ + type: "tool-input-start", + id: value.item.id, + toolName: "code_interpreter", + }) + + controller.enqueue({ + type: "tool-input-delta", + id: value.item.id, + delta: `{"containerId":"${value.item.container_id}","code":"`, + }) + } else if (value.item.type === "file_search_call") { + controller.enqueue({ + type: "tool-call", + toolCallId: value.item.id, + toolName: "file_search", + input: "{}", + providerExecuted: true, + }) + } else if (value.item.type === "image_generation_call") { + controller.enqueue({ + type: "tool-call", + toolCallId: value.item.id, + toolName: "image_generation", + input: "{}", + providerExecuted: true, + }) + } else if (value.item.type === "message") { + // Start a stable text part for this assistant message + currentTextId = value.item.id + controller.enqueue({ + type: "text-start", + id: value.item.id, + providerMetadata: { + openai: { + itemId: value.item.id, + }, + }, + }) + } else if (isResponseOutputItemAddedReasoningChunk(value)) { + activeReasoning[value.output_index] = { + canonicalId: value.item.id, + encryptedContent: value.item.encrypted_content, + summaryParts: [0], + } + currentReasoningOutputIndex = value.output_index + + controller.enqueue({ + type: "reasoning-start", + id: `${value.item.id}:0`, + providerMetadata: { + openai: { + itemId: value.item.id, + reasoningEncryptedContent: value.item.encrypted_content ?? null, + }, + }, + }) + } + } else if (isResponseOutputItemDoneChunk(value)) { + if (value.item.type === "function_call") { + ongoingToolCalls[value.output_index] = undefined + hasFunctionCall = true + + controller.enqueue({ + type: "tool-input-end", + id: value.item.call_id, + }) + + controller.enqueue({ + type: "tool-call", + toolCallId: value.item.call_id, + toolName: value.item.name, + input: value.item.arguments, + providerMetadata: { + openai: { + itemId: value.item.id, + }, + }, + }) + } else if (value.item.type === "web_search_call") { + ongoingToolCalls[value.output_index] = undefined + + controller.enqueue({ + type: "tool-input-end", + id: value.item.id, + }) + + controller.enqueue({ + type: "tool-call", + toolCallId: value.item.id, + toolName: "web_search", + input: JSON.stringify({ action: value.item.action }), + providerExecuted: true, + }) + + controller.enqueue({ + type: "tool-result", + toolCallId: value.item.id, + toolName: "web_search", + result: { status: value.item.status }, + providerExecuted: true, + }) + } else if (value.item.type === "computer_call") { + ongoingToolCalls[value.output_index] = undefined + + controller.enqueue({ + type: "tool-input-end", + id: value.item.id, + }) + + controller.enqueue({ + type: "tool-call", + toolCallId: value.item.id, + toolName: "computer_use", + input: "", + providerExecuted: true, + }) + + controller.enqueue({ + type: "tool-result", + toolCallId: value.item.id, + toolName: "computer_use", + result: { + type: "computer_use_tool_result", + status: value.item.status || "completed", + }, + providerExecuted: true, + }) + } else if (value.item.type === "file_search_call") { + ongoingToolCalls[value.output_index] = undefined + + controller.enqueue({ + type: "tool-result", + toolCallId: value.item.id, + toolName: "file_search", + result: { + queries: value.item.queries, + results: + value.item.results?.map((result) => ({ + attributes: result.attributes, + fileId: result.file_id, + filename: result.filename, + score: result.score, + text: result.text, + })) ?? null, + } satisfies z.infer, + providerExecuted: true, + }) + } else if (value.item.type === "code_interpreter_call") { + ongoingToolCalls[value.output_index] = undefined + + controller.enqueue({ + type: "tool-result", + toolCallId: value.item.id, + toolName: "code_interpreter", + result: { + outputs: value.item.outputs, + } satisfies z.infer, + providerExecuted: true, + }) + } else if (value.item.type === "image_generation_call") { + controller.enqueue({ + type: "tool-result", + toolCallId: value.item.id, + toolName: "image_generation", + result: { + result: value.item.result, + } satisfies z.infer, + providerExecuted: true, + }) + } else if (value.item.type === "local_shell_call") { + ongoingToolCalls[value.output_index] = undefined + + controller.enqueue({ + type: "tool-call", + toolCallId: value.item.call_id, + toolName: "local_shell", + input: JSON.stringify({ + action: { + type: "exec", + command: value.item.action.command, + timeoutMs: value.item.action.timeout_ms, + user: value.item.action.user, + workingDirectory: value.item.action.working_directory, + env: value.item.action.env, + }, + } satisfies z.infer), + providerMetadata: { + openai: { itemId: value.item.id }, + }, + }) + } else if (value.item.type === "message") { + if (currentTextId) { + controller.enqueue({ + type: "text-end", + id: currentTextId, + }) + currentTextId = null + } + } else if (isResponseOutputItemDoneReasoningChunk(value)) { + const activeReasoningPart = activeReasoning[value.output_index] + if (activeReasoningPart) { + for (const summaryIndex of activeReasoningPart.summaryParts) { + controller.enqueue({ + type: "reasoning-end", + id: `${activeReasoningPart.canonicalId}:${summaryIndex}`, + providerMetadata: { + openai: { + itemId: activeReasoningPart.canonicalId, + reasoningEncryptedContent: value.item.encrypted_content ?? null, + }, + }, + }) + } + delete activeReasoning[value.output_index] + if (currentReasoningOutputIndex === value.output_index) { + currentReasoningOutputIndex = null + } + } + } + } else if (isResponseFunctionCallArgumentsDeltaChunk(value)) { + const toolCall = ongoingToolCalls[value.output_index] + + if (toolCall != null) { + controller.enqueue({ + type: "tool-input-delta", + id: toolCall.toolCallId, + delta: value.delta, + }) + } + } else if (isResponseImageGenerationCallPartialImageChunk(value)) { + controller.enqueue({ + type: "tool-result", + toolCallId: value.item_id, + toolName: "image_generation", + result: { + result: value.partial_image_b64, + } satisfies z.infer, + providerExecuted: true, + }) + } else if (isResponseCodeInterpreterCallCodeDeltaChunk(value)) { + const toolCall = ongoingToolCalls[value.output_index] + + if (toolCall != null) { + controller.enqueue({ + type: "tool-input-delta", + id: toolCall.toolCallId, + // The delta is code, which is embedding in a JSON string. + // To escape it, we use JSON.stringify and slice to remove the outer quotes. + delta: JSON.stringify(value.delta).slice(1, -1), + }) + } + } else if (isResponseCodeInterpreterCallCodeDoneChunk(value)) { + const toolCall = ongoingToolCalls[value.output_index] + + if (toolCall != null) { + controller.enqueue({ + type: "tool-input-delta", + id: toolCall.toolCallId, + delta: '"}', + }) + + controller.enqueue({ + type: "tool-input-end", + id: toolCall.toolCallId, + }) + + // immediately send the tool call after the input end: + controller.enqueue({ + type: "tool-call", + toolCallId: toolCall.toolCallId, + toolName: "code_interpreter", + input: JSON.stringify({ + code: value.code, + containerId: toolCall.codeInterpreter!.containerId, + } satisfies z.infer), + providerExecuted: true, + }) + } + } else if (isResponseCreatedChunk(value)) { + responseId = value.response.id + controller.enqueue({ + type: "response-metadata", + id: value.response.id, + timestamp: new Date(value.response.created_at * 1000), + modelId: value.response.model, + }) + } else if (isTextDeltaChunk(value)) { + // Ensure a text-start exists, and normalize deltas to a stable id + if (!currentTextId) { + currentTextId = value.item_id + controller.enqueue({ + type: "text-start", + id: currentTextId, + providerMetadata: { + openai: { itemId: value.item_id }, + }, + }) + } + + controller.enqueue({ + type: "text-delta", + id: currentTextId, + delta: value.delta, + }) + + if (options.providerOptions?.openai?.logprobs && value.logprobs) { + logprobs.push(value.logprobs) + } + } else if (isResponseReasoningSummaryPartAddedChunk(value)) { + const activeItem = + currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null + + // the first reasoning start is pushed in isResponseOutputItemAddedReasoningChunk. + if (activeItem && value.summary_index > 0) { + activeItem.summaryParts.push(value.summary_index) + + controller.enqueue({ + type: "reasoning-start", + id: `${activeItem.canonicalId}:${value.summary_index}`, + providerMetadata: { + openai: { + itemId: activeItem.canonicalId, + reasoningEncryptedContent: activeItem.encryptedContent ?? null, + }, + }, + }) + } + } else if (isResponseReasoningSummaryTextDeltaChunk(value)) { + const activeItem = + currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null + + if (activeItem) { + controller.enqueue({ + type: "reasoning-delta", + id: `${activeItem.canonicalId}:${value.summary_index}`, + delta: value.delta, + providerMetadata: { + openai: { + itemId: activeItem.canonicalId, + }, + }, + }) + } + } else if (isResponseFinishedChunk(value)) { + finishReason = mapOpenAIResponseFinishReason({ + finishReason: value.response.incomplete_details?.reason, + hasFunctionCall, + }) + usage.inputTokens = value.response.usage.input_tokens + usage.outputTokens = value.response.usage.output_tokens + usage.totalTokens = value.response.usage.input_tokens + value.response.usage.output_tokens + usage.reasoningTokens = value.response.usage.output_tokens_details?.reasoning_tokens ?? undefined + usage.cachedInputTokens = value.response.usage.input_tokens_details?.cached_tokens ?? undefined + if (typeof value.response.service_tier === "string") { + serviceTier = value.response.service_tier + } + } else if (isResponseAnnotationAddedChunk(value)) { + if (value.annotation.type === "url_citation") { + controller.enqueue({ + type: "source", + sourceType: "url", + id: self.config.generateId?.() ?? generateId(), + url: value.annotation.url, + title: value.annotation.title, + }) + } else if (value.annotation.type === "file_citation") { + controller.enqueue({ + type: "source", + sourceType: "document", + id: self.config.generateId?.() ?? generateId(), + mediaType: "text/plain", + title: value.annotation.quote ?? value.annotation.filename ?? "Document", + filename: value.annotation.filename ?? value.annotation.file_id, + }) + } + } else if (isErrorChunk(value)) { + controller.enqueue({ type: "error", error: value }) + } + }, + + flush(controller) { + // Close any dangling text part + if (currentTextId) { + controller.enqueue({ type: "text-end", id: currentTextId }) + currentTextId = null + } + + const providerMetadata: SharedV2ProviderMetadata = { + openai: { + responseId, + }, + } + + if (logprobs.length > 0) { + providerMetadata.openai.logprobs = logprobs + } + + if (serviceTier !== undefined) { + providerMetadata.openai.serviceTier = serviceTier + } + + controller.enqueue({ + type: "finish", + finishReason, + usage, + providerMetadata, + }) + }, + }), + ), + request: { body }, + response: { headers: responseHeaders }, + } + } +} + +const usageSchema = z.object({ + input_tokens: z.number(), + input_tokens_details: z.object({ cached_tokens: z.number().nullish() }).nullish(), + output_tokens: z.number(), + output_tokens_details: z.object({ reasoning_tokens: z.number().nullish() }).nullish(), +}) + +const textDeltaChunkSchema = z.object({ + type: z.literal("response.output_text.delta"), + item_id: z.string(), + delta: z.string(), + logprobs: LOGPROBS_SCHEMA.nullish(), +}) + +const errorChunkSchema = z.object({ + type: z.literal("error"), + code: z.string(), + message: z.string(), + param: z.string().nullish(), + sequence_number: z.number(), +}) + +const responseFinishedChunkSchema = z.object({ + type: z.enum(["response.completed", "response.incomplete"]), + response: z.object({ + incomplete_details: z.object({ reason: z.string() }).nullish(), + usage: usageSchema, + service_tier: z.string().nullish(), + }), +}) + +const responseCreatedChunkSchema = z.object({ + type: z.literal("response.created"), + response: z.object({ + id: z.string(), + created_at: z.number(), + model: z.string(), + service_tier: z.string().nullish(), + }), +}) + +const responseOutputItemAddedSchema = z.object({ + type: z.literal("response.output_item.added"), + output_index: z.number(), + item: z.discriminatedUnion("type", [ + z.object({ + type: z.literal("message"), + id: z.string(), + }), + z.object({ + type: z.literal("reasoning"), + id: z.string(), + encrypted_content: z.string().nullish(), + }), + z.object({ + type: z.literal("function_call"), + id: z.string(), + call_id: z.string(), + name: z.string(), + arguments: z.string(), + }), + z.object({ + type: z.literal("web_search_call"), + id: z.string(), + status: z.string(), + action: z + .object({ + type: z.literal("search"), + query: z.string().optional(), + }) + .nullish(), + }), + z.object({ + type: z.literal("computer_call"), + id: z.string(), + status: z.string(), + }), + z.object({ + type: z.literal("file_search_call"), + id: z.string(), + }), + z.object({ + type: z.literal("image_generation_call"), + id: z.string(), + }), + z.object({ + type: z.literal("code_interpreter_call"), + id: z.string(), + container_id: z.string(), + code: z.string().nullable(), + outputs: z + .array( + z.discriminatedUnion("type", [ + z.object({ type: z.literal("logs"), logs: z.string() }), + z.object({ type: z.literal("image"), url: z.string() }), + ]), + ) + .nullable(), + status: z.string(), + }), + ]), +}) + +const responseOutputItemDoneSchema = z.object({ + type: z.literal("response.output_item.done"), + output_index: z.number(), + item: z.discriminatedUnion("type", [ + z.object({ + type: z.literal("message"), + id: z.string(), + }), + z.object({ + type: z.literal("reasoning"), + id: z.string(), + encrypted_content: z.string().nullish(), + }), + z.object({ + type: z.literal("function_call"), + id: z.string(), + call_id: z.string(), + name: z.string(), + arguments: z.string(), + status: z.literal("completed"), + }), + codeInterpreterCallItem, + imageGenerationCallItem, + webSearchCallItem, + fileSearchCallItem, + localShellCallItem, + z.object({ + type: z.literal("computer_call"), + id: z.string(), + status: z.literal("completed"), + }), + ]), +}) + +const responseFunctionCallArgumentsDeltaSchema = z.object({ + type: z.literal("response.function_call_arguments.delta"), + item_id: z.string(), + output_index: z.number(), + delta: z.string(), +}) + +const responseImageGenerationCallPartialImageSchema = z.object({ + type: z.literal("response.image_generation_call.partial_image"), + item_id: z.string(), + output_index: z.number(), + partial_image_b64: z.string(), +}) + +const responseCodeInterpreterCallCodeDeltaSchema = z.object({ + type: z.literal("response.code_interpreter_call_code.delta"), + item_id: z.string(), + output_index: z.number(), + delta: z.string(), +}) + +const responseCodeInterpreterCallCodeDoneSchema = z.object({ + type: z.literal("response.code_interpreter_call_code.done"), + item_id: z.string(), + output_index: z.number(), + code: z.string(), +}) + +const responseAnnotationAddedSchema = z.object({ + type: z.literal("response.output_text.annotation.added"), + annotation: z.discriminatedUnion("type", [ + z.object({ + type: z.literal("url_citation"), + url: z.string(), + title: z.string(), + }), + z.object({ + type: z.literal("file_citation"), + file_id: z.string(), + filename: z.string().nullish(), + index: z.number().nullish(), + start_index: z.number().nullish(), + end_index: z.number().nullish(), + quote: z.string().nullish(), + }), + ]), +}) + +const responseReasoningSummaryPartAddedSchema = z.object({ + type: z.literal("response.reasoning_summary_part.added"), + item_id: z.string(), + summary_index: z.number(), +}) + +const responseReasoningSummaryTextDeltaSchema = z.object({ + type: z.literal("response.reasoning_summary_text.delta"), + item_id: z.string(), + summary_index: z.number(), + delta: z.string(), +}) + +const openaiResponsesChunkSchema = z.union([ + textDeltaChunkSchema, + responseFinishedChunkSchema, + responseCreatedChunkSchema, + responseOutputItemAddedSchema, + responseOutputItemDoneSchema, + responseFunctionCallArgumentsDeltaSchema, + responseImageGenerationCallPartialImageSchema, + responseCodeInterpreterCallCodeDeltaSchema, + responseCodeInterpreterCallCodeDoneSchema, + responseAnnotationAddedSchema, + responseReasoningSummaryPartAddedSchema, + responseReasoningSummaryTextDeltaSchema, + errorChunkSchema, + z.object({ type: z.string() }).loose(), // fallback for unknown chunks +]) + +type ExtractByType = T extends { type: K } ? T : never + +function isTextDeltaChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.output_text.delta" +} + +function isResponseOutputItemDoneChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.output_item.done" +} + +function isResponseOutputItemDoneReasoningChunk(chunk: z.infer): chunk is z.infer< + typeof responseOutputItemDoneSchema +> & { + item: ExtractByType["item"], "reasoning"> +} { + return isResponseOutputItemDoneChunk(chunk) && chunk.item.type === "reasoning" +} + +function isResponseFinishedChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.completed" || chunk.type === "response.incomplete" +} + +function isResponseCreatedChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.created" +} + +function isResponseFunctionCallArgumentsDeltaChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.function_call_arguments.delta" +} +function isResponseImageGenerationCallPartialImageChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.image_generation_call.partial_image" +} + +function isResponseCodeInterpreterCallCodeDeltaChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.code_interpreter_call_code.delta" +} + +function isResponseCodeInterpreterCallCodeDoneChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.code_interpreter_call_code.done" +} + +function isResponseOutputItemAddedChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.output_item.added" +} + +function isResponseOutputItemAddedReasoningChunk(chunk: z.infer): chunk is z.infer< + typeof responseOutputItemAddedSchema +> & { + item: ExtractByType["item"], "reasoning"> +} { + return isResponseOutputItemAddedChunk(chunk) && chunk.item.type === "reasoning" +} + +function isResponseAnnotationAddedChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.output_text.annotation.added" +} + +function isResponseReasoningSummaryPartAddedChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.reasoning_summary_part.added" +} + +function isResponseReasoningSummaryTextDeltaChunk( + chunk: z.infer, +): chunk is z.infer { + return chunk.type === "response.reasoning_summary_text.delta" +} + +function isErrorChunk(chunk: z.infer): chunk is z.infer { + return chunk.type === "error" +} + +type ResponsesModelConfig = { + isReasoningModel: boolean + systemMessageMode: "remove" | "system" | "developer" + requiredAutoTruncation: boolean + supportsFlexProcessing: boolean + supportsPriorityProcessing: boolean +} + +function getResponsesModelConfig(modelId: string): ResponsesModelConfig { + const supportsFlexProcessing = + modelId.startsWith("o3") || + modelId.startsWith("o4-mini") || + (modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-chat")) + const supportsPriorityProcessing = + modelId.startsWith("gpt-4") || + modelId.startsWith("gpt-5-mini") || + (modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-nano") && !modelId.startsWith("gpt-5-chat")) || + modelId.startsWith("o3") || + modelId.startsWith("o4-mini") + const defaults = { + requiredAutoTruncation: false, + systemMessageMode: "system" as const, + supportsFlexProcessing, + supportsPriorityProcessing, + } + + // gpt-5-chat models are non-reasoning + if (modelId.startsWith("gpt-5-chat")) { + return { + ...defaults, + isReasoningModel: false, + } + } + + // o series reasoning models: + if ( + modelId.startsWith("o") || + modelId.startsWith("gpt-5") || + modelId.startsWith("codex-") || + modelId.startsWith("computer-use") + ) { + if (modelId.startsWith("o1-mini") || modelId.startsWith("o1-preview")) { + return { + ...defaults, + isReasoningModel: true, + systemMessageMode: "remove", + } + } + + return { + ...defaults, + isReasoningModel: true, + systemMessageMode: "developer", + } + } + + // gpt models: + return { + ...defaults, + isReasoningModel: false, + } +} + +// TODO AI SDK 6: use optional here instead of nullish +const openaiResponsesProviderOptionsSchema = z.object({ + include: z + .array(z.enum(["reasoning.encrypted_content", "file_search_call.results", "message.output_text.logprobs"])) + .nullish(), + instructions: z.string().nullish(), + + /** + * Return the log probabilities of the tokens. + * + * Setting to true will return the log probabilities of the tokens that + * were generated. + * + * Setting to a number will return the log probabilities of the top n + * tokens that were generated. + * + * @see https://platform.openai.com/docs/api-reference/responses/create + * @see https://cookbook.openai.com/examples/using_logprobs + */ + logprobs: z.union([z.boolean(), z.number().min(1).max(TOP_LOGPROBS_MAX)]).optional(), + + /** + * The maximum number of total calls to built-in tools that can be processed in a response. + * This maximum number applies across all built-in tool calls, not per individual tool. + * Any further attempts to call a tool by the model will be ignored. + */ + maxToolCalls: z.number().nullish(), + + metadata: z.any().nullish(), + parallelToolCalls: z.boolean().nullish(), + previousResponseId: z.string().nullish(), + promptCacheKey: z.string().nullish(), + reasoningEffort: z.string().nullish(), + reasoningSummary: z.string().nullish(), + safetyIdentifier: z.string().nullish(), + serviceTier: z.enum(["auto", "flex", "priority"]).nullish(), + store: z.boolean().nullish(), + strictJsonSchema: z.boolean().nullish(), + textVerbosity: z.enum(["low", "medium", "high"]).nullish(), + user: z.string().nullish(), +}) + +export type OpenAIResponsesProviderOptions = z.infer diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-prepare-tools.ts b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-prepare-tools.ts new file mode 100644 index 000000000..791de3e7c --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-prepare-tools.ts @@ -0,0 +1,177 @@ +import { + type LanguageModelV2CallOptions, + type LanguageModelV2CallWarning, + UnsupportedFunctionalityError, +} from "@ai-sdk/provider" +import { codeInterpreterArgsSchema } from "./tool/code-interpreter" +import { fileSearchArgsSchema } from "./tool/file-search" +import { webSearchArgsSchema } from "./tool/web-search" +import { webSearchPreviewArgsSchema } from "./tool/web-search-preview" +import { imageGenerationArgsSchema } from "./tool/image-generation" +import type { OpenAIResponsesTool } from "./openai-responses-api-types" + +export function prepareResponsesTools({ + tools, + toolChoice, + strictJsonSchema, +}: { + tools: LanguageModelV2CallOptions["tools"] + toolChoice?: LanguageModelV2CallOptions["toolChoice"] + strictJsonSchema: boolean +}): { + tools?: Array + toolChoice?: + | "auto" + | "none" + | "required" + | { type: "file_search" } + | { type: "web_search_preview" } + | { type: "web_search" } + | { type: "function"; name: string } + | { type: "code_interpreter" } + | { type: "image_generation" } + toolWarnings: LanguageModelV2CallWarning[] +} { + // when the tools array is empty, change it to undefined to prevent errors: + tools = tools?.length ? tools : undefined + + const toolWarnings: LanguageModelV2CallWarning[] = [] + + if (tools == null) { + return { tools: undefined, toolChoice: undefined, toolWarnings } + } + + const openaiTools: Array = [] + + for (const tool of tools) { + switch (tool.type) { + case "function": + openaiTools.push({ + type: "function", + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + strict: strictJsonSchema, + }) + break + case "provider-defined": { + switch (tool.id) { + case "openai.file_search": { + const args = fileSearchArgsSchema.parse(tool.args) + + openaiTools.push({ + type: "file_search", + vector_store_ids: args.vectorStoreIds, + max_num_results: args.maxNumResults, + ranking_options: args.ranking + ? { + ranker: args.ranking.ranker, + score_threshold: args.ranking.scoreThreshold, + } + : undefined, + filters: args.filters, + }) + + break + } + case "openai.local_shell": { + openaiTools.push({ + type: "local_shell", + }) + break + } + case "openai.web_search_preview": { + const args = webSearchPreviewArgsSchema.parse(tool.args) + openaiTools.push({ + type: "web_search_preview", + search_context_size: args.searchContextSize, + user_location: args.userLocation, + }) + break + } + case "openai.web_search": { + const args = webSearchArgsSchema.parse(tool.args) + openaiTools.push({ + type: "web_search", + filters: args.filters != null ? { allowed_domains: args.filters.allowedDomains } : undefined, + search_context_size: args.searchContextSize, + user_location: args.userLocation, + }) + break + } + case "openai.code_interpreter": { + const args = codeInterpreterArgsSchema.parse(tool.args) + openaiTools.push({ + type: "code_interpreter", + container: + args.container == null + ? { type: "auto", file_ids: undefined } + : typeof args.container === "string" + ? args.container + : { type: "auto", file_ids: args.container.fileIds }, + }) + break + } + case "openai.image_generation": { + const args = imageGenerationArgsSchema.parse(tool.args) + openaiTools.push({ + type: "image_generation", + background: args.background, + input_fidelity: args.inputFidelity, + input_image_mask: args.inputImageMask + ? { + file_id: args.inputImageMask.fileId, + image_url: args.inputImageMask.imageUrl, + } + : undefined, + model: args.model, + moderation: args.moderation, + partial_images: args.partialImages, + quality: args.quality, + output_compression: args.outputCompression, + output_format: args.outputFormat, + size: args.size, + }) + break + } + } + break + } + default: + toolWarnings.push({ type: "unsupported-tool", tool }) + break + } + } + + if (toolChoice == null) { + return { tools: openaiTools, toolChoice: undefined, toolWarnings } + } + + const type = toolChoice.type + + switch (type) { + case "auto": + case "none": + case "required": + return { tools: openaiTools, toolChoice: type, toolWarnings } + case "tool": + return { + tools: openaiTools, + toolChoice: + toolChoice.toolName === "code_interpreter" || + toolChoice.toolName === "file_search" || + toolChoice.toolName === "image_generation" || + toolChoice.toolName === "web_search_preview" || + toolChoice.toolName === "web_search" + ? { type: toolChoice.toolName } + : { type: "function", name: toolChoice.toolName }, + toolWarnings, + } + default: { + const _exhaustiveCheck: never = type + throw new UnsupportedFunctionalityError({ + functionality: `tool choice type: ${_exhaustiveCheck}`, + }) + } + } +} diff --git a/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-settings.ts b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-settings.ts new file mode 100644 index 000000000..76c97346f --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/openai-responses-settings.ts @@ -0,0 +1 @@ +export type OpenAIResponsesModelId = string diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/code-interpreter.ts b/packages/opencode/src/provider/sdk/copilot/responses/tool/code-interpreter.ts new file mode 100644 index 000000000..2bb4bce77 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/tool/code-interpreter.ts @@ -0,0 +1,88 @@ +import { createProviderDefinedToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils" +import { z } from "zod/v4" + +export const codeInterpreterInputSchema = z.object({ + code: z.string().nullish(), + containerId: z.string(), +}) + +export const codeInterpreterOutputSchema = z.object({ + outputs: z + .array( + z.discriminatedUnion("type", [ + z.object({ type: z.literal("logs"), logs: z.string() }), + z.object({ type: z.literal("image"), url: z.string() }), + ]), + ) + .nullish(), +}) + +export const codeInterpreterArgsSchema = z.object({ + container: z + .union([ + z.string(), + z.object({ + fileIds: z.array(z.string()).optional(), + }), + ]) + .optional(), +}) + +type CodeInterpreterArgs = { + /** + * The code interpreter container. + * Can be a container ID + * or an object that specifies uploaded file IDs to make available to your code. + */ + container?: string | { fileIds?: string[] } +} + +export const codeInterpreterToolFactory = createProviderDefinedToolFactoryWithOutputSchema< + { + /** + * The code to run, or null if not available. + */ + code?: string | null + + /** + * The ID of the container used to run the code. + */ + containerId: string + }, + { + /** + * The outputs generated by the code interpreter, such as logs or images. + * Can be null if no outputs are available. + */ + outputs?: Array< + | { + type: "logs" + + /** + * The logs output from the code interpreter. + */ + logs: string + } + | { + type: "image" + + /** + * The URL of the image output from the code interpreter. + */ + url: string + } + > | null + }, + CodeInterpreterArgs +>({ + id: "openai.code_interpreter", + name: "code_interpreter", + inputSchema: codeInterpreterInputSchema, + outputSchema: codeInterpreterOutputSchema, +}) + +export const codeInterpreter = ( + args: CodeInterpreterArgs = {}, // default +) => { + return codeInterpreterToolFactory(args) +} diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/file-search.ts b/packages/opencode/src/provider/sdk/copilot/responses/tool/file-search.ts new file mode 100644 index 000000000..1fccddaf6 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/tool/file-search.ts @@ -0,0 +1,128 @@ +import { createProviderDefinedToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils" +import type { + OpenAIResponsesFileSearchToolComparisonFilter, + OpenAIResponsesFileSearchToolCompoundFilter, +} from "../openai-responses-api-types" +import { z } from "zod/v4" + +const comparisonFilterSchema = z.object({ + key: z.string(), + type: z.enum(["eq", "ne", "gt", "gte", "lt", "lte"]), + value: z.union([z.string(), z.number(), z.boolean()]), +}) + +const compoundFilterSchema: z.ZodType = z.object({ + type: z.enum(["and", "or"]), + filters: z.array(z.union([comparisonFilterSchema, z.lazy(() => compoundFilterSchema)])), +}) + +export const fileSearchArgsSchema = z.object({ + vectorStoreIds: z.array(z.string()), + maxNumResults: z.number().optional(), + ranking: z + .object({ + ranker: z.string().optional(), + scoreThreshold: z.number().optional(), + }) + .optional(), + filters: z.union([comparisonFilterSchema, compoundFilterSchema]).optional(), +}) + +export const fileSearchOutputSchema = z.object({ + queries: z.array(z.string()), + results: z + .array( + z.object({ + attributes: z.record(z.string(), z.unknown()), + fileId: z.string(), + filename: z.string(), + score: z.number(), + text: z.string(), + }), + ) + .nullable(), +}) + +export const fileSearch = createProviderDefinedToolFactoryWithOutputSchema< + {}, + { + /** + * The search query to execute. + */ + queries: string[] + + /** + * The results of the file search tool call. + */ + results: + | null + | { + /** + * Set of 16 key-value pairs that can be attached to an object. + * This can be useful for storing additional information about the object + * in a structured format, and querying for objects via API or the dashboard. + * Keys are strings with a maximum length of 64 characters. + * Values are strings with a maximum length of 512 characters, booleans, or numbers. + */ + attributes: Record + + /** + * The unique ID of the file. + */ + fileId: string + + /** + * The name of the file. + */ + filename: string + + /** + * The relevance score of the file - a value between 0 and 1. + */ + score: number + + /** + * The text that was retrieved from the file. + */ + text: string + }[] + }, + { + /** + * List of vector store IDs to search through. + */ + vectorStoreIds: string[] + + /** + * Maximum number of search results to return. Defaults to 10. + */ + maxNumResults?: number + + /** + * Ranking options for the search. + */ + ranking?: { + /** + * The ranker to use for the file search. + */ + ranker?: string + + /** + * The score threshold for the file search, a number between 0 and 1. + * Numbers closer to 1 will attempt to return only the most relevant results, + * but may return fewer results. + */ + scoreThreshold?: number + } + + /** + * A filter to apply. + */ + filters?: OpenAIResponsesFileSearchToolComparisonFilter | OpenAIResponsesFileSearchToolCompoundFilter + } +>({ + id: "openai.file_search", + name: "file_search", + inputSchema: z.object({}), + outputSchema: fileSearchOutputSchema, +}) diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/image-generation.ts b/packages/opencode/src/provider/sdk/copilot/responses/tool/image-generation.ts new file mode 100644 index 000000000..7367a4802 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/tool/image-generation.ts @@ -0,0 +1,115 @@ +import { createProviderDefinedToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils" +import { z } from "zod/v4" + +export const imageGenerationArgsSchema = z + .object({ + background: z.enum(["auto", "opaque", "transparent"]).optional(), + inputFidelity: z.enum(["low", "high"]).optional(), + inputImageMask: z + .object({ + fileId: z.string().optional(), + imageUrl: z.string().optional(), + }) + .optional(), + model: z.string().optional(), + moderation: z.enum(["auto"]).optional(), + outputCompression: z.number().int().min(0).max(100).optional(), + outputFormat: z.enum(["png", "jpeg", "webp"]).optional(), + partialImages: z.number().int().min(0).max(3).optional(), + quality: z.enum(["auto", "low", "medium", "high"]).optional(), + size: z.enum(["1024x1024", "1024x1536", "1536x1024", "auto"]).optional(), + }) + .strict() + +export const imageGenerationOutputSchema = z.object({ + result: z.string(), +}) + +type ImageGenerationArgs = { + /** + * Background type for the generated image. Default is 'auto'. + */ + background?: "auto" | "opaque" | "transparent" + + /** + * Input fidelity for the generated image. Default is 'low'. + */ + inputFidelity?: "low" | "high" + + /** + * Optional mask for inpainting. + * Contains image_url (string, optional) and file_id (string, optional). + */ + inputImageMask?: { + /** + * File ID for the mask image. + */ + fileId?: string + + /** + * Base64-encoded mask image. + */ + imageUrl?: string + } + + /** + * The image generation model to use. Default: gpt-image-1. + */ + model?: string + + /** + * Moderation level for the generated image. Default: auto. + */ + moderation?: "auto" + + /** + * Compression level for the output image. Default: 100. + */ + outputCompression?: number + + /** + * The output format of the generated image. One of png, webp, or jpeg. + * Default: png + */ + outputFormat?: "png" | "jpeg" | "webp" + + /** + * Number of partial images to generate in streaming mode, from 0 (default value) to 3. + */ + partialImages?: number + + /** + * The quality of the generated image. + * One of low, medium, high, or auto. Default: auto. + */ + quality?: "auto" | "low" | "medium" | "high" + + /** + * The size of the generated image. + * One of 1024x1024, 1024x1536, 1536x1024, or auto. + * Default: auto. + */ + size?: "auto" | "1024x1024" | "1024x1536" | "1536x1024" +} + +const imageGenerationToolFactory = createProviderDefinedToolFactoryWithOutputSchema< + {}, + { + /** + * The generated image encoded in base64. + */ + result: string + }, + ImageGenerationArgs +>({ + id: "openai.image_generation", + name: "image_generation", + inputSchema: z.object({}), + outputSchema: imageGenerationOutputSchema, +}) + +export const imageGeneration = ( + args: ImageGenerationArgs = {}, // default +) => { + return imageGenerationToolFactory(args) +} diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/local-shell.ts b/packages/opencode/src/provider/sdk/copilot/responses/tool/local-shell.ts new file mode 100644 index 000000000..4ceca0d6c --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/tool/local-shell.ts @@ -0,0 +1,65 @@ +import { createProviderDefinedToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils" +import { z } from "zod/v4" + +export const localShellInputSchema = z.object({ + action: z.object({ + type: z.literal("exec"), + command: z.array(z.string()), + timeoutMs: z.number().optional(), + user: z.string().optional(), + workingDirectory: z.string().optional(), + env: z.record(z.string(), z.string()).optional(), + }), +}) + +export const localShellOutputSchema = z.object({ + output: z.string(), +}) + +export const localShell = createProviderDefinedToolFactoryWithOutputSchema< + { + /** + * Execute a shell command on the server. + */ + action: { + type: "exec" + + /** + * The command to run. + */ + command: string[] + + /** + * Optional timeout in milliseconds for the command. + */ + timeoutMs?: number + + /** + * Optional user to run the command as. + */ + user?: string + + /** + * Optional working directory to run the command in. + */ + workingDirectory?: string + + /** + * Environment variables to set for the command. + */ + env?: Record + } + }, + { + /** + * The output of local shell tool call. + */ + output: string + }, + {} +>({ + id: "openai.local_shell", + name: "local_shell", + inputSchema: localShellInputSchema, + outputSchema: localShellOutputSchema, +}) diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/web-search-preview.ts b/packages/opencode/src/provider/sdk/copilot/responses/tool/web-search-preview.ts new file mode 100644 index 000000000..69ea65ef0 --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/tool/web-search-preview.ts @@ -0,0 +1,104 @@ +import { createProviderDefinedToolFactory } from "@ai-sdk/provider-utils" +import { z } from "zod/v4" + +// Args validation schema +export const webSearchPreviewArgsSchema = z.object({ + /** + * Search context size to use for the web search. + * - high: Most comprehensive context, highest cost, slower response + * - medium: Balanced context, cost, and latency (default) + * - low: Least context, lowest cost, fastest response + */ + searchContextSize: z.enum(["low", "medium", "high"]).optional(), + + /** + * User location information to provide geographically relevant search results. + */ + userLocation: z + .object({ + /** + * Type of location (always 'approximate') + */ + type: z.literal("approximate"), + /** + * Two-letter ISO country code (e.g., 'US', 'GB') + */ + country: z.string().optional(), + /** + * City name (free text, e.g., 'Minneapolis') + */ + city: z.string().optional(), + /** + * Region name (free text, e.g., 'Minnesota') + */ + region: z.string().optional(), + /** + * IANA timezone (e.g., 'America/Chicago') + */ + timezone: z.string().optional(), + }) + .optional(), +}) + +export const webSearchPreview = createProviderDefinedToolFactory< + { + // Web search doesn't take input parameters - it's controlled by the prompt + }, + { + /** + * Search context size to use for the web search. + * - high: Most comprehensive context, highest cost, slower response + * - medium: Balanced context, cost, and latency (default) + * - low: Least context, lowest cost, fastest response + */ + searchContextSize?: "low" | "medium" | "high" + + /** + * User location information to provide geographically relevant search results. + */ + userLocation?: { + /** + * Type of location (always 'approximate') + */ + type: "approximate" + /** + * Two-letter ISO country code (e.g., 'US', 'GB') + */ + country?: string + /** + * City name (free text, e.g., 'Minneapolis') + */ + city?: string + /** + * Region name (free text, e.g., 'Minnesota') + */ + region?: string + /** + * IANA timezone (e.g., 'America/Chicago') + */ + timezone?: string + } + } +>({ + id: "openai.web_search_preview", + name: "web_search_preview", + inputSchema: z.object({ + action: z + .discriminatedUnion("type", [ + z.object({ + type: z.literal("search"), + query: z.string().nullish(), + }), + z.object({ + type: z.literal("open_page"), + url: z.string(), + }), + z.object({ + type: z.literal("find"), + url: z.string(), + pattern: z.string(), + }), + ]) + .nullish(), + }), +}) diff --git a/packages/opencode/src/provider/sdk/copilot/responses/tool/web-search.ts b/packages/opencode/src/provider/sdk/copilot/responses/tool/web-search.ts new file mode 100644 index 000000000..89622ad3c --- /dev/null +++ b/packages/opencode/src/provider/sdk/copilot/responses/tool/web-search.ts @@ -0,0 +1,103 @@ +import { createProviderDefinedToolFactory } from "@ai-sdk/provider-utils" +import { z } from "zod/v4" + +export const webSearchArgsSchema = z.object({ + filters: z + .object({ + allowedDomains: z.array(z.string()).optional(), + }) + .optional(), + + searchContextSize: z.enum(["low", "medium", "high"]).optional(), + + userLocation: z + .object({ + type: z.literal("approximate"), + country: z.string().optional(), + city: z.string().optional(), + region: z.string().optional(), + timezone: z.string().optional(), + }) + .optional(), +}) + +export const webSearchToolFactory = createProviderDefinedToolFactory< + { + // Web search doesn't take input parameters - it's controlled by the prompt + }, + { + /** + * Filters for the search. + */ + filters?: { + /** + * Allowed domains for the search. + * If not provided, all domains are allowed. + * Subdomains of the provided domains are allowed as well. + */ + allowedDomains?: string[] + } + + /** + * Search context size to use for the web search. + * - high: Most comprehensive context, highest cost, slower response + * - medium: Balanced context, cost, and latency (default) + * - low: Least context, lowest cost, fastest response + */ + searchContextSize?: "low" | "medium" | "high" + + /** + * User location information to provide geographically relevant search results. + */ + userLocation?: { + /** + * Type of location (always 'approximate') + */ + type: "approximate" + /** + * Two-letter ISO country code (e.g., 'US', 'GB') + */ + country?: string + /** + * City name (free text, e.g., 'Minneapolis') + */ + city?: string + /** + * Region name (free text, e.g., 'Minnesota') + */ + region?: string + /** + * IANA timezone (e.g., 'America/Chicago') + */ + timezone?: string + } + } +>({ + id: "openai.web_search", + name: "web_search", + inputSchema: z.object({ + action: z + .discriminatedUnion("type", [ + z.object({ + type: z.literal("search"), + query: z.string().nullish(), + }), + z.object({ + type: z.literal("open_page"), + url: z.string(), + }), + z.object({ + type: z.literal("find"), + url: z.string(), + pattern: z.string(), + }), + ]) + .nullish(), + }), +}) + +export const webSearch = ( + args: Parameters[0] = {}, // default +) => { + return webSearchToolFactory(args) +} diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/README.md b/packages/opencode/src/provider/sdk/openai-compatible/src/README.md deleted file mode 100644 index 8ce03d614..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/README.md +++ /dev/null @@ -1,5 +0,0 @@ -This is a temporary package used primarily for GitHub Copilot compatibility. - -Avoid making changes to these files unless you only want to affect the Copilot provider. - -Also, this should ONLY be used for the Copilot provider. diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/index.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/index.ts deleted file mode 100644 index a3435c53e..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { createOpenaiCompatible, openaiCompatible } from "./openai-compatible-provider" -export type { OpenaiCompatibleProvider, OpenaiCompatibleProviderSettings } from "./openai-compatible-provider" diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/openai-compatible-provider.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/openai-compatible-provider.ts deleted file mode 100644 index e71658c2f..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/openai-compatible-provider.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { LanguageModelV2 } from "@ai-sdk/provider" -import { OpenAICompatibleChatLanguageModel } from "@ai-sdk/openai-compatible" -import { type FetchFunction, withoutTrailingSlash, withUserAgentSuffix } from "@ai-sdk/provider-utils" -import { OpenAIResponsesLanguageModel } from "./responses/openai-responses-language-model" - -// Import the version or define it -const VERSION = "0.1.0" - -export type OpenaiCompatibleModelId = string - -export interface OpenaiCompatibleProviderSettings { - /** - * API key for authenticating requests. - */ - apiKey?: string - - /** - * Base URL for the OpenAI Compatible API calls. - */ - baseURL?: string - - /** - * Name of the provider. - */ - name?: string - - /** - * Custom headers to include in the requests. - */ - headers?: Record - - /** - * Custom fetch implementation. - */ - fetch?: FetchFunction -} - -export interface OpenaiCompatibleProvider { - (modelId: OpenaiCompatibleModelId): LanguageModelV2 - chat(modelId: OpenaiCompatibleModelId): LanguageModelV2 - responses(modelId: OpenaiCompatibleModelId): LanguageModelV2 - languageModel(modelId: OpenaiCompatibleModelId): LanguageModelV2 - - // embeddingModel(modelId: any): EmbeddingModelV2 - - // imageModel(modelId: any): ImageModelV2 -} - -/** - * Create an OpenAI Compatible provider instance. - */ -export function createOpenaiCompatible(options: OpenaiCompatibleProviderSettings = {}): OpenaiCompatibleProvider { - const baseURL = withoutTrailingSlash(options.baseURL ?? "https://api.openai.com/v1") - - if (!baseURL) { - throw new Error("baseURL is required") - } - - // Merge headers: defaults first, then user overrides - const headers = { - // Default OpenAI Compatible headers (can be overridden by user) - ...(options.apiKey && { Authorization: `Bearer ${options.apiKey}` }), - ...options.headers, - } - - const getHeaders = () => withUserAgentSuffix(headers, `ai-sdk/openai-compatible/${VERSION}`) - - const createChatModel = (modelId: OpenaiCompatibleModelId) => { - return new OpenAICompatibleChatLanguageModel(modelId, { - provider: `${options.name ?? "openai-compatible"}.chat`, - headers: getHeaders, - url: ({ path }) => `${baseURL}${path}`, - fetch: options.fetch, - }) - } - - const createResponsesModel = (modelId: OpenaiCompatibleModelId) => { - return new OpenAIResponsesLanguageModel(modelId, { - provider: `${options.name ?? "openai-compatible"}.responses`, - headers: getHeaders, - url: ({ path }) => `${baseURL}${path}`, - fetch: options.fetch, - }) - } - - const createLanguageModel = (modelId: OpenaiCompatibleModelId) => createChatModel(modelId) - - const provider = function (modelId: OpenaiCompatibleModelId) { - return createChatModel(modelId) - } - - provider.languageModel = createLanguageModel - provider.chat = createChatModel - provider.responses = createResponsesModel - - return provider as OpenaiCompatibleProvider -} - -// Default OpenAI Compatible provider instance -export const openaiCompatible = createOpenaiCompatible() diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/convert-to-openai-responses-input.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/convert-to-openai-responses-input.ts deleted file mode 100644 index b53da1121..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/convert-to-openai-responses-input.ts +++ /dev/null @@ -1,303 +0,0 @@ -import { - type LanguageModelV2CallWarning, - type LanguageModelV2Prompt, - type LanguageModelV2ToolCallPart, - UnsupportedFunctionalityError, -} from "@ai-sdk/provider" -import { convertToBase64, parseProviderOptions } from "@ai-sdk/provider-utils" -import { z } from "zod/v4" -import type { OpenAIResponsesInput, OpenAIResponsesReasoning } from "./openai-responses-api-types" -import { localShellInputSchema, localShellOutputSchema } from "./tool/local-shell" - -/** - * Check if a string is a file ID based on the given prefixes - * Returns false if prefixes is undefined (disables file ID detection) - */ -function isFileId(data: string, prefixes?: readonly string[]): boolean { - if (!prefixes) return false - return prefixes.some((prefix) => data.startsWith(prefix)) -} - -export async function convertToOpenAIResponsesInput({ - prompt, - systemMessageMode, - fileIdPrefixes, - store, - hasLocalShellTool = false, -}: { - prompt: LanguageModelV2Prompt - systemMessageMode: "system" | "developer" | "remove" - fileIdPrefixes?: readonly string[] - store: boolean - hasLocalShellTool?: boolean -}): Promise<{ - input: OpenAIResponsesInput - warnings: Array -}> { - const input: OpenAIResponsesInput = [] - const warnings: Array = [] - - for (const { role, content } of prompt) { - switch (role) { - case "system": { - switch (systemMessageMode) { - case "system": { - input.push({ role: "system", content }) - break - } - case "developer": { - input.push({ role: "developer", content }) - break - } - case "remove": { - warnings.push({ - type: "other", - message: "system messages are removed for this model", - }) - break - } - default: { - const _exhaustiveCheck: never = systemMessageMode - throw new Error(`Unsupported system message mode: ${_exhaustiveCheck}`) - } - } - break - } - - case "user": { - input.push({ - role: "user", - content: content.map((part, index) => { - switch (part.type) { - case "text": { - return { type: "input_text", text: part.text } - } - case "file": { - if (part.mediaType.startsWith("image/")) { - const mediaType = part.mediaType === "image/*" ? "image/jpeg" : part.mediaType - - return { - type: "input_image", - ...(part.data instanceof URL - ? { image_url: part.data.toString() } - : typeof part.data === "string" && isFileId(part.data, fileIdPrefixes) - ? { file_id: part.data } - : { - image_url: `data:${mediaType};base64,${convertToBase64(part.data)}`, - }), - detail: part.providerOptions?.openai?.imageDetail, - } - } else if (part.mediaType === "application/pdf") { - if (part.data instanceof URL) { - return { - type: "input_file", - file_url: part.data.toString(), - } - } - return { - type: "input_file", - ...(typeof part.data === "string" && isFileId(part.data, fileIdPrefixes) - ? { file_id: part.data } - : { - filename: part.filename ?? `part-${index}.pdf`, - file_data: `data:application/pdf;base64,${convertToBase64(part.data)}`, - }), - } - } else { - throw new UnsupportedFunctionalityError({ - functionality: `file part media type ${part.mediaType}`, - }) - } - } - } - }), - }) - - break - } - - case "assistant": { - const reasoningMessages: Record = {} - const toolCallParts: Record = {} - - for (const part of content) { - switch (part.type) { - case "text": { - input.push({ - role: "assistant", - content: [{ type: "output_text", text: part.text }], - id: (part.providerOptions?.openai?.itemId as string) ?? undefined, - }) - break - } - case "tool-call": { - toolCallParts[part.toolCallId] = part - - if (part.providerExecuted) { - break - } - - if (hasLocalShellTool && part.toolName === "local_shell") { - const parsedInput = localShellInputSchema.parse(part.input) - input.push({ - type: "local_shell_call", - call_id: part.toolCallId, - id: (part.providerOptions?.openai?.itemId as string) ?? undefined, - action: { - type: "exec", - command: parsedInput.action.command, - timeout_ms: parsedInput.action.timeoutMs, - user: parsedInput.action.user, - working_directory: parsedInput.action.workingDirectory, - env: parsedInput.action.env, - }, - }) - - break - } - - input.push({ - type: "function_call", - call_id: part.toolCallId, - name: part.toolName, - arguments: JSON.stringify(part.input), - id: (part.providerOptions?.openai?.itemId as string) ?? undefined, - }) - break - } - - // assistant tool result parts are from provider-executed tools: - case "tool-result": { - if (store) { - // use item references to refer to tool results from built-in tools - input.push({ type: "item_reference", id: part.toolCallId }) - } else { - warnings.push({ - type: "other", - message: `Results for OpenAI tool ${part.toolName} are not sent to the API when store is false`, - }) - } - - break - } - - case "reasoning": { - const providerOptions = await parseProviderOptions({ - provider: "openai", - providerOptions: part.providerOptions, - schema: openaiResponsesReasoningProviderOptionsSchema, - }) - - const reasoningId = providerOptions?.itemId - - if (reasoningId != null) { - const reasoningMessage = reasoningMessages[reasoningId] - - if (store) { - if (reasoningMessage === undefined) { - // use item references to refer to reasoning (single reference) - input.push({ type: "item_reference", id: reasoningId }) - - // store unused reasoning message to mark id as used - reasoningMessages[reasoningId] = { - type: "reasoning", - id: reasoningId, - summary: [], - } - } - } else { - const summaryParts: Array<{ - type: "summary_text" - text: string - }> = [] - - if (part.text.length > 0) { - summaryParts.push({ - type: "summary_text", - text: part.text, - }) - } else if (reasoningMessage !== undefined) { - warnings.push({ - type: "other", - message: `Cannot append empty reasoning part to existing reasoning sequence. Skipping reasoning part: ${JSON.stringify(part)}.`, - }) - } - - if (reasoningMessage === undefined) { - reasoningMessages[reasoningId] = { - type: "reasoning", - id: reasoningId, - encrypted_content: providerOptions?.reasoningEncryptedContent, - summary: summaryParts, - } - input.push(reasoningMessages[reasoningId]) - } else { - reasoningMessage.summary.push(...summaryParts) - } - } - } else { - warnings.push({ - type: "other", - message: `Non-OpenAI reasoning parts are not supported. Skipping reasoning part: ${JSON.stringify(part)}.`, - }) - } - break - } - } - } - - break - } - - case "tool": { - for (const part of content) { - const output = part.output - - if (hasLocalShellTool && part.toolName === "local_shell" && output.type === "json") { - input.push({ - type: "local_shell_call_output", - call_id: part.toolCallId, - output: localShellOutputSchema.parse(output.value).output, - }) - break - } - - let contentValue: string - switch (output.type) { - case "text": - case "error-text": - contentValue = output.value - break - case "content": - case "json": - case "error-json": - contentValue = JSON.stringify(output.value) - break - } - - input.push({ - type: "function_call_output", - call_id: part.toolCallId, - output: contentValue, - }) - } - - break - } - - default: { - const _exhaustiveCheck: never = role - throw new Error(`Unsupported role: ${_exhaustiveCheck}`) - } - } - } - - return { input, warnings } -} - -const openaiResponsesReasoningProviderOptionsSchema = z.object({ - itemId: z.string().nullish(), - reasoningEncryptedContent: z.string().nullish(), -}) - -export type OpenAIResponsesReasoningProviderOptions = z.infer diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/map-openai-responses-finish-reason.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/map-openai-responses-finish-reason.ts deleted file mode 100644 index 54bb9056d..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/map-openai-responses-finish-reason.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { LanguageModelV2FinishReason } from "@ai-sdk/provider" - -export function mapOpenAIResponseFinishReason({ - finishReason, - hasFunctionCall, -}: { - finishReason: string | null | undefined - // flag that checks if there have been client-side tool calls (not executed by openai) - hasFunctionCall: boolean -}): LanguageModelV2FinishReason { - switch (finishReason) { - case undefined: - case null: - return hasFunctionCall ? "tool-calls" : "stop" - case "max_output_tokens": - return "length" - case "content_filter": - return "content-filter" - default: - return hasFunctionCall ? "tool-calls" : "unknown" - } -} diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-config.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-config.ts deleted file mode 100644 index 2241dbb52..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { FetchFunction } from "@ai-sdk/provider-utils" - -export type OpenAIConfig = { - provider: string - url: (options: { modelId: string; path: string }) => string - headers: () => Record - fetch?: FetchFunction - generateId?: () => string - /** - * File ID prefixes used to identify file IDs in Responses API. - * When undefined, all file data is treated as base64 content. - * - * Examples: - * - OpenAI: ['file-'] for IDs like 'file-abc123' - * - Azure OpenAI: ['assistant-'] for IDs like 'assistant-abc123' - */ - fileIdPrefixes?: readonly string[] -} diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-error.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-error.ts deleted file mode 100644 index e78824d36..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-error.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { z } from "zod/v4" -import { createJsonErrorResponseHandler } from "@ai-sdk/provider-utils" - -export const openaiErrorDataSchema = z.object({ - error: z.object({ - message: z.string(), - - // The additional information below is handled loosely to support - // OpenAI-compatible providers that have slightly different error - // responses: - type: z.string().nullish(), - param: z.any().nullish(), - code: z.union([z.string(), z.number()]).nullish(), - }), -}) - -export type OpenAIErrorData = z.infer - -export const openaiFailedResponseHandler: any = createJsonErrorResponseHandler({ - errorSchema: openaiErrorDataSchema, - errorToMessage: (data) => data.error.message, -}) diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-api-types.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-api-types.ts deleted file mode 100644 index cf1a3ba2f..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-api-types.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { JSONSchema7 } from "@ai-sdk/provider" - -export type OpenAIResponsesInput = Array - -export type OpenAIResponsesInputItem = - | OpenAIResponsesSystemMessage - | OpenAIResponsesUserMessage - | OpenAIResponsesAssistantMessage - | OpenAIResponsesFunctionCall - | OpenAIResponsesFunctionCallOutput - | OpenAIResponsesComputerCall - | OpenAIResponsesLocalShellCall - | OpenAIResponsesLocalShellCallOutput - | OpenAIResponsesReasoning - | OpenAIResponsesItemReference - -export type OpenAIResponsesIncludeValue = - | "web_search_call.action.sources" - | "code_interpreter_call.outputs" - | "computer_call_output.output.image_url" - | "file_search_call.results" - | "message.input_image.image_url" - | "message.output_text.logprobs" - | "reasoning.encrypted_content" - -export type OpenAIResponsesIncludeOptions = Array | undefined | null - -export type OpenAIResponsesSystemMessage = { - role: "system" | "developer" - content: string -} - -export type OpenAIResponsesUserMessage = { - role: "user" - content: Array< - | { type: "input_text"; text: string } - | { type: "input_image"; image_url: string } - | { type: "input_image"; file_id: string } - | { type: "input_file"; file_url: string } - | { type: "input_file"; filename: string; file_data: string } - | { type: "input_file"; file_id: string } - > -} - -export type OpenAIResponsesAssistantMessage = { - role: "assistant" - content: Array<{ type: "output_text"; text: string }> - id?: string -} - -export type OpenAIResponsesFunctionCall = { - type: "function_call" - call_id: string - name: string - arguments: string - id?: string -} - -export type OpenAIResponsesFunctionCallOutput = { - type: "function_call_output" - call_id: string - output: string -} - -export type OpenAIResponsesComputerCall = { - type: "computer_call" - id: string - status?: string -} - -export type OpenAIResponsesLocalShellCall = { - type: "local_shell_call" - id: string - call_id: string - action: { - type: "exec" - command: string[] - timeout_ms?: number - user?: string - working_directory?: string - env?: Record - } -} - -export type OpenAIResponsesLocalShellCallOutput = { - type: "local_shell_call_output" - call_id: string - output: string -} - -export type OpenAIResponsesItemReference = { - type: "item_reference" - id: string -} - -/** - * A filter used to compare a specified attribute key to a given value using a defined comparison operation. - */ -export type OpenAIResponsesFileSearchToolComparisonFilter = { - /** - * The key to compare against the value. - */ - key: string - - /** - * Specifies the comparison operator: eq, ne, gt, gte, lt, lte. - */ - type: "eq" | "ne" | "gt" | "gte" | "lt" | "lte" - - /** - * The value to compare against the attribute key; supports string, number, or boolean types. - */ - value: string | number | boolean -} - -/** - * Combine multiple filters using and or or. - */ -export type OpenAIResponsesFileSearchToolCompoundFilter = { - /** - * Type of operation: and or or. - */ - type: "and" | "or" - - /** - * Array of filters to combine. Items can be ComparisonFilter or CompoundFilter. - */ - filters: Array -} - -export type OpenAIResponsesTool = - | { - type: "function" - name: string - description: string | undefined - parameters: JSONSchema7 - strict: boolean | undefined - } - | { - type: "web_search" - filters: { allowed_domains: string[] | undefined } | undefined - search_context_size: "low" | "medium" | "high" | undefined - user_location: - | { - type: "approximate" - city?: string - country?: string - region?: string - timezone?: string - } - | undefined - } - | { - type: "web_search_preview" - search_context_size: "low" | "medium" | "high" | undefined - user_location: - | { - type: "approximate" - city?: string - country?: string - region?: string - timezone?: string - } - | undefined - } - | { - type: "code_interpreter" - container: string | { type: "auto"; file_ids: string[] | undefined } - } - | { - type: "file_search" - vector_store_ids: string[] - max_num_results: number | undefined - ranking_options: { ranker?: string; score_threshold?: number } | undefined - filters: OpenAIResponsesFileSearchToolComparisonFilter | OpenAIResponsesFileSearchToolCompoundFilter | undefined - } - | { - type: "image_generation" - background: "auto" | "opaque" | "transparent" | undefined - input_fidelity: "low" | "high" | undefined - input_image_mask: - | { - file_id: string | undefined - image_url: string | undefined - } - | undefined - model: string | undefined - moderation: "auto" | undefined - output_compression: number | undefined - output_format: "png" | "jpeg" | "webp" | undefined - partial_images: number | undefined - quality: "auto" | "low" | "medium" | "high" | undefined - size: "auto" | "1024x1024" | "1024x1536" | "1536x1024" | undefined - } - | { - type: "local_shell" - } - -export type OpenAIResponsesReasoning = { - type: "reasoning" - id: string - encrypted_content?: string | null - summary: Array<{ - type: "summary_text" - text: string - }> -} diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts deleted file mode 100644 index 0990b7e00..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-language-model.ts +++ /dev/null @@ -1,1732 +0,0 @@ -import { - APICallError, - type LanguageModelV2, - type LanguageModelV2CallWarning, - type LanguageModelV2Content, - type LanguageModelV2FinishReason, - type LanguageModelV2ProviderDefinedTool, - type LanguageModelV2StreamPart, - type LanguageModelV2Usage, - type SharedV2ProviderMetadata, -} from "@ai-sdk/provider" -import { - combineHeaders, - createEventSourceResponseHandler, - createJsonResponseHandler, - generateId, - parseProviderOptions, - type ParseResult, - postJsonToApi, -} from "@ai-sdk/provider-utils" -import { z } from "zod/v4" -import type { OpenAIConfig } from "./openai-config" -import { openaiFailedResponseHandler } from "./openai-error" -import { codeInterpreterInputSchema, codeInterpreterOutputSchema } from "./tool/code-interpreter" -import { fileSearchOutputSchema } from "./tool/file-search" -import { imageGenerationOutputSchema } from "./tool/image-generation" -import { convertToOpenAIResponsesInput } from "./convert-to-openai-responses-input" -import { mapOpenAIResponseFinishReason } from "./map-openai-responses-finish-reason" -import type { OpenAIResponsesIncludeOptions, OpenAIResponsesIncludeValue } from "./openai-responses-api-types" -import { prepareResponsesTools } from "./openai-responses-prepare-tools" -import type { OpenAIResponsesModelId } from "./openai-responses-settings" -import { localShellInputSchema } from "./tool/local-shell" - -const webSearchCallItem = z.object({ - type: z.literal("web_search_call"), - id: z.string(), - status: z.string(), - action: z - .discriminatedUnion("type", [ - z.object({ - type: z.literal("search"), - query: z.string().nullish(), - }), - z.object({ - type: z.literal("open_page"), - url: z.string(), - }), - z.object({ - type: z.literal("find"), - url: z.string(), - pattern: z.string(), - }), - ]) - .nullish(), -}) - -const fileSearchCallItem = z.object({ - type: z.literal("file_search_call"), - id: z.string(), - queries: z.array(z.string()), - results: z - .array( - z.object({ - attributes: z.record(z.string(), z.unknown()), - file_id: z.string(), - filename: z.string(), - score: z.number(), - text: z.string(), - }), - ) - .nullish(), -}) - -const codeInterpreterCallItem = z.object({ - type: z.literal("code_interpreter_call"), - id: z.string(), - code: z.string().nullable(), - container_id: z.string(), - outputs: z - .array( - z.discriminatedUnion("type", [ - z.object({ type: z.literal("logs"), logs: z.string() }), - z.object({ type: z.literal("image"), url: z.string() }), - ]), - ) - .nullable(), -}) - -const localShellCallItem = z.object({ - type: z.literal("local_shell_call"), - id: z.string(), - call_id: z.string(), - action: z.object({ - type: z.literal("exec"), - command: z.array(z.string()), - timeout_ms: z.number().optional(), - user: z.string().optional(), - working_directory: z.string().optional(), - env: z.record(z.string(), z.string()).optional(), - }), -}) - -const imageGenerationCallItem = z.object({ - type: z.literal("image_generation_call"), - id: z.string(), - result: z.string(), -}) - -/** - * `top_logprobs` request body argument can be set to an integer between - * 0 and 20 specifying the number of most likely tokens to return at each - * token position, each with an associated log probability. - * - * @see https://platform.openai.com/docs/api-reference/responses/create#responses_create-top_logprobs - */ -const TOP_LOGPROBS_MAX = 20 - -const LOGPROBS_SCHEMA = z.array( - z.object({ - token: z.string(), - logprob: z.number(), - top_logprobs: z.array( - z.object({ - token: z.string(), - logprob: z.number(), - }), - ), - }), -) - -export class OpenAIResponsesLanguageModel implements LanguageModelV2 { - readonly specificationVersion = "v2" - - readonly modelId: OpenAIResponsesModelId - - private readonly config: OpenAIConfig - - constructor(modelId: OpenAIResponsesModelId, config: OpenAIConfig) { - this.modelId = modelId - this.config = config - } - - readonly supportedUrls: Record = { - "image/*": [/^https?:\/\/.*$/], - "application/pdf": [/^https?:\/\/.*$/], - } - - get provider(): string { - return this.config.provider - } - - private async getArgs({ - maxOutputTokens, - temperature, - stopSequences, - topP, - topK, - presencePenalty, - frequencyPenalty, - seed, - prompt, - providerOptions, - tools, - toolChoice, - responseFormat, - }: Parameters[0]) { - const warnings: LanguageModelV2CallWarning[] = [] - const modelConfig = getResponsesModelConfig(this.modelId) - - if (topK != null) { - warnings.push({ type: "unsupported-setting", setting: "topK" }) - } - - if (seed != null) { - warnings.push({ type: "unsupported-setting", setting: "seed" }) - } - - if (presencePenalty != null) { - warnings.push({ - type: "unsupported-setting", - setting: "presencePenalty", - }) - } - - if (frequencyPenalty != null) { - warnings.push({ - type: "unsupported-setting", - setting: "frequencyPenalty", - }) - } - - if (stopSequences != null) { - warnings.push({ type: "unsupported-setting", setting: "stopSequences" }) - } - - const openaiOptions = await parseProviderOptions({ - provider: "openai", - providerOptions, - schema: openaiResponsesProviderOptionsSchema, - }) - - const { input, warnings: inputWarnings } = await convertToOpenAIResponsesInput({ - prompt, - systemMessageMode: modelConfig.systemMessageMode, - fileIdPrefixes: this.config.fileIdPrefixes, - store: openaiOptions?.store ?? true, - hasLocalShellTool: hasOpenAITool("openai.local_shell"), - }) - - warnings.push(...inputWarnings) - - const strictJsonSchema = openaiOptions?.strictJsonSchema ?? false - - let include: OpenAIResponsesIncludeOptions = openaiOptions?.include - - function addInclude(key: OpenAIResponsesIncludeValue) { - include = include != null ? [...include, key] : [key] - } - - function hasOpenAITool(id: string) { - return tools?.find((tool) => tool.type === "provider-defined" && tool.id === id) != null - } - - // when logprobs are requested, automatically include them: - const topLogprobs = - typeof openaiOptions?.logprobs === "number" - ? openaiOptions?.logprobs - : openaiOptions?.logprobs === true - ? TOP_LOGPROBS_MAX - : undefined - - if (topLogprobs) { - addInclude("message.output_text.logprobs") - } - - // when a web search tool is present, automatically include the sources: - const webSearchToolName = ( - tools?.find( - (tool) => - tool.type === "provider-defined" && - (tool.id === "openai.web_search" || tool.id === "openai.web_search_preview"), - ) as LanguageModelV2ProviderDefinedTool | undefined - )?.name - - if (webSearchToolName) { - addInclude("web_search_call.action.sources") - } - - // when a code interpreter tool is present, automatically include the outputs: - if (hasOpenAITool("openai.code_interpreter")) { - addInclude("code_interpreter_call.outputs") - } - - const baseArgs = { - model: this.modelId, - input, - temperature, - top_p: topP, - max_output_tokens: maxOutputTokens, - - ...((responseFormat?.type === "json" || openaiOptions?.textVerbosity) && { - text: { - ...(responseFormat?.type === "json" && { - format: - responseFormat.schema != null - ? { - type: "json_schema", - strict: strictJsonSchema, - name: responseFormat.name ?? "response", - description: responseFormat.description, - schema: responseFormat.schema, - } - : { type: "json_object" }, - }), - ...(openaiOptions?.textVerbosity && { - verbosity: openaiOptions.textVerbosity, - }), - }, - }), - - // provider options: - max_tool_calls: openaiOptions?.maxToolCalls, - metadata: openaiOptions?.metadata, - parallel_tool_calls: openaiOptions?.parallelToolCalls, - previous_response_id: openaiOptions?.previousResponseId, - store: openaiOptions?.store, - user: openaiOptions?.user, - instructions: openaiOptions?.instructions, - service_tier: openaiOptions?.serviceTier, - include, - prompt_cache_key: openaiOptions?.promptCacheKey, - safety_identifier: openaiOptions?.safetyIdentifier, - top_logprobs: topLogprobs, - - // model-specific settings: - ...(modelConfig.isReasoningModel && - (openaiOptions?.reasoningEffort != null || openaiOptions?.reasoningSummary != null) && { - reasoning: { - ...(openaiOptions?.reasoningEffort != null && { - effort: openaiOptions.reasoningEffort, - }), - ...(openaiOptions?.reasoningSummary != null && { - summary: openaiOptions.reasoningSummary, - }), - }, - }), - ...(modelConfig.requiredAutoTruncation && { - truncation: "auto", - }), - } - - if (modelConfig.isReasoningModel) { - // remove unsupported settings for reasoning models - // see https://platform.openai.com/docs/guides/reasoning#limitations - if (baseArgs.temperature != null) { - baseArgs.temperature = undefined - warnings.push({ - type: "unsupported-setting", - setting: "temperature", - details: "temperature is not supported for reasoning models", - }) - } - - if (baseArgs.top_p != null) { - baseArgs.top_p = undefined - warnings.push({ - type: "unsupported-setting", - setting: "topP", - details: "topP is not supported for reasoning models", - }) - } - } else { - if (openaiOptions?.reasoningEffort != null) { - warnings.push({ - type: "unsupported-setting", - setting: "reasoningEffort", - details: "reasoningEffort is not supported for non-reasoning models", - }) - } - - if (openaiOptions?.reasoningSummary != null) { - warnings.push({ - type: "unsupported-setting", - setting: "reasoningSummary", - details: "reasoningSummary is not supported for non-reasoning models", - }) - } - } - - // Validate flex processing support - if (openaiOptions?.serviceTier === "flex" && !modelConfig.supportsFlexProcessing) { - warnings.push({ - type: "unsupported-setting", - setting: "serviceTier", - details: "flex processing is only available for o3, o4-mini, and gpt-5 models", - }) - // Remove from args if not supported - delete (baseArgs as any).service_tier - } - - // Validate priority processing support - if (openaiOptions?.serviceTier === "priority" && !modelConfig.supportsPriorityProcessing) { - warnings.push({ - type: "unsupported-setting", - setting: "serviceTier", - details: - "priority processing is only available for supported models (gpt-4, gpt-5, gpt-5-mini, o3, o4-mini) and requires Enterprise access. gpt-5-nano is not supported", - }) - // Remove from args if not supported - delete (baseArgs as any).service_tier - } - - const { - tools: openaiTools, - toolChoice: openaiToolChoice, - toolWarnings, - } = prepareResponsesTools({ - tools, - toolChoice, - strictJsonSchema, - }) - - return { - webSearchToolName, - args: { - ...baseArgs, - tools: openaiTools, - tool_choice: openaiToolChoice, - }, - warnings: [...warnings, ...toolWarnings], - } - } - - async doGenerate( - options: Parameters[0], - ): Promise>> { - const { args: body, warnings, webSearchToolName } = await this.getArgs(options) - const url = this.config.url({ - path: "/responses", - modelId: this.modelId, - }) - - const { - responseHeaders, - value: response, - rawValue: rawResponse, - } = await postJsonToApi({ - url, - headers: combineHeaders(this.config.headers(), options.headers), - body, - failedResponseHandler: openaiFailedResponseHandler, - successfulResponseHandler: createJsonResponseHandler( - z.object({ - id: z.string(), - created_at: z.number(), - error: z - .object({ - code: z.string(), - message: z.string(), - }) - .nullish(), - model: z.string(), - output: z.array( - z.discriminatedUnion("type", [ - z.object({ - type: z.literal("message"), - role: z.literal("assistant"), - id: z.string(), - content: z.array( - z.object({ - type: z.literal("output_text"), - text: z.string(), - logprobs: LOGPROBS_SCHEMA.nullish(), - annotations: z.array( - z.discriminatedUnion("type", [ - z.object({ - type: z.literal("url_citation"), - start_index: z.number(), - end_index: z.number(), - url: z.string(), - title: z.string(), - }), - z.object({ - type: z.literal("file_citation"), - file_id: z.string(), - filename: z.string().nullish(), - index: z.number().nullish(), - start_index: z.number().nullish(), - end_index: z.number().nullish(), - quote: z.string().nullish(), - }), - z.object({ - type: z.literal("container_file_citation"), - }), - ]), - ), - }), - ), - }), - webSearchCallItem, - fileSearchCallItem, - codeInterpreterCallItem, - imageGenerationCallItem, - localShellCallItem, - z.object({ - type: z.literal("function_call"), - call_id: z.string(), - name: z.string(), - arguments: z.string(), - id: z.string(), - }), - z.object({ - type: z.literal("computer_call"), - id: z.string(), - status: z.string().optional(), - }), - z.object({ - type: z.literal("reasoning"), - id: z.string(), - encrypted_content: z.string().nullish(), - summary: z.array( - z.object({ - type: z.literal("summary_text"), - text: z.string(), - }), - ), - }), - ]), - ), - service_tier: z.string().nullish(), - incomplete_details: z.object({ reason: z.string() }).nullish(), - usage: usageSchema, - }), - ), - abortSignal: options.abortSignal, - fetch: this.config.fetch, - }) - - if (response.error) { - throw new APICallError({ - message: response.error.message, - url, - requestBodyValues: body, - statusCode: 400, - responseHeaders, - responseBody: rawResponse as string, - isRetryable: false, - }) - } - - const content: Array = [] - const logprobs: Array> = [] - - // flag that checks if there have been client-side tool calls (not executed by openai) - let hasFunctionCall = false - - // map response content to content array - for (const part of response.output) { - switch (part.type) { - case "reasoning": { - // when there are no summary parts, we need to add an empty reasoning part: - if (part.summary.length === 0) { - part.summary.push({ type: "summary_text", text: "" }) - } - - for (const summary of part.summary) { - content.push({ - type: "reasoning" as const, - text: summary.text, - providerMetadata: { - openai: { - itemId: part.id, - reasoningEncryptedContent: part.encrypted_content ?? null, - }, - }, - }) - } - break - } - - case "image_generation_call": { - content.push({ - type: "tool-call", - toolCallId: part.id, - toolName: "image_generation", - input: "{}", - providerExecuted: true, - }) - - content.push({ - type: "tool-result", - toolCallId: part.id, - toolName: "image_generation", - result: { - result: part.result, - } satisfies z.infer, - providerExecuted: true, - }) - - break - } - - case "local_shell_call": { - content.push({ - type: "tool-call", - toolCallId: part.call_id, - toolName: "local_shell", - input: JSON.stringify({ action: part.action } satisfies z.infer), - providerMetadata: { - openai: { - itemId: part.id, - }, - }, - }) - - break - } - - case "message": { - for (const contentPart of part.content) { - if (options.providerOptions?.openai?.logprobs && contentPart.logprobs) { - logprobs.push(contentPart.logprobs) - } - - content.push({ - type: "text", - text: contentPart.text, - providerMetadata: { - openai: { - itemId: part.id, - }, - }, - }) - - for (const annotation of contentPart.annotations) { - if (annotation.type === "url_citation") { - content.push({ - type: "source", - sourceType: "url", - id: this.config.generateId?.() ?? generateId(), - url: annotation.url, - title: annotation.title, - }) - } else if (annotation.type === "file_citation") { - content.push({ - type: "source", - sourceType: "document", - id: this.config.generateId?.() ?? generateId(), - mediaType: "text/plain", - title: annotation.quote ?? annotation.filename ?? "Document", - filename: annotation.filename ?? annotation.file_id, - }) - } - } - } - - break - } - - case "function_call": { - hasFunctionCall = true - - content.push({ - type: "tool-call", - toolCallId: part.call_id, - toolName: part.name, - input: part.arguments, - providerMetadata: { - openai: { - itemId: part.id, - }, - }, - }) - break - } - - case "web_search_call": { - content.push({ - type: "tool-call", - toolCallId: part.id, - toolName: webSearchToolName ?? "web_search", - input: JSON.stringify({ action: part.action }), - providerExecuted: true, - }) - - content.push({ - type: "tool-result", - toolCallId: part.id, - toolName: webSearchToolName ?? "web_search", - result: { status: part.status }, - providerExecuted: true, - }) - - break - } - - case "computer_call": { - content.push({ - type: "tool-call", - toolCallId: part.id, - toolName: "computer_use", - input: "", - providerExecuted: true, - }) - - content.push({ - type: "tool-result", - toolCallId: part.id, - toolName: "computer_use", - result: { - type: "computer_use_tool_result", - status: part.status || "completed", - }, - providerExecuted: true, - }) - break - } - - case "file_search_call": { - content.push({ - type: "tool-call", - toolCallId: part.id, - toolName: "file_search", - input: "{}", - providerExecuted: true, - }) - - content.push({ - type: "tool-result", - toolCallId: part.id, - toolName: "file_search", - result: { - queries: part.queries, - results: - part.results?.map((result) => ({ - attributes: result.attributes, - fileId: result.file_id, - filename: result.filename, - score: result.score, - text: result.text, - })) ?? null, - } satisfies z.infer, - providerExecuted: true, - }) - break - } - - case "code_interpreter_call": { - content.push({ - type: "tool-call", - toolCallId: part.id, - toolName: "code_interpreter", - input: JSON.stringify({ - code: part.code, - containerId: part.container_id, - } satisfies z.infer), - providerExecuted: true, - }) - - content.push({ - type: "tool-result", - toolCallId: part.id, - toolName: "code_interpreter", - result: { - outputs: part.outputs, - } satisfies z.infer, - providerExecuted: true, - }) - break - } - } - } - - const providerMetadata: SharedV2ProviderMetadata = { - openai: { responseId: response.id }, - } - - if (logprobs.length > 0) { - providerMetadata.openai.logprobs = logprobs - } - - if (typeof response.service_tier === "string") { - providerMetadata.openai.serviceTier = response.service_tier - } - - return { - content, - finishReason: mapOpenAIResponseFinishReason({ - finishReason: response.incomplete_details?.reason, - hasFunctionCall, - }), - usage: { - inputTokens: response.usage.input_tokens, - outputTokens: response.usage.output_tokens, - totalTokens: response.usage.input_tokens + response.usage.output_tokens, - reasoningTokens: response.usage.output_tokens_details?.reasoning_tokens ?? undefined, - cachedInputTokens: response.usage.input_tokens_details?.cached_tokens ?? undefined, - }, - request: { body }, - response: { - id: response.id, - timestamp: new Date(response.created_at * 1000), - modelId: response.model, - headers: responseHeaders, - body: rawResponse, - }, - providerMetadata, - warnings, - } - } - - async doStream( - options: Parameters[0], - ): Promise>> { - const { args: body, warnings, webSearchToolName } = await this.getArgs(options) - - const { responseHeaders, value: response } = await postJsonToApi({ - url: this.config.url({ - path: "/responses", - modelId: this.modelId, - }), - headers: combineHeaders(this.config.headers(), options.headers), - body: { - ...body, - stream: true, - }, - failedResponseHandler: openaiFailedResponseHandler, - successfulResponseHandler: createEventSourceResponseHandler(openaiResponsesChunkSchema), - abortSignal: options.abortSignal, - fetch: this.config.fetch, - }) - - const self = this - - let finishReason: LanguageModelV2FinishReason = "unknown" - const usage: LanguageModelV2Usage = { - inputTokens: undefined, - outputTokens: undefined, - totalTokens: undefined, - } - const logprobs: Array> = [] - let responseId: string | null = null - const ongoingToolCalls: Record< - number, - | { - toolName: string - toolCallId: string - codeInterpreter?: { - containerId: string - } - } - | undefined - > = {} - - // flag that checks if there have been client-side tool calls (not executed by openai) - let hasFunctionCall = false - - // Track reasoning by output_index instead of item_id - // GitHub Copilot rotates encrypted item IDs on every event - const activeReasoning: Record< - number, - { - canonicalId: string // the item.id from output_item.added - encryptedContent?: string | null - summaryParts: number[] - } - > = {} - - // Track current active reasoning output_index for correlating summary events - let currentReasoningOutputIndex: number | null = null - - // Track a stable text part id for the current assistant message. - // Copilot may change item_id across text deltas; normalize to one id. - let currentTextId: string | null = null - - let serviceTier: string | undefined - - return { - stream: response.pipeThrough( - new TransformStream>, LanguageModelV2StreamPart>({ - start(controller) { - controller.enqueue({ type: "stream-start", warnings }) - }, - - transform(chunk, controller) { - if (options.includeRawChunks) { - controller.enqueue({ type: "raw", rawValue: chunk.rawValue }) - } - - // handle failed chunk parsing / validation: - if (!chunk.success) { - finishReason = "error" - controller.enqueue({ type: "error", error: chunk.error }) - return - } - - const value = chunk.value - - if (isResponseOutputItemAddedChunk(value)) { - if (value.item.type === "function_call") { - ongoingToolCalls[value.output_index] = { - toolName: value.item.name, - toolCallId: value.item.call_id, - } - - controller.enqueue({ - type: "tool-input-start", - id: value.item.call_id, - toolName: value.item.name, - }) - } else if (value.item.type === "web_search_call") { - ongoingToolCalls[value.output_index] = { - toolName: webSearchToolName ?? "web_search", - toolCallId: value.item.id, - } - - controller.enqueue({ - type: "tool-input-start", - id: value.item.id, - toolName: webSearchToolName ?? "web_search", - }) - } else if (value.item.type === "computer_call") { - ongoingToolCalls[value.output_index] = { - toolName: "computer_use", - toolCallId: value.item.id, - } - - controller.enqueue({ - type: "tool-input-start", - id: value.item.id, - toolName: "computer_use", - }) - } else if (value.item.type === "code_interpreter_call") { - ongoingToolCalls[value.output_index] = { - toolName: "code_interpreter", - toolCallId: value.item.id, - codeInterpreter: { - containerId: value.item.container_id, - }, - } - - controller.enqueue({ - type: "tool-input-start", - id: value.item.id, - toolName: "code_interpreter", - }) - - controller.enqueue({ - type: "tool-input-delta", - id: value.item.id, - delta: `{"containerId":"${value.item.container_id}","code":"`, - }) - } else if (value.item.type === "file_search_call") { - controller.enqueue({ - type: "tool-call", - toolCallId: value.item.id, - toolName: "file_search", - input: "{}", - providerExecuted: true, - }) - } else if (value.item.type === "image_generation_call") { - controller.enqueue({ - type: "tool-call", - toolCallId: value.item.id, - toolName: "image_generation", - input: "{}", - providerExecuted: true, - }) - } else if (value.item.type === "message") { - // Start a stable text part for this assistant message - currentTextId = value.item.id - controller.enqueue({ - type: "text-start", - id: value.item.id, - providerMetadata: { - openai: { - itemId: value.item.id, - }, - }, - }) - } else if (isResponseOutputItemAddedReasoningChunk(value)) { - activeReasoning[value.output_index] = { - canonicalId: value.item.id, - encryptedContent: value.item.encrypted_content, - summaryParts: [0], - } - currentReasoningOutputIndex = value.output_index - - controller.enqueue({ - type: "reasoning-start", - id: `${value.item.id}:0`, - providerMetadata: { - openai: { - itemId: value.item.id, - reasoningEncryptedContent: value.item.encrypted_content ?? null, - }, - }, - }) - } - } else if (isResponseOutputItemDoneChunk(value)) { - if (value.item.type === "function_call") { - ongoingToolCalls[value.output_index] = undefined - hasFunctionCall = true - - controller.enqueue({ - type: "tool-input-end", - id: value.item.call_id, - }) - - controller.enqueue({ - type: "tool-call", - toolCallId: value.item.call_id, - toolName: value.item.name, - input: value.item.arguments, - providerMetadata: { - openai: { - itemId: value.item.id, - }, - }, - }) - } else if (value.item.type === "web_search_call") { - ongoingToolCalls[value.output_index] = undefined - - controller.enqueue({ - type: "tool-input-end", - id: value.item.id, - }) - - controller.enqueue({ - type: "tool-call", - toolCallId: value.item.id, - toolName: "web_search", - input: JSON.stringify({ action: value.item.action }), - providerExecuted: true, - }) - - controller.enqueue({ - type: "tool-result", - toolCallId: value.item.id, - toolName: "web_search", - result: { status: value.item.status }, - providerExecuted: true, - }) - } else if (value.item.type === "computer_call") { - ongoingToolCalls[value.output_index] = undefined - - controller.enqueue({ - type: "tool-input-end", - id: value.item.id, - }) - - controller.enqueue({ - type: "tool-call", - toolCallId: value.item.id, - toolName: "computer_use", - input: "", - providerExecuted: true, - }) - - controller.enqueue({ - type: "tool-result", - toolCallId: value.item.id, - toolName: "computer_use", - result: { - type: "computer_use_tool_result", - status: value.item.status || "completed", - }, - providerExecuted: true, - }) - } else if (value.item.type === "file_search_call") { - ongoingToolCalls[value.output_index] = undefined - - controller.enqueue({ - type: "tool-result", - toolCallId: value.item.id, - toolName: "file_search", - result: { - queries: value.item.queries, - results: - value.item.results?.map((result) => ({ - attributes: result.attributes, - fileId: result.file_id, - filename: result.filename, - score: result.score, - text: result.text, - })) ?? null, - } satisfies z.infer, - providerExecuted: true, - }) - } else if (value.item.type === "code_interpreter_call") { - ongoingToolCalls[value.output_index] = undefined - - controller.enqueue({ - type: "tool-result", - toolCallId: value.item.id, - toolName: "code_interpreter", - result: { - outputs: value.item.outputs, - } satisfies z.infer, - providerExecuted: true, - }) - } else if (value.item.type === "image_generation_call") { - controller.enqueue({ - type: "tool-result", - toolCallId: value.item.id, - toolName: "image_generation", - result: { - result: value.item.result, - } satisfies z.infer, - providerExecuted: true, - }) - } else if (value.item.type === "local_shell_call") { - ongoingToolCalls[value.output_index] = undefined - - controller.enqueue({ - type: "tool-call", - toolCallId: value.item.call_id, - toolName: "local_shell", - input: JSON.stringify({ - action: { - type: "exec", - command: value.item.action.command, - timeoutMs: value.item.action.timeout_ms, - user: value.item.action.user, - workingDirectory: value.item.action.working_directory, - env: value.item.action.env, - }, - } satisfies z.infer), - providerMetadata: { - openai: { itemId: value.item.id }, - }, - }) - } else if (value.item.type === "message") { - if (currentTextId) { - controller.enqueue({ - type: "text-end", - id: currentTextId, - }) - currentTextId = null - } - } else if (isResponseOutputItemDoneReasoningChunk(value)) { - const activeReasoningPart = activeReasoning[value.output_index] - if (activeReasoningPart) { - for (const summaryIndex of activeReasoningPart.summaryParts) { - controller.enqueue({ - type: "reasoning-end", - id: `${activeReasoningPart.canonicalId}:${summaryIndex}`, - providerMetadata: { - openai: { - itemId: activeReasoningPart.canonicalId, - reasoningEncryptedContent: value.item.encrypted_content ?? null, - }, - }, - }) - } - delete activeReasoning[value.output_index] - if (currentReasoningOutputIndex === value.output_index) { - currentReasoningOutputIndex = null - } - } - } - } else if (isResponseFunctionCallArgumentsDeltaChunk(value)) { - const toolCall = ongoingToolCalls[value.output_index] - - if (toolCall != null) { - controller.enqueue({ - type: "tool-input-delta", - id: toolCall.toolCallId, - delta: value.delta, - }) - } - } else if (isResponseImageGenerationCallPartialImageChunk(value)) { - controller.enqueue({ - type: "tool-result", - toolCallId: value.item_id, - toolName: "image_generation", - result: { - result: value.partial_image_b64, - } satisfies z.infer, - providerExecuted: true, - }) - } else if (isResponseCodeInterpreterCallCodeDeltaChunk(value)) { - const toolCall = ongoingToolCalls[value.output_index] - - if (toolCall != null) { - controller.enqueue({ - type: "tool-input-delta", - id: toolCall.toolCallId, - // The delta is code, which is embedding in a JSON string. - // To escape it, we use JSON.stringify and slice to remove the outer quotes. - delta: JSON.stringify(value.delta).slice(1, -1), - }) - } - } else if (isResponseCodeInterpreterCallCodeDoneChunk(value)) { - const toolCall = ongoingToolCalls[value.output_index] - - if (toolCall != null) { - controller.enqueue({ - type: "tool-input-delta", - id: toolCall.toolCallId, - delta: '"}', - }) - - controller.enqueue({ - type: "tool-input-end", - id: toolCall.toolCallId, - }) - - // immediately send the tool call after the input end: - controller.enqueue({ - type: "tool-call", - toolCallId: toolCall.toolCallId, - toolName: "code_interpreter", - input: JSON.stringify({ - code: value.code, - containerId: toolCall.codeInterpreter!.containerId, - } satisfies z.infer), - providerExecuted: true, - }) - } - } else if (isResponseCreatedChunk(value)) { - responseId = value.response.id - controller.enqueue({ - type: "response-metadata", - id: value.response.id, - timestamp: new Date(value.response.created_at * 1000), - modelId: value.response.model, - }) - } else if (isTextDeltaChunk(value)) { - // Ensure a text-start exists, and normalize deltas to a stable id - if (!currentTextId) { - currentTextId = value.item_id - controller.enqueue({ - type: "text-start", - id: currentTextId, - providerMetadata: { - openai: { itemId: value.item_id }, - }, - }) - } - - controller.enqueue({ - type: "text-delta", - id: currentTextId, - delta: value.delta, - }) - - if (options.providerOptions?.openai?.logprobs && value.logprobs) { - logprobs.push(value.logprobs) - } - } else if (isResponseReasoningSummaryPartAddedChunk(value)) { - const activeItem = - currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null - - // the first reasoning start is pushed in isResponseOutputItemAddedReasoningChunk. - if (activeItem && value.summary_index > 0) { - activeItem.summaryParts.push(value.summary_index) - - controller.enqueue({ - type: "reasoning-start", - id: `${activeItem.canonicalId}:${value.summary_index}`, - providerMetadata: { - openai: { - itemId: activeItem.canonicalId, - reasoningEncryptedContent: activeItem.encryptedContent ?? null, - }, - }, - }) - } - } else if (isResponseReasoningSummaryTextDeltaChunk(value)) { - const activeItem = - currentReasoningOutputIndex !== null ? activeReasoning[currentReasoningOutputIndex] : null - - if (activeItem) { - controller.enqueue({ - type: "reasoning-delta", - id: `${activeItem.canonicalId}:${value.summary_index}`, - delta: value.delta, - providerMetadata: { - openai: { - itemId: activeItem.canonicalId, - }, - }, - }) - } - } else if (isResponseFinishedChunk(value)) { - finishReason = mapOpenAIResponseFinishReason({ - finishReason: value.response.incomplete_details?.reason, - hasFunctionCall, - }) - usage.inputTokens = value.response.usage.input_tokens - usage.outputTokens = value.response.usage.output_tokens - usage.totalTokens = value.response.usage.input_tokens + value.response.usage.output_tokens - usage.reasoningTokens = value.response.usage.output_tokens_details?.reasoning_tokens ?? undefined - usage.cachedInputTokens = value.response.usage.input_tokens_details?.cached_tokens ?? undefined - if (typeof value.response.service_tier === "string") { - serviceTier = value.response.service_tier - } - } else if (isResponseAnnotationAddedChunk(value)) { - if (value.annotation.type === "url_citation") { - controller.enqueue({ - type: "source", - sourceType: "url", - id: self.config.generateId?.() ?? generateId(), - url: value.annotation.url, - title: value.annotation.title, - }) - } else if (value.annotation.type === "file_citation") { - controller.enqueue({ - type: "source", - sourceType: "document", - id: self.config.generateId?.() ?? generateId(), - mediaType: "text/plain", - title: value.annotation.quote ?? value.annotation.filename ?? "Document", - filename: value.annotation.filename ?? value.annotation.file_id, - }) - } - } else if (isErrorChunk(value)) { - controller.enqueue({ type: "error", error: value }) - } - }, - - flush(controller) { - // Close any dangling text part - if (currentTextId) { - controller.enqueue({ type: "text-end", id: currentTextId }) - currentTextId = null - } - - const providerMetadata: SharedV2ProviderMetadata = { - openai: { - responseId, - }, - } - - if (logprobs.length > 0) { - providerMetadata.openai.logprobs = logprobs - } - - if (serviceTier !== undefined) { - providerMetadata.openai.serviceTier = serviceTier - } - - controller.enqueue({ - type: "finish", - finishReason, - usage, - providerMetadata, - }) - }, - }), - ), - request: { body }, - response: { headers: responseHeaders }, - } - } -} - -const usageSchema = z.object({ - input_tokens: z.number(), - input_tokens_details: z.object({ cached_tokens: z.number().nullish() }).nullish(), - output_tokens: z.number(), - output_tokens_details: z.object({ reasoning_tokens: z.number().nullish() }).nullish(), -}) - -const textDeltaChunkSchema = z.object({ - type: z.literal("response.output_text.delta"), - item_id: z.string(), - delta: z.string(), - logprobs: LOGPROBS_SCHEMA.nullish(), -}) - -const errorChunkSchema = z.object({ - type: z.literal("error"), - code: z.string(), - message: z.string(), - param: z.string().nullish(), - sequence_number: z.number(), -}) - -const responseFinishedChunkSchema = z.object({ - type: z.enum(["response.completed", "response.incomplete"]), - response: z.object({ - incomplete_details: z.object({ reason: z.string() }).nullish(), - usage: usageSchema, - service_tier: z.string().nullish(), - }), -}) - -const responseCreatedChunkSchema = z.object({ - type: z.literal("response.created"), - response: z.object({ - id: z.string(), - created_at: z.number(), - model: z.string(), - service_tier: z.string().nullish(), - }), -}) - -const responseOutputItemAddedSchema = z.object({ - type: z.literal("response.output_item.added"), - output_index: z.number(), - item: z.discriminatedUnion("type", [ - z.object({ - type: z.literal("message"), - id: z.string(), - }), - z.object({ - type: z.literal("reasoning"), - id: z.string(), - encrypted_content: z.string().nullish(), - }), - z.object({ - type: z.literal("function_call"), - id: z.string(), - call_id: z.string(), - name: z.string(), - arguments: z.string(), - }), - z.object({ - type: z.literal("web_search_call"), - id: z.string(), - status: z.string(), - action: z - .object({ - type: z.literal("search"), - query: z.string().optional(), - }) - .nullish(), - }), - z.object({ - type: z.literal("computer_call"), - id: z.string(), - status: z.string(), - }), - z.object({ - type: z.literal("file_search_call"), - id: z.string(), - }), - z.object({ - type: z.literal("image_generation_call"), - id: z.string(), - }), - z.object({ - type: z.literal("code_interpreter_call"), - id: z.string(), - container_id: z.string(), - code: z.string().nullable(), - outputs: z - .array( - z.discriminatedUnion("type", [ - z.object({ type: z.literal("logs"), logs: z.string() }), - z.object({ type: z.literal("image"), url: z.string() }), - ]), - ) - .nullable(), - status: z.string(), - }), - ]), -}) - -const responseOutputItemDoneSchema = z.object({ - type: z.literal("response.output_item.done"), - output_index: z.number(), - item: z.discriminatedUnion("type", [ - z.object({ - type: z.literal("message"), - id: z.string(), - }), - z.object({ - type: z.literal("reasoning"), - id: z.string(), - encrypted_content: z.string().nullish(), - }), - z.object({ - type: z.literal("function_call"), - id: z.string(), - call_id: z.string(), - name: z.string(), - arguments: z.string(), - status: z.literal("completed"), - }), - codeInterpreterCallItem, - imageGenerationCallItem, - webSearchCallItem, - fileSearchCallItem, - localShellCallItem, - z.object({ - type: z.literal("computer_call"), - id: z.string(), - status: z.literal("completed"), - }), - ]), -}) - -const responseFunctionCallArgumentsDeltaSchema = z.object({ - type: z.literal("response.function_call_arguments.delta"), - item_id: z.string(), - output_index: z.number(), - delta: z.string(), -}) - -const responseImageGenerationCallPartialImageSchema = z.object({ - type: z.literal("response.image_generation_call.partial_image"), - item_id: z.string(), - output_index: z.number(), - partial_image_b64: z.string(), -}) - -const responseCodeInterpreterCallCodeDeltaSchema = z.object({ - type: z.literal("response.code_interpreter_call_code.delta"), - item_id: z.string(), - output_index: z.number(), - delta: z.string(), -}) - -const responseCodeInterpreterCallCodeDoneSchema = z.object({ - type: z.literal("response.code_interpreter_call_code.done"), - item_id: z.string(), - output_index: z.number(), - code: z.string(), -}) - -const responseAnnotationAddedSchema = z.object({ - type: z.literal("response.output_text.annotation.added"), - annotation: z.discriminatedUnion("type", [ - z.object({ - type: z.literal("url_citation"), - url: z.string(), - title: z.string(), - }), - z.object({ - type: z.literal("file_citation"), - file_id: z.string(), - filename: z.string().nullish(), - index: z.number().nullish(), - start_index: z.number().nullish(), - end_index: z.number().nullish(), - quote: z.string().nullish(), - }), - ]), -}) - -const responseReasoningSummaryPartAddedSchema = z.object({ - type: z.literal("response.reasoning_summary_part.added"), - item_id: z.string(), - summary_index: z.number(), -}) - -const responseReasoningSummaryTextDeltaSchema = z.object({ - type: z.literal("response.reasoning_summary_text.delta"), - item_id: z.string(), - summary_index: z.number(), - delta: z.string(), -}) - -const openaiResponsesChunkSchema = z.union([ - textDeltaChunkSchema, - responseFinishedChunkSchema, - responseCreatedChunkSchema, - responseOutputItemAddedSchema, - responseOutputItemDoneSchema, - responseFunctionCallArgumentsDeltaSchema, - responseImageGenerationCallPartialImageSchema, - responseCodeInterpreterCallCodeDeltaSchema, - responseCodeInterpreterCallCodeDoneSchema, - responseAnnotationAddedSchema, - responseReasoningSummaryPartAddedSchema, - responseReasoningSummaryTextDeltaSchema, - errorChunkSchema, - z.object({ type: z.string() }).loose(), // fallback for unknown chunks -]) - -type ExtractByType = T extends { type: K } ? T : never - -function isTextDeltaChunk( - chunk: z.infer, -): chunk is z.infer { - return chunk.type === "response.output_text.delta" -} - -function isResponseOutputItemDoneChunk( - chunk: z.infer, -): chunk is z.infer { - return chunk.type === "response.output_item.done" -} - -function isResponseOutputItemDoneReasoningChunk(chunk: z.infer): chunk is z.infer< - typeof responseOutputItemDoneSchema -> & { - item: ExtractByType["item"], "reasoning"> -} { - return isResponseOutputItemDoneChunk(chunk) && chunk.item.type === "reasoning" -} - -function isResponseFinishedChunk( - chunk: z.infer, -): chunk is z.infer { - return chunk.type === "response.completed" || chunk.type === "response.incomplete" -} - -function isResponseCreatedChunk( - chunk: z.infer, -): chunk is z.infer { - return chunk.type === "response.created" -} - -function isResponseFunctionCallArgumentsDeltaChunk( - chunk: z.infer, -): chunk is z.infer { - return chunk.type === "response.function_call_arguments.delta" -} -function isResponseImageGenerationCallPartialImageChunk( - chunk: z.infer, -): chunk is z.infer { - return chunk.type === "response.image_generation_call.partial_image" -} - -function isResponseCodeInterpreterCallCodeDeltaChunk( - chunk: z.infer, -): chunk is z.infer { - return chunk.type === "response.code_interpreter_call_code.delta" -} - -function isResponseCodeInterpreterCallCodeDoneChunk( - chunk: z.infer, -): chunk is z.infer { - return chunk.type === "response.code_interpreter_call_code.done" -} - -function isResponseOutputItemAddedChunk( - chunk: z.infer, -): chunk is z.infer { - return chunk.type === "response.output_item.added" -} - -function isResponseOutputItemAddedReasoningChunk(chunk: z.infer): chunk is z.infer< - typeof responseOutputItemAddedSchema -> & { - item: ExtractByType["item"], "reasoning"> -} { - return isResponseOutputItemAddedChunk(chunk) && chunk.item.type === "reasoning" -} - -function isResponseAnnotationAddedChunk( - chunk: z.infer, -): chunk is z.infer { - return chunk.type === "response.output_text.annotation.added" -} - -function isResponseReasoningSummaryPartAddedChunk( - chunk: z.infer, -): chunk is z.infer { - return chunk.type === "response.reasoning_summary_part.added" -} - -function isResponseReasoningSummaryTextDeltaChunk( - chunk: z.infer, -): chunk is z.infer { - return chunk.type === "response.reasoning_summary_text.delta" -} - -function isErrorChunk(chunk: z.infer): chunk is z.infer { - return chunk.type === "error" -} - -type ResponsesModelConfig = { - isReasoningModel: boolean - systemMessageMode: "remove" | "system" | "developer" - requiredAutoTruncation: boolean - supportsFlexProcessing: boolean - supportsPriorityProcessing: boolean -} - -function getResponsesModelConfig(modelId: string): ResponsesModelConfig { - const supportsFlexProcessing = - modelId.startsWith("o3") || - modelId.startsWith("o4-mini") || - (modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-chat")) - const supportsPriorityProcessing = - modelId.startsWith("gpt-4") || - modelId.startsWith("gpt-5-mini") || - (modelId.startsWith("gpt-5") && !modelId.startsWith("gpt-5-nano") && !modelId.startsWith("gpt-5-chat")) || - modelId.startsWith("o3") || - modelId.startsWith("o4-mini") - const defaults = { - requiredAutoTruncation: false, - systemMessageMode: "system" as const, - supportsFlexProcessing, - supportsPriorityProcessing, - } - - // gpt-5-chat models are non-reasoning - if (modelId.startsWith("gpt-5-chat")) { - return { - ...defaults, - isReasoningModel: false, - } - } - - // o series reasoning models: - if ( - modelId.startsWith("o") || - modelId.startsWith("gpt-5") || - modelId.startsWith("codex-") || - modelId.startsWith("computer-use") - ) { - if (modelId.startsWith("o1-mini") || modelId.startsWith("o1-preview")) { - return { - ...defaults, - isReasoningModel: true, - systemMessageMode: "remove", - } - } - - return { - ...defaults, - isReasoningModel: true, - systemMessageMode: "developer", - } - } - - // gpt models: - return { - ...defaults, - isReasoningModel: false, - } -} - -// TODO AI SDK 6: use optional here instead of nullish -const openaiResponsesProviderOptionsSchema = z.object({ - include: z - .array(z.enum(["reasoning.encrypted_content", "file_search_call.results", "message.output_text.logprobs"])) - .nullish(), - instructions: z.string().nullish(), - - /** - * Return the log probabilities of the tokens. - * - * Setting to true will return the log probabilities of the tokens that - * were generated. - * - * Setting to a number will return the log probabilities of the top n - * tokens that were generated. - * - * @see https://platform.openai.com/docs/api-reference/responses/create - * @see https://cookbook.openai.com/examples/using_logprobs - */ - logprobs: z.union([z.boolean(), z.number().min(1).max(TOP_LOGPROBS_MAX)]).optional(), - - /** - * The maximum number of total calls to built-in tools that can be processed in a response. - * This maximum number applies across all built-in tool calls, not per individual tool. - * Any further attempts to call a tool by the model will be ignored. - */ - maxToolCalls: z.number().nullish(), - - metadata: z.any().nullish(), - parallelToolCalls: z.boolean().nullish(), - previousResponseId: z.string().nullish(), - promptCacheKey: z.string().nullish(), - reasoningEffort: z.string().nullish(), - reasoningSummary: z.string().nullish(), - safetyIdentifier: z.string().nullish(), - serviceTier: z.enum(["auto", "flex", "priority"]).nullish(), - store: z.boolean().nullish(), - strictJsonSchema: z.boolean().nullish(), - textVerbosity: z.enum(["low", "medium", "high"]).nullish(), - user: z.string().nullish(), -}) - -export type OpenAIResponsesProviderOptions = z.infer diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-prepare-tools.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-prepare-tools.ts deleted file mode 100644 index 791de3e7c..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-prepare-tools.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { - type LanguageModelV2CallOptions, - type LanguageModelV2CallWarning, - UnsupportedFunctionalityError, -} from "@ai-sdk/provider" -import { codeInterpreterArgsSchema } from "./tool/code-interpreter" -import { fileSearchArgsSchema } from "./tool/file-search" -import { webSearchArgsSchema } from "./tool/web-search" -import { webSearchPreviewArgsSchema } from "./tool/web-search-preview" -import { imageGenerationArgsSchema } from "./tool/image-generation" -import type { OpenAIResponsesTool } from "./openai-responses-api-types" - -export function prepareResponsesTools({ - tools, - toolChoice, - strictJsonSchema, -}: { - tools: LanguageModelV2CallOptions["tools"] - toolChoice?: LanguageModelV2CallOptions["toolChoice"] - strictJsonSchema: boolean -}): { - tools?: Array - toolChoice?: - | "auto" - | "none" - | "required" - | { type: "file_search" } - | { type: "web_search_preview" } - | { type: "web_search" } - | { type: "function"; name: string } - | { type: "code_interpreter" } - | { type: "image_generation" } - toolWarnings: LanguageModelV2CallWarning[] -} { - // when the tools array is empty, change it to undefined to prevent errors: - tools = tools?.length ? tools : undefined - - const toolWarnings: LanguageModelV2CallWarning[] = [] - - if (tools == null) { - return { tools: undefined, toolChoice: undefined, toolWarnings } - } - - const openaiTools: Array = [] - - for (const tool of tools) { - switch (tool.type) { - case "function": - openaiTools.push({ - type: "function", - name: tool.name, - description: tool.description, - parameters: tool.inputSchema, - strict: strictJsonSchema, - }) - break - case "provider-defined": { - switch (tool.id) { - case "openai.file_search": { - const args = fileSearchArgsSchema.parse(tool.args) - - openaiTools.push({ - type: "file_search", - vector_store_ids: args.vectorStoreIds, - max_num_results: args.maxNumResults, - ranking_options: args.ranking - ? { - ranker: args.ranking.ranker, - score_threshold: args.ranking.scoreThreshold, - } - : undefined, - filters: args.filters, - }) - - break - } - case "openai.local_shell": { - openaiTools.push({ - type: "local_shell", - }) - break - } - case "openai.web_search_preview": { - const args = webSearchPreviewArgsSchema.parse(tool.args) - openaiTools.push({ - type: "web_search_preview", - search_context_size: args.searchContextSize, - user_location: args.userLocation, - }) - break - } - case "openai.web_search": { - const args = webSearchArgsSchema.parse(tool.args) - openaiTools.push({ - type: "web_search", - filters: args.filters != null ? { allowed_domains: args.filters.allowedDomains } : undefined, - search_context_size: args.searchContextSize, - user_location: args.userLocation, - }) - break - } - case "openai.code_interpreter": { - const args = codeInterpreterArgsSchema.parse(tool.args) - openaiTools.push({ - type: "code_interpreter", - container: - args.container == null - ? { type: "auto", file_ids: undefined } - : typeof args.container === "string" - ? args.container - : { type: "auto", file_ids: args.container.fileIds }, - }) - break - } - case "openai.image_generation": { - const args = imageGenerationArgsSchema.parse(tool.args) - openaiTools.push({ - type: "image_generation", - background: args.background, - input_fidelity: args.inputFidelity, - input_image_mask: args.inputImageMask - ? { - file_id: args.inputImageMask.fileId, - image_url: args.inputImageMask.imageUrl, - } - : undefined, - model: args.model, - moderation: args.moderation, - partial_images: args.partialImages, - quality: args.quality, - output_compression: args.outputCompression, - output_format: args.outputFormat, - size: args.size, - }) - break - } - } - break - } - default: - toolWarnings.push({ type: "unsupported-tool", tool }) - break - } - } - - if (toolChoice == null) { - return { tools: openaiTools, toolChoice: undefined, toolWarnings } - } - - const type = toolChoice.type - - switch (type) { - case "auto": - case "none": - case "required": - return { tools: openaiTools, toolChoice: type, toolWarnings } - case "tool": - return { - tools: openaiTools, - toolChoice: - toolChoice.toolName === "code_interpreter" || - toolChoice.toolName === "file_search" || - toolChoice.toolName === "image_generation" || - toolChoice.toolName === "web_search_preview" || - toolChoice.toolName === "web_search" - ? { type: toolChoice.toolName } - : { type: "function", name: toolChoice.toolName }, - toolWarnings, - } - default: { - const _exhaustiveCheck: never = type - throw new UnsupportedFunctionalityError({ - functionality: `tool choice type: ${_exhaustiveCheck}`, - }) - } - } -} diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-settings.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-settings.ts deleted file mode 100644 index 76c97346f..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/openai-responses-settings.ts +++ /dev/null @@ -1 +0,0 @@ -export type OpenAIResponsesModelId = string diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/code-interpreter.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/code-interpreter.ts deleted file mode 100644 index 2bb4bce77..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/code-interpreter.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { createProviderDefinedToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils" -import { z } from "zod/v4" - -export const codeInterpreterInputSchema = z.object({ - code: z.string().nullish(), - containerId: z.string(), -}) - -export const codeInterpreterOutputSchema = z.object({ - outputs: z - .array( - z.discriminatedUnion("type", [ - z.object({ type: z.literal("logs"), logs: z.string() }), - z.object({ type: z.literal("image"), url: z.string() }), - ]), - ) - .nullish(), -}) - -export const codeInterpreterArgsSchema = z.object({ - container: z - .union([ - z.string(), - z.object({ - fileIds: z.array(z.string()).optional(), - }), - ]) - .optional(), -}) - -type CodeInterpreterArgs = { - /** - * The code interpreter container. - * Can be a container ID - * or an object that specifies uploaded file IDs to make available to your code. - */ - container?: string | { fileIds?: string[] } -} - -export const codeInterpreterToolFactory = createProviderDefinedToolFactoryWithOutputSchema< - { - /** - * The code to run, or null if not available. - */ - code?: string | null - - /** - * The ID of the container used to run the code. - */ - containerId: string - }, - { - /** - * The outputs generated by the code interpreter, such as logs or images. - * Can be null if no outputs are available. - */ - outputs?: Array< - | { - type: "logs" - - /** - * The logs output from the code interpreter. - */ - logs: string - } - | { - type: "image" - - /** - * The URL of the image output from the code interpreter. - */ - url: string - } - > | null - }, - CodeInterpreterArgs ->({ - id: "openai.code_interpreter", - name: "code_interpreter", - inputSchema: codeInterpreterInputSchema, - outputSchema: codeInterpreterOutputSchema, -}) - -export const codeInterpreter = ( - args: CodeInterpreterArgs = {}, // default -) => { - return codeInterpreterToolFactory(args) -} diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/file-search.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/file-search.ts deleted file mode 100644 index 1fccddaf6..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/file-search.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { createProviderDefinedToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils" -import type { - OpenAIResponsesFileSearchToolComparisonFilter, - OpenAIResponsesFileSearchToolCompoundFilter, -} from "../openai-responses-api-types" -import { z } from "zod/v4" - -const comparisonFilterSchema = z.object({ - key: z.string(), - type: z.enum(["eq", "ne", "gt", "gte", "lt", "lte"]), - value: z.union([z.string(), z.number(), z.boolean()]), -}) - -const compoundFilterSchema: z.ZodType = z.object({ - type: z.enum(["and", "or"]), - filters: z.array(z.union([comparisonFilterSchema, z.lazy(() => compoundFilterSchema)])), -}) - -export const fileSearchArgsSchema = z.object({ - vectorStoreIds: z.array(z.string()), - maxNumResults: z.number().optional(), - ranking: z - .object({ - ranker: z.string().optional(), - scoreThreshold: z.number().optional(), - }) - .optional(), - filters: z.union([comparisonFilterSchema, compoundFilterSchema]).optional(), -}) - -export const fileSearchOutputSchema = z.object({ - queries: z.array(z.string()), - results: z - .array( - z.object({ - attributes: z.record(z.string(), z.unknown()), - fileId: z.string(), - filename: z.string(), - score: z.number(), - text: z.string(), - }), - ) - .nullable(), -}) - -export const fileSearch = createProviderDefinedToolFactoryWithOutputSchema< - {}, - { - /** - * The search query to execute. - */ - queries: string[] - - /** - * The results of the file search tool call. - */ - results: - | null - | { - /** - * Set of 16 key-value pairs that can be attached to an object. - * This can be useful for storing additional information about the object - * in a structured format, and querying for objects via API or the dashboard. - * Keys are strings with a maximum length of 64 characters. - * Values are strings with a maximum length of 512 characters, booleans, or numbers. - */ - attributes: Record - - /** - * The unique ID of the file. - */ - fileId: string - - /** - * The name of the file. - */ - filename: string - - /** - * The relevance score of the file - a value between 0 and 1. - */ - score: number - - /** - * The text that was retrieved from the file. - */ - text: string - }[] - }, - { - /** - * List of vector store IDs to search through. - */ - vectorStoreIds: string[] - - /** - * Maximum number of search results to return. Defaults to 10. - */ - maxNumResults?: number - - /** - * Ranking options for the search. - */ - ranking?: { - /** - * The ranker to use for the file search. - */ - ranker?: string - - /** - * The score threshold for the file search, a number between 0 and 1. - * Numbers closer to 1 will attempt to return only the most relevant results, - * but may return fewer results. - */ - scoreThreshold?: number - } - - /** - * A filter to apply. - */ - filters?: OpenAIResponsesFileSearchToolComparisonFilter | OpenAIResponsesFileSearchToolCompoundFilter - } ->({ - id: "openai.file_search", - name: "file_search", - inputSchema: z.object({}), - outputSchema: fileSearchOutputSchema, -}) diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/image-generation.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/image-generation.ts deleted file mode 100644 index 7367a4802..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/image-generation.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { createProviderDefinedToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils" -import { z } from "zod/v4" - -export const imageGenerationArgsSchema = z - .object({ - background: z.enum(["auto", "opaque", "transparent"]).optional(), - inputFidelity: z.enum(["low", "high"]).optional(), - inputImageMask: z - .object({ - fileId: z.string().optional(), - imageUrl: z.string().optional(), - }) - .optional(), - model: z.string().optional(), - moderation: z.enum(["auto"]).optional(), - outputCompression: z.number().int().min(0).max(100).optional(), - outputFormat: z.enum(["png", "jpeg", "webp"]).optional(), - partialImages: z.number().int().min(0).max(3).optional(), - quality: z.enum(["auto", "low", "medium", "high"]).optional(), - size: z.enum(["1024x1024", "1024x1536", "1536x1024", "auto"]).optional(), - }) - .strict() - -export const imageGenerationOutputSchema = z.object({ - result: z.string(), -}) - -type ImageGenerationArgs = { - /** - * Background type for the generated image. Default is 'auto'. - */ - background?: "auto" | "opaque" | "transparent" - - /** - * Input fidelity for the generated image. Default is 'low'. - */ - inputFidelity?: "low" | "high" - - /** - * Optional mask for inpainting. - * Contains image_url (string, optional) and file_id (string, optional). - */ - inputImageMask?: { - /** - * File ID for the mask image. - */ - fileId?: string - - /** - * Base64-encoded mask image. - */ - imageUrl?: string - } - - /** - * The image generation model to use. Default: gpt-image-1. - */ - model?: string - - /** - * Moderation level for the generated image. Default: auto. - */ - moderation?: "auto" - - /** - * Compression level for the output image. Default: 100. - */ - outputCompression?: number - - /** - * The output format of the generated image. One of png, webp, or jpeg. - * Default: png - */ - outputFormat?: "png" | "jpeg" | "webp" - - /** - * Number of partial images to generate in streaming mode, from 0 (default value) to 3. - */ - partialImages?: number - - /** - * The quality of the generated image. - * One of low, medium, high, or auto. Default: auto. - */ - quality?: "auto" | "low" | "medium" | "high" - - /** - * The size of the generated image. - * One of 1024x1024, 1024x1536, 1536x1024, or auto. - * Default: auto. - */ - size?: "auto" | "1024x1024" | "1024x1536" | "1536x1024" -} - -const imageGenerationToolFactory = createProviderDefinedToolFactoryWithOutputSchema< - {}, - { - /** - * The generated image encoded in base64. - */ - result: string - }, - ImageGenerationArgs ->({ - id: "openai.image_generation", - name: "image_generation", - inputSchema: z.object({}), - outputSchema: imageGenerationOutputSchema, -}) - -export const imageGeneration = ( - args: ImageGenerationArgs = {}, // default -) => { - return imageGenerationToolFactory(args) -} diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/local-shell.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/local-shell.ts deleted file mode 100644 index 4ceca0d6c..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/local-shell.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { createProviderDefinedToolFactoryWithOutputSchema } from "@ai-sdk/provider-utils" -import { z } from "zod/v4" - -export const localShellInputSchema = z.object({ - action: z.object({ - type: z.literal("exec"), - command: z.array(z.string()), - timeoutMs: z.number().optional(), - user: z.string().optional(), - workingDirectory: z.string().optional(), - env: z.record(z.string(), z.string()).optional(), - }), -}) - -export const localShellOutputSchema = z.object({ - output: z.string(), -}) - -export const localShell = createProviderDefinedToolFactoryWithOutputSchema< - { - /** - * Execute a shell command on the server. - */ - action: { - type: "exec" - - /** - * The command to run. - */ - command: string[] - - /** - * Optional timeout in milliseconds for the command. - */ - timeoutMs?: number - - /** - * Optional user to run the command as. - */ - user?: string - - /** - * Optional working directory to run the command in. - */ - workingDirectory?: string - - /** - * Environment variables to set for the command. - */ - env?: Record - } - }, - { - /** - * The output of local shell tool call. - */ - output: string - }, - {} ->({ - id: "openai.local_shell", - name: "local_shell", - inputSchema: localShellInputSchema, - outputSchema: localShellOutputSchema, -}) diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/web-search-preview.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/web-search-preview.ts deleted file mode 100644 index 69ea65ef0..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/web-search-preview.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { createProviderDefinedToolFactory } from "@ai-sdk/provider-utils" -import { z } from "zod/v4" - -// Args validation schema -export const webSearchPreviewArgsSchema = z.object({ - /** - * Search context size to use for the web search. - * - high: Most comprehensive context, highest cost, slower response - * - medium: Balanced context, cost, and latency (default) - * - low: Least context, lowest cost, fastest response - */ - searchContextSize: z.enum(["low", "medium", "high"]).optional(), - - /** - * User location information to provide geographically relevant search results. - */ - userLocation: z - .object({ - /** - * Type of location (always 'approximate') - */ - type: z.literal("approximate"), - /** - * Two-letter ISO country code (e.g., 'US', 'GB') - */ - country: z.string().optional(), - /** - * City name (free text, e.g., 'Minneapolis') - */ - city: z.string().optional(), - /** - * Region name (free text, e.g., 'Minnesota') - */ - region: z.string().optional(), - /** - * IANA timezone (e.g., 'America/Chicago') - */ - timezone: z.string().optional(), - }) - .optional(), -}) - -export const webSearchPreview = createProviderDefinedToolFactory< - { - // Web search doesn't take input parameters - it's controlled by the prompt - }, - { - /** - * Search context size to use for the web search. - * - high: Most comprehensive context, highest cost, slower response - * - medium: Balanced context, cost, and latency (default) - * - low: Least context, lowest cost, fastest response - */ - searchContextSize?: "low" | "medium" | "high" - - /** - * User location information to provide geographically relevant search results. - */ - userLocation?: { - /** - * Type of location (always 'approximate') - */ - type: "approximate" - /** - * Two-letter ISO country code (e.g., 'US', 'GB') - */ - country?: string - /** - * City name (free text, e.g., 'Minneapolis') - */ - city?: string - /** - * Region name (free text, e.g., 'Minnesota') - */ - region?: string - /** - * IANA timezone (e.g., 'America/Chicago') - */ - timezone?: string - } - } ->({ - id: "openai.web_search_preview", - name: "web_search_preview", - inputSchema: z.object({ - action: z - .discriminatedUnion("type", [ - z.object({ - type: z.literal("search"), - query: z.string().nullish(), - }), - z.object({ - type: z.literal("open_page"), - url: z.string(), - }), - z.object({ - type: z.literal("find"), - url: z.string(), - pattern: z.string(), - }), - ]) - .nullish(), - }), -}) diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/web-search.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/web-search.ts deleted file mode 100644 index 89622ad3c..000000000 --- a/packages/opencode/src/provider/sdk/openai-compatible/src/responses/tool/web-search.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { createProviderDefinedToolFactory } from "@ai-sdk/provider-utils" -import { z } from "zod/v4" - -export const webSearchArgsSchema = z.object({ - filters: z - .object({ - allowedDomains: z.array(z.string()).optional(), - }) - .optional(), - - searchContextSize: z.enum(["low", "medium", "high"]).optional(), - - userLocation: z - .object({ - type: z.literal("approximate"), - country: z.string().optional(), - city: z.string().optional(), - region: z.string().optional(), - timezone: z.string().optional(), - }) - .optional(), -}) - -export const webSearchToolFactory = createProviderDefinedToolFactory< - { - // Web search doesn't take input parameters - it's controlled by the prompt - }, - { - /** - * Filters for the search. - */ - filters?: { - /** - * Allowed domains for the search. - * If not provided, all domains are allowed. - * Subdomains of the provided domains are allowed as well. - */ - allowedDomains?: string[] - } - - /** - * Search context size to use for the web search. - * - high: Most comprehensive context, highest cost, slower response - * - medium: Balanced context, cost, and latency (default) - * - low: Least context, lowest cost, fastest response - */ - searchContextSize?: "low" | "medium" | "high" - - /** - * User location information to provide geographically relevant search results. - */ - userLocation?: { - /** - * Type of location (always 'approximate') - */ - type: "approximate" - /** - * Two-letter ISO country code (e.g., 'US', 'GB') - */ - country?: string - /** - * City name (free text, e.g., 'Minneapolis') - */ - city?: string - /** - * Region name (free text, e.g., 'Minnesota') - */ - region?: string - /** - * IANA timezone (e.g., 'America/Chicago') - */ - timezone?: string - } - } ->({ - id: "openai.web_search", - name: "web_search", - inputSchema: z.object({ - action: z - .discriminatedUnion("type", [ - z.object({ - type: z.literal("search"), - query: z.string().nullish(), - }), - z.object({ - type: z.literal("open_page"), - url: z.string(), - }), - z.object({ - type: z.literal("find"), - url: z.string(), - pattern: z.string(), - }), - ]) - .nullish(), - }), -}) - -export const webSearch = ( - args: Parameters[0] = {}, // default -) => { - return webSearchToolFactory(args) -} diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 0eee64466..39b25a4b5 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -20,6 +20,7 @@ export namespace ProviderTransform { function sdkKey(npm: string): string | undefined { switch (npm) { case "@ai-sdk/github-copilot": + return "copilot" case "@ai-sdk/openai": case "@ai-sdk/azure": return "openai" @@ -179,6 +180,9 @@ export namespace ProviderTransform { openaiCompatible: { cache_control: { type: "ephemeral" }, }, + copilot: { + copilot_cache_control: { type: "ephemeral" }, + }, } for (const msg of unique([...system, ...final])) { @@ -353,6 +357,15 @@ export namespace ProviderTransform { return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }])) case "@ai-sdk/github-copilot": + if (model.id.includes("gemini")) { + // currently github copilot only returns thinking + return {} + } + if (model.id.includes("claude")) { + return { + thinking: { thinking_budget: 4000 }, + } + } const copilotEfforts = iife(() => { if (id.includes("5.1-codex-max") || id.includes("5.2")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"] return WIDELY_SUPPORTED_EFFORTS diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 0c7652104..befa46fe4 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -148,14 +148,15 @@ export namespace LLM { }, ) - const maxOutputTokens = isCodex - ? undefined - : ProviderTransform.maxOutputTokens( - input.model.api.npm, - params.options, - input.model.limit.output, - OUTPUT_TOKEN_MAX, - ) + const maxOutputTokens = + isCodex || provider.id.includes("github-copilot") + ? undefined + : ProviderTransform.maxOutputTokens( + input.model.api.npm, + params.options, + input.model.limit.output, + OUTPUT_TOKEN_MAX, + ) const tools = await resolveTools(input) diff --git a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts new file mode 100644 index 000000000..b4f13954d --- /dev/null +++ b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts @@ -0,0 +1,478 @@ +import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages" +import { describe, test, expect } from "bun:test" + +describe("user messages", () => { + test("should convert messages with only a text part to a string content", () => { + const result = convertToCopilotMessages([ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, + ]) + + expect(result).toEqual([{ role: "user", content: "Hello" }]) + }) + + test("should convert messages with image parts", () => { + const result = convertToCopilotMessages([ + { + role: "user", + content: [ + { type: "text", text: "Hello" }, + { + type: "file", + data: Buffer.from([0, 1, 2, 3]).toString("base64"), + mediaType: "image/png", + }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "Hello" }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,AAECAw==" }, + }, + ], + }, + ]) + }) + + test("should convert messages with image parts from Uint8Array", () => { + const result = convertToCopilotMessages([ + { + role: "user", + content: [ + { type: "text", text: "Hi" }, + { + type: "file", + data: new Uint8Array([0, 1, 2, 3]), + mediaType: "image/png", + }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "Hi" }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,AAECAw==" }, + }, + ], + }, + ]) + }) + + test("should handle URL-based images", () => { + const result = convertToCopilotMessages([ + { + role: "user", + content: [ + { + type: "file", + data: new URL("https://example.com/image.jpg"), + mediaType: "image/*", + }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: "user", + content: [ + { + type: "image_url", + image_url: { url: "https://example.com/image.jpg" }, + }, + ], + }, + ]) + }) + + test("should handle multiple text parts without flattening", () => { + const result = convertToCopilotMessages([ + { + role: "user", + content: [ + { type: "text", text: "Part 1" }, + { type: "text", text: "Part 2" }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "Part 1" }, + { type: "text", text: "Part 2" }, + ], + }, + ]) + }) +}) + +describe("assistant messages", () => { + test("should convert assistant text messages", () => { + const result = convertToCopilotMessages([ + { + role: "assistant", + content: [{ type: "text", text: "Hello back!" }], + }, + ]) + + expect(result).toEqual([ + { + role: "assistant", + content: "Hello back!", + tool_calls: undefined, + reasoning_text: undefined, + reasoning_opaque: undefined, + }, + ]) + }) + + test("should handle assistant message with null content when only tool calls", () => { + const result = convertToCopilotMessages([ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call1", + toolName: "calculator", + input: { a: 1, b: 2 }, + }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "call1", + type: "function", + function: { + name: "calculator", + arguments: JSON.stringify({ a: 1, b: 2 }), + }, + }, + ], + reasoning_text: undefined, + reasoning_opaque: undefined, + }, + ]) + }) + + test("should concatenate multiple text parts", () => { + const result = convertToCopilotMessages([ + { + role: "assistant", + content: [ + { type: "text", text: "First part. " }, + { type: "text", text: "Second part." }, + ], + }, + ]) + + expect(result[0].content).toBe("First part. Second part.") + }) +}) + +describe("tool calls", () => { + test("should stringify arguments to tool calls", () => { + const result = convertToCopilotMessages([ + { + role: "assistant", + content: [ + { + type: "tool-call", + input: { foo: "bar123" }, + toolCallId: "quux", + toolName: "thwomp", + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "quux", + toolName: "thwomp", + output: { type: "json", value: { oof: "321rab" } }, + }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: "assistant", + content: null, + tool_calls: [ + { + id: "quux", + type: "function", + function: { + name: "thwomp", + arguments: JSON.stringify({ foo: "bar123" }), + }, + }, + ], + reasoning_text: undefined, + reasoning_opaque: undefined, + }, + { + role: "tool", + tool_call_id: "quux", + content: JSON.stringify({ oof: "321rab" }), + }, + ]) + }) + + test("should handle text output type in tool results", () => { + const result = convertToCopilotMessages([ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "getWeather", + output: { type: "text", value: "It is sunny today" }, + }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: "tool", + tool_call_id: "call-1", + content: "It is sunny today", + }, + ]) + }) + + test("should handle multiple tool results as separate messages", () => { + const result = convertToCopilotMessages([ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call1", + toolName: "api1", + output: { type: "text", value: "Result 1" }, + }, + { + type: "tool-result", + toolCallId: "call2", + toolName: "api2", + output: { type: "text", value: "Result 2" }, + }, + ], + }, + ]) + + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call1", + content: "Result 1", + }) + expect(result[1]).toEqual({ + role: "tool", + tool_call_id: "call2", + content: "Result 2", + }) + }) + + test("should handle text plus multiple tool calls", () => { + const result = convertToCopilotMessages([ + { + role: "assistant", + content: [ + { type: "text", text: "Checking... " }, + { + type: "tool-call", + toolCallId: "call1", + toolName: "searchTool", + input: { query: "Weather" }, + }, + { type: "text", text: "Almost there..." }, + { + type: "tool-call", + toolCallId: "call2", + toolName: "mapsTool", + input: { location: "Paris" }, + }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: "assistant", + content: "Checking... Almost there...", + tool_calls: [ + { + id: "call1", + type: "function", + function: { + name: "searchTool", + arguments: JSON.stringify({ query: "Weather" }), + }, + }, + { + id: "call2", + type: "function", + function: { + name: "mapsTool", + arguments: JSON.stringify({ location: "Paris" }), + }, + }, + ], + reasoning_text: undefined, + reasoning_opaque: undefined, + }, + ]) + }) +}) + +describe("reasoning (copilot-specific)", () => { + test("should include reasoning_text from reasoning part", () => { + const result = convertToCopilotMessages([ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Let me think about this..." }, + { type: "text", text: "The answer is 42." }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: "assistant", + content: "The answer is 42.", + tool_calls: undefined, + reasoning_text: "Let me think about this...", + reasoning_opaque: undefined, + }, + ]) + }) + + test("should include reasoning_opaque from providerOptions", () => { + const result = convertToCopilotMessages([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Thinking...", + providerOptions: { + copilot: { reasoningOpaque: "opaque-signature-123" }, + }, + }, + { type: "text", text: "Done!" }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: "assistant", + content: "Done!", + tool_calls: undefined, + reasoning_text: "Thinking...", + reasoning_opaque: "opaque-signature-123", + }, + ]) + }) + + test("should handle reasoning-only assistant message", () => { + const result = convertToCopilotMessages([ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Just thinking, no response yet", + providerOptions: { + copilot: { reasoningOpaque: "sig-abc" }, + }, + }, + ], + }, + ]) + + expect(result).toEqual([ + { + role: "assistant", + content: null, + tool_calls: undefined, + reasoning_text: "Just thinking, no response yet", + reasoning_opaque: "sig-abc", + }, + ]) + }) +}) + +describe("full conversation", () => { + test("should convert a multi-turn conversation with reasoning", () => { + const result = convertToCopilotMessages([ + { + role: "system", + content: "You are a helpful assistant.", + }, + { + role: "user", + content: [{ type: "text", text: "What is 2+2?" }], + }, + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Let me calculate 2+2...", + providerOptions: { + copilot: { reasoningOpaque: "sig-abc" }, + }, + }, + { type: "text", text: "2+2 equals 4." }, + ], + }, + { + role: "user", + content: [{ type: "text", text: "What about 3+3?" }], + }, + ]) + + expect(result).toHaveLength(4) + + const systemMsg = result[0]; + expect(systemMsg.role).toBe("system") + + // Assistant message should have reasoning fields + const assistantMsg = result[2] as { + reasoning_text?: string + reasoning_opaque?: string + } + expect(assistantMsg.reasoning_text).toBe("Let me calculate 2+2...") + expect(assistantMsg.reasoning_opaque).toBe("sig-abc") + }) +}) diff --git a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts b/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts new file mode 100644 index 000000000..f2cd2d50d --- /dev/null +++ b/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts @@ -0,0 +1,555 @@ +import { OpenAICompatibleChatLanguageModel } from "@/provider/sdk/copilot/chat/openai-compatible-chat-language-model" +import { describe, test, expect, mock } from "bun:test" +import type { LanguageModelV2Prompt } from "@ai-sdk/provider" + +async function convertReadableStreamToArray(stream: ReadableStream): Promise { + const reader = stream.getReader() + const result: T[] = [] + while (true) { + const { done, value } = await reader.read() + if (done) break + result.push(value) + } + return result +} + +const TEST_PROMPT: LanguageModelV2Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] + +// Fixtures from copilot_test.exs +const FIXTURES = { + basicText: [ + `data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gemini-2.0-flash-001","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gemini-2.0-flash-001","choices":[{"index":0,"delta":{"content":" world"},"finish_reason":null}]}`, + `data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1677652288,"model":"gemini-2.0-flash-001","choices":[{"index":0,"delta":{"content":"!"},"finish_reason":"stop"}]}`, + `data: [DONE]`, + ], + + reasoningWithToolCalls: [ + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Understanding Dayzee's Purpose**\\n\\nI'm starting to get a better handle on \`dayzee\`.\\n\\n"}}],"created":1764940861,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`, + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Assessing Dayzee's Functionality**\\n\\nI've reviewed the files.\\n\\n"}}],"created":1764940862,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`, + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"filePath\\":\\"/README.md\\"}","name":"read_file"},"id":"call_abc123","index":0,"type":"function"}],"reasoning_opaque":"4CUQ6696CwSXOdQ5rtvDimqA91tBzfmga4ieRbmZ5P67T2NLW3"}}],"created":1764940862,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`, + `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"filePath\\":\\"/mix.exs\\"}","name":"read_file"},"id":"call_def456","index":1,"type":"function"}]}}],"created":1764940862,"id":"OdwyabKMI9yel7oPlbzgwQM","usage":{"completion_tokens":53,"prompt_tokens":19581,"prompt_tokens_details":{"cached_tokens":17068},"total_tokens":19768,"reasoning_tokens":134},"model":"gemini-3-pro-preview"}`, + `data: [DONE]`, + ], + + reasoningWithOpaqueAtEnd: [ + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Analyzing the Inquiry's Nature**\\n\\nI'm currently parsing the user's question.\\n\\n"}}],"created":1765201729,"id":"Ptc2afqsCIHqlOoP653UiAI","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`, + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Reconciling User's Input**\\n\\nI'm grappling with the context.\\n\\n"}}],"created":1765201730,"id":"Ptc2afqsCIHqlOoP653UiAI","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`, + `data: {"choices":[{"index":0,"delta":{"content":"I am Tidewave, a highly skilled AI coding agent.\\n\\n","role":"assistant"}}],"created":1765201730,"id":"Ptc2afqsCIHqlOoP653UiAI","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`, + `data: {"choices":[{"finish_reason":"stop","index":0,"delta":{"content":"How can I help you?","role":"assistant","reasoning_opaque":"/PMlTqxqSJZnUBDHgnnJKLVI4eZQ"}}],"created":1765201730,"id":"Ptc2afqsCIHqlOoP653UiAI","usage":{"completion_tokens":59,"prompt_tokens":5778,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":5932,"reasoning_tokens":95},"model":"gemini-3-pro-preview"}`, + `data: [DONE]`, + ], + + // Case where reasoning_opaque and content come in the SAME chunk + reasoningWithOpaqueAndContentSameChunk: [ + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Understanding the Query's Nature**\\n\\nI'm currently grappling with the user's philosophical query.\\n\\n"}}],"created":1766062103,"id":"FPhDacixL9zrlOoPqLSuyQ4","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`, + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Framing the Response's Core**\\n\\nNow, I'm structuring my response.\\n\\n"}}],"created":1766062103,"id":"FPhDacixL9zrlOoPqLSuyQ4","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`, + `data: {"choices":[{"index":0,"delta":{"content":"Of course. I'm thinking right now.","role":"assistant","reasoning_opaque":"ExXaGwW7jBo39OXRe9EPoFGN1rOtLJBx"}}],"created":1766062103,"id":"FPhDacixL9zrlOoPqLSuyQ4","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`, + `data: {"choices":[{"finish_reason":"stop","index":0,"delta":{"content":" What's on your mind?","role":"assistant"}}],"created":1766062103,"id":"FPhDacixL9zrlOoPqLSuyQ4","usage":{"completion_tokens":78,"prompt_tokens":3767,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":3915,"reasoning_tokens":70},"model":"gemini-2.5-pro"}`, + `data: [DONE]`, + ], + + // Case where reasoning_opaque and content come in same chunk, followed by tool calls + reasoningWithOpaqueContentAndToolCalls: [ + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Analyzing the Structure**\\n\\nI'm currently trying to get a handle on the project's layout. My initial focus is on the file structure itself, specifically the directory organization. I'm hoping this will illuminate how different components interact. I'll need to identify the key modules and their dependencies.\\n\\n\\n"}}],"created":1766066995,"id":"MQtEafqbFYTZsbwPwuCVoAg","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`, + `data: {"choices":[{"index":0,"delta":{"content":"Okay, I need to check out the project's file structure.","role":"assistant","reasoning_opaque":"WHOd3dYFnxEBOsKUXjbX6c2rJa0fS214FHbsj+A3Q+i63SFo7H/92RsownAzyo0h2qEy3cOcrvAatsMx51eCKiMSqt4dYWZhd5YVSgF0CehkpDbWBP/SoRqLU1dhCmUJV/6b5uYFBOzKLBGNadyhI7T1gWFlXntwc6SNjH6DujnFPeVr+L8DdOoUJGJrw2aOfm9NtkXA6wZh9t7dt+831yIIImjD9MHczuXoXj8K7tyLpIJ9KlVXMhnO4IKSYNdKRtoHlGTmudAp5MgH/vLWb6oSsL+ZJl/OdF3WBOeanGhYNoByCRDSvR7anAR/9m5zf9yUax+u/nFg+gzmhFacnzZGtSmcvJ4/4HWKNtUkRASTKeN94DXB8j1ptB/i6ldaMAz2ZyU+sbjPWI8aI4fKJ2MuO01u3uE87xVwpWiM+0rahIzJsllI5edwOaOFtF4tnlCTQafbxHwCZR62uON2E+IjGzW80MzyfYrbLBJKS5zTeHCgPYQSNaKzPfpzkQvdwo3JUnJYcEHgGeKzkq5sbvS5qitCYI7Xue0V98S6/KnUSPnDQBjNnas2i6BqJV2vuCEU/Y3ucrlKVbuRIFCZXCyLzrsGeRLRKlrf5S/HDAQ04IOPQVQhBPvhX0nDjhZB"}}],"created":1766066995,"id":"MQtEafqbFYTZsbwPwuCVoAg","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-2.5-pro"}`, + `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"list_project_files"},"id":"call_MHxqRDd5WVo3NU8wUXRaMmc0MFE","index":0,"type":"function"}]}}],"created":1766066995,"id":"MQtEafqbFYTZsbwPwuCVoAg","usage":{"completion_tokens":19,"prompt_tokens":3767,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":3797,"reasoning_tokens":11},"model":"gemini-2.5-pro"}`, + `data: [DONE]`, + ], + + // Case where reasoning goes directly to tool_calls with NO content + // reasoning_opaque and tool_calls come in the same chunk + reasoningDirectlyToToolCalls: [ + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Executing and Analyzing HTML**\\n\\nI've successfully captured the HTML snapshot using the \`browser_eval\` tool, giving me a solid understanding of the page structure. Now, I'm shifting focus to Elixir code execution with \`project_eval\` to assess my ability to work within the project's environment.\\n\\n\\n"}}],"created":1766068643,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`, + `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","reasoning_text":"**Testing Project Contexts**\\n\\nI've got the HTML body snapshot from \`browser_eval\`, which is a helpful reference. Next, I'm testing my ability to run Elixir code in the project with \`project_eval\`. I'm starting with a simple sum: \`1 + 1\`. This will confirm I'm set up to interact with the project's codebase.\\n\\n\\n"}}],"created":1766068644,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-pro-preview"}`, + `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"code\\":\\"1 + 1\\"}","name":"project_eval"},"id":"call_MHw3RDhmT1J5Z3B6WlhpVjlveTc","index":0,"type":"function"}],"reasoning_opaque":"ytGNWFf2doK38peANDvm7whkLPKrd+Fv6/k34zEPBF6Qwitj4bTZT0FBXleydLb6"}}],"created":1766068644,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":12,"prompt_tokens":8677,"prompt_tokens_details":{"cached_tokens":3692},"total_tokens":8768,"reasoning_tokens":79},"model":"gemini-3-pro-preview"}`, + `data: [DONE]`, + ], +} + +function createMockFetch(chunks: string[]) { + return mock(async () => { + const body = new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(new TextEncoder().encode(chunk + "\n\n")) + } + controller.close() + }, + }) + + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }) + }) +} + +function createModel(fetchFn: ReturnType) { + return new OpenAICompatibleChatLanguageModel("test-model", { + provider: "copilot.chat", + url: () => "https://api.test.com/chat/completions", + headers: () => ({ Authorization: "Bearer test-token" }), + fetch: fetchFn as any, + }) +} + +describe("doStream", () => { + test("should stream text deltas", async () => { + const mockFetch = createMockFetch(FIXTURES.basicText) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + }) + + const parts = await convertReadableStreamToArray(stream) + + // Filter to just the key events + const textParts = parts.filter( + (p) => p.type === "text-start" || p.type === "text-delta" || p.type === "text-end" || p.type === "finish", + ) + + expect(textParts).toMatchObject([ + { type: "text-start", id: "txt-0" }, + { type: "text-delta", id: "txt-0", delta: "Hello" }, + { type: "text-delta", id: "txt-0", delta: " world" }, + { type: "text-delta", id: "txt-0", delta: "!" }, + { type: "text-end", id: "txt-0" }, + { type: "finish", finishReason: "stop" }, + ]) + }) + + test("should stream reasoning with tool calls and capture reasoning_opaque", async () => { + const mockFetch = createMockFetch(FIXTURES.reasoningWithToolCalls) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + }) + + const parts = await convertReadableStreamToArray(stream) + + // Check reasoning parts + const reasoningParts = parts.filter( + (p) => p.type === "reasoning-start" || p.type === "reasoning-delta" || p.type === "reasoning-end", + ) + + expect(reasoningParts[0]).toEqual({ + type: "reasoning-start", + id: "reasoning-0", + }) + + expect(reasoningParts[1]).toMatchObject({ + type: "reasoning-delta", + id: "reasoning-0", + }) + expect((reasoningParts[1] as { delta: string }).delta).toContain("**Understanding Dayzee's Purpose**") + + expect(reasoningParts[2]).toMatchObject({ + type: "reasoning-delta", + id: "reasoning-0", + }) + expect((reasoningParts[2] as { delta: string }).delta).toContain("**Assessing Dayzee's Functionality**") + + // reasoning_opaque should be in reasoning-end providerMetadata + const reasoningEnd = reasoningParts.find((p) => p.type === "reasoning-end") + expect(reasoningEnd).toMatchObject({ + type: "reasoning-end", + id: "reasoning-0", + providerMetadata: { + copilot: { + reasoningOpaque: "4CUQ6696CwSXOdQ5rtvDimqA91tBzfmga4ieRbmZ5P67T2NLW3", + }, + }, + }) + + // Check tool calls + const toolParts = parts.filter( + (p) => p.type === "tool-input-start" || p.type === "tool-call" || p.type === "tool-input-end", + ) + + expect(toolParts).toContainEqual({ + type: "tool-input-start", + id: "call_abc123", + toolName: "read_file", + }) + + expect(toolParts).toContainEqual( + expect.objectContaining({ + type: "tool-call", + toolCallId: "call_abc123", + toolName: "read_file", + }), + ) + + expect(toolParts).toContainEqual({ + type: "tool-input-start", + id: "call_def456", + toolName: "read_file", + }) + + // Check finish + const finish = parts.find((p) => p.type === "finish") + expect(finish).toMatchObject({ + type: "finish", + finishReason: "tool-calls", + usage: { + inputTokens: 19581, + outputTokens: 53, + }, + }) + }) + + test("should handle reasoning_opaque that comes at end with text in between", async () => { + const mockFetch = createMockFetch(FIXTURES.reasoningWithOpaqueAtEnd) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + }) + + const parts = await convertReadableStreamToArray(stream) + + // Check that reasoning comes first + const reasoningStart = parts.findIndex((p) => p.type === "reasoning-start") + const textStart = parts.findIndex((p) => p.type === "text-start") + expect(reasoningStart).toBeLessThan(textStart) + + // Check reasoning deltas + const reasoningDeltas = parts.filter((p) => p.type === "reasoning-delta") + expect(reasoningDeltas).toHaveLength(2) + expect((reasoningDeltas[0] as { delta: string }).delta).toContain("**Analyzing the Inquiry's Nature**") + expect((reasoningDeltas[1] as { delta: string }).delta).toContain("**Reconciling User's Input**") + + // Check text deltas + const textDeltas = parts.filter((p) => p.type === "text-delta") + expect(textDeltas).toHaveLength(2) + expect((textDeltas[0] as { delta: string }).delta).toContain("I am Tidewave") + expect((textDeltas[1] as { delta: string }).delta).toContain("How can I help you?") + + // reasoning-end should be emitted before text-start + const reasoningEndIndex = parts.findIndex((p) => p.type === "reasoning-end") + const textStartIndex = parts.findIndex((p) => p.type === "text-start") + expect(reasoningEndIndex).toBeGreaterThan(-1) + expect(reasoningEndIndex).toBeLessThan(textStartIndex) + + // In this fixture, reasoning_opaque comes AFTER content has started (in chunk 4) + // So it arrives too late to be attached to reasoning-end. But it should still + // be captured and included in the finish event's providerMetadata. + const reasoningEnd = parts.find((p) => p.type === "reasoning-end") + expect(reasoningEnd).toMatchObject({ + type: "reasoning-end", + id: "reasoning-0", + }) + + // reasoning_opaque should be in the finish event's providerMetadata + const finish = parts.find((p) => p.type === "finish") + expect(finish).toMatchObject({ + type: "finish", + finishReason: "stop", + usage: { + inputTokens: 5778, + outputTokens: 59, + }, + providerMetadata: { + copilot: { + reasoningOpaque: "/PMlTqxqSJZnUBDHgnnJKLVI4eZQ", + }, + }, + }) + }) + + test("should handle reasoning_opaque and content in the same chunk", async () => { + const mockFetch = createMockFetch(FIXTURES.reasoningWithOpaqueAndContentSameChunk) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + }) + + const parts = await convertReadableStreamToArray(stream) + + // The critical test: reasoning-end should come BEFORE text-start + const reasoningEndIndex = parts.findIndex((p) => p.type === "reasoning-end") + const textStartIndex = parts.findIndex((p) => p.type === "text-start") + expect(reasoningEndIndex).toBeGreaterThan(-1) + expect(textStartIndex).toBeGreaterThan(-1) + expect(reasoningEndIndex).toBeLessThan(textStartIndex) + + // Check reasoning deltas + const reasoningDeltas = parts.filter((p) => p.type === "reasoning-delta") + expect(reasoningDeltas).toHaveLength(2) + expect((reasoningDeltas[0] as { delta: string }).delta).toContain("**Understanding the Query's Nature**") + expect((reasoningDeltas[1] as { delta: string }).delta).toContain("**Framing the Response's Core**") + + // reasoning_opaque should be in reasoning-end even though it came with content + const reasoningEnd = parts.find((p) => p.type === "reasoning-end") + expect(reasoningEnd).toMatchObject({ + type: "reasoning-end", + id: "reasoning-0", + providerMetadata: { + copilot: { + reasoningOpaque: "ExXaGwW7jBo39OXRe9EPoFGN1rOtLJBx", + }, + }, + }) + + // Check text deltas + const textDeltas = parts.filter((p) => p.type === "text-delta") + expect(textDeltas).toHaveLength(2) + expect((textDeltas[0] as { delta: string }).delta).toContain("Of course. I'm thinking right now.") + expect((textDeltas[1] as { delta: string }).delta).toContain("What's on your mind?") + + // Check finish + const finish = parts.find((p) => p.type === "finish") + expect(finish).toMatchObject({ + type: "finish", + finishReason: "stop", + }) + }) + + test("should handle reasoning_opaque and content followed by tool calls", async () => { + const mockFetch = createMockFetch(FIXTURES.reasoningWithOpaqueContentAndToolCalls) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + }) + + const parts = await convertReadableStreamToArray(stream) + + // Check that reasoning comes first, then text, then tool calls + const reasoningEndIndex = parts.findIndex((p) => p.type === "reasoning-end") + const textStartIndex = parts.findIndex((p) => p.type === "text-start") + const toolStartIndex = parts.findIndex((p) => p.type === "tool-input-start") + + expect(reasoningEndIndex).toBeGreaterThan(-1) + expect(textStartIndex).toBeGreaterThan(-1) + expect(toolStartIndex).toBeGreaterThan(-1) + expect(reasoningEndIndex).toBeLessThan(textStartIndex) + expect(textStartIndex).toBeLessThan(toolStartIndex) + + // Check reasoning content + const reasoningDeltas = parts.filter((p) => p.type === "reasoning-delta") + expect(reasoningDeltas).toHaveLength(1) + expect((reasoningDeltas[0] as { delta: string }).delta).toContain("**Analyzing the Structure**") + + // reasoning_opaque should be in reasoning-end (comes with content in same chunk) + const reasoningEnd = parts.find((p) => p.type === "reasoning-end") + expect(reasoningEnd).toMatchObject({ + type: "reasoning-end", + id: "reasoning-0", + providerMetadata: { + copilot: { + reasoningOpaque: expect.stringContaining("WHOd3dYFnxEBOsKUXjbX6c2rJa0fS214"), + }, + }, + }) + + // Check text content + const textDeltas = parts.filter((p) => p.type === "text-delta") + expect(textDeltas).toHaveLength(1) + expect((textDeltas[0] as { delta: string }).delta).toContain("Okay, I need to check out the project's file structure.") + + // Check tool call + const toolParts = parts.filter( + (p) => p.type === "tool-input-start" || p.type === "tool-call" || p.type === "tool-input-end", + ) + + expect(toolParts).toContainEqual({ + type: "tool-input-start", + id: "call_MHxqRDd5WVo3NU8wUXRaMmc0MFE", + toolName: "list_project_files", + }) + + expect(toolParts).toContainEqual( + expect.objectContaining({ + type: "tool-call", + toolCallId: "call_MHxqRDd5WVo3NU8wUXRaMmc0MFE", + toolName: "list_project_files", + }), + ) + + // Check finish + const finish = parts.find((p) => p.type === "finish") + expect(finish).toMatchObject({ + type: "finish", + finishReason: "tool-calls", + usage: { + inputTokens: 3767, + outputTokens: 19, + }, + }) + }) + + test("should emit reasoning-end before tool-input-start when reasoning goes directly to tool calls", async () => { + const mockFetch = createMockFetch(FIXTURES.reasoningDirectlyToToolCalls) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + }) + + const parts = await convertReadableStreamToArray(stream) + + // Critical check: reasoning-end MUST come before tool-input-start + const reasoningEndIndex = parts.findIndex((p) => p.type === "reasoning-end") + const toolStartIndex = parts.findIndex((p) => p.type === "tool-input-start") + + expect(reasoningEndIndex).toBeGreaterThan(-1) + expect(toolStartIndex).toBeGreaterThan(-1) + expect(reasoningEndIndex).toBeLessThan(toolStartIndex) + + // Check reasoning parts + const reasoningDeltas = parts.filter((p) => p.type === "reasoning-delta") + expect(reasoningDeltas).toHaveLength(2) + expect((reasoningDeltas[0] as { delta: string }).delta).toContain("**Executing and Analyzing HTML**") + expect((reasoningDeltas[1] as { delta: string }).delta).toContain("**Testing Project Contexts**") + + // reasoning_opaque should be in reasoning-end providerMetadata + const reasoningEnd = parts.find((p) => p.type === "reasoning-end") + expect(reasoningEnd).toMatchObject({ + type: "reasoning-end", + id: "reasoning-0", + providerMetadata: { + copilot: { + reasoningOpaque: "ytGNWFf2doK38peANDvm7whkLPKrd+Fv6/k34zEPBF6Qwitj4bTZT0FBXleydLb6", + }, + }, + }) + + // No text parts should exist + const textParts = parts.filter((p) => p.type === "text-start" || p.type === "text-delta" || p.type === "text-end") + expect(textParts).toHaveLength(0) + + // Check tool call + const toolCall = parts.find((p) => p.type === "tool-call") + expect(toolCall).toMatchObject({ + type: "tool-call", + toolCallId: "call_MHw3RDhmT1J5Z3B6WlhpVjlveTc", + toolName: "project_eval", + }) + + // Check finish + const finish = parts.find((p) => p.type === "finish") + expect(finish).toMatchObject({ + type: "finish", + finishReason: "tool-calls", + }) + }) + + test("should include response metadata from first chunk", async () => { + const mockFetch = createMockFetch(FIXTURES.basicText) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + }) + + const parts = await convertReadableStreamToArray(stream) + + const metadata = parts.find((p) => p.type === "response-metadata") + expect(metadata).toMatchObject({ + type: "response-metadata", + id: "chatcmpl-123", + modelId: "gemini-2.0-flash-001", + }) + }) + + test("should emit stream-start with warnings", async () => { + const mockFetch = createMockFetch(FIXTURES.basicText) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: false, + }) + + const parts = await convertReadableStreamToArray(stream) + + const streamStart = parts.find((p) => p.type === "stream-start") + expect(streamStart).toEqual({ + type: "stream-start", + warnings: [], + }) + }) + + test("should include raw chunks when requested", async () => { + const mockFetch = createMockFetch(FIXTURES.basicText) + const model = createModel(mockFetch) + + const { stream } = await model.doStream({ + prompt: TEST_PROMPT, + includeRawChunks: true, + }) + + const parts = await convertReadableStreamToArray(stream) + + const rawChunks = parts.filter((p) => p.type === "raw") + expect(rawChunks.length).toBeGreaterThan(0) + }) +}) + +describe("request body", () => { + test("should send tools in OpenAI format", async () => { + let capturedBody: unknown + const mockFetch = mock(async (_url: string, init?: RequestInit) => { + capturedBody = JSON.parse(init?.body as string) + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`data: [DONE]\n\n`)) + controller.close() + }, + }), + { status: 200, headers: { "Content-Type": "text/event-stream" } }, + ) + }) + + const model = createModel(mockFetch) + + await model.doStream({ + prompt: TEST_PROMPT, + tools: [ + { + type: "function", + name: "get_weather", + description: "Get the weather for a location", + inputSchema: { + type: "object", + properties: { + location: { type: "string" }, + }, + required: ["location"], + }, + }, + ], + includeRawChunks: false, + }) + + expect((capturedBody as { tools: unknown[] }).tools).toEqual([ + { + type: "function", + function: { + name: "get_weather", + description: "Get the weather for a location", + parameters: { + type: "object", + properties: { + location: { type: "string" }, + }, + required: ["location"], + }, + }, + }, + ]) + }) +}) diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index b1e0c9a61..d483539f1 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1101,21 +1101,21 @@ describe("ProviderTransform.message - providerOptions key remapping", () => { expect(result[0].providerOptions?.openai).toBeUndefined() }) - test("openai with github-copilot npm remaps providerID to 'openai'", () => { + test("copilot remaps providerID to 'copilot' key", () => { const model = createModel("github-copilot", "@ai-sdk/github-copilot") const msgs = [ { role: "user", content: "Hello", providerOptions: { - "github-copilot": { someOption: "value" }, + "copilot": { someOption: "value" }, }, }, ] as any[] const result = ProviderTransform.message(msgs, model, {}) - expect(result[0].providerOptions?.openai).toEqual({ someOption: "value" }) + expect(result[0].providerOptions?.copilot).toEqual({ someOption: "value" }) expect(result[0].providerOptions?.["github-copilot"]).toBeUndefined() }) -- cgit v1.2.3