| import { |
| APICallError, |
| InvalidResponseDataError, |
| type LanguageModelV3, |
| type LanguageModelV3CallOptions, |
| type LanguageModelV3Content, |
| type LanguageModelV3StreamPart, |
| type SharedV3ProviderMetadata, |
| type SharedV3Warning, |
| } 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<string, string | undefined> |
| url: (options: { modelId: string; path: string }) => string |
| fetch?: FetchFunction |
| includeUsage?: boolean |
| errorStructure?: ProviderErrorStructure<any> |
| metadataExtractor?: MetadataExtractor |
|
|
| |
| |
| |
| supportsStructuredOutputs?: boolean |
|
|
| |
| |
| |
| supportedUrls?: () => LanguageModelV3["supportedUrls"] |
| } |
|
|
| export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { |
| readonly specificationVersion = "v3" |
|
|
| readonly supportsStructuredOutputs: boolean |
|
|
| readonly modelId: OpenAICompatibleChatModelId |
| private readonly config: OpenAICompatibleChatConfig |
| private readonly failedResponseHandler: ResponseHandler<APICallError> |
| private readonly chunkSchema |
|
|
| constructor(modelId: OpenAICompatibleChatModelId, config: OpenAICompatibleChatConfig) { |
| this.modelId = modelId |
| this.config = config |
|
|
| |
| 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, |
| }: LanguageModelV3CallOptions) { |
| const warnings: SharedV3Warning[] = [] |
|
|
| |
| 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", feature: "topK" }) |
| } |
|
|
| if (responseFormat?.type === "json" && responseFormat.schema != null && !this.supportsStructuredOutputs) { |
| warnings.push({ |
| type: "unsupported", |
| feature: "responseFormat", |
| details: "JSON response format schema is only supported with structuredOutputs", |
| }) |
| } |
|
|
| const { |
| tools: openaiTools, |
| toolChoice: openaiToolChoice, |
| toolWarnings, |
| } = prepareTools({ |
| tools, |
| toolChoice, |
| }) |
|
|
| return { |
| args: { |
| |
| model: this.modelId, |
|
|
| |
| user: compatibleOptions.user, |
|
|
| |
| 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: convertToOpenAICompatibleChatMessages(prompt), |
|
|
| |
| tools: openaiTools, |
| tool_choice: openaiToolChoice, |
|
|
| |
| thinking_budget: compatibleOptions.thinking_budget, |
| }, |
| warnings: [...warnings, ...toolWarnings], |
| } |
| } |
|
|
| async doGenerate(options: LanguageModelV3CallOptions) { |
| 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<LanguageModelV3Content> = [] |
|
|
| |
| const text = choice.message.content |
| if (text != null && text.length > 0) { |
| content.push({ |
| type: "text", |
| text, |
| providerMetadata: choice.message.reasoning_opaque |
| ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } } |
| : undefined, |
| }) |
| } |
|
|
| |
| const reasoning = choice.message.reasoning_text |
| if (reasoning != null && reasoning.length > 0) { |
| content.push({ |
| type: "reasoning", |
| text: reasoning, |
| |
| providerMetadata: choice.message.reasoning_opaque |
| ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } } |
| : undefined, |
| }) |
| } |
|
|
| |
| 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!, |
| providerMetadata: choice.message.reasoning_opaque |
| ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } } |
| : undefined, |
| }) |
| } |
| } |
|
|
| |
| const providerMetadata: SharedV3ProviderMetadata = { |
| [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: { |
| unified: mapOpenAICompatibleFinishReason(choice.finish_reason), |
| raw: choice.finish_reason ?? undefined, |
| }, |
| usage: { |
| inputTokens: { |
| total: responseBody.usage?.prompt_tokens ?? undefined, |
| noCache: undefined, |
| cacheRead: responseBody.usage?.prompt_tokens_details?.cached_tokens ?? undefined, |
| cacheWrite: undefined, |
| }, |
| outputTokens: { |
| total: responseBody.usage?.completion_tokens ?? undefined, |
| text: undefined, |
| reasoning: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined, |
| }, |
| raw: responseBody.usage ?? undefined, |
| }, |
| providerMetadata, |
| request: { body }, |
| response: { |
| ...getResponseMetadata(responseBody), |
| headers: responseHeaders, |
| body: rawResponse, |
| }, |
| warnings, |
| } |
| } |
|
|
| async doStream(options: LanguageModelV3CallOptions) { |
| const { args, warnings } = await this.getArgs({ ...options }) |
|
|
| const body = { |
| ...args, |
| stream: true, |
|
|
| |
| 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: { |
| unified: ReturnType<typeof mapOpenAICompatibleFinishReason> |
| raw: string | undefined |
| } = { |
| unified: "other", |
| raw: undefined, |
| } |
| 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<z.infer<typeof this.chunkSchema>>, LanguageModelV3StreamPart>({ |
| start(controller) { |
| controller.enqueue({ type: "stream-start", warnings }) |
| }, |
|
|
| |
| transform(chunk, controller) { |
| |
| if (options.includeRawChunks) { |
| controller.enqueue({ type: "raw", rawValue: chunk.rawValue }) |
| } |
|
|
| |
| if (!chunk.success) { |
| finishReason = { |
| unified: "error", |
| raw: undefined, |
| } |
| controller.enqueue({ type: "error", error: chunk.error }) |
| return |
| } |
| const value = chunk.value |
|
|
| metadataExtractor?.processChunk(chunk.rawValue) |
|
|
| |
| if ("error" in value) { |
| finishReason = { |
| unified: "error", |
| raw: undefined, |
| } |
| 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 = { |
| unified: mapOpenAICompatibleFinishReason(choice.finish_reason), |
| raw: choice.finish_reason ?? undefined, |
| } |
| } |
|
|
| if (choice?.delta == null) { |
| return |
| } |
|
|
| const delta = choice.delta |
|
|
| |
| 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 |
| } |
|
|
| |
| 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 (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", |
| providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined, |
| }) |
| isActiveText = true |
| } |
|
|
| controller.enqueue({ |
| type: "text-delta", |
| id: "txt-0", |
| delta: delta.content, |
| }) |
| } |
|
|
| if (delta.tool_calls != null) { |
| |
| |
| 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) { |
| |
| if (toolCall.function.arguments.length > 0) { |
| controller.enqueue({ |
| type: "tool-input-delta", |
| id: toolCall.id, |
| delta: toolCall.function.arguments, |
| }) |
| } |
|
|
| |
| |
| 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, |
| providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined, |
| }) |
| toolCall.hasFinished = true |
| } |
| } |
|
|
| continue |
| } |
|
|
| |
| const toolCall = toolCalls[index] |
|
|
| if (toolCall.hasFinished) { |
| continue |
| } |
|
|
| if (toolCallDelta.function?.arguments != null) { |
| toolCall.function!.arguments += toolCallDelta.function?.arguments ?? "" |
| } |
|
|
| |
| controller.enqueue({ |
| type: "tool-input-delta", |
| id: toolCall.id, |
| delta: toolCallDelta.function.arguments ?? "", |
| }) |
|
|
| |
| 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, |
| providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined, |
| }) |
| toolCall.hasFinished = true |
| } |
| } |
| } |
| }, |
|
|
| flush(controller) { |
| if (isActiveReasoning) { |
| controller.enqueue({ |
| type: "reasoning-end", |
| id: "reasoning-0", |
| |
| providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined, |
| }) |
| } |
|
|
| if (isActiveText) { |
| controller.enqueue({ type: "text-end", id: "txt-0" }) |
| } |
|
|
| |
| 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: SharedV3ProviderMetadata = { |
| [providerOptionsName]: {}, |
| |
| ...(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: { |
| total: usage.promptTokens, |
| noCache: |
| usage.promptTokens != undefined && usage.promptTokensDetails.cachedTokens != undefined |
| ? usage.promptTokens - usage.promptTokensDetails.cachedTokens |
| : undefined, |
| cacheRead: usage.promptTokensDetails.cachedTokens, |
| cacheWrite: undefined, |
| }, |
| outputTokens: { |
| total: usage.completionTokens, |
| text: undefined, |
| reasoning: usage.completionTokensDetails.reasoningTokens, |
| }, |
| raw: { |
| prompt_tokens: usage.promptTokens ?? null, |
| completion_tokens: usage.completionTokens ?? null, |
| total_tokens: usage.totalTokens ?? null, |
| }, |
| }, |
| 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() |
|
|
| |
| |
| 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_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, |
| }) |
|
|
| |
| |
| 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(), |
| |
| 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, |
| ]) |
|
|