Merge branch 'dev' into sqlite2

This commit is contained in:
Dax Raad
2026-01-31 16:08:47 -05:00
146 changed files with 12852 additions and 4207 deletions
+1
View File
@@ -20,6 +20,7 @@ export const AcpCommand = cmd({
})
},
handler: async (args) => {
process.env.OPENCODE_CLIENT = "acp"
await bootstrap(process.cwd(), async () => {
const opts = await resolveNetworkOptions(args)
const server = Server.listen(opts)
@@ -104,6 +104,7 @@ export function tui(input: {
args: Args
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
onExit?: () => Promise<void>
}) {
@@ -130,6 +131,7 @@ export function tui(input: {
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
+17 -4
View File
@@ -19,21 +19,34 @@ export const AttachCommand = cmd({
alias: ["s"],
type: "string",
describe: "session id to continue",
})
.option("password", {
alias: ["p"],
type: "string",
describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)",
}),
handler: async (args) => {
let directory = args.dir
if (args.dir) {
const directory = (() => {
if (!args.dir) return undefined
try {
process.chdir(args.dir)
directory = process.cwd()
return process.cwd()
} catch {
// If the directory doesn't exist locally (remote attach), pass it through.
return args.dir
}
}
})()
const headers = (() => {
const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD
if (!password) return undefined
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth }
})()
await tui({
url: args.url,
args: { sessionID: args.session },
directory,
headers,
})
},
})
@@ -345,8 +345,9 @@ export function Autocomplete(props: {
const results: AutocompleteOption[] = [...command.slashes()]
for (const serverCommand of sync.data.command) {
const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : ""
results.push({
display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""),
display: "/" + serverCommand.name + label,
description: serverCommand.description,
onSelect: () => {
const newText = "/" + serverCommand.name + " "
@@ -100,7 +100,7 @@ const TIPS = [
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting',
"Define custom formatter commands with file extensions in config",
"OpenCode uses LSP servers for intelligent code analysis",
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tool/{/highlight} to define new LLM tools",
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tools/{/highlight} to define new LLM tools",
"Tool definitions can invoke scripts written in Python, Go, etc",
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks",
"Use plugins to send OS notifications when sessions complete",
@@ -9,13 +9,20 @@ export type EventSource = {
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { url: string; directory?: string; fetch?: typeof fetch; events?: EventSource }) => {
init: (props: {
url: string
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
events?: EventSource
}) => {
const abort = new AbortController()
const sdk = createOpencodeClient({
baseUrl: props.url,
signal: abort.signal,
directory: props.directory,
fetch: props.fetch,
headers: props.headers,
})
const emitter = createGlobalEmitter<{
+18 -2
View File
@@ -6,6 +6,7 @@ import { Identifier } from "../id/id"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
import { MCP } from "../mcp"
import { Skill } from "../skill"
export namespace Command {
export const Event = {
@@ -26,7 +27,7 @@ export namespace Command {
description: z.string().optional(),
agent: z.string().optional(),
model: z.string().optional(),
mcp: z.boolean().optional(),
source: z.enum(["command", "mcp", "skill"]).optional(),
// workaround for zod not supporting async functions natively so we use getters
// https://zod.dev/v4/changelog?id=zfunction
template: z.promise(z.string()).or(z.string()),
@@ -94,7 +95,7 @@ export namespace Command {
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
result[name] = {
name,
mcp: true,
source: "mcp",
description: prompt.description,
get template() {
// since a getter can't be async we need to manually return a promise here
@@ -118,6 +119,21 @@ export namespace Command {
}
}
// Add skills as invokable commands
for (const skill of await Skill.all()) {
// Skip if a command with this name already exists
if (result[skill.name]) continue
result[skill.name] = {
name: skill.name,
description: skill.description,
source: "skill",
get template() {
return skill.content
},
hints: [],
}
}
return result
})
-23
View File
@@ -1078,29 +1078,6 @@ export namespace Config {
.optional(),
experimental: z
.object({
hook: z
.object({
file_edited: z
.record(
z.string(),
z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array(),
)
.optional(),
session_completed: z
.object({
command: z.string().array(),
environment: z.record(z.string(), z.string()).optional(),
})
.array()
.optional(),
})
.optional(),
chatMaxRetries: z.number().optional().describe("Number of retries for chat completions on failure"),
disable_paste_summary: z.boolean().optional(),
batch_tool: z.boolean().optional().describe("Enable the batch tool"),
openTelemetry: z
+3 -1
View File
@@ -2,7 +2,9 @@ import { Instance } from "../project/instance"
export namespace Env {
const state = Instance.state(() => {
return process.env as Record<string, string | undefined>
// Create a shallow copy to isolate environment per instance
// Prevents parallel tests from interfering with each other's env vars
return { ...process.env } as Record<string, string | undefined>
})
export function get(key: string) {
+3 -3
View File
@@ -214,8 +214,8 @@ export namespace Ripgrep {
input.signal?.throwIfAborted()
const args = [await filepath(), "--files", "--glob=!.git/*"]
if (input.follow !== false) args.push("--follow")
if (input.hidden !== false) args.push("--hidden")
if (input.follow) args.push("--follow")
if (input.hidden) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
@@ -381,7 +381,7 @@ export namespace Ripgrep {
follow?: boolean
}) {
const args = [`${await filepath()}`, "--json", "--hidden", "--glob='!.git/*'"]
if (input.follow !== false) args.push("--follow")
if (input.follow) args.push("--follow")
if (input.glob) {
for (const g of input.glob) {
+13 -1
View File
@@ -25,7 +25,7 @@ export namespace Flag {
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export const OPENCODE_CLIENT = process.env["OPENCODE_CLIENT"] ?? "cli"
export declare const OPENCODE_CLIENT: string
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
@@ -47,6 +47,7 @@ export namespace Flag {
export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
function number(key: string) {
const value = process.env[key]
@@ -77,3 +78,14 @@ Object.defineProperty(Flag, "OPENCODE_CONFIG_DIR", {
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_CLIENT
// This must be evaluated at access time, not module load time,
// because some commands override the client at runtime
Object.defineProperty(Flag, "OPENCODE_CLIENT", {
get() {
return process.env["OPENCODE_CLIENT"] ?? "cli"
},
enumerable: true,
configurable: false,
})
+1 -1
View File
@@ -85,7 +85,7 @@ export namespace ModelsDev {
}
export const Data = lazy(async () => {
const file = Bun.file(filepath)
const file = Bun.file(Flag.OPENCODE_MODELS_PATH ?? filepath)
const result = await file.json().catch(() => {})
if (result) return result
// @ts-ignore
+13 -12
View File
@@ -24,7 +24,7 @@ import { createVertexAnthropic } from "@ai-sdk/google-vertex/anthropic"
import { createOpenAI } from "@ai-sdk/openai"
import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src"
import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/copilot"
import { createXai } from "@ai-sdk/xai"
import { createMistral } from "@ai-sdk/mistral"
import { createGroq } from "@ai-sdk/groq"
@@ -195,11 +195,13 @@ export namespace Provider {
const awsAccessKeyId = Env.get("AWS_ACCESS_KEY_ID")
// TODO: Using process.env directly because Env.set only updates a process.env shallow copy,
// until the scope of the Env API is clarified (test only or runtime?)
const awsBearerToken = iife(() => {
const envToken = Env.get("AWS_BEARER_TOKEN_BEDROCK")
const envToken = process.env.AWS_BEARER_TOKEN_BEDROCK
if (envToken) return envToken
if (auth?.type === "api") {
Env.set("AWS_BEARER_TOKEN_BEDROCK", auth.key)
process.env.AWS_BEARER_TOKEN_BEDROCK = auth.key
return auth.key
}
return undefined
@@ -376,17 +378,19 @@ export namespace Provider {
},
"sap-ai-core": async () => {
const auth = await Auth.get("sap-ai-core")
// TODO: Using process.env directly because Env.set only updates a shallow copy (not process.env),
// until the scope of the Env API is clarified (test only or runtime?)
const envServiceKey = iife(() => {
const envAICoreServiceKey = Env.get("AICORE_SERVICE_KEY")
const envAICoreServiceKey = process.env.AICORE_SERVICE_KEY
if (envAICoreServiceKey) return envAICoreServiceKey
if (auth?.type === "api") {
Env.set("AICORE_SERVICE_KEY", auth.key)
process.env.AICORE_SERVICE_KEY = auth.key
return auth.key
}
return undefined
})
const deploymentId = Env.get("AICORE_DEPLOYMENT_ID")
const resourceGroup = Env.get("AICORE_RESOURCE_GROUP")
const deploymentId = process.env.AICORE_DEPLOYMENT_ID
const resourceGroup = process.env.AICORE_RESOURCE_GROUP
return {
autoload: !!envServiceKey,
@@ -1023,12 +1027,9 @@ export namespace Provider {
})
}
// Special case: google-vertex-anthropic uses a subpath import
const bundledKey =
model.providerID === "google-vertex-anthropic" ? "@ai-sdk/google-vertex/anthropic" : model.api.npm
const bundledFn = BUNDLED_PROVIDERS[bundledKey]
const bundledFn = BUNDLED_PROVIDERS[model.api.npm]
if (bundledFn) {
log.info("using bundled provider", { providerID: model.providerID, pkg: bundledKey })
log.info("using bundled provider", { providerID: model.providerID, pkg: model.api.npm })
const loaded = bundledFn({
name: model.providerID,
...options,
@@ -0,0 +1,169 @@
import {
type LanguageModelV2Prompt,
type SharedV2ProviderMetadata,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
import type { OpenAICompatibleChatPrompt } from "./openai-compatible-api-types"
import { convertToBase64 } from "@ai-sdk/provider-utils"
function getOpenAIMetadata(message: { providerOptions?: SharedV2ProviderMetadata }) {
return message?.providerOptions?.copilot ?? {}
}
export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Prompt): OpenAICompatibleChatPrompt {
const messages: OpenAICompatibleChatPrompt = []
for (const { role, content, ...message } of prompt) {
const metadata = getOpenAIMetadata({ ...message })
switch (role) {
case "system": {
messages.push({
role: "system",
content: [
{
type: "text",
text: 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": {
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: reasoningText,
reasoning_opaque: reasoningOpaque,
...metadata,
})
break
}
case "tool": {
for (const toolResponse of content) {
const output = toolResponse.output
let contentValue: string
switch (output.type) {
case "text":
case "error-text":
contentValue = output.value
break
case "content":
case "json":
case "error-json":
contentValue = JSON.stringify(output.value)
break
}
const toolResponseMetadata = getOpenAIMetadata(toolResponse)
messages.push({
role: "tool",
tool_call_id: toolResponse.toolCallId,
content: contentValue,
...toolResponseMetadata,
})
}
break
}
default: {
const _exhaustiveCheck: never = role
throw new Error(`Unsupported role: ${_exhaustiveCheck}`)
}
}
}
return messages
}
@@ -0,0 +1,15 @@
export function getResponseMetadata({
id,
model,
created,
}: {
id?: string | undefined | null
created?: number | undefined | null
model?: string | undefined | null
}) {
return {
id: id ?? undefined,
modelId: model ?? undefined,
timestamp: created != null ? new Date(created * 1000) : undefined,
}
}
@@ -0,0 +1,17 @@
import type { LanguageModelV2FinishReason } from "@ai-sdk/provider"
export function mapOpenAICompatibleFinishReason(finishReason: string | null | undefined): LanguageModelV2FinishReason {
switch (finishReason) {
case "stop":
return "stop"
case "length":
return "length"
case "content_filter":
return "content-filter"
case "function_call":
case "tool_calls":
return "tool-calls"
default:
return "unknown"
}
}
@@ -0,0 +1,64 @@
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
}
@@ -0,0 +1,765 @@
import {
APICallError,
InvalidResponseDataError,
type LanguageModelV2,
type LanguageModelV2CallWarning,
type LanguageModelV2Content,
type LanguageModelV2FinishReason,
type LanguageModelV2StreamPart,
type SharedV2ProviderMetadata,
} from "@ai-sdk/provider"
import {
combineHeaders,
createEventSourceResponseHandler,
createJsonErrorResponseHandler,
createJsonResponseHandler,
type FetchFunction,
generateId,
isParsableJson,
parseProviderOptions,
type ParseResult,
postJsonToApi,
type ResponseHandler,
} from "@ai-sdk/provider-utils"
import { z } from "zod/v4"
import { convertToOpenAICompatibleChatMessages } from "./convert-to-openai-compatible-chat-messages"
import { getResponseMetadata } from "./get-response-metadata"
import { mapOpenAICompatibleFinishReason } from "./map-openai-compatible-finish-reason"
import { type OpenAICompatibleChatModelId, openaiCompatibleProviderOptions } 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?: () => LanguageModelV2["supportedUrls"]
}
export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
readonly specificationVersion = "v2"
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,
}: Parameters<LanguageModelV2["doGenerate"]>[0]) {
const warnings: LanguageModelV2CallWarning[] = []
// 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-setting", setting: "topK" })
}
if (responseFormat?.type === "json" && responseFormat.schema != null && !this.supportsStructuredOutputs) {
warnings.push({
type: "unsupported-setting",
setting: "responseFormat",
details: "JSON response format schema is only supported with structuredOutputs",
})
}
const {
tools: openaiTools,
toolChoice: openaiToolChoice,
toolWarnings,
} = prepareTools({
tools,
toolChoice,
})
return {
args: {
// model id:
model: this.modelId,
// model specific settings:
user: compatibleOptions.user,
// standardized settings:
max_tokens: maxOutputTokens,
temperature,
top_p: topP,
frequency_penalty: frequencyPenalty,
presence_penalty: presencePenalty,
response_format:
responseFormat?.type === "json"
? this.supportsStructuredOutputs === true && responseFormat.schema != null
? {
type: "json_schema",
json_schema: {
schema: responseFormat.schema,
name: responseFormat.name ?? "response",
description: responseFormat.description,
},
}
: { type: "json_object" }
: undefined,
stop: stopSequences,
seed,
...Object.fromEntries(
Object.entries(providerOptions?.[this.providerOptionsName] ?? {}).filter(
([key]) => !Object.keys(openaiCompatibleProviderOptions.shape).includes(key),
),
),
reasoning_effort: compatibleOptions.reasoningEffort,
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: Parameters<LanguageModelV2["doGenerate"]>[0],
): Promise<Awaited<ReturnType<LanguageModelV2["doGenerate"]>>> {
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<LanguageModelV2Content> = []
// text content:
const text = choice.message.content
if (text != null && text.length > 0) {
content.push({ type: "text", text })
}
// 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!,
})
}
}
// provider metadata:
const providerMetadata: SharedV2ProviderMetadata = {
[this.providerOptionsName]: {},
...(await this.config.metadataExtractor?.extractMetadata?.({
parsedBody: rawResponse,
})),
}
const completionTokenDetails = responseBody.usage?.completion_tokens_details
if (completionTokenDetails?.accepted_prediction_tokens != null) {
providerMetadata[this.providerOptionsName].acceptedPredictionTokens =
completionTokenDetails?.accepted_prediction_tokens
}
if (completionTokenDetails?.rejected_prediction_tokens != null) {
providerMetadata[this.providerOptionsName].rejectedPredictionTokens =
completionTokenDetails?.rejected_prediction_tokens
}
return {
content,
finishReason: mapOpenAICompatibleFinishReason(choice.finish_reason),
usage: {
inputTokens: responseBody.usage?.prompt_tokens ?? undefined,
outputTokens: responseBody.usage?.completion_tokens ?? undefined,
totalTokens: responseBody.usage?.total_tokens ?? undefined,
reasoningTokens: responseBody.usage?.completion_tokens_details?.reasoning_tokens ?? undefined,
cachedInputTokens: responseBody.usage?.prompt_tokens_details?.cached_tokens ?? undefined,
},
providerMetadata,
request: { body },
response: {
...getResponseMetadata(responseBody),
headers: responseHeaders,
body: rawResponse,
},
warnings,
}
}
async doStream(
options: Parameters<LanguageModelV2["doStream"]>[0],
): Promise<Awaited<ReturnType<LanguageModelV2["doStream"]>>> {
const { args, warnings } = await this.getArgs({ ...options })
const body = {
...args,
stream: true,
// only include stream_options when in strict compatibility mode:
stream_options: this.config.includeUsage ? { include_usage: true } : undefined,
}
const metadataExtractor = this.config.metadataExtractor?.createStreamExtractor()
const { responseHeaders, value: response } = await postJsonToApi({
url: this.config.url({
path: "/chat/completions",
modelId: this.modelId,
}),
headers: combineHeaders(this.config.headers(), options.headers),
body,
failedResponseHandler: this.failedResponseHandler,
successfulResponseHandler: createEventSourceResponseHandler(this.chunkSchema),
abortSignal: options.abortSignal,
fetch: this.config.fetch,
})
const toolCalls: Array<{
id: string
type: "function"
function: {
name: string
arguments: string
}
hasFinished: boolean
}> = []
let finishReason: LanguageModelV2FinishReason = "unknown"
const usage: {
completionTokens: number | undefined
completionTokensDetails: {
reasoningTokens: number | undefined
acceptedPredictionTokens: number | undefined
rejectedPredictionTokens: number | undefined
}
promptTokens: number | undefined
promptTokensDetails: {
cachedTokens: number | undefined
}
totalTokens: number | undefined
} = {
completionTokens: undefined,
completionTokensDetails: {
reasoningTokens: undefined,
acceptedPredictionTokens: undefined,
rejectedPredictionTokens: undefined,
},
promptTokens: undefined,
promptTokensDetails: {
cachedTokens: undefined,
},
totalTokens: undefined,
}
let isFirstChunk = true
const providerOptionsName = this.providerOptionsName
let isActiveReasoning = false
let isActiveText = false
let reasoningOpaque: string | undefined
return {
stream: response.pipeThrough(
new TransformStream<ParseResult<z.infer<typeof this.chunkSchema>>, LanguageModelV2StreamPart>({
start(controller) {
controller.enqueue({ type: "stream-start", warnings })
},
// TODO we lost type safety on Chunk, most likely due to the error schema. MUST FIX
transform(chunk, controller) {
// Emit raw chunk if requested (before anything else)
if (options.includeRawChunks) {
controller.enqueue({ type: "raw", rawValue: chunk.rawValue })
}
// handle failed chunk parsing / validation:
if (!chunk.success) {
finishReason = "error"
controller.enqueue({ type: "error", error: chunk.error })
return
}
const value = chunk.value
metadataExtractor?.processChunk(chunk.rawValue)
// handle error chunks:
if ("error" in value) {
finishReason = "error"
controller.enqueue({ type: "error", error: value.error.message })
return
}
if (isFirstChunk) {
isFirstChunk = false
controller.enqueue({
type: "response-metadata",
...getResponseMetadata(value),
})
}
if (value.usage != null) {
const {
prompt_tokens,
completion_tokens,
total_tokens,
prompt_tokens_details,
completion_tokens_details,
} = value.usage
usage.promptTokens = prompt_tokens ?? undefined
usage.completionTokens = completion_tokens ?? undefined
usage.totalTokens = total_tokens ?? undefined
if (completion_tokens_details?.reasoning_tokens != null) {
usage.completionTokensDetails.reasoningTokens = completion_tokens_details?.reasoning_tokens
}
if (completion_tokens_details?.accepted_prediction_tokens != null) {
usage.completionTokensDetails.acceptedPredictionTokens =
completion_tokens_details?.accepted_prediction_tokens
}
if (completion_tokens_details?.rejected_prediction_tokens != null) {
usage.completionTokensDetails.rejectedPredictionTokens =
completion_tokens_details?.rejected_prediction_tokens
}
if (prompt_tokens_details?.cached_tokens != null) {
usage.promptTokensDetails.cachedTokens = prompt_tokens_details?.cached_tokens
}
}
const choice = value.choices[0]
if (choice?.finish_reason != null) {
finishReason = mapOpenAICompatibleFinishReason(choice.finish_reason)
}
if (choice?.delta == null) {
return
}
const delta = choice.delta
// 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" })
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,
})
toolCall.hasFinished = true
}
}
continue
}
// existing tool call, merge if not finished
const toolCall = toolCalls[index]
if (toolCall.hasFinished) {
continue
}
if (toolCallDelta.function?.arguments != null) {
toolCall.function!.arguments += toolCallDelta.function?.arguments ?? ""
}
// send delta
controller.enqueue({
type: "tool-input-delta",
id: toolCall.id,
delta: toolCallDelta.function.arguments ?? "",
})
// check if tool call is complete
if (
toolCall.function?.name != null &&
toolCall.function?.arguments != null &&
isParsableJson(toolCall.function.arguments)
) {
controller.enqueue({
type: "tool-input-end",
id: toolCall.id,
})
controller.enqueue({
type: "tool-call",
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments,
})
toolCall.hasFinished = true
}
}
}
},
flush(controller) {
if (isActiveReasoning) {
controller.enqueue({
type: "reasoning-end",
id: "reasoning-0",
// 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: SharedV2ProviderMetadata = {
[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: usage.promptTokens ?? undefined,
outputTokens: usage.completionTokens ?? undefined,
totalTokens: usage.totalTokens ?? undefined,
reasoningTokens: usage.completionTokensDetails.reasoningTokens ?? undefined,
cachedInputTokens: usage.promptTokensDetails.cachedTokens ?? undefined,
},
providerMetadata,
})
},
}),
),
request: { body },
response: { headers: responseHeaders },
}
}
}
const openaiCompatibleTokenUsageSchema = z
.object({
prompt_tokens: z.number().nullish(),
completion_tokens: z.number().nullish(),
total_tokens: z.number().nullish(),
prompt_tokens_details: z
.object({
cached_tokens: z.number().nullish(),
})
.nullish(),
completion_tokens_details: z
.object({
reasoning_tokens: z.number().nullish(),
accepted_prediction_tokens: z.number().nullish(),
rejected_prediction_tokens: z.number().nullish(),
})
.nullish(),
})
.nullish()
// limited version of the schema, focussed on what is needed for the implementation
// this approach limits breakages when the API changes and increases efficiency
const OpenAICompatibleChatResponseSchema = z.object({
id: z.string().nullish(),
created: z.number().nullish(),
model: z.string().nullish(),
choices: z.array(
z.object({
message: z.object({
role: z.literal("assistant").nullish(),
content: z.string().nullish(),
// 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,
])
@@ -0,0 +1,28 @@
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>
@@ -0,0 +1,44 @@
import type { SharedV2ProviderMetadata } from "@ai-sdk/provider"
/**
Extracts provider-specific metadata from API responses.
Used to standardize metadata handling across different LLM providers while allowing
provider-specific metadata to be captured.
*/
export type MetadataExtractor = {
/**
* Extracts provider metadata from a complete, non-streaming response.
*
* @param parsedBody - The parsed response JSON body from the provider's API.
*
* @returns Provider-specific metadata or undefined if no metadata is available.
* The metadata should be under a key indicating the provider id.
*/
extractMetadata: ({ parsedBody }: { parsedBody: unknown }) => Promise<SharedV2ProviderMetadata | 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(): SharedV2ProviderMetadata | undefined
}
}
@@ -0,0 +1,87 @@
import {
type LanguageModelV2CallOptions,
type LanguageModelV2CallWarning,
UnsupportedFunctionalityError,
} from "@ai-sdk/provider"
export function prepareTools({
tools,
toolChoice,
}: {
tools: LanguageModelV2CallOptions["tools"]
toolChoice?: LanguageModelV2CallOptions["toolChoice"]
}): {
tools:
| undefined
| Array<{
type: "function"
function: {
name: string
description: string | undefined
parameters: unknown
}
}>
toolChoice: { type: "function"; function: { name: string } } | "auto" | "none" | "required" | undefined
toolWarnings: LanguageModelV2CallWarning[]
} {
// when the tools array is empty, change it to undefined to prevent errors:
tools = tools?.length ? tools : undefined
const toolWarnings: LanguageModelV2CallWarning[] = []
if (tools == null) {
return { tools: undefined, toolChoice: undefined, toolWarnings }
}
const openaiCompatTools: Array<{
type: "function"
function: {
name: string
description: string | undefined
parameters: unknown
}
}> = []
for (const tool of tools) {
if (tool.type === "provider-defined") {
toolWarnings.push({ type: "unsupported-tool", tool })
} else {
openaiCompatTools.push({
type: "function",
function: {
name: tool.name,
description: tool.description,
parameters: tool.inputSchema,
},
})
}
}
if (toolChoice == null) {
return { tools: openaiCompatTools, toolChoice: undefined, toolWarnings }
}
const type = toolChoice.type
switch (type) {
case "auto":
case "none":
case "required":
return { tools: openaiCompatTools, toolChoice: type, toolWarnings }
case "tool":
return {
tools: openaiCompatTools,
toolChoice: {
type: "function",
function: { name: toolChoice.toolName },
},
toolWarnings,
}
default: {
const _exhaustiveCheck: never = type
throw new UnsupportedFunctionalityError({
functionality: `tool choice type: ${_exhaustiveCheck}`,
})
}
}
}
@@ -1,6 +1,6 @@
import type { LanguageModelV2 } from "@ai-sdk/provider"
import { OpenAICompatibleChatLanguageModel } from "@ai-sdk/openai-compatible"
import { type FetchFunction, withoutTrailingSlash, withUserAgentSuffix } from "@ai-sdk/provider-utils"
import { OpenAICompatibleChatLanguageModel } from "./chat/openai-compatible-chat-language-model"
import { OpenAIResponsesLanguageModel } from "./responses/openai-responses-language-model"
// Import the version or define it
@@ -0,0 +1,2 @@
export { createOpenaiCompatible, openaiCompatible } from "./copilot-provider"
export type { OpenaiCompatibleProvider, OpenaiCompatibleProviderSettings } from "./copilot-provider"
@@ -0,0 +1,27 @@
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,
}
@@ -183,7 +183,7 @@ export async function convertToOpenAIResponsesInput({
case "reasoning": {
const providerOptions = await parseProviderOptions({
provider: "openai",
provider: "copilot",
providerOptions: part.providerOptions,
schema: openaiResponsesReasoningProviderOptionsSchema,
})
@@ -194,7 +194,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV2 {
}
const openaiOptions = await parseProviderOptions({
provider: "openai",
provider: "copilot",
providerOptions,
schema: openaiResponsesProviderOptionsSchema,
})
@@ -1,2 +0,0 @@
export { createOpenaiCompatible, openaiCompatible } from "./openai-compatible-provider"
export type { OpenaiCompatibleProvider, OpenaiCompatibleProviderSettings } from "./openai-compatible-provider"
+80 -3
View File
@@ -20,6 +20,7 @@ export namespace ProviderTransform {
function sdkKey(npm: string): string | undefined {
switch (npm) {
case "@ai-sdk/github-copilot":
return "copilot"
case "@ai-sdk/openai":
case "@ai-sdk/azure":
return "openai"
@@ -82,7 +83,11 @@ export namespace ProviderTransform {
return msg
})
}
if (model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral")) {
if (
model.providerID === "mistral" ||
model.api.id.toLowerCase().includes("mistral") ||
model.api.id.toLocaleLowerCase().includes("devstral")
) {
const result: ModelMessage[] = []
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]
@@ -179,6 +184,9 @@ export namespace ProviderTransform {
openaiCompatible: {
cache_control: { type: "ephemeral" },
},
copilot: {
copilot_cache_control: { type: "ephemeral" },
},
}
for (const msg of unique([...system, ...final])) {
@@ -353,6 +361,15 @@ export namespace ProviderTransform {
return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/github-copilot":
if (model.id.includes("gemini")) {
// currently github copilot only returns thinking
return {}
}
if (model.id.includes("claude")) {
return {
thinking: { thinking_budget: 4000 },
}
}
const copilotEfforts = iife(() => {
if (id.includes("5.1-codex-max") || id.includes("5.2")) return [...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
return WIDELY_SUPPORTED_EFFORTS
@@ -377,6 +394,31 @@ export namespace ProviderTransform {
case "@ai-sdk/deepinfra":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra
case "@ai-sdk/openai-compatible":
// When using openai-compatible SDK with Claude/Anthropic models,
// we must use snake_case (budget_tokens) as the SDK doesn't convert parameter names
// and the OpenAI-compatible API spec uses snake_case
if (
model.providerID === "anthropic" ||
model.api.id.includes("anthropic") ||
model.api.id.includes("claude") ||
model.id.includes("anthropic") ||
model.id.includes("claude")
) {
return {
high: {
thinking: {
type: "enabled",
budget_tokens: 16000,
},
},
max: {
thinking: {
type: "enabled",
budget_tokens: 31999,
},
},
}
}
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/azure":
@@ -533,6 +575,26 @@ export namespace ProviderTransform {
case "@ai-sdk/perplexity":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity
return {}
case "@mymediset/sap-ai-provider":
case "@jerome-benoit/sap-ai-provider-v2":
if (model.api.id.includes("anthropic")) {
return {
high: {
thinking: {
type: "enabled",
budgetTokens: 16000,
},
},
max: {
thinking: {
type: "enabled",
budgetTokens: 31999,
},
},
}
}
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
}
return {}
}
@@ -594,9 +656,12 @@ export namespace ProviderTransform {
result["reasoningEffort"] = "medium"
}
// Only set textVerbosity for non-chat gpt-5.x models
// Chat models (e.g. gpt-5.2-chat-latest) only support "medium" verbosity
if (
input.model.api.id.includes("gpt-5.") &&
!input.model.api.id.includes("codex") &&
!input.model.api.id.includes("-chat") &&
input.model.providerID !== "azure"
) {
result["textVerbosity"] = "low"
@@ -653,9 +718,21 @@ export namespace ProviderTransform {
const modelCap = modelLimit || globalLimit
const standardLimit = Math.min(modelCap, globalLimit)
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
// Handle thinking mode for @ai-sdk/anthropic, @ai-sdk/google-vertex/anthropic (budgetTokens)
// and @ai-sdk/openai-compatible with Claude (budget_tokens)
if (
npm === "@ai-sdk/anthropic" ||
npm === "@ai-sdk/google-vertex/anthropic" ||
npm === "@ai-sdk/openai-compatible"
) {
const thinking = options?.["thinking"]
const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0
// Support both camelCase (for @ai-sdk/anthropic) and snake_case (for openai-compatible)
const budgetTokens =
typeof thinking?.["budgetTokens"] === "number"
? thinking["budgetTokens"]
: typeof thinking?.["budget_tokens"] === "number"
? thinking["budget_tokens"]
: 0
const enabled = thinking?.["type"] === "enabled"
if (enabled && budgetTokens > 0) {
// Return text tokens so that text + thinking <= model cap, preferring 32k text when possible.
+6
View File
@@ -108,6 +108,12 @@ export namespace Pty {
TERM: "xterm-256color",
OPENCODE_TERMINAL: "1",
} as Record<string, string>
if (process.platform === "win32") {
env.LC_ALL = "C.UTF-8"
env.LC_CTYPE = "C.UTF-8"
env.LANG = "C.UTF-8"
}
log.info("creating session", { id, cmd: command, args, cwd })
const spawn = await pty()
+9 -8
View File
@@ -148,14 +148,15 @@ export namespace LLM {
},
)
const maxOutputTokens = isCodex
? undefined
: ProviderTransform.maxOutputTokens(
input.model.api.npm,
params.options,
input.model.limit.output,
OUTPUT_TOKEN_MAX,
)
const maxOutputTokens =
isCodex || provider.id.includes("github-copilot")
? undefined
: ProviderTransform.maxOutputTokens(
input.model.api.npm,
params.options,
input.model.limit.output,
OUTPUT_TOKEN_MAX,
)
const tools = await resolveTools(input)
+1 -7
View File
@@ -89,13 +89,7 @@ export namespace SessionRetry {
if (json.type === "error" && json.error?.code?.includes("rate_limit")) {
return "Rate Limited"
}
if (
json.error?.message?.includes("no_kv_space") ||
(json.type === "error" && json.error?.type === "server_error") ||
!!json.error
) {
return "Provider Server Error"
}
return JSON.stringify(json)
} catch {
return undefined
}
+2
View File
@@ -18,6 +18,7 @@ export namespace Skill {
name: z.string(),
description: z.string(),
location: z.string(),
content: z.string(),
})
export type Info = z.infer<typeof Info>
@@ -74,6 +75,7 @@ export namespace Skill {
name: parsed.data.name,
description: parsed.data.description,
location: match,
content: md.content,
}
}
+6 -2
View File
@@ -91,6 +91,10 @@ export const BashTool = Tool.define("bash", async () => {
for (const node of tree.rootNode.descendantsOfType("command")) {
if (!node) continue
// Get full command text including redirects if present
let commandText = node.parent?.type === "redirected_statement" ? node.parent.text : node.text
const command = []
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i)
@@ -131,8 +135,8 @@ export const BashTool = Tool.define("bash", async () => {
// cd covered by above check
if (command.length && command[0] !== "cd") {
patterns.add(command.join(" "))
always.add(BashArity.prefix(command).join(" ") + "*")
patterns.add(commandText)
always.add(BashArity.prefix(command).join(" ") + " *")
}
}
+1 -9
View File
@@ -37,15 +37,7 @@ export const GrepTool = Tool.define("grep", {
await assertExternalDirectory(ctx, searchPath, { kind: "directory" })
const rgPath = await Ripgrep.filepath()
const args = [
"-nH",
"--hidden",
"--follow",
"--no-messages",
"--field-match-separator=|",
"--regexp",
params.pattern,
]
const args = ["-nH", "--hidden", "--no-messages", "--field-match-separator=|", "--regexp", params.pattern]
if (params.include) {
args.push("--glob", params.include)
}
+1 -2
View File
@@ -2,7 +2,6 @@ import path from "path"
import z from "zod"
import { Tool } from "./tool"
import { Skill } from "../skill"
import { ConfigMarkdown } from "../config/markdown"
import { PermissionNext } from "../permission/next"
export const SkillTool = Tool.define("skill", async (ctx) => {
@@ -62,7 +61,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
always: [params.name],
metadata: {},
})
const content = (await ConfigMarkdown.parse(skill.location)).content
const content = skill.content
const dir = path.dirname(skill.location)
// Format output similar to plugin pattern