chore(x): migrate to AI SDK 7

Bump apps/x from AI SDK v5 to v7 (single hop through the 6-0 and 7-0
migration guides). All deps upgraded via pnpm --filter:
ai@7, @ai-sdk/{anthropic@4,google@4,openai@4,openai-compatible@3,provider@4},
@openrouter/ai-sdk-provider@3, ollama-ai-provider-v2@4.

Changes (per the official migration guides):
- Provider spec bumped V2->V4: ProviderV2 -> ProviderV4 (models.ts, gateway.ts)
- streamText: result.fullStream -> result.stream (+ StreamTextInvoker seam,
  test fakes); stepCountIs -> isStepCount
- system -> instructions on SDK generateText/streamText/generateObject calls
  (left our own generateObjectSafe.system param untouched)
- allowSystemInMessages: true on calls passing stored messages (v7 rejects
  system-role messages in the array by default; convertFromMessages emits them)
- image content part {type:'image'} -> {type:'file'} (message-encoding.ts)
- tool() no longer accepts a `name` key (removed; tools keyed by ToolSet key)
- usage token relocation: cachedInputTokens -> inputTokenDetails.cacheReadTokens,
  reasoningTokens -> outputTokenDetails.reasoningTokens (mapUsage rewrite);
  renderer decoupled from LanguageModelUsage to a local flat UsageSummary
- providerMetadata relay cast to shared ProviderOptions (spec loosened)

No persisted-data changes: @x/shared is AI-SDK-free, so old chats
list/view/continue unchanged. Full typecheck clean; 637 tests pass
(shared 107, core 447, renderer 83); main.cjs bundles ESM-only ai into CJS.

Pending live check (needs creds): OpenAI strictJsonSchema now defaults true
in v7 and may reject zod schemas for openai-family flavors at runtime.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-07-13 16:22:36 +05:30
parent 10525c58b8
commit cc2daf1874
16 changed files with 210 additions and 207 deletions

View file

@ -17,19 +17,19 @@
"@agentclientprotocol/claude-agent-acp": "^0.55.0",
"@agentclientprotocol/codex-acp": "^1.1.0",
"@agentclientprotocol/sdk": "^1.1.0",
"@ai-sdk/anthropic": "^2.0.63",
"@ai-sdk/google": "^2.0.53",
"@ai-sdk/openai": "^2.0.91",
"@ai-sdk/openai-compatible": "^1.0.33",
"@ai-sdk/provider": "^2.0.1",
"@ai-sdk/anthropic": "^4.0.12",
"@ai-sdk/google": "^4.0.12",
"@ai-sdk/openai": "^4.0.11",
"@ai-sdk/openai-compatible": "^3.0.7",
"@ai-sdk/provider": "^4.0.3",
"@composio/core": "^0.6.0",
"@google-cloud/local-auth": "^3.0.1",
"@modelcontextprotocol/sdk": "^1.25.1",
"@openrouter/ai-sdk-provider": "^1.2.6",
"@openrouter/ai-sdk-provider": "^3.0.0",
"@react-pdf/renderer": "^4.3.2",
"@types/react": "^19.2.7",
"@x/shared": "workspace:*",
"ai": "^5.0.133",
"ai": "^7.0.22",
"awilix": "^12.0.5",
"baileys": "7.0.0-rc13",
"chokidar": "^4.0.3",
@ -42,7 +42,7 @@
"isomorphic-git": "^1.29.0",
"mammoth": "^1.11.0",
"node-html-markdown": "^2.0.0",
"ollama-ai-provider-v2": "^1.5.4",
"ollama-ai-provider-v2": "^4.0.1",
"openid-client": "^6.8.1",
"papaparse": "^5.5.3",
"pdf-parse": "^2.4.5",

View file

