diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/chat/convert-to-openai-compatible-chat-messages.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/convert-to-openai-compatible-chat-messages.ts new file mode 100644 index 00000000000..30062794e2b --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/convert-to-openai-compatible-chat-messages.ts @@ -0,0 +1,153 @@ +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'; +import { Log } from '@/util/log'; + +function getOpenAIMetadata(message: { + providerOptions?: SharedV2ProviderMetadata; +}) { + return message?.providerOptions?.openaiCompatible ?? {}; +} + +export function convertToOpenAICompatibleChatMessages( + prompt: LanguageModelV2Prompt, +): OpenAICompatibleChatPrompt { + const logger = Log.create(); + const messages: OpenAICompatibleChatPrompt = []; + for (const { role, content, ...message } of prompt) { + const metadata = getOpenAIMetadata({ ...message }); + switch (role) { + case 'system': { + messages.push({ role: 'system', 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 = ''; + const toolCalls: Array<{ + id: string; + type: 'function'; + function: { name: string; arguments: string }; + }> = []; + for (const part of content) { + const partMetadata = getOpenAIMetadata(part); + switch (part.type) { + case 'text': { + text += 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, + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + ...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/openai-compatible/src/chat/get-response-metadata.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/get-response-metadata.ts new file mode 100644 index 00000000000..bd358b23f70 --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/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/openai-compatible/src/chat/map-openai-compatible-finish-reason.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/map-openai-compatible-finish-reason.ts new file mode 100644 index 00000000000..b18feae0818 --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/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/openai-compatible/src/chat/openai-compatible-api-types.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/openai-compatible-api-types.ts new file mode 100644 index 00000000000..2609d1c8730 --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/openai-compatible-api-types.ts @@ -0,0 +1,63 @@ +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; +} + +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; +} + +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/openai-compatible/src/chat/openai-compatible-chat-language-model.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/openai-compatible-chat-language-model.ts new file mode 100644 index 00000000000..f334616dd4d --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/openai-compatible-chat-language-model.ts @@ -0,0 +1,728 @@ +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, + type OpenAICompatibleProviderOptions, +} from "./openai-compatible-chat-options" +import { defaultOpenAICompatibleErrorStructure } from "../openai-compatible-error" +import type { ProviderErrorStructure } from "@ai-sdk/openai-compatible" +import type { MetadataExtractor } from "./openai-compatible-metadata-extractor" +import { prepareTools } from "./openai-compatible-prepare-tools" +import { Log } from "@/util/log" + +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: "openai-compatible", + 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, + + // messages: + messages: convertToOpenAICompatibleChatMessages(prompt), + + // tools: + tools: openaiTools, + tool_choice: openaiToolChoice, + }, + 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 log = Log.create() + log.info("responseBody", responseBody) + 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: + const reasoning = choice.message.reasoning_content ?? choice.message.reasoning ?? choice.message.reasoning_text + if (reasoning != null && reasoning.length > 0) { + content.push({ + type: "reasoning", + text: reasoning, + }) + } + + // 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 + + return { + stream: response.pipeThrough( + new TransformStream>, 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 + + // enqueue reasoning before text deltas: + const reasoningContent = delta.reasoning_content ?? delta.reasoning ?? 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 (!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) { + 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" }) + } + + 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 = { + ...metadataExtractor?.buildMetadata(), + } + const log = Log.create() + log.error(`provider metadata = ${JSON.stringify(providerMetadata)}`) + 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(), + reasoning_text: z.string().nullish(), + reasoning_content: z.string().nullish(), + reasoning: 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 = (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(), + // Most openai-compatible models set `reasoning_content`, but some + // providers serving `gpt-oss` set `reasoning`. See #7866 + reasoning_content: z.string().nullish(), + reasoning: z.string().nullish(), + // Copilot sets `reasoning_text` + reasoning_text: 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/openai-compatible/src/chat/openai-compatible-chat-options.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/openai-compatible-chat-options.ts new file mode 100644 index 00000000000..88b077470c2 --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/openai-compatible-chat-options.ts @@ -0,0 +1,20 @@ +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(), +}); + +export type OpenAICompatibleProviderOptions = z.infer< + typeof openaiCompatibleProviderOptions +>; diff --git a/packages/opencode/src/provider/sdk/openai-compatible/src/chat/openai-compatible-metadata-extractor.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/openai-compatible-metadata-extractor.ts new file mode 100644 index 00000000000..17c56c7ac05 --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/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/openai-compatible/src/chat/openai-compatible-prepare-tools.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/chat/openai-compatible-prepare-tools.ts new file mode 100644 index 00000000000..5c5f6681eaa --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/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/openai-compatible/src/openai-compatible-error.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/openai-compatible-error.ts new file mode 100644 index 00000000000..f0ebb31de52 --- /dev/null +++ b/packages/opencode/src/provider/sdk/openai-compatible/src/openai-compatible-error.ts @@ -0,0 +1,30 @@ +import { z, 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/openai-compatible/src/openai-compatible-provider.ts b/packages/opencode/src/provider/sdk/openai-compatible/src/openai-compatible-provider.ts index e71658c2fa0..63df4523bc4 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 @@ -1,8 +1,8 @@ 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 { OpenAICompatibleChatLanguageModel } from "./chat/openai-compatible-chat-language-model" +import { Log } from "@/util/log" // Import the version or define it const VERSION = "0.1.0"