diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e200fd..aa65aac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## 1.0.0 + +### Breaking Changes + +- **Vercel AI SDK v5 support** - This version requires `@ai-sdk/provider` v2.0.0 and `@ai-sdk/provider-utils` v3.0.1 +- Previous versions of Vercel AI SDK (v4) are no longer supported +- Updated `ai` dev dependency to v5.0.0 + +### Changes + +- Refactored `langtail-language-model.ts` for AI SDK v5 compatibility +- Updated `convert-to-openai-chat-messages.ts` for new message format +- Updated `map-langtail-finish-reason.ts` for v5 finish reasons +- Updated `openai-prepare-tools.ts` for v5 tool format +- Removed deprecated `map-openai-chat-logprobs.ts` +- Added comprehensive test coverage for language model and message conversion + ## 0.16.8 - Add GoogleGeminiV1 to ReasoningFormat and update related tests diff --git a/package.json b/package.json index cfc24f8..8a15be7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "langtail", - "version": "0.16.8", + "version": "1.0.0", "description": "", "main": "./Langtail.js", "packageManager": "pnpm@8.15.6", @@ -35,8 +35,9 @@ ], "license": "MIT", "devDependencies": { + "@openrouter/ai-sdk-provider": "^1.2.7", "@types/node": "^20.12.11", - "ai": "^4.3.3", + "ai": "^5.0.0", "copyfiles": "^2.4.1", "fs-extra": "^11.2.0", "jsdom": "^24.1.0", @@ -91,8 +92,8 @@ } }, "dependencies": { - "@ai-sdk/provider": "^1.1.1", - "@ai-sdk/provider-utils": "^2.2.5", + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.1", "@langtail/handlebars-evalless": "^0.1.2", "commander": "^12.1.0", "date-fns": "^3.6.0", @@ -100,7 +101,7 @@ "json-schema-to-zod": "^2.1.0", "openai": "4.82.0", "query-string": "^7.1.3", - "zod": "^3.23.8" + "zod": "^3.25.76" }, "tsup": { "dts": true, diff --git a/playground/openrouter.ts b/playground/openrouter.ts new file mode 100644 index 0000000..3038319 --- /dev/null +++ b/playground/openrouter.ts @@ -0,0 +1,41 @@ +import "dotenv-flow/config" +import { stepCountIs, streamText, tool } from "ai" +import { z } from "zod/v4" + +import { openrouter } from "@openrouter/ai-sdk-provider" + +async function main() { + const result = streamText({ + model: openrouter("google/gemini-3-pro-preview"), + messages: [ + { + role: "user", + content: "What is the weather in Tokyo?", + }, + ], + stopWhen: stepCountIs(5), + onStepFinish: (step) => { + console.log(step.content) + }, + tools: { + weather: tool({ + description: "Get the weather in a location", + inputSchema: z.object({ + location: z.string().describe("The location to get the weather for"), + }), + execute: async ({ location }) => { + return { + location, + temperature: 12, + } + }, + }), + }, + }) + + for await (const chunk of result.fullStream) { + console.log("chunk", chunk) + } +} + +main() diff --git a/playground/vercel.ts b/playground/vercel.ts index 55bf015..c791a5d 100644 --- a/playground/vercel.ts +++ b/playground/vercel.ts @@ -1,86 +1,57 @@ -import 'dotenv-flow/config' -import { generateText, tool, streamText } from 'ai'; -import { langtail } from '../src/vercel-ai'; -import fs from 'fs/promises'; -import { z } from 'zod'; - +import "dotenv-flow/config" +import { stepCountIs, streamText, tool } from "ai" +import { langtail } from "../src/vercel-ai" +import { z } from "zod/v4" async function main() { - const content = await fs.readFile('./playground/text.txt', 'utf-8'); - - const { text, reasoning } = await generateText({ - model: langtail('vtip'), + const result = streamText({ + model: langtail("vtip"), messages: [ { - role: 'user', + role: "user", content: "What is the weather in Tokyo?", }, ], + stopWhen: stepCountIs(5), + onStepFinish: (step) => { + console.log(step.content) + }, tools: { weather: tool({ - description: 'Get the weather in a location', - parameters: z.object({ - location: z.string().describe('The location to get the weather for'), + description: "Get the weather in a location", + inputSchema: z.object({ + location: z.string().describe("The location to get the weather for"), }), - execute: async ({ location }) => { + async execute({ location }) { return { - location, - temperature: 12, + type: "image", + data: "https://stickerapp.co.uk/cdn-assets/images/stickers/608t.png", } }, - }), - }, - }); - const { text: text2, reasoning: reasoning2 } = await generateText({ - model: langtail('vtip'), - providerOptions: { - anthropic: { - thinking: { - budgetTokens: 1025, - type: "enabled", - } - } - }, - messages: [ - { - role: 'user', - content: "What is the weather in Tokyo?", - }, - { - role: 'assistant', - content: text, - }, - { - role: 'user', - content: "What is the weather in Prague?", - }, - ], - tools: { - weather: tool({ - description: 'Get the weather in a location', - parameters: z.object({ - location: z.string().describe('The location to get the weather for'), - }), - execute: async ({ location }) => { + // map to tool result content for LLM consumption: + toModelOutput(result) { return { - location, - temperature: 12, + type: "content", + value: + typeof result === "string" + ? [{ type: "text", text: result }] + : [ + { + type: "media", + data: result.data, + mediaType: "image/png", + }, + ], } }, }), }, - }); - - console.log(text); - console.log(reasoning); - - console.log('--------------------------------'); - - console.log(text2); - console.log(reasoning2); + }) + for await (const chunk of result.fullStream) { + console.log("chunk", chunk) + } } -main(); - +main() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9585c9f..c1874cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,11 +6,11 @@ settings: dependencies: '@ai-sdk/provider': - specifier: ^1.1.1 - version: 1.1.1 + specifier: 2.0.0 + version: 2.0.0 '@ai-sdk/provider-utils': - specifier: ^2.2.5 - version: 2.2.5(zod@3.23.8) + specifier: 3.0.1 + version: 3.0.1(zod@3.25.76) '@langtail/handlebars-evalless': specifier: ^0.1.2 version: 0.1.2 @@ -28,21 +28,24 @@ dependencies: version: 2.1.0 openai: specifier: 4.82.0 - version: 4.82.0(zod@3.23.8) + version: 4.82.0(zod@3.25.76) query-string: specifier: ^7.1.3 version: 7.1.3 zod: - specifier: ^3.23.8 - version: 3.23.8 + specifier: ^3.25.76 + version: 3.25.76 devDependencies: + '@openrouter/ai-sdk-provider': + specifier: ^1.2.7 + version: 1.2.7(ai@5.0.102)(zod@3.25.76) '@types/node': specifier: ^20.12.11 version: 20.12.11 ai: - specifier: ^4.3.3 - version: 4.3.3(zod@3.23.8) + specifier: ^5.0.0 + version: 5.0.102(zod@3.25.76) copyfiles: specifier: ^2.4.1 version: 2.4.1 @@ -70,51 +73,48 @@ devDependencies: packages: - /@ai-sdk/provider-utils@2.2.5(zod@3.23.8): - resolution: {integrity: sha512-9WNqJFsqrkSoSxM7eQEh8TdpMD+hZ+hjsZH8WuvEOxilRo1WAVrnrGwm160OG3IZVNlegRNaPgjYGm/VH5uwUQ==} + /@ai-sdk/gateway@2.0.15(zod@3.25.76): + resolution: {integrity: sha512-i1YVKzC1dg9LGvt+GthhD7NlRhz9J4+ZRj3KELU14IZ/MHPsOBiFeEoCCIDLR+3tqT8/+5nIsK3eZ7DFRfMfdw==} engines: {node: '>=18'} peerDependencies: - zod: ^3.23.8 + zod: ^3.25.76 || ^4.1.8 dependencies: - '@ai-sdk/provider': 1.1.1 - nanoid: 3.3.8 - secure-json-parse: 2.7.0 - zod: 3.23.8 + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.17(zod@3.25.76) + '@vercel/oidc': 3.0.5 + zod: 3.25.76 + dev: true - /@ai-sdk/provider@1.1.1: - resolution: {integrity: sha512-oGto9sz6CSn2Pa6uLPBiCrjphdRIwW/ua9g8W0mzt6o7jPkgdWwCC8XHAKLlfLxRAwHs2lFHc9i+S/cij9LnDA==} + /@ai-sdk/provider-utils@3.0.1(zod@3.25.76): + resolution: {integrity: sha512-/iP1sKc6UdJgGH98OCly7sWJKv+J9G47PnTjIj40IJMUQKwDrUMyf7zOOfRtPwSuNifYhSoJQ4s1WltI65gJ/g==} engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4 dependencies: - json-schema: 0.4.0 + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + zod-to-json-schema: 3.24.5(zod@3.25.76) + dev: false - /@ai-sdk/react@1.2.7(zod@3.23.8): - resolution: {integrity: sha512-XJAc6qpJagfcO8fH25//4ZStwV79jqE0orQN2QI5gwk5Wq+kqQHznr4LCBvt+MtYPpOQDTdcY5vr0IvzicBVwA==} + /@ai-sdk/provider-utils@3.0.17(zod@3.25.76): + resolution: {integrity: sha512-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw==} engines: {node: '>=18'} peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.23.8 - peerDependenciesMeta: - zod: - optional: true + zod: ^3.25.76 || ^4.1.8 dependencies: - '@ai-sdk/provider-utils': 2.2.5(zod@3.23.8) - '@ai-sdk/ui-utils': 1.2.6(zod@3.23.8) - swr: 2.3.3 - throttleit: 2.1.0 - zod: 3.23.8 + '@ai-sdk/provider': 2.0.0 + '@standard-schema/spec': 1.0.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 dev: true - /@ai-sdk/ui-utils@1.2.6(zod@3.23.8): - resolution: {integrity: sha512-jO1tr6wtQKzHUEHq49ev1Hf66rGHFpEAbGLTJfLPkDH3a7O052oFGTvNloRo26gzsr0906kgGNelTcenFMH87A==} + /@ai-sdk/provider@2.0.0: + resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} engines: {node: '>=18'} - peerDependencies: - zod: ^3.23.8 dependencies: - '@ai-sdk/provider': 1.1.1 - '@ai-sdk/provider-utils': 2.2.5(zod@3.23.8) - zod: 3.23.8 - zod-to-json-schema: 3.24.5(zod@3.23.8) - dev: true + json-schema: 0.4.0 /@esbuild/aix-ppc64@0.19.12: resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} @@ -402,6 +402,24 @@ packages: fastq: 1.17.1 dev: true + /@openrouter/ai-sdk-provider@1.2.7(ai@5.0.102)(zod@3.25.76): + resolution: {integrity: sha512-Ra9/93QJ7nKheFlB9ZbiTgpDYeEUA7AuOLqjUIzidKVcVWm4wbHmOOBU5y4rIUgQ1wQNfwRsBPDtGyVMTPeyBg==} + engines: {node: '>=18'} + peerDependencies: + ai: ^5.0.0 + zod: ^3.24.1 || ^v4 + dependencies: + '@openrouter/sdk': 0.1.27 + ai: 5.0.102(zod@3.25.76) + zod: 3.25.76 + dev: true + + /@openrouter/sdk@0.1.27: + resolution: {integrity: sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ==} + dependencies: + zod: 3.25.76 + dev: true + /@opentelemetry/api@1.9.0: resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -522,9 +540,8 @@ packages: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true - /@types/diff-match-patch@1.0.36: - resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} - dev: true + /@standard-schema/spec@1.0.0: + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} @@ -548,6 +565,11 @@ packages: dependencies: undici-types: 5.26.5 + /@vercel/oidc@3.0.5: + resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==} + engines: {node: '>= 20'} + dev: true + /@vitest/expect@1.6.0: resolution: {integrity: sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==} dependencies: @@ -621,23 +643,17 @@ packages: humanize-ms: 1.2.1 dev: false - /ai@4.3.3(zod@3.23.8): - resolution: {integrity: sha512-9A/0SHSferCA93yaJzYxkBTvAXdlNxCpE+ChVJJsNNUNWRwroIfe1JQitDofw30ZTeH7hAiWECb1lcOc3o3AHQ==} + /ai@5.0.102(zod@3.25.76): + resolution: {integrity: sha512-snRK3nS5DESOjjpq7S74g8YszWVMzjagfHqlJWZsbtl9PyOS+2XUd8dt2wWg/jdaq/jh0aU66W1mx5qFjUQyEg==} engines: {node: '>=18'} peerDependencies: - react: ^18 || ^19 || ^19.0.0-rc - zod: ^3.23.8 - peerDependenciesMeta: - react: - optional: true + zod: ^3.25.76 || ^4.1.8 dependencies: - '@ai-sdk/provider': 1.1.1 - '@ai-sdk/provider-utils': 2.2.5(zod@3.23.8) - '@ai-sdk/react': 1.2.7(zod@3.23.8) - '@ai-sdk/ui-utils': 1.2.6(zod@3.23.8) + '@ai-sdk/gateway': 2.0.15(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.17(zod@3.25.76) '@opentelemetry/api': 1.9.0 - jsondiffpatch: 0.6.0 - zod: 3.23.8 + zod: 3.25.76 dev: true /ansi-regex@5.0.1: @@ -748,11 +764,6 @@ packages: type-detect: 4.0.8 dev: true - /chalk@5.4.1: - resolution: {integrity: sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==} - engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - dev: true - /check-error@1.0.3: resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} dependencies: @@ -890,15 +901,6 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - /dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - dev: true - - /diff-match-patch@1.0.5: - resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} - dev: true - /diff-sequences@29.6.3: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -987,6 +989,10 @@ packages: engines: {node: '>=6'} dev: false + /eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + /execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -1366,16 +1372,6 @@ packages: resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==} dev: true - /jsondiffpatch@0.6.0: - resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} - engines: {node: ^18.0.0 || >=20.0.0} - hasBin: true - dependencies: - '@types/diff-match-patch': 1.0.36 - chalk: 5.4.1 - diff-match-patch: 1.0.5 - dev: true - /jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} dependencies: @@ -1517,6 +1513,7 @@ packages: resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + dev: true /neo-async@2.6.2: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} @@ -1602,7 +1599,7 @@ packages: mimic-fn: 4.0.0 dev: true - /openai@4.82.0(zod@3.23.8): + /openai@4.82.0(zod@3.25.76): resolution: {integrity: sha512-1bTxOVGZuVGsKKUWbh3BEwX1QxIXUftJv+9COhhGGVDTFwiaOd4gWsMynF2ewj1mg6by3/O+U8+EEHpWRdPaJg==} hasBin: true peerDependencies: @@ -1621,7 +1618,7 @@ packages: form-data-encoder: 1.7.2 formdata-node: 4.4.1 node-fetch: 2.7.0 - zod: 3.23.8 + zod: 3.25.76 transitivePeerDependencies: - encoding dev: false @@ -1875,9 +1872,6 @@ packages: xmlchars: 2.2.0 dev: true - /secure-json-parse@2.7.0: - resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} - /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2015,15 +2009,6 @@ packages: ts-interface-checker: 0.1.13 dev: true - /swr@2.3.3: - resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==} - peerDependencies: - react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - dependencies: - dequal: 2.0.3 - use-sync-external-store: 1.5.0 - dev: true - /symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} dev: true @@ -2041,11 +2026,6 @@ packages: any-promise: 1.3.0 dev: true - /throttleit@2.1.0: - resolution: {integrity: sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==} - engines: {node: '>=18'} - dev: true - /through2@2.0.5: resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} dependencies: @@ -2200,12 +2180,6 @@ packages: requires-port: 1.0.0 dev: true - /use-sync-external-store@1.5.0: - resolution: {integrity: sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - dev: true - /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true @@ -2488,13 +2462,13 @@ packages: engines: {node: '>=12.20'} dev: true - /zod-to-json-schema@3.24.5(zod@3.23.8): + /zod-to-json-schema@3.24.5(zod@3.25.76): resolution: {integrity: sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==} peerDependencies: zod: ^3.24.1 dependencies: - zod: 3.23.8 - dev: true + zod: 3.25.76 + dev: false - /zod@3.23.8: - resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + /zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} diff --git a/src/dataSchema.ts b/src/dataSchema.ts index b50c3c5..14b34c1 100644 --- a/src/dataSchema.ts +++ b/src/dataSchema.ts @@ -1,4 +1,4 @@ -import z from "zod" +import { z } from "zod/v4" // Define the schema for the data const choiceStreamedSchema = z.object({ @@ -10,7 +10,7 @@ const choiceStreamedSchema = z.object({ token: z.string(), bytes: z.array(z.number()), logprob: z.number(), - top_logprobs: z.record(z.number()), + top_logprobs: z.record(z.string(), z.number()), }), ) .nullish(), diff --git a/src/reasoning-details-schema.ts b/src/reasoning-details-schema.ts index 4f8159b..115f8ea 100644 --- a/src/reasoning-details-schema.ts +++ b/src/reasoning-details-schema.ts @@ -1,4 +1,4 @@ -import { z } from "zod" +import { z } from "zod/v4" export const ReasoningFormat = { Unknown: "unknown", diff --git a/src/schemas.ts b/src/schemas.ts index cbf8f6a..13a4150 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,4 +1,4 @@ -import { z, ZodSchema } from "zod" +import { z, ZodSchema } from "zod/v4" import { Environment, LangtailEnvironment, PromptSlug, Version } from "./types" import type { ChatCompletionCreateParamsStreaming } from "openai/resources/index" import type { ChatCompletionCreateParamsNonStreaming } from "openai/resources/chat/completions" @@ -244,7 +244,7 @@ export const MessageSchema = z.object({ const FunctionSchema = z.object({ name: z.string(), description: z.string(), - parameters: z.record(z.unknown()), + parameters: z.record(z.string(), z.unknown()), id: z.string().optional(), }) satisfies z.ZodType diff --git a/src/vercel-ai/convert-to-openai-chat-messages.spec.ts b/src/vercel-ai/convert-to-openai-chat-messages.spec.ts new file mode 100644 index 0000000..2afda67 --- /dev/null +++ b/src/vercel-ai/convert-to-openai-chat-messages.spec.ts @@ -0,0 +1,647 @@ +import { describe, expect, it } from "vitest" +import { convertToOpenAIChatMessages } from "./convert-to-openai-chat-messages" +import type { LanguageModelV2Prompt } from "@ai-sdk/provider" + +describe("convertToOpenAIChatMessages", () => { + describe("system messages", () => { + it("should convert a simple system message", () => { + const prompt: LanguageModelV2Prompt = [ + { role: "system", content: "You are a helpful assistant." }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result).toEqual([ + { role: "system", content: "You are a helpful assistant." }, + ]) + }) + + it("should add cache_enabled for anthropic cache control", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "system", + content: "You are a helpful assistant.", + providerOptions: { anthropic: { cacheControl: true } }, + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toHaveProperty("cache_enabled", true) + }) + }) + + describe("user messages", () => { + it("should convert a simple text user message", () => { + const prompt: LanguageModelV2Prompt = [ + { role: "user", content: [{ type: "text", text: "Hello!" }] }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result).toEqual([{ role: "user", content: "Hello!" }]) + }) + + it("should convert user message with image file part", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "user", + content: [ + { type: "text", text: "What's in this image?" }, + { + type: "file", + data: "https://example.com/image.png", + mediaType: "image/png", + }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toEqual({ + role: "user", + content: [ + { type: "text", text: "What's in this image?" }, + { + type: "image_url", + image_url: { url: "https://example.com/image.png", detail: undefined }, + }, + ], + }) + }) + }) + + describe("assistant messages with reasoning", () => { + it("should convert assistant message with basic reasoning parts", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Let me think about this..." }, + { type: "text", text: "The answer is 42." }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toEqual({ + role: "assistant", + content: "The answer is 42.", + reasoning: [{ type: "text", text: "Let me think about this..." }], + reasoning_details: undefined, + tool_calls: undefined, + }) + }) + + it("should preserve reasoning with signature from providerOptions (Anthropic format)", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Let me think about this..." }, + { + type: "tool-call", + toolCallId: "call_123", + toolName: "weather", + input: { location: "Tokyo" }, + }, + ], + providerOptions: { + langtail: { + reasoning: [ + { + type: "text", + text: "Let me think about this...", + signature: "EtcCCkYI...", + }, + ], + }, + }, + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toMatchObject({ + role: "assistant", + reasoning: [ + { + type: "text", + text: "Let me think about this...", + signature: "EtcCCkYI...", + }, + ], + tool_calls: [ + { + id: "call_123", + type: "function", + function: { name: "weather", arguments: '{"location":"Tokyo"}' }, + }, + ], + }) + }) + + it("should preserve reasoning with signature from tool-call providerOptions", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking..." }, + { + type: "tool-call", + toolCallId: "call_456", + toolName: "search", + input: { query: "test" }, + providerOptions: { + langtail: { + reasoning: [ + { + type: "text", + text: "Thinking...", + signature: "sig456", + }, + ], + }, + }, + }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toMatchObject({ + role: "assistant", + reasoning: [ + { type: "text", text: "Thinking...", signature: "sig456" }, + ], + }) + }) + + it("should preserve reasoning_details from providerOptions (OpenRouter format)", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Step by step reasoning" }, + { + type: "tool-call", + toolCallId: "call_789", + toolName: "calculate", + input: { expression: "2+2" }, + }, + ], + providerOptions: { + langtail: { + reasoning_details: [ + { + type: "reasoning.text", + text: "Step by step reasoning", + signature: "openrouter_sig", + }, + ], + }, + }, + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toMatchObject({ + role: "assistant", + reasoning_details: [ + { + type: "reasoning.text", + text: "Step by step reasoning", + signature: "openrouter_sig", + }, + ], + }) + }) + + it("should preserve both reasoning and reasoning_details when both present", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "My thinking" }, + { + type: "tool-call", + toolCallId: "call_abc", + toolName: "api", + input: {}, + }, + ], + providerOptions: { + langtail: { + reasoning: [ + { type: "text", text: "My thinking", signature: "anthro_sig" }, + ], + reasoning_details: [ + { type: "reasoning.text", text: "My thinking", signature: "or_sig" }, + ], + }, + }, + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toMatchObject({ + role: "assistant", + reasoning: [ + { type: "text", text: "My thinking", signature: "anthro_sig" }, + ], + reasoning_details: [ + { type: "reasoning.text", text: "My thinking", signature: "or_sig" }, + ], + }) + }) + + it("should handle tool calls with string input", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_str", + toolName: "func", + input: '{"already":"stringified"}', + }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toMatchObject({ + role: "assistant", + tool_calls: [ + { + id: "call_str", + type: "function", + function: { + name: "func", + arguments: '{"already":"stringified"}', + }, + }, + ], + }) + }) + }) + + describe("tool messages", () => { + it("should convert tool result with text output", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_123", + toolName: "weather", + output: { type: "text", value: "Sunny, 25°C" }, + }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_123", + content: "Sunny, 25°C", + }) + }) + + it("should convert tool result with JSON output", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_456", + toolName: "api", + output: { + type: "json", + value: { temperature: 25, condition: "sunny" }, + }, + }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_456", + content: '{"temperature":25,"condition":"sunny"}', + }) + }) + + it("should convert tool result with content containing image URL", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_img", + toolName: "image_gen", + output: { + type: "content", + value: [ + { type: "text", text: "Here's the image:" }, + { + type: "media", + data: "https://example.com/image.png", + mediaType: "image/png", + }, + ], + }, + }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toMatchObject({ + role: "tool", + tool_call_id: "call_img", + content: [ + { type: "text", text: "Here's the image:" }, + { + type: "image_url", + image_url: { url: "https://example.com/image.png" }, + }, + ], + }) + }) + + it("should convert tool result with content containing base64 image", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_b64", + toolName: "screenshot", + output: { + type: "content", + value: [ + { + type: "media", + data: "iVBORw0KGgoAAAANSUhEUg==", + mediaType: "image/png", + }, + ], + }, + }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toMatchObject({ + role: "tool", + content: [ + { + type: "image_url", + image_url: { + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==", + }, + }, + ], + }) + }) + + it("should pass through data URLs unchanged", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_data", + toolName: "gen", + output: { + type: "content", + value: [ + { + type: "media", + data: "data:image/png;base64,abc123", + mediaType: "image/png", + }, + ], + }, + }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toMatchObject({ + role: "tool", + content: [ + { + type: "image_url", + image_url: { url: "data:image/png;base64,abc123" }, + }, + ], + }) + }) + + it("should convert error-text output", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_err", + toolName: "api", + output: { type: "error-text", value: "Connection failed" }, + }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_err", + content: "Connection failed", + }) + }) + + it("should convert error-json output", () => { + const prompt: LanguageModelV2Prompt = [ + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call_err_json", + toolName: "api", + output: { + type: "error-json", + value: { code: 500, message: "Internal error" }, + }, + }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + expect(result[0]).toEqual({ + role: "tool", + tool_call_id: "call_err_json", + content: '{"code":500,"message":"Internal error"}', + }) + }) + }) + + describe("full conversation flow with reasoning preservation", () => { + it("should preserve Anthropic reasoning signature through multi-turn conversation", () => { + const prompt: LanguageModelV2Prompt = [ + { role: "system", content: "You are a helpful weather assistant." }, + { role: "user", content: [{ type: "text", text: "What's the weather in Tokyo?" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "User wants weather for Tokyo..." }, + { + type: "tool-call", + toolCallId: "toolu_01ABC", + toolName: "weather", + input: { location: "Tokyo" }, + providerOptions: { + langtail: { + reasoning: [ + { + type: "text", + text: "User wants weather for Tokyo...", + signature: "EtcCCkYIChgCKkDqzqkY...", + }, + ], + }, + }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "toolu_01ABC", + toolName: "weather", + output: { type: "json", value: { temperature: 22, condition: "cloudy" } }, + }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + // Verify the assistant message has the signature preserved + const assistantMessage = result.find((m) => m.role === "assistant") + expect(assistantMessage).toMatchObject({ + role: "assistant", + reasoning: [ + { + type: "text", + text: "User wants weather for Tokyo...", + signature: "EtcCCkYIChgCKkDqzqkY...", + }, + ], + tool_calls: [ + { + id: "toolu_01ABC", + type: "function", + function: { name: "weather", arguments: '{"location":"Tokyo"}' }, + }, + ], + }) + + // Verify the tool result is properly converted + const toolMessage = result.find((m) => m.role === "tool") + expect(toolMessage).toEqual({ + role: "tool", + tool_call_id: "toolu_01ABC", + content: '{"temperature":22,"condition":"cloudy"}', + }) + }) + + it("should preserve OpenRouter reasoning_details through multi-turn conversation", () => { + const prompt: LanguageModelV2Prompt = [ + { role: "user", content: [{ type: "text", text: "What's the weather?" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Analyzing weather request..." }, + { + type: "tool-call", + toolCallId: "gen-123", + toolName: "get_weather", + input: { city: "NYC" }, + providerOptions: { + langtail: { + reasoning_details: [ + { + type: "reasoning.text", + text: "Analyzing weather request...", + signature: "sha256:abc123", + id: "reasoning-1", + format: "anthropic-claude-v1", + }, + ], + }, + }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "gen-123", + toolName: "get_weather", + output: { type: "text", value: "Sunny, 75°F" }, + }, + ], + }, + ] + + const result = convertToOpenAIChatMessages({ prompt }) + + const assistantMessage = result.find((m) => m.role === "assistant") + expect(assistantMessage).toMatchObject({ + role: "assistant", + reasoning_details: [ + { + type: "reasoning.text", + text: "Analyzing weather request...", + signature: "sha256:abc123", + id: "reasoning-1", + format: "anthropic-claude-v1", + }, + ], + }) + }) + }) +}) + diff --git a/src/vercel-ai/convert-to-openai-chat-messages.ts b/src/vercel-ai/convert-to-openai-chat-messages.ts index 3d78d2a..305b9b0 100644 --- a/src/vercel-ai/convert-to-openai-chat-messages.ts +++ b/src/vercel-ai/convert-to-openai-chat-messages.ts @@ -1,5 +1,5 @@ import { - LanguageModelV1Prompt, + LanguageModelV2Prompt, UnsupportedFunctionalityError, } from "@ai-sdk/provider" import { convertUint8ArrayToBase64 } from "@ai-sdk/provider-utils" @@ -13,7 +13,7 @@ import { ReasoningDetail } from "../reasoning-details-schema" export function convertToOpenAIChatMessages({ prompt, }: { - prompt: LanguageModelV1Prompt + prompt: LanguageModelV2Prompt }): OpenAIChatPrompt { const messages: OpenAIChatPrompt = [] @@ -26,9 +26,9 @@ export function convertToOpenAIChatMessages({ messages.push(message) } - for (const { role, content, providerMetadata } of prompt) { + for (const { role, content, providerOptions } of prompt) { const anthropicCacheControl = Boolean( - providerMetadata?.anthropic?.cacheControl, + providerOptions?.anthropic?.cacheControl, ) switch (role) { @@ -54,25 +54,21 @@ export function convertToOpenAIChatMessages({ case "text": { return { type: "text", text: part.text } } - case "image": { - return { - type: "image_url", - image_url: { - url: - part.image instanceof URL - ? part.image.toString() - : `data:${ - part.mimeType ?? "image/jpeg" - };base64,${convertUint8ArrayToBase64(part.image)}`, - - // OpenAI specific extension: image detail - detail: part.providerMetadata?.openai?.imageDetail, - }, - } - } case "file": { + // Handle file parts - in V2, images are now file parts with mediaType + if (part.mediaType?.startsWith("image/")) { + const url = getFileUrl(part) + return { + type: "image_url", + image_url: { + url, + // OpenAI specific extension: image detail + detail: part.providerOptions?.openai?.imageDetail, + }, + } + } throw new UnsupportedFunctionalityError({ - functionality: "File content parts in user messages", + functionality: `File content parts with mediaType ${part.mediaType} in user messages`, }) } } @@ -93,29 +89,40 @@ export function convertToOpenAIChatMessages({ function: { name: string; arguments: string } }> = [] - // Check if providerMetadata contains preserved reasoning_details - const langtailMetadata = providerMetadata?.langtail as - | { reasoning_details?: ReasoningDetail[] } + // Check if providerOptions contains preserved reasoning data + const langtailMetadata = providerOptions?.langtail as + | { + reasoning_details?: ReasoningDetail[] + reasoning?: Array<{ + type: string + text?: string + signature?: string + }> + } | undefined + if (langtailMetadata?.reasoning_details) { reasoningDetails = langtailMetadata.reasoning_details } + // If we have preserved reasoning with signature (from Anthropic), use it directly + if ( + langtailMetadata?.reasoning && + langtailMetadata.reasoning.length > 0 + ) { + reasoning = langtailMetadata.reasoning as MessageReasoning[] + } + for (const part of content) { switch (part.type) { case "reasoning": { - reasoning.push({ - type: "text", - text: part.text, - signature: part.signature, - }) - break - } - case "redacted-reasoning": { - reasoning.push({ - type: "redacted", - data: part.data, - }) + // Only add reasoning from parts if we don't have preserved reasoning with signature + if (!langtailMetadata?.reasoning) { + reasoning.push({ + type: "text", + text: part.text, + }) + } break } @@ -124,16 +131,55 @@ export function convertToOpenAIChatMessages({ break } case "tool-call": { + // Check for reasoning data in tool call providerOptions + const partLangtail = part.providerOptions?.langtail as + | { + reasoning_details?: ReasoningDetail[] + reasoning?: Array<{ + type: string + text?: string + signature?: string + }> + } + | undefined + + if (partLangtail?.reasoning_details) { + if (!reasoningDetails) { + reasoningDetails = [] + } + reasoningDetails.push(...partLangtail.reasoning_details) + } + + // Use preserved reasoning with signature if available + if ( + partLangtail?.reasoning && + partLangtail.reasoning.length > 0 + ) { + reasoning = partLangtail.reasoning as MessageReasoning[] + } + toolCalls.push({ id: part.toolCallId, type: "function", function: { name: part.toolName, - arguments: JSON.stringify(part.args), + // V2 uses `input` (unknown) instead of `args` (string) + arguments: + typeof part.input === "string" + ? part.input + : JSON.stringify(part.input), }, }) break } + case "file": { + // Skip file parts in assistant messages for now + break + } + case "tool-result": { + // Tool results in assistant content are handled separately + break + } default: { const _exhaustiveCheck: any = part @@ -157,45 +203,7 @@ export function convertToOpenAIChatMessages({ case "tool": { for (const toolResponse of content) { - let toolContent: string | Array - - // Check if result is already in message array format - if ( - Array.isArray(toolResponse.result) && - toolResponse.result.length > 0 && - toolResponse.result.every( - (item: any) => - typeof item === "object" && - item !== null && - "type" in item && - ["text", "image_url"].includes(item.type), - ) - ) { - // Handle as content array (supports images and text) - toolContent = toolResponse.result.map((part: any) => { - switch (part.type) { - case "text": { - return { type: "text", text: part.text } - } - case "image_url": { - return { - type: "image_url", - image_url: { - url: part.image_url.url, - }, - } - } - default: { - throw new Error( - `Unsupported tool result content type: ${part.type}`, - ) - } - } - }) - } else { - // Fall back to JSON string for backward compatibility - toolContent = JSON.stringify(toolResponse.result) - } + const toolContent = getToolResultContent(toolResponse.output) addMessage( { @@ -218,3 +226,70 @@ export function convertToOpenAIChatMessages({ return messages } + +// Helper to convert V2 file part to URL +function getFileUrl(part: { + data: Uint8Array | string | URL + mediaType: string +}): string { + if (part.data instanceof URL) { + return part.data.toString() + } + if (typeof part.data === "string") { + // Check if it's already a data URL or regular URL + if ( + part.data.startsWith("data:") || + part.data.startsWith("http://") || + part.data.startsWith("https://") + ) { + return part.data + } + // Assume base64 string + return `data:${part.mediaType};base64,${part.data}` + } + // Uint8Array + return `data:${part.mediaType};base64,${convertUint8ArrayToBase64(part.data)}` +} + +// Helper to convert V2 tool result output to content +function getToolResultContent( + output: + | { type: "text"; value: string } + | { type: "json"; value: unknown } + | { type: "error-text"; value: string } + | { type: "error-json"; value: unknown } + | { + type: "content" + value: Array< + | { type: "text"; text: string } + | { type: "media"; data: string; mediaType: string } + > + }, +): string | Array { + switch (output.type) { + case "text": + case "error-text": + return output.value + case "json": + case "error-json": + return JSON.stringify(output.value) + case "content": + return output.value.map((item) => { + if (item.type === "text") { + return { type: "text" as const, text: item.text } + } + // media type - support both URLs and base64 data + const data = item.data + const isUrl = + data.startsWith("http://") || + data.startsWith("https://") || + data.startsWith("data:") + return { + type: "image_url" as const, + image_url: { + url: isUrl ? data : `data:${item.mediaType};base64,${data}`, + }, + } + }) + } +} diff --git a/src/vercel-ai/langtail-language-model.spec.ts b/src/vercel-ai/langtail-language-model.spec.ts new file mode 100644 index 0000000..00e275f --- /dev/null +++ b/src/vercel-ai/langtail-language-model.spec.ts @@ -0,0 +1,384 @@ +import { describe, expect, it, vi, beforeEach } from "vitest" + +/** + * Tests for reasoning preservation in the Langtail language model. + * + * These tests verify that reasoning data (including signatures) is properly + * accumulated and preserved through streaming for multi-turn conversations. + * + * Key scenarios: + * 1. Anthropic direct: reasoning comes via delta.reasoning with signature at end + * 2. OpenRouter/Gemini: reasoning comes via delta.reasoning_details + */ + +describe("consolidateReasoningItems", () => { + // Test the consolidation logic directly + const consolidateReasoningItems = ( + items: Array<{ type: string; text?: string; signature?: string }>, + ): Array<{ type: string; text?: string; signature?: string }> => { + let fullText = "" + let signature: string | undefined + + for (const item of items) { + if (item.text) { + fullText += item.text + } + if (item.signature) { + signature = item.signature + } + } + + if (signature) { + return [{ type: "text", text: fullText, signature }] + } else if (fullText) { + return [{ type: "text", text: fullText }] + } + return [] + } + + it("should consolidate multiple text chunks into single block", () => { + const items = [ + { type: "text", text: "First part. " }, + { type: "text", text: "Second part. " }, + { type: "text", text: "Third part." }, + ] + + const result = consolidateReasoningItems(items) + + expect(result).toEqual([ + { type: "text", text: "First part. Second part. Third part." }, + ]) + }) + + it("should preserve signature from final chunk", () => { + const items = [ + { type: "text", text: "The user is asking for" }, + { type: "text", text: " the weather in Tokyo." }, + { type: "text", text: "" }, + { type: "text", signature: "EtcCCkYIChgCKkDqzqkY..." }, + ] + + const result = consolidateReasoningItems(items) + + expect(result).toEqual([ + { + type: "text", + text: "The user is asking for the weather in Tokyo.", + signature: "EtcCCkYIChgCKkDqzqkY...", + }, + ]) + }) + + it("should handle empty items array", () => { + const result = consolidateReasoningItems([]) + expect(result).toEqual([]) + }) + + it("should handle items with only signature (no text)", () => { + const items = [{ type: "text", signature: "sig123" }] + + const result = consolidateReasoningItems(items) + + expect(result).toEqual([{ type: "text", text: "", signature: "sig123" }]) + }) + + it("should handle real Anthropic streaming pattern", () => { + // This simulates the actual streaming chunks from Anthropic + const items = [ + { type: "text", text: "The user is asking for" }, + { type: "text", text: " the weather in Tokyo. I have" }, + { type: "text", text: " access to a weather function that takes" }, + { type: "text", text: " a location parameter. Tokyo is a" }, + { type: "text", text: " clear location, so I can make this" }, + { type: "text", text: " function call." }, + { type: "text", text: "" }, + { + type: "text", + signature: + "EtcCCkYIChgCKkDqzqkYkRluxSkm4WQYpSIx4QPFf3dR3pf+6UuRqiBndx7Y80qRb7yjo/tcCdc13WWyMa4FB4Da8TcNS8kpbR3iEgyJexxFNUBOYH1oc6EaDPMx8InYUprM27bUXSIwGaxj/PSWAuF2OrQoRpmetERVHeqiOnJQy5ikPc7yx4+NBgoebM4kytQdtoJ3qwNFKr4BWd+ojQ8ZEwFjzjTZb2pFu0NIhKWr60YEkYIk7gk1wf/GkjvHiJ3i9i2ZJc/VkEStH0okJLvLUCIFAQCo/SncCyDrBnyKb4K5+rPnMTYWGZlg7JR3gpOQiqYVsNUQIkAYMBos7ct4EA2ON0YEpg7HPoLLpe92mJgZcxpxch+b0/48ClTtVREPkNCcOLdQ+x2Oc8Ituqqml8LQTblDRqaVU0RoeQ/bN+x9AtCSTnBar6VnJ/oeVYr0nwgWel9SaBgB", + }, + ] + + const result = consolidateReasoningItems(items) + + expect(result).toHaveLength(1) + expect(result[0].type).toBe("text") + expect(result[0].text).toBe( + "The user is asking for the weather in Tokyo. I have access to a weather function that takes a location parameter. Tokyo is a clear location, so I can make this function call.", + ) + expect(result[0].signature).toMatch(/^EtcCCkYI/) + }) +}) + +describe("getReasoningProviderMetadata", () => { + // Test the metadata builder logic + const getReasoningProviderMetadata = ( + accumulatedReasoningDetails: Array, + accumulatedReasoningItems: Array<{ + type: string + text?: string + signature?: string + }>, + ) => { + const consolidateReasoningItems = ( + items: Array<{ type: string; text?: string; signature?: string }>, + ): Array<{ type: string; text?: string; signature?: string }> => { + let fullText = "" + let signature: string | undefined + for (const item of items) { + if (item.text) fullText += item.text + if (item.signature) signature = item.signature + } + if (signature) return [{ type: "text", text: fullText, signature }] + if (fullText) return [{ type: "text", text: fullText }] + return [] + } + + const hasReasoningDetails = accumulatedReasoningDetails.length > 0 + const hasReasoningItems = accumulatedReasoningItems.length > 0 + + if (!hasReasoningDetails && !hasReasoningItems) { + return undefined + } + + const langtailMeta: Record = {} + + if (hasReasoningDetails) { + langtailMeta.reasoning_details = accumulatedReasoningDetails + } + + if (hasReasoningItems) { + langtailMeta.reasoning = consolidateReasoningItems(accumulatedReasoningItems) + } + + return { langtail: langtailMeta } + } + + it("should return undefined when no reasoning data", () => { + const result = getReasoningProviderMetadata([], []) + expect(result).toBeUndefined() + }) + + it("should include reasoning_details for OpenRouter format", () => { + const reasoningDetails = [ + { type: "reasoning.text", text: "Thinking...", signature: "sig" }, + ] + + const result = getReasoningProviderMetadata(reasoningDetails, []) + + expect(result).toEqual({ + langtail: { + reasoning_details: reasoningDetails, + }, + }) + }) + + it("should include reasoning for Anthropic format", () => { + const reasoningItems = [ + { type: "text", text: "Thinking..." }, + { type: "text", signature: "sig123" }, + ] + + const result = getReasoningProviderMetadata([], reasoningItems) + + expect(result).toEqual({ + langtail: { + reasoning: [{ type: "text", text: "Thinking...", signature: "sig123" }], + }, + }) + }) + + it("should include both formats when both present", () => { + const reasoningDetails = [{ type: "reasoning.text", text: "Detail" }] + const reasoningItems = [ + { type: "text", text: "Item" }, + { type: "text", signature: "sig" }, + ] + + const result = getReasoningProviderMetadata(reasoningDetails, reasoningItems) + + expect(result).toEqual({ + langtail: { + reasoning_details: reasoningDetails, + reasoning: [{ type: "text", text: "Item", signature: "sig" }], + }, + }) + }) +}) + +describe("Anthropic extended thinking preservation", () => { + it("should describe the required format for Anthropic", () => { + // This test documents the expected format for Anthropic's extended thinking + const expectedAssistantMessageFormat = { + role: "assistant", + content: "", + reasoning: [ + { + type: "text", + text: "The user is asking for the weather in Tokyo. I should call the weather function.", + signature: "EtcCCkYI...", // This is required for Anthropic! + }, + ], + tool_calls: [ + { + id: "toolu_01ABC", + type: "function", + function: { + name: "weather", + arguments: '{"location":"Tokyo"}', + }, + }, + ], + } + + // Verify the structure has all required fields + expect(expectedAssistantMessageFormat.reasoning[0]).toHaveProperty("signature") + expect(expectedAssistantMessageFormat.reasoning[0].type).toBe("text") + }) + + it("should describe the error that occurs without signature", () => { + // This documents the error that Anthropic returns without signature + const expectedError = { + message: + "messages.1.content.0.type: Expected `thinking` or `redacted_thinking`, but found `tool_use`. When `thinking` is enabled, a final `assistant` message must start with a thinking block (preceeding the lastmost set of `tool_use` and `tool_result` blocks).", + } + + expect(expectedError.message).toContain("thinking") + expect(expectedError.message).toContain("tool_use") + }) +}) + +describe("OpenRouter reasoning_details preservation", () => { + it("should describe the required format for OpenRouter/Gemini", () => { + // This test documents the expected format for OpenRouter + const expectedAssistantMessageFormat = { + role: "assistant", + content: "", + reasoning_details: [ + { + type: "reasoning.text", + text: "Analyzing the request...", + signature: "sha256:abc123", + id: "reasoning-1", + format: "anthropic-claude-v1", + }, + ], + tool_calls: [ + { + id: "tool_123", + type: "function", + function: { + name: "weather", + arguments: '{"location":"Tokyo"}', + }, + }, + ], + } + + expect(expectedAssistantMessageFormat.reasoning_details[0]).toHaveProperty( + "type", + "reasoning.text", + ) + }) + + it("should describe the error that occurs without reasoning_details", () => { + // This documents the error from OpenRouter/Gemini without reasoning preservation + const expectedError = { + message: + 'Unable to submit request because function call `default_api:weather` in the 2. content block is missing a `thought_signature`.', + } + + expect(expectedError.message).toContain("thought_signature") + }) +}) + +describe("Stream chunk accumulation patterns", () => { + it("should accumulate reasoning from delta.reasoning (Anthropic)", () => { + // Simulates the pattern of chunks from Anthropic + const chunks = [ + { delta: { reasoning: { type: "text", text: "First " } } }, + { delta: { reasoning: { type: "text", text: "second " } } }, + { delta: { reasoning: { type: "text", text: "third." } } }, + { delta: { reasoning: { type: "text", text: "" } } }, + { delta: { reasoning: { type: "text", signature: "sig123" } } }, + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + type: "function", + function: { name: "test", arguments: "{}" }, + }, + ], + }, + }, + ] + + const accumulatedItems: Array<{ + type: string + text?: string + signature?: string + }> = [] + + for (const chunk of chunks) { + if (chunk.delta.reasoning) { + accumulatedItems.push(chunk.delta.reasoning) + } + } + + expect(accumulatedItems).toHaveLength(5) + expect(accumulatedItems[4].signature).toBe("sig123") + }) + + it("should accumulate reasoning from delta.reasoning_details (OpenRouter)", () => { + // Simulates the pattern of chunks from OpenRouter + const chunks = [ + { + delta: { + reasoning_details: [ + { type: "reasoning.text", text: "Thinking...", signature: null }, + ], + }, + }, + { + delta: { + reasoning_details: [ + { type: "reasoning.encrypted", data: "[REDACTED]" }, + ], + }, + }, + { + delta: { + tool_calls: [ + { + index: 0, + id: "gen-123", + type: "function", + function: { name: "weather", arguments: '{"location":"NYC"}' }, + }, + ], + }, + }, + ] + + const accumulatedDetails: unknown[] = [] + + for (const chunk of chunks) { + if (chunk.delta.reasoning_details) { + accumulatedDetails.push(...chunk.delta.reasoning_details) + } + } + + expect(accumulatedDetails).toHaveLength(2) + expect(accumulatedDetails[0]).toMatchObject({ + type: "reasoning.text", + text: "Thinking...", + }) + expect(accumulatedDetails[1]).toMatchObject({ + type: "reasoning.encrypted", + data: "[REDACTED]", + }) + }) +}) + diff --git a/src/vercel-ai/langtail-language-model.ts b/src/vercel-ai/langtail-language-model.ts index ccfffe5..15e2820 100644 --- a/src/vercel-ai/langtail-language-model.ts +++ b/src/vercel-ai/langtail-language-model.ts @@ -1,11 +1,13 @@ import { InvalidResponseDataError, - LanguageModelV1, - LanguageModelV1CallWarning, - LanguageModelV1FinishReason, - LanguageModelV1LogProbs, - LanguageModelV1ProviderMetadata, - LanguageModelV1StreamPart, + JSONValue, + LanguageModelV2, + LanguageModelV2CallOptions, + LanguageModelV2CallWarning, + LanguageModelV2Content, + LanguageModelV2FinishReason, + LanguageModelV2StreamPart, + SharedV2ProviderMetadata, } from "@ai-sdk/provider" import { ParseResult, @@ -15,7 +17,7 @@ import { isParsableJson, postJsonToApi, } from "@ai-sdk/provider-utils" -import { z } from "zod" +import { z } from "zod/v4" import { convertToOpenAIChatMessages } from "./convert-to-openai-chat-messages" import { mapLangtailFinishReason } from "./map-langtail-finish-reason" import { LangtailChatSettings } from "./langtail-chat-settings" @@ -23,7 +25,6 @@ import { openaiErrorDataSchema, openaiFailedResponseHandler, } from "./openai-error" -import { mapOpenAIChatLogProbsOutput } from "./map-openai-chat-logprobs" import { LangtailPrompts } from "../Langtail" import type { PromptSlug, @@ -54,18 +55,22 @@ export class LangtailChatLanguageModel< P extends PromptSlug = PromptSlug, E extends Environment

