diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index ee7ee75c9f5e..85a9618605ec 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -132,8 +132,9 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { - if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID) - return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) + if (shouldUseCopilotResponsesApi(modelID)) return sdk.responses(modelID) + if (sdk.copilotChat) return sdk.copilotChat(modelID) + return sdk.languageModel(modelID) }, options: {}, } @@ -142,8 +143,9 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { - if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID) - return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) + if (shouldUseCopilotResponsesApi(modelID)) return sdk.responses(modelID) + if (sdk.copilotChat) return sdk.copilotChat(modelID) + return sdk.languageModel(modelID) }, options: {}, } diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/chat/convert-to-copilot-chat-input.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/convert-to-copilot-chat-input.ts new file mode 100644 index 000000000000..1e75fdf64aad --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/convert-to-copilot-chat-input.ts @@ -0,0 +1,192 @@ +import { UnsupportedFunctionalityError, type LanguageModelV2Prompt } from "@ai-sdk/provider" +import { parseProviderOptions } from "@ai-sdk/provider-utils" +import { z } from "zod/v4" + +export const copilotChatProviderOptionsSchema = z.object({ + reasoning_opaque: z.string().nullish(), + reasoning_text: z.string().nullish(), +}) + +export type CopilotChatProviderOptions = z.infer + +type CopilotChatToolCall = { + id: string + type: "function" + function: { + name: string + arguments: string + } +} + +type CopilotChatMessage = + | { + role: "system" | "user" + content: string + } + | { + role: "assistant" + content: string | null + tool_calls?: CopilotChatToolCall[] + reasoning_opaque?: string | null + reasoning_text?: string | null + } + | { + role: "tool" + tool_call_id: string + content: string + } + +function toArgument(value: unknown): string { + if (typeof value === "string") return value + return JSON.stringify(value) +} + +function toToolOutput(output: { type: string; value: unknown }): string { + if (output.type === "text" || output.type === "error-text") { + return String(output.value) + } + + if (output.type === "content" || output.type === "json" || output.type === "error-json") { + return JSON.stringify(output.value) + } + + throw new UnsupportedFunctionalityError({ + functionality: `tool output type ${output.type}`, + }) +} + +function toParts(content: unknown): Array<{ type: string; [key: string]: unknown }> { + if (typeof content === "string") return [{ type: "text", text: content }] + if (Array.isArray(content)) return content + return [] +} + +export async function convertToCopilotChatMessages( + messages: LanguageModelV2Prompt, + providerOptions?: Record, +): Promise { + const base = await parseProviderOptions({ + provider: "openaiCompatible", + providerOptions, + schema: copilotChatProviderOptionsSchema, + }) + + const output: CopilotChatMessage[] = [] + + for (const message of messages) { + if (message.role === "system" || message.role === "user") { + const parts = toParts(message.content) + const texts = parts.filter((part) => part.type === "text") + + if (texts.length === 0 && parts.length > 0) { + throw new UnsupportedFunctionalityError({ + functionality: `message part type for ${message.role}`, + }) + } + + output.push({ + role: message.role, + content: texts.map((part) => String(part.text ?? "")).join(""), + }) + continue + } + + if (message.role === "assistant") { + const parts = toParts(message.content) + const calls: CopilotChatToolCall[] = [] + const texts: string[] = [] + + for (const part of parts) { + if (part.type === "text") { + texts.push(String(part.text ?? "")) + continue + } + + if (part.type === "tool-call") { + if (part.providerExecuted) continue + calls.push({ + id: String(part.toolCallId ?? ""), + type: "function", + function: { + name: String(part.toolName ?? ""), + arguments: toArgument(part.input), + }, + }) + continue + } + + throw new UnsupportedFunctionalityError({ + functionality: `assistant message part type ${part.type}`, + }) + } + + const options = await parseProviderOptions({ + provider: "openaiCompatible", + providerOptions: message.providerOptions ?? providerOptions, + schema: copilotChatProviderOptionsSchema, + }) + + const opaque = options?.reasoning_opaque ?? base?.reasoning_opaque + const reasoning = options?.reasoning_text ?? base?.reasoning_text + + const next: CopilotChatMessage = { + role: "assistant", + content: texts.length > 0 ? texts.join("") : null, + } + + if (calls.length > 0) { + next.tool_calls = calls + } + + if (opaque != null) { + next.reasoning_opaque = opaque + } + + if (reasoning != null) { + next.reasoning_text = reasoning + } + + output.push(next) + continue + } + + if (message.role === "tool") { + if (typeof message.content === "string") { + const call = "toolCallId" in message ? message.toolCallId : undefined + if (typeof call !== "string") { + throw new UnsupportedFunctionalityError({ + functionality: "tool message without tool_call_id", + }) + } + output.push({ role: "tool", tool_call_id: call, content: message.content }) + continue + } + + if (!Array.isArray(message.content)) { + throw new UnsupportedFunctionalityError({ + functionality: "tool message content", + }) + } + + for (const part of message.content) { + if (part.type !== "tool-result") { + throw new UnsupportedFunctionalityError({ + functionality: `tool message part type ${part.type}`, + }) + } + + output.push({ + role: "tool", + tool_call_id: String(part.toolCallId ?? ""), + content: toToolOutput(part.output), + }) + } + + continue + } + + throw new Error("Unsupported role") + } + + return output +} diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/chat/copilot-chat-api-types.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/copilot-chat-api-types.ts new file mode 100644 index 000000000000..75afc39d9389 --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/copilot-chat-api-types.ts @@ -0,0 +1,67 @@ +import { z } from "zod/v4" + +const copilotChatToolCallFunctionSchema = z.object({ + name: z.string().optional(), + arguments: z.string().optional(), +}) + +const copilotChatToolCallSchema = z.object({ + index: z.number().optional(), + id: z.string().optional(), + type: z.literal("function").optional(), + function: copilotChatToolCallFunctionSchema.optional(), +}) + +const copilotChatDeltaSchema = z.object({ + role: z.string().optional(), + content: z.string().nullish(), + reasoning_opaque: z.string().nullish(), + reasoning_text: z.string().nullish(), + tool_calls: z.array(copilotChatToolCallSchema).optional(), +}) + +const promptTokensDetailsSchema = z.object({ + cached_tokens: z.number().optional(), + audio_tokens: z.number().optional(), +}) + +const completionTokensDetailsSchema = z.object({ + reasoning_tokens: z.number().optional(), + accepted_prediction_tokens: z.number().optional(), + rejected_prediction_tokens: z.number().optional(), + audio_tokens: z.number().optional(), +}) + +export const copilotChatUsageSchema = z.object({ + prompt_tokens: z.number(), + completion_tokens: z.number(), + total_tokens: z.number(), + prompt_tokens_details: promptTokensDetailsSchema.nullish(), + completion_tokens_details: completionTokensDetailsSchema.nullish(), +}) + +export const copilotChatChunkSchema = z.object({ + id: z.string(), + created: z.number(), + model: z.string().optional(), + choices: z.array( + z.object({ + delta: copilotChatDeltaSchema, + finish_reason: z.string().nullish(), + }), + ), + usage: copilotChatUsageSchema.nullish(), +}) + +export const copilotChatResponseSchema = z.object({ + id: z.string(), + created: z.number(), + model: z.string().optional(), + choices: z.array( + z.object({ + message: copilotChatDeltaSchema, + finish_reason: z.string().nullish(), + }), + ), + usage: copilotChatUsageSchema.nullish(), +}) diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/chat/copilot-chat-language-model.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/copilot-chat-language-model.ts new file mode 100644 index 000000000000..33f9a5a3395c --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/copilot-chat-language-model.ts @@ -0,0 +1,386 @@ +import { + type LanguageModelV2, + type LanguageModelV2CallWarning, + type LanguageModelV2Content, + type LanguageModelV2FinishReason, + type LanguageModelV2StreamPart, + type LanguageModelV2Usage, + type SharedV2ProviderMetadata, +} from "@ai-sdk/provider" +import { + combineHeaders, + createEventSourceResponseHandler, + createJsonResponseHandler, + generateId, + type ParseResult, + postJsonToApi, +} from "@ai-sdk/provider-utils" +import { z } from "zod/v4" +import { copilotChatChunkSchema, copilotChatResponseSchema, copilotChatUsageSchema } from "./copilot-chat-api-types" +import { convertToCopilotChatMessages } from "./convert-to-copilot-chat-input" +import { openaiFailedResponseHandler } from "../responses/openai-error" +import { OPENAI_COMPATIBLE_SUPPORTED_URLS, type OpenAIConfig } from "../responses/openai-config" +import { ProviderTransform } from "@/provider/transform" + +type CopilotChatChunk = z.infer +type CopilotChatTool = { type: "function"; function: { name: string; description?: string; parameters?: unknown } } +type ToolState = { id: string; name: string; args: string; started: boolean } + +export class CopilotChatLanguageModel implements LanguageModelV2 { + readonly specificationVersion = "v2" + readonly modelId: string + readonly supportedUrls = OPENAI_COMPATIBLE_SUPPORTED_URLS + private readonly config: OpenAIConfig + + constructor(modelId: string, config: OpenAIConfig) { + this.modelId = modelId + this.config = config + } + + get provider(): string { + return this.config.provider + } + + private async getArgs(options: Parameters[0]) { + const warnings: LanguageModelV2CallWarning[] = [] + if (options.topK != null) warnings.push({ type: "unsupported-setting", setting: "topK" }) + + const messages = await convertToCopilotChatMessages(options.prompt, options.providerOptions) + const toolConfig = prepareTools({ tools: options.tools, toolChoice: options.toolChoice, modelId: this.modelId }) + warnings.push(...toolConfig.warnings) + const format = toResponseFormat(options.responseFormat) + + const body = { + model: this.modelId, + messages, + temperature: options.temperature, + top_p: options.topP, + max_tokens: options.maxOutputTokens, + presence_penalty: options.presencePenalty, + frequency_penalty: options.frequencyPenalty, + seed: options.seed, + ...(options.stopSequences != null && { stop: options.stopSequences }), + ...(toolConfig.tools && { tools: toolConfig.tools }), + ...(toolConfig.toolChoice && { tool_choice: toolConfig.toolChoice }), + ...(format && { response_format: format }), + } + + return { body, warnings } + } + + async doGenerate( + options: Parameters[0], + ): Promise>> { + const result = await this.getArgs(options) + const body = result.body + const warnings = result.warnings + const url = this.config.url({ path: "/chat/completions", modelId: this.modelId }) + + const { + responseHeaders, + value: response, + rawValue: rawResponse, + } = await postJsonToApi({ + url, + headers: combineHeaders(this.config.headers(), options.headers), + body, + failedResponseHandler: openaiFailedResponseHandler, + successfulResponseHandler: createJsonResponseHandler(copilotChatResponseSchema), + abortSignal: options.abortSignal, + fetch: this.config.fetch, + }) + + const choice = response.choices[0] + const message = choice?.message + if (!message) throw new Error("Copilot chat response missing message") + + const content: Array = [] + const reasoning = pickReasoning(message) + if (reasoning != null) { + content.push({ + type: "reasoning", + text: reasoning, + providerMetadata: reasoningMetadata(message.reasoning_opaque ?? null), + }) + } + if (typeof message.content === "string" && message.content.length > 0) { + content.push({ type: "text", text: message.content }) + } + + const toolCalls = message.tool_calls ?? [] + for (const call of toolCalls) { + const toolCallId = call.id ?? generateId() + const toolName = call.function?.name ?? "" + const input = call.function?.arguments ?? "" + content.push({ type: "tool-call", toolCallId, toolName, input }) + } + + const finishReason = mapFinishReason({ finishReason: choice.finish_reason, hasToolCall: toolCalls.length > 0 }) + const usage = toUsage(response.usage) + const providerMetadata: SharedV2ProviderMetadata = {} + if (message.reasoning_opaque != null) + providerMetadata.openaiCompatible = { reasoning_opaque: message.reasoning_opaque } + + return { + content, + finishReason, + usage, + request: { body }, + response: { + id: response.id, + timestamp: new Date(response.created * 1000), + modelId: response.model ?? this.modelId, + headers: responseHeaders, + body: rawResponse, + }, + providerMetadata, + warnings, + } + } + + async doStream( + options: Parameters[0], + ): Promise>> { + const result = await this.getArgs(options) + const body = result.body + const warnings = result.warnings + + const { responseHeaders, value: response } = await postJsonToApi({ + url: this.config.url({ path: "/chat/completions", modelId: this.modelId }), + headers: combineHeaders(this.config.headers(), options.headers), + body: { ...body, stream: true }, + failedResponseHandler: openaiFailedResponseHandler, + successfulResponseHandler: createEventSourceResponseHandler(copilotChatChunkSchema), + abortSignal: options.abortSignal, + fetch: this.config.fetch, + }) + + const state = { + textId: null as string | null, + reasoningId: null as string | null, + reasoningOpaque: null as string | null, + finishReason: "unknown" as LanguageModelV2FinishReason, + usage: emptyUsage(), + toolCalls: new Map(), + toolEmitted: false, + hasToolCall: false, + } + + const emitToolCalls = (controller: TransformStreamDefaultController) => { + if (state.toolEmitted || state.toolCalls.size === 0) return + for (const entry of state.toolCalls.values()) { + if (entry.started) controller.enqueue({ type: "tool-input-end", id: entry.id }) + controller.enqueue({ type: "tool-call", toolCallId: entry.id, toolName: entry.name, input: entry.args }) + } + state.toolEmitted = true + } + + const ensureTextStart = (controller: TransformStreamDefaultController) => { + if (state.textId) return + state.textId = generateId() + controller.enqueue({ type: "text-start", id: state.textId }) + } + + const ensureReasoningStart = (controller: TransformStreamDefaultController) => { + if (state.reasoningId) return + state.reasoningId = generateId() + controller.enqueue({ + type: "reasoning-start", + id: state.reasoningId, + providerMetadata: reasoningMetadata(state.reasoningOpaque), + }) + } + + 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 }) + if (!chunk.success) { + state.finishReason = "error" + controller.enqueue({ type: "error", error: chunk.error }) + return + } + + const value = chunk.value + const choice = value.choices[0] + if (!choice) return + const delta = choice.delta + + if (delta.reasoning_opaque != null) state.reasoningOpaque = delta.reasoning_opaque + const reasoning = pickReasoning(delta) + if (reasoning != null) { + ensureReasoningStart(controller) + controller.enqueue({ + type: "reasoning-delta", + id: state.reasoningId as string, + delta: reasoning, + providerMetadata: reasoningMetadata(state.reasoningOpaque), + }) + } + + if (typeof delta.content === "string") { + ensureTextStart(controller) + controller.enqueue({ type: "text-delta", id: state.textId as string, delta: delta.content }) + } + + if (delta.tool_calls) { + for (const call of delta.tool_calls) { + state.hasToolCall = true + const index = call.index ?? 0 + const entry = state.toolCalls.get(index) ?? { + id: call.id ?? generateId(), + name: "", + args: "", + started: false, + } + if (call.id) entry.id = call.id + if (call.function?.name) entry.name = call.function.name + + const args = typeof call.function?.arguments === "string" ? call.function.arguments : "" + if (args) entry.args += args + + const shouldStart = !entry.started && entry.name + if (shouldStart) { + entry.started = true + controller.enqueue({ type: "tool-input-start", id: entry.id, toolName: entry.name }) + if (entry.args) controller.enqueue({ type: "tool-input-delta", id: entry.id, delta: entry.args }) + } + if (entry.started && !shouldStart && args) { + controller.enqueue({ type: "tool-input-delta", id: entry.id, delta: args }) + } + + state.toolCalls.set(index, entry) + } + } + + if (value.usage) state.usage = toUsage(value.usage) + if (choice.finish_reason != null) { + state.finishReason = mapFinishReason({ + finishReason: choice.finish_reason, + hasToolCall: state.hasToolCall, + }) + emitToolCalls(controller) + } + }, + flush(controller) { + if (state.textId) { + controller.enqueue({ type: "text-end", id: state.textId }) + state.textId = null + } + if (state.reasoningId) { + controller.enqueue({ + type: "reasoning-end", + id: state.reasoningId, + providerMetadata: reasoningMetadata(state.reasoningOpaque), + }) + state.reasoningId = null + } + emitToolCalls(controller) + + const providerMetadata: SharedV2ProviderMetadata = {} + if (state.reasoningOpaque != null) + providerMetadata.openaiCompatible = { reasoning_opaque: state.reasoningOpaque } + controller.enqueue({ + type: "finish", + finishReason: state.finishReason, + usage: state.usage, + providerMetadata, + }) + }, + }), + ), + request: { body }, + response: { headers: responseHeaders }, + } + } +} + +function prepareTools(input: { + tools: Parameters[0]["tools"] + toolChoice?: Parameters[0]["toolChoice"] + modelId: string +}) { + const warnings: LanguageModelV2CallWarning[] = [] + const list = input.tools?.length ? input.tools : undefined + if (!list) return { tools: undefined, toolChoice: undefined, warnings } + + const isGemini = input.modelId.toLowerCase().includes("gemini") + + const tools: Array = [] + for (const tool of list) { + if (tool.type === "function") { + const params = isGemini ? ProviderTransform.sanitizeGeminiSchema(tool.inputSchema) : tool.inputSchema + tools.push({ + type: "function", + function: { name: tool.name, description: tool.description, parameters: params }, + }) + continue + } + warnings.push({ type: "unsupported-tool", tool }) + } + + if (!input.toolChoice) return { tools, toolChoice: undefined, warnings } + const type = input.toolChoice.type + if (type === "auto" || type === "none" || type === "required") return { tools, toolChoice: type, warnings } + if (type === "tool") { + return { tools, toolChoice: { type: "function", function: { name: input.toolChoice.toolName } }, warnings } + } + warnings.push({ type: "unsupported-setting", setting: "toolChoice" }) + return { tools, toolChoice: undefined, warnings } +} +function pickReasoning(delta: { reasoning_text?: string | null }) { + return typeof delta.reasoning_text === "string" ? delta.reasoning_text : null +} +function reasoningMetadata(opaque: string | null): SharedV2ProviderMetadata { + return { openaiCompatible: { reasoning_opaque: opaque } } +} +function toResponseFormat(responseFormat: Parameters[0]["responseFormat"]) { + if (!responseFormat || responseFormat.type !== "json") return undefined + if (responseFormat.schema) { + return { + type: "json_schema", + json_schema: { + name: responseFormat.name ?? "response", + description: responseFormat.description, + schema: responseFormat.schema, + strict: false, + }, + } + } + return { type: "json_object" } +} +function emptyUsage(): LanguageModelV2Usage { + return { + inputTokens: undefined, + outputTokens: undefined, + totalTokens: undefined, + reasoningTokens: undefined, + cachedInputTokens: undefined, + } +} +function toUsage(usage: z.infer | null | undefined): LanguageModelV2Usage { + if (!usage) return emptyUsage() + return { + inputTokens: usage.prompt_tokens, + outputTokens: usage.completion_tokens, + totalTokens: usage.total_tokens, + reasoningTokens: usage.completion_tokens_details?.reasoning_tokens ?? undefined, + cachedInputTokens: usage.prompt_tokens_details?.cached_tokens ?? undefined, + } +} +function mapFinishReason(input: { + finishReason: string | null | undefined + hasToolCall: boolean +}): LanguageModelV2FinishReason { + if (input.finishReason == null) return input.hasToolCall ? "tool-calls" : "stop" + if (input.finishReason === "stop") return "stop" + if (input.finishReason === "length") return "length" + if (input.finishReason === "content_filter") return "content-filter" + if (input.finishReason === "tool_calls") return "tool-calls" + if (input.finishReason === "function_call") return "tool-calls" + return input.hasToolCall ? "tool-calls" : "unknown" +} diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/chat/index.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/index.ts new file mode 100644 index 000000000000..d136ed6f67c1 --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/index.ts @@ -0,0 +1,3 @@ +export { CopilotChatLanguageModel } from "./copilot-chat-language-model" +export { copilotChatProviderOptionsSchema } from "./convert-to-copilot-chat-input" +export type { CopilotChatProviderOptions } from "./convert-to-copilot-chat-input" 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 index e71658c2fa0c..d25827463110 100644 --- 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 @@ -2,6 +2,7 @@ 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 { CopilotChatLanguageModel } from "./chat" // Import the version or define it const VERSION = "0.1.0" @@ -38,6 +39,7 @@ export interface OpenaiCompatibleProviderSettings { export interface OpenaiCompatibleProvider { (modelId: OpenaiCompatibleModelId): LanguageModelV2 chat(modelId: OpenaiCompatibleModelId): LanguageModelV2 + copilotChat(modelId: OpenaiCompatibleModelId): LanguageModelV2 responses(modelId: OpenaiCompatibleModelId): LanguageModelV2 languageModel(modelId: OpenaiCompatibleModelId): LanguageModelV2 @@ -83,6 +85,15 @@ export function createOpenaiCompatible(options: OpenaiCompatibleProviderSettings }) } + const createCopilotChatModel = (modelId: OpenaiCompatibleModelId) => { + return new CopilotChatLanguageModel(modelId, { + provider: `${options.name ?? "openai-compatible"}.copilotChat`, + headers: getHeaders, + url: ({ path }) => `${baseURL}${path}`, + fetch: options.fetch, + }) + } + const createLanguageModel = (modelId: OpenaiCompatibleModelId) => createChatModel(modelId) const provider = function (modelId: OpenaiCompatibleModelId) { @@ -91,6 +102,7 @@ export function createOpenaiCompatible(options: OpenaiCompatibleProviderSettings provider.languageModel = createLanguageModel provider.chat = createChatModel + provider.copilotChat = createCopilotChatModel provider.responses = createResponsesModel return provider as OpenaiCompatibleProvider 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 index 2241dbb52491..157ec7216104 100644 --- 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 @@ -7,12 +7,17 @@ export type OpenAIConfig = { 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' - */ + * 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[] } + +export const OPENAI_COMPATIBLE_SUPPORTED_URLS: Record = { + "image/*": [/^https?:\/\/.*$/], + "application/pdf": [/^https?:\/\/.*$/], +} 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 index 0990b7e0077c..a025ad9dfcff 100644 --- 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 @@ -19,7 +19,7 @@ import { postJsonToApi, } from "@ai-sdk/provider-utils" import { z } from "zod/v4" -import type { OpenAIConfig } from "./openai-config" +import { OPENAI_COMPATIBLE_SUPPORTED_URLS, type OpenAIConfig } from "./openai-config" import { openaiFailedResponseHandler } from "./openai-error" import { codeInterpreterInputSchema, codeInterpreterOutputSchema } from "./tool/code-interpreter" import { fileSearchOutputSchema } from "./tool/file-search" @@ -140,10 +140,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 { this.config = config } - readonly supportedUrls: Record = { - "image/*": [/^https?:\/\/.*$/], - "application/pdf": [/^https?:\/\/.*$/], - } + readonly supportedUrls = OPENAI_COMPATIBLE_SUPPORTED_URLS get provider(): string { return this.config.provider diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 39eef6c9165c..03541e815441 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -66,6 +66,41 @@ export namespace ProviderTransform { .filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "") } + const isCopilot = model.api.npm === "@ai-sdk/github-copilot" + if (isCopilot) { + msgs = msgs.map((msg) => { + if (msg.role !== "assistant" || !Array.isArray(msg.content)) return msg + + const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning") + const reasoningText = reasoningParts.map((part: any) => part.text).join("") + const reasoningOpaque = reasoningParts + .map((part: any) => part.providerOptions?.openaiCompatible?.reasoning_opaque) + .find((x: any) => typeof x === "string") + + const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning") + + if (!reasoningOpaque) { + return { + ...msg, + content: filteredContent, + } + } + + return { + ...msg, + content: filteredContent, + providerOptions: { + ...msg.providerOptions, + openaiCompatible: { + ...(msg.providerOptions as any)?.openaiCompatible, + ...(reasoningText ? { reasoning_text: reasoningText } : {}), + ...(reasoningOpaque ? { reasoning_opaque: reasoningOpaque } : {}), + }, + }, + } + }) + } + if (model.api.id.includes("claude")) { return msgs.map((msg) => { if ((msg.role === "assistant" || msg.role === "tool") && Array.isArray(msg.content)) { @@ -662,65 +697,61 @@ export namespace ProviderTransform { return standardLimit } - export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema) { - /* - if (["openai", "azure"].includes(providerID)) { - if (schema.type === "object" && schema.properties) { - for (const [key, value] of Object.entries(schema.properties)) { - if (schema.required?.includes(key)) continue - schema.properties[key] = { - anyOf: [ - value as JSONSchema.JSONSchema, - { - type: "null", - }, - ], - } - } - } + export function sanitizeGeminiSchema(obj: any): any { + if (obj === null || typeof obj !== "object") { + return obj } - */ - // Convert integer enums to string enums for Google/Gemini - if (model.providerID === "google" || model.api.id.includes("gemini")) { - const sanitizeGemini = (obj: any): any => { - if (obj === null || typeof obj !== "object") { - return obj - } + if (Array.isArray(obj)) { + return obj.map(sanitizeGeminiSchema) + } - if (Array.isArray(obj)) { - return obj.map(sanitizeGemini) + const result: any = {} + for (const [key, value] of Object.entries(obj)) { + if (key === "type" && Array.isArray(value)) { + const types = value as string[] + const hasNull = types.includes("null") + const nonNullTypes = types.filter((t) => t !== "null") + + if (hasNull && nonNullTypes.length === 1) { + result.type = nonNullTypes[0] + result.nullable = true + } else if (hasNull && nonNullTypes.length > 1) { + result.type = nonNullTypes + } else if (nonNullTypes.length === 1) { + result.type = nonNullTypes[0] + } else { + result.type = value } - - const result: any = {} - for (const [key, value] of Object.entries(obj)) { - if (key === "enum" && Array.isArray(value)) { - // Convert all enum values to strings - result[key] = value.map((v) => String(v)) - // If we have integer type with enum, change type to string - if (result.type === "integer" || result.type === "number") { - result.type = "string" - } - } else if (typeof value === "object" && value !== null) { - result[key] = sanitizeGemini(value) - } else { - result[key] = value - } + } else if (key === "enum" && Array.isArray(value)) { + // Convert all enum values to strings + result[key] = value.map((v) => String(v)) + // If we have integer type with enum, change type to string + if (result.type === "integer" || result.type === "number") { + result.type = "string" } + } else if (typeof value === "object" && value !== null) { + result[key] = sanitizeGeminiSchema(value) + } else { + result[key] = value + } + } - // Filter required array to only include fields that exist in properties - if (result.type === "object" && result.properties && Array.isArray(result.required)) { - result.required = result.required.filter((field: any) => field in result.properties) - } + // Filter required array to only include fields that exist in properties + if (result.type === "object" && result.properties && Array.isArray(result.required)) { + result.required = result.required.filter((field: any) => field in result.properties) + } - if (result.type === "array" && result.items == null) { - result.items = {} - } + if (result.type === "array" && result.items == null) { + result.items = {} + } - return result - } + return result + } - schema = sanitizeGemini(schema) + export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema) { + if (model.providerID === "google" || model.api.id.includes("gemini")) { + schema = sanitizeGeminiSchema(schema) } return schema