Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,15 +45,39 @@ 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

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

### @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 <id> (<name>):`. 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.
Expand Down
55 changes: 35 additions & 20 deletions packages/aikit/src/cli/modelgen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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"]);

Expand Down Expand Up @@ -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<string, ThinkingLevelMap> = {
"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" },
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -675,13 +682,21 @@ 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" });

const levels = githubCopilotThinkingLevels(source, method);
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;
}
Expand Down
1 change: 1 addition & 0 deletions packages/aikit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
15 changes: 13 additions & 2 deletions packages/aikit/src/llm/failure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,20 @@ const retryAfter = (headers: Record<string, string> | 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(
Expand Down
7 changes: 4 additions & 3 deletions packages/aikit/src/llm/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -68,7 +69,7 @@ type OpenAICompatibleLanguageOptions =
type XaiLanguageOptions = XaiLanguageModelResponsesOptions;
type GitHubCopilotLanguageOptions =
| AnthropicLanguageModelOptions
| OpenAILanguageModelResponsesOptions
| ResponsesLanguageModelOptions
| OpenAICompatibleLanguageModelChatOptions;

export type OpenAIOptions = AISDKOptions<OpenAIProviderSettings, { openai?: OpenAILanguageOptions }>;
Expand All @@ -82,7 +83,7 @@ export type OpenAICompatibleOptions = AISDKOptions<
>;
export type OpenAICodexOptions = AISDKOptions<
OpenAICodexProviderSettings,
{ "openai-codex"?: OpenAICodexLanguageModelOptions }
{ "openai-codex"?: ResponsesLanguageModelOptions }
>;
export type GitHubCopilotOptions = AISDKOptions<
GitHubCopilotProviderSettings,
Expand Down
24 changes: 15 additions & 9 deletions packages/aikit/src/llm/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
38 changes: 27 additions & 11 deletions packages/aikit/src/llm/stream.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -15,9 +16,11 @@ import * as Thinking from "./thinking.ts";
import {
googleThoughtSignature,
convertMessages,
isOwnResponses,
convertTools,
createAssistantMessage,
encodeOpenAIReasoningSignature,
grammarToolInputProperties,
mapFinishReason,
mapUsage,
toolCallFromPart,
Expand Down Expand Up @@ -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,
);
}
Expand All @@ -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<string, unknown> | undefined {
/** Metadata our Responses transport attaches under the model's provider key. */
function responsesMetadata(value: unknown, model: Model.Info): Record<string, unknown> | undefined {
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
const metadata = (value as Record<string, unknown>)["openai-codex"];
const metadata = (value as Record<string, unknown>)[Model.optionsKey(model)];
return typeof metadata === "object" && metadata !== null && !Array.isArray(metadata)
? (metadata as Record<string, unknown>)
: undefined;
Expand Down Expand Up @@ -206,6 +217,7 @@ function finishToolInput(output: Message.AssistantMessage, id: string, stream: A
function finalizeToolCall(
output: Message.AssistantMessage,
part: Extract<TextStreamPart<ToolSet>, { type: "tool-call" }>,
model: Model.Info,
stream: AssistantMessageEventStream,
): void {
const existing = output.parts.find((item) => item.type === "toolCall" && item.callID === part.toolCallId) as
Expand All @@ -216,7 +228,7 @@ function finalizeToolCall(
block.name = part.toolName;
block.arguments =
typeof part.input === "object" && part.input !== null ? (part.input as Record<string, unknown>) : {};
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;
Expand Down Expand Up @@ -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;
}
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -402,9 +414,10 @@ export const stream: Protocol.StreamFunction<Model.KnownProviderEnum, typeof Opt
try {
const plan = Thinking.resolvePlan(model, context, runtimeOptions);
const languageModel = await resolveAISDKLanguageModel(model, runtimeOptions, plan.budget);
const tools = convertTools(context.tools, model);
const messages = convertMessages(context, model, runtimeOptions.method);
const providerOptions = resolveProviderOptions(model, runtimeOptions, plan) as Parameters<
// Every tool callable now, so calls to tools declared in place still parse.
const tools = convertTools(Transcript.getCurrentTools(context), model, runtimeOptions.method);
const messages = convertMessages(context, model, runtimeOptions.method, runtimeOptions.activeTools);
const providerOptions = resolveProviderOptions(model, context, runtimeOptions, plan) as Parameters<
typeof streamText<ToolSet>
>[0]["providerOptions"];
const pricing: Pricing = {
Expand All @@ -418,8 +431,11 @@ export const stream: Protocol.StreamFunction<Model.KnownProviderEnum, typeof Opt

let params: Parameters<typeof streamText<ToolSet>>[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<typeof streamText<ToolSet>>[0]["toolChoice"],
activeTools,
Expand Down
Loading
Loading