@ -304,8 +304,8 @@ async function handleLlmGenerate(
const model = createProvider(providerConfig).languageModel(resolved.model);
const result = await withUseCase({ useCase: 'app_llm_generate', subUseCase: slug }, () => generateText({
model,
...(system ? { system } : {}),
...(rawMessages ? { messages: rawMessages as ModelMessage[] } : { prompt: prompt as string }),
...(system ? { instructions: system } : {}),
...(rawMessages ? { messages: rawMessages as ModelMessage[], allowSystemInMessages: true } : { prompt: prompt as string }),
...(temperature !== undefined ? { temperature } : {}),
maxOutputTokens,
}));

View file

@ -653,7 +653,7 @@ Respond with ONLY valid JSON: either a schedule object or null. No other text.`;
try {
const result = await withUseCase({ useCase: 'knowledge_sync', subUseCase: 'inline_task_classify' }, () => generateText({
model,
system: systemPrompt,
instructions: systemPrompt,
prompt: instruction,
}));

View file

@ -181,7 +181,7 @@ async function generateBrief(event: CalendarEvent, ctx: Awaited<ReturnType<typeo
const result = await withUseCase({ useCase: 'meeting_prep' }, () => generateText({
model,
system: BRIEF_SYSTEM,
instructions: BRIEF_SYSTEM,
prompt: parts.join('\n\n'),
}));
captureLlmUsage({ useCase: 'meeting_prep', model: modelId, provider: providerName, usage: result.usage });

View file

@ -154,7 +154,7 @@ export async function summarizeMeeting(transcript: string, meetingStartTime?: st
const result = await withUseCase({ useCase: 'meeting_note' }, () => generateText({
model,
system: SYSTEM_PROMPT,
instructions: SYSTEM_PROMPT,
prompt,
}));

View file

@ -1,4 +1,4 @@
import { ProviderV2 } from '@ai-sdk/provider';
import { ProviderV4 } from '@ai-sdk/provider';
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { getAccessToken } from '../auth/tokens.js';
import { getCurrentUseCase } from '../analytics/use_case.js';
@ -16,7 +16,7 @@ const authedFetch: typeof fetch = async (input, init) => {
return fetch(input, { ...init, headers });
};
export function getGatewayProvider(): ProviderV2 {
export function getGatewayProvider(): ProviderV4 {
return createOpenRouter({
baseURL: `${API_URL}/v1/llm`,
apiKey: 'managed-by-rowboat',

View file

@ -1,4 +1,4 @@
import { ProviderV2 } from "@ai-sdk/provider";
import { ProviderV4 } from "@ai-sdk/provider";
import { createGateway, generateText, type LanguageModel } from "ai";
import { createOpenAI } from "@ai-sdk/openai";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
@ -22,7 +22,7 @@ import {
export const Provider = LlmProvider;
export const ModelConfig = LlmModelConfig;
export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
export function createProvider(config: z.infer<typeof Provider>): ProviderV4 {
const { apiKey, baseURL, headers } = config;
switch (config.flavor) {
case "openai":
@ -78,7 +78,7 @@ export function createProvider(config: z.infer<typeof Provider>): ProviderV2 {
apiKey,
baseURL,
headers,
}) as unknown as ProviderV2;
}) as unknown as ProviderV4;
case "rowboat":
return getGatewayProvider();
default:
@ -342,7 +342,7 @@ export async function generateOneShot(opts: GenerateTextOptions): Promise<Genera
{ useCase: "copilot_chat", subUseCase: "email_compose" },
() => generateText({
model: languageModel,
...(opts.system ? { system: opts.system } : {}),
...(opts.system ? { instructions: opts.system } : {}),
prompt: opts.prompt,
}),
);

View file

@ -44,7 +44,7 @@ export async function generateObjectSafe<T>(
try {
const result = await generateObject({
model: options.model,
...(options.system ? { system: options.system } : {}),
...(options.system ? { instructions: options.system } : {}),
prompt: options.prompt,
schema: options.schema,
});
@ -68,7 +68,7 @@ export async function generateObjectSafe<T>(
].join("\n\n").trim();
const result = await generateObject({
model: options.model,
system,
instructions: system,
prompt: options.prompt,
schema: options.schema,
});

View file

@ -101,7 +101,9 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
// model knows which images show the user vs their screen.
const textSegments: string[] = userMessageContextPrefix ? [userMessageContextPrefix] : [];
const attachmentLines: string[] = [];
type EncodedImagePart = { type: "image"; image: string; mediaType: string };
// AI SDK 7 collapsed the `image` content part into `file`
// (data + mediaType); image parts are deprecated.
type EncodedImagePart = { type: "file"; data: string; mediaType: string };
const cameraParts: EncodedImagePart[] = [];
const screenParts: EncodedImagePart[] = [];
const frameTimes: string[] = [];
@ -113,7 +115,7 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): ModelM
attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}${lineStr}`);
} else if (part.type === "image") {
const target = part.source === "screen" ? screenParts : cameraParts;
target.push({ type: "image", image: part.data, mediaType: part.mediaType });
target.push({ type: "file", data: part.data, mediaType: part.mediaType });
if (part.capturedAt) frameTimes.push(part.capturedAt);
} else {
textSegments.push(part.text);

View file

@ -1,7 +1,7 @@
import { jsonSchema } from "ai";
import { Agent, ToolAttachment } from "@x/shared/dist/agent.js";
import { AssistantContentPart, AssistantMessage, MessageList, ProviderOptions, ToolCallPart, ToolMessage, UserMessageContext } from "@x/shared/dist/message.js";
import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai";
import { LanguageModel, isStepCount, streamText, tool, Tool, ToolSet } from "ai";
import { z } from "zod";
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
import { execTool } from "../tools/exec-tool.js";
@ -255,7 +255,6 @@ async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<Tool> {
switch (t.type) {
case "mcp":
return tool({
name: t.name,
description: t.description,
inputSchema: jsonSchema(t.inputSchema),
});
@ -265,7 +264,6 @@ async function mapAgentTool(t: z.infer<typeof ToolAttachment>): Promise<Tool> {
throw new Error(`Agent ${t.name} not found`);
}
return tool({
name: t.name,
description: agent.description,
inputSchema: z.object({
message: z.string().describe("The message to send to the workflow"),
@ -1151,21 +1149,23 @@ async function* streamLlm(
}, () => streamText({
model,
messages: converted,
system: instructions,
instructions,
allowSystemInMessages: true,
tools,
stopWhen: stepCountIs(1),
stopWhen: isStepCount(1),
abortSignal: signal,
}))
: streamText({
model,
messages: converted,
system: instructions,
instructions,
allowSystemInMessages: true,
tools,
stopWhen: stepCountIs(1),
stopWhen: isStepCount(1),
abortSignal: signal,
});
const { fullStream } = streamResult;
for await (const event of fullStream) {
const { stream } = streamResult;
for await (const event of stream) {
// Check abort on every chunk for responsiveness
signal?.throwIfAborted();
console.log("-> \t\tstream event", JSON.stringify(event));
@ -1179,39 +1179,39 @@ async function* streamLlm(
case "reasoning-start":
yield {
type: "reasoning-start",
providerOptions: event.providerMetadata,
providerOptions: event.providerMetadata as z.infer<typeof ProviderOptions> | undefined,
};
break;
case "reasoning-delta":
yield {
type: "reasoning-delta",
delta: event.text,
providerOptions: event.providerMetadata,
providerOptions: event.providerMetadata as z.infer<typeof ProviderOptions> | undefined,
};
break;
case "reasoning-end":
yield {
type: "reasoning-end",
providerOptions: event.providerMetadata,
providerOptions: event.providerMetadata as z.infer<typeof ProviderOptions> | undefined,
};
break;
case "text-start":
yield {
type: "text-start",
providerOptions: event.providerMetadata,
providerOptions: event.providerMetadata as z.infer<typeof ProviderOptions> | undefined,
};
break;
case "text-end":
yield {
type: "text-end",
providerOptions: event.providerMetadata,
providerOptions: event.providerMetadata as z.infer<typeof ProviderOptions> | undefined,
};
break;
case "text-delta":
yield {
type: "text-delta",
delta: event.text,
providerOptions: event.providerMetadata,
providerOptions: event.providerMetadata as z.infer<typeof ProviderOptions> | undefined,
};
break;
case "tool-call":
@ -1220,7 +1220,7 @@ async function* streamLlm(
toolCallId: event.toolCallId,
toolName: event.toolName,
input: event.input,
providerOptions: event.providerMetadata,
providerOptions: event.providerMetadata as z.infer<typeof ProviderOptions> | undefined,
};
break;
case "finish-step":
@ -1238,7 +1238,7 @@ async function* streamLlm(
type: "finish-step",
usage: event.usage,
finishReason: event.finishReason,
providerOptions: event.providerMetadata,
providerOptions: event.providerMetadata as z.infer<typeof ProviderOptions> | undefined,
};
break;
default:

View file

@ -15,7 +15,7 @@ function makeRegistry(parts: Array<Record<string, unknown>>, capture: InvokerOpt
invoke: (options) => {
capture.push(options);
return {
fullStream: (async function* () {
stream: (async function* () {
yield* parts;
})(),
};
@ -378,7 +378,7 @@ describe("RealModelRegistry", () => {
invoke: (options) => {
capture.push(options);
return {
fullStream: (async function* () {
stream: (async function* () {
yield { type: "finish-step", finishReason: "stop", usage: {} };
})(),
};
@ -528,7 +528,7 @@ describe("RealModelRegistry", () => {
invoke: (options) => {
capture.push(options);
return {
fullStream: (async function* () {
stream: (async function* () {
yield { type: "finish-step", finishReason: "stop", usage: {} };
})(),
};

View file

@ -1,6 +1,6 @@
import {
jsonSchema,
stepCountIs,
isStepCount,
streamText,
tool,
type LanguageModel,
@ -39,10 +39,18 @@ export type StreamTextInvoker = (options: {
topP?: number;
maxOutputTokens?: number;
providerOptions?: Record<string, Record<string, JsonValue>>;
}) => { fullStream: AsyncIterable<unknown> };
}) => { stream: AsyncIterable<unknown> };
const defaultInvoker: StreamTextInvoker = (options) =>
streamText({ ...options, stopWhen: stepCountIs(1) });
const defaultInvoker: StreamTextInvoker = ({ system, ...options }) =>
// Our seam keeps a `system` field; the SDK's top-level option is now
// `instructions`. `allowSystemInMessages` opts into system-role messages
// in the array (v7 rejects them by default) — persisted chats rely on it.
streamText({
...options,
instructions: system,
allowSystemInMessages: true,
stopWhen: isStepCount(1),
});
export interface RealModelRegistryDeps {
resolveProvider?: (name: string) => Promise<z.infer<typeof LlmProvider>>;
@ -217,7 +225,7 @@ export class RealModelRegistry implements IModelRegistry {
...generationParams,
});
for await (const raw of result.fullStream) {
for await (const raw of result.stream) {
request.signal.throwIfAborted();
const event = raw as {
type: string;
@ -226,7 +234,13 @@ export class RealModelRegistry implements IModelRegistry {
toolName?: string;
input?: unknown;
finishReason?: string;
usage?: Record<string, number | undefined>;
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
inputTokenDetails?: { cacheReadTokens?: number | undefined };
outputTokenDetails?: { reasoningTokens?: number | undefined };
};
providerMetadata?: unknown;
error?: unknown;
};
@ -390,24 +404,33 @@ function mergeProviderOptions(
}
function mapUsage(
usage: Record<string, number | undefined> | undefined,
usage:
| {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
inputTokenDetails?: { cacheReadTokens?: number | undefined };
outputTokenDetails?: { reasoningTokens?: number | undefined };
}
| undefined,
): z.infer<typeof TurnUsage> {
const mapped: z.infer<typeof TurnUsage> = {};
if (!usage) {
return mapped;
}
for (const key of [
"inputTokens",
"outputTokens",
"totalTokens",
"reasoningTokens",
"cachedInputTokens",
] as const) {
const value = usage[key];
// AI SDK 7 relocated cached/reasoning tokens into nested detail objects
// (usage.inputTokenDetails.cacheReadTokens, usage.outputTokenDetails.reasoningTokens);
// our persisted TurnUsage keeps the flat names.
const put = (key: keyof z.infer<typeof TurnUsage>, value: number | undefined) => {
if (typeof value === "number" && Number.isFinite(value)) {
mapped[key] = value;
}
}
};
put("inputTokens", usage.inputTokens);
put("outputTokens", usage.outputTokens);
put("totalTokens", usage.totalTokens);
put("reasoningTokens", usage.outputTokenDetails?.reasoningTokens);
put("cachedInputTokens", usage.inputTokenDetails?.cacheReadTokens);
return mapped;
}