= undefined, V extends Version = undefined, -> implements LanguageModelV1 +> implements LanguageModelV2 { - readonly specificationVersion: "v1" = "v1" - readonly supportsImageUrls = true + readonly specificationVersion = "v2" as const readonly modelId: string readonly promptId: P + readonly provider: string readonly settings: LangtailChatSettings private readonly config: LangtailChatConfig + readonly supportedUrls: Record = { + "image/*": [/^data:image\/[a-zA-Z0-9.+-]+;base64,/, /^https?:\/\/.+/i], + } + constructor( promptId: P, settings: LangtailChatSettings, @@ -75,6 +80,7 @@ export class LangtailChatLanguageModel< this.modelId = settings.model ?? MODEL_IN_LANGTAIL this.settings = settings this.config = config + this.provider = config.provider } get environment(): E extends LangtailEnvironment ? E : "production" { @@ -90,19 +96,6 @@ export class LangtailChatLanguageModel< return this.settings.structuredOutputs ?? false } - get defaultObjectGenerationMode() { - // audio models don't support structured outputs: - if (isAudioModel(this.modelId)) { - return "tool" - } - - return this.supportsStructuredOutputs ? "json" : "tool" - } - - get provider(): string { - return this.config.provider - } - get headers(): Record { return this.config.headers } @@ -116,9 +109,8 @@ export class LangtailChatLanguageModel< } private getArgs({ - mode, prompt, - maxTokens, + maxOutputTokens, temperature, topP, topK, @@ -127,11 +119,11 @@ export class LangtailChatLanguageModel< stopSequences, responseFormat, seed, - providerMetadata, - }: Parameters[0]) { - const type = mode.type - - const warnings: LanguageModelV1CallWarning[] = [] + tools, + toolChoice, + providerOptions, + }: LanguageModelV2CallOptions) { + const warnings: LanguageModelV2CallWarning[] = [] if (topK != null) { warnings.push({ @@ -154,7 +146,7 @@ export class LangtailChatLanguageModel< } // anthropic thinking - const thinking = providerMetadata?.anthropic?.thinking as + const thinking = providerOptions?.anthropic?.thinking as | { budgetTokens: number type: "enabled" @@ -172,7 +164,7 @@ export class LangtailChatLanguageModel< parallel_tool_calls: true, // standardized settings: - max_tokens: maxTokens, + max_tokens: maxOutputTokens, temperature, top_p: topP, frequency_penalty: frequencyPenalty, @@ -211,82 +203,26 @@ export class LangtailChatLanguageModel< baseArgs.presence_penalty = undefined } - switch (type) { - case "regular": { - const { tools, tool_choice, functions, function_call, toolWarnings } = - prepareTools({ - mode, - structuredOutputs: this.supportsStructuredOutputs, - useLegacyFunctionCalling: false, - }) - - return { - args: { - ...baseArgs, - tools, - tool_choice, - functions, - function_call, - }, - warnings: [...warnings, ...toolWarnings], - } - } - - case "object-json": { - return { - args: { - ...baseArgs, - response_format: - this.supportsStructuredOutputs && mode.schema != null - ? { - type: "json_schema", - json_schema: { - schema: mode.schema, - strict: true, - name: mode.name ?? "response", - description: mode.description, - }, - } - : { type: "json_object" }, - }, - warnings, - } - } - - case "object-tool": { - return { - args: { - ...baseArgs, - tool_choice: { - type: "function", - function: { name: mode.tool.name }, - }, - tools: [ - { - type: "function", - function: { - name: mode.tool.name, - description: mode.tool.description, - parameters: mode.tool.parameters, - strict: this.supportsStructuredOutputs ? true : undefined, - }, - }, - ], - }, - warnings, - } - } + // Handle tools if provided + const { mappedTools, mappedToolChoice, toolWarnings } = prepareTools({ + tools, + toolChoice, + structuredOutputs: this.supportsStructuredOutputs, + }) - default: { - const _exhaustiveCheck: never = type - throw new Error(`Unsupported type: ${_exhaustiveCheck}`) - } + return { + args: { + ...baseArgs, + tools: mappedTools, + tool_choice: mappedToolChoice, + }, + warnings: [...warnings, ...toolWarnings], } } async doGenerate( - options: Parameters[0], - ): Promise>> { + options: LanguageModelV2CallOptions, + ): Promise>> { const { args, warnings } = this.getArgs(options) const body = { @@ -306,10 +242,9 @@ export class LangtailChatLanguageModel< abortSignal: options.abortSignal, }) - const { messages: rawPrompt, ...rawSettings } = args const choice = response.choices[0] - let providerMetadata: LanguageModelV1ProviderMetadata | undefined + let providerMetadata: SharedV2ProviderMetadata | undefined if ( response.usage?.completion_tokens_details?.reasoning_tokens != null || response.usage?.prompt_tokens_details?.cached_tokens != null @@ -325,56 +260,63 @@ export class LangtailChatLanguageModel< } } - // Process reasoning_details if present - let reasoningContent: - | string - | { type: "text"; text: string; signature?: string }[] - | { type: "redacted"; data: string }[] - | undefined = choice.message.reasoning as - | string - | { type: "text"; text: string; signature?: string }[] - | { type: "redacted"; data: string }[] - | undefined - - // Convert reasoning_details to reasoning format if available + // Add reasoning_details to providerMetadata for preservation in multi-turn conversations if ( choice.message.reasoning_details && choice.message.reasoning_details.length > 0 ) { - const convertedReasoning: Array<{ - type: "text" - text: string - signature?: string - }> = [] - const convertedRedacted: Array<{ type: "redacted"; data: string }> = [] + if (!providerMetadata) { + providerMetadata = {} + } + providerMetadata.langtail = { + reasoning_details: choice.message + .reasoning_details as ReasoningDetailUnion[], + } + } + + // Build content array (V2 format) + const content: LanguageModelV2Content[] = [] + // Process reasoning content + if ( + choice.message.reasoning_details && + choice.message.reasoning_details.length > 0 + ) { for (const detail of choice.message.reasoning_details) { const typedDetail = detail as ReasoningDetailUnion switch (typedDetail.type) { case ReasoningDetailType.Text: { if (typedDetail.text) { - convertedReasoning.push({ - type: "text", + content.push({ + type: "reasoning", text: typedDetail.text, - signature: typedDetail.signature ?? undefined, + providerMetadata: { + langtail: { reasoning_details: [typedDetail] }, + }, }) } break } case ReasoningDetailType.Summary: { if (typedDetail.summary) { - convertedReasoning.push({ - type: "text", + content.push({ + type: "reasoning", text: typedDetail.summary, + providerMetadata: { + langtail: { reasoning_details: [typedDetail] }, + }, }) } break } case ReasoningDetailType.Encrypted: { if (typedDetail.data) { - convertedRedacted.push({ - type: "redacted", - data: typedDetail.data, + content.push({ + type: "reasoning", + text: "[REDACTED]", + providerMetadata: { + langtail: { reasoning_details: [typedDetail] }, + }, }) } break @@ -384,60 +326,80 @@ export class LangtailChatLanguageModel< } } } - - // Combine both text and redacted reasoning into a single array - // This ensures we don't lose encrypted entries when both types exist - if (convertedReasoning.length > 0 || convertedRedacted.length > 0) { - reasoningContent = [...convertedReasoning, ...convertedRedacted] as - | { type: "text"; text: string; signature?: string }[] - | { type: "redacted"; data: string }[] + } else if (choice.message.reasoning) { + // Fallback to legacy reasoning field + const reasoning = choice.message.reasoning + if (typeof reasoning === "string") { + content.push({ + type: "reasoning", + text: reasoning, + }) } } - // Add reasoning_details to providerMetadata for preservation in multi-turn conversations - if ( - choice.message.reasoning_details && - choice.message.reasoning_details.length > 0 - ) { - if (!providerMetadata) { - providerMetadata = {} - } - providerMetadata.langtail = { - reasoning_details: choice.message - .reasoning_details as ReasoningDetailUnion[], + // Add text content + if (choice.message.content) { + content.push({ + type: "text", + text: choice.message.content, + }) + } + + // Add tool calls + if (choice.message.tool_calls) { + // Collect reasoning_details for tool calls (needed for OpenRouter/Gemini) + const reasoningDetails = choice.message.reasoning_details as + | ReasoningDetailUnion[] + | undefined + + for (const toolCall of choice.message.tool_calls) { + content.push({ + type: "tool-call", + toolCallId: toolCall.id ?? generateId(), + toolName: toolCall.function.name, + input: toolCall.function.arguments!, + // Attach reasoning_details for OpenRouter/Gemini compatibility + providerMetadata: + reasoningDetails && reasoningDetails.length > 0 + ? { langtail: { reasoning_details: reasoningDetails } } + : undefined, + }) } } return { - text: choice.message.content ?? undefined, - reasoning: reasoningContent, - toolCalls: choice.message.tool_calls?.map((toolCall) => ({ - toolCallType: "function", - toolCallId: toolCall.id ?? generateId(), - toolName: toolCall.function.name, - args: toolCall.function.arguments!, - })), + content, finishReason: mapLangtailFinishReason( choice.finish_reason, Boolean(choice.message.tool_calls), ), usage: { - promptTokens: response.usage?.prompt_tokens ?? NaN, - completionTokens: response.usage?.completion_tokens ?? NaN, + inputTokens: response.usage?.prompt_tokens ?? undefined, + outputTokens: response.usage?.completion_tokens ?? undefined, + totalTokens: + response.usage?.prompt_tokens != null && + response.usage?.completion_tokens != null + ? response.usage.prompt_tokens + response.usage.completion_tokens + : undefined, + reasoningTokens: + response.usage?.completion_tokens_details?.reasoning_tokens ?? + undefined, + cachedInputTokens: + response.usage?.prompt_tokens_details?.cached_tokens ?? undefined, + }, + request: { body }, + response: { + ...getResponseMetadata(response), + headers: responseHeaders, }, - rawCall: { rawPrompt, rawSettings }, - rawResponse: { headers: responseHeaders }, - request: { body: JSON.stringify(body) }, - response: getResponseMetadata(response), warnings, - logprobs: mapOpenAIChatLogProbsOutput(choice.logprobs), providerMetadata, } } async doStream( - options: Parameters[0], - ): Promise>> { + options: LanguageModelV2CallOptions, + ): Promise>> { const { args, warnings } = this.getArgs(options) const body = { @@ -457,8 +419,6 @@ export class LangtailChatLanguageModel< abortSignal: options.abortSignal, }) - const { messages: rawPrompt, ...rawSettings } = args - const toolCalls: Array<{ id: string type: "function" @@ -466,29 +426,104 @@ export class LangtailChatLanguageModel< name: string arguments: string } + inputStarted: boolean hasFinished: boolean }> = [] - let finishReason: LanguageModelV1FinishReason = "unknown" + let finishReason: LanguageModelV2FinishReason = "unknown" let usage: { - promptTokens: number | undefined - completionTokens: number | undefined + inputTokens: number | undefined + outputTokens: number | undefined + totalTokens: number | undefined + reasoningTokens: number | undefined + cachedInputTokens: number | undefined } = { - promptTokens: undefined, - completionTokens: undefined, + inputTokens: undefined, + outputTokens: undefined, + totalTokens: undefined, + reasoningTokens: undefined, + cachedInputTokens: undefined, } - let logprobs: LanguageModelV1LogProbs let isFirstChunk = true // Track reasoning details to preserve for multi-turn conversations const accumulatedReasoningDetails: ReasoningDetailUnion[] = [] - let providerMetadata: LanguageModelV1ProviderMetadata | undefined + // Track reasoning items (from delta.reasoning) to preserve signatures for Anthropic + const accumulatedReasoningItems: Array<{ + type: string + text?: string + signature?: string + }> = [] + + // V2 stream state tracking + let textStarted = false + let reasoningStarted = false + let textId: string | undefined + let reasoningId: string | undefined + let responseId: string | undefined + + let providerMetadata: SharedV2ProviderMetadata | undefined + + // Helper to build reasoning providerMetadata for tool calls and finish events + const getReasoningProviderMetadata = (): + | SharedV2ProviderMetadata + | undefined => { + const hasReasoningDetails = accumulatedReasoningDetails.length > 0 + const hasReasoningItems = accumulatedReasoningItems.length > 0 + + if (!hasReasoningDetails && !hasReasoningItems) { + return undefined + } + + const langtailMeta: Record = {} + + if (hasReasoningDetails) { + langtailMeta.reasoning_details = accumulatedReasoningDetails + } + + if (hasReasoningItems) { + // Consolidate reasoning items into a single reasoning block with signature + // Anthropic expects this format: [{ type: "text", text: "...", signature: "..." }] + langtailMeta.reasoning = consolidateReasoningItems( + accumulatedReasoningItems, + ) + } + + return { langtail: langtailMeta } + } + + // Helper to consolidate streaming reasoning items into final format for Anthropic + const consolidateReasoningItems = ( + items: Array<{ type: string; text?: string; signature?: string }>, + ): Array<{ type: string; text?: string; signature?: string }> => { + // Collect all text chunks and the final signature + let fullText = "" + let signature: string | undefined + + for (const item of items) { + if (item.text) { + fullText += item.text + } + if (item.signature) { + signature = item.signature + } + } + + // Return a single consolidated reasoning block + if (signature) { + return [{ type: "text", text: fullText, signature }] + } else if (fullText) { + return [{ type: "text", text: fullText }] + } + return [] + } + return { stream: response.pipeThrough( new TransformStream< ParseResult>, - LanguageModelV1StreamPart + LanguageModelV2StreamPart >({ transform(chunk, controller) { // handle failed chunk parsing / validation: @@ -509,6 +544,12 @@ export class LangtailChatLanguageModel< if (isFirstChunk) { isFirstChunk = false + responseId = value.id ?? generateId() + + controller.enqueue({ + type: "stream-start", + warnings, + }) controller.enqueue({ type: "response-metadata", @@ -518,8 +559,18 @@ export class LangtailChatLanguageModel< if (value.usage != null) { usage = { - promptTokens: value.usage.prompt_tokens ?? undefined, - completionTokens: value.usage.completion_tokens ?? undefined, + inputTokens: value.usage.prompt_tokens ?? undefined, + outputTokens: value.usage.completion_tokens ?? undefined, + totalTokens: + value.usage.prompt_tokens != null && + value.usage.completion_tokens != null + ? value.usage.prompt_tokens + value.usage.completion_tokens + : undefined, + reasoningTokens: + value.usage.completion_tokens_details?.reasoning_tokens ?? + undefined, + cachedInputTokens: + value.usage.prompt_tokens_details?.cached_tokens ?? undefined, } const { @@ -546,9 +597,11 @@ export class LangtailChatLanguageModel< const choice = value.choices[0] if (choice?.finish_reason != null) { + // Use toolCalls.length instead of delta.tool_calls + // because tool_calls may have been in previous chunks finishReason = mapLangtailFinishReason( choice.finish_reason, - Boolean(choice.delta?.tool_calls), + toolCalls.length > 0, ) } @@ -558,10 +611,20 @@ export class LangtailChatLanguageModel< const delta = choice.delta - if (delta.content != null) { + // Helper to emit reasoning chunks + const emitReasoningChunk = (chunkText: string) => { + if (!reasoningStarted) { + reasoningId = responseId ?? generateId() + controller.enqueue({ + type: "reasoning-start", + id: reasoningId, + }) + reasoningStarted = true + } controller.enqueue({ - type: "text-delta", - textDelta: delta.content, + type: "reasoning-delta", + delta: chunkText, + id: reasoningId!, }) } @@ -576,35 +639,20 @@ export class LangtailChatLanguageModel< const typedDetail = detail as ReasoningDetailUnion switch (typedDetail.type) { case ReasoningDetailType.Text: { - if (typedDetail.signature != null) { - controller.enqueue({ - type: "reasoning-signature", - signature: typedDetail.signature, - }) - } if (typedDetail.text != null) { - controller.enqueue({ - type: "reasoning", - textDelta: typedDetail.text, - }) + emitReasoningChunk(typedDetail.text) } break } case ReasoningDetailType.Summary: { if (typedDetail.summary != null) { - controller.enqueue({ - type: "reasoning", - textDelta: typedDetail.summary, - }) + emitReasoningChunk(typedDetail.summary) } break } case ReasoningDetailType.Encrypted: { if (typedDetail.data != null) { - controller.enqueue({ - type: "redacted-reasoning", - data: typedDetail.data, - }) + emitReasoningChunk("[REDACTED]") } break } @@ -617,62 +665,55 @@ export class LangtailChatLanguageModel< } else if (delta.reasoning != null) { // Fallback to legacy reasoning field if reasoning_details not present const reasoningDelta = delta.reasoning + if (typeof reasoningDelta === "string") { - controller.enqueue({ - type: "reasoning", - textDelta: reasoningDelta, - }) + emitReasoningChunk(reasoningDelta) } else if (Array.isArray(reasoningDelta)) { - // Handle the reasoning array for (const reasoningItem of reasoningDelta) { - if (reasoningItem.type === "text") { - if (reasoningItem.signature != null) { - controller.enqueue({ - type: "reasoning-signature", - signature: reasoningItem.signature, - }) - } - if (reasoningItem.text != null) { - controller.enqueue({ - type: "reasoning", - textDelta: reasoningItem.text, - }) - } + // Accumulate reasoning items (including signatures for Anthropic) + accumulatedReasoningItems.push(reasoningItem) + if (reasoningItem.type === "text" && reasoningItem.text) { + emitReasoningChunk(reasoningItem.text) } else if (reasoningItem.type === "redacted") { - controller.enqueue({ - type: "redacted-reasoning", - data: reasoningItem.data, - }) + emitReasoningChunk("[REDACTED]") } } } else { - // Handle as direct object - if (reasoningDelta.type === "text") { - if (reasoningDelta.signature != null) { - controller.enqueue({ - type: "reasoning-signature", - signature: reasoningDelta.signature, - }) - } - if (reasoningDelta.text != null) { - controller.enqueue({ - type: "reasoning", - textDelta: reasoningDelta.text, - }) - } + // Accumulate single reasoning item (including signatures for Anthropic) + accumulatedReasoningItems.push(reasoningDelta) + if (reasoningDelta.type === "text" && reasoningDelta.text) { + emitReasoningChunk(reasoningDelta.text) } else if (reasoningDelta.type === "redacted") { - controller.enqueue({ - type: "redacted-reasoning", - data: reasoningDelta.data, - }) + emitReasoningChunk("[REDACTED]") } } } - const mappedLogprobs = mapOpenAIChatLogProbsOutput(choice?.logprobs) - if (mappedLogprobs?.length) { - if (logprobs === undefined) logprobs = [] - logprobs.push(...mappedLogprobs) + // Handle text content - only if there's actual content + if (delta.content != null && delta.content.length > 0) { + // End reasoning if it was started before text + if (reasoningStarted && !textStarted) { + controller.enqueue({ + type: "reasoning-end", + id: reasoningId!, + }) + reasoningStarted = false + } + + if (!textStarted) { + textId = responseId ?? generateId() + controller.enqueue({ + type: "text-start", + id: textId, + }) + textStarted = true + } + + controller.enqueue({ + type: "text-delta", + delta: delta.content, + id: textId!, + }) } const mappedToolCalls: typeof delta.tool_calls = delta.tool_calls @@ -716,6 +757,7 @@ export class LangtailChatLanguageModel< name: toolCallDelta.function.name, arguments: toolCallDelta.function.arguments ?? "", }, + inputStarted: false, hasFinished: false, } @@ -727,24 +769,37 @@ export class LangtailChatLanguageModel< ) { // send delta if the argument text has already started: if (toolCall.function.arguments.length > 0) { + if (!toolCall.inputStarted) { + toolCall.inputStarted = true + controller.enqueue({ + type: "tool-input-start", + id: toolCall.id, + toolName: toolCall.function.name, + }) + } controller.enqueue({ - type: "tool-call-delta", - toolCallType: "function", - toolCallId: toolCall.id, - toolName: toolCall.function.name, - argsTextDelta: toolCall.function.arguments, + type: "tool-input-delta", + id: toolCall.id, + delta: toolCall.function.arguments, }) } // check if tool call is complete // (some providers send the full tool call in one chunk): if (isParsableJson(toolCall.function.arguments)) { + if (toolCall.inputStarted) { + controller.enqueue({ + type: "tool-input-end", + id: toolCall.id, + }) + } controller.enqueue({ type: "tool-call", - toolCallType: "function", toolCallId: toolCall.id ?? generateId(), toolName: toolCall.function.name, - args: toolCall.function.arguments, + input: toolCall.function.arguments, + // Attach reasoning for multi-turn conversations + providerMetadata: getReasoningProviderMetadata(), }) toolCall.hasFinished = true } @@ -759,6 +814,15 @@ export class LangtailChatLanguageModel< continue } + if (!toolCall.inputStarted) { + toolCall.inputStarted = true + controller.enqueue({ + type: "tool-input-start", + id: toolCall.id, + toolName: toolCall.function.name, + }) + } + if (toolCallDelta.function?.arguments != null) { toolCall.function!.arguments += toolCallDelta.function?.arguments ?? "" @@ -766,11 +830,9 @@ export class LangtailChatLanguageModel< // send delta controller.enqueue({ - type: "tool-call-delta", - toolCallType: "function", - toolCallId: toolCall.id, - toolName: toolCall.function.name, - argsTextDelta: toolCallDelta.function.arguments ?? "", + type: "tool-input-delta", + id: toolCall.id, + delta: toolCallDelta.function.arguments ?? "", }) // check if tool call is complete @@ -779,12 +841,17 @@ export class LangtailChatLanguageModel< toolCall.function?.arguments != null && isParsableJson(toolCall.function.arguments) ) { + controller.enqueue({ + type: "tool-input-end", + id: toolCall.id, + }) controller.enqueue({ type: "tool-call", - toolCallType: "function", toolCallId: toolCall.id ?? generateId(), toolName: toolCall.function.name, - args: toolCall.function.arguments, + input: toolCall.function.arguments, + // Attach reasoning for multi-turn conversations + providerMetadata: getReasoningProviderMetadata(), }) toolCall.hasFinished = true } @@ -793,35 +860,76 @@ export class LangtailChatLanguageModel< }, flush(controller) { - // Include accumulated reasoning_details in providerMetadata if any were received - if (accumulatedReasoningDetails.length > 0) { + // Handle any unfinished tool calls + if (finishReason === "tool-calls") { + for (const toolCall of toolCalls) { + if (toolCall && !toolCall.hasFinished) { + if (toolCall.inputStarted) { + controller.enqueue({ + type: "tool-input-end", + id: toolCall.id, + }) + } + controller.enqueue({ + type: "tool-call", + toolCallId: toolCall.id ?? generateId(), + toolName: toolCall.function.name, + input: isParsableJson(toolCall.function.arguments) + ? toolCall.function.arguments + : "{}", + // Attach reasoning for multi-turn conversations + providerMetadata: getReasoningProviderMetadata(), + }) + toolCall.hasFinished = true + } + } + } + + // End reasoning if still active + if (reasoningStarted) { + controller.enqueue({ + type: "reasoning-end", + id: reasoningId!, + }) + } + + // End text if still active + if (textStarted) { + controller.enqueue({ + type: "text-end", + id: textId!, + }) + } + + // Include accumulated reasoning in providerMetadata if any were received + const reasoningMeta = getReasoningProviderMetadata() + if (reasoningMeta) { if (!providerMetadata) { providerMetadata = {} } - if (!providerMetadata.langtail) { - providerMetadata.langtail = {} + providerMetadata.langtail = { + ...providerMetadata.langtail, + ...reasoningMeta.langtail, } - providerMetadata.langtail.reasoning_details = - accumulatedReasoningDetails } controller.enqueue({ type: "finish", finishReason, - logprobs, usage: { - promptTokens: usage.promptTokens ?? NaN, - completionTokens: usage.completionTokens ?? NaN, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + totalTokens: usage.totalTokens, + reasoningTokens: usage.reasoningTokens, + cachedInputTokens: usage.cachedInputTokens, }, - ...(providerMetadata != null ? { providerMetadata } : {}), + providerMetadata, }) }, }), ), - rawCall: { rawPrompt, rawSettings }, - rawResponse: { headers: responseHeaders }, - request: { body: JSON.stringify(body) }, - warnings, + request: { body }, + response: { headers: responseHeaders }, } } } diff --git a/src/vercel-ai/map-langtail-finish-reason.ts b/src/vercel-ai/map-langtail-finish-reason.ts index 8d0e9d7..b0cbc35 100644 --- a/src/vercel-ai/map-langtail-finish-reason.ts +++ b/src/vercel-ai/map-langtail-finish-reason.ts @@ -1,42 +1,42 @@ -import { LanguageModelV1FinishReason } from '@ai-sdk/provider'; +import { LanguageModelV2FinishReason } from "@ai-sdk/provider" export function mapLangtailFinishReason( finishReason: string | null | undefined, hasToolCalls: boolean, -): LanguageModelV1FinishReason { +): LanguageModelV2FinishReason { switch (finishReason) { // openai: - case 'stop': - return 'stop'; - case 'length': - return 'length'; - case 'content_filter': - return 'content-filter'; - case 'function_call': - case 'tool_calls': - return 'tool-calls'; + case "stop": + return "stop" + case "length": + return "length" + case "content_filter": + return "content-filter" + case "function_call": + case "tool_calls": + return "tool-calls" // anthropic: - case 'end_turn': - case 'stop_sequence': - return 'stop'; - case 'tool_use': - return 'tool-calls'; - case 'max_tokens': - return 'length'; + case "end_turn": + case "stop_sequence": + return "stop" + case "tool_use": + return "tool-calls" + case "max_tokens": + return "length" // google: - case 'STOP': - return hasToolCalls ? 'tool-calls' : 'stop'; - case 'MAX_TOKENS': - return 'length'; - case 'RECITATION': - case 'SAFETY': - return 'content-filter'; - case 'FINISH_REASON_UNSPECIFIED': - case 'OTHER': - return 'other'; + case "STOP": + return hasToolCalls ? "tool-calls" : "stop" + case "MAX_TOKENS": + return "length" + case "RECITATION": + case "SAFETY": + return "content-filter" + case "FINISH_REASON_UNSPECIFIED": + case "OTHER": + return "other" default: - console.warn('Unknown finish reason: ', finishReason); - return 'unknown'; + console.warn("Unknown finish reason: ", finishReason) + return "unknown" } } diff --git a/src/vercel-ai/map-openai-chat-logprobs.ts b/src/vercel-ai/map-openai-chat-logprobs.ts deleted file mode 100644 index 7fbf314..0000000 --- a/src/vercel-ai/map-openai-chat-logprobs.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { LanguageModelV1LogProbs } from '@ai-sdk/provider'; - -type OpenAIChatLogProbs = { - content: - | { - token: string; - logprob: number; - top_logprobs: - | { - token: string; - logprob: number; - }[] - | null; - }[] - | null; -}; - -export function mapOpenAIChatLogProbsOutput( - logprobs: OpenAIChatLogProbs | null | undefined, -): LanguageModelV1LogProbs | undefined { - return ( - logprobs?.content?.map(({ token, logprob, top_logprobs }) => ({ - token, - logprob, - topLogprobs: top_logprobs - ? top_logprobs.map(({ token, logprob }) => ({ - token, - logprob, - })) - : [], - })) ?? undefined - ); -} \ No newline at end of file diff --git a/src/vercel-ai/openai-error.ts b/src/vercel-ai/openai-error.ts index 2e719a2..56b430d 100644 --- a/src/vercel-ai/openai-error.ts +++ b/src/vercel-ai/openai-error.ts @@ -1,5 +1,5 @@ -import { z } from 'zod'; -import { createJsonErrorResponseHandler } from '@ai-sdk/provider-utils'; +import { z } from "zod/v4" +import { createJsonErrorResponseHandler } from "@ai-sdk/provider-utils" export const openaiErrorDataSchema = z.object({ error: z.object({ @@ -12,11 +12,11 @@ export const openaiErrorDataSchema = z.object({ param: z.any().nullish(), code: z.union([z.string(), z.number()]).nullish(), }), -}); +}) -export type OpenAIErrorData = z.infer; +export type OpenAIErrorData = z.infer export const openaiFailedResponseHandler = createJsonErrorResponseHandler({ errorSchema: openaiErrorDataSchema, - errorToMessage: data => data.error.message, -}); \ No newline at end of file + errorToMessage: (data) => data.error.message, +}) diff --git a/src/vercel-ai/openai-prepare-tools.ts b/src/vercel-ai/openai-prepare-tools.ts index b00a59e..4a2d69a 100644 --- a/src/vercel-ai/openai-prepare-tools.ts +++ b/src/vercel-ai/openai-prepare-tools.ts @@ -1,161 +1,106 @@ import { JSONSchema7, - LanguageModelV1, - LanguageModelV1CallWarning, + LanguageModelV2CallWarning, + LanguageModelV2FunctionTool, + LanguageModelV2ProviderDefinedTool, + LanguageModelV2ToolChoice, UnsupportedFunctionalityError, -} from '@ai-sdk/provider'; +} from "@ai-sdk/provider" export function prepareTools({ - mode, - useLegacyFunctionCalling = false, + tools, + toolChoice, structuredOutputs, }: { - mode: Parameters[0]['mode'] & { - type: 'regular'; - }; - useLegacyFunctionCalling: boolean | undefined; - structuredOutputs: boolean; + tools?: Array< + LanguageModelV2FunctionTool | LanguageModelV2ProviderDefinedTool + > + toolChoice?: LanguageModelV2ToolChoice + structuredOutputs: boolean }): { - tools?: { - type: 'function'; + mappedTools?: { + type: "function" function: { - name: string; - description: string | undefined; - parameters: JSONSchema7; - strict?: boolean; - }; - }[]; - tool_choice?: - | 'auto' - | 'none' - | 'required' - | { type: 'function'; function: { name: string } }; - - // legacy support - functions?: { - name: string; - description: string | undefined; - parameters: JSONSchema7; - }[]; - function_call?: { name: string }; - - toolWarnings: LanguageModelV1CallWarning[]; + name: string + description: string | undefined + parameters: JSONSchema7 + strict?: boolean + } + }[] + mappedToolChoice?: + | "auto" + | "none" + | "required" + | { type: "function"; function: { name: string } } + toolWarnings: LanguageModelV2CallWarning[] } { // when the tools array is empty, change it to undefined to prevent errors: - const tools = mode.tools?.length ? mode.tools : undefined; + const toolsArray = tools?.length ? tools : undefined - const toolWarnings: LanguageModelV1CallWarning[] = []; + const toolWarnings: LanguageModelV2CallWarning[] = [] - if (tools == null) { - return { tools: undefined, tool_choice: undefined, toolWarnings }; - } - - const toolChoice = mode.toolChoice; - - if (useLegacyFunctionCalling) { - const openaiFunctions: Array<{ - name: string; - description: string | undefined; - parameters: JSONSchema7; - }> = []; - - for (const tool of tools) { - if (tool.type === 'provider-defined') { - toolWarnings.push({ type: 'unsupported-tool', tool }); - } else { - openaiFunctions.push({ - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }); - } - } - - if (toolChoice == null) { - return { - functions: openaiFunctions, - function_call: undefined, - toolWarnings, - }; - } - - const type = toolChoice.type; - - switch (type) { - case 'auto': - case 'none': - case undefined: - return { - functions: openaiFunctions, - function_call: undefined, - toolWarnings, - }; - case 'required': - throw new UnsupportedFunctionalityError({ - functionality: 'useLegacyFunctionCalling and toolChoice: required', - }); - default: - return { - functions: openaiFunctions, - function_call: { name: toolChoice.toolName }, - toolWarnings, - }; - } + if (toolsArray == null) { + return { mappedTools: undefined, mappedToolChoice: undefined, toolWarnings } } const openaiTools: Array<{ - type: 'function'; + type: "function" function: { - name: string; - description: string | undefined; - parameters: JSONSchema7; - strict: boolean | undefined; - }; - }> = []; + name: string + description: string | undefined + parameters: JSONSchema7 + strict: boolean | undefined + } + }> = [] - for (const tool of tools) { - if (tool.type === 'provider-defined') { - toolWarnings.push({ type: 'unsupported-tool', tool }); + for (const tool of toolsArray) { + if (tool.type === "provider-defined") { + toolWarnings.push({ type: "unsupported-tool", tool }) } else { + // V2 uses inputSchema instead of parameters openaiTools.push({ - type: 'function', + type: "function", function: { name: tool.name, description: tool.description, - parameters: tool.parameters, + parameters: tool.inputSchema as JSONSchema7, strict: structuredOutputs ? true : undefined, }, - }); + }) } } if (toolChoice == null) { - return { tools: openaiTools, tool_choice: undefined, toolWarnings }; + return { + mappedTools: openaiTools, + mappedToolChoice: undefined, + toolWarnings, + } } - const type = toolChoice.type; + const type = toolChoice.type switch (type) { - case 'auto': - case 'none': - case 'required': - return { tools: openaiTools, tool_choice: type, toolWarnings }; - case 'tool': + case "auto": + case "none": + case "required": + return { mappedTools: openaiTools, mappedToolChoice: type, toolWarnings } + case "tool": return { - tools: openaiTools, - tool_choice: { - type: 'function', + mappedTools: openaiTools, + mappedToolChoice: { + type: "function", function: { name: toolChoice.toolName, }, }, toolWarnings, - }; + } default: { - const _exhaustiveCheck: never = type; + const _exhaustiveCheck: never = type throw new UnsupportedFunctionalityError({ functionality: `Unsupported tool choice type: ${_exhaustiveCheck}`, - }); + }) } } -} \ No newline at end of file +}