From 15b046250c1d6eb4c2c1f745b6147e1ec531d9f9 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 27 Oct 2026 18:06:35 +0530 Subject: [PATCH 1/2] feat(aikit): declare tool changes as transcript system messages Add a transcript SystemMessage that adds or removes tools mid-conversation. Context.tools is the initial declaration; each provider takes changes in place where its transport allows and folds them into the top-level tool list otherwise. - Codex and Copilot Responses share one Responses model with a per-backend request profile, declare every tool in additional_tools items and announce removals as text, keeping the cached prefix across changes - track Responses stream items by output_index, since Copilot rotates item ids on every event - limit Copilot models to the ones the login can reach - leave thinking out for Copilot Claude models that cannot disable it, and send the off effort to Copilot GPT models that take one - send tool_choice and parallel_tool_calls by default for Codex only - read the error message from the raw body when the adapter cannot parse it - replay grammar tool calls from every declared tool, and keep in-place declarations out of requests limited by activeTools - remove ToolCall.addedToolNames; the harness does not store system messages yet --- packages/aikit/src/cli/modelgen.ts | 55 ++- packages/aikit/src/index.ts | 1 + packages/aikit/src/llm/failure.ts | 15 +- packages/aikit/src/llm/options.ts | 7 +- packages/aikit/src/llm/provider.ts | 24 +- packages/aikit/src/llm/stream.ts | 38 +- packages/aikit/src/llm/thinking.ts | 13 +- packages/aikit/src/llm/transform.ts | 228 +++++++--- packages/aikit/src/message/message.ts | 49 +- packages/aikit/src/message/transcript.ts | 159 +++++++ packages/aikit/src/oauth/github/copilot.ts | 15 + .../github-copilot/copilot-provider.ts | 54 ++- .../providers/openai-codex/codex-provider.ts | 88 +++- .../aikit/src/providers/openai-codex/index.ts | 48 +- .../codex-error.ts => responses/error.ts} | 44 +- .../model.ts} | 422 +++++++++--------- .../codex-prompt.ts => responses/prompt.ts} | 189 ++++---- .../codex-sse.ts => responses/sse.ts} | 8 +- .../codex-tools.ts => responses/tools.ts} | 155 +++---- .../codex-usage.ts => responses/usage.ts} | 6 +- packages/aikit/src/utils/estimate.ts | 31 +- .../text-only-images.openai-codex.json | 27 ++ ...ing-handoff.anthropic-to-openai-codex.json | 4 + .../__artifacts__/tools-added.anthropic.json | 6 + .../tools-added.github-copilot-chat.json | 6 + .../tools-added.github-copilot-messages.json | 6 + .../tools-added.github-copilot-responses.json | 14 + .../e2e/__artifacts__/tools-added.google.json | 6 + .../tools-added.openai-codex.json | 13 + .../e2e/__artifacts__/tools-added.openai.json | 12 + .../__artifacts__/tools-added.openrouter.json | 6 + .../aikit/test/e2e/tools.added.e2e.test.ts | 232 ++++++++++ packages/aikit/test/estimate.test.ts | 5 +- packages/aikit/test/failure.test.ts | 25 ++ packages/aikit/test/modelgen.test.ts | 11 +- .../aikit/test/oauth.github-copilot.test.ts | 13 + .../test/provider/github-copilot.test.ts | 95 ++++ .../test/provider/openai.codex.prompt.test.ts | 32 +- .../provider/openai.codex.request.test.ts | 134 ++++-- .../test/provider/openai.codex.stream.test.ts | 6 +- .../test/thinking.provideroptions.test.ts | 14 + packages/aikit/test/transcript.test.ts | 249 +++++++++++ packages/aikit/test/transform.test.ts | 48 +- packages/aikit/test/utils/openai-codex.ts | 84 +++- .../server.model-list-copilot.json | 7 + packages/codework/test/server.test.ts | 41 ++ packages/harness/src/context/assemble.ts | 8 +- packages/harness/src/context/codec.ts | 18 +- packages/harness/src/model/catalog.ts | 26 +- packages/harness/test/context.test.ts | 2 +- .../test/fixtures/runner.cycle.spec.ts | 4 +- packages/harness/test/plugin.external.test.ts | 3 +- packages/harness/test/runner.loop.test.ts | 4 +- .../harness/test/tools.read.vision.test.ts | 1 + 54 files changed, 2083 insertions(+), 728 deletions(-) create mode 100644 packages/aikit/src/message/transcript.ts rename packages/aikit/src/providers/{openai-codex/codex-error.ts => responses/error.ts} (80%) rename packages/aikit/src/providers/{openai-codex/codex-language-model.ts => responses/model.ts} (55%) rename packages/aikit/src/providers/{openai-codex/codex-prompt.ts => responses/prompt.ts} (63%) rename packages/aikit/src/providers/{openai-codex/codex-sse.ts => responses/sse.ts} (72%) rename packages/aikit/src/providers/{openai-codex/codex-tools.ts => responses/tools.ts} (55%) rename packages/aikit/src/providers/{openai-codex/codex-usage.ts => responses/usage.ts} (91%) create mode 100644 packages/aikit/test/e2e/__artifacts__/tools-added.anthropic.json create mode 100644 packages/aikit/test/e2e/__artifacts__/tools-added.github-copilot-chat.json create mode 100644 packages/aikit/test/e2e/__artifacts__/tools-added.github-copilot-messages.json create mode 100644 packages/aikit/test/e2e/__artifacts__/tools-added.github-copilot-responses.json create mode 100644 packages/aikit/test/e2e/__artifacts__/tools-added.google.json create mode 100644 packages/aikit/test/e2e/__artifacts__/tools-added.openai-codex.json create mode 100644 packages/aikit/test/e2e/__artifacts__/tools-added.openai.json create mode 100644 packages/aikit/test/e2e/__artifacts__/tools-added.openrouter.json create mode 100644 packages/aikit/test/e2e/tools.added.e2e.test.ts create mode 100644 packages/aikit/test/transcript.test.ts create mode 100644 packages/codework/test/__artifacts__/server.model-list-copilot.json diff --git a/packages/aikit/src/cli/modelgen.ts b/packages/aikit/src/cli/modelgen.ts index 96e94a5c..842e3bbc 100644 --- a/packages/aikit/src/cli/modelgen.ts +++ b/packages/aikit/src/cli/modelgen.ts @@ -288,6 +288,13 @@ const OPENAI_LONG_CONTEXT_PRICING_MODELS = new Set([ "gpt-5.5-pro", ]); +function openAiAcceptsNoReasoning(id: string): boolean { + return ( + OPENAI_RESPONSES_NONE_REASONING_MODELS.has(id) || + (OPENAI_GPT6_MODELS.has(id) && !OPENAI_GPT6_NO_NONE_MODELS.has(id)) + ); +} + function isOpenAiResponses(model: Model.Info): boolean { return model.protocol === Model.KnownProviderEnum.openai || model.protocol === Model.KnownProviderEnum.openaiCodex; } @@ -494,6 +501,20 @@ const OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS = new Set([ "gpt-6-sol", "gpt-6.1-sol", ]); +// Copilot passes `additional_tools` items through to OpenAI for these Responses models. +const GITHUB_COPILOT_ADDITIONAL_TOOLS_MODEL_IDS = new Set([ + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-pro", + "gpt-5.5", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-6-astra", + "gpt-6-sol", + "gpt-6-luna", + "gpt-6.1-sol", +]); // Checked live: the Codex backend accepts reasoning.effort "none" for these. const OPENAI_CODEX_NONE_REASONING_MODEL_IDS = new Set(["gpt-5.6-luna", "gpt-6-luna", "gpt-6-sol"]); @@ -557,21 +578,20 @@ const GITHUB_COPILOT_EXTENDED_CONTEXT = 1_000_000; // models as supporting the extended 1 million token context window (pi). const GITHUB_COPILOT_EXTENDED_CONTEXT_MODELS = new Set([ "claude-fable-5", - "claude-opus-4.6", - "claude-opus-4.7", "claude-opus-4.8", "claude-opus-5", + "claude-opus-5.5", "claude-sonnet-4.6", "claude-sonnet-5", "gpt-5.3-codex", - "gpt-5.4", - "gpt-5.5", + "gpt-6-astra", + "gpt-6-luna", + "gpt-6-sol", ]); // Checked manually against the authenticated GitHub Copilot /models endpoint // (pi). Narrow corrections over models.dev metadata, not a catalog snapshot. const GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES: Record = { - "claude-opus-4.7": { minimal: "low" }, "claude-opus-4.8": { minimal: "low" }, "claude-opus-5": { minimal: "low" }, "claude-sonnet-4.6": { minimal: "low", max: "max" }, @@ -610,8 +630,8 @@ function githubCopilotThinkingLevels(model: ModelsDevModel, method: Model.APIMet const listed = modelsDevEfforts(model); const efforts = listed ? new Set(listed) : undefined; const map: ThinkingLevelMap = {}; - // Copilot rejects `reasoningEffort: "none"` (pi), and the chat route has no - // reasoning control, so only Messages models can disable thinking. + // Off is opt-in per model on Responses (Grok and MAI reject "none"), and the chat route + // has no reasoning control. if (method !== Model.APIMethodEnum.messages) map.off = null; if (!efforts) return Object.keys(map).length > 0 ? map : undefined; @@ -650,19 +670,6 @@ export function githubCopilotBuiltInModels(provider: ModelsDevProvider | undefin api: { id, url: GITHUB_COPILOT_BASE_URL, method }, providerOptionsKey: GITHUB_COPILOT_PROVIDER_ID, protocol: Model.KnownProviderEnum.githubCopilot, - // Copilot's Responses route requires store=false + encrypted reasoning - // content for thinking to round-trip. - ...(method === Model.APIMethodEnum.responses - ? { - providerOptions: { - ...base.providerOptions, - [GITHUB_COPILOT_PROVIDER_ID]: { - store: false, - include: ["reasoning.encrypted_content"], - }, - }, - } - : {}), }; if (GITHUB_COPILOT_EXTENDED_CONTEXT_MODELS.has(id)) info.contextWindow = GITHUB_COPILOT_EXTENDED_CONTEXT; @@ -675,6 +682,9 @@ export function githubCopilotBuiltInModels(provider: ModelsDevProvider | undefin if (method === Model.APIMethodEnum.responses && gpt !== null && Number(gpt[1]) >= 5) { mergeCompat(info, { supportsOpenAIGrammarTools: true }); } + if (method === Model.APIMethodEnum.responses && GITHUB_COPILOT_ADDITIONAL_TOOLS_MODEL_IDS.has(id)) { + mergeCompat(info, { supportsAdditionalTools: true }); + } if (id.startsWith("gpt-5")) mergeThinkingLevelMap(info, { off: null, minimal: "low" }); if (id.includes("fable-5")) mergeThinkingLevelMap(info, { off: null, xhigh: "xhigh", max: "max" }); @@ -682,6 +692,11 @@ export function githubCopilotBuiltInModels(provider: ModelsDevProvider | undefin if (levels) mergeThinkingLevelMap(info, levels); const override = GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES[id]; if (override) mergeThinkingLevelMap(info, override); + // Copilot forwards reasoning.effort to OpenAI unchanged, so the GPT models OpenAI lets + // turn reasoning off take "none" here too (checked live). + if (method === Model.APIMethodEnum.responses && openAiAcceptsNoReasoning(id)) { + mergeThinkingLevelMap(info, { off: "none" }); + } models[id] = info; } diff --git a/packages/aikit/src/index.ts b/packages/aikit/src/index.ts index 2026f496..6905e3ac 100644 --- a/packages/aikit/src/index.ts +++ b/packages/aikit/src/index.ts @@ -5,6 +5,7 @@ export * as Protocol from "./llm/protocol.ts"; export { getEnvApiKey } from "./llm/runtime.ts"; export { ThinkingBudgets } from "./llm/shared.ts"; export * as Message from "./message/message.ts"; +export * as Transcript from "./message/transcript.ts"; export * as Model from "./model/model.ts"; export { stream } from "./stream.ts"; export { createAssistantMessageEventStream, EventStream } from "./utils/eventstream.ts"; diff --git a/packages/aikit/src/llm/failure.ts b/packages/aikit/src/llm/failure.ts index cc8c4fd4..ebc74fb5 100644 --- a/packages/aikit/src/llm/failure.ts +++ b/packages/aikit/src/llm/failure.ts @@ -78,9 +78,20 @@ const retryAfter = (headers: Record | undefined): number | undef if (Number.isFinite(date)) return Math.max(0, date - Date.now()); }; +const parseJson = (text: string | undefined): unknown => { + if (text === undefined) return undefined; + try { + return JSON.parse(text); + } catch { + return undefined; + } +}; + const apiMetadata = (error: APICallError): Metadata => { - const code = errorCode(error.data); - const data = record(error.data); + // An adapter fronting another vendor's error shape (Copilot's OpenAI-style errors on the + // Anthropic route) leaves `data` unset; the raw body still carries the message and code. + const data = record(error.data) ?? record(parseJson(error.responseBody)); + const code = errorCode(data); const nested = record(data?.error); const structuredMessage = nonEmptyString(data?.message) ?? nonEmptyString(nested?.message); const message = normalizeMessage( diff --git a/packages/aikit/src/llm/options.ts b/packages/aikit/src/llm/options.ts index 1d3af76e..fb0db361 100644 --- a/packages/aikit/src/llm/options.ts +++ b/packages/aikit/src/llm/options.ts @@ -18,7 +18,8 @@ import type { OpenRouterProviderOptions, OpenRouterProviderSettings } from "@ope import { Type, type Static } from "typebox"; import * as Model from "../model/model.ts"; import type { GitHubCopilotProviderSettings } from "../providers/github-copilot/index.ts"; -import type { OpenAICodexLanguageModelOptions, OpenAICodexProviderSettings } from "../providers/openai-codex/index.ts"; +import type { OpenAICodexProviderSettings } from "../providers/openai-codex/index.ts"; +import type { ResponsesLanguageModelOptions } from "../providers/responses/model.ts"; import type * as Protocol from "./protocol.ts"; import { SharedOptions, ThinkingBudgets } from "./shared.ts"; @@ -68,7 +69,7 @@ type OpenAICompatibleLanguageOptions = type XaiLanguageOptions = XaiLanguageModelResponsesOptions; type GitHubCopilotLanguageOptions = | AnthropicLanguageModelOptions - | OpenAILanguageModelResponsesOptions + | ResponsesLanguageModelOptions | OpenAICompatibleLanguageModelChatOptions; export type OpenAIOptions = AISDKOptions; @@ -82,7 +83,7 @@ export type OpenAICompatibleOptions = AISDKOptions< >; export type OpenAICodexOptions = AISDKOptions< OpenAICodexProviderSettings, - { "openai-codex"?: OpenAICodexLanguageModelOptions } + { "openai-codex"?: ResponsesLanguageModelOptions } >; export type GitHubCopilotOptions = AISDKOptions< GitHubCopilotProviderSettings, diff --git a/packages/aikit/src/llm/provider.ts b/packages/aikit/src/llm/provider.ts index cdeb82a0..a6f99628 100644 --- a/packages/aikit/src/llm/provider.ts +++ b/packages/aikit/src/llm/provider.ts @@ -79,22 +79,28 @@ export async function resolveAISDKLanguageModel( } } + const cacheDisabled = + options.cacheRetention === "none" || + (typeof process !== "undefined" && process.env.CODEWORK_CACHE_RETENTION === "none"); + // Our own Responses model (Codex, Copilot `/responses`) reads image support from compat. + const responsesCompat = () => ({ + ...model.compat, + supportsImages: model.input.includes("image"), + ...(typeof factoryOptions.compat === "object" && factoryOptions.compat !== null ? factoryOptions.compat : {}), + }); + if (npm === "@codeworksh/ai-sdk-openai-codex") { // Codex uses the session id for the session-id header and prompt cache key. - const cacheDisabled = - options.cacheRetention === "none" || - (typeof process !== "undefined" && process.env.CODEWORK_CACHE_RETENTION === "none"); if (options.sessionId && !cacheDisabled) factoryOptions.sessionId ??= options.sessionId; - factoryOptions.compat = { - ...model.compat, - supportsImages: model.input.includes("image"), - ...(typeof factoryOptions.compat === "object" && factoryOptions.compat !== null ? factoryOptions.compat : {}), - }; + factoryOptions.compat = responsesCompat(); } if (npm === "@codeworksh/ai-sdk-github-copilot") { - // Copilot uses the session id for X-Interaction-Id request grouping. + // Copilot uses the session id for X-Interaction-Id request grouping, and as the + // Responses prompt cache key. if (options.sessionId) factoryOptions.sessionId ??= options.sessionId; + if (options.sessionId && !cacheDisabled) factoryOptions.promptCacheKey ??= options.sessionId; + factoryOptions.compat = responsesCompat(); const budgetField = Model.thinkingTokenBudgetField(model); if (budgetField && thinkingTokenBudget !== undefined && thinkingTokenBudget > 0) { diff --git a/packages/aikit/src/llm/stream.ts b/packages/aikit/src/llm/stream.ts index aa157d7f..525dcf7d 100644 --- a/packages/aikit/src/llm/stream.ts +++ b/packages/aikit/src/llm/stream.ts @@ -1,6 +1,7 @@ import { APICallError } from "@ai-sdk/provider"; import { RetryError, streamText, type TextStreamPart, type ToolSet } from "ai"; import * as Message from "../message/message.ts"; +import * as Transcript from "../message/transcript.ts"; import * as Model from "../model/model.ts"; import { AssistantMessageEventStream } from "../utils/eventstream.ts"; import { compact } from "../utils/helpers.ts"; @@ -15,9 +16,11 @@ import * as Thinking from "./thinking.ts"; import { googleThoughtSignature, convertMessages, + isOwnResponses, convertTools, createAssistantMessage, encodeOpenAIReasoningSignature, + grammarToolInputProperties, mapFinishReason, mapUsage, toolCallFromPart, @@ -79,11 +82,18 @@ function cacheProviderOptions(model: Model.Info, options: RuntimeOptions): Provi return {}; } -function resolveProviderOptions(model: Model.Info, options: RuntimeOptions, plan: Thinking.Plan): ProviderOptionBag { +function resolveProviderOptions( + model: Model.Info, + context: Message.Context, + options: RuntimeOptions, + plan: Thinking.Plan, +): ProviderOptionBag { + const grammars = grammarToolInputProperties(context, model, options.method); return mergeProviderOptions( model.providerOptions as ProviderOptionBag | undefined, cacheProviderOptions(model, options), Thinking.reasoningProviderOptions(model, plan), + grammars === undefined ? undefined : { [Model.optionsKey(model)]: { grammarToolInputProperties: grammars } }, options.providerOptions as ProviderOptionBag | undefined, ); } @@ -92,9 +102,10 @@ function partIndex(output: Message.AssistantMessage, block: StreamBlock): number return output.parts.indexOf(block as Message.AssistantMessage["parts"][number]); } -function openAICodexMetadata(value: unknown): Record | undefined { +/** Metadata our Responses transport attaches under the model's provider key. */ +function responsesMetadata(value: unknown, model: Model.Info): Record | undefined { if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined; - const metadata = (value as Record)["openai-codex"]; + const metadata = (value as Record)[Model.optionsKey(model)]; return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) ? (metadata as Record) : undefined; @@ -206,6 +217,7 @@ function finishToolInput(output: Message.AssistantMessage, id: string, stream: A function finalizeToolCall( output: Message.AssistantMessage, part: Extract, { type: "tool-call" }>, + model: Model.Info, stream: AssistantMessageEventStream, ): void { const existing = output.parts.find((item) => item.type === "toolCall" && item.callID === part.toolCallId) as @@ -216,7 +228,7 @@ function finalizeToolCall( block.name = part.toolName; block.arguments = typeof part.input === "object" && part.input !== null ? (part.input as Record) : {}; - const namespace = openAICodexMetadata(part.providerMetadata)?.namespace; + const namespace = responsesMetadata(part.providerMetadata, model)?.namespace; const thoughtSignature = googleThoughtSignature(part.providerMetadata); if (thoughtSignature) block.thoughtSignature = thoughtSignature; if (typeof namespace === "string") block.namespace = namespace; @@ -262,7 +274,7 @@ function handlePart( break; } case "text-end": { - const messageId = openAICodexMetadata(part.providerMetadata)?.messageId; + const messageId = responsesMetadata(part.providerMetadata, model)?.messageId; finishTextBlock(output, part.id, stream, typeof messageId === "string" ? messageId : undefined); break; } @@ -288,7 +300,7 @@ function handlePart( break; } case "reasoning-end": { - const reasoningItem = openAICodexMetadata(part.providerMetadata)?.reasoningItem; + const reasoningItem = responsesMetadata(part.providerMetadata, model)?.reasoningItem; const openAISignature = encodeOpenAIReasoningSignature(part.providerMetadata); finishThinkingBlock( output, @@ -317,7 +329,7 @@ function handlePart( finishToolInput(output, part.id, stream); break; case "tool-call": - finalizeToolCall(output, part, stream); + finalizeToolCall(output, part, model, stream); break; case "start-step": stampProviderThinkingLevel(output, part.request.body); @@ -402,9 +414,10 @@ export const stream: Protocol.StreamFunction >[0]["providerOptions"]; const pricing: Pricing = { @@ -418,8 +431,11 @@ export const stream: Protocol.StreamFunction>[0] = compact({ model: languageModel, - system: context.systemPrompt, + // Our own Responses transport receives the system prompt among the messages. + system: isOwnResponses(model, runtimeOptions.method) ? undefined : context.systemPrompt, messages, + // Tool changes declared in place travel as system messages. + allowSystemInMessages: messages.some((message) => message.role === "system") || undefined, tools, toolChoice: runtimeOptions.toolChoice as Parameters>[0]["toolChoice"], activeTools, diff --git a/packages/aikit/src/llm/thinking.ts b/packages/aikit/src/llm/thinking.ts index 10537307..5d78ccae 100644 --- a/packages/aikit/src/llm/thinking.ts +++ b/packages/aikit/src/llm/thinking.ts @@ -134,11 +134,16 @@ export function disabledProviderOptions(model: Model.Info): ProviderOptionBag { } if (key === "github-copilot") { - // Copilot accepts `thinking: {type:"disabled"}` on the Messages route; on - // Responses it rejects `reasoningEffort: "none"`, and the chat route has no - // reasoning control at all. + // Copilot accepts `thinking: {type:"disabled"}` on the Messages route, except for a + // Claude that cannot disable thinking: it rejects the request, so the field is left + // out and Copilot's default applies. On Responses only the models mapped to an off + // effort take one, and the chat route has no reasoning control at all. + const off = model.thinkingLevelMap?.off; if (model.api?.method === Model.APIMethodEnum.messages) { - return { [key]: { thinking: { type: "disabled" } } }; + return off === null ? {} : { [key]: { thinking: { type: "disabled" } } }; + } + if (model.api?.method === Model.APIMethodEnum.responses && typeof off === "string") { + return { [key]: { reasoningEffort: off } }; } return {}; } diff --git a/packages/aikit/src/llm/transform.ts b/packages/aikit/src/llm/transform.ts index c51c3fd7..45e5a53e 100644 --- a/packages/aikit/src/llm/transform.ts +++ b/packages/aikit/src/llm/transform.ts @@ -9,10 +9,12 @@ import { type ToolSet, type UserModelMessage, } from "ai"; +import type { LanguageModelV3FunctionTool, SharedV3ProviderOptions } from "@ai-sdk/provider"; import * as Message from "../message/message.ts"; +import * as Transcript from "../message/transcript.ts"; import { Pricing } from "./pricing.ts"; import * as Model from "../model/model.ts"; -import { resolveOpenAICodexToolConstraint } from "../providers/openai-codex/codex-tools.ts"; +import { resolveToolGrammar, type ResponsesToolChange } from "../providers/responses/tools.ts"; import { shortHash } from "../utils/hash.ts"; import { parseStreamingJson } from "../utils/jsonparse.ts"; import { sanitizeSurrogates } from "../utils/sanitize.ts"; @@ -105,6 +107,15 @@ function isAnthropicWire(model: Model.Info, method: Model.APIMethodEnum): boolea return isAnthropicMessagesModel(model); } +/** Whether the model runs on our own Responses transport: Codex, and Copilot's `/responses`. */ +export function isOwnResponses(model: Model.Info, methodOverride?: Model.APIMethodEnum): boolean { + if (model.protocol === Model.KnownProviderEnum.openaiCodex) return true; + return ( + model.protocol === Model.KnownProviderEnum.githubCopilot && + resolveMethod(model, methodOverride) === Model.APIMethodEnum.responses + ); +} + function isChatCompletions(model: Model.Info, method: Model.APIMethodEnum): boolean { const npm = packageForModel(model); // The compatible adapter falls back to its chat model for every other method. @@ -215,7 +226,8 @@ export function normalizeForeignToolCallId(id: string): string { return normalizeOpenAICodexIdPart(id.split("|")[0] ?? id); } -function toolCallIdNormalizer(model: Model.Info) { +function toolCallIdNormalizer(model: Model.Info, method: Model.APIMethodEnum) { + if (isOwnResponses(model, method)) return normalizeOpenAICodexToolCallId; switch (model.protocol) { case Model.KnownProviderEnum.openaiCodex: return normalizeOpenAICodexToolCallId; @@ -240,9 +252,7 @@ function isRecord(value: unknown): value is Record { export function encodeOpenAIReasoningSignature(providerMetadata: unknown): string | undefined { if (!isRecord(providerMetadata)) return; - // Copilot's Responses adapter is named "github-copilot", so its reasoning - // metadata lands under that key rather than "openai". - const metadata = providerMetadata.openai ?? providerMetadata["github-copilot"]; + const metadata = providerMetadata.openai; if (!isRecord(metadata) || typeof metadata.itemId !== "string") return; const signature: OpenAIReasoningMetadata = { itemId: metadata.itemId }; @@ -268,6 +278,29 @@ function decodeOpenAIReasoningSignature(signature: string): OpenAIReasoningMetad } } +/** + * The reasoning item our Responses transport replays from a thinking signature. Copilot + * messages from before it ran on that transport carry `{ itemId, reasoningEncryptedContent }` + * instead of the item; those replay when they hold encrypted content. + */ +function responsesReasoningItem(signature: string): string | undefined { + try { + const parsed: unknown = JSON.parse(signature); + if (!isRecord(parsed)) return; + if (parsed.type === "reasoning" && typeof parsed.id === "string") return signature; + const legacy = decodeOpenAIReasoningSignature(signature); + if (!legacy || typeof legacy.reasoningEncryptedContent !== "string") return; + return JSON.stringify({ + type: "reasoning", + id: legacy.itemId, + encrypted_content: legacy.reasoningEncryptedContent, + summary: [], + }); + } catch { + return; + } +} + export function googleThoughtSignature(metadata: unknown): string | undefined { if (!isRecord(metadata)) return; for (const key of ["google", "google-vertex"]) { @@ -286,6 +319,11 @@ function assistantMessages( const assistantContent: Exclude = []; const toolResults: ToolModelMessage["content"] = []; const movedImageContent: Exclude = []; + // Replay metadata for our own Responses transport, only from a message it produced. + const responsesKey = + isOwnResponses(model, method) && message.protocol === model.protocol && message.provider.id === model.provider.id + ? Model.optionsKey(model) + : undefined; const googleReplay = (model.protocol === "google" || model.protocol === "google-vertex") && message.protocol === model.protocol && @@ -311,8 +349,8 @@ function assistantMessages( type: "text", text, ...(google ? { providerOptions: google } : {}), - ...(part.textSignature && message.protocol === Model.KnownProviderEnum.openaiCodex - ? { providerOptions: { "openai-codex": { messageId: part.textSignature } } } + ...(part.textSignature && responsesKey + ? { providerOptions: { [responsesKey]: { messageId: part.textSignature } } } : {}), }); continue; @@ -321,23 +359,26 @@ function assistantMessages( const thinking = sanitizeSurrogates(part.thinking); if (thinking.trim().length === 0 && !part.thinkingSignature) continue; + if (responsesKey) { + // Only a signed reasoning item replays; unsigned thinking is dropped. + const reasoningItem = part.thinkingSignature ? responsesReasoningItem(part.thinkingSignature) : undefined; + if (!reasoningItem) continue; + assistantContent.push({ + type: "reasoning", + text: thinking, + providerOptions: { [responsesKey]: { reasoningItem } }, + } as (typeof assistantContent)[number]); + continue; + } + if (message.protocol === Model.KnownProviderEnum.githubCopilot) { /* - * Copilot signs reasoning differently per endpoint family: Responses - * models round-trip encoded item metadata, Messages models carry - * Anthropic signatures, and the chat endpoint cannot replay thinking - * at all. Thinking that cannot be replayed natively survives as plain - * text instead of going out unsigned. + * Copilot Messages models carry Anthropic signatures, and the chat endpoint + * cannot replay thinking at all. Thinking that cannot be replayed natively + * survives as plain text instead of going out unsigned. */ - const method = model.api?.method; const openai = part.thinkingSignature ? decodeOpenAIReasoningSignature(part.thinkingSignature) : undefined; - if (method === Model.APIMethodEnum.responses && openai) { - assistantContent.push({ - type: "reasoning", - text: thinking, - providerOptions: { "github-copilot": openai }, - } as (typeof assistantContent)[number]); - } else if (method === Model.APIMethodEnum.messages && part.thinkingSignature && !openai) { + if (method === Model.APIMethodEnum.messages && part.thinkingSignature && !openai) { assistantContent.push({ type: "reasoning", text: thinking, @@ -359,36 +400,24 @@ function assistantMessages( } else if (google) { reasoning.providerOptions = google; } else if (part.thinkingSignature && message.protocol !== "google" && message.protocol !== "google-vertex") { - reasoning.providerOptions = - message.protocol === Model.KnownProviderEnum.openaiCodex - ? { "openai-codex": { reasoningItem: part.thinkingSignature } } - : { anthropic: { signature: part.thinkingSignature } }; + reasoning.providerOptions = { anthropic: { signature: part.thinkingSignature } }; } assistantContent.push(reasoning as (typeof assistantContent)[number]); continue; } if (part.type !== "toolCall") continue; - const codexToolMetadata: { namespace?: string; omitItemId?: boolean } = {}; - if (part.namespace && message.protocol === Model.KnownProviderEnum.openaiCodex) { - codexToolMetadata.namespace = part.namespace; - } - if ( - model.protocol === Model.KnownProviderEnum.openaiCodex && - message.protocol === model.protocol && - message.provider.id === model.provider.id && - message.model !== model.id - ) { - codexToolMetadata.omitItemId = true; - } + const responsesToolMetadata: { namespace?: string; omitItemId?: boolean } = {}; + if (responsesKey && part.namespace) responsesToolMetadata.namespace = part.namespace; + if (responsesKey && message.model !== model.id) responsesToolMetadata.omitItemId = true; assistantContent.push({ type: "tool-call", toolCallId: part.callID, toolName: part.name, input: sanitizeRecord(part.arguments ?? {}), ...(google ? { providerOptions: google } : {}), - ...(Object.keys(codexToolMetadata).length > 0 - ? { providerOptions: { "openai-codex": codexToolMetadata } } + ...(responsesKey && Object.keys(responsesToolMetadata).length > 0 + ? { providerOptions: { [responsesKey]: responsesToolMetadata } } : {}), }); @@ -398,9 +427,6 @@ function assistantMessages( type: "tool-result", toolCallId: part.callID, toolName: part.name, - ...(terminal?.addedToolNames?.length - ? { providerOptions: { "openai-codex": { addedToolNames: terminal.addedToolNames } } } - : {}), output: terminal ? toolResultOutput(terminal, moved) : { @@ -442,16 +468,85 @@ function assistantMessages( return result; } +/** + * How the model's transport expresses tool changes after the first request. Only our own + * Responses transport can declare tools in place; the AI SDK adapters take the current tool + * set top-level. A request limited to some tools (`activeTools`) takes them top-level too, + * since only that list is filtered. + */ +export function transcriptToolSupport( + model: Model.Info, + method: Model.APIMethodEnum | undefined, + restricted = false, +): Transcript.TranscriptToolSupport { + if (restricted || !isOwnResponses(model, method)) return "none"; + if (model.compat?.supportsAdditionalTools) return "changes"; + return model.compat?.supportsToolSearch ? "additions" : "none"; +} + +/** A tool as the language model receives it, for declaring it inside the prompt. */ +function functionTool(tool: Message.Tool, model: Model.Info, method: Model.APIMethodEnum): LanguageModelV3FunctionTool { + const { inputSchema, strict, providerOptions } = toolWire(tool, model, method); + return { + type: "function", + name: tool.name, + description: tool.description, + inputSchema: JSON.parse(JSON.stringify(inputSchema)) as LanguageModelV3FunctionTool["inputSchema"], + ...(strict ? { strict } : {}), + ...(providerOptions ? { providerOptions } : {}), + }; +} + +function toolChangeMessage( + toolsAdded: readonly Message.Tool[], + toolsRemoved: readonly Message.ToolReference[], + support: Transcript.TranscriptToolSupport, + model: Model.Info, + method: Model.APIMethodEnum, +): ModelMessage | undefined { + if (toolsAdded.length === 0 && toolsRemoved.length === 0) return; + const toolChange: ResponsesToolChange = { + mode: support === "changes" ? "changes" : "additions", + tools: toolsAdded.map((tool) => functionTool(tool, model, method)), + removed: toolsRemoved.map((tool) => tool.name), + }; + return { + role: "system", + content: "", + // Function tools are plain JSON once their schema went through `functionTool`. + providerOptions: { [Model.optionsKey(model)]: { toolChange } } as unknown as SharedV3ProviderOptions, + }; +} + export function convertMessages( context: Message.Context, model: Model.Info, methodOverride?: Model.APIMethodEnum, + activeTools?: readonly string[], ): ModelMessage[] { const method = resolveMethod(model, methodOverride); const messages: ModelMessage[] = []; - const transformedMessages = Message.transformMessages(context.messages, model, toolCallIdNormalizer(model)); + const transformedMessages = Message.transformMessages(context.messages, model, toolCallIdNormalizer(model, method)); + const support = transcriptToolSupport(model, method, activeTools !== undefined); + const transcript = Transcript.resolveTranscriptTools(context, support); + + if (transcript.initialToolsInPlace) { + const head = toolChangeMessage(context.tools ?? [], [], support, model, method); + if (head) messages.push(head); + } + // Our Responses transport takes the system prompt in place, after the initial tool declaration. + if (isOwnResponses(model, method) && context.systemPrompt) { + messages.push({ role: "system", content: sanitizeSurrogates(context.systemPrompt) }); + } + let systemIndex = 0; for (const msg of transformedMessages) { + if (msg.role === "system") { + if (systemIndex++ < transcript.foldedSystemMessages) continue; + const change = toolChangeMessage(msg.toolsAdded ?? [], msg.toolsRemoved ?? [], support, model, method); + if (change) messages.push(change); + continue; + } if (msg.role === "user") { messages.push(...userContent(msg.parts)); continue; @@ -462,21 +557,52 @@ export function convertMessages( return messages; } -export function convertTools(tools?: Message.Tool[], model?: Model.Info): ToolSet | undefined { +/** + * The string input property of every grammar tool the transcript declared, for our Responses + * transport: earlier calls to a tool since removed or redefined still replay as grammar calls. + */ +export function grammarToolInputProperties( + context: Message.Context, + model: Model.Info, + method: Model.APIMethodEnum | undefined, +): Record | undefined { + if (!isOwnResponses(model, method)) return; + const properties: Record = {}; + for (const tool of Transcript.getDeclaredTools(context)) { + const grammar = resolveToolGrammar(tool, model.compat); + if (grammar) properties[tool.name] = grammar.inputProperty; + } + return Object.keys(properties).length > 0 ? properties : undefined; +} + +/** The parameter schema, strict flag and grammar a tool goes out with on this model. */ +function toolWire(tool: Message.Tool, model?: Model.Info, method?: Model.APIMethodEnum) { + const strictParameters = resolveStrictParameters(tool, model); + const grammarConstraint = + model && isOwnResponses(model, method) ? resolveToolGrammar(tool, model.compat) : undefined; + return { + inputSchema: strictParameters ?? tool.parameters, + strict: strictParameters ? true : undefined, + providerOptions: + model && grammarConstraint ? { [Model.optionsKey(model)]: { grammar: grammarConstraint } } : undefined, + }; +} + +export function convertTools( + tools?: Message.Tool[], + model?: Model.Info, + method?: Model.APIMethodEnum, +): ToolSet | undefined { if (!tools || tools.length === 0) return; const result: ToolSet = {}; for (const tool of tools) { - const strictParameters = resolveStrictParameters(tool, model); - const grammarConstraint = - model?.protocol === Model.KnownProviderEnum.openaiCodex - ? resolveOpenAICodexToolConstraint(tool, model.compat) - : undefined; + const { inputSchema, strict, providerOptions } = toolWire(tool, model, method); result[tool.name] = { description: tool.description, - inputSchema: jsonSchema(strictParameters ?? (tool.parameters as any)), - ...(strictParameters ? { strict: true } : {}), - ...(grammarConstraint ? { providerOptions: { "openai-codex": { grammar: grammarConstraint } } } : {}), + inputSchema: jsonSchema(inputSchema as any), + ...(strict ? { strict } : {}), + ...(providerOptions ? { providerOptions } : {}), }; } return result; diff --git a/packages/aikit/src/message/message.ts b/packages/aikit/src/message/message.ts index e16d2a77..08cd4e58 100644 --- a/packages/aikit/src/message/message.ts +++ b/packages/aikit/src/message/message.ts @@ -37,8 +37,6 @@ export const ToolCallBaseSchema = Type.Object({ arguments: Type.Record(Type.String(), Type.Any()), thoughtSignature: Type.Optional(Type.String()), // Google-specific: opaque signature for reusing thought context namespace: Type.Optional(Type.String()), // OpenAI Responses namespace for dynamically loaded tools - /** Names from Context.tools that became available after this tool result. */ - addedToolNames: Type.Optional(Type.Array(Type.String())), time: Type.Object({ start: Type.Number(), // Unix timestamp in milliseconds end: Type.Number(), // Unix timestamp in milliseconds, last known lifecycle update @@ -198,9 +196,6 @@ export const AssistantMessageSchema = Type.Object({ }); export type AssistantMessage = Static; -export const MessageSchema = Type.Union([UserMessageSchema, AssistantMessageSchema]); -export type Message = Static; - type UserMessageInit = Omit & { messageId?: string; }; @@ -285,6 +280,46 @@ export function defineTool(tool: Tool) return tool; } +export const ToolReferenceSchema = Type.Object({ + name: Type.String(), +}); +export type ToolReference = Static; + +/** + * A change to the tool set at one point in the transcript. + * + * `Context.tools` holds the tools at the start of the conversation; each system message + * changes that set from its position on. Replaying them in order yields the current tools. + * A redefinition is a removal plus an addition of the same name. + */ +export const SystemMessageSchema = Type.Object({ + messageId: Type.String(), + role: Type.Literal("system"), + time: Type.Object({ + created: Type.Number(), + }), + /** Complete definitions of tools that become available at this point. */ + toolsAdded: Type.Optional(Type.Array(ToolSchema)), + /** Tools that stop being available at this point. */ + toolsRemoved: Type.Optional(Type.Array(ToolReferenceSchema)), + /** Caller data kept with the message; never sent to a provider. */ + metadata: Type.Optional(Type.Record(Type.String(), Type.Unknown())), +}); +export type SystemMessage = Static; + +export const MessageSchema = Type.Union([SystemMessageSchema, UserMessageSchema, AssistantMessageSchema]); +export type Message = Static; + +type SystemMessageInit = Omit & { + messageId?: string; + time?: SystemMessage["time"]; +}; + +export function createSystemMessage(message: SystemMessageInit): SystemMessage { + const { messageId = createMessageId(), time = { created: Date.now() }, ...rest } = message; + return { messageId, role: "system", time, ...rest }; +} + export const ContextSchema = Type.Object({ systemPrompt: Type.Optional(Type.String()), messages: Type.Array(MessageSchema), @@ -350,6 +385,10 @@ export function transformMessages( const transformed: Message[] = []; for (const msg of messages) { + if (msg.role === "system") { + transformed.push(msg); + continue; + } if (msg.role === "user") { transformed.push(supportsImages ? msg : { ...msg, parts: omitImages(msg.parts, USER_IMAGE_PLACEHOLDER) }); continue; diff --git a/packages/aikit/src/message/transcript.ts b/packages/aikit/src/message/transcript.ts new file mode 100644 index 00000000..c7e7c0e9 --- /dev/null +++ b/packages/aikit/src/message/transcript.ts @@ -0,0 +1,159 @@ +/* + * @file Tool state carried by a transcript. + * + * `Context.tools` is the tool set at the start of the conversation. Each system message + * changes it from its position on, so replaying them in order yields the tools available + * now. Providers either declare those changes in place, which keeps the cached prefix, or + * fold them into the top-level tool list. + */ + +import type { Context, Message, SystemMessage, Tool, ToolReference } from "./message.ts"; + +type Transcript = Pick; + +function isSystemMessage(message: Message): message is SystemMessage { + return message.role === "system"; +} + +/** The tool changes in order, led by `Context.tools` as the initial declaration. */ +function toolDeclarations(context: Transcript): Pick[] { + return [{ toolsAdded: context.tools ?? [] }, ...context.messages.filter(isSystemMessage)]; +} + +function replay(declarations: ReadonlyArray>): Tool[] { + const tools = new Map(); + for (const declaration of declarations) { + for (const tool of declaration.toolsRemoved ?? []) tools.delete(tool.name); + for (const tool of declaration.toolsAdded ?? []) tools.set(tool.name, tool); + } + return [...tools.values()]; +} + +/** The tools available after applying every transcript change in order. */ +export function getCurrentTools(context: Transcript): Tool[] { + return replay(toolDeclarations(context)); +} + +/** + * Every tool the transcript ever declared, the latest definition of each name winning. Removals + * are ignored: a removed tool's earlier calls still replay in the form it was called in. + */ +export function getDeclaredTools(context: Transcript): Tool[] { + const tools = new Map(); + for (const declaration of toolDeclarations(context)) { + for (const tool of declaration.toolsAdded ?? []) tools.set(tool.name, tool); + } + return [...tools.values()]; +} + +/** Strip a tool to what the model sees, for comparison and persistence. */ +export function toToolDeclaration(tool: Tool): Tool { + return { + name: tool.name, + description: tool.description, + parameters: JSON.parse(JSON.stringify(tool.parameters)) as Tool["parameters"], + ...(tool.constrainedSampling === undefined ? {} : { constrainedSampling: tool.constrainedSampling }), + }; +} + +/** + * Whether two tools declare the same interface to the model. The JSON round trip in + * {@link toToolDeclaration} drops typebox symbol keys and `undefined` fields and fixes the key + * order, so comparing the serialized declarations is exact. + */ +export function declarationsEqual(left: Tool, right: Tool): boolean { + return JSON.stringify(toToolDeclaration(left)) === JSON.stringify(toToolDeclaration(right)); +} + +export interface ToolStateChanges { + toolsAdded: Tool[]; + toolsRemoved: ToolReference[]; +} + +/** Compare two complete tool states. A changed definition is a removal followed by an addition. */ +export function getToolStateChanges(previous: readonly Tool[], current: readonly Tool[]): ToolStateChanges { + const previousTools = new Map(previous.map((tool) => [tool.name, tool])); + const currentTools = new Map(current.map((tool) => [tool.name, tool])); + return { + toolsAdded: current + .filter((tool) => { + const previousTool = previousTools.get(tool.name); + return previousTool === undefined || !declarationsEqual(previousTool, tool); + }) + .map(toToolDeclaration), + toolsRemoved: previous + .filter((tool) => { + const currentTool = currentTools.get(tool.name); + return currentTool === undefined || !declarationsEqual(tool, currentTool); + }) + .map((tool) => ({ name: tool.name })), + }; +} + +/** Index of the last declaration with a removal or same-name redeclaration, or -1. */ +function lastNonAdditiveChange(declarations: ReadonlyArray>) { + // A name declared before counts as a redeclaration even after its removal: a removed tool + // declared again later is not reliably picked up again. + const declared = new Set(); + let last = -1; + for (const [index, declaration] of declarations.entries()) { + if ((declaration.toolsRemoved?.length ?? 0) > 0) last = index; + for (const tool of declaration.toolsAdded ?? []) { + if (declared.has(tool.name)) last = index; + declared.add(tool.name); + } + } + return last; +} + +/** + * How a transport can express tool changes after the first request. + * + * - `"none"`: only the top-level tool list exists; it always holds the current tools. + * - `"additions"`: tools can be added at a later message, but not removed or redefined. + * - `"changes"`: every tool, including the initial ones, is declared in place, and a later + * declaration under the same name replaces the earlier one. Removals are not expressible on + * the wire, so the transport announces them as text and the removed tools stay declared. + */ +export type TranscriptToolSupport = "none" | "additions" | "changes"; + +export interface TranscriptTools { + /** Tools sent in the top-level request field. */ + requestTools: Tool[]; + /** Whether `Context.tools` is declared in place at the head of the input instead of top-level. */ + initialToolsInPlace: boolean; + /** + * Number of leading transcript system messages whose changes `requestTools` already holds. + * Every later system message declares its changes in place. + */ + foldedSystemMessages: number; +} + +/** + * Split tool declarations between the top-level request field and in-place changes. + * + * With `"changes"`, the top-level list stays empty and every declaration, including + * `Context.tools`, goes in place: an in-place declaration can replace an earlier in-place one, + * but not a top-level one. With `"additions"`, a removal or redefinition cannot be expressed in + * place, so the top-level list becomes the tool state after the last such change; later + * additions stay in place, and the cached prefix is invalidated once per removal instead of on + * every later addition. With `"none"`, the top-level list is the current tool set. + */ +export function resolveTranscriptTools(context: Transcript, support: TranscriptToolSupport): TranscriptTools { + const declarations = toolDeclarations(context); + const transcriptMessages = declarations.length - 1; + if (support === "none") { + return { + requestTools: replay(declarations), + initialToolsInPlace: false, + foldedSystemMessages: transcriptMessages, + }; + } + if (support === "changes") return { requestTools: [], initialToolsInPlace: true, foldedSystemMessages: 0 }; + const folded = Math.max(1, lastNonAdditiveChange(declarations) + 1); + return { + requestTools: replay(declarations.slice(0, folded)), + initialToolsInPlace: false, + foldedSystemMessages: folded - 1, + }; +} diff --git a/packages/aikit/src/oauth/github/copilot.ts b/packages/aikit/src/oauth/github/copilot.ts index 40e58d78..d8956ffa 100644 --- a/packages/aikit/src/oauth/github/copilot.ts +++ b/packages/aikit/src/oauth/github/copilot.ts @@ -125,6 +125,21 @@ export function gitHubApiDomain(domain: string): string { return domain === DEFAULT_DOMAIN ? "api.github.com" : `api.${domain}`; } +/** + * The catalog models a login can reach, by the `availableModelIds` it recorded at login. + * A login that recorded none keeps every model. + */ +export function filterGitHubCopilotModels( + models: Readonly>, + credentials: Pick | undefined, +): Record { + const ids = credentials?.availableModelIds; + if (ids === undefined) return { ...models }; + const available = new Set(ids); + // A settings entry can alias a model; Copilot is asked for its API id. + return Object.fromEntries(Object.entries(models).filter(([, model]) => available.has(model.api?.id ?? model.id))); +} + /** The Copilot inference host for a credential or enterprise domain. */ export function gitHubCopilotBaseUrl(options?: { enterpriseUrl?: string | undefined; diff --git a/packages/aikit/src/providers/github-copilot/copilot-provider.ts b/packages/aikit/src/providers/github-copilot/copilot-provider.ts index efbc80b0..c2daacb3 100644 --- a/packages/aikit/src/providers/github-copilot/copilot-provider.ts +++ b/packages/aikit/src/providers/github-copilot/copilot-provider.ts @@ -3,17 +3,22 @@ * * Copilot exposes three inference protocols on one host — OpenAI Chat * Completions (`/chat/completions`), OpenAI Responses (`/responses`), and - * Anthropic Messages (`/v1/messages`) — so this provider composes the official - * AI SDK adapters behind one `ProviderV4` and routes each model through - * `api.method`. Auth and Copilot request headers are applied inside the shared - * fetch wrapper, which is why the adapters receive a placeholder key: their - * headers never reach the wire. + * Anthropic Messages (`/v1/messages`) — so this provider routes each model + * through `api.method`. Chat and Messages use the official AI SDK adapters; + * Responses uses aikit's own Responses model, which declares tools added + * mid-conversation in place. Auth and Copilot request headers are applied inside + * the shared fetch wrapper, which is why the adapters receive a placeholder key: + * their headers never reach the wire. */ import { createAnthropic } from "@ai-sdk/anthropic"; -import { createOpenAI } from "@ai-sdk/openai"; import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; -import { NoSuchModelError, type LanguageModelV4, type ProviderV4 } from "@ai-sdk/provider"; +import { NoSuchModelError, type LanguageModelV3, type LanguageModelV4, type ProviderV4 } from "@ai-sdk/provider"; +import { + ResponsesLanguageModel, + type ResponsesCompatibility, + type ResponsesRequestProfile, +} from "../responses/model.ts"; import { createCopilotFetch, type GitHubCopilotApiKey } from "./copilot-fetch.ts"; import type { GitHubCopilotInteractionType } from "./copilot-headers.ts"; @@ -22,6 +27,15 @@ export const GITHUB_COPILOT_PROVIDER_NAME = "github-copilot"; const PLACEHOLDER_API_KEY = "github-copilot"; +/** The copilot fetch reads the JSON body for its headers, so it goes uncompressed. */ +const COPILOT_RESPONSES_REQUEST: ResponsesRequestProfile = { + systemPrompt: "developer", + maxOutputTokens: true, + encryptedReasoning: "withEffort", + compress: false, + toolDefaults: false, +}; + export interface GitHubCopilotProvider extends ProviderV4 { /** Chat Completions — the generic fallback for third-party models. */ (modelId: string): LanguageModelV4; @@ -29,7 +43,7 @@ export interface GitHubCopilotProvider extends ProviderV4 { /** `POST /chat/completions` — Gemini, Kimi, and other third-party models. */ chat(modelId: string): LanguageModelV4; /** `POST /responses` — GPT, Grok, OSWE, and MAI models. */ - responses(modelId: string): LanguageModelV4; + responses(modelId: string): LanguageModelV3; /** `POST /v1/messages` — Claude models. */ messages(modelId: string): LanguageModelV4; } @@ -54,6 +68,10 @@ export interface GitHubCopilotProviderSettings { interactionType?: GitHubCopilotInteractionType; /** Forwarded to the Chat Completions adapter only. */ transformRequestBody?: (body: Record) => Record; + /** Sent as `prompt_cache_key` on Responses requests. */ + promptCacheKey?: string; + /** Responses model capabilities supplied by aikit; absent flags are disabled. */ + compat?: ResponsesCompatibility; } export function createGitHubCopilot(options: GitHubCopilotProviderSettings = {}): GitHubCopilotProvider { @@ -75,13 +93,17 @@ export function createGitHubCopilot(options: GitHubCopilotProviderSettings = {}) includeUsage: true, ...(options.transformRequestBody !== undefined && { transformRequestBody: options.transformRequestBody }), }); - const responses = createOpenAI({ - name: GITHUB_COPILOT_PROVIDER_NAME, - baseURL, - apiKey: PLACEHOLDER_API_KEY, - ...headers, - fetch, - }); + const responses = (modelId: string) => + new ResponsesLanguageModel(modelId, { + provider: GITHUB_COPILOT_PROVIDER_NAME, + label: "GitHub Copilot", + url: `${baseURL}/responses`, + headers: () => ({ ...options.headers, accept: "text/event-stream", "content-type": "application/json" }), + fetch, + request: COPILOT_RESPONSES_REQUEST, + ...(options.promptCacheKey !== undefined && { promptCacheKey: options.promptCacheKey }), + ...(options.compat !== undefined && { compat: options.compat }), + }); const messages = createAnthropic({ name: GITHUB_COPILOT_PROVIDER_NAME, // The Anthropic adapter appends /messages to its baseURL. @@ -100,7 +122,7 @@ export function createGitHubCopilot(options: GitHubCopilotProviderSettings = {}) provider.specificationVersion = "v4" as const; provider.languageModel = (modelId: string) => chat.chatModel(modelId); provider.chat = (modelId: string) => chat.chatModel(modelId); - provider.responses = (modelId: string) => responses.responses(modelId); + provider.responses = responses; provider.messages = (modelId: string) => messages.messages(modelId); provider.embeddingModel = (modelId: string) => { throw new NoSuchModelError({ diff --git a/packages/aikit/src/providers/openai-codex/codex-provider.ts b/packages/aikit/src/providers/openai-codex/codex-provider.ts index 5867e858..28c2e956 100644 --- a/packages/aikit/src/providers/openai-codex/codex-provider.ts +++ b/packages/aikit/src/providers/openai-codex/codex-provider.ts @@ -1,15 +1,77 @@ import { LoadAPIKeyError, NoSuchModelError, type LanguageModelV3, type ProviderV3 } from "@ai-sdk/provider"; +import type { TSchema } from "typebox"; +import type * as Message from "../../message/message.ts"; import { getOpenAICodexAccountId } from "../../oauth/openai/codex.ts"; import { - OPENAI_CODEX_DEFAULT_BASE_URL, - OpenAICodexLanguageModel, - clampOpenAICodexPromptCacheKey, - type OpenAICodexModelId, - type OpenAICodexCompatibility, - type OpenAICodexServiceTier, -} from "./codex-language-model.ts"; + ResponsesLanguageModel, + clampPromptCacheKey, + type ResponsesCompatibility, + type ResponsesRequestProfile, + type ResponsesServiceTier, +} from "../responses/model.ts"; export const OPENAI_CODEX_API_KEY_ENV = "OPENAI_CODEX_API_KEY"; +export const OPENAI_CODEX_DEFAULT_BASE_URL = "https://chatgpt.com/backend-api"; + +export type OpenAICodexModelId = + | "gpt-5.3-codex-spark" + | "gpt-5.4" + | "gpt-5.4-mini" + | "gpt-5.5" + | "gpt-5.6-luna" + | "gpt-5.6-sol" + | "gpt-5.6-terra" + | (string & {}); + +export type OpenAICodexCompatibility = ResponsesCompatibility; + +/** + * The ChatGPT Codex backend requires `instructions`, rejects `max_output_tokens`, and + * takes a zstd-compressed body. + */ +const CODEX_REQUEST: ResponsesRequestProfile = { + systemPrompt: "instructions", + maxOutputTokens: false, + encryptedReasoning: "always", + compress: true, + toolDefaults: true, + textVerbosity: "low", +}; + +/** + * Resolve the Codex responses endpoint from a base URL that may already + * include the `/codex` or `/codex/responses` suffix. + */ +export function resolveOpenAICodexUrl(baseUrl?: string): string { + const raw = baseUrl?.trim() ? baseUrl : OPENAI_CODEX_DEFAULT_BASE_URL; + const normalized = raw.replace(/\/+$/, ""); + if (normalized.endsWith("/codex/responses")) return normalized; + if (normalized.endsWith("/codex")) return `${normalized}/responses`; + return `${normalized}/codex/responses`; +} + +/** Tool helpers for Responses-only tool kinds. */ +export const openAICodexTools = { + custom(args: { + name: string; + description: string; + parameters: TParameters; + format: { type: "grammar"; syntax: "lark" | "regex"; definition: string }; + }): Message.Tool { + return { + name: args.name, + description: args.description, + parameters: args.parameters, + constrainedSampling: { + type: "grammar", + variants: + args.format.syntax === "lark" + ? { openai_lark: args.format.definition } + : { openai_regex: args.format.definition }, + }, + }; + }, +}; const DEFAULT_ORIGINATOR = "codework"; @@ -52,7 +114,7 @@ export interface OpenAICodexProviderSettings { sessionId?: string; /** Default service tier for requests. */ - serviceTier?: OpenAICodexServiceTier; + serviceTier?: ResponsesServiceTier; /** `originator` header value; defaults to `codework`. */ originator?: string; @@ -90,7 +152,7 @@ function resolveAccountId(settings: OpenAICodexProviderSettings, apiKey: string) export function createOpenAICodex(options: OpenAICodexProviderSettings = {}): OpenAICodexProvider { const baseURL = options.baseURL?.replace(/\/+$/, "") || OPENAI_CODEX_DEFAULT_BASE_URL; - const sessionId = clampOpenAICodexPromptCacheKey(options.sessionId); + const sessionId = clampPromptCacheKey(options.sessionId); const getHeaders = async (): Promise> => { const apiKey = await resolveApiKey(options); @@ -109,12 +171,14 @@ export function createOpenAICodex(options: OpenAICodexProviderSettings = {}): Op }; const createLanguageModel = (modelId: OpenAICodexModelId) => - new OpenAICodexLanguageModel(modelId, { + new ResponsesLanguageModel(modelId, { provider: "openai-codex", - baseURL, + label: "OpenAI Codex", + url: resolveOpenAICodexUrl(baseURL), headers: getHeaders, + request: CODEX_REQUEST, ...(options.fetch !== undefined && { fetch: options.fetch }), - ...(sessionId !== undefined && { sessionId }), + ...(sessionId !== undefined && { promptCacheKey: sessionId }), ...(options.serviceTier !== undefined && { serviceTier: options.serviceTier }), ...(options.compat !== undefined && { compat: options.compat }), }); diff --git a/packages/aikit/src/providers/openai-codex/index.ts b/packages/aikit/src/providers/openai-codex/index.ts index 63694063..fd9b4292 100644 --- a/packages/aikit/src/providers/openai-codex/index.ts +++ b/packages/aikit/src/providers/openai-codex/index.ts @@ -1,42 +1,20 @@ -export { createOpenAICodexAPICallError, openAICodexErrorMessage } from "./codex-error.ts"; -export { - OPENAI_CODEX_DEFAULT_BASE_URL, - OPENAI_CODEX_PROMPT_CACHE_KEY_MAX_LENGTH, - OpenAICodexLanguageModel, - clampOpenAICodexPromptCacheKey, - resolveOpenAICodexUrl, - type OpenAICodexLanguageModelConfig, - type OpenAICodexLanguageModelOptions, - type OpenAICodexCompatibility, - type OpenAICodexModelId, - type OpenAICodexServiceTier, -} from "./codex-language-model.ts"; -export { - convertToOpenAICodexPrompt, - collectOpenAICodexDeferredToolNames, - joinToolCallId, - splitToolCallId, - type OpenAICodexInputItem, - type OpenAICodexPrompt, - type OpenAICodexPromptOptions, -} from "./codex-prompt.ts"; export { createOpenAICodex, openaiCodex, + openAICodexTools, + resolveOpenAICodexUrl, OPENAI_CODEX_API_KEY_ENV, + OPENAI_CODEX_DEFAULT_BASE_URL, + type OpenAICodexCompatibility, + type OpenAICodexModelId, type OpenAICodexProvider, type OpenAICodexProviderSettings, } from "./codex-provider.ts"; -export { - appendOpenAICodexGrammarInputJsonDelta, - openAICodexTools, - prepareOpenAICodexTools, - resolveOpenAICodexToolConstraint, - resolveOpenAICodexDeferredToolsMode, - type OpenAICodexDeferredToolsMode, - type OpenAICodexGrammarConstraint, - type OpenAICodexGrammarInputBuffer, - type OpenAICodexTool, - type OpenAICodexToolChoice, -} from "./codex-tools.ts"; -export { convertOpenAICodexUsage, mapOpenAICodexFinishReason, type OpenAICodexUsage } from "./codex-usage.ts"; +export type { + ResponsesGrammarConstraint as OpenAICodexGrammarConstraint, + ResponsesTool as OpenAICodexTool, +} from "../responses/tools.ts"; +export type { + ResponsesLanguageModelOptions as OpenAICodexLanguageModelOptions, + ResponsesServiceTier as OpenAICodexServiceTier, +} from "../responses/model.ts"; diff --git a/packages/aikit/src/providers/openai-codex/codex-error.ts b/packages/aikit/src/providers/responses/error.ts similarity index 80% rename from packages/aikit/src/providers/openai-codex/codex-error.ts rename to packages/aikit/src/providers/responses/error.ts index 9c54ae5e..63168aaa 100644 --- a/packages/aikit/src/providers/openai-codex/codex-error.ts +++ b/packages/aikit/src/providers/responses/error.ts @@ -1,6 +1,6 @@ import { APICallError } from "@ai-sdk/provider"; -type OpenAICodexError = { +type ResponsesError = { code?: string; type?: string; message?: string; @@ -8,8 +8,8 @@ type OpenAICodexError = { resets_at?: number; }; -type OpenAICodexErrorPayload = { - error?: OpenAICodexError; +type ResponsesErrorPayload = { + error?: ResponsesError; }; const asRecord = (value: unknown): Record | undefined => @@ -20,7 +20,7 @@ const asRecord = (value: unknown): Record | undefined => const asString = (value: unknown): string | undefined => typeof value === "string" && value.trim() ? value.trim() : undefined; -function normalizeErrorPayload(value: unknown): OpenAICodexErrorPayload { +function normalizeErrorPayload(value: unknown): ResponsesErrorPayload { const record = asRecord(value); const nested = asRecord(record?.error); const source = nested ?? record ?? {}; @@ -28,7 +28,7 @@ function normalizeErrorPayload(value: unknown): OpenAICodexErrorPayload { const type = asString(source.type); const message = asString(source.message); const planType = asString(source.plan_type); - const error: OpenAICodexError = { + const error: ResponsesError = { ...(code !== undefined && { code }), ...(type !== undefined && { type }), ...(message !== undefined && { message }), @@ -38,7 +38,7 @@ function normalizeErrorPayload(value: unknown): OpenAICodexErrorPayload { return { error }; } -function parseErrorPayload(body: string): OpenAICodexErrorPayload | undefined { +function parseErrorPayload(body: string): ResponsesErrorPayload | undefined { try { return normalizeErrorPayload(JSON.parse(body) as unknown); } catch { @@ -46,16 +46,16 @@ function parseErrorPayload(body: string): OpenAICodexErrorPayload | undefined { } } -function errorIdentifiers(error: OpenAICodexError | undefined): string { +function errorIdentifiers(error: ResponsesError | undefined): string { return `${error?.code ?? ""} ${error?.type ?? ""}`.toLowerCase(); } -function hasIdentifier(error: OpenAICodexError | undefined, identifiers: readonly string[]): boolean { +function hasIdentifier(error: ResponsesError | undefined, identifiers: readonly string[]): boolean { const value = errorIdentifiers(error); return identifiers.some((identifier) => value.includes(identifier)); } -function isUsageLimitError(error: OpenAICodexError | undefined): boolean { +function isUsageLimitError(error: ResponsesError | undefined): boolean { return hasIdentifier(error, ["usage_limit_reached", "gousagelimiterror", "freeusagelimiterror"]); } @@ -69,7 +69,7 @@ function isTerminalRateLimitError(body: string, payload = parseErrorPayload(body ); } -function streamErrorStatus(error: OpenAICodexError | undefined): number { +function streamErrorStatus(error: ResponsesError | undefined): number { if (isUsageLimitError(error) || hasIdentifier(error, ["usage_not_included", "insufficient_quota", "rate_limit"])) { return 429; } @@ -97,11 +97,12 @@ function retryAfterHeader(message: string | undefined): Record { } /** - * Turn a Codex error payload into a human-friendly message. Only terminal + * Turn a Responses error payload into a human-friendly message. Only terminal * ChatGPT plan exhaustion is rewritten; transient 429s retain backend detail. + * `label` names the backend, e.g. "OpenAI Codex". */ -export function openAICodexErrorMessage(_status: number, body: string, statusText?: string): string { - let message = body || statusText || "OpenAI Codex request failed"; +export function responsesErrorMessage(label: string, body: string, statusText?: string): string { + let message = body || statusText || `${label} request failed`; const payload = parseErrorPayload(body); const error = payload?.error; @@ -118,7 +119,8 @@ export function openAICodexErrorMessage(_status: number, body: string, statusTex return message; } -export async function createOpenAICodexAPICallError(args: { +export async function createResponsesAPICallError(args: { + label: string; response: Response; url: string; requestBodyValues: unknown; @@ -132,7 +134,7 @@ export async function createOpenAICodexAPICallError(args: { const data = parseErrorPayload(responseBody); return new APICallError({ - message: openAICodexErrorMessage(response.status, responseBody, response.statusText), + message: responsesErrorMessage(args.label, responseBody, response.statusText), url, requestBodyValues, statusCode: response.status, @@ -148,7 +150,8 @@ export async function createOpenAICodexAPICallError(args: { } /** Map an error embedded in a successful SSE response to the same API error shape as HTTP failures. */ -export function createOpenAICodexStreamError(args: { +export function createResponsesStreamError(args: { + label: string; error: unknown; url: string; requestBodyValues: unknown; @@ -165,7 +168,7 @@ export function createOpenAICodexStreamError(args: { }; return new APICallError({ - message: openAICodexErrorMessage(statusCode, responseBody), + message: responsesErrorMessage(args.label, responseBody), url, requestBodyValues, statusCode, @@ -178,13 +181,14 @@ export function createOpenAICodexStreamError(args: { } /** A transport failure used when an SSE response ends without a terminal Responses event. */ -export function createOpenAICodexPrematureCloseError(args: { +export function createResponsesPrematureCloseError(args: { + label: string; url: string; requestBodyValues: unknown; responseHeaders?: Record; }): APICallError { return new APICallError({ - message: "OpenAI Codex stream closed before response.completed", + message: `${args.label} stream closed before response.completed`, url: args.url, requestBodyValues: args.requestBodyValues, ...(args.responseHeaders !== undefined && { responseHeaders: args.responseHeaders }), @@ -197,7 +201,7 @@ export function createOpenAICodexPrematureCloseError(args: { * it the way AI SDK providers do, so it reads as a retryable transport failure that * carries the request. Aborts pass through untouched. */ -export function openAICodexFetchError(error: unknown, url: string, requestBodyValues: unknown): unknown { +export function responsesFetchError(error: unknown, url: string, requestBodyValues: unknown): unknown { if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) return error; const cause = error instanceof Error && error.cause instanceof Error ? error.cause : error; return new APICallError({ diff --git a/packages/aikit/src/providers/openai-codex/codex-language-model.ts b/packages/aikit/src/providers/responses/model.ts similarity index 55% rename from packages/aikit/src/providers/openai-codex/codex-language-model.ts rename to packages/aikit/src/providers/responses/model.ts index a5f6f290..166f58be 100644 --- a/packages/aikit/src/providers/openai-codex/codex-language-model.ts +++ b/packages/aikit/src/providers/responses/model.ts @@ -11,30 +11,26 @@ import type { SharedV3Warning, } from "@ai-sdk/provider"; import { - createOpenAICodexAPICallError, - openAICodexFetchError, - createOpenAICodexPrematureCloseError, - createOpenAICodexStreamError, -} from "./codex-error.ts"; -import { collectOpenAICodexDeferredToolNames, convertToOpenAICodexPrompt, joinToolCallId } from "./codex-prompt.ts"; -import { parseOpenAICodexSSEStream } from "./codex-sse.ts"; -import { - appendOpenAICodexGrammarInputJsonDelta, - prepareOpenAICodexTools, - resolveOpenAICodexDeferredToolsMode, - type OpenAICodexGrammarInputBuffer, -} from "./codex-tools.ts"; -import { convertOpenAICodexUsage, mapOpenAICodexFinishReason, type OpenAICodexUsage } from "./codex-usage.ts"; - -export const OPENAI_CODEX_DEFAULT_BASE_URL = "https://chatgpt.com/backend-api"; -export const OPENAI_CODEX_PROMPT_CACHE_KEY_MAX_LENGTH = 64; + createResponsesAPICallError, + responsesFetchError, + createResponsesPrematureCloseError, + createResponsesStreamError, +} from "./error.ts"; +import { collectInPlaceToolNames, convertToResponsesPrompt, joinToolCallId } from "./prompt.ts"; +import { parseResponsesSSEStream } from "./sse.ts"; +import { appendGrammarInputJsonDelta, prepareResponsesTools, type GrammarInputBuffer } from "./tools.ts"; +import { convertResponsesUsage, mapResponsesFinishReason, type ResponsesUsage } from "./usage.ts"; + +export const PROMPT_CACHE_KEY_MAX_LENGTH = 64; const REQUEST_COMPRESSION_ZSTD_LEVEL = 3; +// The Responses API rejects max_output_tokens below 16. +const MIN_OUTPUT_TOKENS = 16; type ProcessWithBuiltinModule = typeof process & { getBuiltinModule?: (id: "node:zlib") => typeof NodeZlib; }; -function compressOpenAICodexRequest(body: string): Uint8Array | undefined { +function compressRequest(body: string): Uint8Array | undefined { if (typeof process === "undefined" || !(process.versions?.node || process.versions?.bun)) return undefined; const zlib = (process as ProcessWithBuiltinModule).getBuiltinModule?.("node:zlib"); if (!zlib || typeof zlib.zstdCompressSync !== "function") return undefined; @@ -48,74 +44,77 @@ function compressOpenAICodexRequest(body: string): Uint8Array | undefined { } } -export function clampOpenAICodexPromptCacheKey(key: string | undefined): string | undefined { +export function clampPromptCacheKey(key: string | undefined): string | undefined { if (key === undefined) return undefined; const characters = Array.from(key); - return characters.length <= OPENAI_CODEX_PROMPT_CACHE_KEY_MAX_LENGTH + return characters.length <= PROMPT_CACHE_KEY_MAX_LENGTH ? key - : characters.slice(0, OPENAI_CODEX_PROMPT_CACHE_KEY_MAX_LENGTH).join(""); + : characters.slice(0, PROMPT_CACHE_KEY_MAX_LENGTH).join(""); } -export type OpenAICodexModelId = - | "gpt-5.3-codex-spark" - | "gpt-5.4" - | "gpt-5.4-mini" - | "gpt-5.5" - | "gpt-5.6-luna" - | "gpt-5.6-sol" - | "gpt-5.6-terra" - | (string & {}); - -export type OpenAICodexServiceTier = "auto" | "default" | "flex" | "scale" | "priority"; - -export type OpenAICodexCompatibility = { - supportsToolSearch?: boolean; - supportsAdditionalTools?: boolean; - supportsStrictMode?: boolean; - supportsOpenAIGrammarTools?: boolean; +export type ResponsesServiceTier = "auto" | "default" | "flex" | "scale" | "priority"; + +export type ResponsesCompatibility = { /** Internal transport capability derived from the model's input modalities. */ supportsImages?: boolean; }; /** - * Per-call options, passed via `providerOptions["openai-codex"]`. + * Per-call options, passed via `providerOptions[]`. */ -export type OpenAICodexLanguageModelOptions = { +export type ResponsesLanguageModelOptions = { /** Reasoning effort forwarded as `reasoning.effort`. */ reasoningEffort?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" | (string & {}); /** Reasoning summary verbosity; defaults to `auto` when an effort is set. */ reasoningSummary?: "auto" | "concise" | "detailed" | "off" | "on" | null; - /** Output text verbosity; defaults to `low`. */ + /** Output text verbosity; defaults to the backend's `request.textVerbosity`. */ textVerbosity?: "low" | "medium" | "high"; - serviceTier?: OpenAICodexServiceTier; + serviceTier?: ResponsesServiceTier; + /** + * The string input property of each grammar tool the conversation declared, including tools + * since removed, so their earlier calls replay as grammar calls. The current tools win. + */ + grammarToolInputProperties?: Record; }; -export type OpenAICodexLanguageModelConfig = { +/** How one Responses backend takes a request. */ +export type ResponsesRequestProfile = { + /** Where system prompt text goes: the `instructions` field, or a developer message in place. */ + systemPrompt: "instructions" | "developer"; + /** Whether `max_output_tokens` is sent; the ChatGPT Codex backend rejects it. */ + maxOutputTokens: boolean; + /** Whether `reasoning.encrypted_content` is requested on every call or only with an effort. */ + encryptedReasoning: "always" | "withEffort"; + /** zstd-compress the body; a fetch that reads the JSON body needs it uncompressed. */ + compress: boolean; + /** + * Whether `tool_choice: "auto"` and `parallel_tool_calls` go on every request, as the Codex + * backend expects. Otherwise only a tool choice the call names is sent: some proxied models + * (Grok on Copilot) reject either field on a request without tools. + */ + toolDefaults: boolean; + /** `text.verbosity` when the call sets none. */ + textVerbosity?: ResponsesLanguageModelOptions["textVerbosity"]; +}; + +export type ResponsesLanguageModelConfig = { + /** Provider name, and the providerOptions / providerMetadata key. */ provider: string; - baseURL: string; + /** Backend name for error messages, e.g. "OpenAI Codex". */ + label: string; + url: string; headers: () => PromiseLike> | Record; fetch?: typeof globalThis.fetch; - sessionId?: string; - serviceTier?: OpenAICodexServiceTier; - compat?: OpenAICodexCompatibility; + promptCacheKey?: string; + serviceTier?: ResponsesServiceTier; + compat?: ResponsesCompatibility; + request: ResponsesRequestProfile; }; -/** - * Resolve the Codex responses endpoint from a base URL that may already - * include the `/codex` or `/codex/responses` suffix. - */ -export function resolveOpenAICodexUrl(baseUrl?: string): string { - const raw = baseUrl?.trim() ? baseUrl : OPENAI_CODEX_DEFAULT_BASE_URL; - const normalized = raw.replace(/\/+$/, ""); - if (normalized.endsWith("/codex/responses")) return normalized; - if (normalized.endsWith("/codex")) return `${normalized}/responses`; - return `${normalized}/codex/responses`; -} - -type CodexEvent = Record; +type ResponsesEvent = Record; function asRecord(value: unknown): Record | undefined { - return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as CodexEvent) : undefined; + return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as ResponsesEvent) : undefined; } function asString(value: unknown): string | undefined { @@ -130,13 +129,13 @@ function cleanHeaders(headers: Record): Record = { verbosity: codexOptions.textVerbosity ?? "low" }; + const verbosity = callOptions.textVerbosity ?? request.textVerbosity; + const text: Record = verbosity ? { verbosity } : {}; if (options.responseFormat?.type === "json" && options.responseFormat.schema !== undefined) { text.format = { type: "json_schema", strict: true, schema: options.responseFormat.schema, - name: options.responseFormat.name ?? "codex_output_schema", + name: options.responseFormat.name ?? "output_schema", }; } const args: Record = { model: this.modelId, - // The ChatGPT backend rejects stored responses for Codex subscriptions. + // Reasoning replays from encrypted content, so nothing is stored server-side. store: false, - instructions, + ...(instructions !== undefined && { instructions }), input, - tool_choice: tools.codexToolChoice ?? "auto", - parallel_tool_calls: true, - include: ["reasoning.encrypted_content"], - text, + ...(request.toolDefaults + ? { tool_choice: tools.toolChoiceValue ?? "auto", parallel_tool_calls: true } + : tools.toolChoiceValue !== undefined && { tool_choice: tools.toolChoiceValue }), + ...((request.encryptedReasoning === "always" || callOptions.reasoningEffort) && { + include: ["reasoning.encrypted_content"], + }), + ...(Object.keys(text).length > 0 && { text }), }; - if (tools.codexTools) args.tools = tools.codexTools; + if (tools.responsesTools) args.tools = tools.responsesTools; if (options.temperature != null) args.temperature = options.temperature; + if (request.maxOutputTokens && options.maxOutputTokens != null) { + args.max_output_tokens = Math.max(options.maxOutputTokens, MIN_OUTPUT_TOKENS); + } - const promptCacheKey = clampOpenAICodexPromptCacheKey(this.config.sessionId); + const promptCacheKey = clampPromptCacheKey(this.config.promptCacheKey); if (promptCacheKey) args.prompt_cache_key = promptCacheKey; - const serviceTier = codexOptions.serviceTier ?? this.config.serviceTier; + const serviceTier = callOptions.serviceTier ?? this.config.serviceTier; if (serviceTier) args.service_tier = serviceTier; - if (codexOptions.reasoningEffort) { + if (callOptions.reasoningEffort) { args.reasoning = { - effort: codexOptions.reasoningEffort, - summary: codexOptions.reasoningSummary ?? "auto", + effort: callOptions.reasoningEffort, + summary: callOptions.reasoningSummary ?? "auto", }; } @@ -235,7 +243,7 @@ export class OpenAICodexLanguageModel implements LanguageModelV3 { const reasoningBlocks = new Map>(); const warnings: SharedV3Warning[] = []; let finishReason: LanguageModelV3FinishReason = { unified: "other", raw: undefined }; - let usage: LanguageModelV3Usage = convertOpenAICodexUsage(undefined); + let usage: LanguageModelV3Usage = convertResponsesUsage(undefined); let responseId: string | undefined; let responseModelId: string | undefined; @@ -317,14 +325,14 @@ export class OpenAICodexLanguageModel implements LanguageModelV3 { async doStream(options: LanguageModelV3CallOptions): Promise { const { args, warnings, grammarToolInputProperties } = this.getArgs(options); const body = { ...args, stream: true }; - const url = resolveOpenAICodexUrl(this.config.baseURL); + const { url, label } = this.config; const headers = cleanHeaders({ ...(await this.config.headers()), ...options.headers, }); const bodyJson = JSON.stringify(body); - const compressedBody = compressOpenAICodexRequest(bodyJson); + const compressedBody = this.config.request.compress ? compressRequest(bodyJson) : undefined; if (compressedBody) headers["content-encoding"] = "zstd"; const fetchImpl = this.config.fetch ?? globalThis.fetch; @@ -334,14 +342,14 @@ export class OpenAICodexLanguageModel implements LanguageModelV3 { body: compressedBody ?? bodyJson, ...(options.abortSignal !== undefined && { signal: options.abortSignal }), }).catch((error: unknown) => { - throw openAICodexFetchError(error, url, body); + throw responsesFetchError(error, url, body); }); if (!response.ok) { - throw await createOpenAICodexAPICallError({ response, url, requestBodyValues: body }); + throw await createResponsesAPICallError({ label, response, url, requestBodyValues: body }); } if (!response.body) { - throw new Error("openAI codex response has no body"); + throw new Error(`${label} response has no body`); } const responseHeaders: Record = {}; @@ -350,8 +358,9 @@ export class OpenAICodexLanguageModel implements LanguageModelV3 { }); return { - stream: parseOpenAICodexSSEStream(response.body).pipeThrough( + stream: parseResponsesSSEStream(response.body).pipeThrough( this.createTransformStream(warnings, options.includeRawChunks ?? false, grammarToolInputProperties, { + label, url, requestBodyValues: body, responseHeaders, @@ -367,40 +376,51 @@ export class OpenAICodexLanguageModel implements LanguageModelV3 { includeRawChunks: boolean, grammarToolInputProperties: ReadonlyMap, request: { + label: string; url: string; requestBodyValues: unknown; responseHeaders: Record; }, - ): TransformStream { + ): TransformStream { + const { provider } = this.config; let finishReason: LanguageModelV3FinishReason = { unified: "other", raw: undefined }; - let usage: OpenAICodexUsage | undefined; + let usage: ResponsesUsage | undefined; let hasToolCalls = false; let finished = false; let servedServiceTier: string | undefined; - // function_call argument deltas arrive keyed by item id; tool parts use the - // composite `call_id|item_id` so multi-turn replay can recover both halves. - const toolCallIdsByItemId = new Map(); - const customToolInputsByItemId = new Map< - string, - { - toolCallId: string; - toolName: string; - inputProperty: string; - buffer: OpenAICodexGrammarInputBuffer; - } - >(); + /* + * Items are tracked by `output_index`: Copilot sends a different item id on every + * event of the same item. Tool parts use the composite `call_id|item_id` from the + * item's first event so multi-turn replay can recover both halves. + */ + type Slot = + | { kind: "text"; id: string } + | { kind: "reasoning"; id: string } + | { kind: "function"; toolCallId: string; toolName: string } + | { + kind: "custom"; + toolCallId: string; + toolName: string; + inputProperty: string; + buffer: GrammarInputBuffer; + }; + const slots = new Map(); + const slotKey = (event: ResponsesEvent, item?: ResponsesEvent): string | undefined => + typeof event.output_index === "number" + ? `#${event.output_index}` + : (asString(event.item_id) ?? asString(item?.id)); const finish = (controller: TransformStreamDefaultController): void => { if (finished) return; finished = true; controller.enqueue({ type: "finish", finishReason, - usage: convertOpenAICodexUsage(usage), + usage: convertResponsesUsage(usage), // The tier the response was served at decides what it costs, and it is // not always the tier that was requested. ...(servedServiceTier === undefined ? {} - : { providerMetadata: { "openai-codex": { serviceTier: servedServiceTier } } }), + : { providerMetadata: { [provider]: { serviceTier: servedServiceTier } } }), }); }; const fail = (controller: TransformStreamDefaultController, error: unknown): void => { @@ -410,98 +430,88 @@ export class OpenAICodexLanguageModel implements LanguageModelV3 { controller.terminate(); }; - const handleOutputItemAdded = ( - item: CodexEvent, + const startSlot = ( + key: string, + item: ResponsesEvent, controller: TransformStreamDefaultController, - ) => { + ): Slot | undefined => { const itemType = asString(item.type); const itemId = asString(item.id); if (!itemType || !itemId) return; + let slot: Slot | undefined; if (itemType === "message") { + slot = { kind: "text", id: itemId }; controller.enqueue({ type: "text-start", id: itemId }); } else if (itemType === "reasoning") { + slot = { kind: "reasoning", id: itemId }; controller.enqueue({ type: "reasoning-start", id: itemId }); } else if (itemType === "function_call") { - const toolCallId = joinToolCallId(asString(item.call_id) ?? itemId, itemId); - toolCallIdsByItemId.set(itemId, toolCallId); + const toolName = asString(item.name) ?? ""; + slot = { kind: "function", toolCallId: joinToolCallId(asString(item.call_id) ?? itemId, itemId), toolName }; hasToolCalls = true; - controller.enqueue({ - type: "tool-input-start", - id: toolCallId, - toolName: asString(item.name) ?? "", - }); + controller.enqueue({ type: "tool-input-start", id: slot.toolCallId, toolName }); } else if (itemType === "custom_tool_call") { const toolName = asString(item.name) ?? ""; - const inputProperty = grammarToolInputProperties.get(toolName) ?? "input"; - const toolCallId = joinToolCallId(asString(item.call_id) ?? itemId, itemId); - customToolInputsByItemId.set(itemId, { - toolCallId, + slot = { + kind: "custom", + toolCallId: joinToolCallId(asString(item.call_id) ?? itemId, itemId), toolName, - inputProperty, + inputProperty: grammarToolInputProperties.get(toolName) ?? "input", buffer: { input: "", started: false, closed: false }, - }); + }; hasToolCalls = true; - controller.enqueue({ type: "tool-input-start", id: toolCallId, toolName }); + controller.enqueue({ type: "tool-input-start", id: slot.toolCallId, toolName }); } + if (slot) slots.set(key, slot); + return slot; }; - const handleOutputItemDone = ( - item: CodexEvent, + const endSlot = ( + key: string, + item: ResponsesEvent, controller: TransformStreamDefaultController, ) => { - const itemType = asString(item.type); - const itemId = asString(item.id); - if (!itemType || !itemId) return; - - if (itemType === "message") { - controller.enqueue({ - type: "text-end", - id: itemId, - providerMetadata: { "openai-codex": { messageId: itemId } }, - }); - } else if (itemType === "reasoning") { + const slot = slots.get(key) ?? startSlot(key, item, controller); + if (!slot) return; + slots.delete(key); + const namespace = asString(item.namespace); + const namespaceMetadata = namespace ? { providerMetadata: { [provider]: { namespace } } } : {}; + + if (slot.kind === "text") { + const messageId = asString(item.id) ?? slot.id; + controller.enqueue({ type: "text-end", id: slot.id, providerMetadata: { [provider]: { messageId } } }); + } else if (slot.kind === "reasoning") { controller.enqueue({ type: "reasoning-end", - id: itemId, - providerMetadata: { "openai-codex": { reasoningItem: JSON.stringify(item) } }, + id: slot.id, + providerMetadata: { [provider]: { reasoningItem: JSON.stringify(item) } }, }); - } else if (itemType === "function_call") { - const toolCallId = - toolCallIdsByItemId.get(itemId) ?? joinToolCallId(asString(item.call_id) ?? itemId, itemId); - hasToolCalls = true; - controller.enqueue({ type: "tool-input-end", id: toolCallId }); + } else if (slot.kind === "function") { + controller.enqueue({ type: "tool-input-end", id: slot.toolCallId }); controller.enqueue({ type: "tool-call", - toolCallId, - toolName: asString(item.name) ?? "", + toolCallId: slot.toolCallId, + toolName: asString(item.name) ?? slot.toolName, input: asString(item.arguments) ?? "", - ...(asString(item.namespace) - ? { providerMetadata: { "openai-codex": { namespace: asString(item.namespace)! } } } - : {}), + ...namespaceMetadata, }); - } else if (itemType === "custom_tool_call") { - const state = customToolInputsByItemId.get(itemId); - if (!state) return; - const input = asString(item.input) ?? state.buffer.input; - const delta = appendOpenAICodexGrammarInputJsonDelta(state.buffer, state.inputProperty, input, true); - if (delta) controller.enqueue({ type: "tool-input-delta", id: state.toolCallId, delta }); - hasToolCalls = true; - controller.enqueue({ type: "tool-input-end", id: state.toolCallId }); + } else { + const input = asString(item.input) ?? slot.buffer.input; + const delta = appendGrammarInputJsonDelta(slot.buffer, slot.inputProperty, input, true); + if (delta) controller.enqueue({ type: "tool-input-delta", id: slot.toolCallId, delta }); + controller.enqueue({ type: "tool-input-end", id: slot.toolCallId }); controller.enqueue({ type: "tool-call", - toolCallId: state.toolCallId, - toolName: state.toolName, - input: JSON.stringify({ [state.inputProperty]: input }), - ...(asString(item.namespace) - ? { providerMetadata: { "openai-codex": { namespace: asString(item.namespace)! } } } - : {}), + toolCallId: slot.toolCallId, + toolName: slot.toolName, + input: JSON.stringify({ [slot.inputProperty]: input }), + ...namespaceMetadata, }); - customToolInputsByItemId.delete(itemId); } }; - return new TransformStream({ + return new TransformStream({ start(controller) { controller.enqueue({ type: "stream-start", warnings }); }, @@ -529,80 +539,66 @@ export class OpenAICodexLanguageModel implements LanguageModelV3 { case "response.output_item.added": { const item = asRecord(event.item); - if (item) handleOutputItemAdded(item, controller); + const key = item ? slotKey(event, item) : undefined; + if (item && key) startSlot(key, item, controller); break; } case "response.output_text.delta": { - const itemId = asString(event.item_id); + const slot = slots.get(slotKey(event) ?? ""); const delta = asString(event.delta); - if (itemId && delta) controller.enqueue({ type: "text-delta", id: itemId, delta }); + if (slot?.kind === "text" && delta) controller.enqueue({ type: "text-delta", id: slot.id, delta }); break; } case "response.reasoning_text.delta": case "response.reasoning_summary_text.delta": { - const itemId = asString(event.item_id); + const slot = slots.get(slotKey(event) ?? ""); const delta = asString(event.delta); - if (itemId && delta) controller.enqueue({ type: "reasoning-delta", id: itemId, delta }); + if (slot?.kind === "reasoning" && delta) { + controller.enqueue({ type: "reasoning-delta", id: slot.id, delta }); + } break; } case "response.function_call_arguments.delta": { - const itemId = asString(event.item_id); - const delta = asString(event.delta); - const toolCallId = itemId ? toolCallIdsByItemId.get(itemId) : undefined; - if (toolCallId && delta) controller.enqueue({ type: "tool-input-delta", id: toolCallId, delta }); - break; - } - - case "response.custom_tool_call_input.delta": { - const itemId = asString(event.item_id); + const slot = slots.get(slotKey(event) ?? ""); const delta = asString(event.delta); - const state = itemId ? customToolInputsByItemId.get(itemId) : undefined; - if (!state || delta === undefined) break; - const jsonDelta = appendOpenAICodexGrammarInputJsonDelta( - state.buffer, - state.inputProperty, - state.buffer.input + delta, - false, - ); - if (jsonDelta) - controller.enqueue({ type: "tool-input-delta", id: state.toolCallId, delta: jsonDelta }); + if (slot?.kind === "function" && delta) { + controller.enqueue({ type: "tool-input-delta", id: slot.toolCallId, delta }); + } break; } + case "response.custom_tool_call_input.delta": case "response.custom_tool_call_input.done": { - const itemId = asString(event.item_id); - const input = asString(event.input); - const state = itemId ? customToolInputsByItemId.get(itemId) : undefined; - if (!state || input === undefined) break; - const jsonDelta = appendOpenAICodexGrammarInputJsonDelta( - state.buffer, - state.inputProperty, - input, - true, - ); + const slot = slots.get(slotKey(event) ?? ""); + if (slot?.kind !== "custom") break; + const done = type === "response.custom_tool_call_input.done"; + const input = done ? asString(event.input) : slot.buffer.input + (asString(event.delta) ?? ""); + if (input === undefined) break; + const jsonDelta = appendGrammarInputJsonDelta(slot.buffer, slot.inputProperty, input, done); if (jsonDelta) - controller.enqueue({ type: "tool-input-delta", id: state.toolCallId, delta: jsonDelta }); + controller.enqueue({ type: "tool-input-delta", id: slot.toolCallId, delta: jsonDelta }); break; } case "response.output_item.done": { const item = asRecord(event.item); - if (item) handleOutputItemDone(item, controller); + const key = item ? slotKey(event, item) : undefined; + if (item && key) endSlot(key, item, controller); break; } case "response.completed": case "response.incomplete": { const response = asRecord(event.response); - usage = (response?.usage as OpenAICodexUsage | undefined) ?? usage; + usage = (response?.usage as ResponsesUsage | undefined) ?? usage; servedServiceTier = asString(response?.service_tier) ?? servedServiceTier; const status = asString(response?.status) ?? (type === "response.incomplete" ? "incomplete" : "completed"); const incompleteReason = asString(asRecord(response?.incomplete_details)?.reason); - finishReason = mapOpenAICodexFinishReason(status, hasToolCalls, incompleteReason); + finishReason = mapResponsesFinishReason(status, hasToolCalls, incompleteReason); finish(controller); controller.terminate(); break; @@ -614,8 +610,8 @@ export class OpenAICodexLanguageModel implements LanguageModelV3 { finishReason = { unified: "error", raw: "failed" }; fail( controller, - createOpenAICodexStreamError({ - error: error ?? { message: "OpenAI Codex response failed" }, + createResponsesStreamError({ + error: error ?? { message: `${request.label} response failed` }, ...request, }), ); @@ -625,7 +621,7 @@ export class OpenAICodexLanguageModel implements LanguageModelV3 { case "error": { const code = asString(event.code); finishReason = { unified: "error", raw: code ?? "error" }; - fail(controller, createOpenAICodexStreamError({ error: event, ...request })); + fail(controller, createResponsesStreamError({ error: event, ...request })); break; } @@ -640,7 +636,7 @@ export class OpenAICodexLanguageModel implements LanguageModelV3 { finished = true; controller.enqueue({ type: "error", - error: createOpenAICodexPrematureCloseError(request), + error: createResponsesPrematureCloseError(request), }); } }, diff --git a/packages/aikit/src/providers/openai-codex/codex-prompt.ts b/packages/aikit/src/providers/responses/prompt.ts similarity index 63% rename from packages/aikit/src/providers/openai-codex/codex-prompt.ts rename to packages/aikit/src/providers/responses/prompt.ts index 522cb9a0..efc175a8 100644 --- a/packages/aikit/src/providers/openai-codex/codex-prompt.ts +++ b/packages/aikit/src/providers/responses/prompt.ts @@ -7,12 +7,12 @@ import type { LanguageModelV4ToolResultOutput, } from "@ai-sdk/provider"; import { shortHash } from "../../utils/hash.ts"; -import type { OpenAICodexDeferredToolsMode, OpenAICodexTool } from "./codex-tools.ts"; +import { toResponsesTool, type ResponsesTool, type ResponsesToolChange } from "./tools.ts"; /** - * OpenAI Responses API input item types accepted by the Codex backend. + * OpenAI Responses API input item types this transport sends. */ -export type OpenAICodexInputItem = +export type ResponsesInputItem = | { role: "user"; content: Array< @@ -59,10 +59,14 @@ export type OpenAICodexInputItem = call_id: string; output: string; } + | { + role: "developer"; + content: string; + } | { type: "additional_tools"; role: "developer"; - tools: OpenAICodexTool[]; + tools: ResponsesTool[]; } | { type: "tool_search_call"; @@ -76,19 +80,25 @@ export type OpenAICodexInputItem = call_id: string; execution: "client"; status: "completed"; - tools: OpenAICodexTool[]; + tools: ResponsesTool[]; }; -export type OpenAICodexPrompt = { - instructions: string; - input: OpenAICodexInputItem[]; +export type ResponsesPrompt = { + /** The system prompt, when it travels in the `instructions` field. */ + instructions?: string; + input: ResponsesInputItem[]; }; const DEFAULT_INSTRUCTIONS = "You are a helpful assistant."; -export type OpenAICodexPromptOptions = { - deferredTools?: ReadonlyMap; - deferredToolsMode?: OpenAICodexDeferredToolsMode; +export type ResponsesPromptOptions = { + /** The providerOptions key this transport reads, e.g. `openai-codex`. */ + provider: string; + /** + * Where system prompt text goes: the `instructions` field (the Codex backend requires + * one), or a developer message in place. + */ + systemPrompt: "instructions" | "developer"; grammarToolInputProperties?: ReadonlyMap; supportsImages?: boolean; }; @@ -138,7 +148,7 @@ function imageUrlFromFilePart(part: LanguageModelV3FilePart | LanguageModelV4Fil function toolResultOutput( output: LanguageModelV3ToolResultOutput | LanguageModelV4ToolResultOutput, supportsImages: boolean, -): Extract["output"] { +): Extract["output"] { switch (output.type) { case "text": case "error-text": @@ -207,110 +217,107 @@ function customToolOutput( return typeof value === "string" ? value : JSON.stringify(value); } -function codexProviderMetadata( +function providerMetadata( providerOptions: Record | undefined, + provider: string, ): Record | undefined { - const codex = providerOptions?.["openai-codex"]; - return typeof codex === "object" && codex !== null && !Array.isArray(codex) - ? (codex as Record) + const metadata = providerOptions?.[provider]; + return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata) + ? (metadata as Record) : undefined; } -function addedToolNames(providerOptions: Record | undefined): string[] { - const names = codexProviderMetadata(providerOptions)?.addedToolNames; - return Array.isArray(names) ? names.filter((name): name is string => typeof name === "string") : []; +function toolChange( + providerOptions: Record | undefined, + provider: string, +): ResponsesToolChange | undefined { + return providerMetadata(providerOptions, provider)?.toolChange as ResponsesToolChange | undefined; } function reasoningItem( providerOptions: Record | undefined, -): Extract | undefined { - const value = codexProviderMetadata(providerOptions)?.reasoningItem; + provider: string, +): Extract | undefined { + const value = providerMetadata(providerOptions, provider)?.reasoningItem; if (typeof value !== "string") return undefined; try { const parsed = JSON.parse(value) as unknown; if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined; const item = parsed as Record; return item.type === "reasoning" && typeof item.id === "string" - ? (item as Extract) + ? (item as Extract) : undefined; } catch { return undefined; } } -/** Collect definitions that were introduced at a transcript load point rather than at request start. */ -export function collectOpenAICodexDeferredToolNames( +/** Names of every tool declared in place by a system message's tool change. */ +export function collectInPlaceToolNames( prompt: LanguageModelV3Prompt | LanguageModelV4Prompt, + provider: string, ): Set { - const deferredNames = new Set(); - const usedNames = new Set(); - + const names = new Set(); for (const message of prompt) { - if (message.role === "assistant") { - for (const part of message.content) { - if (part.type === "tool-call") usedNames.add(part.toolName); - if (part.type === "tool-result") { - for (const name of addedToolNames(part.providerOptions)) { - if (!usedNames.has(name)) deferredNames.add(name); - } - } - } - continue; - } - if (message.role !== "tool") continue; - for (const part of message.content) { - if (part.type !== "tool-result") continue; - for (const name of addedToolNames(part.providerOptions)) { - if (!usedNames.has(name)) deferredNames.add(name); - } - } + if (message.role !== "system") continue; + for (const tool of toolChange(message.providerOptions, provider)?.tools ?? []) names.add(tool.name); } + return names; +} - return deferredNames; +/* + * `additional_tools` items accumulate: a later item adds tools and replaces same-name + * definitions from earlier ones, but cannot remove any. Without this notice, models read the + * latest item as the complete tool set once a removal was announced, and restating the tools + * that are still unavailable keeps smaller models from calling them after later loads. + */ +function incrementalToolsNotice(unavailable: ReadonlySet): string { + const notice = + "This is an incremental tools update. Previously declared tools remain available unless explicitly marked unavailable. A tool declared again here is available again, and its latest definition replaces the earlier one."; + return unavailable.size > 0 ? `${notice}\nStill unavailable: ${[...unavailable].join(", ")}.` : notice; } -function deferredToolCallId(toolCallId: string, names: readonly string[]): string { - return `cdwrk_aikit_tool_${shortHash(`${toolCallId}:${names.join(",")}`)}`; +function toolRemovalNotice(names: readonly string[]): string { + return `The following tools are no longer available. Do not call them:\n${names.map((name) => `- ${name}`).join("\n")}`; } /** - * Convert an AI SDK V3 prompt into the Codex Responses request shape. - * System messages become `instructions`; everything else maps to `input` items. + * Convert an AI SDK V3 prompt into the Responses request shape. Tool changes are + * declared in place, other system messages go where `options.systemPrompt` says, + * and everything else maps to `input` items. */ -export function convertToOpenAICodexPrompt( +export function convertToResponsesPrompt( prompt: LanguageModelV3Prompt | LanguageModelV4Prompt, - options?: OpenAICodexPromptOptions, -): OpenAICodexPrompt { + options: ResponsesPromptOptions, +): ResponsesPrompt { + const { provider } = options; let instructions: string | undefined; - const input: OpenAICodexInputItem[] = []; + const input: ResponsesInputItem[] = []; let syntheticMessageCounter = 0; - const loadedToolNames = new Set(); - - const appendDeferredTools = (toolCallId: string, names: readonly string[]): void => { - const tools: OpenAICodexTool[] = []; - for (const name of names) { - const tool = options?.deferredTools?.get(name); - if (!tool || loadedToolNames.has(name)) continue; - loadedToolNames.add(name); - tools.push(tool); - } - if (tools.length === 0) return; + let toolChangeCounter = 0; + let declaredAdditionalTools = false; + /** Tools announced as removed and not declared again since. */ + const unavailableTools = new Set(); - if (options?.deferredToolsMode === "additional-tools") { + const appendToolChange = (change: ResponsesToolChange): void => { + const seed = toolChangeCounter++; + const tools = change.tools.map((tool) => toResponsesTool(tool, provider)); + for (const tool of tools) unavailableTools.delete(tool.name); + if (tools.length > 0 && change.mode === "changes") { + if (declaredAdditionalTools) { + input.push({ role: "developer", content: incrementalToolsNotice(unavailableTools) }); + } + declaredAdditionalTools = true; input.push({ type: "additional_tools", role: "developer", tools }); - return; - } - if (options?.deferredToolsMode === "tool-search") { - const callId = deferredToolCallId( - toolCallId, - tools.map((tool) => tool.name), - ); + } else if (tools.length > 0) { + const names = tools.map((tool) => tool.name); + const callId = `cdwrk_aikit_tool_${shortHash(`${seed}:${names.join(",")}`)}`; input.push({ type: "tool_search_call", call_id: callId, execution: "client", status: "completed", - arguments: { query: tools.map((tool) => tool.name).join(" "), limit: tools.length }, + arguments: { query: names.join(" "), limit: names.length }, }); input.push({ type: "tool_search_output", @@ -320,17 +327,33 @@ export function convertToOpenAICodexPrompt( tools: tools.map((tool) => ({ ...tool, defer_loading: true })), }); } + // A redefinition is a removal plus an addition of the same name; only true removals are announced. + const added = new Set(tools.map((tool) => tool.name)); + const removed = change.removed.filter((name) => !added.has(name)); + if (removed.length === 0) return; + input.push({ role: "developer", content: toolRemovalNotice(removed) }); + // Recorded after this change's additions, whose notice precedes this removal notice. + for (const name of removed) unavailableTools.add(name); }; for (const message of prompt) { switch (message.role) { case "system": { + const change = toolChange(message.providerOptions, provider); + if (change) { + appendToolChange(change); + break; + } + if (options.systemPrompt === "developer") { + if (message.content.length > 0) input.push({ role: "developer", content: message.content }); + break; + } instructions = instructions == null ? message.content : `${instructions}\n\n${message.content}`; break; } case "user": { - const content: Extract["content"] = []; + const content: Extract["content"] = []; for (const part of message.content) { if (part.type === "text") { content.push({ type: "input_text", text: part.text }); @@ -348,11 +371,11 @@ export function convertToOpenAICodexPrompt( case "assistant": { for (const part of message.content) { if (part.type === "reasoning") { - const item = reasoningItem(part.providerOptions); + const item = reasoningItem(part.providerOptions, provider); if (item) input.push(item); } else if (part.type === "text") { syntheticMessageCounter++; - const messageId = codexProviderMetadata(part.providerOptions)?.messageId; + const messageId = providerMetadata(part.providerOptions, provider)?.messageId; input.push({ type: "message", role: "assistant", @@ -365,9 +388,9 @@ export function convertToOpenAICodexPrompt( }); } else if (part.type === "tool-call") { const { callId, itemId } = splitToolCallId(part.toolCallId); - const metadata = codexProviderMetadata(part.providerOptions); + const metadata = providerMetadata(part.providerOptions, provider); const namespace = metadata?.namespace; - const inputProperty = options?.grammarToolInputProperties?.get(part.toolName); + const inputProperty = options.grammarToolInputProperties?.get(part.toolName); if (inputProperty) { input.push({ type: "custom_tool_call", @@ -396,25 +419,25 @@ export function convertToOpenAICodexPrompt( for (const part of message.content) { if (part.type !== "tool-result") continue; const { callId } = splitToolCallId(part.toolCallId); - if (options?.grammarToolInputProperties?.has(part.toolName)) { + if (options.grammarToolInputProperties?.has(part.toolName)) { input.push({ type: "custom_tool_call_output", call_id: callId, - output: customToolOutput(part.output, options?.supportsImages ?? true), + output: customToolOutput(part.output, options.supportsImages ?? true), }); } else { input.push({ type: "function_call_output", call_id: callId, - output: toolResultOutput(part.output, options?.supportsImages ?? true), + output: toolResultOutput(part.output, options.supportsImages ?? true), }); } - appendDeferredTools(part.toolCallId, addedToolNames(part.providerOptions)); } break; } } } + if (options.systemPrompt === "developer") return { input }; return { instructions: instructions || DEFAULT_INSTRUCTIONS, input }; } diff --git a/packages/aikit/src/providers/openai-codex/codex-sse.ts b/packages/aikit/src/providers/responses/sse.ts similarity index 72% rename from packages/aikit/src/providers/openai-codex/codex-sse.ts rename to packages/aikit/src/providers/responses/sse.ts index c9fd59b0..3215371e 100644 --- a/packages/aikit/src/providers/openai-codex/codex-sse.ts +++ b/packages/aikit/src/providers/responses/sse.ts @@ -1,7 +1,7 @@ import { EventSourceParserStream, type EventSourceMessage } from "eventsource-parser/stream"; -/** Parse standards-compliant event streams from the Codex Responses endpoint. */ -export function parseOpenAICodexSSEStream(body: ReadableStream): ReadableStream> { +/** Parse standards-compliant event streams from a Responses endpoint. */ +export function parseResponsesSSEStream(body: ReadableStream): ReadableStream> { const decoder = new TextDecoder(); return body .pipeThrough( @@ -26,11 +26,11 @@ export function parseOpenAICodexSSEStream(body: ReadableStream): Rea try { const parsed: unknown = JSON.parse(data); if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new TypeError("codex SSE data must be a JSON object"); + throw new TypeError("Responses SSE data must be a JSON object"); } controller.enqueue(parsed as Record); } catch (cause) { - controller.error(new Error(`invalid OpenAI Codex SSE JSON: ${data.slice(0, 200)}`, { cause })); + controller.error(new Error(`invalid Responses SSE JSON: ${data.slice(0, 200)}`, { cause })); } }, }), diff --git a/packages/aikit/src/providers/openai-codex/codex-tools.ts b/packages/aikit/src/providers/responses/tools.ts similarity index 55% rename from packages/aikit/src/providers/openai-codex/codex-tools.ts rename to packages/aikit/src/providers/responses/tools.ts index fdd39036..a407daf2 100644 --- a/packages/aikit/src/providers/openai-codex/codex-tools.ts +++ b/packages/aikit/src/providers/responses/tools.ts @@ -1,9 +1,13 @@ -import type { LanguageModelV3CallOptions, LanguageModelV3ToolChoice, SharedV3Warning } from "@ai-sdk/provider"; +import type { + LanguageModelV3CallOptions, + LanguageModelV3FunctionTool, + LanguageModelV3ToolChoice, + SharedV3Warning, +} from "@ai-sdk/provider"; import type { TSchema } from "typebox"; import * as Message from "../../message/message.ts"; -import type { OpenAICodexCompatibility } from "./codex-language-model.ts"; -export type OpenAICodexTool = +export type ResponsesTool = | { type: "function"; name: string; @@ -24,21 +28,21 @@ export type OpenAICodexTool = defer_loading?: boolean; }; -export type OpenAICodexGrammarConstraint = { +export type ResponsesGrammarConstraint = { type: "grammar"; format: "lark" | "regex"; definition: string; inputProperty: string; }; -export type OpenAICodexGrammarInputBuffer = { +export type GrammarInputBuffer = { input: string; started: boolean; closed: boolean; }; -export function appendOpenAICodexGrammarInputJsonDelta( - buffer: OpenAICodexGrammarInputBuffer, +export function appendGrammarInputJsonDelta( + buffer: GrammarInputBuffer, inputProperty: string, nextInput: string, close: boolean, @@ -91,10 +95,10 @@ function inferGrammarInputProperty(parameters: TSchema): string { return inputProperty; } -export function resolveOpenAICodexToolConstraint( +export function resolveToolGrammar( tool: Message.Tool, - compat?: OpenAICodexCompatibility, -): OpenAICodexGrammarConstraint | undefined { + compat?: { supportsOpenAIGrammarTools?: boolean }, +): ResponsesGrammarConstraint | undefined { const config = tool.constrainedSampling; if (!config || config.type !== "grammar") return undefined; if (!(compat?.supportsOpenAIGrammarTools ?? false)) return undefined; @@ -121,130 +125,115 @@ export function resolveOpenAICodexToolConstraint( } } -export const openAICodexTools = { - custom(args: { - name: string; - description: string; - parameters: TParameters; - format: { type: "grammar"; syntax: "lark" | "regex"; definition: string }; - }): Message.Tool { - return { - name: args.name, - description: args.description, - parameters: args.parameters, - constrainedSampling: { - type: "grammar", - variants: - args.format.syntax === "lark" - ? { openai_lark: args.format.definition } - : { openai_regex: args.format.definition }, - }, - }; - }, +export type ResponsesToolChoice = "auto" | "none" | "required"; + +/** + * Tool changes declared at a system message's position, carried in its + * `providerOptions[].toolChange`. `changes` declares tools in + * `additional_tools` items; `additions` declares them as a synthetic tool search. + */ +export type ResponsesToolChange = { + mode: "changes" | "additions"; + tools: LanguageModelV3FunctionTool[]; + removed: string[]; }; -export type OpenAICodexToolChoice = "auto" | "none" | "required"; -export type OpenAICodexDeferredToolsMode = "additional-tools" | "tool-search"; - -export function resolveOpenAICodexDeferredToolsMode( - compat?: OpenAICodexCompatibility, -): OpenAICodexDeferredToolsMode | undefined { - const supportsAdditionalTools = compat?.supportsAdditionalTools ?? false; - if (supportsAdditionalTools) return "additional-tools"; +/** A function tool carries its grammar in `providerOptions[].grammar`. */ +function toolGrammar(tool: LanguageModelV3FunctionTool, provider: string): ResponsesGrammarConstraint | undefined { + const metadata = tool.providerOptions?.[provider] as Record | undefined; + const grammar = metadata?.grammar as ResponsesGrammarConstraint | undefined; + return grammar?.type === "grammar" ? grammar : undefined; +} - const supportsToolSearch = compat?.supportsToolSearch ?? false; - return supportsToolSearch ? "tool-search" : undefined; +export function toResponsesTool(tool: LanguageModelV3FunctionTool, provider: string): ResponsesTool { + const grammar = toolGrammar(tool, provider); + return grammar + ? { + type: "custom", + name: tool.name, + ...(tool.description !== undefined && { description: tool.description }), + format: { + type: "grammar", + syntax: grammar.format, + definition: grammar.definition, + }, + } + : { + type: "function", + name: tool.name, + ...(tool.description !== undefined && { description: tool.description }), + parameters: tool.inputSchema as Record, + strict: tool.strict ?? null, + }; } -export function prepareOpenAICodexTools({ +export function prepareResponsesTools({ + provider, tools, toolChoice, - deferredToolNames, + inPlaceToolNames, }: { + provider: string; tools: LanguageModelV3CallOptions["tools"]; toolChoice: LanguageModelV3ToolChoice | undefined; - deferredToolNames?: ReadonlySet; + /** Tools declared in place in the input; they stay out of the top-level list. */ + inPlaceToolNames?: ReadonlySet; }): { - codexTools: OpenAICodexTool[] | undefined; - deferredCodexTools: ReadonlyMap; + responsesTools: ResponsesTool[] | undefined; grammarToolInputProperties: ReadonlyMap; - codexToolChoice: OpenAICodexToolChoice | undefined; + toolChoiceValue: ResponsesToolChoice | undefined; warnings: SharedV3Warning[]; } { const warnings: SharedV3Warning[] = []; - const deferredCodexTools = new Map(); const grammarToolInputProperties = new Map(); if (!tools || tools.length === 0) { return { - codexTools: undefined, - deferredCodexTools, + responsesTools: undefined, grammarToolInputProperties, - codexToolChoice: undefined, + toolChoiceValue: undefined, warnings, }; } - const codexTools: OpenAICodexTool[] = []; + const responsesTools: ResponsesTool[] = []; for (const tool of tools) { if (tool.type !== "function") { warnings.push({ type: "unsupported", feature: `tool type ${tool.type}`, - details: `OpenAI Codex only supports function tools; ignoring ${tool.name}.`, + details: `Only function tools are supported; ignoring ${tool.name}.`, }); continue; } - const metadata = tool.providerOptions?.["openai-codex"] as Record | undefined; - const grammar = metadata?.grammar as OpenAICodexGrammarConstraint | undefined; - const codexTool: OpenAICodexTool = - grammar?.type === "grammar" - ? { - type: "custom", - name: tool.name, - ...(tool.description !== undefined && { description: tool.description }), - format: { - type: "grammar", - syntax: grammar.format, - definition: grammar.definition, - }, - } - : { - type: "function", - name: tool.name, - ...(tool.description !== undefined && { description: tool.description }), - parameters: tool.inputSchema as Record, - strict: tool.strict ?? null, - }; - if (grammar?.type === "grammar") grammarToolInputProperties.set(tool.name, grammar.inputProperty); - if (deferredToolNames?.has(tool.name)) deferredCodexTools.set(tool.name, codexTool); - else codexTools.push(codexTool); + const grammar = toolGrammar(tool, provider); + if (grammar) grammarToolInputProperties.set(tool.name, grammar.inputProperty); + if (!inPlaceToolNames?.has(tool.name)) responsesTools.push(toResponsesTool(tool, provider)); } - let codexToolChoice: OpenAICodexToolChoice | undefined; + let toolChoiceValue: ResponsesToolChoice | undefined; switch (toolChoice?.type) { case undefined: break; case "auto": case "none": case "required": - codexToolChoice = toolChoice.type; + toolChoiceValue = toolChoice.type; break; case "tool": warnings.push({ type: "unsupported", feature: "toolChoice tool name", - details: "OpenAI Codex does not support forcing a specific tool; using 'required' instead.", + details: "Forcing a specific tool is not supported; using 'required' instead.", }); - codexToolChoice = "required"; + toolChoiceValue = "required"; break; } return { - codexTools: codexTools.length > 0 ? codexTools : undefined, - deferredCodexTools, + responsesTools: responsesTools.length > 0 ? responsesTools : undefined, grammarToolInputProperties, - codexToolChoice, + toolChoiceValue, warnings, }; } diff --git a/packages/aikit/src/providers/openai-codex/codex-usage.ts b/packages/aikit/src/providers/responses/usage.ts similarity index 91% rename from packages/aikit/src/providers/openai-codex/codex-usage.ts rename to packages/aikit/src/providers/responses/usage.ts index fca94786..debe593c 100644 --- a/packages/aikit/src/providers/openai-codex/codex-usage.ts +++ b/packages/aikit/src/providers/responses/usage.ts @@ -1,6 +1,6 @@ import type { JSONObject, LanguageModelV3FinishReason, LanguageModelV3Usage } from "@ai-sdk/provider"; -export type OpenAICodexUsage = { +export type ResponsesUsage = { input_tokens?: number; output_tokens?: number; total_tokens?: number; @@ -8,7 +8,7 @@ export type OpenAICodexUsage = { output_tokens_details?: { reasoning_tokens?: number }; }; -export function convertOpenAICodexUsage(usage: OpenAICodexUsage | undefined): LanguageModelV3Usage { +export function convertResponsesUsage(usage: ResponsesUsage | undefined): LanguageModelV3Usage { const inputTotal = usage?.input_tokens; const cachedTokens = usage?.input_tokens_details?.cached_tokens ?? 0; const cacheWriteTokens = usage?.input_tokens_details?.cache_write_tokens ?? 0; @@ -31,7 +31,7 @@ export function convertOpenAICodexUsage(usage: OpenAICodexUsage | undefined): La }; } -export function mapOpenAICodexFinishReason( +export function mapResponsesFinishReason( status: string | undefined, hasToolCalls: boolean, incompleteReason?: string, diff --git a/packages/aikit/src/utils/estimate.ts b/packages/aikit/src/utils/estimate.ts index dc66453e..9712c6fe 100644 --- a/packages/aikit/src/utils/estimate.ts +++ b/packages/aikit/src/utils/estimate.ts @@ -65,6 +65,7 @@ function toolResultChars(part: Message.ToolCall): number { } export function estimateMessageTokens(message: Message.Message): number { + if (message.role === "system") return estimateToolsTokens(message.toolsAdded); if (message.role === "user") return estimateContentTokens(message.parts); let chars = 0; @@ -122,7 +123,7 @@ function estimateMessages(messages: ReadonlyArray): ContextUsag const usageTokens = calculateContextTokens(usage.usage); // Tool results are attached to the assistant turn after its usage was // reported, not sent as separate messages. Only its output is counted. - const latest = messages[usage.index]!; + const latest = messages[usage.index] as Message.AssistantMessage; let resultChars = 0; for (const part of latest.parts) { if (part.type === "toolCall") resultChars += toolResultChars(part); @@ -142,37 +143,15 @@ function estimateToolsTokens(tools: ReadonlyArray | undefined): nu return estimateTextTokens(safeJsonStringify(tools)); } -/** Tools discovered by execution of the given assistant turn or later turns. */ -function toolNamesAddedAfter(messages: ReadonlyArray, index: number): Set { - const names = new Set(); - for (let i = index; i < messages.length; i++) { - const message = messages[i]!; - if (message.role !== "assistant") continue; - for (const part of message.parts) { - if (part.type !== "toolCall") continue; - for (const name of part.addedToolNames ?? []) names.add(name); - } - } - return names; -} - export function estimateContextTokens(context: Message.Context): ContextUsageEstimate { const estimate = estimateMessages(context.messages); /* * Reported usage already includes the system prompt and the tools that were - * live at the time, so only what arrived since is added on top. + * live at the time. Tools added since arrive as system messages after it, + * which the trailing estimate already counts. */ - if (estimate.lastUsageIndex !== null) { - const added = toolNamesAddedAfter(context.messages, estimate.lastUsageIndex); - const addedTokens = estimateToolsTokens(context.tools?.filter((tool) => added.has(tool.name))); - return { - tokens: estimate.tokens + addedTokens, - usageTokens: estimate.usageTokens, - trailingTokens: estimate.trailingTokens + addedTokens, - lastUsageIndex: estimate.lastUsageIndex, - }; - } + if (estimate.lastUsageIndex !== null) return estimate; const prefixTokens = (context.systemPrompt ? estimateTextTokens(context.systemPrompt) : 0) + estimateToolsTokens(context.tools); diff --git a/packages/aikit/test/e2e/__artifacts__/text-only-images.openai-codex.json b/packages/aikit/test/e2e/__artifacts__/text-only-images.openai-codex.json index 8ecb8e81..5b3205d2 100644 --- a/packages/aikit/test/e2e/__artifacts__/text-only-images.openai-codex.json +++ b/packages/aikit/test/e2e/__artifacts__/text-only-images.openai-codex.json @@ -1,4 +1,31 @@ [ + { + "role": "system", + "content": "", + "providerOptions": { + "openai-codex": { + "toolChange": { + "mode": "changes", + "tools": [ + { + "type": "function", + "name": "screenshot", + "description": "Takes a screenshot", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ], + "removed": [] + } + } + } + }, + { + "role": "system", + "content": "You are a helpful assistant." + }, { "role": "user", "content": [ diff --git a/packages/aikit/test/e2e/__artifacts__/thinking-handoff.anthropic-to-openai-codex.json b/packages/aikit/test/e2e/__artifacts__/thinking-handoff.anthropic-to-openai-codex.json index 659c2bbe..bd881b93 100644 --- a/packages/aikit/test/e2e/__artifacts__/thinking-handoff.anthropic-to-openai-codex.json +++ b/packages/aikit/test/e2e/__artifacts__/thinking-handoff.anthropic-to-openai-codex.json @@ -1,4 +1,8 @@ [ + { + "role": "system", + "content": "string" + }, { "role": "user", "content": [ diff --git a/packages/aikit/test/e2e/__artifacts__/tools-added.anthropic.json b/packages/aikit/test/e2e/__artifacts__/tools-added.anthropic.json new file mode 100644 index 00000000..b51800f4 --- /dev/null +++ b/packages/aikit/test/e2e/__artifacts__/tools-added.anthropic.json @@ -0,0 +1,6 @@ +{ + "topLevelTools": [ + "late_tool" + ], + "called": "late_tool" +} \ No newline at end of file diff --git a/packages/aikit/test/e2e/__artifacts__/tools-added.github-copilot-chat.json b/packages/aikit/test/e2e/__artifacts__/tools-added.github-copilot-chat.json new file mode 100644 index 00000000..b51800f4 --- /dev/null +++ b/packages/aikit/test/e2e/__artifacts__/tools-added.github-copilot-chat.json @@ -0,0 +1,6 @@ +{ + "topLevelTools": [ + "late_tool" + ], + "called": "late_tool" +} \ No newline at end of file diff --git a/packages/aikit/test/e2e/__artifacts__/tools-added.github-copilot-messages.json b/packages/aikit/test/e2e/__artifacts__/tools-added.github-copilot-messages.json new file mode 100644 index 00000000..b51800f4 --- /dev/null +++ b/packages/aikit/test/e2e/__artifacts__/tools-added.github-copilot-messages.json @@ -0,0 +1,6 @@ +{ + "topLevelTools": [ + "late_tool" + ], + "called": "late_tool" +} \ No newline at end of file diff --git a/packages/aikit/test/e2e/__artifacts__/tools-added.github-copilot-responses.json b/packages/aikit/test/e2e/__artifacts__/tools-added.github-copilot-responses.json new file mode 100644 index 00000000..25bfd7c9 --- /dev/null +++ b/packages/aikit/test/e2e/__artifacts__/tools-added.github-copilot-responses.json @@ -0,0 +1,14 @@ +{ + "topLevelTools": [], + "input": [ + "additional_tools: base_tool", + "developer: You complete tasks by calling tools. Never answer in text when a tool can do the step.", + "user", + "function_call: base_tool", + "function_call_output", + "developer: This is an incremental tools update. Previously declared tools remain available unless explicitly marked unavailable. A tool declared again here is available again, and its latest definition replaces the earlier one.", + "additional_tools: late_tool", + "developer: The following tools are no longer available. Do not call them:" + ], + "called": "late_tool" +} \ No newline at end of file diff --git a/packages/aikit/test/e2e/__artifacts__/tools-added.google.json b/packages/aikit/test/e2e/__artifacts__/tools-added.google.json new file mode 100644 index 00000000..b51800f4 --- /dev/null +++ b/packages/aikit/test/e2e/__artifacts__/tools-added.google.json @@ -0,0 +1,6 @@ +{ + "topLevelTools": [ + "late_tool" + ], + "called": "late_tool" +} \ No newline at end of file diff --git a/packages/aikit/test/e2e/__artifacts__/tools-added.openai-codex.json b/packages/aikit/test/e2e/__artifacts__/tools-added.openai-codex.json new file mode 100644 index 00000000..a0c4babc --- /dev/null +++ b/packages/aikit/test/e2e/__artifacts__/tools-added.openai-codex.json @@ -0,0 +1,13 @@ +{ + "topLevelTools": [], + "input": [ + "additional_tools: base_tool", + "user", + "function_call: base_tool", + "function_call_output", + "developer: This is an incremental tools update. Previously declared tools remain available unless explicitly marked unavailable. A tool declared again here is available again, and its latest definition replaces the earlier one.", + "additional_tools: late_tool", + "developer: The following tools are no longer available. Do not call them:" + ], + "called": "late_tool" +} \ No newline at end of file diff --git a/packages/aikit/test/e2e/__artifacts__/tools-added.openai.json b/packages/aikit/test/e2e/__artifacts__/tools-added.openai.json new file mode 100644 index 00000000..e330e1fd --- /dev/null +++ b/packages/aikit/test/e2e/__artifacts__/tools-added.openai.json @@ -0,0 +1,12 @@ +{ + "topLevelTools": [ + "late_tool" + ], + "input": [ + "developer: You complete tasks by calling tools. Never answer in text when a tool can do the step.", + "user", + "function_call: base_tool", + "function_call_output" + ], + "called": "late_tool" +} \ No newline at end of file diff --git a/packages/aikit/test/e2e/__artifacts__/tools-added.openrouter.json b/packages/aikit/test/e2e/__artifacts__/tools-added.openrouter.json new file mode 100644 index 00000000..b51800f4 --- /dev/null +++ b/packages/aikit/test/e2e/__artifacts__/tools-added.openrouter.json @@ -0,0 +1,6 @@ +{ + "topLevelTools": [ + "late_tool" + ], + "called": "late_tool" +} \ No newline at end of file diff --git a/packages/aikit/test/e2e/tools.added.e2e.test.ts b/packages/aikit/test/e2e/tools.added.e2e.test.ts new file mode 100644 index 00000000..0462f8cb --- /dev/null +++ b/packages/aikit/test/e2e/tools.added.e2e.test.ts @@ -0,0 +1,232 @@ +import { zstdDecompressSync } from "node:zlib"; +import Type from "typebox"; +import { describe, expect, it } from "vite-plus/test"; +import { llm } from "../../src/llm.ts"; +import * as Message from "../../src/message/message.ts"; +import { + getGitHubCopilotApiKey, + GitHubCopilotOAuthClient, + gitHubCopilotBaseUrl, +} from "../../src/oauth/github/copilot.ts"; +import { complete } from "../../src/stream.ts"; +import { + ANTHROPIC_E2E_MODELS, + anthropicOptions, + describeIfAnthropic, + describeIfGoogle, + describeIfOpenAI, + describeIfOpenAICodex, + describeIfOpenRouter, + getAnthropicModel, + getGoogleModel, + getOpenAICodexModel, + getOpenAIModel, + getOpenRouterModel, + googleOptions, + openaiCodexOptions, + openaiOptions, + openrouterOptions, + type StreamableModel, +} from "../utils/llm.ts"; + +const baseTool = Message.defineTool({ + name: "base_tool", + description: "Starts the task. Call it first.", + parameters: Type.Object({}), +}); + +const lateTool = Message.defineTool({ + name: "late_tool", + description: "Finishes the task. Pass the code base_tool returned.", + parameters: Type.Object({ code: Type.String() }), +}); + +const copilotApiKey = await getGitHubCopilotApiKey(); +const copilotBaseUrl = gitHubCopilotBaseUrl((await new GitHubCopilotOAuthClient().getCredentials()) ?? undefined); +const describeIfGitHubCopilot = copilotApiKey ? describe : describe.skip; + +async function getCopilotModel(modelId: string): Promise { + const model = await llm("github-copilot", modelId); + if (!model) throw new Error(`expected github-copilot model ${modelId}`); + return model as StreamableModel; +} + +/** Records each request body the provider sends (Codex zstd-compresses its own), then forwards it. */ +function recordRequests() { + const bodies: Array> = []; + const record: typeof fetch = (input, init) => { + const body = init?.body; + if (typeof body === "string") bodies.push(JSON.parse(body)); + else if (body instanceof Uint8Array) bodies.push(JSON.parse(zstdDecompressSync(body).toString())); + return fetch(input, init); + }; + return { bodies, fetch: record }; +} + +function toolName(tool: unknown): string | undefined { + if (typeof tool !== "object" || tool === null) return; + const record = tool as { name?: unknown; function?: { name?: unknown } }; + if (typeof record.name === "string") return record.name; + if (typeof record.function?.name === "string") return record.function.name; +} + +/** Names of the tools in the request's top-level tool list, across wire formats. */ +function topLevelTools(body: Record): string[] { + const tools = (body.tools ?? []) as unknown[]; + return tools.flatMap((tool) => { + const declarations = (tool as { functionDeclarations?: unknown[] }).functionDeclarations; + return (declarations ?? [tool]).map(toolName).filter((name): name is string => name !== undefined); + }); +} + +/** The Responses input as one line per item, so where each tool was declared stays visible. */ +function responsesLayout(body: Record): string[] | undefined { + const input = body.input as Array> | undefined; + if (!Array.isArray(input)) return; + return input.flatMap((item) => { + if (item.type === "reasoning") return []; + if (item.type === "additional_tools" || item.type === "tool_search_output") { + return [`${item.type}: ${(item.tools as unknown[]).map(toolName).join(", ")}`]; + } + if (item.type === "function_call") return [`function_call: ${String(item.name)}`]; + if (typeof item.type === "string") return [item.type]; + const content = typeof item.content === "string" ? `: ${item.content.split("\n")[0]}` : ""; + return [`${String(item.role)}${content}`]; + }); +} + +/* + * A tool added mid-conversation is callable on the next request. The first turn only has + * `base_tool`; after its result a system message adds `late_tool` and removes `base_tool`, + * and the second turn must call `late_tool`. The artifact records where each tool went. + */ +async function callToolAddedMidConversation(model: StreamableModel, options: object, name: string) { + const context: Message.Context = { + systemPrompt: "You complete tasks by calling tools. Never answer in text when a tool can do the step.", + messages: [ + Message.createUserMessage({ + role: "user", + parts: [ + { + type: "text", + text: "Call base_tool now. Once it returns, call late_tool with the code it gave you.", + }, + ], + time: { created: Date.now() }, + }), + ], + tools: [baseTool], + }; + + const first = await complete(model, context, options as never); + expect(first.stopReason, first.errorMessage).toBe("toolUse"); + const call = first.parts.find((part): part is Message.ToolCall => part.type === "toolCall"); + if (!call || call.name !== "base_tool") throw new Error(`expected a base_tool call, received ${call?.name}`); + context.messages.push({ + ...first, + parts: first.parts.map((part) => + part === call + ? { + ...call, + status: "completed", + result: { + content: [{ type: "text", text: "Code: 4711. late_tool is now available; call it next." }], + isError: false, + }, + time: { ...call.time, end: Date.now() }, + } + : part, + ), + }); + context.messages.push( + Message.createSystemMessage({ toolsAdded: [lateTool], toolsRemoved: [{ name: "base_tool" }] }), + ); + + const requests = recordRequests(); + const second = await complete(model, context, { + ...options, + factoryOptions: { ...(options as { factoryOptions?: object }).factoryOptions, fetch: requests.fetch }, + } as never); + expect(second.stopReason, second.errorMessage).toBe("toolUse"); + const called = second.parts.filter((part): part is Message.ToolCall => part.type === "toolCall"); + // Some models call it more than once in parallel; every call must be the added tool. + expect(called.length).toBeGreaterThan(0); + expect(new Set(called.map((part) => part.name))).toEqual(new Set(["late_tool"])); + expect(called[0]?.arguments).toMatchObject({ code: expect.stringContaining("4711") }); + + const body = requests.bodies.at(-1); + if (!body) throw new Error("expected a recorded request"); + const layout = responsesLayout(body); + const artifact = { topLevelTools: topLevelTools(body), ...(layout ? { input: layout } : {}), called: "late_tool" }; + await expect(JSON.stringify(artifact, null, "\t")).toMatchFileSnapshot(`./__artifacts__/tools-added.${name}.json`); + return artifact; +} + +describe("Tools added mid-conversation", () => { + describeIfOpenAICodex("OpenAI Codex provider", () => { + it("gpt-6-luna declares tool changes in place", { retry: 2, timeout: 120_000 }, async () => { + const model = await getOpenAICodexModel("gpt-6-luna"); + const artifact = await callToolAddedMidConversation(model, openaiCodexOptions(), "openai-codex"); + expect(artifact.topLevelTools).toEqual([]); + }); + }); + + describeIfAnthropic("Anthropic provider", () => { + it(ANTHROPIC_E2E_MODELS[0], { retry: 2, timeout: 120_000 }, async () => { + const model = await getAnthropicModel(ANTHROPIC_E2E_MODELS[0]); + const artifact = await callToolAddedMidConversation(model, anthropicOptions({ maxTokens: 512 }), "anthropic"); + expect(artifact.topLevelTools).toEqual(["late_tool"]); + }); + }); + + describeIfOpenAI("OpenAI provider", () => { + it("gpt-6-luna", { retry: 2, timeout: 120_000 }, async () => { + const model = await getOpenAIModel("gpt-6-luna"); + const artifact = await callToolAddedMidConversation(model, openaiOptions(), "openai"); + expect(artifact.topLevelTools).toEqual(["late_tool"]); + }); + }); + + describeIfGoogle("Google provider", () => { + it("gemini-3.5-flash", { retry: 2, timeout: 120_000 }, async () => { + const model = await getGoogleModel(); + const artifact = await callToolAddedMidConversation(model, googleOptions(), "google"); + expect(artifact.topLevelTools).toEqual(["late_tool"]); + }); + }); + + describeIfGitHubCopilot("GitHub Copilot provider", () => { + const options = (extras: object = {}) => ({ apiKey: copilotApiKey!, baseURL: copilotBaseUrl, ...extras }); + + it("gpt-6-luna declares tool changes in place on /responses", { retry: 2, timeout: 120_000 }, async () => { + const model = await getCopilotModel("gpt-6-luna"); + const artifact = await callToolAddedMidConversation( + model, + options({ reasoning: "low" }), + "github-copilot-responses", + ); + expect(artifact.topLevelTools).toEqual([]); + }); + + it("claude-sonnet-5.5 takes the current tools on /v1/messages", { retry: 2, timeout: 120_000 }, async () => { + const model = await getCopilotModel("claude-sonnet-5.5"); + // Thinking off: this Claude cannot disable thinking, so the request leaves it out. + const artifact = await callToolAddedMidConversation(model, options(), "github-copilot-messages"); + expect(artifact.topLevelTools).toEqual(["late_tool"]); + }); + + it("gemini-3.8-flash takes the current tools on /chat/completions", { retry: 2, timeout: 120_000 }, async () => { + const model = await getCopilotModel("gemini-3.8-flash"); + const artifact = await callToolAddedMidConversation(model, options(), "github-copilot-chat"); + expect(artifact.topLevelTools).toEqual(["late_tool"]); + }); + }); + + describeIfOpenRouter("OpenRouter provider", () => { + it("deepseek/deepseek-v4.1-flash", { retry: 2, timeout: 120_000 }, async () => { + const model = await getOpenRouterModel("deepseek/deepseek-v4.1-flash"); + const artifact = await callToolAddedMidConversation(model, openrouterOptions(), "openrouter"); + expect(artifact.topLevelTools).toEqual(["late_tool"]); + }); + }); +}); diff --git a/packages/aikit/test/estimate.test.ts b/packages/aikit/test/estimate.test.ts index 0557d014..2718e8b3 100644 --- a/packages/aikit/test/estimate.test.ts +++ b/packages/aikit/test/estimate.test.ts @@ -67,10 +67,9 @@ describe("estimateContextTokens", () => { it("adds results and discovered tools on the usage-bearing turn only once", () => { const latest = assistant(100, 1_000); latest.parts = [makeCompletedToolCall("read", "read", [{ type: "text", text: "r".repeat(40_000) }])]; - const call = latest.parts[0]!; - if (call.type === "toolCall") call.addedToolNames = ["new_tool"]; const tool: Message.Tool = { name: "new_tool", description: "new", parameters: { type: "object" } }; - const context: Message.Context = { messages: [latest], tools: [tool] }; + const loaded = Message.createSystemMessage({ toolsAdded: [tool], time: { created: 150 } }); + const context: Message.Context = { messages: [latest, loaded] }; const added = 10_000 + estimateTextTokens(JSON.stringify([tool])); expect(estimateContextTokens(context)).toMatchObject({ tokens: 1_000 + added, trailingTokens: added }); expect(clampMaxTokensToContext(makeModel({ contextWindow: 20_000 }), context, 16_000)).toBe( diff --git a/packages/aikit/test/failure.test.ts b/packages/aikit/test/failure.test.ts index 29abb1b1..38b58150 100644 --- a/packages/aikit/test/failure.test.ts +++ b/packages/aikit/test/failure.test.ts @@ -37,6 +37,31 @@ describe("provider failure normalization", () => { expect(JSON.stringify(failure)).not.toContain("sensitive"); }); + it("reads the message from a body the adapter could not parse", () => { + // Copilot answers its Anthropic route with OpenAI-style errors. + const failure = Failure.normalize( + new APICallError({ + message: "Bad Request", + url: "https://api.githubcopilot.com/v1/messages", + requestBodyValues: {}, + statusCode: 400, + responseBody: JSON.stringify({ + error: { + message: "The requested model is not supported.", + code: "model_not_supported", + type: "invalid_request_error", + }, + }), + }), + ); + + expect(failure).toMatchObject({ + message: "the requested model is not supported", + code: "model_not_supported", + status: 400, + }); + }); + it("reads retry-after-ms and an HTTP-date retry-after", () => { const retryAfter = (responseHeaders: Record) => Failure.normalize( diff --git a/packages/aikit/test/modelgen.test.ts b/packages/aikit/test/modelgen.test.ts index 3e9efd25..ccab8509 100644 --- a/packages/aikit/test/modelgen.test.ts +++ b/packages/aikit/test/modelgen.test.ts @@ -288,18 +288,15 @@ describe("githubCopilotBuiltInModels", () => { } }); - it("marks adaptive Claude and stores reasoning options for Responses models", () => { + it("marks adaptive Claude and in-place tool support for Responses models", () => { const models = githubCopilotBuiltInModels(copilotModelsDevProvider()); const claude = models["claude-opus-4.8"]!; expect(claude.compat?.forceAdaptiveThinking).toBe(true); expect(claude.thinkingLevelMap).toMatchObject({ minimal: "low", xhigh: "xhigh", max: "max" }); const gpt = models["gpt-5.4"]!; - expect(gpt.providerOptions?.["github-copilot"]).toEqual({ - store: false, - include: ["reasoning.encrypted_content"], - }); - expect(gpt.compat?.supportsOpenAIGrammarTools).toBe(true); - expect(gpt.thinkingLevelMap).toMatchObject({ off: null, minimal: "low", xhigh: "xhigh", max: null }); + expect(gpt.providerOptions?.["github-copilot"]).toBeUndefined(); + expect(gpt.compat).toMatchObject({ supportsOpenAIGrammarTools: true, supportsAdditionalTools: true }); + expect(gpt.thinkingLevelMap).toMatchObject({ off: "none", minimal: "low", xhigh: "xhigh", max: null }); }); }); diff --git a/packages/aikit/test/oauth.github-copilot.test.ts b/packages/aikit/test/oauth.github-copilot.test.ts index ba0951aa..c93befe9 100644 --- a/packages/aikit/test/oauth.github-copilot.test.ts +++ b/packages/aikit/test/oauth.github-copilot.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { fetchGitHubCopilotModels, + filterGitHubCopilotModels, getGitHubCopilotApiKey, GitHubCopilotOAuthClient, GITHUB_COPILOT_PROVIDER_ID, @@ -248,3 +249,15 @@ describe("GitHubCopilotOAuthClient", () => { expect(await new JsonGitHubCopilotAuthStorage({ path }).get()).toBeUndefined(); }); }); + +describe("filterGitHubCopilotModels", () => { + it("checks the API id, so an aliased model follows the model it calls", () => { + const models = { + fast: { id: "fast", api: { id: "gpt-6-luna" } }, + slow: { id: "slow", api: { id: "gpt-6-sol" } }, + "gpt-6-sol": { id: "gpt-6-sol" }, + }; + expect(Object.keys(filterGitHubCopilotModels(models, { availableModelIds: ["gpt-6-luna"] }))).toEqual(["fast"]); + expect(Object.keys(filterGitHubCopilotModels(models, {}))).toEqual(["fast", "slow", "gpt-6-sol"]); + }); +}); diff --git a/packages/aikit/test/provider/github-copilot.test.ts b/packages/aikit/test/provider/github-copilot.test.ts index 49ad0a19..1f8e355d 100644 --- a/packages/aikit/test/provider/github-copilot.test.ts +++ b/packages/aikit/test/provider/github-copilot.test.ts @@ -3,7 +3,16 @@ import { GITHUB_COPILOT_API_VERSION, GITHUB_COPILOT_STATIC_HEADERS, createCopilotFetch, + createGitHubCopilot, } from "../../src/providers/github-copilot/index.ts"; +import { stream } from "../../src/stream.ts"; +import { makeModel, makeUserMessage } from "../utils/fixtures.ts"; +import { + openAICodexSSEResponse, + openAICodexTextEvents, + openAICodexUserPrompt, + readOpenAICodexStream, +} from "../utils/openai-codex.ts"; type CapturedRequest = { url: string; headers: Headers; body: unknown }; @@ -200,3 +209,89 @@ describe("createCopilotFetch", () => { expect(requests[2]!.headers.get("anthropic-beta")).toBe(beta); }); }); + +describe("GitHub Copilot Responses", () => { + const sse = (events: Array>) => { + const bodies: Array> = []; + const fetch: typeof globalThis.fetch = async (_input, init) => { + bodies.push(JSON.parse(init?.body as string) as Record); + return openAICodexSSEResponse(events); + }; + return { bodies, fetch }; + }; + const responses = (fetch: typeof globalThis.fetch) => + createGitHubCopilot({ apiKey: "ghu_test", fetch }).responses("gpt-6-luna"); + + it("keeps one tool call when Copilot rotates the item id on every event", async () => { + const { fetch } = sse([ + { type: "response.created", response: { id: "resp_1", model: "gpt-6-luna" } }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "function_call", id: "fc_a", call_id: "call_1", name: "math", arguments: "" }, + }, + { type: "response.function_call_arguments.delta", item_id: "fc_b", output_index: 0, delta: '{"a":' }, + { type: "response.function_call_arguments.delta", item_id: "fc_c", output_index: 0, delta: "15}" }, + { + type: "response.output_item.done", + output_index: 0, + item: { type: "function_call", id: "fc_d", call_id: "call_1", name: "math", arguments: '{"a":15}' }, + }, + { + type: "response.completed", + response: { status: "completed", usage: { input_tokens: 1, output_tokens: 1 } }, + }, + ]); + const { stream: parts } = await responses(fetch).doStream({ prompt: openAICodexUserPrompt }); + const calls = (await readOpenAICodexStream(parts)).filter((part) => part.type === "tool-call"); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ toolName: "math", input: '{"a":15}' }); + }); + + it("sends tool fields only for a tool choice the call names", async () => { + const { bodies, fetch } = sse(openAICodexTextEvents); + const model = responses(fetch); + await model.doStream({ prompt: openAICodexUserPrompt }); + await model.doStream({ + prompt: openAICodexUserPrompt, + tools: [{ type: "function", name: "math", inputSchema: { type: "object", properties: {} } }], + toolChoice: { type: "auto" }, + }); + + // Grok on Copilot rejects either field on a request without tools. + expect(bodies[0]).not.toHaveProperty("tool_choice"); + expect(bodies[0]).not.toHaveProperty("parallel_tool_calls"); + expect(bodies[1]).toMatchObject({ tool_choice: "auto" }); + expect(bodies[1]).not.toHaveProperty("parallel_tool_calls"); + }); + + it("turns reasoning off with the model's off effort", async () => { + const { bodies, fetch } = sse(openAICodexTextEvents); + const model = makeModel({ + id: "gpt-6-luna", + provider: { id: "github-copilot", name: "GitHub Copilot", source: "custom", env: [] }, + npm: "@codeworksh/ai-sdk-github-copilot", + api: { id: "gpt-6-luna", method: "responses" }, + protocol: "github-copilot", + providerOptionsKey: "github-copilot", + reasoning: true, + thinkingLevelMap: { off: "none" }, + }); + const off = await stream.complete( + model, + { messages: [makeUserMessage("hi")] }, + { apiKey: "ghu_test", factoryOptions: { fetch } }, + ); + const cannotDisable = await stream.complete( + { ...model, thinkingLevelMap: { off: null } }, + { messages: [makeUserMessage("hi")] }, + { apiKey: "ghu_test", factoryOptions: { fetch } }, + ); + + expect(off.stopReason, off.errorMessage).toBe("stop"); + expect(cannotDisable.stopReason, cannotDisable.errorMessage).toBe("stop"); + expect(bodies[0]).toMatchObject({ reasoning: { effort: "none" } }); + expect(bodies[1]).not.toHaveProperty("reasoning"); + }); +}); diff --git a/packages/aikit/test/provider/openai.codex.prompt.test.ts b/packages/aikit/test/provider/openai.codex.prompt.test.ts index 233cbc96..00b9ca74 100644 --- a/packages/aikit/test/provider/openai.codex.prompt.test.ts +++ b/packages/aikit/test/provider/openai.codex.prompt.test.ts @@ -1,10 +1,16 @@ /** AI SDK prompt conversion for the OpenAI Codex Responses protocol. */ import type { LanguageModelV3Prompt } from "@ai-sdk/provider"; import { describe, expect, it } from "vite-plus/test"; -import { - appendOpenAICodexGrammarInputJsonDelta, - convertToOpenAICodexPrompt, -} from "../../src/providers/openai-codex/index.ts"; +import { convertToResponsesPrompt, type ResponsesPromptOptions } from "../../src/providers/responses/prompt.ts"; +import { appendGrammarInputJsonDelta } from "../../src/providers/responses/tools.ts"; + +/** Codex's prompt shape: system text in `instructions`, options under `openai-codex`. */ +function codexPrompt( + prompt: Parameters[0], + options: Partial = {}, +) { + return convertToResponsesPrompt(prompt, { provider: "openai-codex", systemPrompt: "instructions", ...options }); +} describe("prompt conversion", () => { it("replays signed reasoning, assistant message ids, and deferred-tool namespaces", () => { @@ -14,7 +20,7 @@ describe("prompt conversion", () => { summary: [], encrypted_content: "encrypted", }; - const { input } = convertToOpenAICodexPrompt([ + const { input } = codexPrompt([ { role: "assistant", content: [ @@ -62,7 +68,7 @@ describe("prompt conversion", () => { }); it("converts AI SDK V4 image file data to input_image entries", () => { - const { input } = convertToOpenAICodexPrompt([ + const { input } = codexPrompt([ { role: "user", content: [ @@ -83,7 +89,7 @@ describe("prompt conversion", () => { it("replays native custom tool calls and results from ordinary AI SDK tool parts", () => { const grammarToolInputProperties = new Map([["sample", "payload"]]); - const { input } = convertToOpenAICodexPrompt( + const { input } = codexPrompt( [ { role: "assistant", @@ -127,13 +133,13 @@ describe("prompt conversion", () => { it("produces append-only JSON deltas for raw grammar input", () => { const buffer = { input: "", started: false, closed: false }; - expect(appendOpenAICodexGrammarInputJsonDelta(buffer, "payload", 'a"', false)).toBe('{"payload":"a\\"'); - expect(appendOpenAICodexGrammarInputJsonDelta(buffer, "payload", 'a"\nb', true)).toBe('\\nb"}'); - expect(appendOpenAICodexGrammarInputJsonDelta(buffer, "payload", 'a"\nb', true)).toBeUndefined(); + expect(appendGrammarInputJsonDelta(buffer, "payload", 'a"', false)).toBe('{"payload":"a\\"'); + expect(appendGrammarInputJsonDelta(buffer, "payload", 'a"\nb', true)).toBe('\\nb"}'); + expect(appendGrammarInputJsonDelta(buffer, "payload", 'a"\nb', true)).toBeUndefined(); }); it("omits Responses item ids for non-composite and cross-model tool calls", () => { - const { input } = convertToOpenAICodexPrompt([ + const { input } = codexPrompt([ { role: "assistant", content: [ @@ -176,7 +182,7 @@ describe("prompt conversion", () => { }, ]; - expect(convertToOpenAICodexPrompt(prompt, { supportsImages: true }).input[0]).toEqual({ + expect(codexPrompt(prompt, { supportsImages: true }).input[0]).toEqual({ type: "function_call_output", call_id: "call_1", output: [ @@ -184,7 +190,7 @@ describe("prompt conversion", () => { { type: "input_image", image_url: "data:image/png;base64,aGVsbG8=", detail: "auto" }, ], }); - expect(convertToOpenAICodexPrompt(prompt, { supportsImages: false }).input[0]).toEqual({ + expect(codexPrompt(prompt, { supportsImages: false }).input[0]).toEqual({ type: "function_call_output", call_id: "call_1", output: "the page", diff --git a/packages/aikit/test/provider/openai.codex.request.test.ts b/packages/aikit/test/provider/openai.codex.request.test.ts index 284c2eee..6c53b818 100644 --- a/packages/aikit/test/provider/openai.codex.request.test.ts +++ b/packages/aikit/test/provider/openai.codex.request.test.ts @@ -1,14 +1,17 @@ +import Type from "typebox"; import { stream } from "../../src/stream.ts"; -import { makeUserMessage } from "../utils/fixtures.ts"; +import * as Message from "../../src/message/message.ts"; +import { makeAssistantMessage, makeCompletedToolCall, makeUserMessage } from "../utils/fixtures.ts"; /** Request shaping for the OpenAI Codex Responses endpoint. */ import { describe, expect, it } from "vite-plus/test"; -import { createOpenAICodex, resolveOpenAICodexUrl } from "../../src/providers/openai-codex/index.ts"; +import { createOpenAICodex, openAICodexTools, resolveOpenAICodexUrl } from "../../src/providers/openai-codex/index.ts"; import { createOpenAICodexMockFetch, OPENAI_CODEX_TEST_ACCOUNT_ID, OPENAI_CODEX_TEST_API_KEY, - openAICodexDeferredTools, - openAICodexDeferredToolsPrompt, + openAICodexLoadableTools, + openAICodexToolChangePrompt, + openAICodexToolRemovalPrompt, openAICodexModels, openAICodexSSEResponse, openAICodexTextEvents, @@ -105,53 +108,65 @@ describe("request", () => { }); }); - it("keeps deferred grammar tools native inside additional_tools", async () => { + it("declares every tool in additional_tools and keeps grammar tools native", async () => { const { fetch, body } = createOpenAICodexMockFetch(openAICodexSSEResponse(openAICodexTextEvents)); - await createOpenAICodex({ - apiKey: OPENAI_CODEX_TEST_API_KEY, - fetch, - compat: { ...openAICodexModels()["gpt-5.6-luna"]!.compat }, - })("gpt-5.6-luna").doStream({ - prompt: openAICodexDeferredToolsPrompt, - tools: [ - openAICodexDeferredTools[0]!, - { - ...openAICodexDeferredTools[1]!, - providerOptions: { - "openai-codex": { - grammar: { - type: "grammar", - format: "regex", - definition: "[a-z]+", - inputProperty: "value", - }, - }, - }, + const grammarTool = { + ...openAICodexLoadableTools[1]!, + providerOptions: { + "openai-codex": { + grammar: { type: "grammar", format: "regex", definition: "[a-z]+", inputProperty: "value" }, }, + }, + }; + await createOpenAICodex({ apiKey: OPENAI_CODEX_TEST_API_KEY, fetch })("gpt-5.6-luna").doStream({ + prompt: openAICodexToolChangePrompt("changes", grammarTool), + tools: [openAICodexLoadableTools[0]!, grammarTool], + }); + + const payload = body(); + expect(payload.tools).toBeUndefined(); + const input = payload.input as Array>; + const additionalTools = input.filter((item) => item.type === "additional_tools"); + expect(additionalTools.map((item) => item.tools)).toEqual([ + [expect.objectContaining({ type: "function", name: "base_tool" })], + [ + expect.objectContaining({ + type: "custom", + name: "late_tool", + format: { type: "grammar", syntax: "regex", definition: "[a-z]+" }, + }), ], + ]); + // The head declaration leads the input; the later one follows the tool result. + expect(input[0]).toMatchObject({ type: "additional_tools" }); + const late = input.indexOf(additionalTools[1]!); + expect(input[late - 2]).toMatchObject({ type: "function_call_output" }); + expect(input[late - 1]).toMatchObject({ + role: "developer", + content: expect.stringContaining("incremental tools update"), }); + }); - const additionalTools = (body().input as Array>).find( - (item) => item.type === "additional_tools", - ); - expect(additionalTools?.tools).toEqual([ - expect.objectContaining({ - type: "custom", - name: "late_tool", - format: { type: "grammar", syntax: "regex", definition: "[a-z]+" }, - }), + it("announces removals as text and lists them while still unavailable", async () => { + const { fetch, body } = createOpenAICodexMockFetch(openAICodexSSEResponse(openAICodexTextEvents)); + await createOpenAICodex({ apiKey: OPENAI_CODEX_TEST_API_KEY, fetch })("gpt-5.6-luna").doStream({ + prompt: openAICodexToolRemovalPrompt, + tools: [openAICodexLoadableTools[1]!], + }); + + const input = body().input as Array>; + const developer = input.filter((item) => item.role === "developer" && typeof item.content === "string"); + expect(developer.map((item) => item.content)).toEqual([ + expect.stringContaining("no longer available. Do not call them:\n- base_tool"), + expect.stringContaining("Still unavailable: base_tool."), ]); }); - it("falls back to transcript tool_search items without additional tools", async () => { + it("declares later tools as a synthetic tool search in additions mode", async () => { const { fetch, body } = createOpenAICodexMockFetch(openAICodexSSEResponse(openAICodexTextEvents)); - await createOpenAICodex({ - apiKey: OPENAI_CODEX_TEST_API_KEY, - fetch, - compat: { supportsOpenAIGrammarTools: true, supportsToolSearch: true }, - })("gpt-6-luna").doStream({ - prompt: openAICodexDeferredToolsPrompt, - tools: openAICodexDeferredTools, + await createOpenAICodex({ apiKey: OPENAI_CODEX_TEST_API_KEY, fetch })("gpt-6-luna").doStream({ + prompt: openAICodexToolChangePrompt("additions"), + tools: openAICodexLoadableTools, }); const payload = body(); @@ -169,11 +184,11 @@ describe("request", () => { expect(input.some((item) => item.type === "additional_tools")).toBe(false); }); - it("keeps all tools top-level when the Codex model has no deferred-tool support", async () => { + it("keeps all tools top-level when the prompt declares no tool changes", async () => { const { fetch, body } = createOpenAICodexMockFetch(openAICodexSSEResponse(openAICodexTextEvents)); await createOpenAICodex({ apiKey: OPENAI_CODEX_TEST_API_KEY, fetch })("gpt-5.3-codex-spark").doStream({ - prompt: openAICodexDeferredToolsPrompt, - tools: openAICodexDeferredTools, + prompt: openAICodexUserPrompt, + tools: openAICodexLoadableTools, }); const payload = body(); @@ -328,4 +343,33 @@ describe("transport failures", () => { expect(calls).toBe(1); expect(result.stopReason).toBe("aborted"); }); + + it("replays a grammar tool's calls after the tool is removed", async () => { + const model = openAICodexModels()["gpt-6-luna"]!; + const edit = openAICodexTools.custom({ + name: "edit", + description: "Edit text", + parameters: Type.Object({ patch: Type.String() }), + format: { type: "grammar", syntax: "regex", definition: "[a-z]+" }, + }); + const call = makeCompletedToolCall("call_1|ctc_1", "edit", [{ type: "text", text: "done" }], { patch: "abc" }); + const { fetch, body } = createOpenAICodexMockFetch(openAICodexSSEResponse(openAICodexTextEvents)); + const result = await stream.complete( + model, + { + tools: [edit], + messages: [ + makeUserMessage("edit it"), + makeAssistantMessage(model, { stopReason: "toolUse", parts: [call] }), + Message.createSystemMessage({ toolsRemoved: [{ name: "edit" }] }), + ], + }, + { apiKey: OPENAI_CODEX_TEST_API_KEY, factoryOptions: { fetch } }, + ); + + expect(result.stopReason, result.errorMessage).toBe("stop"); + expect(body().input).toContainEqual( + expect.objectContaining({ type: "custom_tool_call", name: "edit", input: "abc" }), + ); + }); }); diff --git a/packages/aikit/test/provider/openai.codex.stream.test.ts b/packages/aikit/test/provider/openai.codex.stream.test.ts index 3df1e55c..60ed3645 100644 --- a/packages/aikit/test/provider/openai.codex.stream.test.ts +++ b/packages/aikit/test/provider/openai.codex.stream.test.ts @@ -1,7 +1,9 @@ /** Streaming, SSE decoding, usage, and error mapping for the OpenAI Codex provider. */ import { APICallError } from "@ai-sdk/provider"; import { describe, expect, it } from "vite-plus/test"; -import { convertOpenAICodexUsage, createOpenAICodex, joinToolCallId } from "../../src/providers/openai-codex/index.ts"; +import { createOpenAICodex } from "../../src/providers/openai-codex/index.ts"; +import { joinToolCallId } from "../../src/providers/responses/prompt.ts"; +import { convertResponsesUsage } from "../../src/providers/responses/usage.ts"; import { createOpenAICodexMockFetch, OPENAI_CODEX_TEST_API_KEY, @@ -18,7 +20,7 @@ describe("usage and finish-reason mapping", () => { input_tokens_details: { cached_tokens: 25, cache_write_tokens: 10 }, output_tokens_details: { reasoning_tokens: 7 }, }; - expect(convertOpenAICodexUsage(usage)).toEqual({ + expect(convertResponsesUsage(usage)).toEqual({ inputTokens: { total: 100, noCache: 65, cacheRead: 25, cacheWrite: 10 }, outputTokens: { total: 30, text: 23, reasoning: 7 }, raw: usage, diff --git a/packages/aikit/test/thinking.provideroptions.test.ts b/packages/aikit/test/thinking.provideroptions.test.ts index 394040ad..00c10b85 100644 --- a/packages/aikit/test/thinking.provideroptions.test.ts +++ b/packages/aikit/test/thinking.provideroptions.test.ts @@ -92,6 +92,20 @@ describe("Thinking.disabledProviderOptions", () => { ).toEqual({ anthropic: { effort: "high" } }); }); + it("leaves thinking out for a Copilot Claude that cannot disable it", () => { + const copilotClaude = (thinkingLevelMap?: Record) => + off({ + id: "claude-sonnet-5.5", + protocol: "github-copilot", + providerOptionsKey: "github-copilot", + api: { id: "claude-sonnet-5.5", method: "messages" }, + reasoning: true, + ...(thinkingLevelMap ? { thinkingLevelMap } : {}), + }); + expect(copilotClaude({ off: null })).toEqual({}); + expect(copilotClaude()).toEqual({ "github-copilot": { thinking: { type: "disabled" } } }); + }); + it("falls back to the lowest level for Gemini 3, which cannot disable thinking", () => { expect( off({ diff --git a/packages/aikit/test/transcript.test.ts b/packages/aikit/test/transcript.test.ts new file mode 100644 index 00000000..2d221ea7 --- /dev/null +++ b/packages/aikit/test/transcript.test.ts @@ -0,0 +1,249 @@ +/** Tool changes carried by transcript system messages, from the context down to the Responses wire. */ +import Type from "typebox"; +import { describe, expect, it } from "vite-plus/test"; +import * as Message from "../src/message/message.ts"; +import * as Transcript from "../src/message/transcript.ts"; +import type * as Model from "../src/model/model.ts"; +import { stream } from "../src/stream.ts"; +import { makeUserMessage } from "./utils/fixtures.ts"; +import { + createOpenAICodexMockFetch, + OPENAI_CODEX_TEST_API_KEY, + openAICodexModels, + openAICodexSSEResponse, + openAICodexTextEvents, +} from "./utils/openai-codex.ts"; + +type ResponsesBody = { + tools?: Array<{ name: string; description?: string }>; + input: Array<{ + type?: string; + role?: string; + content?: unknown; + tools?: Array<{ name: string; description?: string }>; + }>; +}; + +function tool(name: string, description = `${name} tool`): Message.Tool { + return { name, description, parameters: Type.Object({}) }; +} + +const baseTool = tool("base_tool"); +const dropTool = tool("drop_tool"); +const lateTool = tool("late_tool"); + +const system = (change: Pick) => + Message.createSystemMessage(change); + +const changesModel = () => openAICodexModels()["gpt-6-luna"]!; +const additionsModel = (): Model.Info => { + const model = changesModel(); + return { ...model, compat: { ...model.compat, supportsAdditionalTools: false, supportsToolSearch: true } }; +}; + +async function capture(model: Model.Info, context: Message.Context): Promise { + const { fetch, body } = createOpenAICodexMockFetch(openAICodexSSEResponse(openAICodexTextEvents)); + const result = await stream.complete(model, context, { + apiKey: OPENAI_CODEX_TEST_API_KEY, + factoryOptions: { fetch }, + }); + expect(result.stopReason, result.errorMessage).toBe("stop"); + return body() as ResponsesBody; +} + +/** + * One request after each step: the initial tools, the removal of drop_tool, then two loads. + * Returns the request before the removal followed by the one after each later step. + */ +async function captureToolHistory(model: Model.Info): Promise { + const steps: Message.Message[][] = [ + [makeUserMessage("first")], + [system({ toolsRemoved: [{ name: "drop_tool" }] }), makeUserMessage("after removal")], + [system({ toolsAdded: [lateTool] }), makeUserMessage("after first load")], + [system({ toolsAdded: [tool("later_tool")] }), makeUserMessage("after second load")], + ]; + const messages: Message.Message[] = []; + const bodies: ResponsesBody[] = []; + for (const step of steps) { + messages.push(...step); + bodies.push( + await capture(model, { systemPrompt: "base prompt", tools: [baseTool, dropTool], messages: [...messages] }), + ); + } + return bodies; +} + +/** Every request's input starts with the previous request's input, so the cached prefix holds. */ +function expectGrowingPrefix(inputs: ReadonlyArray): void { + for (let index = 1; index < inputs.length; index++) { + const previous = inputs[index - 1] ?? []; + expect(inputs[index]?.slice(0, previous.length)).toEqual(previous); + } +} + +const declared = (body: ResponsesBody | undefined, type: string) => + body?.input.filter((item) => item.type === type).map((item) => item.tools?.map(({ name }) => name)); +const notices = (body: ResponsesBody | undefined, text: string) => + body?.input.flatMap((item) => + typeof item.content === "string" && item.content.includes(text) ? [item.content] : [], + ); + +describe("transcript tool changes on the Responses wire", () => { + it("keeps the request prefix across a removal and later loads", async () => { + const bodies = await captureToolHistory(changesModel()); + + for (const body of bodies) expect(body.tools).toBeUndefined(); + expectGrowingPrefix(bodies.map((body) => body.input)); + const last = bodies.at(-1); + expect(declared(last, "additional_tools")).toEqual([["base_tool", "drop_tool"], ["late_tool"], ["later_tool"]]); + expect(notices(last, "no longer available. Do not call them:\n- drop_tool")).toHaveLength(1); + // Each later load restates the tools that are still unavailable. + expect(notices(last, "incremental tools update")).toEqual([ + expect.stringContaining("\nStill unavailable: drop_tool."), + expect.stringContaining("\nStill unavailable: drop_tool."), + ]); + }); + + it("moves the tool search boundary once on a removal and keeps the prefix across later loads", async () => { + const [beforeRemoval, ...afterRemoval] = await captureToolHistory(additionsModel()); + + expect(beforeRemoval?.tools?.map(({ name }) => name)).toEqual(["base_tool", "drop_tool"]); + for (const body of afterRemoval) expect(body.tools?.map(({ name }) => name)).toEqual(["base_tool"]); + expectGrowingPrefix(afterRemoval.map((body) => body.input)); + expect(declared(afterRemoval.at(-1), "tool_search_output")).toEqual([["late_tool"], ["later_tool"]]); + }); + + it("redefines a tool in place instead of announcing a removal", async () => { + const body = await capture(changesModel(), { + tools: [baseTool], + messages: [ + makeUserMessage("before"), + system({ toolsRemoved: [{ name: "base_tool" }], toolsAdded: [tool("base_tool", "changed")] }), + ], + }); + + expect(body.tools).toBeUndefined(); + expect(body.input).toMatchObject([ + { type: "additional_tools", tools: [{ name: "base_tool", description: "base_tool tool" }] }, + { role: "user" }, + { role: "developer", content: expect.stringContaining("latest definition replaces the earlier one") }, + { type: "additional_tools", tools: [{ name: "base_tool", description: "changed" }] }, + ]); + expect(body.input).toHaveLength(4); + }); + + it("stops listing a tool as unavailable once it is declared again", async () => { + const body = await capture(changesModel(), { + tools: [baseTool, dropTool], + messages: [ + system({ toolsRemoved: [{ name: "drop_tool" }] }), + system({ toolsAdded: [dropTool] }), + makeUserMessage("go"), + ], + }); + + const [notice] = notices(body, "incremental tools update") ?? []; + expect(notice).toBeDefined(); + expect(notice).not.toContain("Still unavailable"); + expect(body.input.at(-2)).toMatchObject({ type: "additional_tools", tools: [{ name: "drop_tool" }] }); + }); + + it("announces a removal after the additions of the same change", async () => { + const body = await capture(changesModel(), { + tools: [baseTool, dropTool], + messages: [system({ toolsAdded: [lateTool], toolsRemoved: [{ name: "drop_tool" }] }), makeUserMessage("go")], + }); + + const [notice, ...rest] = body.input.slice(-4); + expect(notice?.content).toEqual(expect.stringContaining("incremental tools update")); + expect(notice?.content).not.toEqual(expect.stringContaining("Still unavailable")); + expect(rest).toMatchObject([ + { type: "additional_tools", tools: [{ name: "late_tool" }] }, + { content: expect.stringContaining("no longer available. Do not call them:\n- drop_tool") }, + { role: "user" }, + ]); + }); + + it("sends the current tools top-level on a model without in-place declarations", async () => { + const body = await capture(openAICodexModels()["gpt-5.3-codex-spark"]!, { + tools: [baseTool, dropTool], + messages: [ + makeUserMessage("before"), + system({ toolsAdded: [lateTool], toolsRemoved: [{ name: "drop_tool" }] }), + ], + }); + + expect(body.tools?.map(({ name }) => name)).toEqual(["base_tool", "late_tool"]); + expect(body.input.some((item) => item.type === "additional_tools" || item.type === "tool_search_call")).toBe( + false, + ); + }); +}); + +describe("Transcript", () => { + it("resolves the top-level tool boundary per transport", () => { + const messages = [ + system({ toolsRemoved: [{ name: "drop_tool" }] }), + system({ toolsAdded: [dropTool] }), + system({ toolsAdded: [tool("late_tool", "x")] }), + system({ toolsAdded: [tool("late_tool", "y")] }), + ]; + const context = (count: number) => ({ tools: [baseTool, dropTool], messages: messages.slice(0, count) }); + + expect(Transcript.resolveTranscriptTools(context(1), "additions")).toEqual({ + requestTools: [baseTool], + initialToolsInPlace: false, + foldedSystemMessages: 1, + }); + // A removed tool declared again moves the boundary; the new late_tool after it stays in place. + expect(Transcript.resolveTranscriptTools(context(3), "additions")).toEqual({ + requestTools: [baseTool, dropTool], + initialToolsInPlace: false, + foldedSystemMessages: 2, + }); + // The redefinition of late_tool moves the boundary to its message. + expect(Transcript.resolveTranscriptTools(context(4), "additions")).toEqual({ + requestTools: [baseTool, dropTool, tool("late_tool", "y")], + initialToolsInPlace: false, + foldedSystemMessages: 4, + }); + expect(Transcript.resolveTranscriptTools(context(4), "changes")).toEqual({ + requestTools: [], + initialToolsInPlace: true, + foldedSystemMessages: 0, + }); + expect(Transcript.resolveTranscriptTools(context(4), "none")).toEqual({ + requestTools: [baseTool, dropTool, tool("late_tool", "y")], + initialToolsInPlace: false, + foldedSystemMessages: 4, + }); + }); + + it("treats a changed definition as a removal plus an addition", () => { + const changed = tool("base_tool", "changed"); + expect(Transcript.getToolStateChanges([baseTool, dropTool], [changed, lateTool])).toEqual({ + toolsAdded: [changed, lateTool], + toolsRemoved: [{ name: "base_tool" }, { name: "drop_tool" }], + }); + // Typebox symbol keys are not part of the declaration. + const plain: Message.Tool = { + name: "base_tool", + description: "base_tool tool", + parameters: { type: "object", properties: {} }, + }; + expect(Transcript.declarationsEqual(baseTool, plain)).toBe(true); + }); + + it("keeps removed tools among the declared ones, latest definition winning", () => { + const changed = tool("base_tool", "changed"); + expect( + Transcript.getDeclaredTools({ + tools: [baseTool], + messages: [ + system({ toolsRemoved: [{ name: "base_tool" }], toolsAdded: [changed] }), + system({ toolsAdded: [lateTool] }), + ], + }), + ).toEqual([changed, lateTool]); + }); +}); diff --git a/packages/aikit/test/transform.test.ts b/packages/aikit/test/transform.test.ts index 22c17a29..e1f43787 100644 --- a/packages/aikit/test/transform.test.ts +++ b/packages/aikit/test/transform.test.ts @@ -473,17 +473,32 @@ describe("GitHub Copilot thinking replay", () => { }); const openaiSignature = JSON.stringify({ itemId: "rs_9", reasoningEncryptedContent: "enc-9" }); - it("round-trips Responses signatures under the github-copilot key", () => { + it("replays Responses reasoning items under the github-copilot key, including older signatures", () => { const model = copilotModel(Model.APIMethodEnum.responses); + const item = JSON.stringify({ type: "reasoning", id: "rs_1", encrypted_content: "enc-1", summary: [] }); const message = makeAssistantMessage(model, { - parts: [{ type: "thinking", thinking: "reasoned", thinkingSignature: openaiSignature }], + parts: [ + { type: "thinking", thinking: "current", thinkingSignature: item }, + { type: "thinking", thinking: "older", thinkingSignature: openaiSignature }, + { type: "thinking", thinking: "unsigned" }, + ], }); const converted = convertMessages({ messages: [message] }, model); expect(converted[0]?.content).toEqual([ + { type: "reasoning", text: "current", providerOptions: { "github-copilot": { reasoningItem: item } } }, { type: "reasoning", - text: "reasoned", - providerOptions: { "github-copilot": { itemId: "rs_9", reasoningEncryptedContent: "enc-9" } }, + text: "older", + providerOptions: { + "github-copilot": { + reasoningItem: JSON.stringify({ + type: "reasoning", + id: "rs_9", + encrypted_content: "enc-9", + summary: [], + }), + }, + }, }, ]); }); @@ -680,24 +695,35 @@ describe("convertMessages", () => { ]); }); - it("attaches Codex namespace and deferred-tool metadata", () => { - const codexModel = makeCodexModel(); + it("attaches Codex namespace metadata and declares later tools in place", () => { + const codexModel = makeCodexModel({ compat: { supportsAdditionalTools: true } }); const toolCall: Message.ToolCallCompletedPart = { ...makeCompletedToolCall("call-1|fc-1", "search", [{ type: "text", text: "found" }], { query: "x" }), namespace: "workspace", - addedToolNames: ["late_tool"], }; const assistant = makeAssistantMessage(codexModel, { stopReason: "toolUse", parts: [toolCall] }); - const messages = convertMessages({ messages: [assistant] }, codexModel); + const lateTool: Message.Tool = { name: "late_tool", description: "Late", parameters: { type: "object" } }; + const loaded = Message.createSystemMessage({ toolsAdded: [lateTool] }); + const messages = convertMessages({ messages: [assistant, loaded] }, codexModel); expect(messages[0]).toMatchObject({ role: "assistant", content: [{ providerOptions: { "openai-codex": { namespace: "workspace" } } }], }); - expect(messages[1]).toMatchObject({ - role: "tool", - content: [{ providerOptions: { "openai-codex": { addedToolNames: ["late_tool"] } } }], + expect(messages[1]).toMatchObject({ role: "tool" }); + expect(messages[2]).toMatchObject({ + role: "system", + content: "", + providerOptions: { + "openai-codex": { toolChange: { mode: "changes", tools: [{ name: "late_tool" }], removed: [] } }, + }, }); + + // `activeTools` filters only the top-level list, so a limited request declares nothing in place. + const limited = convertMessages({ messages: [assistant, loaded], tools: [lateTool] }, codexModel, undefined, [ + "late_tool", + ]); + expect(limited.some((message) => message.role === "system")).toBe(false); }); it("omits item ids when replaying a prior Codex model id", () => { diff --git a/packages/aikit/test/utils/openai-codex.ts b/packages/aikit/test/utils/openai-codex.ts index 1988e1f8..546bd6df 100644 --- a/packages/aikit/test/utils/openai-codex.ts +++ b/packages/aikit/test/utils/openai-codex.ts @@ -1,4 +1,10 @@ -import type { LanguageModelV3FunctionTool, LanguageModelV3Prompt, LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import type { + LanguageModelV3FunctionTool, + LanguageModelV3Prompt, + LanguageModelV3StreamPart, + SharedV3ProviderOptions, +} from "@ai-sdk/provider"; +import type { ResponsesToolChange } from "../../src/providers/responses/tools.ts"; import { zstdDecompressSync } from "node:zlib"; import { buildCatalog } from "../../src/cli/modelgen.ts"; import type * as Model from "../../src/model/model.ts"; @@ -111,28 +117,52 @@ export const openAICodexUserPrompt: LanguageModelV3Prompt = [ { role: "user", content: [{ type: "text", text: "Hello" }] }, ]; -export const openAICodexDeferredToolsPrompt: LanguageModelV3Prompt = [ - { role: "user", content: [{ type: "text", text: "Use the tools" }] }, - { - role: "assistant", - content: [{ type: "tool-call", toolCallId: "call_1|fc_1", toolName: "base_tool", input: {} }], - }, - { - role: "tool", - content: [ - { - type: "tool-result", - toolCallId: "call_1|fc_1", - toolName: "base_tool", - output: { type: "text", value: "done" }, - providerOptions: { "openai-codex": { addedToolNames: ["late_tool"] } }, - }, - ], - }, - { role: "user", content: [{ type: "text", text: "Continue" }] }, -]; +function toolChangeMessage( + mode: "changes" | "additions", + tools: LanguageModelV3FunctionTool[], + removed: string[] = [], +): LanguageModelV3Prompt[number] { + const toolChange: ResponsesToolChange = { mode, tools, removed }; + // Function tools are plain JSON, as aikit builds them. + return { + role: "system", + content: "", + providerOptions: { "openai-codex": { toolChange } } as unknown as SharedV3ProviderOptions, + }; +} -export const openAICodexDeferredTools: LanguageModelV3FunctionTool[] = [ +/** + * A transcript where `late_tool` is added after the first tool result. `changes` also declares + * the initial `base_tool` in place at the head, as aikit does for that mode. + */ +export function openAICodexToolChangePrompt( + mode: "changes" | "additions", + lateTool: LanguageModelV3FunctionTool = openAICodexLoadableTools[1]!, +): LanguageModelV3Prompt { + return [ + ...(mode === "changes" ? [toolChangeMessage(mode, [openAICodexLoadableTools[0]!])] : []), + { role: "user", content: [{ type: "text", text: "Use the tools" }] }, + { + role: "assistant", + content: [{ type: "tool-call", toolCallId: "call_1|fc_1", toolName: "base_tool", input: {} }], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_1|fc_1", + toolName: "base_tool", + output: { type: "text", value: "done" }, + }, + ], + }, + toolChangeMessage(mode, [lateTool]), + { role: "user", content: [{ type: "text", text: "Continue" }] }, + ]; +} + +export const openAICodexLoadableTools: LanguageModelV3FunctionTool[] = [ { type: "function", name: "base_tool", @@ -147,6 +177,16 @@ export const openAICodexDeferredTools: LanguageModelV3FunctionTool[] = [ }, ]; +/** A `changes` transcript that removes `base_tool` and then adds `late_tool`. */ +export const openAICodexToolRemovalPrompt: LanguageModelV3Prompt = [ + toolChangeMessage("changes", [openAICodexLoadableTools[0]!]), + { role: "user", content: [{ type: "text", text: "Use the tools" }] }, + toolChangeMessage("changes", [], ["base_tool"]), + { role: "user", content: [{ type: "text", text: "Next" }] }, + toolChangeMessage("changes", [openAICodexLoadableTools[1]!]), + { role: "user", content: [{ type: "text", text: "Continue" }] }, +]; + export const openAICodexTextEvents: Array> = [ { type: "response.created", response: { id: "resp_1", model: "gpt-5.4" } }, { diff --git a/packages/codework/test/__artifacts__/server.model-list-copilot.json b/packages/codework/test/__artifacts__/server.model-list-copilot.json new file mode 100644 index 00000000..55516c59 --- /dev/null +++ b/packages/codework/test/__artifacts__/server.model-list-copilot.json @@ -0,0 +1,7 @@ +{ + "before": [], + "after": [ + "claude-sonnet-5.5", + "gpt-6-luna" + ] +} diff --git a/packages/codework/test/server.test.ts b/packages/codework/test/server.test.ts index 2caf9fad..3fb2a721 100644 --- a/packages/codework/test/server.test.ts +++ b/packages/codework/test/server.test.ts @@ -680,6 +680,47 @@ describe("WebSocket client", () => { expect(yield* rpc["auth.status"]({ provider: "openai-codex" })).toMatchObject({ accountId: "acct_server" }); }).pipe(Effect.scoped, Effect.provide(websocket()), Effect.timeout("10 seconds"), Effect.runPromise)); + it("model.list keeps only the Copilot models the login can reach", () => { + // The env token would make Copilot an API-key provider, which is never filtered. + vi.stubEnv("COPILOT_GITHUB_TOKEN", ""); + return Effect.gen(function* () { + const rpc = yield* connectedClient; + const copilotModels = () => + rpc["model.list"]({}).pipe( + Effect.map(({ providers }) => + (providers.find(({ id }) => id === "github-copilot")?.models ?? []).map(({ id }) => id).sort(), + ), + ); + const before = yield* copilotModels(); + yield* rpc["auth.save"]({ + credentials: { + provider: "github-copilot", + access: "ghu_token", + refresh: "ghu_token", + expires: 0, + apiEndpoint: "https://api.individual.githubcopilot.com", + availableModelIds: ["gpt-6-luna", "claude-sonnet-5.5", "not-in-the-catalog"], + }, + }); + const after = yield* copilotModels(); + expect(before).toEqual([]); + expect(after).toEqual(["claude-sonnet-5.5", "gpt-6-luna"]); + return { before, after }; + }) + .pipe( + Effect.scoped, + Effect.provide(websocket()), + Effect.timeout("10 seconds"), + Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())), + Effect.runPromise, + ) + .then((artifact) => + expect(`${JSON.stringify(artifact, null, "\t")}\n`).toMatchFileSnapshot( + "./__artifacts__/server.model-list-copilot.json", + ), + ); + }); + it("refreshes server OpenAI Codex credentials", () => { const nativeFetch = globalThis.fetch.bind(globalThis); const encode = (value: Record) => Buffer.from(JSON.stringify(value)).toString("base64url"); diff --git a/packages/harness/src/context/assemble.ts b/packages/harness/src/context/assemble.ts index e0ca6bc0..b8cefb5a 100644 --- a/packages/harness/src/context/assemble.ts +++ b/packages/harness/src/context/assemble.ts @@ -3,13 +3,13 @@ import { DateTime, Effect, Schema } from "effect"; import { optional } from "../schema.ts"; import { SessionSchema } from "../session/schema.ts"; import type { Session } from "../session/session.ts"; -import { decodeMessage, decodeSyntheticMessage } from "./codec.ts"; +import { decodeMessage, decodeSyntheticMessage, type ContextMessage } from "./codec.ts"; import { ContextDecodeError } from "./errors.ts"; export interface Snapshot { readonly sessionId: string; readonly leafEntryId: string | null; - readonly messages: ReadonlyArray; + readonly messages: ReadonlyArray; readonly lastAssistant?: { readonly entryId: string; readonly message: Message.AssistantMessage; @@ -84,7 +84,7 @@ export const assemblePath = Effect.fn("Context.assemblePath")(function* ( let lastAssistant: Snapshot["lastAssistant"]; let latestCompaction: { readonly index: number; readonly data: SessionSchema.CompactionData } | undefined; - const messagesByEntry = new Map(); + const messagesByEntry = new Map(); const compactions = new Map(); const branchSummaries = new Map(); @@ -165,7 +165,7 @@ export const assemblePath = Effect.fn("Context.assemblePath")(function* ( effectivePath = [committedPath[index]!, ...kept, ...committedPath.slice(index + 1)]; } - const messages: Message.Message[] = []; + const messages: ContextMessage[] = []; for (const hydrated of effectivePath) { const { entry } = hydrated; const message = messagesByEntry.get(entry.id); diff --git a/packages/harness/src/context/codec.ts b/packages/harness/src/context/codec.ts index 903d075c..34df7ac1 100644 --- a/packages/harness/src/context/codec.ts +++ b/packages/harness/src/context/codec.ts @@ -17,6 +17,9 @@ export interface EncodedMessage { type AikitPart = Message.AssistantMessage["parts"][number] | Message.UserMessage["parts"][number]; +/** A message the session stores as an entry. Transcript system messages are not stored yet. */ +export type ContextMessage = Message.UserMessage | Message.AssistantMessage; + const reasonOf = (cause: unknown): string => (cause instanceof Error ? cause.message : String(cause)); const decodeJsonObject = Schema.decodeUnknownEffect(SessionSchema.JsonObject); const encodeJsonObject = Schema.encodeEffect(SessionSchema.JsonObject); @@ -57,11 +60,16 @@ const validateDecoded = ( value: unknown, entryId: string, type: string, -): Effect.Effect => +): Effect.Effect => Effect.try({ try: () => validateAikitMessage(value, `context entry ${entryId}`), catch: (cause) => new ContextDecodeError({ entryId, type, reason: reasonOf(cause) }), - }); + }).pipe( + Effect.filterOrFail( + (message): message is ContextMessage => message.role !== "system", + () => new ContextDecodeError({ entryId, type, reason: "system messages are not stored as entries" }), + ), + ); export const validateUserMessage = ( value: unknown, @@ -74,10 +82,10 @@ export const validateUserMessage = ( }); export const encodeMessage = Effect.fn("Context.encodeMessage")(function* ( - message: Message.Message, + message: ContextMessage, ): Effect.fn.Return { const validated = yield* Effect.try({ - try: () => validateAikitMessage(message, `context message ${message.messageId}`), + try: () => validateAikitMessage(message, `context message ${message.messageId}`) as ContextMessage, catch: (cause) => new ContextEncodeError({ messageId: message.messageId, @@ -193,7 +201,7 @@ export const decodeSyntheticMessage = Effect.fn("Context.decodeSyntheticMessage" export const decodeMessage = Effect.fn("Context.decodeMessage")(function* ( hydrated: Session.HydratedEntry, -): Effect.fn.Return { +): Effect.fn.Return { const { entry } = hydrated; if (entry.type !== "user" && entry.type !== "assistant") { return yield* new ContextDecodeError({ diff --git a/packages/harness/src/model/catalog.ts b/packages/harness/src/model/catalog.ts index d3a372a7..ed8145c1 100644 --- a/packages/harness/src/model/catalog.ts +++ b/packages/harness/src/model/catalog.ts @@ -10,7 +10,7 @@ */ import { getEnvApiKey, Model } from "@codeworksh/aikit"; import { downloadCatalog } from "@codeworksh/aikit/modelgen"; -import { JsonGitHubCopilotAuthStorage } from "@codeworksh/aikit/oauth/github/copilot"; +import { filterGitHubCopilotModels, JsonGitHubCopilotAuthStorage } from "@codeworksh/aikit/oauth/github/copilot"; import { JsonOpenAICodexAuthStorage } from "@codeworksh/aikit/oauth/openai/codex"; import { Clock, Config, Context, Duration, Effect, Layer, Option, Ref, Schedule, Schema } from "effect"; import { fileSystem, hostPath } from "../host.ts"; @@ -178,8 +178,8 @@ export class Credentials extends Context.Service (authFile === undefined ? {} : { path: authFile }); -/** Whether an OAuth login is stored for the protocol. Reads the file; never refreshes a token. */ -const loggedIn = (protocol: string, authFile: string | undefined) => { +/** The OAuth login stored for the protocol, if any. Reads the file; never refreshes a token. */ +const storedLogin = (protocol: string, authFile: string | undefined) => { const storage = oauthStorage(authFile); const store = protocol === "openai-codex" @@ -187,11 +187,8 @@ const loggedIn = (protocol: string, authFile: string | undefined) => { : protocol === "github-copilot" ? new JsonGitHubCopilotAuthStorage(storage) : undefined; - if (store === undefined) return Effect.succeed(false); - return Effect.tryPromise(() => store.get()).pipe( - Effect.map((stored) => stored !== undefined), - Effect.orElseSucceed(() => false), - ); + if (store === undefined) return Effect.void; + return Effect.tryPromise(() => store.get()).pipe(Effect.orElseSucceed(() => undefined)); }; /** @@ -243,7 +240,8 @@ const oauthProtocols: ReadonlySet = new Set(["openai-codex", "github-cop * only the user's. * * `apiKey`: the provider's `key` or `env` names are set in the host environment (they include the - * OAuth providers' env tokens). `oauth`: a login is stored in the {@link Credentials} file. `none`: + * OAuth providers' env tokens). `oauth`: a login is stored in the {@link Credentials} file; a + * Copilot login keeps only the models its account can reach. `none`: * the provider names no key and isn't an OAuth provider. A provider with a key and a login reports * `apiKey`, the one a model call uses first. * @@ -262,16 +260,22 @@ export const available = Effect.fn("ModelCatalog.available")(function* ( if (entries === undefined || first === undefined) continue; const keyless = first.provider.key === undefined && first.provider.env.length === 0 && !oauthProtocols.has(first.protocol); + const login = + getEnvApiKey(first.provider) === undefined ? yield* storedLogin(first.protocol, authFile) : undefined; const credential: Credential | undefined = getEnvApiKey(first.provider) !== undefined ? "apiKey" - : (yield* loggedIn(first.protocol, authFile)) + : login !== undefined ? "oauth" : keyless ? "none" : undefined; if (credential === undefined || (filter.credential !== undefined && filter.credential !== credential)) continue; - usable.push({ id, credential, models: entries }); + const models = + credential === "oauth" && first.protocol === "github-copilot" && login !== undefined && "access" in login + ? filterGitHubCopilotModels(entries, login) + : entries; + usable.push({ id, credential, models }); } return usable; }); diff --git a/packages/harness/test/context.test.ts b/packages/harness/test/context.test.ts index af053705..939813cf 100644 --- a/packages/harness/test/context.test.ts +++ b/packages/harness/test/context.test.ts @@ -66,7 +66,7 @@ const setup = Effect.gen(function* () { title: "Context test", }); let seq = 0; - const appendMessage = Effect.fnUntraced(function* (message: Message.Message) { + const appendMessage = Effect.fnUntraced(function* (message: ContextCodec.ContextMessage) { const encoded = yield* ContextCodec.encodeMessage(message); seq += 1; yield* sessions.append({ diff --git a/packages/harness/test/fixtures/runner.cycle.spec.ts b/packages/harness/test/fixtures/runner.cycle.spec.ts index 6cf7b694..762afd5a 100644 --- a/packages/harness/test/fixtures/runner.cycle.spec.ts +++ b/packages/harness/test/fixtures/runner.cycle.spec.ts @@ -79,13 +79,13 @@ const inputStateFor = (sql: SqlClient.SqlClient) => `, }); -const textOf = (message: Message.Message): string => +const textOf = (message: Message.UserMessage | Message.AssistantMessage): string => message.parts .flatMap((part) => (part.type === "text" ? [part.text] : [])) .join("\n") .trim(); -const thinkingOf = (message: Message.Message): string => +const thinkingOf = (message: Message.UserMessage | Message.AssistantMessage): string => message.parts .flatMap((part) => (part.type === "thinking" ? [part.thinking] : [])) .join("\n") diff --git a/packages/harness/test/plugin.external.test.ts b/packages/harness/test/plugin.external.test.ts index 8e002f2d..475ce2f9 100644 --- a/packages/harness/test/plugin.external.test.ts +++ b/packages/harness/test/plugin.external.test.ts @@ -138,7 +138,8 @@ describe("third-party plugins", () => { }, }); // The continuation request carries the terminal part, not a pending one. - const continued = contexts[1]?.messages.at(-1)?.parts.find((part) => part.type === "toolCall"); + const last = contexts[1]?.messages.at(-1); + const continued = last?.role === "assistant" ? last.parts.find((part) => part.type === "toolCall") : undefined; expect(continued).toMatchObject({ callID: "call_echo", status: "completed" }); })); diff --git a/packages/harness/test/runner.loop.test.ts b/packages/harness/test/runner.loop.test.ts index 0c3a9170..02dd3145 100644 --- a/packages/harness/test/runner.loop.test.ts +++ b/packages/harness/test/runner.loop.test.ts @@ -333,7 +333,9 @@ describe("runner loop — tool continuation and lifecycle gate", () => { expect(contexts).toHaveLength(2); expect(snapshots).toBe(2); - const firstTool = contexts[1]!.messages[1]!.parts.find((part) => part.type === "toolCall"); + const second = contexts[1]!.messages[1]!; + const firstTool = + second.role === "assistant" ? second.parts.find((part) => part.type === "toolCall") : undefined; expect(firstTool).toMatchObject({ callID: "call_echo", status: "completed" }); const path = yield* sessions.path(sessionId); diff --git a/packages/harness/test/tools.read.vision.test.ts b/packages/harness/test/tools.read.vision.test.ts index 97d50e4a..7f2d26bd 100644 --- a/packages/harness/test/tools.read.vision.test.ts +++ b/packages/harness/test/tools.read.vision.test.ts @@ -47,6 +47,7 @@ const view = (input: LLM.Input) => Message.transformMessages(input.context.messages, input.resolvedModel).map((message) => { const content = (parts: ReadonlyArray) => parts.map((part) => (part.type === "text" ? part.text : { image: part.mimeType })); + if (message.role === "system") return { role: "system" }; if (message.role === "user") return { role: "user", content: content(message.parts) }; return { role: "assistant", From a55755d3ebb891fb9650bbea0cd6a3c72c124452 Mon Sep 17 00:00:00 2001 From: Sanchit Rk <0xsanchit@gmail.com> Date: Tue, 27 Oct 2026 18:37:21 +0530 Subject: [PATCH 2/2] docs: add unreleased notes for transcript tool changes and Copilot fixes --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab18baa9..7f3b2fe9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,10 +25,12 @@ This file is the canonical source for unreleased changes and published release n - A turn started for a prompt that was cancelled just before it joined the conversation no longer asks the model anything. - A prompt changed, cancelled or sent again while it was being taken in no longer fails the run; the run takes in the prompt as it now stands. +- GitHub Copilot lists only the models its login can reach, by the `availableModelIds` recorded at login. A settings model that aliases another is matched by its API id. #### Internal - Migrations add `session_input.cancelled_seq`, and `session.execution` and `session.restarts`. +- Stored context messages are user and assistant messages only; a stored system message fails to decode with "system messages are not stored as entries". - Tests use `gpt-6-luna` in place of the deprecated `gpt-5.5`. ### @codeworksh/cli @@ -43,6 +45,7 @@ This file is the canonical source for unreleased changes and published release n #### Fixed - Ctrl+C on `codework run` without `--server` stops the run as a user stop, so `codework serve` does not resume it later. +- `model.list` lists only the GitHub Copilot models the login can reach. #### Internal @@ -50,8 +53,31 @@ This file is the canonical source for unreleased changes and published release n ### @codeworksh/aikit +#### Added + +- Transcript system messages change the tools mid-conversation: `Message.createSystemMessage({ toolsAdded?, toolsRemoved?, metadata? })`. `Context.tools` is the initial set. OpenAI Codex and GitHub Copilot Responses models that support it declare every tool in place, in `additional_tools` items, and announce removals as text, so the cached prompt prefix holds across tool changes; models with tool search only get later additions as a tool search result; every other model gets the current tools top-level. +- `Transcript` helpers: `getCurrentTools`, `getDeclaredTools`, `getToolStateChanges`, `declarationsEqual`, `toToolDeclaration` and `resolveTranscriptTools`. +- `filterGitHubCopilotModels` keeps the catalog models a Copilot login can reach. +- GitHub Copilot GPT models turn reasoning off with `reasoning.effort: "none"` where OpenAI accepts it, such as gpt-5.3-codex, gpt-5.6-luna and gpt-6-luna. + +#### Changed + +- GitHub Copilot Responses requests go through aikit's own Responses model, shared with OpenAI Codex. They carry `prompt_cache_key` from the session id, put the system prompt in a developer message, and send `max_output_tokens` of at least 16. +- Copilot's 1M-token context list now holds claude-fable-5, claude-opus-4.8, claude-opus-5, claude-opus-5.5, claude-sonnet-4.6, claude-sonnet-5, gpt-5.3-codex, gpt-6-astra, gpt-6-luna and gpt-6-sol. + +#### Removed + +- `ToolCall.addedToolNames`. A system message's `toolsAdded` takes its place, and token estimates count it. + #### Fixed +- GitHub Copilot Claude models that cannot disable thinking (Opus 5.5, Sonnet 5.5, Fable 5) no longer fail with thinking off; the request leaves thinking out. +- A GitHub Copilot Responses tool call no longer splits into an empty call and a call with the arguments; Copilot changes the item id on every stream event. +- GitHub Copilot Grok models no longer fail on a request without tools: `tool_choice` and `parallel_tool_calls` are sent only when the call names a tool choice. OpenAI Codex still sends both on every request. +- A provider error the adapter cannot parse keeps its message and code, read from the response body. Copilot's Claude route reported "bad Request" for a model the account cannot use. +- A grammar tool's earlier calls replay as grammar calls after the tool is removed. +- A request limited by `activeTools` sends no tools outside that list. + - `modelgen` leaves out providers with no models instead of writing them as empty entries. - Images in a failed tool result reach the model instead of being dropped. On the Anthropic wire the result is sent as text with `(image attached after the tool results)`, and the images follow the turn's tool results under `Images from the error result of tool call ():`. Other wires keep the images in the result; there the AI SDK sends it without an error flag. - On Chat Completions wires (OpenAI-compatible, Copilot chat, OpenAI with `method: chat`) and Gemini before 3, tool result images are sent in a user message after the tool results. Chat Completions sent them as JSON text, and Gemini before 3 lost the function response of a result with only images.