chore: fork — remove vendor CI, repoint release checks to Neuron Gitea

This commit is contained in:
2026-08-21 13:36:36 -05:00
parent 57fa34f235
commit b8c189b4b7
140 changed files with 734 additions and 16034 deletions
@@ -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)
}
-2
View File
@@ -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)
}),
)
}),
}
+6 -15
View File
@@ -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`
+1
View File
@@ -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()
}),
)
})