Skip to content

Commit 251f096

Browse files
authored
feat(aikit): declare tool changes as transcript system messages (#110)
1 parent f4c1464 commit 251f096

55 files changed

Lines changed: 2109 additions & 728 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@ This file is the canonical source for unreleased changes and published release n
2525

2626
- A turn started for a prompt that was cancelled just before it joined the conversation no longer asks the model anything.
2727
- 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.
28+
- 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.
2829

2930
#### Internal
3031

3132
- Migrations add `session_input.cancelled_seq`, and `session.execution` and `session.restarts`.
33+
- Stored context messages are user and assistant messages only; a stored system message fails to decode with "system messages are not stored as entries".
3234
- Tests use `gpt-6-luna` in place of the deprecated `gpt-5.5`.
3335

3436
### @codeworksh/cli
@@ -43,15 +45,39 @@ This file is the canonical source for unreleased changes and published release n
4345
#### Fixed
4446

4547
- Ctrl+C on `codework run` without `--server` stops the run as a user stop, so `codework serve` does not resume it later.
48+
- `model.list` lists only the GitHub Copilot models the login can reach.
4649

4750
#### Internal
4851

4952
- Tests use `gpt-6-luna` in place of the deprecated `gpt-5.5`.
5053

5154
### @codeworksh/aikit
5255

56+
#### Added
57+
58+
- 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.
59+
- `Transcript` helpers: `getCurrentTools`, `getDeclaredTools`, `getToolStateChanges`, `declarationsEqual`, `toToolDeclaration` and `resolveTranscriptTools`.
60+
- `filterGitHubCopilotModels` keeps the catalog models a Copilot login can reach.
61+
- 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.
62+
63+
#### Changed
64+
65+
- 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.
66+
- 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.
67+
68+
#### Removed
69+
70+
- `ToolCall.addedToolNames`. A system message's `toolsAdded` takes its place, and token estimates count it.
71+
5372
#### Fixed
5473

74+
- 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.
75+
- 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.
76+
- 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.
77+
- 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.
78+
- A grammar tool's earlier calls replay as grammar calls after the tool is removed.
79+
- A request limited by `activeTools` sends no tools outside that list.
80+
5581
- `modelgen` leaves out providers with no models instead of writing them as empty entries.
5682
- 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 <id> (<name>):`. Other wires keep the images in the result; there the AI SDK sends it without an error flag.
5783
- 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.

‎packages/aikit/src/cli/modelgen.ts‎

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,13 @@ const OPENAI_LONG_CONTEXT_PRICING_MODELS = new Set([
288288
"gpt-5.5-pro",
289289
]);
290290

291+
function openAiAcceptsNoReasoning(id: string): boolean {
292+
return (
293+
OPENAI_RESPONSES_NONE_REASONING_MODELS.has(id) ||
294+
(OPENAI_GPT6_MODELS.has(id) && !OPENAI_GPT6_NO_NONE_MODELS.has(id))
295+
);
296+
}
297+
291298
function isOpenAiResponses(model: Model.Info): boolean {
292299
return model.protocol === Model.KnownProviderEnum.openai || model.protocol === Model.KnownProviderEnum.openaiCodex;
293300
}
@@ -494,6 +501,20 @@ const OPENAI_CODEX_ADDITIONAL_TOOLS_MODEL_IDS = new Set([
494501
"gpt-6-sol",
495502
"gpt-6.1-sol",
496503
]);
504+
// Copilot passes `additional_tools` items through to OpenAI for these Responses models.
505+
const GITHUB_COPILOT_ADDITIONAL_TOOLS_MODEL_IDS = new Set([
506+
"gpt-5.4",
507+
"gpt-5.4-mini",
508+
"gpt-5.4-pro",
509+
"gpt-5.5",
510+
"gpt-5.6-sol",
511+
"gpt-5.6-terra",
512+
"gpt-5.6-luna",
513+
"gpt-6-astra",
514+
"gpt-6-sol",
515+
"gpt-6-luna",
516+
"gpt-6.1-sol",
517+
]);
497518
// Checked live: the Codex backend accepts reasoning.effort "none" for these.
498519
const OPENAI_CODEX_NONE_REASONING_MODEL_IDS = new Set(["gpt-5.6-luna", "gpt-6-luna", "gpt-6-sol"]);
499520

@@ -557,21 +578,20 @@ const GITHUB_COPILOT_EXTENDED_CONTEXT = 1_000_000;
557578
// models as supporting the extended 1 million token context window (pi).
558579
const GITHUB_COPILOT_EXTENDED_CONTEXT_MODELS = new Set([
559580
"claude-fable-5",
560-
"claude-opus-4.6",
561-
"claude-opus-4.7",
562581
"claude-opus-4.8",
563582
"claude-opus-5",
583+
"claude-opus-5.5",
564584
"claude-sonnet-4.6",
565585
"claude-sonnet-5",
566586
"gpt-5.3-codex",
567-
"gpt-5.4",
568-
"gpt-5.5",
587+
"gpt-6-astra",
588+
"gpt-6-luna",
589+
"gpt-6-sol",
569590
]);
570591

571592
// Checked manually against the authenticated GitHub Copilot /models endpoint
572593
// (pi). Narrow corrections over models.dev metadata, not a catalog snapshot.
573594
const GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES: Record<string, ThinkingLevelMap> = {
574-
"claude-opus-4.7": { minimal: "low" },
575595
"claude-opus-4.8": { minimal: "low" },
576596
"claude-opus-5": { minimal: "low" },
577597
"claude-sonnet-4.6": { minimal: "low", max: "max" },
@@ -610,8 +630,8 @@ function githubCopilotThinkingLevels(model: ModelsDevModel, method: Model.APIMet
610630
const listed = modelsDevEfforts(model);
611631
const efforts = listed ? new Set(listed) : undefined;
612632
const map: ThinkingLevelMap = {};
613-
// Copilot rejects `reasoningEffort: "none"` (pi), and the chat route has no
614-
// reasoning control, so only Messages models can disable thinking.
633+
// Off is opt-in per model on Responses (Grok and MAI reject "none"), and the chat route
634+
// has no reasoning control.
615635
if (method !== Model.APIMethodEnum.messages) map.off = null;
616636
if (!efforts) return Object.keys(map).length > 0 ? map : undefined;
617637

@@ -650,19 +670,6 @@ export function githubCopilotBuiltInModels(provider: ModelsDevProvider | undefin
650670
api: { id, url: GITHUB_COPILOT_BASE_URL, method },
651671
providerOptionsKey: GITHUB_COPILOT_PROVIDER_ID,
652672
protocol: Model.KnownProviderEnum.githubCopilot,
653-
// Copilot's Responses route requires store=false + encrypted reasoning
654-
// content for thinking to round-trip.
655-
...(method === Model.APIMethodEnum.responses
656-
? {
657-
providerOptions: {
658-
...base.providerOptions,
659-
[GITHUB_COPILOT_PROVIDER_ID]: {
660-
store: false,
661-
include: ["reasoning.encrypted_content"],
662-
},
663-
},
664-
}
665-
: {}),
666673
};
667674

668675
if (GITHUB_COPILOT_EXTENDED_CONTEXT_MODELS.has(id)) info.contextWindow = GITHUB_COPILOT_EXTENDED_CONTEXT;
@@ -675,13 +682,21 @@ export function githubCopilotBuiltInModels(provider: ModelsDevProvider | undefin
675682
if (method === Model.APIMethodEnum.responses && gpt !== null && Number(gpt[1]) >= 5) {
676683
mergeCompat(info, { supportsOpenAIGrammarTools: true });
677684
}
685+
if (method === Model.APIMethodEnum.responses && GITHUB_COPILOT_ADDITIONAL_TOOLS_MODEL_IDS.has(id)) {
686+
mergeCompat(info, { supportsAdditionalTools: true });
687+
}
678688
if (id.startsWith("gpt-5")) mergeThinkingLevelMap(info, { off: null, minimal: "low" });
679689
if (id.includes("fable-5")) mergeThinkingLevelMap(info, { off: null, xhigh: "xhigh", max: "max" });
680690

681691
const levels = githubCopilotThinkingLevels(source, method);
682692
if (levels) mergeThinkingLevelMap(info, levels);
683693
const override = GITHUB_COPILOT_THINKING_LEVEL_OVERRIDES[id];
684694
if (override) mergeThinkingLevelMap(info, override);
695+
// Copilot forwards reasoning.effort to OpenAI unchanged, so the GPT models OpenAI lets
696+
// turn reasoning off take "none" here too (checked live).
697+
if (method === Model.APIMethodEnum.responses && openAiAcceptsNoReasoning(id)) {
698+
mergeThinkingLevelMap(info, { off: "none" });
699+
}
685700

686701
models[id] = info;
687702
}

‎packages/aikit/src/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export * as Protocol from "./llm/protocol.ts";
55
export { getEnvApiKey } from "./llm/runtime.ts";
66
export { ThinkingBudgets } from "./llm/shared.ts";
77
export * as Message from "./message/message.ts";
8+
export * as Transcript from "./message/transcript.ts";
89
export * as Model from "./model/model.ts";
910
export { stream } from "./stream.ts";
1011
export { createAssistantMessageEventStream, EventStream } from "./utils/eventstream.ts";

‎packages/aikit/src/llm/failure.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,20 @@ const retryAfter = (headers: Record<string, string> | undefined): number | undef
7878
if (Number.isFinite(date)) return Math.max(0, date - Date.now());
7979
};
8080

81+
const parseJson = (text: string | undefined): unknown => {
82+
if (text === undefined) return undefined;
83+
try {
84+
return JSON.parse(text);
85+
} catch {
86+
return undefined;
87+
}
88+
};
89+
8190
const apiMetadata = (error: APICallError): Metadata => {
82-
const code = errorCode(error.data);
83-
const data = record(error.data);
91+
// An adapter fronting another vendor's error shape (Copilot's OpenAI-style errors on the
92+
// Anthropic route) leaves `data` unset; the raw body still carries the message and code.
93+
const data = record(error.data) ?? record(parseJson(error.responseBody));
94+
const code = errorCode(data);
8495
const nested = record(data?.error);
8596
const structuredMessage = nonEmptyString(data?.message) ?? nonEmptyString(nested?.message);
8697
const message = normalizeMessage(

‎packages/aikit/src/llm/options.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ import type { OpenRouterProviderOptions, OpenRouterProviderSettings } from "@ope
1818
import { Type, type Static } from "typebox";
1919
import * as Model from "../model/model.ts";
2020
import type { GitHubCopilotProviderSettings } from "../providers/github-copilot/index.ts";
21-
import type { OpenAICodexLanguageModelOptions, OpenAICodexProviderSettings } from "../providers/openai-codex/index.ts";
21+
import type { OpenAICodexProviderSettings } from "../providers/openai-codex/index.ts";
22+
import type { ResponsesLanguageModelOptions } from "../providers/responses/model.ts";
2223
import type * as Protocol from "./protocol.ts";
2324
import { SharedOptions, ThinkingBudgets } from "./shared.ts";
2425

@@ -68,7 +69,7 @@ type OpenAICompatibleLanguageOptions =
6869
type XaiLanguageOptions = XaiLanguageModelResponsesOptions;
6970
type GitHubCopilotLanguageOptions =
7071
| AnthropicLanguageModelOptions
71-
| OpenAILanguageModelResponsesOptions
72+
| ResponsesLanguageModelOptions
7273
| OpenAICompatibleLanguageModelChatOptions;
7374

7475
export type OpenAIOptions = AISDKOptions<OpenAIProviderSettings, { openai?: OpenAILanguageOptions }>;
@@ -82,7 +83,7 @@ export type OpenAICompatibleOptions = AISDKOptions<
8283
>;
8384
export type OpenAICodexOptions = AISDKOptions<
8485
OpenAICodexProviderSettings,
85-
{ "openai-codex"?: OpenAICodexLanguageModelOptions }
86+
{ "openai-codex"?: ResponsesLanguageModelOptions }
8687
>;
8788
export type GitHubCopilotOptions = AISDKOptions<
8889
GitHubCopilotProviderSettings,

‎packages/aikit/src/llm/provider.ts‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,22 +79,28 @@ export async function resolveAISDKLanguageModel(
7979
}
8080
}
8181

82+
const cacheDisabled =
83+
options.cacheRetention === "none" ||
84+
(typeof process !== "undefined" && process.env.CODEWORK_CACHE_RETENTION === "none");
85+
// Our own Responses model (Codex, Copilot `/responses`) reads image support from compat.
86+
const responsesCompat = () => ({
87+
...model.compat,
88+
supportsImages: model.input.includes("image"),
89+
...(typeof factoryOptions.compat === "object" && factoryOptions.compat !== null ? factoryOptions.compat : {}),
90+
});
91+
8292
if (npm === "@codeworksh/ai-sdk-openai-codex") {
8393
// Codex uses the session id for the session-id header and prompt cache key.
84-
const cacheDisabled =
85-
options.cacheRetention === "none" ||
86-
(typeof process !== "undefined" && process.env.CODEWORK_CACHE_RETENTION === "none");
8794
if (options.sessionId && !cacheDisabled) factoryOptions.sessionId ??= options.sessionId;
88-
factoryOptions.compat = {
89-
...model.compat,
90-
supportsImages: model.input.includes("image"),
91-
...(typeof factoryOptions.compat === "object" && factoryOptions.compat !== null ? factoryOptions.compat : {}),
92-
};
95+
factoryOptions.compat = responsesCompat();
9396
}
9497

9598
if (npm === "@codeworksh/ai-sdk-github-copilot") {
96-
// Copilot uses the session id for X-Interaction-Id request grouping.
99+
// Copilot uses the session id for X-Interaction-Id request grouping, and as the
100+
// Responses prompt cache key.
97101
if (options.sessionId) factoryOptions.sessionId ??= options.sessionId;
102+
if (options.sessionId && !cacheDisabled) factoryOptions.promptCacheKey ??= options.sessionId;
103+
factoryOptions.compat = responsesCompat();
98104

99105
const budgetField = Model.thinkingTokenBudgetField(model);
100106
if (budgetField && thinkingTokenBudget !== undefined && thinkingTokenBudget > 0) {

‎packages/aikit/src/llm/stream.ts‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { APICallError } from "@ai-sdk/provider";
22
import { RetryError, streamText, type TextStreamPart, type ToolSet } from "ai";
33
import * as Message from "../message/message.ts";
4+
import * as Transcript from "../message/transcript.ts";
45
import * as Model from "../model/model.ts";
56
import { AssistantMessageEventStream } from "../utils/eventstream.ts";
67
import { compact } from "../utils/helpers.ts";
@@ -15,9 +16,11 @@ import * as Thinking from "./thinking.ts";
1516
import {
1617
googleThoughtSignature,
1718
convertMessages,
19+
isOwnResponses,
1820
convertTools,
1921
createAssistantMessage,
2022
encodeOpenAIReasoningSignature,
23+
grammarToolInputProperties,
2124
mapFinishReason,
2225
mapUsage,
2326
toolCallFromPart,
@@ -79,11 +82,18 @@ function cacheProviderOptions(model: Model.Info, options: RuntimeOptions): Provi
7982
return {};
8083
}
8184

82-
function resolveProviderOptions(model: Model.Info, options: RuntimeOptions, plan: Thinking.Plan): ProviderOptionBag {
85+
function resolveProviderOptions(
86+
model: Model.Info,
87+
context: Message.Context,
88+
options: RuntimeOptions,
89+
plan: Thinking.Plan,
90+
): ProviderOptionBag {
91+
const grammars = grammarToolInputProperties(context, model, options.method);
8392
return mergeProviderOptions(
8493
model.providerOptions as ProviderOptionBag | undefined,
8594
cacheProviderOptions(model, options),
8695
Thinking.reasoningProviderOptions(model, plan),
96+
grammars === undefined ? undefined : { [Model.optionsKey(model)]: { grammarToolInputProperties: grammars } },
8797
options.providerOptions as ProviderOptionBag | undefined,
8898
);
8999
}
@@ -92,9 +102,10 @@ function partIndex(output: Message.AssistantMessage, block: StreamBlock): number
92102
return output.parts.indexOf(block as Message.AssistantMessage["parts"][number]);
93103
}
94104

95-
function openAICodexMetadata(value: unknown): Record<string, unknown> | undefined {
105+
/** Metadata our Responses transport attaches under the model's provider key. */
106+
function responsesMetadata(value: unknown, model: Model.Info): Record<string, unknown> | undefined {
96107
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
97-
const metadata = (value as Record<string, unknown>)["openai-codex"];
108+
const metadata = (value as Record<string, unknown>)[Model.optionsKey(model)];
98109
return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata)
99110
? (metadata as Record<string, unknown>)
100111
: undefined;
@@ -206,6 +217,7 @@ function finishToolInput(output: Message.AssistantMessage, id: string, stream: A
206217
function finalizeToolCall(
207218
output: Message.AssistantMessage,
208219
part: Extract<TextStreamPart<ToolSet>, { type: "tool-call" }>,
220+
model: Model.Info,
209221
stream: AssistantMessageEventStream,
210222
): void {
211223
const existing = output.parts.find((item) => item.type === "toolCall" && item.callID === part.toolCallId) as
@@ -216,7 +228,7 @@ function finalizeToolCall(
216228
block.name = part.toolName;
217229
block.arguments =
218230
typeof part.input === "object" && part.input !== null ? (part.input as Record<string, unknown>) : {};
219-
const namespace = openAICodexMetadata(part.providerMetadata)?.namespace;
231+
const namespace = responsesMetadata(part.providerMetadata, model)?.namespace;
220232
const thoughtSignature = googleThoughtSignature(part.providerMetadata);
221233
if (thoughtSignature) block.thoughtSignature = thoughtSignature;
222234
if (typeof namespace === "string") block.namespace = namespace;
@@ -262,7 +274,7 @@ function handlePart(
262274
break;
263275
}
264276
case "text-end": {
265-
const messageId = openAICodexMetadata(part.providerMetadata)?.messageId;
277+
const messageId = responsesMetadata(part.providerMetadata, model)?.messageId;
266278
finishTextBlock(output, part.id, stream, typeof messageId === "string" ? messageId : undefined);
267279
break;
268280
}
@@ -288,7 +300,7 @@ function handlePart(
288300
break;
289301
}
290302
case "reasoning-end": {
291-
const reasoningItem = openAICodexMetadata(part.providerMetadata)?.reasoningItem;
303+
const reasoningItem = responsesMetadata(part.providerMetadata, model)?.reasoningItem;
292304
const openAISignature = encodeOpenAIReasoningSignature(part.providerMetadata);
293305
finishThinkingBlock(
294306
output,
@@ -317,7 +329,7 @@ function handlePart(
317329
finishToolInput(output, part.id, stream);
318330
break;
319331
case "tool-call":
320-
finalizeToolCall(output, part, stream);
332+
finalizeToolCall(output, part, model, stream);
321333
break;
322334
case "start-step":
323335
stampProviderThinkingLevel(output, part.request.body);
@@ -402,9 +414,10 @@ export const stream: Protocol.StreamFunction<Model.KnownProviderEnum, typeof Opt
402414
try {
403415
const plan = Thinking.resolvePlan(model, context, runtimeOptions);
404416
const languageModel = await resolveAISDKLanguageModel(model, runtimeOptions, plan.budget);
405-
const tools = convertTools(context.tools, model);
406-
const messages = convertMessages(context, model, runtimeOptions.method);
407-
const providerOptions = resolveProviderOptions(model, runtimeOptions, plan) as Parameters<
417+
// Every tool callable now, so calls to tools declared in place still parse.
418+
const tools = convertTools(Transcript.getCurrentTools(context), model, runtimeOptions.method);
419+
const messages = convertMessages(context, model, runtimeOptions.method, runtimeOptions.activeTools);
420+
const providerOptions = resolveProviderOptions(model, context, runtimeOptions, plan) as Parameters<
408421
typeof streamText<ToolSet>
409422
>[0]["providerOptions"];
410423
const pricing: Pricing = {
@@ -418,8 +431,11 @@ export const stream: Protocol.StreamFunction<Model.KnownProviderEnum, typeof Opt
418431

419432
let params: Parameters<typeof streamText<ToolSet>>[0] = compact({
420433
model: languageModel,
421-
system: context.systemPrompt,
434+
// Our own Responses transport receives the system prompt among the messages.
435+
system: isOwnResponses(model, runtimeOptions.method) ? undefined : context.systemPrompt,
422436
messages,
437+
// Tool changes declared in place travel as system messages.
438+
allowSystemInMessages: messages.some((message) => message.role === "system") || undefined,
423439
tools,
424440
toolChoice: runtimeOptions.toolChoice as Parameters<typeof streamText<ToolSet>>[0]["toolChoice"],
425441
activeTools,

0 commit comments

Comments
 (0)