diff --git a/docs/prompt-caching.md b/docs/prompt-caching.md index b23e8d6..674eac9 100644 --- a/docs/prompt-caching.md +++ b/docs/prompt-caching.md @@ -11,3 +11,4 @@ That guide covers provider-specific behavior, pricing, configuration requirement See ecosystem-specific examples in this repository for runnable reference implementations: - **TypeScript + fetch**: [typescript/fetch/src/prompt-caching/](../typescript/fetch/src/prompt-caching/) +- **AI SDK v5** (Vercel): [typescript/ai-sdk-v5/src/prompt-caching/](../typescript/ai-sdk-v5/src/prompt-caching/) diff --git a/typescript/ai-sdk-v5/README.md b/typescript/ai-sdk-v5/README.md new file mode 100644 index 0000000..ef5a521 --- /dev/null +++ b/typescript/ai-sdk-v5/README.md @@ -0,0 +1,40 @@ +# AI SDK v5 Examples + +Examples using Vercel AI SDK v5 with @openrouter/ai-sdk-provider. + +## Prerequisites + +- Bun runtime: `curl -fsSL https://bun.sh/install | bash` +- `OPENROUTER_API_KEY` environment variable + +## Running Examples + +```bash +# From monorepo root (typescript/) +bun examples + +# Or from this workspace +cd ai-sdk-v5 +bun examples +``` + +## Features + +- [prompt-caching](./src/prompt-caching/) - Anthropic caching examples with AI SDK v5 + +### Key Configuration + +**CRITICAL**: The AI SDK example requires: +```typescript +extraBody: { + stream_options: { include_usage: true } +} +``` + +Without this, usage details (including cached_tokens) are not populated in the response. + +## Dependencies + +- `@openrouter-examples/shared` - Shared constants (LARGE_SYSTEM_PROMPT) and types +- `@openrouter/ai-sdk-provider` - OpenRouter provider for AI SDK +- `ai` v5.x - Vercel AI SDK diff --git a/typescript/ai-sdk-v5/package.json b/typescript/ai-sdk-v5/package.json new file mode 100644 index 0000000..ee55441 --- /dev/null +++ b/typescript/ai-sdk-v5/package.json @@ -0,0 +1,18 @@ +{ + "name": "@openrouter-examples/ai-sdk-v5", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "examples": "bun run run-examples.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@openrouter-examples/shared": "workspace:*", + "@openrouter/ai-sdk-provider": "1.2.2", + "ai": "^5.0.92" + }, + "devDependencies": { + "@types/bun": "latest" + } +} diff --git a/typescript/ai-sdk-v5/run-examples.ts b/typescript/ai-sdk-v5/run-examples.ts new file mode 100755 index 0000000..67429a3 --- /dev/null +++ b/typescript/ai-sdk-v5/run-examples.ts @@ -0,0 +1,57 @@ +#!/usr/bin/env bun +/** + * Run all example files in the src/ directory + * Each example is run in a separate process to handle process.exit() calls + */ + +import { readdirSync, statSync } from 'fs'; +import { join } from 'path'; +import { $ } from 'bun'; + +const srcDir = join(import.meta.dir, 'src'); + +// Recursively find all .ts files in src/ +function findExamples(dir: string): string[] { + const entries = readdirSync(dir); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = join(dir, entry); + const stat = statSync(fullPath); + + if (stat.isDirectory()) { + files.push(...findExamples(fullPath)); + } else if (entry.endsWith('.ts')) { + files.push(fullPath); + } + } + + return files.sort(); +} + +const examples = findExamples(srcDir); +console.log(`Found ${examples.length} example(s)\n`); + +let failed = 0; +for (const example of examples) { + const relativePath = example.replace(import.meta.dir + '/', ''); + console.log(`\n${'='.repeat(80)}`); + console.log(`Running: ${relativePath}`); + console.log('='.repeat(80)); + + try { + await $`bun run ${example}`.quiet(); + console.log(`✅ ${relativePath} completed successfully`); + } catch (error) { + console.error(`❌ ${relativePath} failed`); + failed++; + } +} + +console.log(`\n${'='.repeat(80)}`); +console.log(`Results: ${examples.length - failed}/${examples.length} passed`); +console.log('='.repeat(80)); + +if (failed > 0) { + process.exit(1); +} diff --git a/typescript/ai-sdk-v5/src/prompt-caching/README.md b/typescript/ai-sdk-v5/src/prompt-caching/README.md new file mode 100644 index 0000000..b50cdec --- /dev/null +++ b/typescript/ai-sdk-v5/src/prompt-caching/README.md @@ -0,0 +1,12 @@ +# Prompt Caching Examples (AI SDK v5) + +Examples demonstrating prompt caching with Vercel AI SDK v5. + +## Documentation + +For full prompt caching documentation including all providers, pricing, and configuration details, see: +- **[OpenRouter Prompt Caching Guide](https://openrouter.ai/docs/features/prompt-caching)** + +## Examples in This Directory + +See the TypeScript files in this directory for specific examples with complete working code. diff --git a/typescript/ai-sdk-v5/src/prompt-caching/anthropic-multi-message-cache.ts b/typescript/ai-sdk-v5/src/prompt-caching/anthropic-multi-message-cache.ts new file mode 100644 index 0000000..b3b4d9c --- /dev/null +++ b/typescript/ai-sdk-v5/src/prompt-caching/anthropic-multi-message-cache.ts @@ -0,0 +1,141 @@ +/** + * Example: Anthropic Prompt Caching - Multi-Message Conversation (AI SDK v5) + * + * This example demonstrates Anthropic prompt caching in a multi-message conversation + * via OpenRouter using Vercel AI SDK v5. + * + * Pattern: User message cache in multi-turn conversation + * - Cache large context in first user message + * - Cache persists through conversation history + */ + +import { LARGE_SYSTEM_PROMPT } from '@openrouter-examples/shared/constants'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { generateText } from 'ai'; + +const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, + extraBody: { + stream_options: { include_usage: true }, + }, +}); + +async function main() { + console.log('╔════════════════════════════════════════════════════════════════════════════╗'); + console.log('║ Anthropic Prompt Caching - Multi-Message (AI SDK v5) ║'); + console.log('╚════════════════════════════════════════════════════════════════════════════╝'); + console.log(); + console.log('Testing cache_control in multi-turn conversation'); + console.log(); + + try { + const testId = Date.now(); + const model = openrouter('anthropic/claude-3-5-sonnet'); + const largeContext = `Test ${testId}: Context:\n\n${LARGE_SYSTEM_PROMPT}`; + + // First call with conversation history + console.log('First Call (Cache Miss Expected)'); + const result1 = await generateText({ + model, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: largeContext, + providerOptions: { + openrouter: { + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + { + type: 'text', + text: "Hello, what's your purpose?", + }, + ], + }, + { + role: 'assistant', + content: "I'm an AI assistant designed to help with various tasks.", + }, + { + role: 'user', + content: 'What programming languages do you know?', + }, + ], + }); + + // FIXME: providerMetadata.openrouter.usage should have proper type with promptTokensDetails + const cached1 = + // @ts-expect-error - usage is typed as JSONValue but should be OpenRouterUsage + result1.providerMetadata?.openrouter?.usage?.promptTokensDetails?.cachedTokens ?? 0; + console.log(` Response: ${result1.text.substring(0, 80)}...`); + console.log(` cached_tokens=${cached1}`); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Second identical call - should hit cache + console.log('\nSecond Call (Cache Hit Expected)'); + const result2 = await generateText({ + model, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: largeContext, + providerOptions: { + openrouter: { + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + { + type: 'text', + text: "Hello, what's your purpose?", + }, + ], + }, + { + role: 'assistant', + content: "I'm an AI assistant designed to help with various tasks.", + }, + { + role: 'user', + content: 'What programming languages do you know?', + }, + ], + }); + + // FIXME: providerMetadata.openrouter.usage should have proper type with promptTokensDetails + const cached2 = + // @ts-expect-error - usage is typed as JSONValue but should be OpenRouterUsage + result2.providerMetadata?.openrouter?.usage?.promptTokensDetails?.cachedTokens ?? 0; + console.log(` Response: ${result2.text.substring(0, 80)}...`); + console.log(` cached_tokens=${cached2}`); + + // Analysis + console.log('\n' + '='.repeat(80)); + console.log('ANALYSIS'); + console.log('='.repeat(80)); + console.log(`First call: cached_tokens=${cached1} (expected: 0)`); + console.log(`Second call: cached_tokens=${cached2} (expected: >0)`); + + const success = cached1 === 0 && cached2 > 0; + console.log(`\nResult: ${success ? '✓ CACHE WORKING' : '✗ CACHE NOT WORKING'}`); + + if (success) { + console.log('\n✓ SUCCESS - Multi-message caching is working correctly'); + } else { + console.log('\n✗ FAILURE - Multi-message caching is not working as expected'); + } + } catch (error) { + console.error('\n❌ ERROR:', error); + process.exit(1); + } +} + +main(); diff --git a/typescript/ai-sdk-v5/src/prompt-caching/anthropic-no-cache-control.ts b/typescript/ai-sdk-v5/src/prompt-caching/anthropic-no-cache-control.ts new file mode 100644 index 0000000..9383583 --- /dev/null +++ b/typescript/ai-sdk-v5/src/prompt-caching/anthropic-no-cache-control.ts @@ -0,0 +1,123 @@ +/** + * Example: Anthropic Prompt Caching - Control (No cache_control) (AI SDK v5) + * + * This is a CONTROL scenario demonstrating that without cache_control, + * no caching occurs. + * + * Purpose: Validates that cache behavior is due to cache_control, not coincidence + */ + +import { LARGE_SYSTEM_PROMPT } from '@openrouter-examples/shared/constants'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { generateText } from 'ai'; + +const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, + extraBody: { + stream_options: { include_usage: true }, + }, +}); + +async function main() { + console.log('╔════════════════════════════════════════════════════════════════════════════╗'); + console.log('║ Anthropic Prompt Caching - Control (No cache_control) (AI SDK v5) ║'); + console.log('╚════════════════════════════════════════════════════════════════════════════╝'); + console.log(); + console.log('Testing WITHOUT cache_control (control scenario)'); + console.log(); + console.log('Expected behavior:'); + console.log(' 1st call: cached_tokens = 0 (no cache_control)'); + console.log(' 2nd call: cached_tokens = 0 (no cache_control)'); + console.log(); + + try { + const testId = Date.now(); + const model = openrouter('anthropic/claude-3-5-sonnet'); + const largeContext = `Test ${testId}: Context:\n\n${LARGE_SYSTEM_PROMPT}`; + + // First call - NO cache_control + console.log('First Call (No Cache Expected)'); + const result1 = await generateText({ + model, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: largeContext, + // NO cache_control - this is the control + }, + { + type: 'text', + text: 'What are the key principles?', + }, + ], + }, + ], + }); + + // FIXME: providerMetadata.openrouter.usage should have proper type with promptTokensDetails + const cached1 = + // @ts-expect-error - usage is typed as JSONValue but should be OpenRouterUsage + result1.providerMetadata?.openrouter?.usage?.promptTokensDetails?.cachedTokens ?? 0; + console.log(` cached_tokens=${cached1}`); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Second call - still NO cache_control + console.log('\nSecond Call (No Cache Expected)'); + const result2 = await generateText({ + model, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: largeContext, + // NO cache_control + }, + { + type: 'text', + text: 'What are the key principles?', + }, + ], + }, + ], + }); + + // FIXME: providerMetadata.openrouter.usage should have proper type with promptTokensDetails + const cached2 = + // @ts-expect-error - usage is typed as JSONValue but should be OpenRouterUsage + result2.providerMetadata?.openrouter?.usage?.promptTokensDetails?.cachedTokens ?? 0; + console.log(` cached_tokens=${cached2}`); + + // Analysis + console.log('\n' + '='.repeat(80)); + console.log('ANALYSIS (CONTROL)'); + console.log('='.repeat(80)); + console.log(`First call: cached_tokens=${cached1} (expected: 0)`); + console.log(`Second call: cached_tokens=${cached2} (expected: 0)`); + + if (cached1 === 0 && cached2 === 0) { + console.log('✓ No cache metrics present (expected for control - no cache_control)'); + } else { + console.log('✗ Unexpected cache metrics in control scenario'); + } + + const success = cached1 === 0 && cached2 === 0; + console.log(`\nResult: ${success ? '✓ CONTROL VALID' : '✗ CONTROL INVALID'}`); + + if (success) { + console.log('\n✓ SUCCESS - Control scenario confirms no false positives'); + } else { + console.log('\n✗ FAILURE - Control scenario shows unexpected cache behavior'); + } + } catch (error) { + console.error('\n❌ ERROR:', error); + process.exit(1); + } +} + +main(); diff --git a/typescript/ai-sdk-v5/src/prompt-caching/anthropic-user-message-cache.ts b/typescript/ai-sdk-v5/src/prompt-caching/anthropic-user-message-cache.ts new file mode 100644 index 0000000..86e159c --- /dev/null +++ b/typescript/ai-sdk-v5/src/prompt-caching/anthropic-user-message-cache.ts @@ -0,0 +1,173 @@ +/** + * Example: Anthropic Prompt Caching - User Message (AI SDK v5) + * + * This example demonstrates Anthropic prompt caching on a user message via OpenRouter + * using Vercel AI SDK v5. + * + * Pattern: User message with providerOptions.openrouter.cacheControl + * - User message with content array + * - cache_control on text content block via providerOptions + * + * CRITICAL CONFIGURATION: + * - **MUST** include extraBody: { stream_options: { include_usage: true } } + * - Without this, usage details (including cached_tokens) are not populated + */ + +import { LARGE_SYSTEM_PROMPT } from '@openrouter-examples/shared/constants'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { generateText } from 'ai'; + +// Create the OpenRouter provider +// CRITICAL: extraBody with stream_options is REQUIRED for usage details +const openrouter = createOpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY, + extraBody: { + stream_options: { include_usage: true }, // Required for cached_tokens field + }, +}); + +/** + * Main example + */ +async function main() { + console.log('╔════════════════════════════════════════════════════════════════════════════╗'); + console.log('║ Anthropic Prompt Caching - User Message (AI SDK v5) ║'); + console.log('╚════════════════════════════════════════════════════════════════════════════╝'); + console.log(); + console.log('Testing cache_control on user message content block'); + console.log( + `Context size: ${LARGE_SYSTEM_PROMPT.length} characters (~${Math.round(LARGE_SYSTEM_PROMPT.length / 4)} tokens)`, + ); + console.log(); + console.log('Expected behavior:'); + console.log(' 1st call: cached_tokens = 0 (cache miss, creates cache)'); + console.log(' 2nd call: cached_tokens > 0 (cache hit, reads from cache)'); + console.log(); + + try { + const testId = Date.now(); + const model = openrouter('anthropic/claude-3-5-sonnet'); + + // Use large context in user message + const largeContext = `Test ${testId}: Here is a comprehensive codebase to analyze:\n\n${LARGE_SYSTEM_PROMPT}`; + + // First call - should create cache + console.log('First Call (Cache Miss Expected)'); + const result1 = await generateText({ + model, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: largeContext, + providerOptions: { + openrouter: { + cacheControl: { type: 'ephemeral' }, // Cache this content block + }, + }, + }, + { + type: 'text', + text: 'Based on this codebase, what are the main patterns used?', + }, + ], + }, + ], + }); + + console.log(' Response:', result1.text.substring(0, 100) + '...'); + const usage1 = result1.providerMetadata?.openrouter?.usage; + // FIXME: providerMetadata.openrouter.usage should have proper type with promptTokensDetails, promptTokens, completionTokens + // @ts-expect-error - usage is typed as JSONValue but should be OpenRouterUsage + const cached1 = usage1?.promptTokensDetails?.cachedTokens ?? 0; + console.log( + // @ts-expect-error - usage is typed as JSONValue but should be OpenRouterUsage + ` Tokens: prompt=${usage1?.promptTokens}, completion=${usage1?.completionTokens}, cached=${cached1}`, + ); + + // Wait 1 second + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // Second identical call - should hit cache + console.log('\nSecond Call (Cache Hit Expected)'); + const result2 = await generateText({ + model, + messages: [ + { + role: 'user', + content: [ + { + type: 'text', + text: largeContext, + providerOptions: { + openrouter: { + cacheControl: { type: 'ephemeral' }, + }, + }, + }, + { + type: 'text', + text: 'Based on this codebase, what are the main patterns used?', + }, + ], + }, + ], + }); + + console.log(' Response:', result2.text.substring(0, 100) + '...'); + const usage2 = result2.providerMetadata?.openrouter?.usage; + // FIXME: providerMetadata.openrouter.usage should have proper type with promptTokensDetails, promptTokens, completionTokens + // @ts-expect-error - usage is typed as JSONValue but should be OpenRouterUsage + const cached2 = usage2?.promptTokensDetails?.cachedTokens ?? 0; + console.log( + // @ts-expect-error - usage is typed as JSONValue but should be OpenRouterUsage + ` Tokens: prompt=${usage2?.promptTokens}, completion=${usage2?.completionTokens}, cached=${cached2}`, + ); + + // Analysis + console.log('\n' + '='.repeat(80)); + console.log('ANALYSIS'); + console.log('='.repeat(80)); + + console.log(`First call: cached_tokens=${cached1} (expected: 0)`); + console.log(`Second call: cached_tokens=${cached2} (expected: >0)`); + + if (cached1 === 0) { + console.log('✓ First call cache miss (created cache)'); + } else { + console.log(`⚠ First call unexpectedly had cached tokens: ${cached1}`); + } + + if (cached2 > 0) { + console.log(`✓ Second call cache hit: ${cached2} tokens read from cache`); + } else { + console.log('✗ Second call did NOT hit cache'); + } + + const success = cached1 === 0 && cached2 > 0; + console.log(`\nResult: ${success ? '✓ CACHE WORKING' : '✗ CACHE NOT WORKING'}`); + + if (success) { + console.log('\n════════════════════════════════════════════════════════════════════════════'); + console.log('✓ SUCCESS - User message caching is working correctly'); + console.log('════════════════════════════════════════════════════════════════════════════'); + } else { + console.log('\n════════════════════════════════════════════════════════════════════════════'); + console.log('✗ FAILURE - User message caching is not working as expected'); + console.log('════════════════════════════════════════════════════════════════════════════'); + } + } catch (error) { + console.error('\n❌ ERROR during testing:'); + if (error instanceof Error) { + console.error('Error message:', error.message); + console.error('Stack trace:', error.stack); + } else { + console.error('Unknown error:', error); + } + process.exit(1); + } +} + +main(); diff --git a/typescript/ai-sdk-v5/tsconfig.json b/typescript/ai-sdk-v5/tsconfig.json new file mode 100644 index 0000000..822a727 --- /dev/null +++ b/typescript/ai-sdk-v5/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "lib": ["ES2022"], + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["bun-types"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/typescript/package.json b/typescript/package.json index 9624171..4b437ad 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -11,7 +11,8 @@ }, "workspaces": [ "shared", - "fetch" + "fetch", + "ai-sdk-v5" ], "devDependencies": { "@types/bun": "1.3.2",