chore: fork — remove vendor CI, repoint release checks to Neuron Gitea
This commit is contained in:
@@ -1,5 +0,0 @@
|
||||
This is a temporary package used primarily for GitHub Copilot compatibility.
|
||||
|
||||
These DO NOT apply for openai-compatible providers or majority of providers supporting completions/responses apis. THIS IS ONLY FOR GITHUB COPILOT!!!
|
||||
|
||||
Avoid making edits to these files
|
||||
@@ -1,170 +0,0 @@
|
||||
import {
|
||||
type LanguageModelV3Prompt,
|
||||
type SharedV3ProviderOptions,
|
||||
UnsupportedFunctionalityError,
|
||||
} from "@ai-sdk/provider"
|
||||
import type { OpenAICompatibleChatPrompt } from "./openai-compatible-api-types"
|
||||
import { convertToBase64 } from "@ai-sdk/provider-utils"
|
||||
|
||||
function getOpenAIMetadata(message: { providerOptions?: SharedV3ProviderOptions }) {
|
||||
return message?.providerOptions?.copilot ?? {}
|
||||
}
|
||||
|
||||
export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV3Prompt): OpenAICompatibleChatPrompt {
|
||||
const messages: OpenAICompatibleChatPrompt = []
|
||||
for (const { role, content, ...message } of prompt) {
|
||||
const metadata = getOpenAIMetadata({ ...message })
|
||||
switch (role) {
|
||||
case "system": {
|
||||
messages.push({
|
||||
role: "system",
|
||||
content: 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": {
|
||||
if (part.text) 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: reasoningOpaque ? reasoningText : undefined,
|
||||
reasoning_opaque: reasoningOpaque,
|
||||
...metadata,
|
||||
})
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
case "tool": {
|
||||
for (const toolResponse of content) {
|
||||
if (toolResponse.type === "tool-approval-response") {
|
||||
continue
|
||||
}
|
||||
const output = toolResponse.output
|
||||
|
||||
let contentValue: string
|
||||
switch (output.type) {
|
||||
case "text":
|
||||
case "error-text":
|
||||
contentValue = output.value
|
||||
break
|
||||
case "execution-denied":
|
||||
contentValue = output.reason ?? "Tool execution denied."
|
||||
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
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { LanguageModelV3FinishReason } from "@ai-sdk/provider"
|
||||
|
||||
export function mapOpenAICompatibleFinishReason(
|
||||
finishReason: string | null | undefined,
|
||||
): LanguageModelV3FinishReason["unified"] {
|
||||
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 "other"
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { JSONValue } from "@ai-sdk/provider"
|
||||
|
||||
export type OpenAICompatibleChatPrompt = Array<OpenAICompatibleMessage>
|
||||
|
||||
export type OpenAICompatibleMessage =
|
||||
| OpenAICompatibleSystemMessage
|
||||
| OpenAICompatibleUserMessage
|
||||
| OpenAICompatibleAssistantMessage
|
||||
| OpenAICompatibleToolMessage
|
||||
|
||||
// Allow for arbitrary additional properties for general purpose
|
||||
// provider-metadata-specific extensibility.
|
||||
type JsonRecord<T = never> = Record<string, JSONValue | JSONValue[] | T | T[] | undefined>
|
||||
|
||||
export interface OpenAICompatibleSystemMessage extends JsonRecord<OpenAICompatibleSystemContentPart> {
|
||||
role: "system"
|
||||
content: string | Array<OpenAICompatibleSystemContentPart>
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleSystemContentPart extends JsonRecord {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface OpenAICompatibleUserMessage extends JsonRecord<OpenAICompatibleContentPart> {
|
||||
role: "user"
|
||||
content: string | Array<OpenAICompatibleContentPart>
|
||||
}
|
||||
|
||||
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<OpenAICompatibleMessageToolCall> {
|
||||
role: "assistant"
|
||||
content?: string | null
|
||||
tool_calls?: Array<OpenAICompatibleMessageToolCall>
|
||||
// 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
|
||||
}
|
||||
@@ -1,815 +0,0 @@
|
||||
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
|
||||
|
||||
/**
|
||||
* Whether the model supports structured outputs.
|
||||
*/
|
||||
supportsStructuredOutputs?: boolean
|
||||
|
||||
/**
|
||||
* The supported URLs for the model.
|
||||
*/
|
||||
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 // 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,
|
||||
}: LanguageModelV3CallOptions) {
|
||||
const warnings: SharedV3Warning[] = []
|
||||
|
||||
// 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", 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 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: 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> = []
|
||||
|
||||
// text content:
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// 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!,
|
||||
providerMetadata: choice.message.reasoning_opaque
|
||||
? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// provider metadata:
|
||||
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,
|
||||
|
||||
// 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: {
|
||||
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 })
|
||||
},
|
||||
|
||||
// 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 = {
|
||||
unified: "error",
|
||||
raw: undefined,
|
||||
}
|
||||
controller.enqueue({ type: "error", error: chunk.error })
|
||||
return
|
||||
}
|
||||
const value = chunk.value
|
||||
|
||||
metadataExtractor?.processChunk(chunk.rawValue)
|
||||
|
||||
// handle error chunks:
|
||||
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
|
||||
|
||||
// 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",
|
||||
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
|
||||
})
|
||||
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,
|
||||
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
|
||||
})
|
||||
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,
|
||||
providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
|
||||
})
|
||||
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: SharedV3ProviderMetadata = {
|
||||
[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: {
|
||||
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()
|
||||
|
||||
// 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,
|
||||
])
|
||||
@@ -1,28 +0,0 @@
|
||||
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>
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { SharedV3ProviderMetadata } 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<SharedV3ProviderMetadata | undefined>
|
||||
|
||||
/**
|
||||
* 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(): SharedV3ProviderMetadata | undefined
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { type LanguageModelV3CallOptions, type SharedV3Warning, UnsupportedFunctionalityError } from "@ai-sdk/provider"
|
||||
|
||||
export function prepareTools({
|
||||
tools,
|
||||
toolChoice,
|
||||
}: {
|
||||
tools: LanguageModelV3CallOptions["tools"]
|
||||
toolChoice?: LanguageModelV3CallOptions["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: SharedV3Warning[]
|
||||
} {
|
||||
// when the tools array is empty, change it to undefined to prevent errors:
|
||||
tools = tools?.length ? tools : undefined
|
||||
|
||||
const toolWarnings: SharedV3Warning[] = []
|
||||
|
||||
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") {
|
||||
toolWarnings.push({ type: "unsupported", feature: `tool type: ${tool.type}` })
|
||||
} 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}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import type { LanguageModelV3 } 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<string, string>
|
||||
|
||||
/**
|
||||
* Custom fetch implementation.
|
||||
*/
|
||||
fetch?: FetchFunction
|
||||
}
|
||||
|
||||
export interface OpenaiCompatibleProvider {
|
||||
(modelId: OpenaiCompatibleModelId): LanguageModelV3
|
||||
chat(modelId: OpenaiCompatibleModelId): LanguageModelV3
|
||||
responses(modelId: OpenaiCompatibleModelId): LanguageModelV3
|
||||
languageModel(modelId: OpenaiCompatibleModelId): LanguageModelV3
|
||||
|
||||
// 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()
|
||||
@@ -1,27 +0,0 @@
|
||||
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<T> = {
|
||||
errorSchema: ZodType<T>
|
||||
errorToMessage: (error: T) => string
|
||||
isRetryable?: (response: Response, error?: T) => boolean
|
||||
}
|
||||
|
||||
export const defaultOpenAICompatibleErrorStructure: ProviderErrorStructure<OpenAICompatibleErrorData> = {
|
||||
errorSchema: openaiCompatibleErrorDataSchema,
|
||||
errorToMessage: (data) => data.error.message,
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
import {
|
||||
type LanguageModelV3Prompt,
|
||||
type LanguageModelV3ToolCallPart,
|
||||
type SharedV3Warning,
|
||||
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: LanguageModelV3Prompt
|
||||
systemMessageMode: "system" | "developer" | "remove"
|
||||
fileIdPrefixes?: readonly string[]
|
||||
store: boolean
|
||||
hasLocalShellTool?: boolean
|
||||
}): Promise<{
|
||||
input: OpenAIResponsesInput
|
||||
warnings: Array<SharedV3Warning>
|
||||
}> {
|
||||
const input: OpenAIResponsesInput = []
|
||||
const warnings: Array<SharedV3Warning> = []
|
||||
const processedApprovalIds = new Set<string>()
|
||||
|
||||
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?.copilot?.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<string, OpenAIResponsesReasoning> = {}
|
||||
const toolCallParts: Record<string, LanguageModelV3ToolCallPart> = {}
|
||||
|
||||
for (const part of content) {
|
||||
switch (part.type) {
|
||||
case "text": {
|
||||
input.push({
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: part.text }],
|
||||
id: (part.providerOptions?.copilot?.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?.copilot?.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?.copilot?.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) {
|
||||
if (part.type === "tool-approval-response") {
|
||||
if (processedApprovalIds.has(part.approvalId)) {
|
||||
continue
|
||||
}
|
||||
processedApprovalIds.add(part.approvalId)
|
||||
|
||||
if (store) {
|
||||
input.push({
|
||||
type: "item_reference",
|
||||
id: part.approvalId,
|
||||
})
|
||||
}
|
||||
|
||||
input.push({
|
||||
type: "mcp_approval_response",
|
||||
approval_request_id: part.approvalId,
|
||||
approve: part.approved,
|
||||
})
|
||||
continue
|
||||
}
|
||||
const output = part.output
|
||||
|
||||
if (output.type === "execution-denied") {
|
||||
const approvalId = (output.providerOptions?.copilot as { approvalId?: string } | undefined)?.approvalId
|
||||
|
||||
if (approvalId) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
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 "execution-denied":
|
||||
contentValue = output.reason ?? "Tool execution denied."
|
||||
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<typeof openaiResponsesReasoningProviderOptionsSchema>
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { LanguageModelV3FinishReason } 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
|
||||
}): LanguageModelV3FinishReason["unified"] {
|
||||
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" : "other"
|
||||
}
|
||||
}
|
||||
@@ -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<string, string | undefined>
|
||||
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[]
|
||||
}
|
||||
@@ -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<typeof openaiErrorDataSchema>
|
||||
|
||||
export const openaiFailedResponseHandler: any = createJsonErrorResponseHandler({
|
||||
errorSchema: openaiErrorDataSchema,
|
||||
errorToMessage: (data) => data.error.message,
|
||||
})
|
||||
@@ -1,214 +0,0 @@
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
|
||||
export type OpenAIResponsesInput = Array<OpenAIResponsesInputItem>
|
||||
|
||||
export type OpenAIResponsesInputItem =
|
||||
| OpenAIResponsesSystemMessage
|
||||
| OpenAIResponsesUserMessage
|
||||
| OpenAIResponsesAssistantMessage
|
||||
| OpenAIResponsesFunctionCall
|
||||
| OpenAIResponsesFunctionCallOutput
|
||||
| OpenAIResponsesComputerCall
|
||||
| OpenAIResponsesLocalShellCall
|
||||
| OpenAIResponsesLocalShellCallOutput
|
||||
| OpenAIResponsesReasoning
|
||||
| OpenAIResponsesItemReference
|
||||
| OpenAIResponsesMcpApprovalResponse
|
||||
|
||||
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<OpenAIResponsesIncludeValue> | 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<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
export type OpenAIResponsesLocalShellCallOutput = {
|
||||
type: "local_shell_call_output"
|
||||
call_id: string
|
||||
output: string
|
||||
}
|
||||
|
||||
export type OpenAIResponsesItemReference = {
|
||||
type: "item_reference"
|
||||
id: string
|
||||
}
|
||||
|
||||
export type OpenAIResponsesMcpApprovalResponse = {
|
||||
type: "mcp_approval_response"
|
||||
approval_request_id: string
|
||||
approve: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<OpenAIResponsesFileSearchToolComparisonFilter | OpenAIResponsesFileSearchToolCompoundFilter>
|
||||
}
|
||||
|
||||
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
|
||||
}>
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,173 +0,0 @@
|
||||
import { type LanguageModelV3CallOptions, type SharedV3Warning, 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: LanguageModelV3CallOptions["tools"]
|
||||
toolChoice?: LanguageModelV3CallOptions["toolChoice"]
|
||||
strictJsonSchema: boolean
|
||||
}): {
|
||||
tools?: Array<OpenAIResponsesTool>
|
||||
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: SharedV3Warning[]
|
||||
} {
|
||||
// when the tools array is empty, change it to undefined to prevent errors:
|
||||
tools = tools?.length ? tools : undefined
|
||||
|
||||
const toolWarnings: SharedV3Warning[] = []
|
||||
|
||||
if (tools == null) {
|
||||
return { tools: undefined, toolChoice: undefined, toolWarnings }
|
||||
}
|
||||
|
||||
const openaiTools: Array<OpenAIResponsesTool> = []
|
||||
|
||||
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": {
|
||||
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", feature: "tool type" })
|
||||
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}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export type OpenAIResponsesModelId = string
|
||||
@@ -1,87 +0,0 @@
|
||||
import { createProviderToolFactoryWithOutputSchema } 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 = createProviderToolFactoryWithOutputSchema<
|
||||
{
|
||||
/**
|
||||
* 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",
|
||||
inputSchema: codeInterpreterInputSchema,
|
||||
outputSchema: codeInterpreterOutputSchema,
|
||||
})
|
||||
|
||||
export const codeInterpreter = (
|
||||
args: CodeInterpreterArgs = {}, // default
|
||||
) => {
|
||||
return codeInterpreterToolFactory(args)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { createProviderToolFactoryWithOutputSchema } 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<any> = 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 = createProviderToolFactoryWithOutputSchema<
|
||||
{},
|
||||
{
|
||||
/**
|
||||
* 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<string, unknown>
|
||||
|
||||
/**
|
||||
* 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",
|
||||
inputSchema: z.object({}),
|
||||
outputSchema: fileSearchOutputSchema,
|
||||
})
|
||||
@@ -1,114 +0,0 @@
|
||||
import { createProviderToolFactoryWithOutputSchema } 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 = createProviderToolFactoryWithOutputSchema<
|
||||
{},
|
||||
{
|
||||
/**
|
||||
* The generated image encoded in base64.
|
||||
*/
|
||||
result: string
|
||||
},
|
||||
ImageGenerationArgs
|
||||
>({
|
||||
id: "openai.image_generation",
|
||||
inputSchema: z.object({}),
|
||||
outputSchema: imageGenerationOutputSchema,
|
||||
})
|
||||
|
||||
export const imageGeneration = (
|
||||
args: ImageGenerationArgs = {}, // default
|
||||
) => {
|
||||
return imageGenerationToolFactory(args)
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { createProviderToolFactoryWithOutputSchema } 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 = createProviderToolFactoryWithOutputSchema<
|
||||
{
|
||||
/**
|
||||
* 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<string, string>
|
||||
}
|
||||
},
|
||||
{
|
||||
/**
|
||||
* The output of local shell tool call.
|
||||
*/
|
||||
output: string
|
||||
},
|
||||
{}
|
||||
>({
|
||||
id: "openai.local_shell",
|
||||
inputSchema: localShellInputSchema,
|
||||
outputSchema: localShellOutputSchema,
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
import { createProviderToolFactory } 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 = createProviderToolFactory<
|
||||
{
|
||||
// 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",
|
||||
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(),
|
||||
}),
|
||||
})
|
||||
@@ -1,102 +0,0 @@
|
||||
import { createProviderToolFactory } 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 = createProviderToolFactory<
|
||||
{
|
||||
// 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",
|
||||
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<typeof webSearchToolFactory>[0] = {}, // default
|
||||
) => {
|
||||
return webSearchToolFactory(args)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { CoherePlugin } from "./provider/cohere"
|
||||
import { DeepInfraPlugin } from "./provider/deepinfra"
|
||||
import { DynamicProviderPlugin } from "./provider/dynamic"
|
||||
import { GatewayPlugin } from "./provider/gateway"
|
||||
import { GithubCopilotPlugin } from "./provider/github-copilot"
|
||||
import { GitLabPlugin } from "./provider/gitlab"
|
||||
import { GooglePlugin } from "./provider/google"
|
||||
import { GoogleVertexAnthropicPlugin, GoogleVertexPlugin } from "./provider/google-vertex"
|
||||
@@ -45,7 +44,6 @@ export const ProviderPlugins: PluginInternal.Plugin<PluginInternal.Requirements
|
||||
CoherePlugin,
|
||||
DeepInfraPlugin,
|
||||
GatewayPlugin,
|
||||
GithubCopilotPlugin,
|
||||
GitLabPlugin,
|
||||
GooglePlugin,
|
||||
GoogleVertexAnthropicPlugin,
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Effect } from "effect"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
|
||||
|
||||
export const GithubCopilotPlugin = {
|
||||
id: "github-copilot",
|
||||
effect: Effect.fn(function* (ctx: PluginContext) {
|
||||
yield* ctx.catalog.transform(
|
||||
Effect.fn(function* (evt) {
|
||||
const item = evt.provider.get(ProviderV2.ID.githubCopilot)
|
||||
if (!item || !item.models.has(ModelV2.ID.make("gpt-5-chat-latest"))) return
|
||||
evt.model.update(item.provider.id, ModelV2.ID.make("gpt-5-chat-latest"), (model) => {
|
||||
// This chat-only alias conflicts with the Copilot GPT-5 Responses route,
|
||||
// so hide it only for Copilot rather than for every provider catalog.
|
||||
model.enabled = false
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.sdk(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.package !== "@ai-sdk/github-copilot") return
|
||||
const mod = yield* Effect.promise(() => import("../../github-copilot/copilot-provider"))
|
||||
evt.sdk = mod.createOpenaiCompatible(evt.options)
|
||||
}),
|
||||
)
|
||||
yield* ctx.aisdk.language(
|
||||
Effect.fn(function* (evt) {
|
||||
if (evt.model.providerID !== ProviderV2.ID.githubCopilot) return
|
||||
if (evt.sdk.responses === undefined && evt.sdk.chat === undefined) {
|
||||
evt.language = evt.sdk.languageModel(evt.model.api.id)
|
||||
return
|
||||
}
|
||||
if (evt.options.endpoint === "responses" && evt.sdk.responses) {
|
||||
evt.language = evt.sdk.responses(evt.model.api.id)
|
||||
return
|
||||
}
|
||||
if (evt.options.endpoint === "chat" && evt.sdk.chat) {
|
||||
evt.language = evt.sdk.chat(evt.model.api.id)
|
||||
return
|
||||
}
|
||||
const match = /^gpt-(\d+)/.exec(evt.model.api.id)
|
||||
// Copilot supports Responses for GPT-5 class models, except mini variants
|
||||
// which still need the chat-completions endpoint.
|
||||
evt.language =
|
||||
match && Number(match[1]) >= 5 && !evt.model.api.id.startsWith("gpt-5-mini") && evt.sdk.responses
|
||||
? evt.sdk.responses(evt.model.api.id)
|
||||
: evt.sdk.chat(evt.model.api.id)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
export const MAX_STEPS_PROMPT = `CRITICAL - MAXIMUM STEPS REACHED
|
||||
export const MAX_STEPS_PROMPT = `The maximum number of steps allowed for this task has been reached. Tools are disabled until the next user input.
|
||||
|
||||
The maximum number of steps allowed for this task has been reached. Tools are disabled until next user input. Respond with text only.
|
||||
|
||||
STRICT REQUIREMENTS:
|
||||
1. Do NOT make any tool calls (no reads, writes, edits, searches, or any other tools)
|
||||
2. MUST provide a text response summarizing work done so far
|
||||
3. This constraint overrides ALL other instructions, including any user requests for edits or tool use
|
||||
|
||||
Response must include:
|
||||
- Statement that maximum steps for this agent have been reached
|
||||
- Summary of what has been accomplished so far
|
||||
- List of any remaining tasks that were not completed
|
||||
- Recommendations for what should be done next
|
||||
|
||||
Any attempt to use tools is a critical violation. Respond with text ONLY.`
|
||||
Respond with text only:
|
||||
- State that the step limit was reached
|
||||
- Summarize what was accomplished so far
|
||||
- List any remaining tasks that were not completed
|
||||
- Recommend what should be done next`
|
||||
|
||||
@@ -32,6 +32,7 @@ export const Model = Schema.Struct({
|
||||
Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
reasoning: Schema.optional(Schema.Finite),
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
context_over_200k: Schema.optional(
|
||||
|
||||
@@ -1,523 +0,0 @@
|
||||
import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@opencode-ai/core/github-copilot/chat/convert-to-openai-compatible-chat-messages"
|
||||
import { describe, test, expect } from "bun:test"
|
||||
|
||||
describe("system messages", () => {
|
||||
test("should convert system message content to string", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant with AGENTS.md instructions.",
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a helpful assistant with AGENTS.md instructions.",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
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 omit reasoning_text without reasoning_opaque", () => {
|
||||
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: undefined,
|
||||
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 include reasoning_opaque from text part providerOptions", () => {
|
||||
const result = convertToCopilotMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Done!",
|
||||
providerOptions: {
|
||||
copilot: { reasoningOpaque: "opaque-text-456" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
role: "assistant",
|
||||
content: "Done!",
|
||||
tool_calls: undefined,
|
||||
reasoning_text: undefined,
|
||||
reasoning_opaque: "opaque-text-456",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -1,592 +0,0 @@
|
||||
import { OpenAICompatibleChatLanguageModel } from "@opencode-ai/core/github-copilot/chat/openai-compatible-chat-language-model"
|
||||
import { describe, test, expect, mock } from "bun:test"
|
||||
import type { LanguageModelV3Prompt } from "@ai-sdk/provider"
|
||||
|
||||
async function convertReadableStreamToArray<T>(stream: ReadableStream<T>): Promise<T[]> {
|
||||
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: LanguageModelV3Prompt = [{ 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]`,
|
||||
],
|
||||
|
||||
reasoningOpaqueWithToolCallsNoReasoningText: [
|
||||
`data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only","index":0,"type":"function"}],"reasoning_opaque":"opaque-xyz"}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`,
|
||||
`data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only_2","index":1,"type":"function"}]}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":12,"prompt_tokens":123,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":135,"reasoning_tokens":0},"model":"gemini-3-flash-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<typeof mock>) {
|
||||
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: { unified: "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: { unified: "tool-calls" },
|
||||
usage: {
|
||||
inputTokens: { total: 19581 },
|
||||
outputTokens: { total: 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: { unified: "stop" },
|
||||
usage: {
|
||||
inputTokens: { total: 5778 },
|
||||
outputTokens: { total: 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: { unified: "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: { unified: "tool-calls" },
|
||||
usage: {
|
||||
inputTokens: { total: 3767 },
|
||||
outputTokens: { total: 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: { unified: "tool-calls" },
|
||||
})
|
||||
})
|
||||
|
||||
test("should attach reasoning_opaque to tool calls without reasoning_text", async () => {
|
||||
const mockFetch = createMockFetch(FIXTURES.reasoningOpaqueWithToolCallsNoReasoningText)
|
||||
const model = createModel(mockFetch)
|
||||
|
||||
const { stream } = await model.doStream({
|
||||
prompt: TEST_PROMPT,
|
||||
includeRawChunks: false,
|
||||
})
|
||||
|
||||
const parts = await convertReadableStreamToArray(stream)
|
||||
const reasoningParts = parts.filter(
|
||||
(p) => p.type === "reasoning-start" || p.type === "reasoning-delta" || p.type === "reasoning-end",
|
||||
)
|
||||
|
||||
expect(reasoningParts).toHaveLength(0)
|
||||
|
||||
const toolCall = parts.find((p) => p.type === "tool-call" && p.toolCallId === "call_reasoning_only")
|
||||
expect(toolCall).toMatchObject({
|
||||
type: "tool-call",
|
||||
toolCallId: "call_reasoning_only",
|
||||
toolName: "read_file",
|
||||
providerMetadata: {
|
||||
copilot: {
|
||||
reasoningOpaque: "opaque-xyz",
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,206 +0,0 @@
|
||||
import { OpenAIResponsesLanguageModel } from "@opencode-ai/core/github-copilot/responses/openai-responses-language-model"
|
||||
import { convertToOpenAIResponsesInput } from "@opencode-ai/core/github-copilot/responses/convert-to-openai-responses-input"
|
||||
import { describe, test, expect, mock } from "bun:test"
|
||||
import type { LanguageModelV3Prompt } from "@ai-sdk/provider"
|
||||
|
||||
const TEST_PROMPT: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }]
|
||||
|
||||
function createMockFetch(body: unknown) {
|
||||
return mock(
|
||||
async () => new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }),
|
||||
)
|
||||
}
|
||||
|
||||
function createModel(fetchFn: ReturnType<typeof mock>) {
|
||||
return new OpenAIResponsesLanguageModel("test-model", {
|
||||
provider: "copilot",
|
||||
url: () => "https://api.test.com/responses",
|
||||
headers: () => ({ Authorization: "Bearer test-token" }),
|
||||
fetch: fetchFn as any,
|
||||
})
|
||||
}
|
||||
|
||||
// GitHub Copilot's Responses model echoes item metadata (itemId, reasoningEncryptedContent,
|
||||
// responseId, ...) under the "copilot" providerOptions/providerMetadata namespace, matching the
|
||||
// namespace request options already use. It used to echo this metadata under "openai" (a leftover
|
||||
// from forking the OpenAI Responses model), which left it unreachable by anything reading the
|
||||
// "copilot" namespace and let stale itemIds slip past stripping meant for that namespace.
|
||||
describe("doGenerate", () => {
|
||||
test("attaches item metadata under the copilot namespace, not openai", async () => {
|
||||
const mockFetch = createMockFetch({
|
||||
id: "resp_1",
|
||||
created_at: 0,
|
||||
model: "gpt-5.5",
|
||||
output: [
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "enc_1",
|
||||
summary: [{ type: "summary_text", text: "thinking..." }],
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
id: "msg_1",
|
||||
content: [{ type: "output_text", text: "Hello there", annotations: [] }],
|
||||
},
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_1",
|
||||
name: "bash",
|
||||
arguments: "{}",
|
||||
id: "fc_1",
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 10, output_tokens: 5 },
|
||||
})
|
||||
const model = createModel(mockFetch)
|
||||
|
||||
const { content, providerMetadata } = await model.doGenerate({
|
||||
prompt: TEST_PROMPT,
|
||||
includeRawChunks: false,
|
||||
} as any)
|
||||
|
||||
const reasoning = content.find((part: any) => part.type === "reasoning") as any
|
||||
expect(reasoning.providerMetadata?.copilot?.itemId).toBe("rs_1")
|
||||
expect(reasoning.providerMetadata?.copilot?.reasoningEncryptedContent).toBe("enc_1")
|
||||
expect(reasoning.providerMetadata?.openai).toBeUndefined()
|
||||
|
||||
const text = content.find((part: any) => part.type === "text") as any
|
||||
expect(text.providerMetadata?.copilot?.itemId).toBe("msg_1")
|
||||
expect(text.providerMetadata?.openai).toBeUndefined()
|
||||
|
||||
const toolCall = content.find((part: any) => part.type === "tool-call") as any
|
||||
expect(toolCall.providerMetadata?.copilot?.itemId).toBe("fc_1")
|
||||
expect(toolCall.providerMetadata?.openai).toBeUndefined()
|
||||
|
||||
expect(providerMetadata?.copilot?.responseId).toBe("resp_1")
|
||||
expect(providerMetadata?.openai).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("convertToOpenAIResponsesInput", () => {
|
||||
test("echoes a stale tool-call itemId from the copilot namespace as the function_call id", async () => {
|
||||
const { input } = await convertToOpenAIResponsesInput({
|
||||
prompt: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_1",
|
||||
toolName: "bash",
|
||||
input: { command: "ls" },
|
||||
providerOptions: { copilot: { itemId: "fc_999" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
systemMessageMode: "system",
|
||||
store: false,
|
||||
})
|
||||
|
||||
expect(input).toEqual([
|
||||
{
|
||||
type: "function_call",
|
||||
call_id: "call_1",
|
||||
name: "bash",
|
||||
arguments: JSON.stringify({ command: "ls" }),
|
||||
id: "fc_999",
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("omits the function_call id once the stale copilot itemId has been stripped", async () => {
|
||||
const { input } = await convertToOpenAIResponsesInput({
|
||||
prompt: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_1",
|
||||
toolName: "bash",
|
||||
input: { command: "ls" },
|
||||
providerOptions: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
systemMessageMode: "system",
|
||||
store: false,
|
||||
})
|
||||
|
||||
expect((input[0] as any).id).toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves reasoning items keyed by the copilot namespace instead of dropping them", async () => {
|
||||
const { input, warnings } = await convertToOpenAIResponsesInput({
|
||||
prompt: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "thinking...",
|
||||
providerOptions: { copilot: { itemId: "rs_1", reasoningEncryptedContent: "enc_1" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
systemMessageMode: "system",
|
||||
store: false,
|
||||
})
|
||||
|
||||
expect(warnings).toEqual([])
|
||||
expect(input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "enc_1",
|
||||
summary: [{ type: "summary_text", text: "thinking..." }],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("drops reasoning items with no copilot itemId and warns, as before", async () => {
|
||||
const { input, warnings } = await convertToOpenAIResponsesInput({
|
||||
prompt: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "reasoning", text: "thinking...", providerOptions: {} }],
|
||||
},
|
||||
],
|
||||
systemMessageMode: "system",
|
||||
store: false,
|
||||
})
|
||||
|
||||
expect(input).toEqual([])
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toMatchObject({
|
||||
message: expect.stringContaining("Non-OpenAI reasoning parts are not supported"),
|
||||
})
|
||||
})
|
||||
|
||||
test("reads imageDetail from the copilot namespace on user file parts", async () => {
|
||||
const { input } = await convertToOpenAIResponsesInput({
|
||||
prompt: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "file",
|
||||
mediaType: "image/png",
|
||||
data: "aGVsbG8=",
|
||||
providerOptions: { copilot: { imageDetail: "high" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
systemMessageMode: "system",
|
||||
store: false,
|
||||
})
|
||||
|
||||
expect((input[0] as any).content[0].detail).toBe("high")
|
||||
})
|
||||
})
|
||||
@@ -1,278 +0,0 @@
|
||||
import { AISDK } from "@opencode-ai/core/aisdk"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Catalog } from "@opencode-ai/core/catalog"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { PluginV2 } from "@opencode-ai/core/plugin"
|
||||
import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { GithubCopilotPlugin } from "@opencode-ai/core/plugin/provider/github-copilot"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import type { LanguageModelV3 } from "@ai-sdk/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
|
||||
const it = testEffect(PluginTestLayer)
|
||||
|
||||
const addPlugin = Effect.fn(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const host = yield* PluginHost.make(plugin)
|
||||
yield* GithubCopilotPlugin.effect(host)
|
||||
})
|
||||
|
||||
function required<T>(value: T | undefined): T {
|
||||
if (value === undefined) throw new Error("Expected value")
|
||||
return value
|
||||
}
|
||||
|
||||
function fakeSelectorSdk(calls: string[]) {
|
||||
const make = (method: string) => (id: string) => {
|
||||
calls.push(`${method}:${id}`)
|
||||
return { modelId: id, provider: method, specificationVersion: "v3" } as unknown as LanguageModelV3
|
||||
}
|
||||
return {
|
||||
responses: make("responses"),
|
||||
messages: make("messages"),
|
||||
chat: make("chat"),
|
||||
languageModel: make("languageModel"),
|
||||
}
|
||||
}
|
||||
|
||||
describe("GithubCopilotPlugin", () => {
|
||||
it.effect("creates the bundled Copilot SDK for the GitHub Copilot package", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
yield* addPlugin()
|
||||
const ignored = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/openai-compatible",
|
||||
options: { name: "github-copilot" },
|
||||
})
|
||||
const result = yield* aisdk.runSDK({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
package: "@ai-sdk/github-copilot",
|
||||
options: { name: "github-copilot" },
|
||||
})
|
||||
expect(ignored.sdk).toBeUndefined()
|
||||
expect(result.sdk).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects languageModel when responses and chat are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("claude-sonnet-4")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual(["languageModel:claude-sonnet-4"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("selects languageModel with the API model ID when responses and chat are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("alias")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: { languageModel: fakeSelectorSdk(calls).languageModel },
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual(["languageModel:claude-sonnet-4"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses responses for gpt-5 models except gpt-5-mini", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5.1-codex")),
|
||||
api: { id: ModelV2.ID.make("gpt-5.1-codex"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-4o")),
|
||||
api: { id: ModelV2.ID.make("gpt-4o"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini")),
|
||||
api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-mini-2025-08-07")),
|
||||
api: { id: ModelV2.ID.make("gpt-5-mini-2025-08-07"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual([
|
||||
"responses:gpt-5",
|
||||
"responses:gpt-5.1-codex",
|
||||
"chat:gpt-4o",
|
||||
"chat:gpt-5-mini",
|
||||
"chat:gpt-5-mini-2025-08-07",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses advertised Copilot endpoint metadata before model ID fallbacks", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("mai-code-1-flash-picker"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
settings: { endpoint: "responses" },
|
||||
},
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { endpoint: "responses" },
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
|
||||
api: {
|
||||
id: ModelV2.ID.make("gpt-5"),
|
||||
type: "aisdk",
|
||||
package: "test-provider",
|
||||
settings: { endpoint: "chat" },
|
||||
},
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: { endpoint: "chat" },
|
||||
})
|
||||
expect(calls).toEqual(["responses:mai-code-1-flash-picker", "chat:gpt-5"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses the API model ID when selecting responses or chat", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("default")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("small")),
|
||||
api: { id: ModelV2.ID.make("gpt-5-mini"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("sonnet")),
|
||||
api: { id: ModelV2.ID.make("claude-sonnet-4"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual(["responses:gpt-5", "chat:gpt-5-mini", "chat:claude-sonnet-4"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("disables gpt-5-chat-latest before Copilot language selection", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("github-copilot"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
required(yield* catalog.model.get(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
|
||||
.enabled,
|
||||
).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not disable gpt-5-chat-latest for non-Copilot providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const catalog = yield* Catalog.Service
|
||||
yield* catalog.transform((catalog) => {
|
||||
catalog.provider.update(ProviderV2.ID.make("custom-copilot"), () => {})
|
||||
catalog.model.update(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest"), () => {})
|
||||
})
|
||||
yield* addPlugin()
|
||||
expect(
|
||||
required(yield* catalog.model.get(ProviderV2.ID.make("custom-copilot"), ModelV2.ID.make("gpt-5-chat-latest")))
|
||||
.enabled,
|
||||
).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores non-Copilot providers", () =>
|
||||
Effect.gen(function* () {
|
||||
const plugin = yield* PluginV2.Service
|
||||
const aisdk = yield* AISDK.Service
|
||||
const calls: string[] = []
|
||||
yield* addPlugin()
|
||||
const result = yield* aisdk.runLanguage({
|
||||
model: ModelV2.Info.make({
|
||||
...ModelV2.Info.empty(ProviderV2.ID.make("openai"), ModelV2.ID.make("gpt-5")),
|
||||
api: { id: ModelV2.ID.make("gpt-5"), type: "aisdk", package: "test-provider" },
|
||||
}),
|
||||
sdk: fakeSelectorSdk(calls),
|
||||
options: {},
|
||||
})
|
||||
expect(calls).toEqual([])
|
||||
expect(result.language).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user