From 92d560b32add842d4de14fdabb5f3002f08390a5 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 23 May 2026 14:08:12 -0700 Subject: [PATCH] Split public LLM provider code from internal --- bun.lock | 13 +- common/src/testing/mocks/database.ts | 2 +- common/src/testing/setup.ts | 2 +- docs/architecture.md | 14 +- package.json | 2 +- .../src/__tests__/loop-agent-steps.test.ts | 8 +- .../__tests__/run-agent-step-tools.test.ts | 8 +- .../agent-runtime/src/prompt-agent-stream.ts | 7 +- packages/internal/package.json | 7 - .../map-openai-compatible-finish-reason.ts | 19 - .../image/openai-compatible-image-settings.ts | 1 - ...onvert-to-openrouter-chat-messages.test.ts | 551 ------ .../convert-to-openrouter-chat-messages.ts | 223 --- .../openrouter-ai-sdk/chat/file-url-utils.ts | 34 - .../openrouter-ai-sdk/chat/get-tool-choice.ts | 39 - .../src/openrouter-ai-sdk/chat/index.test.ts | 1599 ----------------- .../src/openrouter-ai-sdk/chat/index.ts | 852 --------- .../src/openrouter-ai-sdk/chat/is-url.ts | 15 - .../src/openrouter-ai-sdk/chat/schemas.ts | 164 -- ...convert-to-openrouter-completion-prompt.ts | 151 -- .../completion/index.test.ts | 665 ------- .../src/openrouter-ai-sdk/completion/index.ts | 344 ---- .../openrouter-ai-sdk/completion/schemas.ts | 50 - .../internal/src/openrouter-ai-sdk/facade.ts | 83 - .../internal/src/openrouter-ai-sdk/index.ts | 3 - .../src/openrouter-ai-sdk/internal/index.ts | 5 - .../src/openrouter-ai-sdk/provider.ts | 180 -- .../schemas/error-response.test.ts | 51 - .../schemas/error-response.ts | 18 - .../schemas/reasoning-details.ts | 48 - .../tests/provider-options.test.ts | 223 --- .../tests/stream-usage-accounting.test.ts | 219 --- .../tests/usage-accounting.test.ts | 183 -- .../src/openrouter-ai-sdk/types/index.ts | 70 - .../openrouter-chat-completions-input.ts | 78 - .../types/openrouter-chat-settings.ts | 133 -- .../types/openrouter-completion-settings.ts | 39 - packages/llm-providers/package.json | 35 + ...to-openai-compatible-chat-messages.test.ts | 0 ...vert-to-openai-compatible-chat-messages.ts | 0 .../chat/get-response-metadata.ts | 8 +- .../map-openai-compatible-finish-reason.ts} | 2 +- .../chat/openai-compatible-api-types.ts | 54 +- .../openai-compatible-chat-language-model.ts | 319 ++-- .../chat/openai-compatible-chat-options.ts | 8 +- .../openai-compatible-metadata-extractor.ts | 14 +- .../chat/openai-compatible-prepare-tools.ts | 61 +- ...-to-openai-compatible-completion-prompt.ts | 55 +- .../completion/get-response-metadata.ts | 8 +- .../map-openai-compatible-finish-reason.ts | 12 +- ...ai-compatible-completion-language-model.ts | 173 +- .../openai-compatible-completion-options.ts | 8 +- .../openai-compatible-embedding-model.ts | 84 +- .../openai-compatible-embedding-options.ts | 8 +- .../image/openai-compatible-image-model.ts | 56 +- .../image/openai-compatible-image-settings.ts | 1 + .../src/openai-compatible/index.ts | 24 +- .../src/openai-compatible/internal/index.ts | 8 +- .../openai-compatible-error.ts | 20 +- .../openai-compatible-provider.ts | 98 +- .../src/openai-compatible/version.ts | 4 +- packages/llm-providers/tsconfig.json | 9 + sdk/scripts/build.ts | 6 +- sdk/src/impl/llm.ts | 55 +- sdk/src/impl/model-provider.ts | 22 +- sdk/tsconfig.json | 3 +- tsconfig.json | 2 + 67 files changed, 633 insertions(+), 6627 deletions(-) delete mode 100644 packages/internal/src/openai-compatible/completion/map-openai-compatible-finish-reason.ts delete mode 100644 packages/internal/src/openai-compatible/image/openai-compatible-image-settings.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.test.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/chat/file-url-utils.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/chat/get-tool-choice.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/chat/index.test.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/chat/index.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/chat/is-url.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/chat/schemas.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/completion/convert-to-openrouter-completion-prompt.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/completion/index.test.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/completion/index.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/completion/schemas.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/facade.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/index.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/internal/index.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/provider.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/schemas/error-response.test.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/schemas/error-response.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/schemas/reasoning-details.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/tests/provider-options.test.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/tests/stream-usage-accounting.test.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/tests/usage-accounting.test.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/types/index.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-completions-input.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-settings.ts delete mode 100644 packages/internal/src/openrouter-ai-sdk/types/openrouter-completion-settings.ts create mode 100644 packages/llm-providers/package.json rename packages/{internal => llm-providers}/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts (100%) rename packages/{internal => llm-providers}/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts (100%) rename packages/{internal => llm-providers}/src/openai-compatible/chat/get-response-metadata.ts (65%) rename packages/{internal/src/openrouter-ai-sdk/utils/map-finish-reason.ts => llm-providers/src/openai-compatible/chat/map-openai-compatible-finish-reason.ts} (89%) rename packages/{internal => llm-providers}/src/openai-compatible/chat/openai-compatible-api-types.ts (53%) rename packages/{internal => llm-providers}/src/openai-compatible/chat/openai-compatible-chat-language-model.ts (81%) rename packages/{internal => llm-providers}/src/openai-compatible/chat/openai-compatible-chat-options.ts (86%) rename packages/{internal => llm-providers}/src/openai-compatible/chat/openai-compatible-metadata-extractor.ts (89%) rename packages/{internal => llm-providers}/src/openai-compatible/chat/openai-compatible-prepare-tools.ts (61%) rename packages/{internal => llm-providers}/src/openai-compatible/completion/convert-to-openai-compatible-completion-prompt.ts (67%) rename packages/{internal => llm-providers}/src/openai-compatible/completion/get-response-metadata.ts (65%) rename packages/{internal/src/openai-compatible/chat => llm-providers/src/openai-compatible/completion}/map-openai-compatible-finish-reason.ts (72%) rename packages/{internal => llm-providers}/src/openai-compatible/completion/openai-compatible-completion-language-model.ts (76%) rename packages/{internal => llm-providers}/src/openai-compatible/completion/openai-compatible-completion-options.ts (90%) rename packages/{internal => llm-providers}/src/openai-compatible/embedding/openai-compatible-embedding-model.ts (66%) rename packages/{internal => llm-providers}/src/openai-compatible/embedding/openai-compatible-embedding-options.ts (85%) rename packages/{internal => llm-providers}/src/openai-compatible/image/openai-compatible-image-model.ts (73%) create mode 100644 packages/llm-providers/src/openai-compatible/image/openai-compatible-image-settings.ts rename packages/{internal => llm-providers}/src/openai-compatible/index.ts (64%) rename packages/{internal => llm-providers}/src/openai-compatible/internal/index.ts (69%) rename packages/{internal => llm-providers}/src/openai-compatible/openai-compatible-error.ts (72%) rename packages/{internal => llm-providers}/src/openai-compatible/openai-compatible-provider.ts (70%) rename packages/{internal => llm-providers}/src/openai-compatible/version.ts (57%) create mode 100644 packages/llm-providers/tsconfig.json diff --git a/bun.lock b/bun.lock index e575f4f9df..4f6021307f 100644 --- a/bun.lock +++ b/bun.lock @@ -212,7 +212,6 @@ "name": "@codebuff/internal", "version": "1.0.0", "dependencies": { - "@ai-sdk/provider-utils": "^3.0.17", "@codebuff/common": "workspace:*", "drizzle-kit": "0.31.8", "drizzle-orm": "0.45.1", @@ -222,6 +221,16 @@ "server-only": "0.0.1", }, }, + "packages/llm-providers": { + "name": "@codebuff/llm-providers", + "version": "1.0.0", + "dependencies": { + "@ai-sdk/provider": "2.0.1", + "@ai-sdk/provider-utils": "^3.0.17", + "ai": "^5.0.52", + "zod": "^4.2.1", + }, + }, "scripts": { "name": "@codebuff/scripts", "version": "1.0.0", @@ -499,6 +508,8 @@ "@codebuff/internal": ["@codebuff/internal@workspace:packages/internal"], + "@codebuff/llm-providers": ["@codebuff/llm-providers@workspace:packages/llm-providers"], + "@codebuff/scripts": ["@codebuff/scripts@workspace:scripts"], "@codebuff/sdk": ["@codebuff/sdk@workspace:sdk"], diff --git a/common/src/testing/mocks/database.ts b/common/src/testing/mocks/database.ts index c78353b2c8..3ad9c108ea 100644 --- a/common/src/testing/mocks/database.ts +++ b/common/src/testing/mocks/database.ts @@ -241,7 +241,7 @@ export interface DbSpies { * * @example * ```typescript - * import db from '@codebuff/internal/db' + * const db = createMockDbOperations() * * describe('my test', () => { * let dbSpies: DbSpies diff --git a/common/src/testing/setup.ts b/common/src/testing/setup.ts index 631178350c..5758fbb601 100644 --- a/common/src/testing/setup.ts +++ b/common/src/testing/setup.ts @@ -114,7 +114,7 @@ export interface TestSetupResult { * @example * ```typescript * import * as analytics from '@codebuff/common/analytics' - * import db from '@codebuff/internal/db' + * const db = createMockDbOperations() * * describe('my test', () => { * const setup = createTestSetup({ diff --git a/docs/architecture.md b/docs/architecture.md index 4c60d4ae22..a47a90657b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,7 +52,7 @@ The public SDK used by the CLI and available to external users via `@codebuff/sd - **Executes tool calls locally** on the user's machine (file edits, terminal commands, code search) - Manages model provider selection: Claude OAuth, ChatGPT OAuth, or Codebuff backend - Handles credentials, retry logic, and error transformation -- **Depends on:** `agent-runtime`, `common`, `internal` (for OpenAI-compatible provider) +- **Depends on:** `agent-runtime`, `common`, `llm-providers` ### `packages/agent-runtime/` — Agent Execution Engine @@ -113,17 +113,23 @@ The Codebuff web server, marketing site, and API. ### `packages/internal/` — Internal Utilities -Server-side utilities, database schema, and vendor forks shared between `web` and `sdk`. +Server-side utilities, database schema, and service integrations shared by private server packages. - **Key areas:** - `src/db/` — Drizzle ORM schema (`schema.ts`), migrations, Docker Compose for local Postgres - `src/env.ts` — Server environment variable validation (@t3-oss/env-nextjs) - `src/loops/` — Loops email service integration (transactional emails) - - `src/openai-compatible/` — Forked OpenAI-compatible AI SDK provider (used by the SDK to call the Codebuff backend) - - `src/openrouter-ai-sdk/` — Forked OpenRouter AI SDK provider (used by the web server) - `src/templates/` — Agent template fetching and validation - **Depends on:** `common` +### `packages/llm-providers/` — Public LLM Provider Shims + +Provider adapters that are safe for public packages to depend on. + +- **Key areas:** + - `src/openai-compatible/` — Forked OpenAI-compatible AI SDK provider used by the SDK for the Codebuff backend and ChatGPT OAuth flows +- **Depends on:** AI SDK provider packages + ### `packages/billing/` — Billing & Credits Credit management, subscription handling, and usage tracking. diff --git a/package.json b/package.json index 6ae23fa737..dd3d36e6ad 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "release:freebuff": "bun run --cwd=freebuff release", "clean-ts": "find . -name '*.tsbuildinfo' -type f -delete && find . -name '.next' -type d -exec rm -rf {} + 2>/dev/null || true && find . -name 'node_modules' -type d -exec rm -rf {} + 2>/dev/null || true && bun install", "typecheck": "bun scripts/check-env-architecture.ts && bun --filter='*' run typecheck && echo '✅ All type checks passed!'", - "test": "bun --filter='{@codebuff/common,@codebuff/agents,@codebuff/agent-runtime,@codebuff/sdk,@codebuff/web,@codebuff/cli,@codebuff/evals,@codebuff/scripts}' run test", + "test": "bun --filter='{@codebuff/common,@codebuff/agents,@codebuff/agent-runtime,@codebuff/llm-providers,@codebuff/sdk,@codebuff/web,@codebuff/cli,@codebuff/evals,@codebuff/scripts}' run test", "init-worktree": "bun scripts/init-worktree.ts", "cleanup-worktree": "bun scripts/cleanup-worktree.ts", "generate-tool-definitions": "bun scripts/generate-tool-definitions.ts" diff --git a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts index 74a637c8ef..1b2768dfd2 100644 --- a/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts +++ b/packages/agent-runtime/src/__tests__/loop-agent-steps.test.ts @@ -2,11 +2,13 @@ import * as analytics from '@codebuff/common/analytics' import { TEST_USER_ID } from '@codebuff/common/old-constants' import { createTestAgentRuntimeParams } from '@codebuff/common/testing/fixtures/agent-runtime' import { clearMockedModules } from '@codebuff/common/testing/mock-modules' -import { setupDbSpies } from '@codebuff/common/testing/mocks/database' +import { + createMockDbOperations, + setupDbSpies, +} from '@codebuff/common/testing/mocks/database' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { AbortError, promptSuccess } from '@codebuff/common/util/error' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' -import db from '@codebuff/internal/db' import { afterAll, afterEach, @@ -61,7 +63,7 @@ describe('loopAgentSteps - runAgentStep vs runProgrammaticStep behavior', () => llmCallCount = 0 // Setup spies for database operations using typed helper - dbSpies = setupDbSpies(db) + dbSpies = setupDbSpies(createMockDbOperations()) agentRuntimeImpl.promptAiSdkStream = mock(async function* ({}) { llmCallCount++ diff --git a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts index f3a793c35a..d55ac77d1a 100644 --- a/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts +++ b/packages/agent-runtime/src/__tests__/run-agent-step-tools.test.ts @@ -1,11 +1,13 @@ import * as analytics from '@codebuff/common/analytics' import { TEST_USER_ID } from '@codebuff/common/old-constants' import { TEST_AGENT_RUNTIME_IMPL } from '@codebuff/common/testing/impl/agent-runtime' -import { setupDbSpies } from '@codebuff/common/testing/mocks/database' +import { + createMockDbOperations, + setupDbSpies, +} from '@codebuff/common/testing/mocks/database' import { getInitialSessionState } from '@codebuff/common/types/session-state' import { promptSuccess } from '@codebuff/common/util/error' import { assistantMessage, userMessage } from '@codebuff/common/util/messages' -import db from '@codebuff/internal/db' import { afterAll, afterEach, @@ -66,7 +68,7 @@ describe('runAgentStep - set_output tool', () => { } // Setup spies for database operations using typed helper - dbSpies = setupDbSpies(db) + dbSpies = setupDbSpies(createMockDbOperations()) // Mock analytics spyOn(analytics, 'trackEvent').mockImplementation(() => {}) diff --git a/packages/agent-runtime/src/prompt-agent-stream.ts b/packages/agent-runtime/src/prompt-agent-stream.ts index c3ce83d15d..7e41e0385c 100644 --- a/packages/agent-runtime/src/prompt-agent-stream.ts +++ b/packages/agent-runtime/src/prompt-agent-stream.ts @@ -3,11 +3,14 @@ import { globalStopSequence } from './constants' import type { AgentTemplate } from './templates/types' import type { TrackEventFn } from '@codebuff/common/types/contracts/analytics' import type { SendActionFn } from '@codebuff/common/types/contracts/client' -import type { CacheDebugUsageData, PromptAiSdkStreamFn } from '@codebuff/common/types/contracts/llm' +import type { + CacheDebugUsageData, + PromptAiSdkStreamFn, +} from '@codebuff/common/types/contracts/llm' import type { Logger } from '@codebuff/common/types/contracts/logger' import type { ParamsOf } from '@codebuff/common/types/function-params' import type { Message } from '@codebuff/common/types/messages/codebuff-message' -import type { OpenRouterProviderOptions } from '@codebuff/internal/openrouter-ai-sdk' +import type { OpenRouterProviderOptions } from '@codebuff/common/types/agent-template' import type { ToolSet } from 'ai' export const getAgentStreamFromTemplate = (params: { diff --git a/packages/internal/package.json b/packages/internal/package.json index 7c4f797e7a..8183341630 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -22,12 +22,6 @@ "types": "./src/loops/index.ts", "default": "./src/loops/index.ts" }, - "./openrouter-ai-sdk": { - "bun": "./src/openrouter-ai-sdk/index.ts", - "import": "./src/openrouter-ai-sdk/index.ts", - "types": "./src/openrouter-ai-sdk/index.ts", - "default": "./src/openrouter-ai-sdk/index.ts" - }, "./env": { "react-server": "./src/env.react-server.ts", "browser": "./src/env.browser.ts", @@ -58,7 +52,6 @@ "bun": "1.3.11" }, "dependencies": { - "@ai-sdk/provider-utils": "^3.0.17", "@codebuff/common": "workspace:*", "drizzle-kit": "0.31.8", "drizzle-orm": "0.45.1", diff --git a/packages/internal/src/openai-compatible/completion/map-openai-compatible-finish-reason.ts b/packages/internal/src/openai-compatible/completion/map-openai-compatible-finish-reason.ts deleted file mode 100644 index b18feae081..0000000000 --- a/packages/internal/src/openai-compatible/completion/map-openai-compatible-finish-reason.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { LanguageModelV2FinishReason } from '@ai-sdk/provider'; - -export function mapOpenAICompatibleFinishReason( - finishReason: string | null | undefined, -): LanguageModelV2FinishReason { - switch (finishReason) { - case 'stop': - return 'stop'; - case 'length': - return 'length'; - case 'content_filter': - return 'content-filter'; - case 'function_call': - case 'tool_calls': - return 'tool-calls'; - default: - return 'unknown'; - } -} diff --git a/packages/internal/src/openai-compatible/image/openai-compatible-image-settings.ts b/packages/internal/src/openai-compatible/image/openai-compatible-image-settings.ts deleted file mode 100644 index 463fd56530..0000000000 --- a/packages/internal/src/openai-compatible/image/openai-compatible-image-settings.ts +++ /dev/null @@ -1 +0,0 @@ -export type OpenAICompatibleImageModelId = string; diff --git a/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.test.ts b/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.test.ts deleted file mode 100644 index a6897db596..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.test.ts +++ /dev/null @@ -1,551 +0,0 @@ -import { describe, expect, it } from 'bun:test' - -import { convertToOpenRouterChatMessages } from './convert-to-openrouter-chat-messages' - -describe('user messages', () => { - it('should convert image Uint8Array', async () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'user', - content: [ - { type: 'text', text: 'Hello' }, - { - type: 'file', - data: new Uint8Array([0, 1, 2, 3]), - mediaType: 'image/png', - }, - ], - }, - ]) - - expect(result).toEqual([ - { - role: 'user', - content: [ - { type: 'text', text: 'Hello' }, - { - type: 'image_url', - image_url: { url: 'data:image/png;base64,AAECAw==' }, - }, - ], - }, - ]) - }) - - it('should convert image urls', async () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'user', - content: [ - { type: 'text', text: 'Hello' }, - { - type: 'file', - data: 'https://example.com/image.png', - mediaType: 'image/png', - }, - ], - }, - ]) - - expect(result).toEqual([ - { - role: 'user', - content: [ - { type: 'text', text: 'Hello' }, - { - type: 'image_url', - image_url: { url: 'https://example.com/image.png' }, - }, - ], - }, - ]) - }) - - it('should convert messages with image base64', async () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'user', - content: [ - { type: 'text', text: 'Hello' }, - { - type: 'file', - data: 'data:image/png;base64,AAECAw==', - mediaType: 'image/png', - }, - ], - }, - ]) - - expect(result).toEqual([ - { - role: 'user', - content: [ - { type: 'text', text: 'Hello' }, - { - type: 'image_url', - image_url: { url: 'data:image/png;base64,AAECAw==' }, - }, - ], - }, - ]) - }) -}) - -describe('cache control', () => { - it('should pass cache control from system message provider metadata', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'system', - content: 'System prompt', - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' }, - }, - }, - }, - ]) - - expect(result).toEqual([ - { - role: 'system', - content: 'System prompt', - cache_control: { type: 'ephemeral' }, - }, - ]) - }) - - it('should pass cache control from user message provider metadata (single text part)', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' }, - }, - }, - }, - ]) - - expect(result).toEqual([ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Hello', - cache_control: { type: 'ephemeral' }, - }, - ], - }, - ]) - }) - - it('should pass cache control from content part provider metadata (single text part)', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Hello', - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' }, - }, - }, - }, - ], - }, - ]) - - expect(result).toEqual([ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Hello', - cache_control: { type: 'ephemeral' }, - }, - ], - }, - ]) - }) - - it('should pass cache control from user message provider metadata (multiple parts)', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'user', - content: [ - { type: 'text', text: 'Hello' }, - { - type: 'file', - data: new Uint8Array([0, 1, 2, 3]), - mediaType: 'image/png', - }, - ], - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' }, - }, - }, - }, - ]) - - expect(result).toEqual([ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Hello', - cache_control: { type: 'ephemeral' }, - }, - { - type: 'image_url', - image_url: { url: 'data:image/png;base64,AAECAw==' }, - cache_control: { type: 'ephemeral' }, - }, - ], - }, - ]) - }) - - it('should pass cache control from user message provider metadata without cache control (single text part)', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - ]) - - expect(result).toEqual([ - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - ]) - }) - - it('should pass cache control to multiple image parts from user message provider metadata', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'user', - content: [ - { type: 'text', text: 'Hello' }, - { - type: 'file', - data: new Uint8Array([0, 1, 2, 3]), - mediaType: 'image/png', - }, - { - type: 'file', - data: new Uint8Array([4, 5, 6, 7]), - mediaType: 'image/jpeg', - }, - ], - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' }, - }, - }, - }, - ]) - - expect(result).toEqual([ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Hello', - cache_control: { type: 'ephemeral' }, - }, - { - type: 'image_url', - image_url: { url: 'data:image/png;base64,AAECAw==' }, - cache_control: { type: 'ephemeral' }, - }, - { - type: 'image_url', - image_url: { url: 'data:image/jpeg;base64,BAUGBw==' }, - cache_control: { type: 'ephemeral' }, - }, - ], - }, - ]) - }) - - it('should pass cache control to file parts from user message provider metadata', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'user', - content: [ - { type: 'text', text: 'Hello' }, - { - type: 'file', - data: 'ZmlsZSBjb250ZW50', - mediaType: 'text/plain', - providerOptions: { - openrouter: { - filename: 'file.txt', - }, - }, - }, - ], - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' }, - }, - }, - }, - ]) - - expect(result).toEqual([ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Hello', - cache_control: { type: 'ephemeral' }, - }, - { - type: 'file', - file: { - filename: 'file.txt', - file_data: 'data:text/plain;base64,ZmlsZSBjb250ZW50', - }, - cache_control: { type: 'ephemeral' }, - }, - ], - }, - ]) - }) - - it('should handle mixed part-specific and message-level cache control for multiple parts', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Hello', - // No part-specific provider metadata - }, - { - type: 'file', - data: new Uint8Array([0, 1, 2, 3]), - mediaType: 'image/png', - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' }, - }, - }, - }, - { - type: 'file', - data: 'ZmlsZSBjb250ZW50', - mediaType: 'text/plain', - providerOptions: { - openrouter: { - filename: 'file.txt', - }, - }, - // No part-specific provider metadata - }, - ], - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' }, - }, - }, - }, - ]) - - expect(result).toEqual([ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Hello', - cache_control: { type: 'ephemeral' }, - }, - { - type: 'image_url', - image_url: { url: 'data:image/png;base64,AAECAw==' }, - cache_control: { type: 'ephemeral' }, - }, - { - type: 'file', - file: { - filename: 'file.txt', - file_data: 'data:text/plain;base64,ZmlsZSBjb250ZW50', - }, - cache_control: { type: 'ephemeral' }, - }, - ], - }, - ]) - }) - - it('should pass cache control from individual content part provider metadata', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Hello', - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' }, - }, - }, - }, - { - type: 'file', - data: new Uint8Array([0, 1, 2, 3]), - mediaType: 'image/png', - }, - ], - }, - ]) - - expect(result).toEqual([ - { - role: 'user', - content: [ - { - type: 'text', - text: 'Hello', - cache_control: { type: 'ephemeral' }, - }, - { - type: 'image_url', - image_url: { url: 'data:image/png;base64,AAECAw==' }, - }, - ], - }, - ]) - }) - - it('should pass cache control from assistant message provider metadata', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'assistant', - content: [{ type: 'text', text: 'Assistant response' }], - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' }, - }, - }, - }, - ]) - - expect(result).toEqual([ - { - role: 'assistant', - content: 'Assistant response', - cache_control: { type: 'ephemeral' }, - }, - ]) - }) - - it('should pass cache control from tool message provider metadata', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: 'call-123', - toolName: 'calculator', - output: { - type: 'json', - value: { answer: 42 }, - }, - }, - ], - providerOptions: { - anthropic: { - cacheControl: { type: 'ephemeral' }, - }, - }, - }, - ]) - - expect(result).toEqual([ - { - role: 'tool', - tool_call_id: 'call-123', - content: JSON.stringify({ answer: 42 }), - cache_control: { type: 'ephemeral' }, - }, - ]) - }) - - it('should support the alias cache_control field', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'system', - content: 'System prompt', - providerOptions: { - anthropic: { - cache_control: { type: 'ephemeral' }, - }, - }, - }, - ]) - - expect(result).toEqual([ - { - role: 'system', - content: 'System prompt', - cache_control: { type: 'ephemeral' }, - }, - ]) - }) - - it('should support cache control on last message in content array', () => { - const result = convertToOpenRouterChatMessages([ - { - role: 'system', - content: 'System prompt', - }, - { - role: 'user', - content: [ - { type: 'text', text: 'User prompt' }, - { - type: 'text', - text: 'User prompt 2', - providerOptions: { - anthropic: { cacheControl: { type: 'ephemeral' } }, - }, - }, - ], - }, - ]) - - expect(result).toEqual([ - { - role: 'system', - content: 'System prompt', - }, - { - role: 'user', - content: [ - { type: 'text', text: 'User prompt' }, - { - type: 'text', - text: 'User prompt 2', - cache_control: { type: 'ephemeral' }, - }, - ], - }, - ]) - }) -}) diff --git a/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.ts b/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.ts deleted file mode 100644 index 41cf10d76a..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/chat/convert-to-openrouter-chat-messages.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { getFileUrl } from './file-url-utils' -import { isUrl } from './is-url' -import { ReasoningDetailType } from '../schemas/reasoning-details' - -import type { ReasoningDetailUnion } from '../schemas/reasoning-details' -import type { - ChatCompletionContentPart, - OpenRouterChatCompletionsInput, -} from '../types/openrouter-chat-completions-input' -import type { - LanguageModelV2FilePart, - LanguageModelV2Prompt, - LanguageModelV2TextPart, - LanguageModelV2ToolResultPart, - SharedV2ProviderMetadata, -} from '@ai-sdk/provider' - -// Type for OpenRouter Cache Control following Anthropic's pattern -export type OpenRouterCacheControl = { type: 'ephemeral' } - -function getCacheControl( - providerMetadata: SharedV2ProviderMetadata | undefined, -): OpenRouterCacheControl | undefined { - const anthropic = providerMetadata?.anthropic - const openrouter = providerMetadata?.openrouter - - // Allow both cacheControl and cache_control: - return (openrouter?.cacheControl ?? - openrouter?.cache_control ?? - anthropic?.cacheControl ?? - anthropic?.cache_control) as OpenRouterCacheControl | undefined -} - -export function convertToOpenRouterChatMessages( - prompt: LanguageModelV2Prompt, -): OpenRouterChatCompletionsInput { - const messages: OpenRouterChatCompletionsInput = [] - for (const { role, content, providerOptions } of prompt) { - switch (role) { - case 'system': { - messages.push({ - role: 'system', - content, - cache_control: getCacheControl(providerOptions), - }) - break - } - - case 'user': { - // Get message level cache control - const messageCacheControl = getCacheControl(providerOptions) - const contentParts: ChatCompletionContentPart[] = content.map( - (part: LanguageModelV2TextPart | LanguageModelV2FilePart) => { - const cacheControl = - getCacheControl(part.providerOptions) ?? messageCacheControl - - switch (part.type) { - case 'text': - return { - type: 'text' as const, - text: part.text, - // For text parts, only use part-specific cache control - cache_control: cacheControl, - } - case 'file': { - if (part.mediaType?.startsWith('image/')) { - const url = getFileUrl({ - part, - defaultMediaType: 'image/jpeg', - }) - return { - type: 'image_url' as const, - image_url: { - url, - }, - // For image parts, use part-specific or message-level cache control - cache_control: cacheControl, - } - } - - const fileName = String( - part.providerOptions?.openrouter?.filename ?? - part.filename ?? - '', - ) - - const fileData = getFileUrl({ - part, - defaultMediaType: 'application/pdf', - }) - - if ( - isUrl({ - url: fileData, - protocols: new Set(['http:', 'https:']), - }) - ) { - return { - type: 'file' as const, - file: { - filename: fileName, - file_data: fileData, - }, - } satisfies ChatCompletionContentPart - } - - return { - type: 'file' as const, - file: { - filename: fileName, - file_data: fileData, - }, - cache_control: cacheControl, - } satisfies ChatCompletionContentPart - } - default: { - return { - type: 'text' as const, - text: '', - cache_control: cacheControl, - } - } - } - }, - ) - - // For multi-part messages, don't add cache_control at the root level - messages.push({ - role: 'user', - content: contentParts, - }) - - break - } - - case 'assistant': { - let text = '' - let reasoning = '' - const reasoningDetails: ReasoningDetailUnion[] = [] - const toolCalls: Array<{ - id: string - type: 'function' - function: { name: string; arguments: string } - }> = [] - - for (const part of content) { - switch (part.type) { - case 'text': { - text += part.text - break - } - case 'tool-call': { - toolCalls.push({ - id: part.toolCallId, - type: 'function', - function: { - name: part.toolName, - arguments: JSON.stringify(part.input), - }, - }) - break - } - case 'reasoning': { - reasoning += part.text - reasoningDetails.push({ - type: ReasoningDetailType.Text, - text: part.text, - }) - - break - } - - case 'file': - break - default: { - break - } - } - } - - messages.push({ - role: 'assistant', - content: text, - tool_calls: toolCalls.length > 0 ? toolCalls : undefined, - reasoning: reasoning || undefined, - reasoning_details: - reasoningDetails.length > 0 ? reasoningDetails : undefined, - cache_control: getCacheControl(providerOptions), - }) - - break - } - - case 'tool': { - for (const toolResponse of content) { - const content = getToolResultContent(toolResponse) - - messages.push({ - role: 'tool', - tool_call_id: toolResponse.toolCallId, - content, - cache_control: - getCacheControl(providerOptions) ?? - getCacheControl(toolResponse.providerOptions), - }) - } - break - } - - default: { - break - } - } - } - - return messages -} - -function getToolResultContent(input: LanguageModelV2ToolResultPart): string { - return input.output.type === 'text' - ? input.output.value - : JSON.stringify(input.output.value) -} diff --git a/packages/internal/src/openrouter-ai-sdk/chat/file-url-utils.ts b/packages/internal/src/openrouter-ai-sdk/chat/file-url-utils.ts deleted file mode 100644 index d094c28cb4..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/chat/file-url-utils.ts +++ /dev/null @@ -1,34 +0,0 @@ - -import { convertUint8ArrayToBase64 } from '@ai-sdk/provider-utils' - -import { isUrl } from './is-url' - -import type { LanguageModelV2FilePart } from '@ai-sdk/provider' - -export function getFileUrl({ - part, - defaultMediaType, -}: { - part: LanguageModelV2FilePart - defaultMediaType: string -}) { - if (part.data instanceof Uint8Array) { - const base64 = convertUint8ArrayToBase64(part.data) - return `data:${part.mediaType ?? defaultMediaType};base64,${base64}` - } - - const stringUrl = part.data.toString() - - if ( - isUrl({ - url: stringUrl, - protocols: new Set(['http:', 'https:']), - }) - ) { - return stringUrl - } - - return stringUrl.startsWith('data:') - ? stringUrl - : `data:${part.mediaType ?? defaultMediaType};base64,${stringUrl}` -} diff --git a/packages/internal/src/openrouter-ai-sdk/chat/get-tool-choice.ts b/packages/internal/src/openrouter-ai-sdk/chat/get-tool-choice.ts deleted file mode 100644 index dad83d4d9a..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/chat/get-tool-choice.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { z } from 'zod/v4' - -import type { LanguageModelV2ToolChoice } from '@ai-sdk/provider' - - -const ChatCompletionToolChoiceSchema = z.union([ - z.literal('auto'), - z.literal('none'), - z.literal('required'), - z.object({ - type: z.literal('function'), - function: z.object({ - name: z.string(), - }), - }), -]) - -type ChatCompletionToolChoice = z.infer - -export function getChatCompletionToolChoice( - toolChoice: LanguageModelV2ToolChoice, -): ChatCompletionToolChoice { - switch (toolChoice.type) { - case 'auto': - case 'none': - case 'required': - return toolChoice.type - case 'tool': { - return { - type: 'function', - function: { name: toolChoice.toolName }, - } - } - default: { - toolChoice satisfies never - throw new Error(`Invalid tool choice type: ${toolChoice}`) - } - } -} diff --git a/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts b/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts deleted file mode 100644 index d2143a7533..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/chat/index.test.ts +++ /dev/null @@ -1,1599 +0,0 @@ -import { convertReadableStreamToArray } from '@ai-sdk/provider-utils/test' -import { beforeEach, describe, expect, it } from 'bun:test' - -import { createOpenRouter } from '../provider' -import { ReasoningDetailType } from '../schemas/reasoning-details' - -import type { ReasoningDetailUnion } from '../schemas/reasoning-details' -import type { LanguageModelV2Prompt } from '@ai-sdk/provider' - - - -const TEST_PROMPT: LanguageModelV2Prompt = [ - { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, -] - -const TEST_LOGPROBS = { - content: [ - { - token: 'Hello', - logprob: -0.0009994634, - top_logprobs: [ - { - token: 'Hello', - logprob: -0.0009994634, - }, - ], - }, - { - token: '!', - logprob: -0.13410144, - top_logprobs: [ - { - token: '!', - logprob: -0.13410144, - }, - ], - }, - { - token: ' How', - logprob: -0.0009250381, - top_logprobs: [ - { - token: ' How', - logprob: -0.0009250381, - }, - ], - }, - { - token: ' can', - logprob: -0.047709424, - top_logprobs: [ - { - token: ' can', - logprob: -0.047709424, - }, - ], - }, - { - token: ' I', - logprob: -0.000009014684, - top_logprobs: [ - { - token: ' I', - logprob: -0.000009014684, - }, - ], - }, - { - token: ' assist', - logprob: -0.009125131, - top_logprobs: [ - { - token: ' assist', - logprob: -0.009125131, - }, - ], - }, - { - token: ' you', - logprob: -0.0000066306106, - top_logprobs: [ - { - token: ' you', - logprob: -0.0000066306106, - }, - ], - }, - { - token: ' today', - logprob: -0.00011093382, - top_logprobs: [ - { - token: ' today', - logprob: -0.00011093382, - }, - ], - }, - { - token: '?', - logprob: -0.00004596782, - top_logprobs: [ - { - token: '?', - logprob: -0.00004596782, - }, - ], - }, - ], -} - -type MockResponseDefinition = - | { - type: 'json-value' - body: any - headers?: Record - status?: number - } - | { - type: 'stream-chunks' - chunks: string[] - headers?: Record - status?: number - } - -type MockServerRoute = { - response: MockResponseDefinition -} - -type MockServerCall = { - requestHeaders: Record - requestBodyJson: Promise -} - -const createStreamFromChunks = (chunks: string[]) => - new ReadableStream({ - start(controller) { - try { - for (const chunk of chunks) { - controller.enqueue(chunk) - } - } finally { - controller.close() - } - }, - }).pipeThrough(new TextEncoderStream()) - -function toHeadersRecord(headers?: HeadersInit): Record { - const result: Record = {} - - if (!headers) { - return result - } - - if (headers instanceof Headers) { - headers.forEach((value, key) => { - result[key.toLowerCase()] = value - }) - return result - } - - if (Array.isArray(headers)) { - for (const [key, value] of headers) { - result[String(key).toLowerCase()] = String(value) - } - return result - } - - for (const [key, value] of Object.entries(headers)) { - if (typeof value !== 'undefined') { - result[key.toLowerCase()] = String(value) - } - } - - return result -} - -function parseRequestBody(body: BodyInit | null | undefined): any { - if (body == null) { - return undefined - } - - if (typeof body === 'string') { - try { - return JSON.parse(body) - } catch { - return undefined - } - } - - return undefined -} - -function createMockServer(routes: Record) { - const urls: Record = Object.fromEntries( - Object.entries(routes).map(([url, config]) => [ - url, - { - response: { ...config.response }, - }, - ]), - ) - - const calls: MockServerCall[] = [] - - const buildResponse = (definition: MockResponseDefinition): Response => { - const status = definition.status ?? 200 - - if (definition.type === 'json-value') { - return new Response(JSON.stringify(definition.body), { - status, - headers: { - 'Content-Type': 'application/json', - ...definition.headers, - }, - }) - } - - return new Response(createStreamFromChunks(definition.chunks), { - status, - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...definition.headers, - }, - }) - } - - const fetchImpl = async (input: RequestInfo, init: RequestInit = {}) => { - const url = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url - - const route = urls[url] - - if (!route) { - return new Response('Not Found', { status: 404 }) - } - - const requestHeaders = toHeadersRecord(init.headers) - const requestBodyJson = Promise.resolve(parseRequestBody(init.body)) - - calls.push({ requestHeaders, requestBodyJson }) - - return buildResponse(route.response) - } - - const fetch = ((input: RequestInfo | URL, init?: RequestInit) => - fetchImpl(input as RequestInfo, init ?? {})) as typeof global.fetch - - fetch.preconnect = async () => {} - - return { - urls, - calls, - fetch, - } -} - -describe('doGenerate', () => { - const server = createMockServer({ - 'https://openrouter.ai/api/v1/chat/completions': { - response: { type: 'json-value', body: {} }, - }, - }) - - const provider = createOpenRouter({ - apiKey: 'test-api-key', - compatibility: 'strict', - fetch: server.fetch, - }) - - const model = provider.chat('anthropic/claude-3.5-sonnet') - - beforeEach(() => { - server.calls.length = 0 - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'json-value', - body: {}, - } - }) - - function prepareJsonResponse({ - content = '', - reasoning, - reasoning_details, - usage = { - prompt_tokens: 4, - total_tokens: 34, - completion_tokens: 30, - }, - logprobs = null, - finish_reason = 'stop', - }: { - content?: string - reasoning?: string - reasoning_details?: Array - usage?: { - prompt_tokens: number - total_tokens: number - completion_tokens: number - } - logprobs?: { - content: - | { - token: string - logprob: number - top_logprobs: { token: string; logprob: number }[] - }[] - | null - } | null - finish_reason?: string - } = {}) { - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'json-value', - body: { - id: 'chatcmpl-95ZTZkhr0mHNKqerQfiwkuox3PHAd', - object: 'chat.completion', - created: 1711115037, - model: 'gpt-3.5-turbo-0125', - choices: [ - { - index: 0, - message: { - role: 'assistant', - content, - reasoning, - reasoning_details, - }, - logprobs, - finish_reason, - }, - ], - usage, - system_fingerprint: 'fp_3bc1b5746c', - }, - } - } - - it('should extract text response', async () => { - prepareJsonResponse({ content: 'Hello, World!' }) - - const result = await model.doGenerate({ - prompt: TEST_PROMPT, - }) - - expect(result.content[0]).toStrictEqual({ - type: 'text', - text: 'Hello, World!', - }) - }) - - it('should extract usage', async () => { - prepareJsonResponse({ - content: '', - usage: { prompt_tokens: 20, total_tokens: 25, completion_tokens: 5 }, - }) - - const { usage } = await model.doGenerate({ - prompt: TEST_PROMPT, - }) - - expect(usage).toStrictEqual({ - inputTokens: 20, - outputTokens: 5, - totalTokens: 25, - reasoningTokens: 0, - cachedInputTokens: 0, - }) - }) - - it('should extract logprobs', async () => { - prepareJsonResponse({ - logprobs: TEST_LOGPROBS, - }) - - await provider.chat('openai/gpt-3.5-turbo', { logprobs: 1 }).doGenerate({ - prompt: TEST_PROMPT, - }) - }) - - it('should extract finish reason', async () => { - prepareJsonResponse({ - content: '', - finish_reason: 'stop', - }) - - const response = await model.doGenerate({ - prompt: TEST_PROMPT, - }) - - expect(response.finishReason).toStrictEqual('stop') - }) - - it('should support unknown finish reason', async () => { - prepareJsonResponse({ - content: '', - finish_reason: 'eos', - }) - - const response = await model.doGenerate({ - prompt: TEST_PROMPT, - }) - - expect(response.finishReason).toStrictEqual('unknown') - }) - - it('should extract reasoning content from reasoning field', async () => { - prepareJsonResponse({ - content: 'Hello!', - reasoning: - 'I need to think about this... The user said hello, so I should respond with a greeting.', - }) - - const result = await model.doGenerate({ - prompt: TEST_PROMPT, - }) - - expect(result.content).toStrictEqual([ - { - type: 'reasoning', - text: 'I need to think about this... The user said hello, so I should respond with a greeting.', - }, - { - type: 'text', - text: 'Hello!', - }, - ]) - }) - - it('should extract reasoning content from reasoning_details', async () => { - prepareJsonResponse({ - content: 'Hello!', - reasoning_details: [ - { - type: ReasoningDetailType.Text, - text: 'Let me analyze this request...', - }, - { - type: ReasoningDetailType.Summary, - summary: 'The user wants a greeting response.', - }, - ], - }) - - const result = await model.doGenerate({ - prompt: TEST_PROMPT, - }) - - expect(result.content).toStrictEqual([ - { - type: 'reasoning', - text: 'Let me analyze this request...', - }, - { - type: 'reasoning', - text: 'The user wants a greeting response.', - }, - { - type: 'text', - text: 'Hello!', - }, - ]) - }) - - it('should handle encrypted reasoning details', async () => { - prepareJsonResponse({ - content: 'Hello!', - reasoning_details: [ - { - type: ReasoningDetailType.Encrypted, - data: 'encrypted_reasoning_data_here', - }, - ], - }) - - const result = await model.doGenerate({ - prompt: TEST_PROMPT, - }) - - expect(result.content).toStrictEqual([ - { - type: 'reasoning', - text: '[REDACTED]', - }, - { - type: 'text', - text: 'Hello!', - }, - ]) - }) - - it('should prioritize reasoning_details over reasoning when both are present', async () => { - prepareJsonResponse({ - content: 'Hello!', - reasoning: 'This should be ignored when reasoning_details is present', - reasoning_details: [ - { - type: ReasoningDetailType.Text, - text: 'Processing from reasoning_details...', - }, - { - type: ReasoningDetailType.Summary, - summary: 'Summary from reasoning_details', - }, - ], - }) - - const result = await model.doGenerate({ - prompt: TEST_PROMPT, - }) - - expect(result.content).toStrictEqual([ - { - type: 'reasoning', - text: 'Processing from reasoning_details...', - }, - { - type: 'reasoning', - text: 'Summary from reasoning_details', - }, - { - type: 'text', - text: 'Hello!', - }, - ]) - - // Verify that the reasoning field content is not included - expect(result.content).not.toContainEqual({ - type: 'reasoning', - text: 'This should be ignored when reasoning_details is present', - }) - }) - - it('should pass the model and the messages', async () => { - prepareJsonResponse({ content: '' }) - - await model.doGenerate({ - prompt: TEST_PROMPT, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toStrictEqual({ - model: 'anthropic/claude-3.5-sonnet', - messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], - }) - }) - - it('should pass the models array when provided', async () => { - prepareJsonResponse({ content: '' }) - - const customModel = provider.chat('anthropic/claude-3.5-sonnet', { - models: ['anthropic/claude-2', 'gryphe/mythomax-l2-13b'], - }) - - await customModel.doGenerate({ - prompt: TEST_PROMPT, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toStrictEqual({ - model: 'anthropic/claude-3.5-sonnet', - models: ['anthropic/claude-2', 'gryphe/mythomax-l2-13b'], - messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], - }) - }) - - it('should pass settings', async () => { - prepareJsonResponse() - - await provider - .chat('openai/gpt-3.5-turbo', { - logitBias: { 50256: -100 }, - logprobs: 2, - parallelToolCalls: false, - user: 'test-user-id', - }) - .doGenerate({ - prompt: TEST_PROMPT, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toStrictEqual({ - model: 'openai/gpt-3.5-turbo', - messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], - logprobs: true, - top_logprobs: 2, - logit_bias: { 50256: -100 }, - parallel_tool_calls: false, - user: 'test-user-id', - }) - }) - - it('should pass tools and toolChoice', async () => { - prepareJsonResponse({ content: '' }) - - await model.doGenerate({ - prompt: TEST_PROMPT, - tools: [ - { - type: 'function', - name: 'test-tool', - description: 'Test tool', - inputSchema: { - type: 'object', - properties: { value: { type: 'string' } }, - required: ['value'], - additionalProperties: false, - $schema: 'http://json-schema.org/draft-07/schema#', - }, - }, - ], - toolChoice: { - type: 'tool', - toolName: 'test-tool', - }, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toStrictEqual({ - model: 'anthropic/claude-3.5-sonnet', - messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], - tools: [ - { - type: 'function', - function: { - name: 'test-tool', - description: 'Test tool', - parameters: { - type: 'object', - properties: { value: { type: 'string' } }, - required: ['value'], - additionalProperties: false, - $schema: 'http://json-schema.org/draft-07/schema#', - }, - }, - }, - ], - tool_choice: { - type: 'function', - function: { name: 'test-tool' }, - }, - }) - }) - - it('should pass headers', async () => { - prepareJsonResponse({ content: '' }) - - const provider = createOpenRouter({ - apiKey: 'test-api-key', - headers: { - 'Custom-Provider-Header': 'provider-header-value', - }, - fetch: server.fetch, - }) - - await provider.chat('openai/gpt-3.5-turbo').doGenerate({ - prompt: TEST_PROMPT, - headers: { - 'Custom-Request-Header': 'request-header-value', - }, - }) - - const requestHeaders = server.calls[0]!.requestHeaders - - expect(requestHeaders.authorization).toBe('Bearer test-api-key') - expect(requestHeaders['content-type']).toBe('application/json') - expect(requestHeaders['custom-provider-header']).toBe( - 'provider-header-value', - ) - expect(requestHeaders['custom-request-header']).toBe('request-header-value') - expect(requestHeaders['user-agent']).toMatch( - /^ai-sdk\/provider-utils\/\d+\.\d+\.\d+ runtime\/bun\/\d+\.\d+\.\d+$/, - ) - }) - - it('should pass responseFormat for JSON schema structured outputs', async () => { - prepareJsonResponse({ content: '{"name": "John", "age": 30}' }) - - const testSchema = { - type: 'object' as const, - properties: { - name: { type: 'string' as const }, - age: { type: 'number' as const }, - }, - required: ['name', 'age'], - additionalProperties: false, - } - - await model.doGenerate({ - prompt: TEST_PROMPT, - responseFormat: { - type: 'json', - schema: testSchema, - name: 'PersonResponse', - description: 'A person object', - }, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toStrictEqual({ - model: 'anthropic/claude-3.5-sonnet', - messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], - response_format: { - type: 'json_schema', - json_schema: { - schema: testSchema, - strict: true, - name: 'PersonResponse', - description: 'A person object', - }, - }, - }) - }) - - it('should use default name when name is not provided in responseFormat', async () => { - prepareJsonResponse({ content: '{"name": "John", "age": 30}' }) - - const testSchema = { - type: 'object' as const, - properties: { - name: { type: 'string' as const }, - age: { type: 'number' as const }, - }, - required: ['name', 'age'], - additionalProperties: false, - } - - await model.doGenerate({ - prompt: TEST_PROMPT, - responseFormat: { - type: 'json', - schema: testSchema, - }, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toStrictEqual({ - model: 'anthropic/claude-3.5-sonnet', - messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], - response_format: { - type: 'json_schema', - json_schema: { - schema: testSchema, - strict: true, - name: 'response', - }, - }, - }) - }) -}) - -describe('doStream', () => { - const server = createMockServer({ - 'https://openrouter.ai/api/v1/chat/completions': { - response: { type: 'stream-chunks', chunks: [] }, - }, - }) - - const provider = createOpenRouter({ - apiKey: 'test-api-key', - compatibility: 'strict', - fetch: server.fetch, - }) - - const model = provider.chat('anthropic/claude-3.5-sonnet') - - beforeEach(() => { - server.calls.length = 0 - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: [], - } - }) - - function prepareStreamResponse({ - content, - usage = { - prompt_tokens: 17, - total_tokens: 244, - completion_tokens: 227, - }, - logprobs = null, - finish_reason = 'stop', - }: { - content: string[] - usage?: { - prompt_tokens: number - total_tokens: number - completion_tokens: number - } - logprobs?: { - content: - | { - token: string - logprob: number - top_logprobs: { token: string; logprob: number }[] - }[] - | null - } | null - finish_reason?: string - }) { - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: [ - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613",` + - `"system_fingerprint":null,"choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}\n\n`, - ...content.flatMap((text) => { - return `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":1,"delta":{"content":"${text}"},"finish_reason":null}]}\n\n` - }), - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":null,"choices":[{"index":0,"delta":{},"finish_reason":"${finish_reason}","logprobs":${JSON.stringify( - logprobs, - )}}]}\n\n`, - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1702657020,"model":"gpt-3.5-turbo-0613","system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":${JSON.stringify( - usage, - )}}\n\n`, - 'data: [DONE]\n\n', - ], - } - } - - it('should stream text deltas', async () => { - prepareStreamResponse({ - content: ['Hello', ', ', 'World!'], - finish_reason: 'stop', - usage: { - prompt_tokens: 17, - total_tokens: 244, - completion_tokens: 227, - }, - logprobs: TEST_LOGPROBS, - }) - - const { stream } = await model.doStream({ - prompt: TEST_PROMPT, - }) - - // note: space moved to last chunk bc of trimming - const elements = await convertReadableStreamToArray(stream) - expect(elements).toStrictEqual([ - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0613', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0613', - }, - { type: 'text-start', id: expect.any(String) }, - { type: 'text-delta', delta: 'Hello', id: expect.any(String) }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0613', - }, - { type: 'text-delta', delta: ', ', id: expect.any(String) }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0613', - }, - { type: 'text-delta', delta: 'World!', id: expect.any(String) }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0613', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0613', - }, - { - type: 'text-end', - id: expect.any(String), - }, - { - type: 'finish', - finishReason: 'stop', - - providerMetadata: { - openrouter: { - usage: { - completionTokens: 227, - promptTokens: 17, - totalTokens: 244, - }, - }, - }, - usage: { - inputTokens: 17, - outputTokens: 227, - totalTokens: 244, - reasoningTokens: Number.NaN, - cachedInputTokens: Number.NaN, - }, - }, - ]) - }) - - it('should prioritize reasoning_details over reasoning when both are present in streaming', async () => { - // This test verifies that when the API returns both 'reasoning' and 'reasoning_details' fields, - // we prioritize reasoning_details and ignore the reasoning field to avoid duplicates. - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: [ - // First chunk: both reasoning and reasoning_details with different content - `data: {"id":"chatcmpl-reasoning","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":"",` + - `"reasoning":"This should be ignored...",` + - `"reasoning_details":[{"type":"${ReasoningDetailType.Text}","text":"Let me think about this..."}]},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - // Second chunk: reasoning_details with multiple types - `data: {"id":"chatcmpl-reasoning","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{` + - `"reasoning":"Also ignored",` + - `"reasoning_details":[{"type":"${ReasoningDetailType.Summary}","summary":"User wants a greeting"},{"type":"${ReasoningDetailType.Encrypted}","data":"secret"}]},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - // Third chunk: only reasoning field (should be processed) - `data: {"id":"chatcmpl-reasoning","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{` + - `"reasoning":"This reasoning is used"},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - // Content chunk - `data: {"id":"chatcmpl-reasoning","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"Hello!"},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - // Finish chunk - `data: {"id":"chatcmpl-reasoning","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{},` + - `"logprobs":null,"finish_reason":"stop"}]}\n\n`, - `data: {"id":"chatcmpl-reasoning","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"completion_tokens":30,"total_tokens":47}}\n\n`, - 'data: [DONE]\n\n', - ], - } - - const { stream } = await model.doStream({ - prompt: TEST_PROMPT, - }) - - const elements = await convertReadableStreamToArray(stream) - - // Filter for reasoning-related elements - const reasoningElements = elements.filter( - (el) => - el.type === 'reasoning-start' || - el.type === 'reasoning-delta' || - el.type === 'reasoning-end', - ) - - // Debug output to see what we're getting - // console.log('Reasoning elements count:', reasoningElements.length); - // console.log('Reasoning element types:', reasoningElements.map(el => el.type)); - - // We should get reasoning content from reasoning_details when present, not reasoning field - // start + 4 deltas (text, summary, encrypted, reasoning-only) + end = 6 - expect(reasoningElements).toHaveLength(6) - - // Verify the content comes from reasoning_details, not reasoning field - const reasoningDeltas = reasoningElements - .filter((el) => el.type === 'reasoning-delta') - .map( - (el) => - (el as { type: 'reasoning-delta'; delta: string; id: string }).delta, - ) - - expect(reasoningDeltas).toEqual([ - 'Let me think about this...', // from reasoning_details text - 'User wants a greeting', // from reasoning_details summary - '[REDACTED]', // from reasoning_details encrypted - 'This reasoning is used', // from reasoning field (no reasoning_details) - ]) - - // Verify that "This should be ignored..." and "Also ignored" are NOT in the output - expect(reasoningDeltas).not.toContain('This should be ignored...') - expect(reasoningDeltas).not.toContain('Also ignored') - }) - - it('should maintain correct reasoning order when content comes after reasoning (issue #7824)', async () => { - // This test reproduces the issue where reasoning appears first but then gets "pushed down" - // by content that comes later in the stream - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: [ - // First chunk: Start with reasoning - `data: {"id":"chatcmpl-order-test","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant",` + - `"reasoning":"I need to think about this step by step..."},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - // Second chunk: More reasoning - `data: {"id":"chatcmpl-order-test","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{` + - `"reasoning":" First, I should analyze the request."},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - // Third chunk: Even more reasoning - `data: {"id":"chatcmpl-order-test","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{` + - `"reasoning":" Then I should provide a helpful response."},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - // Fourth chunk: Content starts - `data: {"id":"chatcmpl-order-test","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"Hello! "},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - // Fifth chunk: More content - `data: {"id":"chatcmpl-order-test","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"content":"How can I help you today?"},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - // Finish chunk - `data: {"id":"chatcmpl-order-test","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{},` + - `"logprobs":null,"finish_reason":"stop"}]}\n\n`, - `data: {"id":"chatcmpl-order-test","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":17,"completion_tokens":30,"total_tokens":47}}\n\n`, - 'data: [DONE]\n\n', - ], - } - - const { stream } = await model.doStream({ - prompt: TEST_PROMPT, - }) - - const elements = await convertReadableStreamToArray(stream) - - // The expected order should be: - // 1. reasoning-start - // 2. reasoning-delta (3 times) - // 3. reasoning-end (when text starts) - // 4. text-start - // 5. text-delta (2 times) - // 6. text-end (when stream finishes) - - const streamOrder = elements.map((el) => el.type) - - // Find the positions of key events - const reasoningStartIndex = streamOrder.indexOf('reasoning-start') - const reasoningEndIndex = streamOrder.indexOf('reasoning-end') - const textStartIndex = streamOrder.indexOf('text-start') - - // Reasoning should come before text and end before text starts - expect(reasoningStartIndex).toBeLessThan(textStartIndex) - expect(reasoningEndIndex).toBeLessThan(textStartIndex) - - // Verify reasoning content - const reasoningDeltas = elements - .filter((el) => el.type === 'reasoning-delta') - .map((el) => (el as { type: 'reasoning-delta'; delta: string }).delta) - - expect(reasoningDeltas).toEqual([ - 'I need to think about this step by step...', - ' First, I should analyze the request.', - ' Then I should provide a helpful response.', - ]) - - // Verify text content - const textDeltas = elements - .filter((el) => el.type === 'text-delta') - .map((el) => (el as { type: 'text-delta'; delta: string }).delta) - - expect(textDeltas).toEqual(['Hello! ', 'How can I help you today?']) - }) - - it('should stream tool deltas', async () => { - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: [ - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,` + - `"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":""}}]},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\""}}]},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"value"}}]},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\":\\""}}]},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"Spark"}}]},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"le"}}]},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":" Day"}}]},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\\"}"}}]},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]}\n\n`, - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":53,"completion_tokens":17,"total_tokens":70}}\n\n`, - 'data: [DONE]\n\n', - ], - } - - const { stream } = await model.doStream({ - tools: [ - { - type: 'function', - name: 'test-tool', - inputSchema: { - type: 'object', - properties: { value: { type: 'string' } }, - required: ['value'], - additionalProperties: false, - $schema: 'http://json-schema.org/draft-07/schema#', - }, - }, - ], - prompt: TEST_PROMPT, - }) - - const elements = await convertReadableStreamToArray(stream) - - expect(elements).toStrictEqual([ - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - toolName: 'test-tool', - type: 'tool-input-start', - }, - { - type: 'tool-input-delta', - id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - delta: '{"', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - type: 'tool-input-delta', - id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - delta: 'value', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - type: 'tool-input-delta', - id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - delta: '":"', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - type: 'tool-input-delta', - id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - delta: 'Spark', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - type: 'tool-input-delta', - id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - delta: 'le', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - type: 'tool-input-delta', - id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - delta: ' Day', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - type: 'tool-input-delta', - id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - delta: '"}', - }, - { - type: 'tool-call', - toolCallId: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - toolName: 'test-tool', - input: '{"value":"Sparkle Day"}', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - type: 'finish', - finishReason: 'tool-calls', - providerMetadata: { - openrouter: { - usage: { - completionTokens: 17, - promptTokens: 53, - totalTokens: 70, - }, - }, - }, - usage: { - inputTokens: 53, - outputTokens: 17, - totalTokens: 70, - reasoningTokens: Number.NaN, - cachedInputTokens: Number.NaN, - }, - }, - ]) - }) - - it('should stream tool call that is sent in one chunk', async () => { - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: [ - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{"role":"assistant","content":null,` + - `"tool_calls":[{"index":0,"id":"call_O17Uplv4lJvD6DVdIvFFeRMw","type":"function","function":{"name":"test-tool","arguments":"{\\"value\\":\\"Sparkle Day\\"}"}}]},` + - `"logprobs":null,"finish_reason":null}]}\n\n`, - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[{"index":0,"delta":{},"logprobs":null,"finish_reason":"tool_calls"}]}\n\n`, - `data: {"id":"chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP","object":"chat.completion.chunk","created":1711357598,"model":"gpt-3.5-turbo-0125",` + - `"system_fingerprint":"fp_3bc1b5746c","choices":[],"usage":{"prompt_tokens":53,"completion_tokens":17,"total_tokens":70}}\n\n`, - 'data: [DONE]\n\n', - ], - } - - const { stream } = await model.doStream({ - tools: [ - { - type: 'function', - name: 'test-tool', - inputSchema: { - type: 'object', - properties: { value: { type: 'string' } }, - required: ['value'], - additionalProperties: false, - $schema: 'http://json-schema.org/draft-07/schema#', - }, - }, - ], - prompt: TEST_PROMPT, - }) - - const elements = await convertReadableStreamToArray(stream) - expect(elements).toStrictEqual([ - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - type: 'tool-input-start', - id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - toolName: 'test-tool', - }, - { - type: 'tool-input-delta', - id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - delta: '{"value":"Sparkle Day"}', - }, - { - type: 'tool-input-end', - id: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - }, - { - type: 'tool-call', - toolCallId: 'call_O17Uplv4lJvD6DVdIvFFeRMw', - toolName: 'test-tool', - input: '{"value":"Sparkle Day"}', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - type: 'response-metadata', - id: 'chatcmpl-96aZqmeDpA9IPD6tACY8djkMsJCMP', - }, - { - type: 'response-metadata', - modelId: 'gpt-3.5-turbo-0125', - }, - { - type: 'finish', - finishReason: 'tool-calls', - providerMetadata: { - openrouter: { - usage: { - completionTokens: 17, - promptTokens: 53, - totalTokens: 70, - }, - }, - }, - usage: { - inputTokens: 53, - outputTokens: 17, - totalTokens: 70, - reasoningTokens: Number.NaN, - cachedInputTokens: Number.NaN, - }, - }, - ]) - }) - - it('should handle error stream parts', async () => { - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: [ - `data: {"error":{"message": "The server had an error processing your request. Sorry about that! You can retry your request, or contact us through our ` + - `help center at help.openrouter.com if you keep seeing this error.","type":"server_error","param":null,"code":null}}\n\n`, - 'data: [DONE]\n\n', - ], - } - - const { stream } = await model.doStream({ - prompt: TEST_PROMPT, - }) - - const elements = await convertReadableStreamToArray(stream) - - expect(elements).toStrictEqual([ - { - type: 'error', - error: { - message: - 'The server had an error processing your request. Sorry about that! ' + - 'You can retry your request, or contact us through our help center at ' + - 'help.openrouter.com if you keep seeing this error.', - type: 'server_error', - code: null, - param: null, - }, - }, - { - finishReason: 'error', - providerMetadata: { - openrouter: { - usage: {}, - }, - }, - type: 'finish', - usage: { - inputTokens: Number.NaN, - outputTokens: Number.NaN, - totalTokens: Number.NaN, - reasoningTokens: Number.NaN, - cachedInputTokens: Number.NaN, - }, - }, - ]) - }) - - it('should handle unparsable stream parts', async () => { - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: ['data: {unparsable}\n\n', 'data: [DONE]\n\n'], - } - - const { stream } = await model.doStream({ - prompt: TEST_PROMPT, - }) - - const elements = await convertReadableStreamToArray(stream) - - expect(elements.length).toBe(2) - expect(elements[0]?.type).toBe('error') - expect(elements[1]).toStrictEqual({ - finishReason: 'error', - - type: 'finish', - providerMetadata: { - openrouter: { - usage: {}, - }, - }, - usage: { - inputTokens: Number.NaN, - outputTokens: Number.NaN, - totalTokens: Number.NaN, - reasoningTokens: Number.NaN, - cachedInputTokens: Number.NaN, - }, - }) - }) - - it('should pass the messages and the model', async () => { - prepareStreamResponse({ content: [] }) - - await model.doStream({ - prompt: TEST_PROMPT, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toStrictEqual({ - stream: true, - stream_options: { include_usage: true }, - model: 'anthropic/claude-3.5-sonnet', - messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], - }) - }) - - it('should pass headers', async () => { - prepareStreamResponse({ content: [] }) - - const provider = createOpenRouter({ - apiKey: 'test-api-key', - headers: { - 'Custom-Provider-Header': 'provider-header-value', - }, - fetch: server.fetch, - }) - - await provider.chat('openai/gpt-3.5-turbo').doStream({ - prompt: TEST_PROMPT, - headers: { - 'Custom-Request-Header': 'request-header-value', - }, - }) - - const requestHeaders = server.calls[0]!.requestHeaders - - expect(requestHeaders.authorization).toBe('Bearer test-api-key') - expect(requestHeaders['content-type']).toBe('application/json') - expect(requestHeaders['custom-provider-header']).toBe( - 'provider-header-value', - ) - expect(requestHeaders['custom-request-header']).toBe('request-header-value') - expect(requestHeaders['user-agent']).toMatch( - /^ai-sdk\/provider-utils\/\d+\.\d+\.\d+ runtime\/bun\/\d+\.\d+\.\d+$/, - ) - }) - - it('should pass extra body', async () => { - prepareStreamResponse({ content: [] }) - - const provider = createOpenRouter({ - apiKey: 'test-api-key', - extraBody: { - custom_field: 'custom_value', - providers: { - anthropic: { - custom_field: 'custom_value', - }, - }, - }, - fetch: server.fetch, - }) - - await provider.chat('anthropic/claude-3.5-sonnet').doStream({ - prompt: TEST_PROMPT, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toHaveProperty('custom_field', 'custom_value') - expect(requestBody).toHaveProperty( - 'providers.anthropic.custom_field', - 'custom_value', - ) - }) - - it('should pass responseFormat for JSON schema structured outputs', async () => { - prepareStreamResponse({ content: ['{"name": "John", "age": 30}'] }) - - const testSchema = { - type: 'object' as const, - properties: { - name: { type: 'string' as const }, - age: { type: 'number' as const }, - }, - required: ['name', 'age'], - additionalProperties: false, - } - - await model.doStream({ - prompt: TEST_PROMPT, - responseFormat: { - type: 'json', - schema: testSchema, - name: 'PersonResponse', - description: 'A person object', - }, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toStrictEqual({ - stream: true, - stream_options: { include_usage: true }, - model: 'anthropic/claude-3.5-sonnet', - messages: [{ role: 'user', content: [{ type: 'text', text: 'Hello' }] }], - response_format: { - type: 'json_schema', - json_schema: { - schema: testSchema, - strict: true, - name: 'PersonResponse', - description: 'A person object', - }, - }, - }) - }) -}) diff --git a/packages/internal/src/openrouter-ai-sdk/chat/index.ts b/packages/internal/src/openrouter-ai-sdk/chat/index.ts deleted file mode 100644 index 593a369c99..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/chat/index.ts +++ /dev/null @@ -1,852 +0,0 @@ -import { InvalidResponseDataError } from '@ai-sdk/provider' -import { - combineHeaders, - createEventSourceResponseHandler, - createJsonResponseHandler, - generateId, - isParsableJson, - postJsonToApi, -} from '@ai-sdk/provider-utils' - -import { convertToOpenRouterChatMessages } from './convert-to-openrouter-chat-messages' -import { getChatCompletionToolChoice } from './get-tool-choice' -import { - OpenRouterNonStreamChatCompletionResponseSchema, - OpenRouterStreamChatCompletionChunkSchema, -} from './schemas' -import { openrouterFailedResponseHandler } from '../schemas/error-response' -import { ReasoningDetailType } from '../schemas/reasoning-details' -import { mapOpenRouterFinishReason } from '../utils/map-finish-reason' - -import type { OpenRouterUsageAccounting } from '../types/index' -import type { - OpenRouterChatModelId, - OpenRouterChatSettings, -} from '../types/openrouter-chat-settings' -import type { - LanguageModelV2, - LanguageModelV2CallOptions, - LanguageModelV2CallWarning, - LanguageModelV2Content, - LanguageModelV2FinishReason, - LanguageModelV2ResponseMetadata, - LanguageModelV2StreamPart, - LanguageModelV2Usage, - SharedV2Headers, -} from '@ai-sdk/provider' -import type { ParseResult } from '@ai-sdk/provider-utils' -import type { FinishReason } from 'ai' -import type { z } from 'zod/v4' - -type OpenRouterChatConfig = { - provider: string - compatibility: 'strict' | 'compatible' - headers: () => Record - url: (options: { modelId: string; path: string }) => string - fetch?: typeof fetch - extraBody?: Record -} - -export class OpenRouterChatLanguageModel implements LanguageModelV2 { - readonly specificationVersion = 'v2' as const - readonly provider = 'openrouter' - readonly defaultObjectGenerationMode = 'tool' as const - - readonly modelId: OpenRouterChatModelId - readonly supportedUrls: Record = { - 'image/*': [ - /^data:image\/[a-zA-Z]+;base64,/, - /^https?:\/\/.+\.(jpg|jpeg|png|gif|webp)$/i, - ], - // 'text/*': [/^data:text\//, /^https?:\/\/.+$/], - 'application/*': [/^data:application\//, /^https?:\/\/.+$/], - } - readonly settings: OpenRouterChatSettings - - private readonly config: OpenRouterChatConfig - - constructor( - modelId: OpenRouterChatModelId, - settings: OpenRouterChatSettings, - config: OpenRouterChatConfig, - ) { - this.modelId = modelId - this.settings = settings - this.config = config - } - - private getArgs({ - prompt, - maxOutputTokens, - temperature, - topP, - frequencyPenalty, - presencePenalty, - seed, - stopSequences, - responseFormat, - topK, - tools, - toolChoice, - }: LanguageModelV2CallOptions) { - const baseArgs = { - // model id: - model: this.modelId, - models: this.settings.models, - - // model specific settings: - logit_bias: this.settings.logitBias, - logprobs: - this.settings.logprobs === true || - typeof this.settings.logprobs === 'number' - ? true - : undefined, - top_logprobs: - typeof this.settings.logprobs === 'number' - ? this.settings.logprobs - : typeof this.settings.logprobs === 'boolean' - ? this.settings.logprobs - ? 0 - : undefined - : undefined, - user: this.settings.user, - parallel_tool_calls: this.settings.parallelToolCalls, - - // standardized settings: - max_tokens: maxOutputTokens, - temperature, - top_p: topP, - frequency_penalty: frequencyPenalty, - presence_penalty: presencePenalty, - seed, - - ...(this.modelId === 'x-ai/grok-code-fast-1' - ? {} - : { stop: stopSequences }), - response_format: responseFormat, - top_k: topK, - - // messages: - messages: convertToOpenRouterChatMessages(prompt), - - // OpenRouter specific settings: - include_reasoning: this.settings.includeReasoning, - reasoning: this.settings.reasoning, - usage: this.settings.usage, - - // Web search settings: - plugins: this.settings.plugins, - web_search_options: this.settings.web_search_options, - // Provider routing settings: - provider: this.settings.provider, - - // extra body: - ...this.config.extraBody, - ...this.settings.extraBody, - } - - if (responseFormat?.type === 'json' && responseFormat.schema != null) { - return { - ...baseArgs, - response_format: { - type: 'json_schema', - json_schema: { - schema: responseFormat.schema, - strict: true, - name: responseFormat.name ?? 'response', - ...(responseFormat.description && { - description: responseFormat.description, - }), - }, - }, - } - } - - if (tools && tools.length > 0) { - // TODO: support built-in tools - const mappedTools = tools - .filter((tool) => tool.type === 'function') - .map((tool) => ({ - type: 'function' as const, - function: { - name: tool.name, - description: tool.description, - parameters: tool.inputSchema, - }, - })) - - return { - ...baseArgs, - tools: mappedTools, - tool_choice: toolChoice - ? getChatCompletionToolChoice(toolChoice) - : undefined, - } - } - - return baseArgs - } - - async doGenerate(options: LanguageModelV2CallOptions): Promise<{ - content: Array - finishReason: LanguageModelV2FinishReason - usage: LanguageModelV2Usage - warnings: Array - providerMetadata?: { - openrouter: { - provider: string - usage: OpenRouterUsageAccounting - } - } - request?: { body?: unknown } - response?: LanguageModelV2ResponseMetadata & { - headers?: SharedV2Headers - body?: unknown - } - }> { - const providerOptions = options.providerOptions || {} - const openrouterOptions = providerOptions.openrouter || {} - - const args = { - ...this.getArgs(options), - ...openrouterOptions, - } - - const { value: response, responseHeaders } = await postJsonToApi({ - url: this.config.url({ - path: '/chat/completions', - modelId: this.modelId, - }), - headers: combineHeaders(this.config.headers(), options.headers), - body: args, - failedResponseHandler: openrouterFailedResponseHandler, - successfulResponseHandler: createJsonResponseHandler( - OpenRouterNonStreamChatCompletionResponseSchema, - ), - abortSignal: options.abortSignal, - fetch: this.config.fetch, - }) - - const choice = response.choices[0] - - if (!choice) { - throw new Error('No choice in response') - } - - // Extract detailed usage information - const usageInfo: LanguageModelV2Usage = response.usage - ? { - inputTokens: response.usage.prompt_tokens ?? 0, - outputTokens: response.usage.completion_tokens ?? 0, - totalTokens: - (response.usage.prompt_tokens ?? 0) + - (response.usage.completion_tokens ?? 0), - reasoningTokens: - response.usage.completion_tokens_details?.reasoning_tokens ?? 0, - cachedInputTokens: - response.usage.prompt_tokens_details?.cached_tokens ?? 0, - } - : { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - reasoningTokens: 0, - cachedInputTokens: 0, - } - - const reasoningDetails = choice.message.reasoning_details ?? [] - - const reasoning: Array = - reasoningDetails.length > 0 - ? reasoningDetails - .map((detail) => { - switch (detail.type) { - case ReasoningDetailType.Text: { - if (detail.text) { - return { - type: 'reasoning' as const, - text: detail.text, - } - } - break - } - case ReasoningDetailType.Summary: { - if (detail.summary) { - return { - type: 'reasoning' as const, - text: detail.summary, - } - } - break - } - case ReasoningDetailType.Encrypted: { - // For encrypted reasoning, we include a redacted placeholder - if (detail.data) { - return { - type: 'reasoning' as const, - text: '[REDACTED]', - } - } - break - } - default: { - detail satisfies never - } - } - return null - }) - .filter((p) => p !== null) - : choice.message.reasoning - ? [ - { - type: 'reasoning' as const, - text: choice.message.reasoning, - }, - ] - : [] - - const content: Array = [] - - // Add reasoning content first - content.push(...reasoning) - - if (choice.message.content) { - content.push({ - type: 'text' as const, - text: choice.message.content, - }) - } - - if (choice.message.tool_calls) { - for (const toolCall of choice.message.tool_calls) { - content.push({ - type: 'tool-call' as const, - toolCallId: toolCall.id ?? generateId(), - toolName: toolCall.function.name, - input: toolCall.function.arguments, - }) - } - } - - if (choice.message.annotations) { - for (const annotation of choice.message.annotations) { - if (annotation.type === 'url_citation') { - content.push({ - type: 'source' as const, - sourceType: 'url' as const, - id: annotation.url_citation.url, - url: annotation.url_citation.url, - title: annotation.url_citation.title, - providerMetadata: { - openrouter: { - content: annotation.url_citation.content || '', - }, - }, - }) - } - } - } - - return { - content, - finishReason: mapOpenRouterFinishReason(choice.finish_reason), - usage: usageInfo, - warnings: [], - providerMetadata: { - openrouter: { - provider: response.provider ?? '', - usage: { - promptTokens: usageInfo.inputTokens ?? 0, - completionTokens: usageInfo.outputTokens ?? 0, - totalTokens: usageInfo.totalTokens ?? 0, - cost: response.usage?.cost, - promptTokensDetails: { - cachedTokens: - response.usage?.prompt_tokens_details?.cached_tokens ?? 0, - }, - completionTokensDetails: { - reasoningTokens: - response.usage?.completion_tokens_details?.reasoning_tokens ?? - 0, - }, - costDetails: { - upstreamInferenceCost: - response.usage?.cost_details?.upstream_inference_cost ?? 0, - }, - }, - }, - }, - request: { body: args }, - response: { - id: response.id, - modelId: response.model, - headers: responseHeaders, - }, - } - } - - async doStream(options: LanguageModelV2CallOptions): Promise<{ - stream: ReadableStream - warnings: Array - request?: { body?: unknown } - response?: LanguageModelV2ResponseMetadata & { - headers?: SharedV2Headers - body?: unknown - } - }> { - const providerOptions = options.providerOptions || {} - const openrouterOptions = providerOptions.openrouter || {} - - const args = { - ...this.getArgs(options), - ...openrouterOptions, - } - - const { value: response, responseHeaders } = await postJsonToApi({ - url: this.config.url({ - path: '/chat/completions', - modelId: this.modelId, - }), - headers: combineHeaders(this.config.headers(), options.headers), - body: { - ...args, - stream: true, - - // only include stream_options when in strict compatibility mode: - stream_options: - this.config.compatibility === 'strict' - ? { - include_usage: true, - // If user has requested usage accounting, make sure we get it in the stream - ...(this.settings.usage?.include - ? { include_usage: true } - : {}), - } - : undefined, - }, - failedResponseHandler: openrouterFailedResponseHandler, - successfulResponseHandler: createEventSourceResponseHandler( - OpenRouterStreamChatCompletionChunkSchema, - ), - abortSignal: options.abortSignal, - fetch: this.config.fetch, - }) - - const toolCalls: Array<{ - id: string - type: 'function' - function: { - name: string - arguments: string - } - inputStarted: boolean - sent: boolean - }> = [] - - let finishReason: FinishReason = 'other' - const usage: LanguageModelV2Usage = { - inputTokens: Number.NaN, - outputTokens: Number.NaN, - totalTokens: Number.NaN, - reasoningTokens: Number.NaN, - cachedInputTokens: Number.NaN, - } - - // Track provider-specific usage information - const openrouterUsage: Partial = {} - - let textStarted = false - let reasoningStarted = false - let textId: string | undefined - let reasoningId: string | undefined - let openrouterResponseId: string | undefined - let provider: string | undefined - - return { - stream: response.pipeThrough( - new TransformStream< - ParseResult< - z.infer - >, - LanguageModelV2StreamPart - >({ - transform(chunk, controller) { - // handle failed chunk parsing / validation: - if (!chunk.success) { - finishReason = 'error' - controller.enqueue({ type: 'error', error: chunk.error }) - return - } - - const value = chunk.value - - // handle error chunks: - if ('error' in value) { - finishReason = 'error' - controller.enqueue({ type: 'error', error: value.error }) - return - } - - if (value.provider) { - provider = value.provider - } - - if (value.id) { - openrouterResponseId = value.id - controller.enqueue({ - type: 'response-metadata', - id: value.id, - }) - } - - if (value.model) { - controller.enqueue({ - type: 'response-metadata', - modelId: value.model, - }) - } - - if (value.usage != null) { - usage.inputTokens = value.usage.prompt_tokens - usage.outputTokens = value.usage.completion_tokens - usage.totalTokens = - value.usage.prompt_tokens + value.usage.completion_tokens - - // Collect OpenRouter specific usage information - openrouterUsage.promptTokens = value.usage.prompt_tokens - - if (value.usage.prompt_tokens_details) { - const cachedInputTokens = - value.usage.prompt_tokens_details.cached_tokens ?? 0 - - usage.cachedInputTokens = cachedInputTokens - openrouterUsage.promptTokensDetails = { - cachedTokens: cachedInputTokens, - } - } - - openrouterUsage.completionTokens = value.usage.completion_tokens - if (value.usage.completion_tokens_details) { - const reasoningTokens = - value.usage.completion_tokens_details.reasoning_tokens ?? 0 - - usage.reasoningTokens = reasoningTokens - openrouterUsage.completionTokensDetails = { - reasoningTokens, - } - } - - const upstreamInferenceCost = - value.usage.cost_details?.upstream_inference_cost - if ( - upstreamInferenceCost != null && - upstreamInferenceCost !== undefined - ) { - openrouterUsage.costDetails = { - upstreamInferenceCost, - } - } - - if (value.usage.cost !== undefined) { - openrouterUsage.cost = value.usage.cost - } - openrouterUsage.totalTokens = value.usage.total_tokens - } - - const choice = value.choices[0] - - if (choice?.finish_reason != null) { - finishReason = mapOpenRouterFinishReason(choice.finish_reason) - } - - if (choice?.delta == null) { - return - } - - const delta = choice.delta - - const emitReasoningChunk = (chunkText: string) => { - if (!reasoningStarted) { - reasoningId = openrouterResponseId || generateId() - controller.enqueue({ - type: 'reasoning-start', - id: reasoningId, - }) - reasoningStarted = true - } - controller.enqueue({ - type: 'reasoning-delta', - delta: chunkText, - id: reasoningId || generateId(), - }) - } - - if (delta.reasoning_details && delta.reasoning_details.length > 0) { - for (const detail of delta.reasoning_details) { - switch (detail.type) { - case ReasoningDetailType.Text: { - if (detail.text) { - emitReasoningChunk(detail.text) - } - break - } - case ReasoningDetailType.Encrypted: { - if (detail.data) { - emitReasoningChunk('[REDACTED]') - } - break - } - case ReasoningDetailType.Summary: { - if (detail.summary) { - emitReasoningChunk(detail.summary) - } - break - } - default: { - detail satisfies never - break - } - } - } - } else if (delta.reasoning) { - emitReasoningChunk(delta.reasoning) - } - - if (delta.content) { - // If reasoning was previously active and now we're starting text content, - // we should end the reasoning first to maintain proper order - if (reasoningStarted && !textStarted) { - controller.enqueue({ - type: 'reasoning-end', - id: reasoningId || generateId(), - }) - reasoningStarted = false // Mark as ended so we don't end it again in flush - } - - if (!textStarted) { - textId = openrouterResponseId || generateId() - controller.enqueue({ - type: 'text-start', - id: textId, - }) - textStarted = true - } - controller.enqueue({ - type: 'text-delta', - delta: delta.content, - id: textId || generateId(), - }) - } - - if (delta.annotations) { - for (const annotation of delta.annotations) { - if (annotation.type === 'url_citation') { - controller.enqueue({ - type: 'source', - sourceType: 'url' as const, - id: annotation.url_citation.url, - url: annotation.url_citation.url, - title: annotation.url_citation.title, - providerMetadata: { - openrouter: { - content: annotation.url_citation.content || '', - }, - }, - }) - } - } - } - - if (delta.tool_calls != null) { - for (const toolCallDelta of delta.tool_calls) { - const index = toolCallDelta.index ?? toolCalls.length - 1 - - // Tool call start. OpenRouter returns all information except the arguments in the first chunk. - if (toolCalls[index] == null) { - if (toolCallDelta.type !== 'function') { - throw new InvalidResponseDataError({ - data: toolCallDelta, - message: `Expected 'function' type.`, - }) - } - - if (toolCallDelta.id == null) { - throw new InvalidResponseDataError({ - data: toolCallDelta, - message: `Expected 'id' to be a string.`, - }) - } - - if (toolCallDelta.function?.name == null) { - throw new InvalidResponseDataError({ - data: toolCallDelta, - message: `Expected 'function.name' to be a string.`, - }) - } - - toolCalls[index] = { - id: toolCallDelta.id, - type: 'function', - function: { - name: toolCallDelta.function.name, - arguments: toolCallDelta.function.arguments ?? '', - }, - inputStarted: false, - sent: false, - } - - const toolCall = toolCalls[index] - - if (toolCall == null) { - throw new Error('Tool call is missing') - } - - // check if tool call is complete (some providers send the full tool call in one chunk) - if ( - toolCall.function?.name != null && - toolCall.function?.arguments != null && - isParsableJson(toolCall.function.arguments) - ) { - toolCall.inputStarted = true - - controller.enqueue({ - type: 'tool-input-start', - id: toolCall.id, - toolName: toolCall.function.name, - }) - - // send delta - controller.enqueue({ - type: 'tool-input-delta', - id: toolCall.id, - delta: toolCall.function.arguments, - }) - - controller.enqueue({ - type: 'tool-input-end', - id: toolCall.id, - }) - - // send tool call - controller.enqueue({ - type: 'tool-call', - toolCallId: toolCall.id, - toolName: toolCall.function.name, - input: toolCall.function.arguments, - }) - - toolCall.sent = true - } - - continue - } - - // existing tool call, merge - const toolCall = toolCalls[index] - - if (toolCall == null) { - throw new Error('Tool call is missing') - } - - 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 ?? '' - } - - // send delta - controller.enqueue({ - type: 'tool-input-delta', - id: toolCall.id, - delta: toolCallDelta.function.arguments ?? '', - }) - - // check if tool call is complete - if ( - toolCall.function?.name != null && - toolCall.function?.arguments != null && - isParsableJson(toolCall.function.arguments) - ) { - controller.enqueue({ - type: 'tool-call', - toolCallId: toolCall.id ?? generateId(), - toolName: toolCall.function.name, - input: toolCall.function.arguments, - }) - - toolCall.sent = true - } - } - } - }, - - flush(controller) { - // Forward any unsent tool calls if finish reason is 'tool-calls' - if (finishReason === 'tool-calls') { - for (const toolCall of toolCalls) { - if (toolCall && !toolCall.sent) { - controller.enqueue({ - type: 'tool-call', - toolCallId: toolCall.id ?? generateId(), - toolName: toolCall.function.name, - // Coerce invalid arguments to an empty JSON object - input: isParsableJson(toolCall.function.arguments) - ? toolCall.function.arguments - : '{}', - }) - toolCall.sent = true - } - } - } - - // End reasoning first if it was started, to maintain proper order - if (reasoningStarted) { - controller.enqueue({ - type: 'reasoning-end', - id: reasoningId || generateId(), - }) - } - if (textStarted) { - controller.enqueue({ - type: 'text-end', - id: textId || generateId(), - }) - } - - const openrouterMetadata: { - usage: Partial - provider?: string - } = { - usage: openrouterUsage, - } - - // Only include provider if it's actually set - if (provider !== undefined) { - openrouterMetadata.provider = provider - } - - controller.enqueue({ - type: 'finish', - finishReason, - usage, - providerMetadata: { - openrouter: openrouterMetadata, - }, - }) - }, - }), - ), - warnings: [], - request: { body: args }, - response: { headers: responseHeaders }, - } - } -} diff --git a/packages/internal/src/openrouter-ai-sdk/chat/is-url.ts b/packages/internal/src/openrouter-ai-sdk/chat/is-url.ts deleted file mode 100644 index 137a636d3d..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/chat/is-url.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function isUrl({ - url, - protocols, -}: { - url: string | URL - protocols: Set<`${string}:`> -}): boolean { - try { - const urlObj = new URL(url) - // Cast to the literal string due to Set inferred input type - return protocols.has(urlObj.protocol as `${string}:`) - } catch (_) { - return false - } -} diff --git a/packages/internal/src/openrouter-ai-sdk/chat/schemas.ts b/packages/internal/src/openrouter-ai-sdk/chat/schemas.ts deleted file mode 100644 index 5c71c30282..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/chat/schemas.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { z } from 'zod/v4' - -import { OpenRouterErrorResponseSchema } from '../schemas/error-response' -import { ReasoningDetailArraySchema } from '../schemas/reasoning-details' - -const OpenRouterChatCompletionBaseResponseSchema = z.object({ - id: z.string().optional(), - model: z.string().optional(), - provider: z.string().optional(), - usage: z - .object({ - prompt_tokens: z.number(), - prompt_tokens_details: z - .object({ - cached_tokens: z.number(), - }) - .nullish(), - completion_tokens: z.number(), - completion_tokens_details: z - .object({ - reasoning_tokens: z.number(), - }) - .nullish(), - total_tokens: z.number(), - cost: z.number().optional(), - cost_details: z - .object({ - upstream_inference_cost: z.number().nullish(), - }) - .nullish(), - }) - .nullish(), -}) -// limited version of the schema, focussed on what is needed for the implementation -// this approach limits breakages when the API changes and increases efficiency -export const OpenRouterNonStreamChatCompletionResponseSchema = - OpenRouterChatCompletionBaseResponseSchema.extend({ - choices: z.array( - z.object({ - message: z.object({ - role: z.literal('assistant'), - content: z.string().nullable().optional(), - reasoning: z.string().nullable().optional(), - reasoning_details: ReasoningDetailArraySchema.nullish(), - - tool_calls: z - .array( - z.object({ - id: z.string().optional().nullable(), - type: z.literal('function'), - function: z.object({ - name: z.string(), - arguments: z.string(), - }), - }), - ) - .optional(), - - annotations: z - .array( - z.object({ - type: z.enum(['url_citation']), - url_citation: z.object({ - end_index: z.number(), - start_index: z.number(), - title: z.string(), - url: z.string(), - content: z.string().optional(), - }), - }), - ) - .nullish(), - }), - index: z.number().nullish(), - logprobs: z - .object({ - content: z - .array( - z.object({ - token: z.string(), - logprob: z.number(), - top_logprobs: z.array( - z.object({ - token: z.string(), - logprob: z.number(), - }), - ), - }), - ) - .nullable(), - }) - .nullable() - .optional(), - finish_reason: z.string().optional().nullable(), - }), - ), - }) -// limited version of the schema, focussed on what is needed for the implementation -// this approach limits breakages when the API changes and increases efficiency -export const OpenRouterStreamChatCompletionChunkSchema = z.union([ - OpenRouterChatCompletionBaseResponseSchema.extend({ - choices: z.array( - z.object({ - delta: z - .object({ - role: z.enum(['assistant']).optional(), - content: z.string().nullish(), - reasoning: z.string().nullish().optional(), - reasoning_details: ReasoningDetailArraySchema.nullish(), - tool_calls: z - .array( - z.object({ - index: z.number().nullish(), - id: z.string().nullish(), - type: z.literal('function').optional(), - function: z.object({ - name: z.string().nullish(), - arguments: z.string().nullish(), - }), - }), - ) - .nullish(), - - annotations: z - .array( - z.object({ - type: z.enum(['url_citation']), - url_citation: z.object({ - end_index: z.number(), - start_index: z.number(), - title: z.string(), - url: z.string(), - content: z.string().optional(), - }), - }), - ) - .nullish(), - }) - .nullish(), - logprobs: z - .object({ - content: z - .array( - z.object({ - token: z.string(), - logprob: z.number(), - top_logprobs: z.array( - z.object({ - token: z.string(), - logprob: z.number(), - }), - ), - }), - ) - .nullable(), - }) - .nullish(), - finish_reason: z.string().nullable().optional(), - index: z.number().nullish(), - }), - ), - }), - OpenRouterErrorResponseSchema, -]) diff --git a/packages/internal/src/openrouter-ai-sdk/completion/convert-to-openrouter-completion-prompt.ts b/packages/internal/src/openrouter-ai-sdk/completion/convert-to-openrouter-completion-prompt.ts deleted file mode 100644 index 4b5b5c90cf..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/completion/convert-to-openrouter-completion-prompt.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { - InvalidPromptError, - UnsupportedFunctionalityError, -} from '@ai-sdk/provider' - -import type { - LanguageModelV2FilePart, - LanguageModelV2Prompt, - LanguageModelV2ReasoningPart, - LanguageModelV2TextPart, - LanguageModelV2ToolCallPart, - LanguageModelV2ToolResultPart, -} from '@ai-sdk/provider' - - -export function convertToOpenRouterCompletionPrompt({ - prompt, - inputFormat, - user = 'user', - assistant = 'assistant', -}: { - prompt: LanguageModelV2Prompt - inputFormat: 'prompt' | 'messages' - user?: string - assistant?: string -}): { - prompt: string -} { - // When the user supplied a prompt input, we don't transform it: - if ( - inputFormat === 'prompt' && - prompt.length === 1 && - prompt[0] && - prompt[0].role === 'user' && - prompt[0].content.length === 1 && - prompt[0].content[0] && - prompt[0].content[0].type === 'text' - ) { - return { prompt: prompt[0].content[0].text } - } - - // otherwise transform to a chat message format: - let text = '' - - // if first message is a system message, add it to the text: - if (prompt[0] && prompt[0].role === 'system') { - text += `${prompt[0].content}\n\n` - prompt = prompt.slice(1) - } - - for (const { role, content } of prompt) { - switch (role) { - case 'system': { - throw new InvalidPromptError({ - message: `Unexpected system message in prompt: ${content}`, - prompt, - }) - } - - case 'user': { - const userMessage = content - .map((part: LanguageModelV2TextPart | LanguageModelV2FilePart) => { - switch (part.type) { - case 'text': { - return part.text - } - - case 'file': { - throw new UnsupportedFunctionalityError({ - functionality: 'file attachments', - }) - } - default: { - return '' - } - } - }) - .join('') - - text += `${user}:\n${userMessage}\n\n` - break - } - - case 'assistant': { - const assistantMessage = content - .map( - ( - part: - | LanguageModelV2TextPart - | LanguageModelV2FilePart - | LanguageModelV2ReasoningPart - | LanguageModelV2ToolCallPart - | LanguageModelV2ToolResultPart, - ) => { - switch (part.type) { - case 'text': { - return part.text - } - case 'tool-call': { - throw new UnsupportedFunctionalityError({ - functionality: 'tool-call messages', - }) - } - case 'tool-result': { - throw new UnsupportedFunctionalityError({ - functionality: 'tool-result messages', - }) - } - case 'reasoning': { - throw new UnsupportedFunctionalityError({ - functionality: 'reasoning messages', - }) - } - - case 'file': { - throw new UnsupportedFunctionalityError({ - functionality: 'file attachments', - }) - } - - default: { - return '' - } - } - }, - ) - .join('') - - text += `${assistant}:\n${assistantMessage}\n\n` - break - } - - case 'tool': { - throw new UnsupportedFunctionalityError({ - functionality: 'tool messages', - }) - } - - default: { - break - } - } - } - - // Assistant message prefix: - text += `${assistant}:\n` - - return { - prompt: text, - } -} diff --git a/packages/internal/src/openrouter-ai-sdk/completion/index.test.ts b/packages/internal/src/openrouter-ai-sdk/completion/index.test.ts deleted file mode 100644 index cca1ac805a..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/completion/index.test.ts +++ /dev/null @@ -1,665 +0,0 @@ -import { convertReadableStreamToArray } from '@ai-sdk/provider-utils/test' -import { beforeEach, describe, expect, it } from 'bun:test' - -import { createOpenRouter } from '../provider' - -import type { LanguageModelV2Prompt } from '@ai-sdk/provider' - -const TEST_PROMPT: LanguageModelV2Prompt = [ - { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, -] - -const TEST_LOGPROBS = { - tokens: [' ever', ' after', '.\n\n', 'The', ' end', '.'], - token_logprobs: [ - -0.0664508, -0.014520033, -1.3820221, -0.7890417, -0.5323165, -0.10247037, - ], - top_logprobs: [ - { - ' ever': -0.0664508, - }, - { - ' after': -0.014520033, - }, - { - '.\n\n': -1.3820221, - }, - { - The: -0.7890417, - }, - { - ' end': -0.5323165, - }, - { - '.': -0.10247037, - }, - ] as Record[], -} - -type MockResponseDefinition = - | { - type: 'json-value' - body: any - headers?: Record - status?: number - } - | { - type: 'stream-chunks' - chunks: string[] - headers?: Record - status?: number - } - -type MockServerRoute = { - response: MockResponseDefinition -} - -type MockServerCall = { - requestHeaders: Record - requestBodyJson: Promise -} - -const createStreamFromChunks = (chunks: string[]) => - new ReadableStream({ - start(controller) { - try { - for (const chunk of chunks) { - controller.enqueue(chunk) - } - } finally { - controller.close() - } - }, - }).pipeThrough(new TextEncoderStream()) - -function toHeadersRecord(headers?: HeadersInit): Record { - const result: Record = {} - - if (!headers) { - return result - } - - if (headers instanceof Headers) { - headers.forEach((value, key) => { - result[key.toLowerCase()] = value - }) - return result - } - - if (Array.isArray(headers)) { - for (const [key, value] of headers) { - result[String(key).toLowerCase()] = String(value) - } - return result - } - - for (const [key, value] of Object.entries(headers)) { - if (typeof value !== 'undefined') { - result[key.toLowerCase()] = String(value) - } - } - - return result -} - -function parseRequestBody(body: BodyInit | null | undefined): any { - if (body == null) { - return undefined - } - - if (typeof body === 'string') { - try { - return JSON.parse(body) - } catch { - return undefined - } - } - - return undefined -} - -function createMockServer(routes: Record) { - const urls: Record = Object.fromEntries( - Object.entries(routes).map(([url, config]) => [ - url, - { - response: { ...config.response }, - }, - ]), - ) - - const calls: MockServerCall[] = [] - - const buildResponse = (definition: MockResponseDefinition): Response => { - const status = definition.status ?? 200 - - if (definition.type === 'json-value') { - return new Response(JSON.stringify(definition.body), { - status, - headers: { - 'Content-Type': 'application/json', - ...definition.headers, - }, - }) - } - - return new Response(createStreamFromChunks(definition.chunks), { - status, - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...definition.headers, - }, - }) - } - - const fetchImpl = async (input: RequestInfo, init: RequestInit = {}) => { - const url = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url - - const route = urls[url] - - if (!route) { - return new Response('Not Found', { status: 404 }) - } - - const requestHeaders = toHeadersRecord(init.headers) - const requestBodyJson = Promise.resolve(parseRequestBody(init.body)) - - calls.push({ requestHeaders, requestBodyJson }) - - return buildResponse(route.response) - } - - const fetch = ((input: RequestInfo | URL, init?: RequestInit) => - fetchImpl(input as RequestInfo, init ?? {})) as typeof global.fetch - - fetch.preconnect = async () => {} - - return { - urls, - calls, - fetch, - } -} - -describe('doGenerate', () => { - const server = createMockServer({ - 'https://openrouter.ai/api/v1/completions': { - response: { type: 'json-value', body: {} }, - }, - }) - - const provider = createOpenRouter({ - apiKey: 'test-api-key', - compatibility: 'strict', - fetch: server.fetch, - }) - - const model = provider.completion('openai/gpt-3.5-turbo-instruct') - - beforeEach(() => { - server.calls.length = 0 - server.urls['https://openrouter.ai/api/v1/completions']!.response = { - type: 'json-value', - body: {}, - } - }) - - function prepareJsonResponse({ - content = '', - usage = { - prompt_tokens: 4, - total_tokens: 34, - completion_tokens: 30, - }, - logprobs = null, - finish_reason = 'stop', - }: { - content?: string - usage?: { - prompt_tokens: number - total_tokens: number - completion_tokens: number - } - logprobs?: { - tokens: string[] - token_logprobs: number[] - top_logprobs: Record[] - } | null - finish_reason?: string - }) { - server.urls['https://openrouter.ai/api/v1/completions']!.response = { - type: 'json-value', - body: { - id: 'cmpl-96cAM1v77r4jXa4qb2NSmRREV5oWB', - object: 'text_completion', - created: 1711363706, - model: 'openai/gpt-3.5-turbo-instruct', - choices: [ - { - text: content, - index: 0, - logprobs, - finish_reason, - }, - ], - usage, - }, - } - } - - it('should extract text response', async () => { - prepareJsonResponse({ content: 'Hello, World!' }) - - const { content } = await model.doGenerate({ - prompt: TEST_PROMPT, - }) - - const text = content[0]?.type === 'text' ? content[0].text : '' - - expect(text).toStrictEqual('Hello, World!') - }) - - it('should extract usage', async () => { - prepareJsonResponse({ - content: '', - usage: { prompt_tokens: 20, total_tokens: 25, completion_tokens: 5 }, - }) - - const { usage } = await model.doGenerate({ - prompt: TEST_PROMPT, - }) - - expect(usage).toStrictEqual({ - inputTokens: 20, - outputTokens: 5, - totalTokens: 25, - reasoningTokens: 0, - cachedInputTokens: 0, - }) - }) - - it('should extract logprobs', async () => { - prepareJsonResponse({ logprobs: TEST_LOGPROBS }) - - const provider = createOpenRouter({ - apiKey: 'test-api-key', - fetch: server.fetch, - }) - - await provider - .completion('openai/gpt-3.5-turbo', { logprobs: 1 }) - .doGenerate({ - prompt: TEST_PROMPT, - }) - }) - - it('should extract finish reason', async () => { - prepareJsonResponse({ - content: '', - finish_reason: 'stop', - }) - - const { finishReason } = await provider - .completion('openai/gpt-3.5-turbo-instruct') - .doGenerate({ - prompt: TEST_PROMPT, - }) - - expect(finishReason).toStrictEqual('stop') - }) - - it('should support unknown finish reason', async () => { - prepareJsonResponse({ - content: '', - finish_reason: 'eos', - }) - - const { finishReason } = await provider - .completion('openai/gpt-3.5-turbo-instruct') - .doGenerate({ - prompt: TEST_PROMPT, - }) - - expect(finishReason).toStrictEqual('unknown') - }) - - it('should pass the model and the prompt', async () => { - prepareJsonResponse({ content: '' }) - - await model.doGenerate({ - prompt: TEST_PROMPT, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toStrictEqual({ - model: 'openai/gpt-3.5-turbo-instruct', - prompt: 'Hello', - }) - }) - - it('should pass the models array when provided', async () => { - prepareJsonResponse({ content: '' }) - - const customModel = provider.completion('openai/gpt-3.5-turbo-instruct', { - models: ['openai/gpt-4', 'anthropic/claude-2'], - }) - - await customModel.doGenerate({ - prompt: TEST_PROMPT, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toStrictEqual({ - model: 'openai/gpt-3.5-turbo-instruct', - models: ['openai/gpt-4', 'anthropic/claude-2'], - prompt: 'Hello', - }) - }) - - it('should pass headers', async () => { - prepareJsonResponse({ content: '' }) - - const provider = createOpenRouter({ - apiKey: 'test-api-key', - headers: { - 'Custom-Provider-Header': 'provider-header-value', - }, - fetch: server.fetch, - }) - - await provider.completion('openai/gpt-3.5-turbo-instruct').doGenerate({ - prompt: TEST_PROMPT, - headers: { - 'Custom-Request-Header': 'request-header-value', - }, - }) - - const requestHeaders = server.calls[0]!.requestHeaders - - expect(requestHeaders.authorization).toBe('Bearer test-api-key') - expect(requestHeaders['content-type']).toBe('application/json') - expect(requestHeaders['custom-provider-header']).toBe( - 'provider-header-value', - ) - expect(requestHeaders['custom-request-header']).toBe('request-header-value') - expect(requestHeaders['user-agent']).toMatch( - /^ai-sdk\/provider-utils\/\d+\.\d+\.\d+ runtime\/bun\/\d+\.\d+\.\d+$/, - ) - }) -}) - -describe('doStream', () => { - const server = createMockServer({ - 'https://openrouter.ai/api/v1/completions': { - response: { type: 'stream-chunks', chunks: [] }, - }, - }) - - const provider = createOpenRouter({ - apiKey: 'test-api-key', - compatibility: 'strict', - fetch: server.fetch, - }) - - const model = provider.completion('openai/gpt-3.5-turbo-instruct') - - beforeEach(() => { - server.calls.length = 0 - server.urls['https://openrouter.ai/api/v1/completions']!.response = { - type: 'stream-chunks', - chunks: [], - } - }) - - function prepareStreamResponse({ - content, - finish_reason = 'stop', - usage = { - prompt_tokens: 10, - total_tokens: 372, - completion_tokens: 362, - }, - logprobs = null, - }: { - content: string[] - usage?: { - prompt_tokens: number - total_tokens: number - completion_tokens: number - } - logprobs?: { - tokens: string[] - token_logprobs: number[] - top_logprobs: Record[] - } | null - finish_reason?: string - }) { - server.urls['https://openrouter.ai/api/v1/completions']!.response = { - type: 'stream-chunks', - chunks: [ - ...content.map((text) => { - return `data: {"id":"cmpl-96c64EdfhOw8pjFFgVpLuT8k2MtdT","object":"text_completion","created":1711363440,"choices":[{"text":"${text}","index":0,"logprobs":null,"finish_reason":null}],"model":"openai/gpt-3.5-turbo-instruct"}\n\n` - }), - `data: {"id":"cmpl-96c3yLQE1TtZCd6n6OILVmzev8M8H","object":"text_completion","created":1711363310,"choices":[{"text":"","index":0,"logprobs":${JSON.stringify( - logprobs, - )},"finish_reason":"${finish_reason}"}],"model":"openai/gpt-3.5-turbo-instruct"}\n\n`, - `data: {"id":"cmpl-96c3yLQE1TtZCd6n6OILVmzev8M8H","object":"text_completion","created":1711363310,"model":"openai/gpt-3.5-turbo-instruct","usage":${JSON.stringify( - usage, - )},"choices":[]}\n\n`, - 'data: [DONE]\n\n', - ], - } - } - - it('should stream text deltas', async () => { - prepareStreamResponse({ - content: ['Hello', ', ', 'World!'], - finish_reason: 'stop', - usage: { - prompt_tokens: 10, - total_tokens: 372, - completion_tokens: 362, - }, - logprobs: TEST_LOGPROBS, - }) - - const { stream } = await model.doStream({ - prompt: TEST_PROMPT, - }) - - // note: space moved to last chunk bc of trimming - const elements = await convertReadableStreamToArray(stream) - expect(elements).toStrictEqual([ - { type: 'text-delta', delta: 'Hello', id: expect.any(String) }, - { type: 'text-delta', delta: ', ', id: expect.any(String) }, - { type: 'text-delta', delta: 'World!', id: expect.any(String) }, - { type: 'text-delta', delta: '', id: expect.any(String) }, - { - type: 'finish', - finishReason: 'stop', - providerMetadata: { - openrouter: { - usage: { - promptTokens: 10, - completionTokens: 362, - totalTokens: 372, - }, - }, - }, - usage: { - inputTokens: 10, - outputTokens: 362, - totalTokens: 372, - reasoningTokens: Number.NaN, - cachedInputTokens: Number.NaN, - }, - }, - ]) - }) - - it('should handle error stream parts', async () => { - server.urls['https://openrouter.ai/api/v1/completions']!.response = { - type: 'stream-chunks', - chunks: [ - `data: {"error":{"message": "The server had an error processing your request. Sorry about that! You can retry your request, or contact us through our ` + - `help center at help.openrouter.com if you keep seeing this error.","type":"server_error","param":null,"code":null}}\n\n`, - 'data: [DONE]\n\n', - ], - } - - const { stream } = await model.doStream({ - prompt: TEST_PROMPT, - }) - - const elements = await convertReadableStreamToArray(stream) - - expect(elements).toStrictEqual([ - { - type: 'error', - error: { - message: - 'The server had an error processing your request. Sorry about that! ' + - 'You can retry your request, or contact us through our help center at ' + - 'help.openrouter.com if you keep seeing this error.', - type: 'server_error', - code: null, - param: null, - }, - }, - { - finishReason: 'error', - providerMetadata: { - openrouter: { - usage: {}, - }, - }, - type: 'finish', - usage: { - inputTokens: Number.NaN, - outputTokens: Number.NaN, - totalTokens: Number.NaN, - reasoningTokens: Number.NaN, - cachedInputTokens: Number.NaN, - }, - }, - ]) - }) - - it('should handle unparsable stream parts', async () => { - server.urls['https://openrouter.ai/api/v1/completions']!.response = { - type: 'stream-chunks', - chunks: ['data: {unparsable}\n\n', 'data: [DONE]\n\n'], - } - - const { stream } = await model.doStream({ - prompt: TEST_PROMPT, - }) - - const elements = await convertReadableStreamToArray(stream) - - expect(elements.length).toBe(2) - expect(elements[0]?.type).toBe('error') - expect(elements[1]).toStrictEqual({ - finishReason: 'error', - providerMetadata: { - openrouter: { - usage: {}, - }, - }, - type: 'finish', - usage: { - inputTokens: Number.NaN, - outputTokens: Number.NaN, - totalTokens: Number.NaN, - reasoningTokens: Number.NaN, - cachedInputTokens: Number.NaN, - }, - }) - }) - - it('should pass the model and the prompt', async () => { - prepareStreamResponse({ content: [] }) - - await model.doStream({ - prompt: TEST_PROMPT, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toStrictEqual({ - stream: true, - stream_options: { include_usage: true }, - model: 'openai/gpt-3.5-turbo-instruct', - prompt: 'Hello', - }) - }) - - it('should pass headers', async () => { - prepareStreamResponse({ content: [] }) - - const provider = createOpenRouter({ - apiKey: 'test-api-key', - headers: { - 'Custom-Provider-Header': 'provider-header-value', - }, - fetch: server.fetch, - }) - - await provider.completion('openai/gpt-3.5-turbo-instruct').doStream({ - prompt: TEST_PROMPT, - headers: { - 'Custom-Request-Header': 'request-header-value', - }, - }) - - const requestHeaders = server.calls[0]!.requestHeaders - - expect(requestHeaders.authorization).toBe('Bearer test-api-key') - expect(requestHeaders['content-type']).toBe('application/json') - expect(requestHeaders['custom-provider-header']).toBe( - 'provider-header-value', - ) - expect(requestHeaders['custom-request-header']).toBe('request-header-value') - expect(requestHeaders['user-agent']).toMatch( - /^ai-sdk\/provider-utils\/\d+\.\d+\.\d+ runtime\/bun\/\d+\.\d+\.\d+$/, - ) - }) - - it('should pass extra body', async () => { - prepareStreamResponse({ content: [] }) - - const provider = createOpenRouter({ - apiKey: 'test-api-key', - extraBody: { - custom_field: 'custom_value', - providers: { - anthropic: { - custom_field: 'custom_value', - }, - }, - }, - fetch: server.fetch, - }) - - await provider.completion('openai/gpt-4o').doStream({ - prompt: TEST_PROMPT, - }) - - const requestBody = await server.calls[0]!.requestBodyJson - - expect(requestBody).toHaveProperty('custom_field', 'custom_value') - expect(requestBody).toHaveProperty( - 'providers.anthropic.custom_field', - 'custom_value', - ) - }) -}) diff --git a/packages/internal/src/openrouter-ai-sdk/completion/index.ts b/packages/internal/src/openrouter-ai-sdk/completion/index.ts deleted file mode 100644 index 1185f2cf1d..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/completion/index.ts +++ /dev/null @@ -1,344 +0,0 @@ -import { UnsupportedFunctionalityError } from '@ai-sdk/provider' -import { - combineHeaders, - createEventSourceResponseHandler, - createJsonResponseHandler, - generateId, - postJsonToApi, -} from '@ai-sdk/provider-utils' - -import { convertToOpenRouterCompletionPrompt } from './convert-to-openrouter-completion-prompt' -import { OpenRouterCompletionChunkSchema } from './schemas' -import { openrouterFailedResponseHandler } from '../schemas/error-response' -import { mapOpenRouterFinishReason } from '../utils/map-finish-reason' - -import type { OpenRouterUsageAccounting } from '../types' -import type { - OpenRouterCompletionModelId, - OpenRouterCompletionSettings, -} from '../types/openrouter-completion-settings' -import type { - LanguageModelV2, - LanguageModelV2CallOptions, - LanguageModelV2StreamPart, - LanguageModelV2Usage, -} from '@ai-sdk/provider' -import type { ParseResult } from '@ai-sdk/provider-utils' -import type { FinishReason } from 'ai' -import type { z } from 'zod/v4' - - - -type OpenRouterCompletionConfig = { - provider: string - compatibility: 'strict' | 'compatible' - headers: () => Record - url: (options: { modelId: string; path: string }) => string - fetch?: typeof fetch - extraBody?: Record -} - -export class OpenRouterCompletionLanguageModel implements LanguageModelV2 { - readonly specificationVersion = 'v2' as const - readonly provider = 'openrouter' - readonly modelId: OpenRouterCompletionModelId - readonly supportedUrls: Record = { - 'image/*': [ - /^data:image\/[a-zA-Z]+;base64,/, - /^https?:\/\/.+\.(jpg|jpeg|png|gif|webp)$/i, - ], - 'text/*': [/^data:text\//, /^https?:\/\/.+$/], - 'application/*': [/^data:application\//, /^https?:\/\/.+$/], - } - readonly defaultObjectGenerationMode = undefined - readonly settings: OpenRouterCompletionSettings - - private readonly config: OpenRouterCompletionConfig - - constructor( - modelId: OpenRouterCompletionModelId, - settings: OpenRouterCompletionSettings, - config: OpenRouterCompletionConfig, - ) { - this.modelId = modelId - this.settings = settings - this.config = config - } - - private getArgs({ - prompt, - maxOutputTokens, - temperature, - topP, - frequencyPenalty, - presencePenalty, - seed, - responseFormat, - topK, - stopSequences, - tools, - toolChoice, - }: LanguageModelV2CallOptions) { - const { prompt: completionPrompt } = convertToOpenRouterCompletionPrompt({ - prompt, - inputFormat: 'prompt', - }) - - if (tools?.length) { - throw new UnsupportedFunctionalityError({ - functionality: 'tools', - }) - } - - if (toolChoice) { - throw new UnsupportedFunctionalityError({ - functionality: 'toolChoice', - }) - } - - return { - // model id: - model: this.modelId, - models: this.settings.models, - - // model specific settings: - logit_bias: this.settings.logitBias, - logprobs: - typeof this.settings.logprobs === 'number' - ? this.settings.logprobs - : typeof this.settings.logprobs === 'boolean' - ? this.settings.logprobs - ? 0 - : undefined - : undefined, - suffix: this.settings.suffix, - user: this.settings.user, - - // standardized settings: - max_tokens: maxOutputTokens, - temperature, - top_p: topP, - frequency_penalty: frequencyPenalty, - presence_penalty: presencePenalty, - seed, - - ...(this.modelId === 'x-ai/grok-code-fast-1' - ? {} - : { stop: stopSequences }), - response_format: responseFormat, - top_k: topK, - - // prompt: - prompt: completionPrompt, - - // OpenRouter specific settings: - include_reasoning: this.settings.includeReasoning, - reasoning: this.settings.reasoning, - - // extra body: - ...this.config.extraBody, - ...this.settings.extraBody, - } - } - - async doGenerate( - options: LanguageModelV2CallOptions, - ): Promise>> { - const providerOptions = options.providerOptions || {} - const openrouterOptions = providerOptions.openrouter || {} - - const args = { - ...this.getArgs(options), - ...openrouterOptions, - } - - const { value: response, responseHeaders } = await postJsonToApi({ - url: this.config.url({ - path: '/completions', - modelId: this.modelId, - }), - headers: combineHeaders(this.config.headers(), options.headers), - body: args, - failedResponseHandler: openrouterFailedResponseHandler, - successfulResponseHandler: createJsonResponseHandler( - OpenRouterCompletionChunkSchema, - ), - abortSignal: options.abortSignal, - fetch: this.config.fetch, - }) - - if ('error' in response) { - throw new Error(`${response.error.message}`) - } - - const choice = response.choices[0] - - if (!choice) { - throw new Error('No choice in OpenRouter completion response') - } - - return { - content: [ - { - type: 'text', - text: choice.text ?? '', - }, - ], - finishReason: mapOpenRouterFinishReason(choice.finish_reason), - usage: { - inputTokens: response.usage?.prompt_tokens ?? 0, - outputTokens: response.usage?.completion_tokens ?? 0, - totalTokens: - (response.usage?.prompt_tokens ?? 0) + - (response.usage?.completion_tokens ?? 0), - reasoningTokens: - response.usage?.completion_tokens_details?.reasoning_tokens ?? 0, - cachedInputTokens: - response.usage?.prompt_tokens_details?.cached_tokens ?? 0, - }, - warnings: [], - response: { - headers: responseHeaders, - }, - } - } - - async doStream( - options: LanguageModelV2CallOptions, - ): Promise>> { - const providerOptions = options.providerOptions || {} - const openrouterOptions = providerOptions.openrouter || {} - - const args = { - ...this.getArgs(options), - ...openrouterOptions, - } - - const { value: response, responseHeaders } = await postJsonToApi({ - url: this.config.url({ - path: '/completions', - modelId: this.modelId, - }), - headers: combineHeaders(this.config.headers(), options.headers), - body: { - ...args, - stream: true, - - // only include stream_options when in strict compatibility mode: - stream_options: - this.config.compatibility === 'strict' - ? { include_usage: true } - : undefined, - }, - failedResponseHandler: openrouterFailedResponseHandler, - successfulResponseHandler: createEventSourceResponseHandler( - OpenRouterCompletionChunkSchema, - ), - abortSignal: options.abortSignal, - fetch: this.config.fetch, - }) - - let finishReason: FinishReason = 'other' - const usage: LanguageModelV2Usage = { - inputTokens: Number.NaN, - outputTokens: Number.NaN, - totalTokens: Number.NaN, - reasoningTokens: Number.NaN, - cachedInputTokens: Number.NaN, - } - - const openrouterUsage: Partial = {} - return { - stream: response.pipeThrough( - new TransformStream< - ParseResult>, - LanguageModelV2StreamPart - >({ - transform(chunk, controller) { - // handle failed chunk parsing / validation: - if (!chunk.success) { - finishReason = 'error' - controller.enqueue({ type: 'error', error: chunk.error }) - return - } - - const value = chunk.value - - // handle error chunks: - if ('error' in value) { - finishReason = 'error' - controller.enqueue({ type: 'error', error: value.error }) - return - } - - if (value.usage != null) { - usage.inputTokens = value.usage.prompt_tokens - usage.outputTokens = value.usage.completion_tokens - usage.totalTokens = - value.usage.prompt_tokens + value.usage.completion_tokens - - // Collect OpenRouter specific usage information - openrouterUsage.promptTokens = value.usage.prompt_tokens - - if (value.usage.prompt_tokens_details) { - const cachedInputTokens = - value.usage.prompt_tokens_details.cached_tokens ?? 0 - - usage.cachedInputTokens = cachedInputTokens - openrouterUsage.promptTokensDetails = { - cachedTokens: cachedInputTokens, - } - } - - openrouterUsage.completionTokens = value.usage.completion_tokens - if (value.usage.completion_tokens_details) { - const reasoningTokens = - value.usage.completion_tokens_details.reasoning_tokens ?? 0 - - usage.reasoningTokens = reasoningTokens - openrouterUsage.completionTokensDetails = { - reasoningTokens, - } - } - - if (value.usage.cost !== undefined) { - openrouterUsage.cost = value.usage.cost - } - openrouterUsage.totalTokens = value.usage.total_tokens - } - - const choice = value.choices[0] - - if (choice?.finish_reason != null) { - finishReason = mapOpenRouterFinishReason(choice.finish_reason) - } - - if (choice?.text != null) { - controller.enqueue({ - type: 'text-delta', - delta: choice.text, - id: generateId(), - }) - } - }, - - flush(controller) { - controller.enqueue({ - type: 'finish', - finishReason, - usage, - providerMetadata: { - openrouter: { - usage: openrouterUsage, - }, - }, - }) - }, - }), - ), - response: { - headers: responseHeaders, - }, - } - } -} diff --git a/packages/internal/src/openrouter-ai-sdk/completion/schemas.ts b/packages/internal/src/openrouter-ai-sdk/completion/schemas.ts deleted file mode 100644 index 28f82abfcd..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/completion/schemas.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { z } from 'zod/v4' - -import { OpenRouterErrorResponseSchema } from '../schemas/error-response' -import { ReasoningDetailArraySchema } from '../schemas/reasoning-details' - -// limited version of the schema, focussed on what is needed for the implementation -// this approach limits breakages when the API changes and increases efficiency -export const OpenRouterCompletionChunkSchema = z.union([ - z.object({ - id: z.string().optional(), - model: z.string().optional(), - choices: z.array( - z.object({ - text: z.string(), - reasoning: z.string().nullish().optional(), - reasoning_details: ReasoningDetailArraySchema.nullish(), - - finish_reason: z.string().nullish(), - index: z.number().nullish(), - logprobs: z - .object({ - tokens: z.array(z.string()), - token_logprobs: z.array(z.number()), - top_logprobs: z.array(z.record(z.string(), z.number())).nullable(), - }) - .nullable() - .optional(), - }), - ), - usage: z - .object({ - prompt_tokens: z.number(), - prompt_tokens_details: z - .object({ - cached_tokens: z.number(), - }) - .nullish(), - completion_tokens: z.number(), - completion_tokens_details: z - .object({ - reasoning_tokens: z.number(), - }) - .nullish(), - total_tokens: z.number(), - cost: z.number().optional(), - }) - .nullish(), - }), - OpenRouterErrorResponseSchema, -]) diff --git a/packages/internal/src/openrouter-ai-sdk/facade.ts b/packages/internal/src/openrouter-ai-sdk/facade.ts deleted file mode 100644 index cd66240457..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/facade.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { loadApiKey, withoutTrailingSlash } from '@ai-sdk/provider-utils' - -import { OpenRouterChatLanguageModel } from './chat' -import { OpenRouterCompletionLanguageModel } from './completion' - -import type { OpenRouterProviderSettings } from './provider' -import type { - OpenRouterChatModelId, - OpenRouterChatSettings, -} from './types/openrouter-chat-settings' -import type { - OpenRouterCompletionModelId, - OpenRouterCompletionSettings, -} from './types/openrouter-completion-settings' - - -/** -@deprecated Use `createOpenRouter` instead. - */ -export class OpenRouter { - /** -Use a different URL prefix for API calls, e.g. to use proxy servers. -The default prefix is `https://openrouter.ai/api/v1`. - */ - readonly baseURL: string - - /** -API key that is being sent using the `Authorization` header. -It defaults to the `OPENROUTER_API_KEY` environment variable. - */ - readonly apiKey?: string - - /** -Custom headers to include in the requests. - */ - readonly headers?: Record - - /** - * Creates a new OpenRouter provider instance. - */ - constructor(options: OpenRouterProviderSettings = {}) { - this.baseURL = - withoutTrailingSlash(options.baseURL ?? options.baseUrl) ?? - 'https://openrouter.ai/api/v1' - this.apiKey = options.apiKey - this.headers = options.headers - } - - private get baseConfig() { - return { - baseURL: this.baseURL, - headers: () => ({ - Authorization: `Bearer ${loadApiKey({ - apiKey: this.apiKey, - environmentVariableName: 'OPENROUTER_API_KEY', - description: 'OpenRouter', - })}`, - ...this.headers, - }), - } - } - - chat(modelId: OpenRouterChatModelId, settings: OpenRouterChatSettings = {}) { - return new OpenRouterChatLanguageModel(modelId, settings, { - provider: 'openrouter.chat', - ...this.baseConfig, - compatibility: 'strict', - url: ({ path }) => `${this.baseURL}${path}`, - }) - } - - completion( - modelId: OpenRouterCompletionModelId, - settings: OpenRouterCompletionSettings = {}, - ) { - return new OpenRouterCompletionLanguageModel(modelId, settings, { - provider: 'openrouter.completion', - ...this.baseConfig, - compatibility: 'strict', - url: ({ path }) => `${this.baseURL}${path}`, - }) - } -} diff --git a/packages/internal/src/openrouter-ai-sdk/index.ts b/packages/internal/src/openrouter-ai-sdk/index.ts deleted file mode 100644 index 14e12e4960..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './facade' -export * from './provider' -export * from './types' diff --git a/packages/internal/src/openrouter-ai-sdk/internal/index.ts b/packages/internal/src/openrouter-ai-sdk/internal/index.ts deleted file mode 100644 index 5f7acdc51e..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/internal/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from '../chat' -export * from '../completion' -export * from '../types' -export * from '../types/openrouter-chat-settings' -export * from '../types/openrouter-completion-settings' diff --git a/packages/internal/src/openrouter-ai-sdk/provider.ts b/packages/internal/src/openrouter-ai-sdk/provider.ts deleted file mode 100644 index 181be2e867..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/provider.ts +++ /dev/null @@ -1,180 +0,0 @@ - -import { loadApiKey, withoutTrailingSlash } from '@ai-sdk/provider-utils' - -import { OpenRouterChatLanguageModel } from './chat' -import { OpenRouterCompletionLanguageModel } from './completion' - -import type { - OpenRouterChatModelId, - OpenRouterChatSettings, -} from './types/openrouter-chat-settings' -import type { - OpenRouterCompletionModelId, - OpenRouterCompletionSettings, -} from './types/openrouter-completion-settings' -import type { LanguageModelV2 } from '@ai-sdk/provider' - -export type { OpenRouterCompletionSettings } - -export interface OpenRouterProvider extends LanguageModelV2 { - ( - modelId: OpenRouterChatModelId, - settings?: OpenRouterCompletionSettings, - ): OpenRouterCompletionLanguageModel - ( - modelId: OpenRouterChatModelId, - settings?: OpenRouterChatSettings, - ): OpenRouterChatLanguageModel - - languageModel( - modelId: OpenRouterChatModelId, - settings?: OpenRouterCompletionSettings, - ): OpenRouterCompletionLanguageModel - languageModel( - modelId: OpenRouterChatModelId, - settings?: OpenRouterChatSettings, - ): OpenRouterChatLanguageModel - - /** -Creates an OpenRouter chat model for text generation. - */ - chat( - modelId: OpenRouterChatModelId, - settings?: OpenRouterChatSettings, - ): OpenRouterChatLanguageModel - - /** -Creates an OpenRouter completion model for text generation. - */ - completion( - modelId: OpenRouterCompletionModelId, - settings?: OpenRouterCompletionSettings, - ): OpenRouterCompletionLanguageModel -} - -export interface OpenRouterProviderSettings { - /** -Base URL for the OpenRouter API calls. - */ - baseURL?: string - - /** -@deprecated Use `baseURL` instead. - */ - baseUrl?: string - - /** -API key for authenticating requests. - */ - apiKey?: string - - /** -Custom headers to include in the requests. - */ - headers?: Record - - /** -OpenRouter compatibility mode. Should be set to `strict` when using the OpenRouter API, -and `compatible` when using 3rd party providers. In `compatible` mode, newer -information such as streamOptions are not being sent. Defaults to 'compatible'. - */ - compatibility?: 'strict' | 'compatible' - - /** -Custom fetch implementation. You can use it as a middleware to intercept requests, -or to provide a custom fetch implementation for e.g. testing. - */ - fetch?: typeof fetch - - /** -A JSON object to send as the request body to access OpenRouter features & upstream provider features. - */ - extraBody?: Record -} - -/** -Create an OpenRouter provider instance. - */ -export function createOpenRouter( - options: OpenRouterProviderSettings = {}, -): OpenRouterProvider { - const baseURL = - withoutTrailingSlash(options.baseURL ?? options.baseUrl) ?? - 'https://openrouter.ai/api/v1' - - // we default to compatible, because strict breaks providers like Groq: - const compatibility = options.compatibility ?? 'compatible' - - const getHeaders = () => ({ - Authorization: `Bearer ${loadApiKey({ - apiKey: options.apiKey, - environmentVariableName: 'OPENROUTER_API_KEY', - description: 'OpenRouter', - })}`, - ...options.headers, - }) - - const createChatModel = ( - modelId: OpenRouterChatModelId, - settings: OpenRouterChatSettings = {}, - ) => - new OpenRouterChatLanguageModel(modelId, settings, { - provider: 'openrouter.chat', - url: ({ path }) => `${baseURL}${path}`, - headers: getHeaders, - compatibility, - fetch: options.fetch, - extraBody: options.extraBody, - }) - - const createCompletionModel = ( - modelId: OpenRouterCompletionModelId, - settings: OpenRouterCompletionSettings = {}, - ) => - new OpenRouterCompletionLanguageModel(modelId, settings, { - provider: 'openrouter.completion', - url: ({ path }) => `${baseURL}${path}`, - headers: getHeaders, - compatibility, - fetch: options.fetch, - extraBody: options.extraBody, - }) - - const createLanguageModel = ( - modelId: OpenRouterChatModelId | OpenRouterCompletionModelId, - settings?: OpenRouterChatSettings | OpenRouterCompletionSettings, - ) => { - if (new.target) { - throw new Error( - 'The OpenRouter model function cannot be called with the new keyword.', - ) - } - - if (modelId === 'openai/gpt-3.5-turbo-instruct') { - return createCompletionModel( - modelId, - settings as OpenRouterCompletionSettings, - ) - } - - return createChatModel(modelId, settings as OpenRouterChatSettings) - } - - const provider = ( - modelId: OpenRouterChatModelId | OpenRouterCompletionModelId, - settings?: OpenRouterChatSettings | OpenRouterCompletionSettings, - ) => createLanguageModel(modelId, settings) - - provider.languageModel = createLanguageModel - provider.chat = createChatModel - provider.completion = createCompletionModel - - return provider as OpenRouterProvider -} - -/** -Default OpenRouter provider instance. It uses 'strict' compatibility mode. - */ -export const openrouter = createOpenRouter({ - compatibility: 'strict', // strict for OpenRouter API -}) diff --git a/packages/internal/src/openrouter-ai-sdk/schemas/error-response.test.ts b/packages/internal/src/openrouter-ai-sdk/schemas/error-response.test.ts deleted file mode 100644 index 60de40c0fc..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/schemas/error-response.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'bun:test' - -import { OpenRouterErrorResponseSchema } from './error-response' - -describe('OpenRouterErrorResponseSchema', () => { - it('should be valid without a type, code, and param', () => { - const errorWithoutTypeCodeAndParam = { - error: { - message: 'Example error message', - metadata: { provider_name: 'Morph' }, - }, - user_id: 'example_1', - } - - const result = OpenRouterErrorResponseSchema.parse( - errorWithoutTypeCodeAndParam, - ) - - expect(result).toEqual({ - error: { - message: 'Example error message', - code: null, - type: null, - param: null, - }, - }) - }) - - it('should be invalid with a type', () => { - const errorWithType = { - error: { - message: 'Example error message with type', - type: 'invalid_request_error', - code: 400, - param: 'canBeAnything', - metadata: { provider_name: 'Morph' }, - }, - } - - const result = OpenRouterErrorResponseSchema.parse(errorWithType) - - expect(result).toEqual({ - error: { - code: 400, - message: 'Example error message with type', - type: 'invalid_request_error', - param: 'canBeAnything', - }, - }) - }) -}) diff --git a/packages/internal/src/openrouter-ai-sdk/schemas/error-response.ts b/packages/internal/src/openrouter-ai-sdk/schemas/error-response.ts deleted file mode 100644 index 311bf39943..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/schemas/error-response.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createJsonErrorResponseHandler } from '@ai-sdk/provider-utils' -import { z } from 'zod/v4' - -export const OpenRouterErrorResponseSchema = z.object({ - error: z.object({ - code: z.union([z.string(), z.number()]).nullable().optional().default(null), - message: z.string(), - type: z.string().nullable().optional().default(null), - param: z.any().nullable().optional().default(null), - }), -}) - -export type OpenRouterErrorData = z.infer - -export const openrouterFailedResponseHandler = createJsonErrorResponseHandler({ - errorSchema: OpenRouterErrorResponseSchema, - errorToMessage: (data: OpenRouterErrorData) => data.error.message, -}) diff --git a/packages/internal/src/openrouter-ai-sdk/schemas/reasoning-details.ts b/packages/internal/src/openrouter-ai-sdk/schemas/reasoning-details.ts deleted file mode 100644 index 51cc9af276..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/schemas/reasoning-details.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from 'zod/v4' - -export enum ReasoningDetailType { - Summary = 'reasoning.summary', - Encrypted = 'reasoning.encrypted', - Text = 'reasoning.text', -} - -export const ReasoningDetailSummarySchema = z.object({ - type: z.literal(ReasoningDetailType.Summary), - summary: z.string(), -}) -export type ReasoningDetailSummary = z.infer< - typeof ReasoningDetailSummarySchema -> - -export const ReasoningDetailEncryptedSchema = z.object({ - type: z.literal(ReasoningDetailType.Encrypted), - data: z.string(), -}) -export type ReasoningDetailEncrypted = z.infer< - typeof ReasoningDetailEncryptedSchema -> - -export const ReasoningDetailTextSchema = z.object({ - type: z.literal(ReasoningDetailType.Text), - text: z.string().nullish(), - signature: z.string().nullish(), -}) - -export type ReasoningDetailText = z.infer - -export const ReasoningDetailUnionSchema = z.union([ - ReasoningDetailSummarySchema, - ReasoningDetailEncryptedSchema, - ReasoningDetailTextSchema, -]) - -const ReasoningDetailsWithUnknownSchema = z.union([ - ReasoningDetailUnionSchema, - z.unknown().transform(() => null), -]) - -export type ReasoningDetailUnion = z.infer - -export const ReasoningDetailArraySchema = z - .array(ReasoningDetailsWithUnknownSchema) - .transform((d) => d.filter((d): d is ReasoningDetailUnion => !!d)) diff --git a/packages/internal/src/openrouter-ai-sdk/tests/provider-options.test.ts b/packages/internal/src/openrouter-ai-sdk/tests/provider-options.test.ts deleted file mode 100644 index 466b0549af..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/tests/provider-options.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { streamText } from 'ai' -import { beforeEach, describe, expect, it, mock } from 'bun:test' - -import { createOpenRouter } from '../provider' - -import type { ModelMessage } from 'ai' - -type MockResponseDefinition = - | { - type: 'json-value' - body: any - headers?: Record - status?: number - } - | { - type: 'stream-chunks' - chunks: string[] - headers?: Record - status?: number - } - -type MockServerRoute = { - response: MockResponseDefinition -} - -type MockServerCall = { - requestHeaders: Record - requestBodyJson: Promise -} - -const createStreamFromChunks = (chunks: string[]) => - new ReadableStream({ - start(controller) { - try { - for (const chunk of chunks) { - controller.enqueue(chunk) - } - } finally { - controller.close() - } - }, - }).pipeThrough(new TextEncoderStream()) - -function toHeadersRecord(headers?: HeadersInit): Record { - const result: Record = {} - - if (!headers) { - return result - } - - if (headers instanceof Headers) { - headers.forEach((value, key) => { - result[key.toLowerCase()] = value - }) - return result - } - - if (Array.isArray(headers)) { - for (const [key, value] of headers) { - result[String(key).toLowerCase()] = String(value) - } - return result - } - - for (const [key, value] of Object.entries(headers)) { - if (typeof value !== 'undefined') { - result[key.toLowerCase()] = String(value) - } - } - - return result -} - -function parseRequestBody(body: BodyInit | null | undefined): any { - if (body == null) { - return undefined - } - - if (typeof body === 'string') { - try { - return JSON.parse(body) - } catch { - return undefined - } - } - - return undefined -} - -function createMockServer(routes: Record) { - const urls: Record = Object.fromEntries( - Object.entries(routes).map(([url, config]) => [ - url, - { - response: { ...config.response }, - }, - ]), - ) - - const calls: MockServerCall[] = [] - - const buildResponse = (definition: MockResponseDefinition): Response => { - const status = definition.status ?? 200 - - if (definition.type === 'json-value') { - return new Response(JSON.stringify(definition.body), { - status, - headers: { - 'Content-Type': 'application/json', - ...definition.headers, - }, - }) - } - - return new Response(createStreamFromChunks(definition.chunks), { - status, - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - ...definition.headers, - }, - }) - } - - const fetchImpl = async (input: RequestInfo, init: RequestInit = {}) => { - const url = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url - - const route = urls[url] - - if (!route) { - return new Response('Not Found', { status: 404 }) - } - - const requestHeaders = toHeadersRecord(init.headers) - const requestBodyJson = Promise.resolve(parseRequestBody(init.body)) - - calls.push({ requestHeaders, requestBodyJson }) - - return buildResponse(route.response) - } - - const fetch = ((input: RequestInfo | URL, init?: RequestInit) => - fetchImpl(input as RequestInfo, init ?? {})) as typeof global.fetch - - fetch.preconnect = async () => {} - - return { - urls, - calls, - fetch, - } -} - -// Add type assertions for the mocked classes -const TEST_MESSAGES: ModelMessage[] = [ - { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, -] - -describe('providerOptions', () => { - const server = createMockServer({ - 'https://openrouter.ai/api/v1/chat/completions': { - response: { - type: 'stream-chunks', - chunks: [], - }, - }, - }) - - const openrouter = createOpenRouter({ - apiKey: 'test', - fetch: server.fetch, - }) - - beforeEach(() => { - mock.clearAllMocks() - server.calls.length = 0 - server.urls['https://openrouter.ai/api/v1/chat/completions']!.response = { - type: 'stream-chunks', - chunks: [ - 'data: {"choices":[{"delta":{"content":"ok"}}]}' + '\n\n', - 'data: [DONE]' + '\n\n', - ], - } - }) - - it('should set providerOptions openrouter to extra body', async () => { - const model = openrouter('anthropic/claude-3.7-sonnet') - - await streamText({ - model: model, - messages: TEST_MESSAGES, - providerOptions: { - openrouter: { - reasoning: { - max_tokens: 1000, - }, - }, - }, - }).consumeStream() - - const requestBody = await server.calls[0]?.requestBodyJson - - expect(requestBody).toStrictEqual({ - messages: [ - { - content: [{ type: 'text', text: 'Hello' }], - role: 'user', - }, - ], - reasoning: { - max_tokens: 1000, - }, - model: 'anthropic/claude-3.7-sonnet', - stream: true, - }) - }) -}) diff --git a/packages/internal/src/openrouter-ai-sdk/tests/stream-usage-accounting.test.ts b/packages/internal/src/openrouter-ai-sdk/tests/stream-usage-accounting.test.ts deleted file mode 100644 index 8091a61a18..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/tests/stream-usage-accounting.test.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { convertReadableStreamToArray } from '@ai-sdk/provider-utils/test' -import { afterEach, beforeEach, describe, expect, it } from 'bun:test' - -import { OpenRouterChatLanguageModel } from '../chat' - -import type { OpenRouterChatSettings } from '../types/openrouter-chat-settings' - -describe('OpenRouter Streaming Usage Accounting', () => { - const originalFetch = global.fetch - let capturedRequests: Array<{ - url: string - body?: any - }> = [] - let nextResponseChunks: string[] = [] - - const createStreamFromChunks = (chunks: string[]) => - new ReadableStream({ - start(controller) { - for (const chunk of chunks) { - controller.enqueue(chunk) - } - controller.close() - }, - }).pipeThrough(new TextEncoderStream()) - - beforeEach(() => { - capturedRequests = [] - global.fetch = (async (input: RequestInfo, init?: RequestInit) => { - const url = - typeof input === 'string' - ? input - : input instanceof URL - ? input.toString() - : input.url - - let parsedBody: any - if (init?.body && typeof init.body === 'string') { - try { - parsedBody = JSON.parse(init.body) - } catch { - parsedBody = undefined - } - } - - capturedRequests.push({ url, body: parsedBody }) - - return new Response(createStreamFromChunks(nextResponseChunks), { - status: 200, - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - }, - }) - }) as typeof global.fetch - }) - - afterEach(() => { - global.fetch = originalFetch - nextResponseChunks = [] - }) - - function prepareStreamResponse(includeUsage = true) { - nextResponseChunks = [ - `data: {"id":"test-id","model":"test-model","choices":[{"delta":{"content":"Hello"},"index":0}]}\n\n`, - `data: {"choices":[{"finish_reason":"stop","index":0}]}\n\n`, - ] - - if (includeUsage) { - nextResponseChunks.push( - `data: ${JSON.stringify({ - usage: { - prompt_tokens: 10, - prompt_tokens_details: { cached_tokens: 5 }, - completion_tokens: 20, - completion_tokens_details: { reasoning_tokens: 8 }, - total_tokens: 30, - cost: 0.0015, - cost_details: { - upstream_inference_cost: 19, - }, - }, - choices: [], - })}\n\n`, - ) - } - - nextResponseChunks.push('data: [DONE]\n\n') - } - - it('should include stream_options.include_usage in request when enabled', async () => { - prepareStreamResponse() - - // Create model with usage accounting enabled - const settings: OpenRouterChatSettings = { - usage: { include: true }, - } - - const model = new OpenRouterChatLanguageModel('test-model', settings, { - provider: 'openrouter.chat', - url: () => 'https://api.openrouter.ai/chat/completions', - headers: () => ({}), - compatibility: 'strict', - fetch: global.fetch, - }) - - // Call the model with streaming - await model.doStream({ - prompt: [ - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - ], - maxOutputTokens: 100, - }) - - // Verify stream options - const requestBody = capturedRequests[0]?.body - expect(requestBody).toBeDefined() - expect(requestBody.stream).toBe(true) - expect(requestBody.stream_options).toEqual({ - include_usage: true, - }) - }) - - it('should include provider-specific metadata in finish event when usage accounting is enabled', async () => { - prepareStreamResponse(true) - - // Create model with usage accounting enabled - const settings: OpenRouterChatSettings = { - usage: { include: true }, - } - - const model = new OpenRouterChatLanguageModel('test-model', settings, { - provider: 'openrouter.chat', - url: () => 'https://api.openrouter.ai/chat/completions', - headers: () => ({}), - compatibility: 'strict', - fetch: global.fetch, - }) - - // Call the model with streaming - const result = await model.doStream({ - prompt: [ - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - ], - maxOutputTokens: 100, - }) - - // Read all chunks from the stream - const chunks = await convertReadableStreamToArray(result.stream) - - // Find the finish chunk - const finishChunk = chunks.find((chunk) => chunk.type === 'finish') - expect(finishChunk).toBeDefined() - - // Verify metadata is included - expect(finishChunk?.providerMetadata).toBeDefined() - const openrouterData = finishChunk?.providerMetadata?.openrouter - expect(openrouterData).toBeDefined() - - const usage = openrouterData?.usage - expect(usage).toMatchObject({ - promptTokens: 10, - completionTokens: 20, - totalTokens: 30, - cost: 0.0015, - costDetails: { - upstreamInferenceCost: 19, - }, - promptTokensDetails: { cachedTokens: 5 }, - completionTokensDetails: { reasoningTokens: 8 }, - }) - }) - - it('should not include provider-specific metadata when usage accounting is disabled', async () => { - prepareStreamResponse(false) - - // Create model with usage accounting disabled - const settings: OpenRouterChatSettings = { - // No usage property - } - - const model = new OpenRouterChatLanguageModel('test-model', settings, { - provider: 'openrouter.chat', - url: () => 'https://api.openrouter.ai/chat/completions', - headers: () => ({}), - compatibility: 'strict', - fetch: global.fetch, - }) - - // Call the model with streaming - const result = await model.doStream({ - prompt: [ - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - ], - maxOutputTokens: 100, - }) - - // Read all chunks from the stream - const chunks = await convertReadableStreamToArray(result.stream) - - // Find the finish chunk - const finishChunk = chunks.find((chunk) => chunk.type === 'finish') - expect(finishChunk).toBeDefined() - - // Verify that provider metadata is not included - expect(finishChunk?.providerMetadata?.openrouter).toStrictEqual({ - usage: {}, - }) - }) -}) diff --git a/packages/internal/src/openrouter-ai-sdk/tests/usage-accounting.test.ts b/packages/internal/src/openrouter-ai-sdk/tests/usage-accounting.test.ts deleted file mode 100644 index 4189a3d8b9..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/tests/usage-accounting.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -import { createTestServer } from '@ai-sdk/provider-utils/test' -import { describe, expect, it } from 'bun:test' - -import { OpenRouterChatLanguageModel } from '../chat' - -import type { OpenRouterChatSettings } from '../types/openrouter-chat-settings' - -describe('OpenRouter Usage Accounting', () => { - const server = createTestServer({ - 'https://api.openrouter.ai/chat/completions': { - response: { type: 'json-value', body: {} }, - }, - }) - - function prepareJsonResponse(includeUsage = true) { - const response = { - id: 'test-id', - model: 'test-model', - choices: [ - { - message: { - role: 'assistant', - content: 'Hello, I am an AI assistant.', - }, - index: 0, - finish_reason: 'stop', - }, - ], - usage: includeUsage - ? { - prompt_tokens: 10, - prompt_tokens_details: { - cached_tokens: 5, - }, - completion_tokens: 20, - completion_tokens_details: { - reasoning_tokens: 8, - }, - total_tokens: 30, - cost: 0.0015, - cost_details: { - upstream_inference_cost: 19, - }, - } - : undefined, - } - - server.urls['https://api.openrouter.ai/chat/completions']!.response = { - type: 'json-value', - body: response, - } - } - - it('should include usage parameter in the request when enabled', async () => { - prepareJsonResponse() - - // Create model with usage accounting enabled - const settings: OpenRouterChatSettings = { - usage: { include: true }, - } - - const model = new OpenRouterChatLanguageModel('test-model', settings, { - provider: 'openrouter.chat', - url: () => 'https://api.openrouter.ai/chat/completions', - headers: () => ({}), - compatibility: 'strict', - fetch: global.fetch, - }) - - // Call the model - await model.doGenerate({ - prompt: [ - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - ], - maxOutputTokens: 100, - }) - - // Check request contains usage parameter - const requestBody = await server.calls[0]!.requestBodyJson - expect(requestBody).toBeDefined() - expect(requestBody).toHaveProperty('usage') - expect(requestBody.usage).toEqual({ include: true }) - }) - - it('should include provider-specific metadata in response when usage accounting is enabled', async () => { - prepareJsonResponse() - - // Create model with usage accounting enabled - const settings: OpenRouterChatSettings = { - usage: { include: true }, - } - - const model = new OpenRouterChatLanguageModel('test-model', settings, { - provider: 'openrouter.chat', - url: () => 'https://api.openrouter.ai/chat/completions', - headers: () => ({}), - compatibility: 'strict', - fetch: global.fetch, - }) - - // Call the model - const result = await model.doGenerate({ - prompt: [ - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - ], - maxOutputTokens: 100, - }) - - // Check result contains provider metadata - expect(result.providerMetadata).toBeDefined() - const providerData = result.providerMetadata - - // Check for OpenRouter usage data - expect(providerData?.openrouter).toBeDefined() - const openrouterData = providerData?.openrouter as Record - expect(openrouterData.usage).toBeDefined() - - const usage = openrouterData.usage - expect(usage).toMatchObject({ - promptTokens: 10, - completionTokens: 20, - totalTokens: 30, - cost: 0.0015, - promptTokensDetails: { - cachedTokens: 5, - }, - completionTokensDetails: { - reasoningTokens: 8, - }, - }) - }) - - it('should not include provider-specific metadata when usage accounting is disabled', async () => { - prepareJsonResponse() - - // Create model with usage accounting disabled - const settings: OpenRouterChatSettings = { - // No usage property - } - - const model = new OpenRouterChatLanguageModel('test-model', settings, { - provider: 'openrouter.chat', - url: () => 'https://api.openrouter.ai/chat/completions', - headers: () => ({}), - compatibility: 'strict', - fetch: global.fetch, - }) - - // Call the model - const result = await model.doGenerate({ - prompt: [ - { - role: 'user', - content: [{ type: 'text', text: 'Hello' }], - }, - ], - maxOutputTokens: 100, - }) - - // Verify that OpenRouter metadata is not included - expect(result.providerMetadata?.openrouter?.usage).toStrictEqual({ - promptTokens: 10, - completionTokens: 20, - totalTokens: 30, - cost: 0.0015, - costDetails: { - upstreamInferenceCost: 19, - }, - promptTokensDetails: { - cachedTokens: 5, - }, - completionTokensDetails: { - reasoningTokens: 8, - }, - }) - }) -}) diff --git a/packages/internal/src/openrouter-ai-sdk/types/index.ts b/packages/internal/src/openrouter-ai-sdk/types/index.ts deleted file mode 100644 index 64f779c21e..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/types/index.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { LanguageModelV2, LanguageModelV2Prompt } from '@ai-sdk/provider' - -export type { LanguageModelV2, LanguageModelV2Prompt } - -export type OpenRouterProviderOptions = { - models?: string[] - - /** - * https://openrouter.ai/docs/use-cases/reasoning-tokens - * One of `max_tokens` or `effort` is required. - * If `exclude` is true, reasoning will be removed from the response. Default is false. - */ - reasoning?: { - enabled?: boolean - exclude?: boolean - } & ( - | { - max_tokens: number - } - | { - effort: 'high' | 'medium' | 'low' | 'minimal' | 'none' - } - ) - - /** - * A unique identifier representing your end-user, which can - * help OpenRouter to monitor and detect abuse. - */ - user?: string -} - -export type OpenRouterSharedSettings = OpenRouterProviderOptions & { - /** - * @deprecated use `reasoning` instead - */ - includeReasoning?: boolean - - extraBody?: Record - - /** - * Enable usage accounting to get detailed token usage information. - * https://openrouter.ai/docs/use-cases/usage-accounting - */ - usage?: { - /** - * When true, includes token usage information in the response. - */ - include: boolean - } -} - -/** - * Usage accounting response - * @see https://openrouter.ai/docs/use-cases/usage-accounting - */ -export type OpenRouterUsageAccounting = { - promptTokens: number - promptTokensDetails?: { - cachedTokens: number - } - completionTokens: number - completionTokensDetails?: { - reasoningTokens: number - } - totalTokens: number - cost?: number - costDetails: { - upstreamInferenceCost: number - } -} diff --git a/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-completions-input.ts b/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-completions-input.ts deleted file mode 100644 index 4187661d3a..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-completions-input.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { ReasoningDetailUnion } from '../schemas/reasoning-details' - -// Type for OpenRouter Cache Control following Anthropic's pattern -export type OpenRouterCacheControl = { type: 'ephemeral' } - -export type OpenRouterChatCompletionsInput = Array - -export type ChatCompletionMessageParam = - | ChatCompletionSystemMessageParam - | ChatCompletionUserMessageParam - | ChatCompletionAssistantMessageParam - | ChatCompletionToolMessageParam - -export interface ChatCompletionSystemMessageParam { - role: 'system' - content: string - cache_control?: OpenRouterCacheControl -} - -export interface ChatCompletionUserMessageParam { - role: 'user' - content: string | Array - cache_control?: OpenRouterCacheControl -} - -export type ChatCompletionContentPart = - | ChatCompletionContentPartText - | ChatCompletionContentPartImage - | ChatCompletionContentPartFile - -export interface ChatCompletionContentPartFile { - type: 'file' - file: { - filename: string - file_data: string - } - cache_control?: OpenRouterCacheControl -} - -export interface ChatCompletionContentPartImage { - type: 'image_url' - image_url: { - url: string - } - cache_control?: OpenRouterCacheControl -} - -export interface ChatCompletionContentPartText { - type: 'text' - text: string - reasoning?: string | null - cache_control?: OpenRouterCacheControl -} - -export interface ChatCompletionAssistantMessageParam { - role: 'assistant' - content?: string | null - reasoning?: string | null - reasoning_details?: ReasoningDetailUnion[] - tool_calls?: Array - cache_control?: OpenRouterCacheControl -} - -export interface ChatCompletionMessageToolCall { - type: 'function' - id: string - function: { - arguments: string - name: string - } -} - -export interface ChatCompletionToolMessageParam { - role: 'tool' - content: string - tool_call_id: string - cache_control?: OpenRouterCacheControl -} diff --git a/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-settings.ts b/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-settings.ts deleted file mode 100644 index 90a6690743..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/types/openrouter-chat-settings.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { OpenRouterSharedSettings } from '..' - -// https://openrouter.ai/api/v1/models -export type OpenRouterChatModelId = string - -export type OpenRouterChatSettings = { - /** -Modify the likelihood of specified tokens appearing in the completion. - -Accepts a JSON object that maps tokens (specified by their token ID in -the GPT tokenizer) to an associated bias value from -100 to 100. You -can use this tokenizer tool to convert text to token IDs. Mathematically, -the bias is added to the logits generated by the model prior to sampling. -The exact effect will vary per model, but values between -1 and 1 should -decrease or increase likelihood of selection; values like -100 or 100 -should result in a ban or exclusive selection of the relevant token. - -As an example, you can pass {"50256": -100} to prevent the <|end-of-text|> -token from being generated. -*/ - logitBias?: Record - - /** -Return the log probabilities of the tokens. Including logprobs will increase -the response size and can slow down response times. However, it can -be useful to understand better how the model is behaving. - -Setting to true will return the log probabilities of the tokens that -were generated. - -Setting to a number will return the log probabilities of the top n -tokens that were generated. -*/ - logprobs?: boolean | number - - /** -Whether to enable parallel function calling during tool use. Default to true. - */ - parallelToolCalls?: boolean - - /** -A unique identifier representing your end-user, which can help OpenRouter to -monitor and detect abuse. Learn more. -*/ - user?: string - - /** - * Web search plugin configuration for enabling web search capabilities - */ - plugins?: Array<{ - id: 'web' - /** - * Maximum number of search results to include (default: 5) - */ - max_results?: number - /** - * Custom search prompt to guide the search query - */ - search_prompt?: string - }> - - /** - * Built-in web search options for models that support native web search - */ - web_search_options?: { - /** - * Maximum number of search results to include - */ - max_results?: number - /** - * Custom search prompt to guide the search query - */ - search_prompt?: string - } - - /** - * Provider routing preferences to control request routing behavior - */ - provider?: { - /** - * List of provider slugs to try in order (e.g. ["anthropic", "openai"]) - */ - order?: string[] - /** - * Whether to allow backup providers when primary is unavailable (default: true) - */ - allow_fallbacks?: boolean - /** - * Only use providers that support all parameters in your request (default: false) - */ - require_parameters?: boolean - /** - * Control whether to use providers that may store data - */ - data_collection?: 'allow' | 'deny' - /** - * List of provider slugs to allow for this request - */ - only?: string[] - /** - * List of provider slugs to skip for this request - */ - ignore?: string[] - /** - * List of quantization levels to filter by (e.g. ["int4", "int8"]) - */ - quantizations?: Array< - | 'int4' - | 'int8' - | 'fp4' - | 'fp6' - | 'fp8' - | 'fp16' - | 'bf16' - | 'fp32' - | 'unknown' - > - /** - * Sort providers by price, throughput, or latency - */ - sort?: 'price' | 'throughput' | 'latency' - /** - * Maximum pricing you want to pay for this request - */ - max_price?: { - prompt?: number | string - completion?: number | string - image?: number | string - audio?: number | string - request?: number | string - } - } -} & OpenRouterSharedSettings diff --git a/packages/internal/src/openrouter-ai-sdk/types/openrouter-completion-settings.ts b/packages/internal/src/openrouter-ai-sdk/types/openrouter-completion-settings.ts deleted file mode 100644 index 661aa3f7e4..0000000000 --- a/packages/internal/src/openrouter-ai-sdk/types/openrouter-completion-settings.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { OpenRouterSharedSettings } from '.' - -export type OpenRouterCompletionModelId = string - -export type OpenRouterCompletionSettings = { - /** -Modify the likelihood of specified tokens appearing in the completion. - -Accepts a JSON object that maps tokens (specified by their token ID in -the GPT tokenizer) to an associated bias value from -100 to 100. You -can use this tokenizer tool to convert text to token IDs. Mathematically, -the bias is added to the logits generated by the model prior to sampling. -The exact effect will vary per model, but values between -1 and 1 should -decrease or increase likelihood of selection; values like -100 or 100 -should result in a ban or exclusive selection of the relevant token. - -As an example, you can pass {"50256": -100} to prevent the <|end-of-text|> -token from being generated. - */ - logitBias?: Record - - /** -Return the log probabilities of the tokens. Including logprobs will increase -the response size and can slow down response times. However, it can -be useful to better understand how the model is behaving. - -Setting to true will return the log probabilities of the tokens that -were generated. - -Setting to a number will return the log probabilities of the top n -tokens that were generated. - */ - logprobs?: boolean | number - - /** -The suffix that comes after a completion of inserted text. - */ - suffix?: string -} & OpenRouterSharedSettings diff --git a/packages/llm-providers/package.json b/packages/llm-providers/package.json new file mode 100644 index 0000000000..6093d03d57 --- /dev/null +++ b/packages/llm-providers/package.json @@ -0,0 +1,35 @@ +{ + "name": "@codebuff/llm-providers", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + "./openai-compatible": { + "bun": "./src/openai-compatible/index.ts", + "import": "./src/openai-compatible/index.ts", + "types": "./src/openai-compatible/index.ts", + "default": "./src/openai-compatible/index.ts" + }, + "./*": { + "bun": "./src/*.ts", + "import": "./src/*.ts", + "types": "./src/*.ts", + "default": "./src/*.ts" + } + }, + "scripts": { + "typecheck": "tsc --noEmit -p .", + "test": "bun test" + }, + "sideEffects": false, + "engines": { + "bun": "1.3.11" + }, + "dependencies": { + "@ai-sdk/provider": "2.0.1", + "@ai-sdk/provider-utils": "^3.0.17", + "ai": "^5.0.52", + "zod": "^4.2.1" + }, + "devDependencies": {} +} diff --git a/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts similarity index 100% rename from packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts rename to packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.test.ts diff --git a/packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts b/packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts similarity index 100% rename from packages/internal/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts rename to packages/llm-providers/src/openai-compatible/chat/convert-to-openai-compatible-chat-messages.ts diff --git a/packages/internal/src/openai-compatible/chat/get-response-metadata.ts b/packages/llm-providers/src/openai-compatible/chat/get-response-metadata.ts similarity index 65% rename from packages/internal/src/openai-compatible/chat/get-response-metadata.ts rename to packages/llm-providers/src/openai-compatible/chat/get-response-metadata.ts index bd358b23f7..708fd968e3 100644 --- a/packages/internal/src/openai-compatible/chat/get-response-metadata.ts +++ b/packages/llm-providers/src/openai-compatible/chat/get-response-metadata.ts @@ -3,13 +3,13 @@ export function getResponseMetadata({ model, created, }: { - id?: string | undefined | null; - created?: number | undefined | null; - model?: string | undefined | null; + id?: string | undefined | null + created?: number | undefined | null + model?: string | undefined | null }) { return { id: id ?? undefined, modelId: model ?? undefined, timestamp: created != null ? new Date(created * 1000) : undefined, - }; + } } diff --git a/packages/internal/src/openrouter-ai-sdk/utils/map-finish-reason.ts b/packages/llm-providers/src/openai-compatible/chat/map-openai-compatible-finish-reason.ts similarity index 89% rename from packages/internal/src/openrouter-ai-sdk/utils/map-finish-reason.ts rename to packages/llm-providers/src/openai-compatible/chat/map-openai-compatible-finish-reason.ts index b6f9aea783..2a4e9eccc2 100644 --- a/packages/internal/src/openrouter-ai-sdk/utils/map-finish-reason.ts +++ b/packages/llm-providers/src/openai-compatible/chat/map-openai-compatible-finish-reason.ts @@ -1,6 +1,6 @@ import type { LanguageModelV2FinishReason } from '@ai-sdk/provider' -export function mapOpenRouterFinishReason( +export function mapOpenAICompatibleFinishReason( finishReason: string | null | undefined, ): LanguageModelV2FinishReason { switch (finishReason) { diff --git a/packages/internal/src/openai-compatible/chat/openai-compatible-api-types.ts b/packages/llm-providers/src/openai-compatible/chat/openai-compatible-api-types.ts similarity index 53% rename from packages/internal/src/openai-compatible/chat/openai-compatible-api-types.ts rename to packages/llm-providers/src/openai-compatible/chat/openai-compatible-api-types.ts index 87afbd575a..f8db776604 100644 --- a/packages/internal/src/openai-compatible/chat/openai-compatible-api-types.ts +++ b/packages/llm-providers/src/openai-compatible/chat/openai-compatible-api-types.ts @@ -1,63 +1,61 @@ -import type { JSONValue } from '@ai-sdk/provider'; +import type { JSONValue } from '@ai-sdk/provider' -export type OpenAICompatibleChatPrompt = Array; +export type OpenAICompatibleChatPrompt = Array export type OpenAICompatibleMessage = | OpenAICompatibleSystemMessage | OpenAICompatibleUserMessage | OpenAICompatibleAssistantMessage - | OpenAICompatibleToolMessage; + | OpenAICompatibleToolMessage // Allow for arbitrary additional properties for general purpose // provider-metadata-specific extensibility. type JsonRecord = Record< string, JSONValue | JSONValue[] | T | T[] | undefined ->; +> export interface OpenAICompatibleSystemMessage extends JsonRecord { - role: 'system'; - content: string; + role: 'system' + content: string } -export interface OpenAICompatibleUserMessage - extends JsonRecord { - role: 'user'; - content: string | Array; +export interface OpenAICompatibleUserMessage extends JsonRecord { + role: 'user' + content: string | Array } export type OpenAICompatibleContentPart = | OpenAICompatibleContentPartText - | OpenAICompatibleContentPartImage; + | OpenAICompatibleContentPartImage export interface OpenAICompatibleContentPartImage extends JsonRecord { - type: 'image_url'; - image_url: { url: string }; + type: 'image_url' + image_url: { url: string } } export interface OpenAICompatibleContentPartText extends JsonRecord { - type: 'text'; - text: string; + type: 'text' + text: string } -export interface OpenAICompatibleAssistantMessage - extends JsonRecord { - role: 'assistant'; - content?: string | null; - tool_calls?: Array; +export interface OpenAICompatibleAssistantMessage extends JsonRecord { + role: 'assistant' + content?: string | null + tool_calls?: Array } export interface OpenAICompatibleMessageToolCall extends JsonRecord { - type: 'function'; - id: string; + type: 'function' + id: string function: { - arguments: string; - name: string; - }; + arguments: string + name: string + } } export interface OpenAICompatibleToolMessage extends JsonRecord { - role: 'tool'; - content: string; - tool_call_id: string; + role: 'tool' + content: string + tool_call_id: string } diff --git a/packages/internal/src/openai-compatible/chat/openai-compatible-chat-language-model.ts b/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-language-model.ts similarity index 81% rename from packages/internal/src/openai-compatible/chat/openai-compatible-chat-language-model.ts rename to packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-language-model.ts index 7b2619ae8f..7e49bfcadc 100644 --- a/packages/internal/src/openai-compatible/chat/openai-compatible-chat-language-model.ts +++ b/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-language-model.ts @@ -1,6 +1,4 @@ -import { - InvalidResponseDataError -} from '@ai-sdk/provider'; +import { InvalidResponseDataError } from '@ai-sdk/provider' import { combineHeaders, createEventSourceResponseHandler, @@ -9,26 +7,20 @@ import { generateId, isParsableJson, parseProviderOptions, - postJsonToApi -} from '@ai-sdk/provider-utils'; -import { z } from 'zod/v4'; - -import { convertToOpenAICompatibleChatMessages } from './convert-to-openai-compatible-chat-messages'; -import { getResponseMetadata } from './get-response-metadata'; -import { mapOpenAICompatibleFinishReason } from './map-openai-compatible-finish-reason'; -import { - openaiCompatibleProviderOptions, -} from './openai-compatible-chat-options'; -import { - defaultOpenAICompatibleErrorStructure -} from '../openai-compatible-error'; -import { prepareTools } from './openai-compatible-prepare-tools'; - -import type { - OpenAICompatibleChatModelId} from './openai-compatible-chat-options'; -import type { - ProviderErrorStructure} from '../openai-compatible-error'; -import type { MetadataExtractor } from './openai-compatible-metadata-extractor'; + postJsonToApi, +} from '@ai-sdk/provider-utils' +import { z } from 'zod/v4' + +import { convertToOpenAICompatibleChatMessages } from './convert-to-openai-compatible-chat-messages' +import { getResponseMetadata } from './get-response-metadata' +import { mapOpenAICompatibleFinishReason } from './map-openai-compatible-finish-reason' +import { openaiCompatibleProviderOptions } from './openai-compatible-chat-options' +import { defaultOpenAICompatibleErrorStructure } from '../openai-compatible-error' +import { prepareTools } from './openai-compatible-prepare-tools' + +import type { OpenAICompatibleChatModelId } from './openai-compatible-chat-options' +import type { ProviderErrorStructure } from '../openai-compatible-error' +import type { MetadataExtractor } from './openai-compatible-metadata-extractor' import type { APICallError, LanguageModelV2, @@ -36,70 +28,72 @@ import type { LanguageModelV2Content, LanguageModelV2FinishReason, LanguageModelV2StreamPart, - SharedV2ProviderMetadata} from '@ai-sdk/provider'; + SharedV2ProviderMetadata, +} from '@ai-sdk/provider' import type { FetchFunction, ParseResult, - ResponseHandler} from '@ai-sdk/provider-utils'; + ResponseHandler, +} from '@ai-sdk/provider-utils' export type OpenAICompatibleChatConfig = { - provider: string; - headers: () => Record; - url: (options: { modelId: string; path: string }) => string; - fetch?: FetchFunction; - includeUsage?: boolean; - errorStructure?: ProviderErrorStructure; - metadataExtractor?: MetadataExtractor; + provider: string + headers: () => Record + url: (options: { modelId: string; path: string }) => string + fetch?: FetchFunction + includeUsage?: boolean + errorStructure?: ProviderErrorStructure + metadataExtractor?: MetadataExtractor /** * Whether the model supports structured outputs. */ - supportsStructuredOutputs?: boolean; + supportsStructuredOutputs?: boolean /** * The supported URLs for the model. */ - supportedUrls?: () => LanguageModelV2['supportedUrls']; -}; + supportedUrls?: () => LanguageModelV2['supportedUrls'] +} export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { - readonly specificationVersion = 'v2'; + readonly specificationVersion = 'v2' - readonly supportsStructuredOutputs: boolean; + readonly supportsStructuredOutputs: boolean - readonly modelId: OpenAICompatibleChatModelId; - private readonly config: OpenAICompatibleChatConfig; - private readonly failedResponseHandler: ResponseHandler; - private readonly chunkSchema; // type inferred via constructor + readonly modelId: OpenAICompatibleChatModelId + private readonly config: OpenAICompatibleChatConfig + private readonly failedResponseHandler: ResponseHandler + private readonly chunkSchema // type inferred via constructor constructor( modelId: OpenAICompatibleChatModelId, config: OpenAICompatibleChatConfig, ) { - this.modelId = modelId; - this.config = config; + this.modelId = modelId + this.config = config // initialize error handling: const errorStructure = - config.errorStructure ?? defaultOpenAICompatibleErrorStructure; + config.errorStructure ?? defaultOpenAICompatibleErrorStructure this.chunkSchema = createOpenAICompatibleChatChunkSchema( errorStructure.errorSchema, - ); - this.failedResponseHandler = createJsonErrorResponseHandler(errorStructure); + ) + this.failedResponseHandler = createJsonErrorResponseHandler(errorStructure) - this.supportsStructuredOutputs = config.supportsStructuredOutputs ?? false; + this.supportsStructuredOutputs = config.supportsStructuredOutputs ?? false } get provider(): string { - return this.config.provider; + return this.config.provider } private get providerOptionsName(): string { - return this.config.provider.split('.')[0].trim(); + return this.config.provider.split('.')[0].trim() } get supportedUrls() { - return this.config.supportedUrls?.() ?? {}; + return this.config.supportedUrls?.() ?? {} } private async getArgs({ @@ -117,26 +111,26 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { toolChoice, tools, }: Parameters[0]) { - const warnings: LanguageModelV2CallWarning[] = []; + const warnings: LanguageModelV2CallWarning[] = [] // Parse provider options const baseOptionsResult = await parseProviderOptions({ provider: 'openai-compatible', providerOptions, schema: openaiCompatibleProviderOptions, - }); + }) const providerOptionsResult = await parseProviderOptions({ provider: this.providerOptionsName, providerOptions, schema: openaiCompatibleProviderOptions, - }); + }) const compatibleOptions = Object.assign( baseOptionsResult ?? {}, providerOptionsResult ?? {}, - ); + ) if (topK != null) { - warnings.push({ type: 'unsupported-setting', setting: 'topK' }); + warnings.push({ type: 'unsupported-setting', setting: 'topK' }) } if ( @@ -149,7 +143,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { setting: 'responseFormat', details: 'JSON response format schema is only supported with structuredOutputs', - }); + }) } const { @@ -159,7 +153,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { } = prepareTools({ tools, toolChoice, - }); + }) return { args: { @@ -212,15 +206,15 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { tool_choice: openaiToolChoice, }, warnings: [...warnings, ...toolWarnings], - }; + } } async doGenerate( options: Parameters[0], ): Promise>> { - const { args, warnings } = await this.getArgs({ ...options }); + const { args, warnings } = await this.getArgs({ ...options }) - const body = JSON.stringify(args); + const body = JSON.stringify(args) const { responseHeaders, @@ -239,25 +233,25 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { ), abortSignal: options.abortSignal, fetch: this.config.fetch, - }); + }) - const choice = responseBody.choices[0]; - const content: Array = []; + const choice = responseBody.choices[0] + const content: Array = [] // text content: - const text = choice.message.content; + const text = choice.message.content if (text != null && text.length > 0) { - content.push({ type: 'text', text }); + content.push({ type: 'text', text }) } // reasoning content: const reasoning = - choice.message.reasoning_content ?? choice.message.reasoning; + choice.message.reasoning_content ?? choice.message.reasoning if (reasoning != null && reasoning.length > 0) { content.push({ type: 'reasoning', text: reasoning, - }); + }) } // tool calls: @@ -268,7 +262,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { toolCallId: toolCall.id ?? generateId(), toolName: toolCall.function.name, input: toolCall.function.arguments!, - }); + }) } } @@ -276,20 +270,19 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { const extractedMetadata = await this.config.metadataExtractor?.extractMetadata?.({ parsedBody: rawResponse, - }); + }) const providerMetadata: SharedV2ProviderMetadata = { [this.providerOptionsName]: {}, ...extractedMetadata, - }; - const completionTokenDetails = - responseBody.usage?.completion_tokens_details; + } + const completionTokenDetails = responseBody.usage?.completion_tokens_details if (completionTokenDetails?.accepted_prediction_tokens != null) { providerMetadata[this.providerOptionsName].acceptedPredictionTokens = - completionTokenDetails?.accepted_prediction_tokens; + completionTokenDetails?.accepted_prediction_tokens } if (completionTokenDetails?.rejected_prediction_tokens != null) { providerMetadata[this.providerOptionsName].rejectedPredictionTokens = - completionTokenDetails?.rejected_prediction_tokens; + completionTokenDetails?.rejected_prediction_tokens } return { @@ -313,13 +306,13 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { body: rawResponse, }, warnings, - }; + } } async doStream( options: Parameters[0], ): Promise>> { - const { args, warnings } = await this.getArgs({ ...options }); + const { args, warnings } = await this.getArgs({ ...options }) const body = { ...args, @@ -329,10 +322,10 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { stream_options: this.config.includeUsage ? { include_usage: true } : undefined, - }; + } const metadataExtractor = - this.config.metadataExtractor?.createStreamExtractor(); + this.config.metadataExtractor?.createStreamExtractor() const { responseHeaders, value: response } = await postJsonToApi({ url: this.config.url({ @@ -347,31 +340,31 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { ), abortSignal: options.abortSignal, fetch: this.config.fetch, - }); + }) const toolCalls: Array<{ - id: string; - type: 'function'; + id: string + type: 'function' function: { - name: string; - arguments: string; - }; - hasFinished: boolean; - }> = []; + name: string + arguments: string + } + hasFinished: boolean + }> = [] - let finishReason: LanguageModelV2FinishReason = 'unknown'; + let finishReason: LanguageModelV2FinishReason = 'unknown' const usage: { - completionTokens: number | undefined; + completionTokens: number | undefined completionTokensDetails: { - reasoningTokens: number | undefined; - acceptedPredictionTokens: number | undefined; - rejectedPredictionTokens: number | undefined; - }; - promptTokens: number | undefined; + reasoningTokens: number | undefined + acceptedPredictionTokens: number | undefined + rejectedPredictionTokens: number | undefined + } + promptTokens: number | undefined promptTokensDetails: { - cachedTokens: number | undefined; - }; - totalTokens: number | undefined; + cachedTokens: number | undefined + } + totalTokens: number | undefined } = { completionTokens: undefined, completionTokensDetails: { @@ -384,11 +377,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { cachedTokens: undefined, }, totalTokens: undefined, - }; - let isFirstChunk = true; - const providerOptionsName = this.providerOptionsName; - let isActiveReasoning = false; - let isActiveText = false; + } + let isFirstChunk = true + const providerOptionsName = this.providerOptionsName + let isActiveReasoning = false + let isActiveText = false return { stream: response.pipeThrough( @@ -397,40 +390,40 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { LanguageModelV2StreamPart >({ start(controller) { - controller.enqueue({ type: 'stream-start', warnings }); + controller.enqueue({ type: 'stream-start', warnings }) }, // TODO we lost type safety on Chunk, most likely due to the error schema. MUST FIX transform(chunk, controller) { // Emit raw chunk if requested (before anything else) if (options.includeRawChunks) { - controller.enqueue({ type: 'raw', rawValue: chunk.rawValue }); + controller.enqueue({ type: 'raw', rawValue: chunk.rawValue }) } // handle failed chunk parsing / validation: if (!chunk.success) { - finishReason = 'error'; - controller.enqueue({ type: 'error', error: chunk.error }); - return; + finishReason = 'error' + controller.enqueue({ type: 'error', error: chunk.error }) + return } - const value = chunk.value; + const value = chunk.value - metadataExtractor?.processChunk(chunk.rawValue); + metadataExtractor?.processChunk(chunk.rawValue) // handle error chunks: if ('error' in value) { - finishReason = 'error'; - controller.enqueue({ type: 'error', error: value.error.message }); - return; + finishReason = 'error' + controller.enqueue({ type: 'error', error: value.error.message }) + return } if (isFirstChunk) { - isFirstChunk = false; + isFirstChunk = false controller.enqueue({ type: 'response-metadata', ...getResponseMetadata(value), - }); + }) } if (value.usage != null) { @@ -440,98 +433,98 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { total_tokens, prompt_tokens_details, completion_tokens_details, - } = value.usage; + } = value.usage - usage.promptTokens = prompt_tokens ?? undefined; - usage.completionTokens = completion_tokens ?? undefined; - usage.totalTokens = total_tokens ?? undefined; + usage.promptTokens = prompt_tokens ?? undefined + usage.completionTokens = completion_tokens ?? undefined + usage.totalTokens = total_tokens ?? undefined if (completion_tokens_details?.reasoning_tokens != null) { usage.completionTokensDetails.reasoningTokens = - completion_tokens_details?.reasoning_tokens; + completion_tokens_details?.reasoning_tokens } if ( completion_tokens_details?.accepted_prediction_tokens != null ) { usage.completionTokensDetails.acceptedPredictionTokens = - completion_tokens_details?.accepted_prediction_tokens; + completion_tokens_details?.accepted_prediction_tokens } if ( completion_tokens_details?.rejected_prediction_tokens != null ) { usage.completionTokensDetails.rejectedPredictionTokens = - completion_tokens_details?.rejected_prediction_tokens; + completion_tokens_details?.rejected_prediction_tokens } if (prompt_tokens_details?.cached_tokens != null) { usage.promptTokensDetails.cachedTokens = - prompt_tokens_details?.cached_tokens; + prompt_tokens_details?.cached_tokens } } - const choice = value.choices[0]; + const choice = value.choices[0] if (choice?.finish_reason != null) { finishReason = mapOpenAICompatibleFinishReason( choice.finish_reason, - ); + ) } if (choice?.delta == null) { - return; + return } - const delta = choice.delta; + const delta = choice.delta // enqueue reasoning before text deltas: - const reasoningContent = delta.reasoning_content ?? delta.reasoning; + const reasoningContent = delta.reasoning_content ?? delta.reasoning if (reasoningContent) { if (!isActiveReasoning) { controller.enqueue({ type: 'reasoning-start', id: 'reasoning-0', - }); - isActiveReasoning = true; + }) + isActiveReasoning = true } controller.enqueue({ type: 'reasoning-delta', id: 'reasoning-0', delta: reasoningContent, - }); + }) } if (delta.content) { if (!isActiveText) { - controller.enqueue({ type: 'text-start', id: 'txt-0' }); - isActiveText = true; + controller.enqueue({ type: 'text-start', id: 'txt-0' }) + isActiveText = true } controller.enqueue({ type: 'text-delta', id: 'txt-0', delta: delta.content, - }); + }) } if (delta.tool_calls != null) { for (const toolCallDelta of delta.tool_calls) { - const index = toolCallDelta.index; + const index = toolCallDelta.index if (toolCalls[index] == null) { if (toolCallDelta.function?.name == null) { throw new InvalidResponseDataError({ data: toolCallDelta, message: `Expected 'function.name' to be a string.`, - }); + }) } // UPDATED (James): Generate an ID if the provider doesn't include one (e.g., GLM models) - const toolCallId = toolCallDelta.id ?? generateId(); + const toolCallId = toolCallDelta.id ?? generateId() controller.enqueue({ type: 'tool-input-start', id: toolCallId, toolName: toolCallDelta.function.name, - }); + }) toolCalls[index] = { id: toolCallId, @@ -541,9 +534,9 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { arguments: toolCallDelta.function.arguments ?? '', }, hasFinished: false, - }; + } - const toolCall = toolCalls[index]; + const toolCall = toolCalls[index] if ( toolCall.function?.name != null && @@ -555,7 +548,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { type: 'tool-input-delta', id: toolCall.id, delta: toolCall.function.arguments, - }); + }) } // check if tool call is complete @@ -564,31 +557,31 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { controller.enqueue({ type: 'tool-input-end', id: toolCall.id, - }); + }) controller.enqueue({ type: 'tool-call', toolCallId: toolCall.id ?? generateId(), toolName: toolCall.function.name, input: toolCall.function.arguments, - }); - toolCall.hasFinished = true; + }) + toolCall.hasFinished = true } } - continue; + continue } // existing tool call, merge if not finished - const toolCall = toolCalls[index]; + const toolCall = toolCalls[index] if (toolCall.hasFinished) { - continue; + continue } if (toolCallDelta.function?.arguments != null) { toolCall.function!.arguments += - toolCallDelta.function?.arguments ?? ''; + toolCallDelta.function?.arguments ?? '' } // send delta @@ -596,7 +589,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { type: 'tool-input-delta', id: toolCall.id, delta: toolCallDelta.function.arguments ?? '', - }); + }) // check if tool call is complete if ( @@ -607,15 +600,15 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { controller.enqueue({ type: 'tool-input-end', id: toolCall.id, - }); + }) controller.enqueue({ type: 'tool-call', toolCallId: toolCall.id ?? generateId(), toolName: toolCall.function.name, input: toolCall.function.arguments, - }); - toolCall.hasFinished = true; + }) + toolCall.hasFinished = true } } } @@ -623,45 +616,45 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { flush(controller) { if (isActiveReasoning) { - controller.enqueue({ type: 'reasoning-end', id: 'reasoning-0' }); + controller.enqueue({ type: 'reasoning-end', id: 'reasoning-0' }) } if (isActiveText) { - controller.enqueue({ type: 'text-end', id: 'txt-0' }); + controller.enqueue({ type: 'text-end', id: 'txt-0' }) } // go through all tool calls and send the ones that are not finished for (const toolCall of toolCalls.filter( - toolCall => !toolCall.hasFinished, + (toolCall) => !toolCall.hasFinished, )) { controller.enqueue({ type: 'tool-input-end', id: toolCall.id, - }); + }) controller.enqueue({ type: 'tool-call', toolCallId: toolCall.id ?? generateId(), toolName: toolCall.function.name, input: toolCall.function.arguments, - }); + }) } const providerMetadata: SharedV2ProviderMetadata = { [providerOptionsName]: {}, ...metadataExtractor?.buildMetadata(), - }; + } if ( usage.completionTokensDetails.acceptedPredictionTokens != null ) { providerMetadata[providerOptionsName].acceptedPredictionTokens = - usage.completionTokensDetails.acceptedPredictionTokens; + usage.completionTokensDetails.acceptedPredictionTokens } if ( usage.completionTokensDetails.rejectedPredictionTokens != null ) { providerMetadata[providerOptionsName].rejectedPredictionTokens = - usage.completionTokensDetails.rejectedPredictionTokens; + usage.completionTokensDetails.rejectedPredictionTokens } controller.enqueue({ @@ -677,13 +670,13 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 { usage.promptTokensDetails.cachedTokens ?? undefined, }, providerMetadata, - }); + }) }, }), ), request: { body }, response: { headers: responseHeaders }, - }; + } } } @@ -705,7 +698,7 @@ const openaiCompatibleTokenUsageSchema = z }) .nullish(), }) - .nullish(); + .nullish() // limited version of the schema, focussed on what is needed for the implementation // this approach limits breakages when the API changes and increases efficiency @@ -736,7 +729,7 @@ const OpenAICompatibleChatResponseSchema = z.object({ }), ), usage: openaiCompatibleTokenUsageSchema, -}); +}) // limited version of the schema, focussed on what is needed for the implementation // this approach limits breakages when the API changes and increases efficiency @@ -780,4 +773,4 @@ const createOpenAICompatibleChatChunkSchema = < usage: openaiCompatibleTokenUsageSchema, }), errorSchema, - ]); + ]) diff --git a/packages/internal/src/openai-compatible/chat/openai-compatible-chat-options.ts b/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-options.ts similarity index 86% rename from packages/internal/src/openai-compatible/chat/openai-compatible-chat-options.ts rename to packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-options.ts index 4fd1877db0..13c5d76407 100644 --- a/packages/internal/src/openai-compatible/chat/openai-compatible-chat-options.ts +++ b/packages/llm-providers/src/openai-compatible/chat/openai-compatible-chat-options.ts @@ -1,6 +1,6 @@ -import { z } from 'zod/v4'; +import { z } from 'zod/v4' -export type OpenAICompatibleChatModelId = string; +export type OpenAICompatibleChatModelId = string export const openaiCompatibleProviderOptions = z.object({ /** @@ -18,8 +18,8 @@ export const openaiCompatibleProviderOptions = z.object({ * Controls the verbosity of the generated text. Defaults to `medium`. */ textVerbosity: z.string().optional(), -}); +}) export type OpenAICompatibleProviderOptions = z.infer< typeof openaiCompatibleProviderOptions ->; +> diff --git a/packages/internal/src/openai-compatible/chat/openai-compatible-metadata-extractor.ts b/packages/llm-providers/src/openai-compatible/chat/openai-compatible-metadata-extractor.ts similarity index 89% rename from packages/internal/src/openai-compatible/chat/openai-compatible-metadata-extractor.ts rename to packages/llm-providers/src/openai-compatible/chat/openai-compatible-metadata-extractor.ts index 17c56c7ac0..9abd60bcda 100644 --- a/packages/internal/src/openai-compatible/chat/openai-compatible-metadata-extractor.ts +++ b/packages/llm-providers/src/openai-compatible/chat/openai-compatible-metadata-extractor.ts @@ -1,4 +1,4 @@ -import type { SharedV2ProviderMetadata } from '@ai-sdk/provider'; +import type { SharedV2ProviderMetadata } from '@ai-sdk/provider' /** Extracts provider-specific metadata from API responses. @@ -17,8 +17,8 @@ export type MetadataExtractor = { extractMetadata: ({ parsedBody, }: { - parsedBody: unknown; - }) => Promise; + parsedBody: unknown + }) => Promise /** * Creates an extractor for handling streaming responses. The returned object provides @@ -34,7 +34,7 @@ export type MetadataExtractor = { * * @param parsedChunk - The parsed JSON response chunk from the provider's API */ - processChunk(parsedChunk: unknown): void; + processChunk(parsedChunk: unknown): void /** * Builds the metadata object after all chunks have been processed. @@ -43,6 +43,6 @@ export type MetadataExtractor = { * @returns Provider-specific metadata or undefined if no metadata is available. * The metadata should be under a key indicating the provider id. */ - buildMetadata(): SharedV2ProviderMetadata | undefined; - }; -}; + buildMetadata(): SharedV2ProviderMetadata | undefined + } +} diff --git a/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.ts b/packages/llm-providers/src/openai-compatible/chat/openai-compatible-prepare-tools.ts similarity index 61% rename from packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.ts rename to packages/llm-providers/src/openai-compatible/chat/openai-compatible-prepare-tools.ts index e48c8ec06c..7a53d2bd15 100644 --- a/packages/internal/src/openai-compatible/chat/openai-compatible-prepare-tools.ts +++ b/packages/llm-providers/src/openai-compatible/chat/openai-compatible-prepare-tools.ts @@ -1,57 +1,56 @@ -import { - UnsupportedFunctionalityError, -} from '@ai-sdk/provider'; +import { UnsupportedFunctionalityError } from '@ai-sdk/provider' import type { LanguageModelV2CallOptions, - LanguageModelV2CallWarning} from '@ai-sdk/provider'; + LanguageModelV2CallWarning, +} from '@ai-sdk/provider' export function prepareTools({ tools, toolChoice, }: { - tools: LanguageModelV2CallOptions['tools']; - toolChoice?: LanguageModelV2CallOptions['toolChoice']; + tools: LanguageModelV2CallOptions['tools'] + toolChoice?: LanguageModelV2CallOptions['toolChoice'] }): { tools: | undefined | Array<{ - type: 'function'; + type: 'function' function: { - name: string; - description: string | undefined; - parameters: unknown; - }; - }>; + name: string + description: string | undefined + parameters: unknown + } + }> toolChoice: | { type: 'function'; function: { name: string } } | 'auto' | 'none' | 'required' - | undefined; - toolWarnings: LanguageModelV2CallWarning[]; + | undefined + toolWarnings: LanguageModelV2CallWarning[] } { // when the tools array is empty, change it to undefined to prevent errors: - tools = tools?.length ? tools : undefined; + tools = tools?.length ? tools : undefined - const toolWarnings: LanguageModelV2CallWarning[] = []; + const toolWarnings: LanguageModelV2CallWarning[] = [] if (tools == null) { - return { tools: undefined, toolChoice: undefined, toolWarnings }; + return { tools: undefined, toolChoice: undefined, toolWarnings } } const openaiCompatTools: Array<{ - type: 'function'; + type: 'function' function: { - name: string; - description: string | undefined; - parameters: unknown; - }; - }> = []; + name: string + description: string | undefined + parameters: unknown + } + }> = [] for (const tool of tools) { if (tool.type === 'provider-defined') { - toolWarnings.push({ type: 'unsupported-tool', tool }); + toolWarnings.push({ type: 'unsupported-tool', tool }) } else { openaiCompatTools.push({ type: 'function', @@ -60,21 +59,21 @@ export function prepareTools({ description: tool.description, parameters: tool.inputSchema, }, - }); + }) } } if (toolChoice == null) { - return { tools: openaiCompatTools, toolChoice: undefined, toolWarnings }; + return { tools: openaiCompatTools, toolChoice: undefined, toolWarnings } } - const type = toolChoice.type; + const type = toolChoice.type switch (type) { case 'auto': case 'none': case 'required': - return { tools: openaiCompatTools, toolChoice: type, toolWarnings }; + return { tools: openaiCompatTools, toolChoice: type, toolWarnings } case 'tool': return { tools: openaiCompatTools, @@ -83,12 +82,12 @@ export function prepareTools({ function: { name: toolChoice.toolName }, }, toolWarnings, - }; + } default: { - const _exhaustiveCheck: never = type; + const _exhaustiveCheck: never = type throw new UnsupportedFunctionalityError({ functionality: `tool choice type: ${_exhaustiveCheck}`, - }); + }) } } } diff --git a/packages/internal/src/openai-compatible/completion/convert-to-openai-compatible-completion-prompt.ts b/packages/llm-providers/src/openai-compatible/completion/convert-to-openai-compatible-completion-prompt.ts similarity index 67% rename from packages/internal/src/openai-compatible/completion/convert-to-openai-compatible-completion-prompt.ts rename to packages/llm-providers/src/openai-compatible/completion/convert-to-openai-compatible-completion-prompt.ts index fec938c059..c22a8ec277 100644 --- a/packages/internal/src/openai-compatible/completion/convert-to-openai-compatible-completion-prompt.ts +++ b/packages/llm-providers/src/openai-compatible/completion/convert-to-openai-compatible-completion-prompt.ts @@ -1,30 +1,29 @@ import { InvalidPromptError, UnsupportedFunctionalityError, -} from '@ai-sdk/provider'; +} from '@ai-sdk/provider' -import type { - LanguageModelV2Prompt} from '@ai-sdk/provider'; +import type { LanguageModelV2Prompt } from '@ai-sdk/provider' export function convertToOpenAICompatibleCompletionPrompt({ prompt, user = 'user', assistant = 'assistant', }: { - prompt: LanguageModelV2Prompt; - user?: string; - assistant?: string; + prompt: LanguageModelV2Prompt + user?: string + assistant?: string }): { - prompt: string; - stopSequences?: string[]; + prompt: string + stopSequences?: string[] } { // transform to a chat message format: - let text = ''; + let text = '' // if first message is a system message, add it to the text: if (prompt[0].role === 'system') { - text += `${prompt[0].content}\n\n`; - prompt = prompt.slice(1); + text += `${prompt[0].content}\n\n` + prompt = prompt.slice(1) } for (const { role, content } of prompt) { @@ -33,65 +32,65 @@ export function convertToOpenAICompatibleCompletionPrompt({ throw new InvalidPromptError({ message: 'Unexpected system message in prompt: ${content}', prompt, - }); + }) } case 'user': { const userMessage = content - .map(part => { + .map((part) => { switch (part.type) { case 'text': { - return part.text; + return part.text } } return }) .filter(Boolean) - .join(''); + .join('') - text += `${user}:\n${userMessage}\n\n`; - break; + text += `${user}:\n${userMessage}\n\n` + break } case 'assistant': { const assistantMessage = content - .map(part => { + .map((part) => { switch (part.type) { case 'text': { - return part.text; + return part.text } case 'tool-call': { throw new UnsupportedFunctionalityError({ functionality: 'tool-call messages', - }); + }) } } return undefined }) - .join(''); + .join('') - text += `${assistant}:\n${assistantMessage}\n\n`; - break; + text += `${assistant}:\n${assistantMessage}\n\n` + break } case 'tool': { throw new UnsupportedFunctionalityError({ functionality: 'tool messages', - }); + }) } default: { - const _exhaustiveCheck: never = role; - throw new Error(`Unsupported role: ${_exhaustiveCheck}`); + const _exhaustiveCheck: never = role + throw new Error(`Unsupported role: ${_exhaustiveCheck}`) } } } // Assistant message prefix: - text += `${assistant}:\n`; + text += `${assistant}:\n` return { prompt: text, stopSequences: [`\n${user}:`], - }; + } } diff --git a/packages/internal/src/openai-compatible/completion/get-response-metadata.ts b/packages/llm-providers/src/openai-compatible/completion/get-response-metadata.ts similarity index 65% rename from packages/internal/src/openai-compatible/completion/get-response-metadata.ts rename to packages/llm-providers/src/openai-compatible/completion/get-response-metadata.ts index bd358b23f7..708fd968e3 100644 --- a/packages/internal/src/openai-compatible/completion/get-response-metadata.ts +++ b/packages/llm-providers/src/openai-compatible/completion/get-response-metadata.ts @@ -3,13 +3,13 @@ export function getResponseMetadata({ model, created, }: { - id?: string | undefined | null; - created?: number | undefined | null; - model?: string | undefined | null; + id?: string | undefined | null + created?: number | undefined | null + model?: string | undefined | null }) { return { id: id ?? undefined, modelId: model ?? undefined, timestamp: created != null ? new Date(created * 1000) : undefined, - }; + } } diff --git a/packages/internal/src/openai-compatible/chat/map-openai-compatible-finish-reason.ts b/packages/llm-providers/src/openai-compatible/completion/map-openai-compatible-finish-reason.ts similarity index 72% rename from packages/internal/src/openai-compatible/chat/map-openai-compatible-finish-reason.ts rename to packages/llm-providers/src/openai-compatible/completion/map-openai-compatible-finish-reason.ts index b18feae081..2a4e9eccc2 100644 --- a/packages/internal/src/openai-compatible/chat/map-openai-compatible-finish-reason.ts +++ b/packages/llm-providers/src/openai-compatible/completion/map-openai-compatible-finish-reason.ts @@ -1,19 +1,19 @@ -import type { LanguageModelV2FinishReason } from '@ai-sdk/provider'; +import type { LanguageModelV2FinishReason } from '@ai-sdk/provider' export function mapOpenAICompatibleFinishReason( finishReason: string | null | undefined, ): LanguageModelV2FinishReason { switch (finishReason) { case 'stop': - return 'stop'; + return 'stop' case 'length': - return 'length'; + return 'length' case 'content_filter': - return 'content-filter'; + return 'content-filter' case 'function_call': case 'tool_calls': - return 'tool-calls'; + return 'tool-calls' default: - return 'unknown'; + return 'unknown' } } diff --git a/packages/internal/src/openai-compatible/completion/openai-compatible-completion-language-model.ts b/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-language-model.ts similarity index 76% rename from packages/internal/src/openai-compatible/completion/openai-compatible-completion-language-model.ts rename to packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-language-model.ts index fb32ad3aeb..ddc84bab9a 100644 --- a/packages/internal/src/openai-compatible/completion/openai-compatible-completion-language-model.ts +++ b/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-language-model.ts @@ -4,24 +4,18 @@ import { createJsonErrorResponseHandler, createJsonResponseHandler, parseProviderOptions, - postJsonToApi -} from '@ai-sdk/provider-utils'; -import { z } from 'zod/v4'; - -import { - defaultOpenAICompatibleErrorStructure -} from '../openai-compatible-error'; -import { convertToOpenAICompatibleCompletionPrompt } from './convert-to-openai-compatible-completion-prompt'; -import { getResponseMetadata } from './get-response-metadata'; -import { mapOpenAICompatibleFinishReason } from './map-openai-compatible-finish-reason'; -import { - openaiCompatibleCompletionProviderOptions, -} from './openai-compatible-completion-options'; - -import type { - OpenAICompatibleCompletionModelId} from './openai-compatible-completion-options'; -import type { - ProviderErrorStructure} from '../openai-compatible-error'; + postJsonToApi, +} from '@ai-sdk/provider-utils' +import { z } from 'zod/v4' + +import { defaultOpenAICompatibleErrorStructure } from '../openai-compatible-error' +import { convertToOpenAICompatibleCompletionPrompt } from './convert-to-openai-compatible-completion-prompt' +import { getResponseMetadata } from './get-response-metadata' +import { mapOpenAICompatibleFinishReason } from './map-openai-compatible-finish-reason' +import { openaiCompatibleCompletionProviderOptions } from './openai-compatible-completion-options' + +import type { OpenAICompatibleCompletionModelId } from './openai-compatible-completion-options' +import type { ProviderErrorStructure } from '../openai-compatible-error' import type { APICallError, LanguageModelV2, @@ -30,62 +24,61 @@ import type { LanguageModelV2FinishReason, LanguageModelV2StreamPart, LanguageModelV2Usage, -} from '@ai-sdk/provider'; +} from '@ai-sdk/provider' import type { FetchFunction, ParseResult, - ResponseHandler} from '@ai-sdk/provider-utils'; + ResponseHandler, +} from '@ai-sdk/provider-utils' type OpenAICompatibleCompletionConfig = { - provider: string; - includeUsage?: boolean; - headers: () => Record; - url: (options: { modelId: string; path: string }) => string; - fetch?: FetchFunction; - errorStructure?: ProviderErrorStructure; + provider: string + includeUsage?: boolean + headers: () => Record + url: (options: { modelId: string; path: string }) => string + fetch?: FetchFunction + errorStructure?: ProviderErrorStructure /** * The supported URLs for the model. */ - supportedUrls?: () => LanguageModelV2['supportedUrls']; -}; + supportedUrls?: () => LanguageModelV2['supportedUrls'] +} -export class OpenAICompatibleCompletionLanguageModel - implements LanguageModelV2 -{ - readonly specificationVersion = 'v2'; +export class OpenAICompatibleCompletionLanguageModel implements LanguageModelV2 { + readonly specificationVersion = 'v2' - readonly modelId: OpenAICompatibleCompletionModelId; - private readonly config: OpenAICompatibleCompletionConfig; - private readonly failedResponseHandler: ResponseHandler; - private readonly chunkSchema; // type inferred via constructor + readonly modelId: OpenAICompatibleCompletionModelId + private readonly config: OpenAICompatibleCompletionConfig + private readonly failedResponseHandler: ResponseHandler + private readonly chunkSchema // type inferred via constructor constructor( modelId: OpenAICompatibleCompletionModelId, config: OpenAICompatibleCompletionConfig, ) { - this.modelId = modelId; - this.config = config; + this.modelId = modelId + this.config = config // initialize error handling: const errorStructure = - config.errorStructure ?? defaultOpenAICompatibleErrorStructure; + config.errorStructure ?? defaultOpenAICompatibleErrorStructure this.chunkSchema = createOpenAICompatibleCompletionChunkSchema( errorStructure.errorSchema, - ); - this.failedResponseHandler = createJsonErrorResponseHandler(errorStructure); + ) + this.failedResponseHandler = createJsonErrorResponseHandler(errorStructure) } get provider(): string { - return this.config.provider; + return this.config.provider } private get providerOptionsName(): string { - return this.config.provider.split('.')[0].trim(); + return this.config.provider.split('.')[0].trim() } get supportedUrls() { - return this.config.supportedUrls?.() ?? {}; + return this.config.supportedUrls?.() ?? {} } private async getArgs({ @@ -103,26 +96,26 @@ export class OpenAICompatibleCompletionLanguageModel tools, toolChoice, }: Parameters[0]) { - const warnings: LanguageModelV2CallWarning[] = []; + const warnings: LanguageModelV2CallWarning[] = [] // Parse provider options const completionOptionsResult = await parseProviderOptions({ provider: this.providerOptionsName, providerOptions, schema: openaiCompatibleCompletionProviderOptions, - }); - const completionOptions = completionOptionsResult ?? {}; + }) + const completionOptions = completionOptionsResult ?? {} if (topK != null) { - warnings.push({ type: 'unsupported-setting', setting: 'topK' }); + warnings.push({ type: 'unsupported-setting', setting: 'topK' }) } if (tools?.length) { - warnings.push({ type: 'unsupported-setting', setting: 'tools' }); + warnings.push({ type: 'unsupported-setting', setting: 'tools' }) } if (toolChoice != null) { - warnings.push({ type: 'unsupported-setting', setting: 'toolChoice' }); + warnings.push({ type: 'unsupported-setting', setting: 'toolChoice' }) } if (responseFormat != null && responseFormat.type !== 'text') { @@ -130,13 +123,13 @@ export class OpenAICompatibleCompletionLanguageModel type: 'unsupported-setting', setting: 'responseFormat', details: 'JSON response format is not supported.', - }); + }) } const { prompt: completionPrompt, stopSequences } = - convertToOpenAICompatibleCompletionPrompt({ prompt }); + convertToOpenAICompatibleCompletionPrompt({ prompt }) - const stop = [...(stopSequences ?? []), ...(userStopSequences ?? [])]; + const stop = [...(stopSequences ?? []), ...(userStopSequences ?? [])] return { args: { @@ -165,13 +158,13 @@ export class OpenAICompatibleCompletionLanguageModel stop: stop.length > 0 ? stop : undefined, }, warnings, - }; + } } async doGenerate( options: Parameters[0], ): Promise>> { - const { args, warnings } = await this.getArgs(options); + const { args, warnings } = await this.getArgs(options) const { responseHeaders, @@ -190,14 +183,14 @@ export class OpenAICompatibleCompletionLanguageModel ), abortSignal: options.abortSignal, fetch: this.config.fetch, - }); + }) - const choice = response.choices[0]; - const content: Array = []; + const choice = response.choices[0] + const content: Array = [] // text content: if (choice.text != null && choice.text.length > 0) { - content.push({ type: 'text', text: choice.text }); + content.push({ type: 'text', text: choice.text }) } return { @@ -215,13 +208,13 @@ export class OpenAICompatibleCompletionLanguageModel body: rawResponse, }, warnings, - }; + } } async doStream( options: Parameters[0], ): Promise>> { - const { args, warnings } = await this.getArgs(options); + const { args, warnings } = await this.getArgs(options) const body = { ...args, @@ -231,7 +224,7 @@ export class OpenAICompatibleCompletionLanguageModel stream_options: this.config.includeUsage ? { include_usage: true } : undefined, - }; + } const { responseHeaders, value: response } = await postJsonToApi({ url: this.config.url({ @@ -246,15 +239,15 @@ export class OpenAICompatibleCompletionLanguageModel ), abortSignal: options.abortSignal, fetch: this.config.fetch, - }); + }) - let finishReason: LanguageModelV2FinishReason = 'unknown'; + let finishReason: LanguageModelV2FinishReason = 'unknown' const usage: LanguageModelV2Usage = { inputTokens: undefined, outputTokens: undefined, totalTokens: undefined, - }; - let isFirstChunk = true; + } + let isFirstChunk = true return { stream: response.pipeThrough( @@ -263,56 +256,56 @@ export class OpenAICompatibleCompletionLanguageModel LanguageModelV2StreamPart >({ start(controller) { - controller.enqueue({ type: 'stream-start', warnings }); + controller.enqueue({ type: 'stream-start', warnings }) }, transform(chunk, controller) { if (options.includeRawChunks) { - controller.enqueue({ type: 'raw', rawValue: chunk.rawValue }); + controller.enqueue({ type: 'raw', rawValue: chunk.rawValue }) } // handle failed chunk parsing / validation: if (!chunk.success) { - finishReason = 'error'; - controller.enqueue({ type: 'error', error: chunk.error }); - return; + finishReason = 'error' + controller.enqueue({ type: 'error', error: chunk.error }) + return } - const value = chunk.value; + const value = chunk.value // handle error chunks: if ('error' in value) { - finishReason = 'error'; - controller.enqueue({ type: 'error', error: value.error }); - return; + finishReason = 'error' + controller.enqueue({ type: 'error', error: value.error }) + return } if (isFirstChunk) { - isFirstChunk = false; + isFirstChunk = false controller.enqueue({ type: 'response-metadata', ...getResponseMetadata(value), - }); + }) controller.enqueue({ type: 'text-start', id: '0', - }); + }) } if (value.usage != null) { - usage.inputTokens = value.usage.prompt_tokens ?? undefined; - usage.outputTokens = value.usage.completion_tokens ?? undefined; - usage.totalTokens = value.usage.total_tokens ?? undefined; + usage.inputTokens = value.usage.prompt_tokens ?? undefined + usage.outputTokens = value.usage.completion_tokens ?? undefined + usage.totalTokens = value.usage.total_tokens ?? undefined } - const choice = value.choices[0]; + const choice = value.choices[0] if (choice?.finish_reason != null) { finishReason = mapOpenAICompatibleFinishReason( choice.finish_reason, - ); + ) } if (choice?.text != null) { @@ -320,26 +313,26 @@ export class OpenAICompatibleCompletionLanguageModel type: 'text-delta', id: '0', delta: choice.text, - }); + }) } }, flush(controller) { if (!isFirstChunk) { - controller.enqueue({ type: 'text-end', id: '0' }); + controller.enqueue({ type: 'text-end', id: '0' }) } controller.enqueue({ type: 'finish', finishReason, usage, - }); + }) }, }), ), request: { body }, response: { headers: responseHeaders }, - }; + } } } @@ -347,7 +340,7 @@ const usageSchema = z.object({ prompt_tokens: z.number(), completion_tokens: z.number(), total_tokens: z.number(), -}); +}) // limited version of the schema, focussed on what is needed for the implementation // this approach limits breakages when the API changes and increases efficiency @@ -362,7 +355,7 @@ const openaiCompatibleCompletionResponseSchema = z.object({ }), ), usage: usageSchema.nullish(), -}); +}) // limited version of the schema, focussed on what is needed for the implementation // this approach limits breakages when the API changes and increases efficiency @@ -386,4 +379,4 @@ const createOpenAICompatibleCompletionChunkSchema = < usage: usageSchema.nullish(), }), errorSchema, - ]); + ]) diff --git a/packages/internal/src/openai-compatible/completion/openai-compatible-completion-options.ts b/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-options.ts similarity index 90% rename from packages/internal/src/openai-compatible/completion/openai-compatible-completion-options.ts rename to packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-options.ts index 9f6a5ca114..a34e5de348 100644 --- a/packages/internal/src/openai-compatible/completion/openai-compatible-completion-options.ts +++ b/packages/llm-providers/src/openai-compatible/completion/openai-compatible-completion-options.ts @@ -1,6 +1,6 @@ -import { z } from 'zod/v4'; +import { z } from 'zod/v4' -export type OpenAICompatibleCompletionModelId = string; +export type OpenAICompatibleCompletionModelId = string export const openaiCompatibleCompletionProviderOptions = z.object({ /** @@ -26,8 +26,8 @@ export const openaiCompatibleCompletionProviderOptions = z.object({ * monitor and detect abuse. */ user: z.string().optional(), -}); +}) export type OpenAICompatibleCompletionProviderOptions = z.infer< typeof openaiCompatibleCompletionProviderOptions ->; +> diff --git a/packages/internal/src/openai-compatible/embedding/openai-compatible-embedding-model.ts b/packages/llm-providers/src/openai-compatible/embedding/openai-compatible-embedding-model.ts similarity index 66% rename from packages/internal/src/openai-compatible/embedding/openai-compatible-embedding-model.ts rename to packages/llm-providers/src/openai-compatible/embedding/openai-compatible-embedding-model.ts index 1ef99d2062..096a934f4f 100644 --- a/packages/internal/src/openai-compatible/embedding/openai-compatible-embedding-model.ts +++ b/packages/llm-providers/src/openai-compatible/embedding/openai-compatible-embedding-model.ts @@ -1,79 +1,67 @@ -import { - TooManyEmbeddingValuesForCallError, -} from '@ai-sdk/provider'; +import { TooManyEmbeddingValuesForCallError } from '@ai-sdk/provider' import { combineHeaders, createJsonErrorResponseHandler, createJsonResponseHandler, parseProviderOptions, postJsonToApi, -} from '@ai-sdk/provider-utils'; -import { z } from 'zod/v4'; +} from '@ai-sdk/provider-utils' +import { z } from 'zod/v4' -import { - openaiCompatibleEmbeddingProviderOptions, -} from './openai-compatible-embedding-options'; -import { - defaultOpenAICompatibleErrorStructure -} from '../openai-compatible-error'; - -import type { - OpenAICompatibleEmbeddingModelId} from './openai-compatible-embedding-options'; -import type { - ProviderErrorStructure} from '../openai-compatible-error'; -import type { - EmbeddingModelV2} from '@ai-sdk/provider'; -import type { - FetchFunction} from '@ai-sdk/provider-utils'; +import { openaiCompatibleEmbeddingProviderOptions } from './openai-compatible-embedding-options' +import { defaultOpenAICompatibleErrorStructure } from '../openai-compatible-error' + +import type { OpenAICompatibleEmbeddingModelId } from './openai-compatible-embedding-options' +import type { ProviderErrorStructure } from '../openai-compatible-error' +import type { EmbeddingModelV2 } from '@ai-sdk/provider' +import type { FetchFunction } from '@ai-sdk/provider-utils' type OpenAICompatibleEmbeddingConfig = { /** Override the maximum number of embeddings per call. */ - maxEmbeddingsPerCall?: number; + maxEmbeddingsPerCall?: number /** Override the parallelism of embedding calls. */ - supportsParallelCalls?: boolean; + supportsParallelCalls?: boolean - provider: string; - url: (options: { modelId: string; path: string }) => string; - headers: () => Record; - fetch?: FetchFunction; - errorStructure?: ProviderErrorStructure; -}; + provider: string + url: (options: { modelId: string; path: string }) => string + headers: () => Record + fetch?: FetchFunction + errorStructure?: ProviderErrorStructure +} -export class OpenAICompatibleEmbeddingModel - implements EmbeddingModelV2 -{ - readonly specificationVersion = 'v2'; - readonly modelId: OpenAICompatibleEmbeddingModelId; +export class OpenAICompatibleEmbeddingModel implements EmbeddingModelV2 { + readonly specificationVersion = 'v2' + readonly modelId: OpenAICompatibleEmbeddingModelId - private readonly config: OpenAICompatibleEmbeddingConfig; + private readonly config: OpenAICompatibleEmbeddingConfig get provider(): string { - return this.config.provider; + return this.config.provider } get maxEmbeddingsPerCall(): number { - return this.config.maxEmbeddingsPerCall ?? 2048; + return this.config.maxEmbeddingsPerCall ?? 2048 } get supportsParallelCalls(): boolean { - return this.config.supportsParallelCalls ?? true; + return this.config.supportsParallelCalls ?? true } constructor( modelId: OpenAICompatibleEmbeddingModelId, config: OpenAICompatibleEmbeddingConfig, ) { - this.modelId = modelId; - this.config = config; + this.modelId = modelId + this.config = config } private get providerOptionsName(): string { - return this.config.provider.split('.')[0].trim(); + return this.config.provider.split('.')[0].trim() } async doEmbed({ @@ -88,16 +76,16 @@ export class OpenAICompatibleEmbeddingModel provider: 'openai-compatible', providerOptions, schema: openaiCompatibleEmbeddingProviderOptions, - }); + }) const providerOptionsResult = await parseProviderOptions({ provider: this.providerOptionsName, providerOptions, schema: openaiCompatibleEmbeddingProviderOptions, - }); + }) const compatibleOptions = Object.assign( baseOptionsResult ?? {}, providerOptionsResult ?? {}, - ); + ) if (values.length > this.maxEmbeddingsPerCall) { throw new TooManyEmbeddingValuesForCallError({ @@ -105,7 +93,7 @@ export class OpenAICompatibleEmbeddingModel modelId: this.modelId, maxEmbeddingsPerCall: this.maxEmbeddingsPerCall, values, - }); + }) } const { @@ -133,16 +121,16 @@ export class OpenAICompatibleEmbeddingModel ), abortSignal, fetch: this.config.fetch, - }); + }) return { - embeddings: response.data.map(item => item.embedding), + embeddings: response.data.map((item) => item.embedding), usage: response.usage ? { tokens: response.usage.prompt_tokens } : undefined, providerMetadata: response.providerMetadata, response: { headers: responseHeaders, body: rawValue }, - }; + } } } @@ -154,4 +142,4 @@ const openaiTextEmbeddingResponseSchema = z.object({ providerMetadata: z .record(z.string(), z.record(z.string(), z.any())) .optional(), -}); +}) diff --git a/packages/internal/src/openai-compatible/embedding/openai-compatible-embedding-options.ts b/packages/llm-providers/src/openai-compatible/embedding/openai-compatible-embedding-options.ts similarity index 85% rename from packages/internal/src/openai-compatible/embedding/openai-compatible-embedding-options.ts rename to packages/llm-providers/src/openai-compatible/embedding/openai-compatible-embedding-options.ts index 1bfef6d69c..fec65b664e 100644 --- a/packages/internal/src/openai-compatible/embedding/openai-compatible-embedding-options.ts +++ b/packages/llm-providers/src/openai-compatible/embedding/openai-compatible-embedding-options.ts @@ -1,6 +1,6 @@ -import { z } from 'zod/v4'; +import { z } from 'zod/v4' -export type OpenAICompatibleEmbeddingModelId = string; +export type OpenAICompatibleEmbeddingModelId = string export const openaiCompatibleEmbeddingProviderOptions = z.object({ /** @@ -14,8 +14,8 @@ export const openaiCompatibleEmbeddingProviderOptions = z.object({ * monitor and detect abuse. */ user: z.string().optional(), -}); +}) export type OpenAICompatibleEmbeddingProviderOptions = z.infer< typeof openaiCompatibleEmbeddingProviderOptions ->; +> diff --git a/packages/internal/src/openai-compatible/image/openai-compatible-image-model.ts b/packages/llm-providers/src/openai-compatible/image/openai-compatible-image-model.ts similarity index 73% rename from packages/internal/src/openai-compatible/image/openai-compatible-image-model.ts rename to packages/llm-providers/src/openai-compatible/image/openai-compatible-image-model.ts index 1a0dcc040b..e6ec4e7db4 100644 --- a/packages/internal/src/openai-compatible/image/openai-compatible-image-model.ts +++ b/packages/llm-providers/src/openai-compatible/image/openai-compatible-image-model.ts @@ -3,37 +3,33 @@ import { createJsonErrorResponseHandler, createJsonResponseHandler, postJsonToApi, -} from '@ai-sdk/provider-utils'; -import { z } from 'zod/v4'; +} from '@ai-sdk/provider-utils' +import { z } from 'zod/v4' -import { - defaultOpenAICompatibleErrorStructure -} from '../openai-compatible-error'; +import { defaultOpenAICompatibleErrorStructure } from '../openai-compatible-error' -import type { - ProviderErrorStructure} from '../openai-compatible-error'; -import type { OpenAICompatibleImageModelId } from './openai-compatible-image-settings'; -import type { ImageModelV2, ImageModelV2CallWarning } from '@ai-sdk/provider'; -import type { - FetchFunction} from '@ai-sdk/provider-utils'; +import type { ProviderErrorStructure } from '../openai-compatible-error' +import type { OpenAICompatibleImageModelId } from './openai-compatible-image-settings' +import type { ImageModelV2, ImageModelV2CallWarning } from '@ai-sdk/provider' +import type { FetchFunction } from '@ai-sdk/provider-utils' export type OpenAICompatibleImageModelConfig = { - provider: string; - headers: () => Record; - url: (options: { modelId: string; path: string }) => string; - fetch?: FetchFunction; - errorStructure?: ProviderErrorStructure; + provider: string + headers: () => Record + url: (options: { modelId: string; path: string }) => string + fetch?: FetchFunction + errorStructure?: ProviderErrorStructure _internal?: { - currentDate?: () => Date; - }; -}; + currentDate?: () => Date + } +} export class OpenAICompatibleImageModel implements ImageModelV2 { - readonly specificationVersion = 'v2'; - readonly maxImagesPerCall = 10; + readonly specificationVersion = 'v2' + readonly maxImagesPerCall = 10 get provider(): string { - return this.config.provider; + return this.config.provider } constructor( @@ -53,7 +49,7 @@ export class OpenAICompatibleImageModel implements ImageModelV2 { }: Parameters[0]): Promise< Awaited> > { - const warnings: Array = []; + const warnings: Array = [] if (aspectRatio != null) { warnings.push({ @@ -61,14 +57,14 @@ export class OpenAICompatibleImageModel implements ImageModelV2 { setting: 'aspectRatio', details: 'This model does not support aspect ratio. Use `size` instead.', - }); + }) } if (seed != null) { - warnings.push({ type: 'unsupported-setting', setting: 'seed' }); + warnings.push({ type: 'unsupported-setting', setting: 'seed' }) } - const currentDate = this.config._internal?.currentDate?.() ?? new Date(); + const currentDate = this.config._internal?.currentDate?.() ?? new Date() const { value: response, responseHeaders } = await postJsonToApi({ url: this.config.url({ path: '/images/generations', @@ -91,17 +87,17 @@ export class OpenAICompatibleImageModel implements ImageModelV2 { ), abortSignal, fetch: this.config.fetch, - }); + }) return { - images: response.data.map(item => item.b64_json), + images: response.data.map((item) => item.b64_json), warnings, response: { timestamp: currentDate, modelId: this.modelId, headers: responseHeaders, }, - }; + } } } @@ -109,4 +105,4 @@ export class OpenAICompatibleImageModel implements ImageModelV2 { // this approach limits breakages when the API changes and increases efficiency const openaiCompatibleImageResponseSchema = z.object({ data: z.array(z.object({ b64_json: z.string() })), -}); +}) diff --git a/packages/llm-providers/src/openai-compatible/image/openai-compatible-image-settings.ts b/packages/llm-providers/src/openai-compatible/image/openai-compatible-image-settings.ts new file mode 100644 index 0000000000..6dacdd9d53 --- /dev/null +++ b/packages/llm-providers/src/openai-compatible/image/openai-compatible-image-settings.ts @@ -0,0 +1 @@ +export type OpenAICompatibleImageModelId = string diff --git a/packages/internal/src/openai-compatible/index.ts b/packages/llm-providers/src/openai-compatible/index.ts similarity index 64% rename from packages/internal/src/openai-compatible/index.ts rename to packages/llm-providers/src/openai-compatible/index.ts index 75da5c767b..6d7686255c 100644 --- a/packages/internal/src/openai-compatible/index.ts +++ b/packages/llm-providers/src/openai-compatible/index.ts @@ -1,27 +1,27 @@ -export { OpenAICompatibleChatLanguageModel } from './chat/openai-compatible-chat-language-model'; +export { OpenAICompatibleChatLanguageModel } from './chat/openai-compatible-chat-language-model' export type { OpenAICompatibleChatModelId, OpenAICompatibleProviderOptions, -} from './chat/openai-compatible-chat-options'; -export { OpenAICompatibleCompletionLanguageModel } from './completion/openai-compatible-completion-language-model'; +} from './chat/openai-compatible-chat-options' +export { OpenAICompatibleCompletionLanguageModel } from './completion/openai-compatible-completion-language-model' export type { OpenAICompatibleCompletionModelId, OpenAICompatibleCompletionProviderOptions, -} from './completion/openai-compatible-completion-options'; -export { OpenAICompatibleEmbeddingModel } from './embedding/openai-compatible-embedding-model'; +} from './completion/openai-compatible-completion-options' +export { OpenAICompatibleEmbeddingModel } from './embedding/openai-compatible-embedding-model' export type { OpenAICompatibleEmbeddingModelId, OpenAICompatibleEmbeddingProviderOptions, -} from './embedding/openai-compatible-embedding-options'; -export { OpenAICompatibleImageModel } from './image/openai-compatible-image-model'; +} from './embedding/openai-compatible-embedding-options' +export { OpenAICompatibleImageModel } from './image/openai-compatible-image-model' export type { OpenAICompatibleErrorData, ProviderErrorStructure, -} from './openai-compatible-error'; -export type { MetadataExtractor } from './chat/openai-compatible-metadata-extractor'; -export { createOpenAICompatible } from './openai-compatible-provider'; +} from './openai-compatible-error' +export type { MetadataExtractor } from './chat/openai-compatible-metadata-extractor' +export { createOpenAICompatible } from './openai-compatible-provider' export type { OpenAICompatibleProvider, OpenAICompatibleProviderSettings, -} from './openai-compatible-provider'; -export { VERSION } from './version'; +} from './openai-compatible-provider' +export { VERSION } from './version' diff --git a/packages/internal/src/openai-compatible/internal/index.ts b/packages/llm-providers/src/openai-compatible/internal/index.ts similarity index 69% rename from packages/internal/src/openai-compatible/internal/index.ts rename to packages/llm-providers/src/openai-compatible/internal/index.ts index 2b30d3fa18..632f1fed1a 100644 --- a/packages/internal/src/openai-compatible/internal/index.ts +++ b/packages/llm-providers/src/openai-compatible/internal/index.ts @@ -1,4 +1,4 @@ -export { convertToOpenAICompatibleChatMessages } from '../chat/convert-to-openai-compatible-chat-messages'; -export { mapOpenAICompatibleFinishReason } from '../chat/map-openai-compatible-finish-reason'; -export { getResponseMetadata } from '../chat/get-response-metadata'; -export type { OpenAICompatibleChatConfig } from '../chat/openai-compatible-chat-language-model'; +export { convertToOpenAICompatibleChatMessages } from '../chat/convert-to-openai-compatible-chat-messages' +export { mapOpenAICompatibleFinishReason } from '../chat/map-openai-compatible-finish-reason' +export { getResponseMetadata } from '../chat/get-response-metadata' +export type { OpenAICompatibleChatConfig } from '../chat/openai-compatible-chat-language-model' diff --git a/packages/internal/src/openai-compatible/openai-compatible-error.ts b/packages/llm-providers/src/openai-compatible/openai-compatible-error.ts similarity index 72% rename from packages/internal/src/openai-compatible/openai-compatible-error.ts rename to packages/llm-providers/src/openai-compatible/openai-compatible-error.ts index 5d19ebdcb3..b14cdb3f4c 100644 --- a/packages/internal/src/openai-compatible/openai-compatible-error.ts +++ b/packages/llm-providers/src/openai-compatible/openai-compatible-error.ts @@ -1,6 +1,6 @@ -import { z } from 'zod/v4'; +import { z } from 'zod/v4' -import type { ZodType } from 'zod/v4'; +import type { ZodType } from 'zod/v4' export const openaiCompatibleErrorDataSchema = z.object({ error: z.object({ @@ -13,20 +13,20 @@ export const openaiCompatibleErrorDataSchema = z.object({ param: z.any().nullish(), code: z.union([z.string(), z.number()]).nullish(), }), -}); +}) export type OpenAICompatibleErrorData = z.infer< typeof openaiCompatibleErrorDataSchema ->; +> export type ProviderErrorStructure = { - errorSchema: ZodType; - errorToMessage: (error: T) => string; - isRetryable?: (response: Response, error?: T) => boolean; -}; + errorSchema: ZodType + errorToMessage: (error: T) => string + isRetryable?: (response: Response, error?: T) => boolean +} export const defaultOpenAICompatibleErrorStructure: ProviderErrorStructure = { errorSchema: openaiCompatibleErrorDataSchema, - errorToMessage: data => data.error.message, - }; + errorToMessage: (data) => data.error.message, + } diff --git a/packages/internal/src/openai-compatible/openai-compatible-provider.ts b/packages/llm-providers/src/openai-compatible/openai-compatible-provider.ts similarity index 70% rename from packages/internal/src/openai-compatible/openai-compatible-provider.ts rename to packages/llm-providers/src/openai-compatible/openai-compatible-provider.ts index dcd2a546a2..f6b4a36b7f 100644 --- a/packages/internal/src/openai-compatible/openai-compatible-provider.ts +++ b/packages/llm-providers/src/openai-compatible/openai-compatible-provider.ts @@ -1,26 +1,22 @@ import { withoutTrailingSlash, withUserAgentSuffix, -} from '@ai-sdk/provider-utils'; +} from '@ai-sdk/provider-utils' -import { - OpenAICompatibleChatLanguageModel, -} from './chat/openai-compatible-chat-language-model'; -import { OpenAICompatibleCompletionLanguageModel } from './completion/openai-compatible-completion-language-model'; -import { OpenAICompatibleEmbeddingModel } from './embedding/openai-compatible-embedding-model'; -import { OpenAICompatibleImageModel } from './image/openai-compatible-image-model'; -import { VERSION } from './version'; +import { OpenAICompatibleChatLanguageModel } from './chat/openai-compatible-chat-language-model' +import { OpenAICompatibleCompletionLanguageModel } from './completion/openai-compatible-completion-language-model' +import { OpenAICompatibleEmbeddingModel } from './embedding/openai-compatible-embedding-model' +import { OpenAICompatibleImageModel } from './image/openai-compatible-image-model' +import { VERSION } from './version' -import type { - OpenAICompatibleChatConfig} from './chat/openai-compatible-chat-language-model'; +import type { OpenAICompatibleChatConfig } from './chat/openai-compatible-chat-language-model' import type { EmbeddingModelV2, ImageModelV2, LanguageModelV2, ProviderV2, -} from '@ai-sdk/provider'; -import type { - FetchFunction} from '@ai-sdk/provider-utils'; +} from '@ai-sdk/provider' +import type { FetchFunction } from '@ai-sdk/provider-utils' export interface OpenAICompatibleProvider< CHAT_MODEL_IDS extends string = string, @@ -28,66 +24,66 @@ export interface OpenAICompatibleProvider< EMBEDDING_MODEL_IDS extends string = string, IMAGE_MODEL_IDS extends string = string, > extends Omit { - (modelId: CHAT_MODEL_IDS): LanguageModelV2; + (modelId: CHAT_MODEL_IDS): LanguageModelV2 languageModel( modelId: CHAT_MODEL_IDS, config?: Partial, - ): LanguageModelV2; + ): LanguageModelV2 - chatModel(modelId: CHAT_MODEL_IDS): LanguageModelV2; + chatModel(modelId: CHAT_MODEL_IDS): LanguageModelV2 - completionModel(modelId: COMPLETION_MODEL_IDS): LanguageModelV2; + completionModel(modelId: COMPLETION_MODEL_IDS): LanguageModelV2 - textEmbeddingModel(modelId: EMBEDDING_MODEL_IDS): EmbeddingModelV2; + textEmbeddingModel(modelId: EMBEDDING_MODEL_IDS): EmbeddingModelV2 - imageModel(modelId: IMAGE_MODEL_IDS): ImageModelV2; + imageModel(modelId: IMAGE_MODEL_IDS): ImageModelV2 } export interface OpenAICompatibleProviderSettings { /** Base URL for the API calls. */ - baseURL: string; + baseURL: string /** Provider name. */ - name: string; + name: string /** API key for authenticating requests. If specified, adds an `Authorization` header to request headers with the value `Bearer `. This will be added before any headers potentially specified in the `headers` option. */ - apiKey?: string; + apiKey?: string /** Optional custom headers to include in requests. These will be added to request headers after any headers potentially added by use of the `apiKey` option. */ - headers?: Record; + headers?: Record /** Optional custom url query parameters to include in request urls. */ - queryParams?: Record; + queryParams?: Record /** Custom fetch implementation. You can use it as a middleware to intercept requests, or to provide a custom fetch implementation for e.g. testing. */ - fetch?: FetchFunction; + fetch?: FetchFunction /** Include usage information in streaming responses. */ - includeUsage?: boolean; + includeUsage?: boolean /** * Whether the provider supports structured outputs in chat models. */ - supportsStructuredOutputs?: boolean; + supportsStructuredOutputs?: boolean } /** @@ -106,73 +102,73 @@ export function createOpenAICompatible< EMBEDDING_MODEL_IDS, IMAGE_MODEL_IDS > { - const baseURL = withoutTrailingSlash(options.baseURL); - const providerName = options.name; + const baseURL = withoutTrailingSlash(options.baseURL) + const providerName = options.name interface CommonModelConfig { - provider: string; - url: ({ path }: { path: string }) => string; - headers: () => Record; - fetch?: FetchFunction; + provider: string + url: ({ path }: { path: string }) => string + headers: () => Record + fetch?: FetchFunction } const headers = { ...(options.apiKey && { Authorization: `Bearer ${options.apiKey}` }), ...options.headers, - }; + } const getHeaders = () => - withUserAgentSuffix(headers, `ai-sdk/openai-compatible/${VERSION}`); + withUserAgentSuffix(headers, `ai-sdk/openai-compatible/${VERSION}`) const getCommonModelConfig = (modelType: string): CommonModelConfig => ({ provider: `${providerName}.${modelType}`, url: ({ path }) => { - const url = new URL(`${baseURL}${path}`); + const url = new URL(`${baseURL}${path}`) if (options.queryParams) { - url.search = new URLSearchParams(options.queryParams).toString(); + url.search = new URLSearchParams(options.queryParams).toString() } - return url.toString(); + return url.toString() }, headers: getHeaders, fetch: options.fetch, - }); + }) const createLanguageModel = (modelId: CHAT_MODEL_IDS) => - createChatModel(modelId); + createChatModel(modelId) const createChatModel = (modelId: CHAT_MODEL_IDS) => new OpenAICompatibleChatLanguageModel(modelId, { ...getCommonModelConfig('chat'), includeUsage: options.includeUsage, supportsStructuredOutputs: options.supportsStructuredOutputs, - }); + }) const createCompletionModel = (modelId: COMPLETION_MODEL_IDS) => new OpenAICompatibleCompletionLanguageModel(modelId, { ...getCommonModelConfig('completion'), includeUsage: options.includeUsage, - }); + }) const createEmbeddingModel = (modelId: EMBEDDING_MODEL_IDS) => new OpenAICompatibleEmbeddingModel(modelId, { ...getCommonModelConfig('embedding'), - }); + }) const createImageModel = (modelId: IMAGE_MODEL_IDS) => - new OpenAICompatibleImageModel(modelId, getCommonModelConfig('image')); + new OpenAICompatibleImageModel(modelId, getCommonModelConfig('image')) - const provider = (modelId: CHAT_MODEL_IDS) => createLanguageModel(modelId); + const provider = (modelId: CHAT_MODEL_IDS) => createLanguageModel(modelId) - provider.languageModel = createLanguageModel; - provider.chatModel = createChatModel; - provider.completionModel = createCompletionModel; - provider.textEmbeddingModel = createEmbeddingModel; - provider.imageModel = createImageModel; + provider.languageModel = createLanguageModel + provider.chatModel = createChatModel + provider.completionModel = createCompletionModel + provider.textEmbeddingModel = createEmbeddingModel + provider.imageModel = createImageModel return provider as OpenAICompatibleProvider< CHAT_MODEL_IDS, COMPLETION_MODEL_IDS, EMBEDDING_MODEL_IDS, IMAGE_MODEL_IDS - >; + > } diff --git a/packages/internal/src/openai-compatible/version.ts b/packages/llm-providers/src/openai-compatible/version.ts similarity index 57% rename from packages/internal/src/openai-compatible/version.ts rename to packages/llm-providers/src/openai-compatible/version.ts index 8fda877d6d..e8c98e309f 100644 --- a/packages/internal/src/openai-compatible/version.ts +++ b/packages/llm-providers/src/openai-compatible/version.ts @@ -1,5 +1,5 @@ -declare const __PACKAGE_VERSION__: string | undefined; +declare const __PACKAGE_VERSION__: string | undefined export const VERSION: string = typeof __PACKAGE_VERSION__ !== 'undefined' ? __PACKAGE_VERSION__ - : '0.0.0-test'; + : '0.0.0-test' diff --git a/packages/llm-providers/tsconfig.json b/packages/llm-providers/tsconfig.json new file mode 100644 index 0000000000..51864d1a50 --- /dev/null +++ b/packages/llm-providers/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2022", + "types": ["bun", "node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/sdk/scripts/build.ts b/sdk/scripts/build.ts index 854e1ac5fc..a93b246b98 100644 --- a/sdk/scripts/build.ts +++ b/sdk/scripts/build.ts @@ -107,6 +107,7 @@ async function build() { '@codebuff/common', '@codebuff/agent-runtime', '@codebuff/code-map', + '@codebuff/llm-providers', ], }, }, @@ -159,10 +160,7 @@ async function fixDuplicateImports() { await writeFile('dist/index.d.ts', content) console.log(' ✓ Fixed duplicate imports in bundled types') } catch (error) { - console.warn( - ' ⚠ Warning: Could not fix duplicate imports:', - error.message, - ) + console.warn(' ⚠ Warning: Could not fix duplicate imports:', error.message) } } diff --git a/sdk/src/impl/llm.ts b/sdk/src/impl/llm.ts index 60bb678bb1..06988fc565 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -3,7 +3,11 @@ import { isFreeMode } from '@codebuff/common/constants/free-agents' import { models, PROFIT_MARGIN } from '@codebuff/common/old-constants' import { buildArray } from '@codebuff/common/util/array' import { normalizeProviderRequestBodyForCacheDebug } from '@codebuff/common/util/cache-debug' -import { getErrorObject, promptAborted, promptSuccess } from '@codebuff/common/util/error' +import { + getErrorObject, + promptAborted, + promptSuccess, +} from '@codebuff/common/util/error' import { convertCbToModelMessages } from '@codebuff/common/util/messages' import { isExplicitlyDefinedModel } from '@codebuff/common/util/model-utils' import { StopSequenceHandler } from '@codebuff/common/util/stop-sequence' @@ -26,7 +30,10 @@ import { refreshChatGptOAuthToken } from '../credentials' import { getErrorStatusCode } from '../error-utils' import type { ModelRequestParams } from './model-provider' -import type { OpenRouterProviderRoutingOptions } from '@codebuff/common/types/agent-template' +import type { + OpenRouterProviderOptions, + OpenRouterProviderRoutingOptions, +} from '@codebuff/common/types/agent-template' import type { PromptAiSdkFn, PromptAiSdkStreamFn, @@ -35,7 +42,6 @@ import type { } from '@codebuff/common/types/contracts/llm' import type { ParamsOf } from '@codebuff/common/types/function-params' import type { JSONObject } from '@codebuff/common/types/json' -import type { OpenRouterProviderOptions } from '@codebuff/internal/openrouter-ai-sdk' import type { LanguageModel } from 'ai' import type z from 'zod/v4' @@ -283,12 +289,15 @@ export async function* promptAiSdkStream( chatGptOAuthRetried?: boolean }, ): ReturnType { + const { providerOptions: originalProviderOptions, ...streamParams } = params + const { - providerOptions: originalProviderOptions, - ...streamParams + logger, + trackEvent, + userId, + userInputId, + model: requestedModel, } = params - - const { logger, trackEvent, userId, userInputId, model: requestedModel } = params const agentChunkMetadata = params.agentId != null ? { agentId: params.agentId } : undefined @@ -334,12 +343,12 @@ export async function* promptAiSdkStream( ...(isChatGptOAuth ? {} : { - providerOptions: getProviderOptions({ - ...params, - providerOptions: originalProviderOptions, - agentProviderOptions: params.agentProviderOptions, + providerOptions: getProviderOptions({ + ...params, + providerOptions: originalProviderOptions, + agentProviderOptions: params.agentProviderOptions, + }), }), - }), // Handle tool call errors gracefully by passing them through to our validation layer // instead of throwing (which would halt the agent). The only special case is when // the tool name matches a spawnable agent - transform those to spawn_agents calls. @@ -516,7 +525,10 @@ export async function* promptAiSdkStream( }) if (chatGptErrorPolicy === 'fallback-rate-limit') { - const rateLimitErrorDetails = chunkValue.error instanceof Error ? chunkValue.error.message : String(chunkValue.error) + const rateLimitErrorDetails = + chunkValue.error instanceof Error + ? chunkValue.error.message + : String(chunkValue.error) logger.warn( { error: getErrorObject(chunkValue.error) }, 'ChatGPT OAuth rate limited during stream', @@ -568,14 +580,20 @@ export async function* promptAiSdkStream( if (!params.chatGptOAuthRetried) { const refreshed = await refreshChatGptOAuthToken() if (refreshed) { - logger.info({ model: requestedModel }, 'ChatGPT OAuth token refreshed, retrying request') + logger.info( + { model: requestedModel }, + 'ChatGPT OAuth token refreshed, retrying request', + ) const retryResult = yield* promptAiSdkStream({ ...params, chatGptOAuthRetried: true, }) return retryResult } - logger.warn({ model: requestedModel }, 'ChatGPT OAuth token refresh failed, unable to recover') + logger.warn( + { model: requestedModel }, + 'ChatGPT OAuth token refresh failed, unable to recover', + ) } // Refresh failed or already retried @@ -609,11 +627,8 @@ export async function* promptAiSdkStream( if (chunkValue.type === 'reasoning-delta') { const reasoningExcluded = (['openrouter', 'codebuff'] as const).some( (p) => - ( - params.providerOptions?.[p] as - | OpenRouterProviderOptions - | undefined - )?.reasoning?.exclude, + (params.providerOptions?.[p] as OpenRouterProviderOptions | undefined) + ?.reasoning?.exclude, ) if (!reasoningExcluded) { yield { diff --git a/sdk/src/impl/model-provider.ts b/sdk/src/impl/model-provider.ts index 83e016c611..268c7394d0 100644 --- a/sdk/src/impl/model-provider.ts +++ b/sdk/src/impl/model-provider.ts @@ -20,12 +20,10 @@ import { import { OpenAICompatibleChatLanguageModel, VERSION, -} from '@codebuff/internal/openai-compatible/index' +} from '@codebuff/llm-providers/openai-compatible' import { WEBSITE_URL } from '../constants' -import { - getValidChatGptOAuthCredentials, -} from '../credentials' +import { getValidChatGptOAuthCredentials } from '../credentials' import { getByokOpenrouterApiKeyFromEnv } from '../env' import { createChatGptBackendFetch, @@ -111,10 +109,12 @@ type OpenRouterUsageAccounting = { * * If ChatGPT OAuth credentials are available and the model is an OpenAI model, * returns an OpenAI direct model. Otherwise, returns the Codebuff backend model. - * + * * This function is async because it may need to refresh the OAuth token. */ -export async function getModelForRequest(params: ModelRequestParams): Promise { +export async function getModelForRequest( + params: ModelRequestParams, +): Promise { const { apiKey, model, skipChatGptOAuth, costMode } = params // Check if we should use ChatGPT OAuth direct @@ -138,7 +138,10 @@ export async function getModelForRequest(params: ModelRequestParams): Promise