diff --git a/package-lock.json b/package-lock.json index 1237b31..fbe688a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,11 +22,11 @@ "sqlite-vec": "0.1.7-alpha.2" }, "devDependencies": { - "@ai-sdk/anthropic": "^2.0.105", - "@ai-sdk/google": "^2.0.99", - "@ai-sdk/openai": "^2.0.89", - "@ai-sdk/openai-compatible": "^1.0.31", - "@ai-sdk/provider": "^2.0.5", + "@ai-sdk/anthropic": "^3.0.125", + "@ai-sdk/google": "^3.0.129", + "@ai-sdk/openai": "^3.0.121", + "@ai-sdk/openai-compatible": "^2.0.80", + "@ai-sdk/provider": "^3.0.18", "@dagrejs/dagre": "^1.1.8", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -68,7 +68,7 @@ "@types/turndown": "^5.0.6", "@vitejs/plugin-react": "^5.1.2", "@xyflow/react": "^12.10.0", - "ai": "^5.0.123", + "ai": "^6.0.297", "autoprefixer": "^10.4.23", "cheerio": "^1.2.0", "class-variance-authority": "^0.7.1", @@ -129,14 +129,14 @@ "license": "MIT" }, "node_modules/@ai-sdk/anthropic": { - "version": "2.0.105", - "resolved": "https://registry.npmmirror.com/@ai-sdk/anthropic/-/anthropic-2.0.105.tgz", - "integrity": "sha512-yz+RATWLmsvxKuswRxuvasUlrhxgwCY9RrafM2q2ZBax7XFDuiMR7fv/syMTNRtLo4Y3Oclz8x89V28TiAvARA==", + "version": "3.0.125", + "resolved": "https://registry.npmmirror.com/@ai-sdk/anthropic/-/anthropic-3.0.125.tgz", + "integrity": "sha512-oCBy62UBmf94aptAkb+0kB5ac4503cta2SXEGAGNweEscRmeSWsDVx8YWVdJ5vZqFoyJdyWTrKrZ2J9IodWbwA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "2.0.5", - "@ai-sdk/provider-utils": "3.0.39" + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.56" }, "engines": { "node": ">=18" @@ -146,15 +146,15 @@ } }, "node_modules/@ai-sdk/gateway": { - "version": "2.0.157", - "resolved": "https://registry.npmmirror.com/@ai-sdk/gateway/-/gateway-2.0.157.tgz", - "integrity": "sha512-fEhZ1W1Dn3Lyr4pm8bxSoH79CRFCxBR4JW/lrN7A3TpoNXN7O4uHBcVNaEqESJVjBOc6mESG9jaFALRixHgodw==", + "version": "3.0.206", + "resolved": "https://registry.npmmirror.com/@ai-sdk/gateway/-/gateway-3.0.206.tgz", + "integrity": "sha512-AdfedgmKysARpRA1oTW9r/yYyA1kHw1MDL9LzYV1aTCKGZxj2xltxybxzMK4ioE6Dh0dhAXnQ2JSTA7HKYFlaQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "2.0.5", - "@ai-sdk/provider-utils": "3.0.39", - "@vercel/oidc": "3.1.0" + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.56", + "@vercel/oidc": "3.2.0" }, "engines": { "node": ">=18" @@ -164,14 +164,14 @@ } }, "node_modules/@ai-sdk/google": { - "version": "2.0.99", - "resolved": "https://registry.npmmirror.com/@ai-sdk/google/-/google-2.0.99.tgz", - "integrity": "sha512-TibnKH6ZZ5aPgS7G09On+VdZmy08BIck5JsNL9ddbPyV8ofbPTIAwPhPWRZvBKOOLNbk/Zc5PP0YpGsBin/rJA==", + "version": "3.0.129", + "resolved": "https://registry.npmmirror.com/@ai-sdk/google/-/google-3.0.129.tgz", + "integrity": "sha512-WmPqvn/+y4i6YAvYPttprB8SEuyj0c/o1hqve0p3u5CuxnZ0NV1lt6PCHrnnAi4yP1EEV5+QJAAR1P7tB45bsQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "2.0.5", - "@ai-sdk/provider-utils": "3.0.39" + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.56" }, "engines": { "node": ">=18" @@ -181,14 +181,14 @@ } }, "node_modules/@ai-sdk/openai": { - "version": "2.0.129", - "resolved": "https://registry.npmmirror.com/@ai-sdk/openai/-/openai-2.0.129.tgz", - "integrity": "sha512-uGm9AGQBT0scAhJyIbGGeQa5r55qG3trs3nZEAnzEr5cbBTIb5wgdZKGXcq5x3ukhU7/7XejZtOleEdGQPrDkw==", + "version": "3.0.121", + "resolved": "https://registry.npmmirror.com/@ai-sdk/openai/-/openai-3.0.121.tgz", + "integrity": "sha512-3ZBWdXcwC35W6Gj4FbhogEmAvVxyArzGEHvoADWiSMJffyirKYE30Yy5oM0Ct0fpmHu26mgTrkNugSA/Nm6pXA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "2.0.5", - "@ai-sdk/provider-utils": "3.0.39" + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.56" }, "engines": { "node": ">=18" @@ -198,14 +198,14 @@ } }, "node_modules/@ai-sdk/openai-compatible": { - "version": "1.0.56", - "resolved": "https://registry.npmmirror.com/@ai-sdk/openai-compatible/-/openai-compatible-1.0.56.tgz", - "integrity": "sha512-S84f0SC506XBLEKnMq/Ldinm240z6hOHbl+vHqkdNQ965JqJLWV5D+WBWQ1EriDkbvfoXeJpzckc9ktSOt6nqQ==", + "version": "2.0.80", + "resolved": "https://registry.npmmirror.com/@ai-sdk/openai-compatible/-/openai-compatible-2.0.80.tgz", + "integrity": "sha512-v/X0WZYuQ3qrWkHAI3pbnkx1NMS1T7XGAQvpZxlbela7QwYJ/vAK9Og6mio82DbTbrSLIpspdPxx0itWrWRaYw==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "2.0.5", - "@ai-sdk/provider-utils": "3.0.39" + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.56" }, "engines": { "node": ">=18" @@ -215,9 +215,9 @@ } }, "node_modules/@ai-sdk/provider": { - "version": "2.0.5", - "resolved": "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-2.0.5.tgz", - "integrity": "sha512-cmG40u10QFEUrYXWYom6Lo6DLR+pND3wWABTTT9Nf7zg9F7BD46KNj+STekndp9Kg5ZAIPA0WxmTI0IwRvKvvQ==", + "version": "3.0.18", + "resolved": "https://registry.npmmirror.com/@ai-sdk/provider/-/provider-3.0.18.tgz", + "integrity": "sha512-IpefF5ssZVOZCD4Wu7zxt6PwZoGT2HLVjxpJ10Bjx6EAmRfLYJPjyrfxb0RYDlwvXnDb9nkQ7Do/oJVDuRFzew==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -228,19 +228,19 @@ } }, "node_modules/@ai-sdk/provider-utils": { - "version": "3.0.39", - "resolved": "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-3.0.39.tgz", - "integrity": "sha512-Cb1GE1UKcZxouWhV7weIQoJTgyz4WMswQfapPClN4QQP1pO01ZNSbG9EVN5C0gCsikA8vM0wrJZIT7HiZmH3wA==", + "version": "4.0.56", + "resolved": "https://registry.npmmirror.com/@ai-sdk/provider-utils/-/provider-utils-4.0.56.tgz", + "integrity": "sha512-Svfg3OwsLRqkDDn/lTk6x3Dmrlft+u3Xs2M5YvVqpuDY9sftY125xJ9jnref63W/CrFln0j7Ij3/YgG9WHpOqA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/provider": "2.0.5", - "@standard-schema/spec": "^1.0.0", - "eventsource-parser": "^3.0.6", - "undici": "^5.29.0" + "@ai-sdk/provider": "3.0.18", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^6.28.0" }, "engines": { - "node": ">=18" + "node": ">=18.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" @@ -2465,16 +2465,6 @@ } } }, - "node_modules/@fastify/busboy": { - "version": "2.1.1", - "resolved": "https://registry.npmmirror.com/@fastify/busboy/-/busboy-2.1.1.tgz", - "integrity": "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, "node_modules/@floating-ui/core": { "version": "1.8.0", "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", @@ -6592,9 +6582,9 @@ "license": "ISC" }, "node_modules/@vercel/oidc": { - "version": "3.1.0", - "resolved": "https://registry.npmmirror.com/@vercel/oidc/-/oidc-3.1.0.tgz", - "integrity": "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==", + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", "dev": true, "license": "Apache-2.0", "engines": { @@ -6770,16 +6760,16 @@ } }, "node_modules/ai": { - "version": "5.0.265", - "resolved": "https://registry.npmmirror.com/ai/-/ai-5.0.265.tgz", - "integrity": "sha512-Sl2N7Hz1ngOITsdDcT2nvaZh/fU1CL0DyFQeZeErINq5EQKLE1LDh257C2MFMwrCMTxu7tbkdDvkibNj6cg2wg==", + "version": "6.0.297", + "resolved": "https://registry.npmmirror.com/ai/-/ai-6.0.297.tgz", + "integrity": "sha512-cy6To6NMD+BokL0Jv0iQFDdPv7mCEaJ3y+/22reehtwBgJvFAaBjCQC1vv1jjgcVHoSAstBkbaoKNIHLc7ZSeg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@ai-sdk/gateway": "2.0.157", - "@ai-sdk/provider": "2.0.5", - "@ai-sdk/provider-utils": "3.0.39", - "@opentelemetry/api": "1.9.0" + "@ai-sdk/gateway": "3.0.206", + "@ai-sdk/provider": "3.0.18", + "@ai-sdk/provider-utils": "4.0.56", + "@opentelemetry/api": "^1.9.0" }, "engines": { "node": ">=18" @@ -14115,16 +14105,6 @@ "node": ">=10" } }, - "node_modules/node-gyp/node_modules/undici": { - "version": "6.28.1", - "resolved": "https://registry.npmmirror.com/undici/-/undici-6.28.1.tgz", - "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, "node_modules/node-gyp/node_modules/which": { "version": "6.0.1", "resolved": "https://registry.npmmirror.com/which/-/which-6.0.1.tgz", @@ -18403,16 +18383,13 @@ "license": "MIT" }, "node_modules/undici": { - "version": "5.29.0", - "resolved": "https://registry.npmmirror.com/undici/-/undici-5.29.0.tgz", - "integrity": "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==", + "version": "6.29.0", + "resolved": "https://registry.npmmirror.com/undici/-/undici-6.29.0.tgz", + "integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==", "dev": true, "license": "MIT", - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, "engines": { - "node": ">=14.0" + "node": ">=18.17" } }, "node_modules/undici-types": { diff --git a/package.json b/package.json index 7573bbd..f71ba65 100644 --- a/package.json +++ b/package.json @@ -116,11 +116,11 @@ "rather than in app.asar." ], "devDependencies": { - "@ai-sdk/anthropic": "^2.0.105", - "@ai-sdk/google": "^2.0.99", - "@ai-sdk/openai": "^2.0.89", - "@ai-sdk/openai-compatible": "^1.0.31", - "@ai-sdk/provider": "^2.0.5", + "@ai-sdk/anthropic": "^3.0.125", + "@ai-sdk/google": "^3.0.129", + "@ai-sdk/openai": "^3.0.121", + "@ai-sdk/openai-compatible": "^2.0.80", + "@ai-sdk/provider": "^3.0.18", "@dagrejs/dagre": "^1.1.8", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -162,7 +162,7 @@ "@types/turndown": "^5.0.6", "@vitejs/plugin-react": "^5.1.2", "@xyflow/react": "^12.10.0", - "ai": "^5.0.123", + "ai": "^6.0.297", "autoprefixer": "^10.4.23", "cheerio": "^1.2.0", "class-variance-authority": "^0.7.1", diff --git a/src/main/models/ModelClient.ts b/src/main/models/ModelClient.ts index 975b663..739a1f8 100644 --- a/src/main/models/ModelClient.ts +++ b/src/main/models/ModelClient.ts @@ -44,9 +44,9 @@ const toTokenUsage = (usage: LanguageModelUsage): ChatTokenUsage => ({ }) /** - * 将 APIMessage 转换为 AI SDK 的 CoreMessage 格式 + * 将 APIMessage 转换为 AI SDK 的 ModelMessage 格式 */ -function convertToCoreMessages(messages: APIMessage[]) { +function toModelMessages(messages: APIMessage[]) { return messages.map((msg) => ({ role: msg.role, content: msg.content @@ -90,6 +90,19 @@ export class ModelClient { return this.adapter.chatProviderOptions?.(this.connection) } + /** + * providerOptions for structured-output calls (streamObject). + * + * AI SDK 6 flipped the OpenAI-family `strictJsonSchema` default to true, and the + * schemas these services generate use `.optional()` fields, which strict mode + * rejects because every property has to be listed in `required`. This returns the + * protocol's pre-v6 setting so the request body is unchanged; protocols whose + * structured output is unaffected return undefined. + */ + structuredOutputProviderOptions(): SharedV2ProviderOptions | undefined { + return this.adapter.structuredOutputProviderOptions?.(this.connection) + } + /** * 流式发送消息 * @@ -106,7 +119,7 @@ export class ModelClient { const result = streamText({ model: this.getAIModel(), - messages: convertToCoreMessages(messages), + messages: toModelMessages(messages), temperature: DEFAULT_TEMPERATURE, // Only when the connection asks for a ceiling. Absent means the model's own // default, which is what a reasoning model needs: its thinking shares this diff --git a/src/main/models/protocols/googleGenerativeAi.ts b/src/main/models/protocols/googleGenerativeAi.ts index ba7b54e..3ea05b5 100644 --- a/src/main/models/protocols/googleGenerativeAi.ts +++ b/src/main/models/protocols/googleGenerativeAi.ts @@ -27,7 +27,7 @@ export const googleGenerativeAiAdapter: ProtocolAdapter = { }, createEmbeddingModel(connection): EmbeddingModel { - return createProvider(connection).textEmbedding(connection.modelId) + return createProvider(connection).embedding(connection.modelId) }, embeddingProviderOptions(_connection, dimensions) { diff --git a/src/main/models/protocols/openaiCompletions.ts b/src/main/models/protocols/openaiCompletions.ts index 8e14ba0..9d81582 100644 --- a/src/main/models/protocols/openaiCompletions.ts +++ b/src/main/models/protocols/openaiCompletions.ts @@ -29,7 +29,7 @@ export const openaiCompletionsAdapter: ProtocolAdapter = { }, createEmbeddingModel(connection): EmbeddingModel { - return createProvider(connection).textEmbeddingModel(connection.modelId) + return createProvider(connection).embeddingModel(connection.modelId) }, embeddingProviderOptions(_connection, dimensions) { diff --git a/src/main/models/protocols/openaiResponses.ts b/src/main/models/protocols/openaiResponses.ts index e4233ee..7d59566 100644 --- a/src/main/models/protocols/openaiResponses.ts +++ b/src/main/models/protocols/openaiResponses.ts @@ -27,7 +27,7 @@ export const openaiResponsesAdapter: ProtocolAdapter = { }, createEmbeddingModel(connection): EmbeddingModel { - return createProvider(connection).textEmbeddingModel(connection.modelId) + return createProvider(connection).embeddingModel(connection.modelId) }, embeddingProviderOptions(_connection, dimensions) { @@ -41,6 +41,14 @@ export const openaiResponsesAdapter: ProtocolAdapter = { : undefined }, + /** + * AI SDK 6 把 `strictJsonSchema` 的默认值改为 true,而结构化输出服务用的 schema + * 带 `.optional()` 字段,strict 模式会拒绝。显式关掉,保持与 v5 相同的请求体。 + */ + structuredOutputProviderOptions() { + return { [PROVIDER_OPTIONS_KEY]: { strictJsonSchema: false } } + }, + supportsModelListing: true, listModels: listOpenAICompatibleModels diff --git a/src/main/models/protocols/types.ts b/src/main/models/protocols/types.ts index 659b548..8f6908a 100644 --- a/src/main/models/protocols/types.ts +++ b/src/main/models/protocols/types.ts @@ -40,6 +40,16 @@ export interface ProtocolAdapter { */ chatProviderOptions?(connection: ModelConnection): SharedV2ProviderOptions | undefined + /** + * 把结构化输出(streamObject)的 providerOptions 翻译成该协议的设置。 + * + * AI SDK 6 把 OpenAI 系的 `strictJsonSchema` 默认值从 false 改成 true。本项目生成 + * 的结构化输出 schema 大量使用 `.optional()`,而 strict 模式要求每个属性都出现在 + * `required` 里,服务端会直接拒绝。显式回传 `strictJsonSchema: false` 是为了让线上 + * 请求体与 v5 完全一致。结构化输出不受该默认值影响的协议返回 undefined。 + */ + structuredOutputProviderOptions?(connection: ModelConnection): SharedV2ProviderOptions | undefined + /** * 该协议是否支持从端点拉取模型列表(/v1/models 等)。 */ diff --git a/src/main/services/AnkiCardService.ts b/src/main/services/AnkiCardService.ts index 76872a9..7376d84 100644 --- a/src/main/services/AnkiCardService.ts +++ b/src/main/services/AnkiCardService.ts @@ -214,7 +214,8 @@ export class AnkiCardService { const { partialObjectStream, object } = streamObject({ model: model, schema: AnkiSchema, - prompt: prompt + prompt: prompt, + providerOptions: client.structuredOutputProviderOptions() }) // 监听流式更新 (用于显示进度) diff --git a/src/main/services/MindMapService.ts b/src/main/services/MindMapService.ts index 31ad3f4..514c50b 100644 --- a/src/main/services/MindMapService.ts +++ b/src/main/services/MindMapService.ts @@ -150,7 +150,8 @@ export class MindMapService { const { partialObjectStream, object } = streamObject({ model: model, schema: MindMapSchema, - prompt: prompt + prompt: prompt, + providerOptions: client.structuredOutputProviderOptions() }) // 监听流式更新 (可选,用于显示进度) diff --git a/src/main/services/QuizService.ts b/src/main/services/QuizService.ts index 6f8ee13..ab49fa1 100644 --- a/src/main/services/QuizService.ts +++ b/src/main/services/QuizService.ts @@ -217,7 +217,8 @@ export class QuizService { const { partialObjectStream, object } = streamObject({ model: model, schema: QuizSchema, - prompt: prompt + prompt: prompt, + providerOptions: client.structuredOutputProviderOptions() }) // 监听流式更新 (用于显示进度) diff --git a/test/chatStreamManager.test.ts b/test/chatStreamManager.test.ts index 3ba572c..962cd6d 100644 --- a/test/chatStreamManager.test.ts +++ b/test/chatStreamManager.test.ts @@ -1,7 +1,11 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { MockLanguageModelV2, simulateReadableStream } from 'ai/test' -import type { LanguageModelV2FinishReason, LanguageModelV2StreamPart } from '@ai-sdk/provider' +import { MockLanguageModelV3, simulateReadableStream } from 'ai/test' +import type { + LanguageModelV3FinishReason, + LanguageModelV3StreamPart, + LanguageModelV3Usage +} from '@ai-sdk/provider' import { ModelClient } from '../src/main/models/ModelClient.ts' import { ChatStreamManager, @@ -38,20 +42,28 @@ const connection: ModelConnection = { } /** Provider-vocabulary chunks: `delta`, not `text`, and a terminal `finish`. */ -const textChunks = (text: string): LanguageModelV2StreamPart[] => [ +const textChunks = (text: string): LanguageModelV3StreamPart[] => [ { type: 'text-start', id: 'text-1' }, { type: 'text-delta', id: 'text-1', delta: text }, { type: 'text-end', id: 'text-1' } ] -const finishChunk = (finishReason: LanguageModelV2FinishReason): LanguageModelV2StreamPart => ({ +/** The V3 usage shape: nested totals, not the flat numbers the V2 spec used. */ +const v3Usage = (input: number, output: number): LanguageModelV3Usage => ({ + inputTokens: { total: input, noCache: input, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: output, text: output, reasoning: 0 } +}) + +const finishChunk = ( + reason: LanguageModelV3FinishReason['unified'] +): LanguageModelV3StreamPart => ({ type: 'finish', - finishReason, - usage: { inputTokens: 3, outputTokens: 5, totalTokens: 8 } + finishReason: { unified: reason, raw: reason }, + usage: v3Usage(3, 5) }) -function scriptedClient(chunks: LanguageModelV2StreamPart[]): ModelClient { - const model = new MockLanguageModelV2({ +function scriptedClient(chunks: LanguageModelV3StreamPart[]): ModelClient { + const model = new MockLanguageModelV3({ doStream: async () => ({ stream: simulateReadableStream({ chunks: [{ type: 'stream-start', warnings: [] }, ...chunks] as never[] @@ -69,7 +81,7 @@ function scriptedClient(chunks: LanguageModelV2StreamPart[]): ModelClient { */ function abortableClient(text: string): { client: ModelClient; cancelled: () => boolean } { let cancelled = false - const model = new MockLanguageModelV2({ + const model = new MockLanguageModelV3({ doStream: async ({ abortSignal }) => ({ stream: new ReadableStream({ async start(controller) { @@ -103,7 +115,7 @@ function abortableClient(text: string): { client: ModelClient; cancelled: () => /** A provider whose stream dies mid-flight, as a dropped connection would. */ function erroringClient(text: string): ModelClient { - const model = new MockLanguageModelV2({ + const model = new MockLanguageModelV3({ doStream: async () => ({ stream: new ReadableStream({ async start(controller) { @@ -133,7 +145,7 @@ function flakyClient( message: string, text: string ): { client: ModelClient; calls: () => number } { - const model = new MockLanguageModelV2({ + const model = new MockLanguageModelV3({ doStream: async () => { const attempt = model.doStreamCalls.length if (attempt <= failures) { @@ -169,7 +181,7 @@ function flakyClient( */ function silentClient(text: string): { client: ModelClient; cancelled: () => boolean } { let cancelled = false - const model = new MockLanguageModelV2({ + const model = new MockLanguageModelV3({ doStream: async ({ abortSignal }) => ({ stream: new ReadableStream({ async start(controller) { @@ -766,9 +778,9 @@ test('a continuation writes into the answer it continues, and keeps what was the /** A provider that answers `texts[i]` with `reasons[i]` on its i-th call. */ function ceilingClient( texts: string[], - reasons: LanguageModelV2FinishReason[] + reasons: LanguageModelV3FinishReason['unified'][] ): { client: ModelClient; calls: () => number } { - const model = new MockLanguageModelV2({ + const model = new MockLanguageModelV3({ doStream: async () => { const call = model.doStreamCalls.length - 1 const text = texts[Math.min(call, texts.length - 1)] diff --git a/test/connectionCapabilities.test.ts b/test/connectionCapabilities.test.ts index d7163a0..269d3a2 100644 --- a/test/connectionCapabilities.test.ts +++ b/test/connectionCapabilities.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { MockLanguageModelV2, simulateReadableStream } from 'ai/test' +import { MockLanguageModelV3, simulateReadableStream } from 'ai/test' import { ModelClient } from '../src/main/models/ModelClient.ts' import { openaiCompletionsAdapter } from '../src/main/models/protocols/openaiCompletions.ts' import { openaiResponsesAdapter } from '../src/main/models/protocols/openaiResponses.ts' @@ -85,20 +85,41 @@ test('gemini takes a thinking budget, not an effort level', () => { ) }) +test('structured output pins the pre-v6 strictness default for openai-responses', () => { + // AI SDK 6 flips the OpenAI-family `strictJsonSchema` default to true. The + // structured-output schemas use `.optional()` fields, which strict mode rejects + // because every property has to be listed in `required`. + // + // Only openai-responses is affected: it always sends a JSON schema. The + // openai-completions adapter goes through `@ai-sdk/openai-compatible`, which + // only sends a schema when `structuredOutputs` is enabled and otherwise sends + // `json_object`, so its strictness never applies. + assert.deepEqual(openaiResponsesAdapter.structuredOutputProviderOptions?.(base), { + openai: { strictJsonSchema: false } + }) + assert.equal(openaiCompletionsAdapter.structuredOutputProviderOptions?.(base), undefined) + // Anthropic and Gemini structured output is unaffected by that default. + assert.equal(anthropicMessagesAdapter.structuredOutputProviderOptions?.(base), undefined) + assert.equal(googleGenerativeAiAdapter.structuredOutputProviderOptions?.(base), undefined) +}) + /** A client that records what reached the provider. */ function inspectingClient(connection: Partial): { client: ModelClient - model: MockLanguageModelV2 + model: MockLanguageModelV3 } { - const model = new MockLanguageModelV2({ + const model = new MockLanguageModelV3({ doStream: async () => ({ stream: simulateReadableStream({ chunks: [ { type: 'stream-start', warnings: [] }, { type: 'finish', - finishReason: 'stop', - usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 } + } } ] as never[] }) diff --git a/test/streamChat.test.ts b/test/streamChat.test.ts index 00de06f..a0dc4f8 100644 --- a/test/streamChat.test.ts +++ b/test/streamChat.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { MockLanguageModelV2, simulateReadableStream } from 'ai/test' -import type { LanguageModelV2StreamPart } from '@ai-sdk/provider' +import { MockLanguageModelV3, simulateReadableStream } from 'ai/test' +import type { LanguageModelV3StreamPart, LanguageModelV3Usage } from '@ai-sdk/provider' import type { UIMessageChunk } from 'ai' import { ModelClient } from '../src/main/models/ModelClient.ts' import type { ModelConnection } from '../src/shared/types/connection.ts' @@ -38,8 +38,14 @@ const waitFor = async (condition: () => boolean, timeoutMs = 1000): Promise { - const model = new MockLanguageModelV2({ +/** The V3 usage shape: nested totals, not the flat numbers the V2 spec used. */ +const v3Usage = (input: number, output: number): LanguageModelV3Usage => ({ + inputTokens: { total: input, noCache: input, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: output, text: output, reasoning: 0 } +}) + +const clientFor = (chunks: LanguageModelV3StreamPart[]): ModelClient => { + const model = new MockLanguageModelV3({ doStream: async () => ({ stream: simulateReadableStream({ chunks: [{ type: 'stream-start', warnings: [] }, ...chunks] as never[] @@ -54,16 +60,16 @@ const clientFor = (chunks: LanguageModelV2StreamPart[]): ModelClient => { /** Builds a client and hands back the model, so the call args can be inspected. */ const inspectingClient = ( connectionOverride: Partial -): { client: ModelClient; model: MockLanguageModelV2 } => { - const model = new MockLanguageModelV2({ +): { client: ModelClient; model: MockLanguageModelV3 } => { + const model = new MockLanguageModelV3({ doStream: async () => ({ stream: simulateReadableStream({ chunks: [ { type: 'stream-start', warnings: [] }, { type: 'finish', - finishReason: 'stop', - usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 } + finishReason: { unified: 'stop', raw: 'stop' }, + usage: v3Usage(1, 1) } ] as never[] }) @@ -84,8 +90,8 @@ test('the stream is the SDK’s own event stream, not a reduced protocol of ours { type: 'text-end', id: 't1' }, { type: 'finish', - finishReason: 'stop', - usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 } + finishReason: { unified: 'stop', raw: 'stop' }, + usage: v3Usage(1, 2) } ]) @@ -117,8 +123,8 @@ test('a provider error arrives as an event carrying its real message', async () { type: 'text-end', id: 't1' }, { type: 'finish', - finishReason: 'stop', - usage: { inputTokens: 1, outputTokens: 2, totalTokens: 3 } + finishReason: { unified: 'stop', raw: 'stop' }, + usage: v3Usage(1, 2) } ]) @@ -138,7 +144,7 @@ test('a provider error arrives as an event carrying its real message', async () }) test('a transport failure surfaces as a rejection, which the owner turns into an outcome', async () => { - const model = new MockLanguageModelV2({ + const model = new MockLanguageModelV3({ doStream: async () => ({ stream: new ReadableStream({ async start(controller) { @@ -162,7 +168,7 @@ test('the signal the caller passes is the one that cancels the request', async ( // What #145 got wrong: the execution owned a signal that nothing used, so the // provider stream kept running and only the bookkeeping was stopped. let observed: AbortSignal | undefined - const model = new MockLanguageModelV2({ + const model = new MockLanguageModelV3({ doStream: async ({ abortSignal }) => { observed = abortSignal return {