Unverified 提交 54b7a22d authored 作者: Will Chen's avatar Will Chen 提交者: GitHub

Upgrade to AI SDK v6 (#2102)

<!-- CURSOR_SUMMARY --> > [!NOTE] > Modernizes AI integration across the app. > > - Upgrade `ai` to v6 and all `@ai-sdk/*` providers to v3/v4; add `@ai-sdk/mcp`, remove `@openrouter/ai-sdk-provider` > - Migrate from `LanguageModelV2` to `LanguageModel`/`LanguageModelV3`; refactor fallback model to v3 streaming API > - Switch OpenRouter to `createOpenAICompatible` (`https://openrouter.ai/api/v1`) > - Replace experimental MCP with `@ai-sdk/mcp` (`createMCPClient`); update tool execution to `ToolExecutionOptions` and sanitize tool keys > - Update AI message envelope to `ai@v6` and types (`AiMessagesJsonV6`); adjust DB schema and parsing utilities > - Update chat/local-agent handlers to v6 stream parts (reasoning/tool parts), persist `aiMessagesJson`, and wire MCP tools into `ToolSet` > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fc966595cbd9c6ff7d261497f00bfe79d0fff9e3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Upgrade the app to AI SDK v6 to modernize model integrations, MCP tooling, and streaming. This improves stability, unifies types on v3, and removes the OpenRouter provider in favor of an OpenAI-compatible setup. - **Dependencies** - Upgraded ai to 6.0.14 and all @ai-sdk providers to v3/v4. - Added @ai-sdk/mcp; removed @openrouter/ai-sdk-provider. - Pinned @ai-sdk/provider to 3.0.2. - Updated transitive deps (e.g., google-auth-library, gaxios) for Node >=18. - **Refactors** - Moved from LanguageModelV2 to v3 and standardized on ai’s LanguageModel. - Rebuilt fallback model to v3 spec with safer stream retries. - Switched MCP client to createMCPClient and updated tool execution to ToolExecutionOptions. - Replaced OpenRouter integration with createOpenAICompatible (baseURL https://openrouter.ai/api/v1). - Updated AI messages envelope to "ai@v6"; older "ai@v5" envelopes are ignored. <sup>Written for commit fc966595cbd9c6ff7d261497f00bfe79d0fff9e3. Summary will update on new commits.</sup> <!-- End of auto-generated description by cubic. -->
上级 da46d808
差异被折叠。
......@@ -85,15 +85,16 @@
"vitest": "^3.1.1"
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^3.0.15",
"@ai-sdk/anthropic": "^2.0.4",
"@ai-sdk/azure": "^2.0.17",
"@ai-sdk/google": "^2.0.6",
"@ai-sdk/google-vertex": "3.0.16",
"@ai-sdk/openai": "2.0.15",
"@ai-sdk/openai-compatible": "^1.0.8",
"@ai-sdk/provider-utils": "^3.0.3",
"@ai-sdk/xai": "^2.0.16",
"@ai-sdk/amazon-bedrock": "^4.0.9",
"@ai-sdk/anthropic": "^3.0.7",
"@ai-sdk/azure": "^3.0.7",
"@ai-sdk/google": "^3.0.5",
"@ai-sdk/google-vertex": "4.0.8",
"@ai-sdk/mcp": "^1.0.5",
"@ai-sdk/openai": "3.0.7",
"@ai-sdk/openai-compatible": "^2.0.4",
"@ai-sdk/provider-utils": "^4.0.4",
"@ai-sdk/xai": "^3.0.10",
"@babel/parser": "^7.28.5",
"@biomejs/biome": "^1.9.4",
"@dyad-sh/supabase-management-js": "v1.0.1",
......@@ -102,7 +103,6 @@
"@monaco-editor/react": "^4.7.0-rc.0",
"@neondatabase/api-client": "^2.1.0",
"@neondatabase/serverless": "^1.0.1",
"@openrouter/ai-sdk-provider": "^1.1.2",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.13",
"@radix-ui/react-checkbox": "^1.3.2",
......@@ -127,7 +127,7 @@
"@types/uuid": "^10.0.0",
"@vercel/sdk": "^1.18.0",
"@vitejs/plugin-react": "^4.3.4",
"ai": "^5.0.15",
"ai": "^6.0.14",
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
......
......@@ -3,9 +3,9 @@ import { integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { relations } from "drizzle-orm";
import type { ModelMessage } from "ai";
export const AI_MESSAGES_SDK_VERSION = "ai@v5" as const;
export const AI_MESSAGES_SDK_VERSION = "ai@v6" as const;
export type AiMessagesJsonV5 = {
export type AiMessagesJsonV6 = {
messages: ModelMessage[];
sdkVersion: typeof AI_MESSAGES_SDK_VERSION;
};
......@@ -94,7 +94,7 @@ export const messages = sqliteTable("messages", {
// AI SDK messages (v5 envelope) for preserving tool calls/results in agent mode
aiMessagesJson: text("ai_messages_json", {
mode: "json",
}).$type<AiMessagesJsonV5 | null>(),
}).$type<AiMessagesJsonV6 | null>(),
createdAt: integer("created_at", { mode: "timestamp" })
.notNull()
.default(sql`(unixepoch())`),
......
......@@ -9,6 +9,7 @@ import {
TextStreamPart,
stepCountIs,
hasToolCall,
type ToolExecutionOptions,
} from "ai";
import { db } from "../../db";
......@@ -1705,13 +1706,12 @@ async function getMcpTools(event: IpcMainInvokeEvent): Promise<ToolSet> {
for (const s of servers) {
const client = await mcpManager.getClient(s.id);
const toolSet = await client.tools();
for (const [name, tool] of Object.entries(toolSet)) {
for (const [name, mcpTool] of Object.entries(toolSet)) {
const key = `${String(s.name || "").replace(/[^a-zA-Z0-9_-]/g, "-")}__${String(name).replace(/[^a-zA-Z0-9_-]/g, "-")}`;
const original = tool;
mcpToolSet[key] = {
description: original?.description,
inputSchema: original?.inputSchema,
execute: async (args: any, execCtx: any) => {
description: mcpTool.description,
inputSchema: mcpTool.inputSchema,
execute: async (args: unknown, execCtx: ToolExecutionOptions) => {
const inputPreview =
typeof args === "string"
? args
......@@ -1722,12 +1722,12 @@ async function getMcpTools(event: IpcMainInvokeEvent): Promise<ToolSet> {
serverId: s.id,
serverName: s.name,
toolName: name,
toolDescription: original?.description,
toolDescription: mcpTool.description,
inputPreview,
});
if (!ok) throw new Error(`User declined running tool ${key}`);
const res = await original.execute?.(args, execCtx);
const res = await mcpTool.execute(args, execCtx);
return typeof res === "string" ? res : JSON.stringify(res);
},
......
......@@ -89,9 +89,9 @@ export function registerMcpHandlers() {
const client = await mcpManager.getClient(serverId);
const remoteTools = await client.tools();
const tools = await Promise.all(
Object.entries(remoteTools).map(async ([name, tool]) => ({
Object.entries(remoteTools).map(async ([name, mcpTool]) => ({
name,
description: tool.description ?? null,
description: mcpTool.description ?? null,
consent: await getStoredConsent(serverId, name),
})),
);
......
import { AI_MESSAGES_SDK_VERSION, AiMessagesJsonV5 } from "@/db/schema";
import { AI_MESSAGES_SDK_VERSION, AiMessagesJsonV6 } from "@/db/schema";
import type { ModelMessage } from "ai";
import log from "electron-log";
......@@ -13,12 +13,12 @@ export const MAX_AI_MESSAGES_SIZE = 1_000_000;
*/
export function getAiMessagesJsonIfWithinLimit(
aiMessages: ModelMessage[],
): AiMessagesJsonV5 | undefined {
): AiMessagesJsonV6 | undefined {
if (!aiMessages || aiMessages.length === 0) {
return undefined;
}
const payload: AiMessagesJsonV5 = {
const payload: AiMessagesJsonV6 = {
messages: aiMessages,
sdkVersion: AI_MESSAGES_SDK_VERSION,
};
......@@ -39,7 +39,7 @@ export type DbMessageForParsing = {
id: number;
role: string;
content: string;
aiMessagesJson: AiMessagesJsonV5 | ModelMessage[] | null;
aiMessagesJson: AiMessagesJsonV6 | ModelMessage[] | null;
};
/**
......@@ -61,19 +61,18 @@ export function parseAiMessagesJson(msg: DbMessageForParsing): ModelMessage[] {
return parsed;
}
// Current shape: { messages: ModelMessage[]; sdkVersion: "ai@v5" }
if (
parsed &&
typeof parsed === "object" &&
"sdkVersion" in parsed &&
(parsed as AiMessagesJsonV5).sdkVersion === AI_MESSAGES_SDK_VERSION &&
(parsed as AiMessagesJsonV6).sdkVersion === AI_MESSAGES_SDK_VERSION &&
"messages" in parsed &&
Array.isArray((parsed as AiMessagesJsonV5).messages) &&
(parsed as AiMessagesJsonV5).messages.every(
Array.isArray((parsed as AiMessagesJsonV6).messages) &&
(parsed as AiMessagesJsonV6).messages.every(
(m: ModelMessage) => m && typeof m.role === "string",
)
) {
return (parsed as AiMessagesJsonV5).messages;
return (parsed as AiMessagesJsonV6).messages;
}
}
......
import {
LanguageModelV2,
LanguageModelV2CallOptions,
LanguageModelV2StreamPart,
import type {
LanguageModelV3,
LanguageModelV3CallOptions,
LanguageModelV3StreamPart,
} from "@ai-sdk/provider";
import type { LanguageModel } from "ai";
// Types
interface FallbackSettings {
models: Array<LanguageModelV2>;
models: Array<LanguageModel>;
}
interface RetryState {
......@@ -17,7 +18,7 @@ interface RetryState {
}
interface StreamResult {
stream: ReadableStream<LanguageModelV2StreamPart>;
stream: ReadableStream<LanguageModelV3StreamPart>;
request?: { body?: unknown };
response?: { headers?: Record<string, string> };
}
......@@ -87,12 +88,12 @@ export function defaultShouldRetryThisError(error: any): boolean {
}
}
export function createFallback(settings: FallbackSettings): FallbackModel {
export function createFallback(settings: FallbackSettings): LanguageModel {
return new FallbackModel(settings);
}
export class FallbackModel implements LanguageModelV2 {
readonly specificationVersion = "v2";
class FallbackModel implements LanguageModelV3 {
readonly specificationVersion = "v3" as const;
private readonly settings: FallbackSettings;
private currentModelIndex: number = 0;
private lastModelReset: number = Date.now();
......@@ -114,24 +115,32 @@ export class FallbackModel implements LanguageModelV2 {
}
get modelId(): string {
return this.getCurrentModel().modelId;
return this.getUnderlyingModel().modelId;
}
get provider(): string {
return this.getCurrentModel().provider;
return this.getUnderlyingModel().provider;
}
get supportedUrls():
| Record<string, RegExp[]>
| PromiseLike<Record<string, RegExp[]>> {
return this.getCurrentModel().supportedUrls;
return this.getUnderlyingModel().supportedUrls;
}
private getCurrentModel(): LanguageModelV2 {
private getUnderlyingModel(): LanguageModelV3 {
const model = this.settings.models[this.currentModelIndex];
if (!model) {
throw new Error(`Model at index ${this.currentModelIndex} not found`);
}
// The model is either a string (GatewayModelId) or LanguageModelV2/V3
// In this fallback context, we only support actual model instances
if (typeof model === "string") {
throw new Error("String model IDs are not supported in fallback model");
}
if (model.specificationVersion !== "v3") {
throw new Error("Model is not a v3 model");
}
return model;
}
......@@ -214,11 +223,11 @@ export class FallbackModel implements LanguageModelV2 {
throw new Error("doGenerate is not supported for fallback model");
}
async doStream(options: LanguageModelV2CallOptions): Promise<StreamResult> {
async doStream(options: LanguageModelV3CallOptions): Promise<StreamResult> {
this.checkAndResetModel();
return this.retry(async (retryState) => {
const result = await this.getCurrentModel().doStream(options);
const result = await this.getUnderlyingModel().doStream(options);
// Create a wrapped stream that handles errors gracefully
const wrappedStream = this.createWrappedStream(
......@@ -235,21 +244,21 @@ export class FallbackModel implements LanguageModelV2 {
}
private createWrappedStream(
originalStream: ReadableStream<LanguageModelV2StreamPart>,
options: LanguageModelV2CallOptions,
originalStream: ReadableStream<LanguageModelV3StreamPart>,
options: LanguageModelV3CallOptions,
retryState: RetryState,
): ReadableStream<LanguageModelV2StreamPart> {
): ReadableStream<LanguageModelV3StreamPart> {
let hasStreamedContent = false;
// eslint-disable-next-line @typescript-eslint/no-this-alias
const fallbackModel = this;
return new ReadableStream<LanguageModelV2StreamPart>({
return new ReadableStream<LanguageModelV3StreamPart>({
async start(controller) {
let reader: ReadableStreamDefaultReader<LanguageModelV2StreamPart> | null =
let reader: ReadableStreamDefaultReader<LanguageModelV3StreamPart> | null =
null;
const processStream = async (
stream: ReadableStream<LanguageModelV2StreamPart>,
stream: ReadableStream<LanguageModelV3StreamPart>,
): Promise<void> => {
reader = stream.getReader();
......@@ -322,7 +331,7 @@ export class FallbackModel implements LanguageModelV2 {
try {
// Create a new stream with the next model
const nextResult = await fallbackModel
.getCurrentModel()
.getUnderlyingModel()
.doStream(options);
await processStream(nextResult.stream);
} catch (nextError) {
......
......@@ -4,8 +4,7 @@ import { createAnthropic } from "@ai-sdk/anthropic";
import { createXai } from "@ai-sdk/xai";
import { createVertex as createGoogleVertex } from "@ai-sdk/google-vertex";
import { createAzure } from "@ai-sdk/azure";
import { LanguageModelV2 } from "@ai-sdk/provider";
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
import type { LanguageModel } from "ai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { createAmazonBedrock } from "@ai-sdk/amazon-bedrock";
import type {
......@@ -48,7 +47,7 @@ const AUTO_MODELS = [
];
export interface ModelClient {
model: LanguageModelV2;
model: LanguageModel;
builtinProviderId?: string;
}
......@@ -280,7 +279,11 @@ function getRegularModelClient(
};
}
case "openrouter": {
const provider = createOpenRouter({ apiKey });
const provider = createOpenAICompatible({
name: "openrouter",
baseURL: "https://openrouter.ai/api/v1",
apiKey,
});
return {
modelClient: {
model: provider(model.name),
......
......@@ -8,7 +8,7 @@ import {
import log from "electron-log";
import { getExtraProviderOptions } from "./thinking_utils";
import type { UserSettings } from "../../lib/schemas";
import { LanguageModelV2 } from "@ai-sdk/provider";
import type { LanguageModel } from "ai";
const logger = log.scope("llm_engine_provider");
......@@ -50,10 +50,7 @@ export interface DyadEngineProvider {
/**
Creates a model for text generation.
*/
(
modelId: ExampleChatModelId,
settings?: ExampleChatSettings,
): LanguageModelV2;
(modelId: ExampleChatModelId, settings?: ExampleChatSettings): LanguageModel;
/**
Creates a chat model for text generation.
......@@ -61,7 +58,7 @@ Creates a chat model for text generation.
chatModel(
modelId: ExampleChatModelId,
settings?: ExampleChatSettings,
): LanguageModelV2;
): LanguageModel;
}
export function createDyadEngine(
......
import { db } from "../../db";
import { mcpServers } from "../../db/schema";
import { experimental_createMCPClient, experimental_MCPClient } from "ai";
import { createMCPClient, type MCPClient } from "@ai-sdk/mcp";
import { eq } from "drizzle-orm";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
......@@ -13,9 +13,9 @@ class McpManager {
return this._instance;
}
private clients = new Map<number, experimental_MCPClient>();
private clients = new Map<number, MCPClient>();
async getClient(serverId: number): Promise<experimental_MCPClient> {
async getClient(serverId: number): Promise<MCPClient> {
const existing = this.clients.get(serverId);
if (existing) return existing;
const server = await db
......@@ -40,7 +40,7 @@ class McpManager {
} else {
throw new Error(`Unsupported MCP transport: ${s.transport}`);
}
const client = await experimental_createMCPClient({
const client = await createMCPClient({
transport,
});
this.clients.set(serverId, client);
......
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import type { FetchFunction } from "@ai-sdk/provider-utils";
import { withoutTrailingSlash } from "@ai-sdk/provider-utils";
import type { LanguageModelV2 } from "@ai-sdk/provider";
import type { LanguageModel } from "ai";
type OllamaChatModelId = string;
......@@ -19,7 +19,7 @@ export interface OllamaProviderOptions {
export interface OllamaChatSettings {}
export interface OllamaProvider {
(modelId: OllamaChatModelId, settings?: OllamaChatSettings): LanguageModelV2;
(modelId: OllamaChatModelId, settings?: OllamaChatSettings): LanguageModel;
}
export function createOllamaProvider(
......
......@@ -4,7 +4,13 @@
*/
import { IpcMainInvokeEvent } from "electron";
import { streamText, ToolSet, stepCountIs, ModelMessage } from "ai";
import {
streamText,
ToolSet,
stepCountIs,
ModelMessage,
type ToolExecutionOptions,
} from "ai";
import log from "electron-log";
import { db } from "@/db";
import { chats, messages } from "@/db/schema";
......@@ -466,14 +472,13 @@ async function getMcpTools(
const client = await mcpManager.getClient(s.id);
const toolSet = await client.tools();
for (const [name, tool] of Object.entries(toolSet)) {
for (const [name, mcpTool] of Object.entries(toolSet)) {
const key = `${sanitizeMcpName(s.name || "")}__${sanitizeMcpName(name)}`;
const original = tool;
mcpToolSet[key] = {
description: original?.description,
inputSchema: original?.inputSchema,
execute: async (args: any, execCtx: any) => {
description: mcpTool.description,
inputSchema: mcpTool.inputSchema,
execute: async (args: unknown, execCtx: ToolExecutionOptions) => {
try {
const inputPreview =
typeof args === "string"
......@@ -486,7 +491,7 @@ async function getMcpTools(
serverId: s.id,
serverName: s.name,
toolName: name,
toolDescription: original?.description,
toolDescription: mcpTool.description,
inputPreview,
});
......@@ -499,7 +504,7 @@ async function getMcpTools(
`<dyad-mcp-tool-call server="${serverName}" tool="${toolName}">\n${content}\n</dyad-mcp-tool-call>`,
);
const res = await original.execute?.(args, execCtx);
const res = await mcpTool.execute(args, execCtx);
const resultStr =
typeof res === "string" ? res : JSON.stringify(res);
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论