diff --git a/.codeinsight b/.codeinsight new file mode 100644 index 0000000..275cb57 --- /dev/null +++ b/.codeinsight @@ -0,0 +1,73 @@ +## 🎯 thebird v1.1.0 β€” Anthropic SDK to Gemini streaming bridge β€” drop-in proxy that translates Anthropic message format and tool calls to Google Gemini + +# 11f 721L 24fn 24cls cx2.2 +*Legend: f=files L=lines fn=functions cls=classes cx=avg-complexity | file:line:name(NL)=location Np=params | ↑N=imports-from ↓N=imported-by (N)=occurrences (+N)=more | πŸ”„circular 🏝️isolated πŸ”₯complex πŸ“‹duplicated πŸ“large* + +**Langs:** JS:78% TS:17% JSON:4% + +## πŸ› οΈ Tech Stack + +**Patterns:** generateGemini(7), contents.push(6), main(5), main().catch(5), chat(4), allParts.filter(3) +**Top IDs:** type(47), console(39), log(33), result(26), content(23), text(23) + +## ⚑ Code Patterns + +**Async:** async(38), await(25), Promise(1) +**Errors:** try/catch(6), throw(3) +**Internal calls:** generateGemini(7), contents.push(6), main(5), main().catch(5), chat(4), allParts.filter(3), onStepFinish(3), process.stdout.write(2) + +## πŸ”— I/O & Integration + +**Env vars:** GEMINI_API_KEY +**Storage:** SQL(2), JSON(5) + +## πŸ“Š Code Organization + +**Long funcs:** index.js:14:createFullStream(57L), examples/streaming.js:26:main(54L) +**Classes:** index.d.ts:0:TextBlock, index.d.ts:0:ImageBlockBase64, index.d.ts:0:ImageBlockUrl, index.d.ts:0:ImageBlockInline, index.d.ts:0:ImageBlockFile, index.d.ts:0:ToolUseBlock, index.d.ts:0:ToolResultBlock, index.d.ts:0:Message (+15) + +## πŸ”„ Architecture + +**L0 [pure exports]:** convert(1↓), errors(1↓), client(1↓) +**L3 [pure imports]:** index(3↑) +**Cross-module:** index.jsβ†’lib +**External:** @google/genai + +## πŸ”Œ API Surface + +**Exported fns:** errors.js:11:isRetryable(1p), errors.js:20:withRetry(1p), convert.js:1:cleanSchema(1p), convert.js:12:convertTools(2p), convert.js:21:convertImageBlock(1p), convert.js:38:convertMessages(1p), convert.js:65:extractModelId(1p), convert.js:72:buildConfig(1p), client.js:5:getClient(1p), index.js:5:streamGemini(1p), index.js:72:generateGemini(2p) +**Classes:** TextBlock, ImageBlockBase64, ImageBlockUrl, ImageBlockInline, ImageBlockFile, ToolUseBlock (+17) +**Entry files:** client, convert, errors + +## 🚨 Issues + +- πŸ“‹ 1 duplicated groups + +## 🧹 Dead Code & Tests + +**Orphaned:** tool-use.js, basic-chat.js, streaming.js, vision.js, package.json, multi-turn.js +**Tests:** 0/11 (0%) + +## πŸ“¦ Modules + +- lib: 3f, 3cx, 0↑3↓ +- examples: 5f, 0cx, 0↑0↓ + +## πŸ“„ File Index + +**examples/basic-chat.js** 34L fn: main +**examples/multi-turn.js** 45L fn: chat, main +**examples/streaming.js** 81L fn: main +**examples/tool-use.js** 77L fn: nonStreamingExample, streamingExample, main +**examples/vision.js** 84L fn: base64Example, inlineDataExample, publicUrlExample, main +**index.d.ts** 128L +**index.js** 112L exports: [convertTools], [convertMessages], [streamGemini], [generateGemini], [cleanSchema] fn: streamGemini, createFullStream, generateGemini +**lib/client.js** 10L exports: [getClient] fn: getClient +**lib/convert.js** 86L exports: [cleanSchema], [extractModelId], [convertMessages], [buildConfig], [convertTools] fn: cleanSchema, convertTools, convertImageBlock, convertMessages (+2) +**lib/errors.js** 35L exports: [GeminiError], [isRetryable], [withRetry] fn: constructor, isRetryable, withRetry +**package.json** 29L + +Git: branch: claude/add-code-router-features-TJkSb, 1 uncommitted +Hot: package.json(1), README.md(1), index.js(1) +Conv[JS]: 4-space, single quotes, semicolons, function declarations, relative imports, kebab-case files +Conv[TS]: 2-space, single quotes, semicolons, function declarations, named exports \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2902a0e --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.gm-stop-verified diff --git a/README.md b/README.md index 747ceae..8b49e89 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # thebird -Anthropic SDK to Google Gemini bridge. Drop-in adapter that translates Anthropic-style messages, tool calls, and content blocks to the Gemini API β€” with streaming, non-streaming, vision, retry logic, and full TypeScript types. +Anthropic SDK to multi-provider bridge. Drop-in adapter that translates Anthropic-style messages, tool calls, and content blocks to Google Gemini or any OpenAI-compatible API β€” with routing, transformers, streaming, vision, retry logic, and full TypeScript types. ## Install @@ -8,247 +8,169 @@ Anthropic SDK to Google Gemini bridge. Drop-in adapter that translates Anthropic npm install thebird ``` -Requires `GEMINI_API_KEY` environment variable, or pass `apiKey` directly. - ## Quick Start +**Gemini (direct)** + ```js const { generateGemini, streamGemini } = require('thebird'); +// requires GEMINI_API_KEY env var -// Non-streaming const { text } = await generateGemini({ model: 'gemini-2.0-flash', messages: [{ role: 'user', content: 'Hello!' }] }); -console.log(text); - -// Streaming -const { fullStream } = streamGemini({ - model: 'gemini-2.0-flash', - messages: [{ role: 'user', content: 'Tell me a story.' }] -}); -for await (const event of fullStream) { - if (event.type === 'text-delta') process.stdout.write(event.textDelta); -} ``` -## API - -### `generateGemini(params)` β†’ `Promise<{ text, parts, response }>` - -Non-streaming generation. Automatically handles multi-step tool call loops until a final text response is returned. - -### `streamGemini(params)` β†’ `{ fullStream, warnings }` - -Returns an async iterable of events. Handles agentic tool loops β€” yields events for each step until the model produces a non-tool response. - -### Shared params - -| Param | Type | Default | Description | -|---|---|---|---| -| `model` | `string \| { id }` | `'gemini-2.0-flash'` | Model name or object with `id`/`modelId` | -| `messages` | `Message[]` | required | Conversation history | -| `system` | `string` | β€” | System instruction | -| `tools` | `Tools` | β€” | Tool definitions with optional `execute` | -| `apiKey` | `string` | `GEMINI_API_KEY` env | Override API key | -| `temperature` | `number` | `0.5` | Sampling temperature | -| `maxOutputTokens` | `number` | `8192` | Max output tokens | -| `topP` | `number` | `0.95` | Top-p sampling | -| `topK` | `number` | β€” | Top-k sampling | -| `safetySettings` | `SafetySetting[]` | β€” | Gemini safety thresholds | - -`streamGemini` also accepts: - -| Param | Type | Description | -|---|---|---| -| `onStepFinish` | `() => Promise` | Called after each reasoning step | - -## Message Format - -Messages follow the Anthropic SDK format: +**Multi-provider router** ```js -// Simple text -{ role: 'user', content: 'Hello' } +const { createRouter } = require('thebird'); + +const router = createRouter({ + Providers: [ + { name: 'deepseek', api_base_url: 'https://api.deepseek.com/chat/completions', api_key: process.env.DEEPSEEK_API_KEY, models: ['deepseek-chat', 'deepseek-reasoner'], transformer: { use: ['deepseek'] } }, + { name: 'gemini', api_base_url: 'https://generativelanguage.googleapis.com/v1beta/models/', api_key: process.env.GEMINI_API_KEY, models: ['gemini-2.5-pro'] }, + { name: 'ollama', api_base_url: 'http://localhost:11434/v1/chat/completions', api_key: 'ollama', models: ['qwen2.5-coder:latest'] }, + ], + Router: { + default: 'deepseek,deepseek-chat', + background: 'ollama,qwen2.5-coder:latest', + think: 'deepseek,deepseek-reasoner', + longContext: 'gemini,gemini-2.5-pro', + longContextThreshold: 60000, + } +}); -// Content blocks -{ role: 'user', content: [ - { type: 'text', text: 'What is in this image?' }, - { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: '...' } } -]} +// Stream β€” routes automatically based on taskType and token count +const { fullStream } = router.stream({ messages, taskType: 'think' }); +for await (const event of fullStream) { + if (event.type === 'text-delta') process.stdout.write(event.textDelta); +} -// Tool result (from assistant loop) -{ role: 'user', content: [ - { type: 'tool_result', name: 'my_tool', content: '{"result": 42}' } -]} +// Generate +const { text } = await router.generate({ messages }); ``` -## Vision / Images - -Four image formats are supported: +**File-based config** β€” place config at `~/.thebird/config.json` (or set `THEBIRD_CONFIG` env) and use the auto-loading shorthand: ```js -// 1. Anthropic SDK style β€” base64 -{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: '' } } +const { streamRouter, generateRouter } = require('thebird'); +const { fullStream } = streamRouter({ messages, taskType: 'background' }); +``` -// 2. Anthropic SDK style β€” URL -{ type: 'image', source: { type: 'url', url: 'https://...' } } +## Routing -// 3. Gemini native β€” inline base64 -{ inlineData: { mimeType: 'image/jpeg', data: '' } } +`createRouter` / `streamRouter` pick a provider+model per request: -// 4. Gemini native β€” file URI -{ fileData: { mimeType: 'image/jpeg', fileUri: 'gs://...' } } -``` +| Route key | Trigger | +|---|---| +| `default` | Any request not matched by another rule | +| `background` | `taskType: 'background'` | +| `think` | `taskType: 'think'` | +| `webSearch` | `taskType: 'webSearch'` | +| `image` | `taskType: 'image'` | +| `longContext` | Estimated token count > `longContextThreshold` (default 60 000) | +| subagent tag | First user message starts with `provider,model` | +| custom function | `customRouter: async (params, cfg) => 'provider,model'` in config | -Example: +Route values are `"providerName,modelName"` strings matching a `Providers` entry. -```js -const result = await generateGemini({ - model: 'gemini-2.0-flash', - messages: [{ - role: 'user', - content: [ - { type: 'image', source: { type: 'base64', media_type: 'image/png', data: base64 } }, - { type: 'text', text: 'Describe this image.' } - ] - }] -}); -``` - -## Tool Calling +## Transformers -Define tools as an object keyed by name. The `execute` function is called automatically during agentic loops. +Apply per-provider request/response transformations. Set on the provider's `transformer.use` array. -```js -const tools = { - get_weather: { - description: 'Get the weather for a city.', - parameters: { - type: 'object', - properties: { - city: { type: 'string' } - }, - required: ['city'] - }, - execute: async ({ city }) => ({ temperature: 22, condition: 'Sunny' }) +```json +{ + "name": "deepseek", + "transformer": { + "use": ["deepseek"], + "deepseek-chat": { "use": [["maxtoken", { "max_tokens": 8192 }], "tooluse"] } } -}; - -const { text } = await generateGemini({ - model: 'gemini-2.0-flash', - messages: [{ role: 'user', content: "What's the weather in Paris?" }], - tools -}); +} ``` -Tool schemas are automatically cleaned β€” `additionalProperties` and `$schema` are stripped for Gemini compatibility. +Built-in transformers: -## Streaming Events +| Name | Effect | +|---|---| +| `deepseek` | Strips `cache_control`, normalises system to string | +| `openrouter` | Adds `HTTP-Referer` / `X-Title` headers; optional `provider` routing | +| `maxtoken` | Sets `max_tokens` to the given value | +| `tooluse` | Adds `tool_choice: {type:"required"}` when tools are present | +| `cleancache` | Strips all `cache_control` fields recursively | +| `reasoning` | Moves `reasoning_content` to `_reasoning` in response | +| `sampling` | Removes `top_k` / `repetition_penalty` | +| `groq` | Removes `top_k` | -`fullStream` yields a sequence of typed events: +Pass options as a nested array: `["maxtoken", { "max_tokens": 16384 }]`. -| Event type | Fields | Description | -|---|---|---| -| `start-step` | β€” | Beginning of a reasoning step | -| `text-delta` | `textDelta: string` | Streamed text chunk | -| `tool-call` | `toolCallId, toolName, args` | Model called a tool | -| `tool-result` | `toolCallId, toolName, args, result` | Tool execution result | -| `finish-step` | `finishReason: 'stop' \| 'tool-calls' \| 'error'` | Step completed | -| `error` | `error: Error` | Error during step | +## Config File -```js -const { fullStream } = streamGemini({ model: 'gemini-2.0-flash', messages, tools }); +`~/.thebird/config.json` (or `THEBIRD_CONFIG` env var) β€” same schema as the inline config object. Supports `$VAR` / `${VAR}` environment variable interpolation anywhere in the file. -for await (const event of fullStream) { - switch (event.type) { - case 'text-delta': process.stdout.write(event.textDelta); break; - case 'tool-call': console.log('Calling tool:', event.toolName, event.args); break; - case 'tool-result': console.log('Result:', event.result); break; - case 'finish-step': console.log('Done, reason:', event.finishReason); break; - case 'error': console.error('Error:', event.error); break; - } +```json +{ + "Providers": [ + { "name": "openrouter", "api_base_url": "https://openrouter.ai/api/v1/chat/completions", "api_key": "$OPENROUTER_API_KEY", "models": ["google/gemini-2.5-pro-preview"], "transformer": { "use": ["openrouter"] } } + ], + "Router": { "default": "openrouter,google/gemini-2.5-pro-preview" } } ``` -## Safety Settings +## Gemini Direct API -```js -const { text } = await generateGemini({ - model: 'gemini-2.0-flash', - messages: [...], - safetySettings: [ - { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_ONLY_HIGH' }, - { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_ONLY_HIGH' } - ] -}); -``` +`streamGemini` / `generateGemini` bypass routing and call Gemini natively via `@google/genai`. Requires `GEMINI_API_KEY`. -## Retry Logic +### Params -All API calls automatically retry on 5xx errors and 429 rate limits with exponential backoff (max 3 retries, delays up to ~16 seconds). +| Param | Type | Default | Description | +|---|---|---|---| +| `model` | `string \| { id }` | `'gemini-2.0-flash'` | Model id | +| `messages` | `Message[]` | required | Conversation history | +| `system` | `string` | β€” | System instruction | +| `tools` | `Tools` | β€” | Tool definitions | +| `apiKey` | `string` | `GEMINI_API_KEY` | Override API key | +| `temperature` | `number` | `0.5` | Sampling temperature | +| `maxOutputTokens` | `number` | `8192` | Max tokens | +| `topP` | `number` | `0.95` | Top-p | +| `topK` | `number` | β€” | Top-k | +| `safetySettings` | `SafetySetting[]` | β€” | Safety thresholds | -```js -const { GeminiError } = require('thebird'); - -try { - const result = await generateGemini({ ... }); -} catch (err) { - if (err instanceof GeminiError) { - console.log('Status:', err.status); - console.log('Retryable:', err.retryable); - } -} -``` +## Message Format -## Error Handling +Messages follow the Anthropic SDK format. All image block variants are supported: ```js -const { GeminiError } = require('thebird'); - -// GeminiError properties: -// err.message β€” human-readable message -// err.status β€” HTTP status code (e.g. 429, 500) -// err.code β€” error code string if available -// err.retryable β€” whether automatic retry was attempted +{ role: 'user', content: [ + { type: 'text', text: 'Describe this image.' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: '...' } } +]} ``` -## TypeScript +## Streaming Events -Full types are bundled β€” no `@types/` package needed. +| Event | Fields | Description | +|---|---|---| +| `start-step` | β€” | Beginning of a reasoning step | +| `text-delta` | `textDelta` | Streamed text chunk | +| `tool-call` | `toolCallId, toolName, args` | Model invoked a tool | +| `tool-result` | `toolCallId, toolName, args, result` | Tool execution result | +| `finish-step` | `finishReason` | Step completed | +| `error` | `error` | Error during step | -```ts -import { generateGemini, streamGemini, GeminiError, Message, Tools, StreamEvent } from 'thebird'; +## TypeScript -const messages: Message[] = [{ role: 'user', content: 'Hello' }]; -const { text } = await generateGemini({ messages }); +```ts +import { createRouter, streamRouter, generateGemini, RouterConfiguration, ProviderConfig, RouterConfig } from 'thebird'; ``` ## Utilities ```js const { convertMessages, convertTools, cleanSchema } = require('thebird'); - -// Convert Anthropic messages to Gemini contents format -const contents = convertMessages(messages); - -// Convert tools map to Gemini function declarations array -const declarations = convertTools(tools); - -// Strip additionalProperties/$schema from a JSON schema -const cleaned = cleanSchema(rawSchema); ``` -## Examples - -See the [`examples/`](./examples/) directory: - -- [`basic-chat.js`](./examples/basic-chat.js) β€” Simple text generation with system prompt -- [`tool-use.js`](./examples/tool-use.js) β€” Tool/function calling (streaming and non-streaming) -- [`vision.js`](./examples/vision.js) β€” Image understanding with all three image formats -- [`streaming.js`](./examples/streaming.js) β€” All streaming event types with stats -- [`multi-turn.js`](./examples/multi-turn.js) β€” Multi-turn chat history pattern - ## License MIT diff --git a/index.d.ts b/index.d.ts index f9d4cde..f492d6c 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,60 +1,20 @@ -export interface TextBlock { - type: 'text'; - text: string; -} - -export interface ImageBlockBase64 { - type: 'image'; - source: { type: 'base64'; media_type: string; data: string }; -} - -export interface ImageBlockUrl { - type: 'image'; - source: { type: 'url'; url: string; media_type?: string }; -} - -export interface ImageBlockInline { - inlineData: { mimeType: string; data: string }; -} - -export interface ImageBlockFile { - fileData: { mimeType: string; fileUri: string }; -} - +export interface TextBlock { type: 'text'; text: string } +export interface ImageBlockBase64 { type: 'image'; source: { type: 'base64'; media_type: string; data: string } } +export interface ImageBlockUrl { type: 'image'; source: { type: 'url'; url: string; media_type?: string } } +export interface ImageBlockInline { inlineData: { mimeType: string; data: string } } +export interface ImageBlockFile { fileData: { mimeType: string; fileUri: string } } export type ImageBlock = ImageBlockBase64 | ImageBlockUrl | ImageBlockInline | ImageBlockFile; - -export interface ToolUseBlock { - type: 'tool_use'; - name: string; - input: Record; -} - -export interface ToolResultBlock { - type: 'tool_result'; - name: string; - content: string | Record; -} - +export interface ToolUseBlock { type: 'tool_use'; name: string; input: Record } +export interface ToolResultBlock { type: 'tool_result'; name: string; content: string | Record } export type ContentBlock = TextBlock | ImageBlock | ToolUseBlock | ToolResultBlock; - -export interface Message { - role: 'user' | 'assistant'; - content: string | ContentBlock[]; -} - +export interface Message { role: 'user' | 'assistant'; content: string | ContentBlock[] } export interface ToolDefinition { description?: string; parameters?: Record; execute?: (args: Record, ctx?: { toolCallId: string }) => Promise; } - export type Tools = Record; - -export interface SafetySetting { - category: string; - threshold: string; -} - +export interface SafetySetting { category: string; threshold: string } export interface GenerationParams { model?: string | { modelId?: string; id?: string }; system?: string; @@ -66,41 +26,57 @@ export interface GenerationParams { topP?: number; topK?: number; safetySettings?: SafetySetting[]; + configPath?: string; + taskType?: 'background' | 'think' | 'webSearch' | 'image'; } - -// --- streamGemini --- - export interface StartStepEvent { type: 'start-step' } export interface TextDeltaEvent { type: 'text-delta'; textDelta: string } export interface ToolCallEvent { type: 'tool-call'; toolCallId: string; toolName: string; args: Record } export interface ToolResultEvent { type: 'tool-result'; toolCallId: string; toolName: string; args: Record; result: unknown } export interface FinishStepEvent { type: 'finish-step'; finishReason: 'stop' | 'tool-calls' | 'error' } export interface ErrorEvent { type: 'error'; error: Error } - export type StreamEvent = StartStepEvent | TextDeltaEvent | ToolCallEvent | ToolResultEvent | FinishStepEvent | ErrorEvent; - -export interface StreamResult { - fullStream: AsyncIterable; - warnings: Promise; -} - -export interface StreamParams extends GenerationParams { - onStepFinish?: () => Promise | void; -} - +export interface StreamResult { fullStream: AsyncIterable; warnings: Promise } +export interface StreamParams extends GenerationParams { onStepFinish?: () => Promise | void } export function streamGemini(params: StreamParams): StreamResult; - -// --- generateGemini --- - -export interface GenerateResult { - text: string; - parts: unknown[]; - response: unknown; -} - +export interface GenerateResult { text: string; parts: unknown[]; response: unknown } export function generateGemini(params: GenerationParams): Promise; -// --- utilities --- +export type TransformerEntry = string | [string, Record]; +export interface TransformerConfig { + use?: TransformerEntry[]; + [modelName: string]: { use?: TransformerEntry[] } | TransformerEntry[] | undefined; +} +export interface ProviderConfig { + name: string; + api_base_url: string; + api_key: string; + models?: string[]; + transformer?: TransformerConfig; +} +export interface RouterConfig { + default?: string; + background?: string; + think?: string; + longContext?: string; + longContextThreshold?: number; + webSearch?: string; + image?: string; +} +export interface RouterConfiguration { + Providers?: ProviderConfig[]; + providers?: ProviderConfig[]; + Router?: RouterConfig; + customRouter?: (params: GenerationParams, config: RouterConfig) => Promise; + configPath?: string; +} +export interface RouterInstance { + stream(params: StreamParams): StreamResult; + generate(params: GenerationParams): Promise; +} +export function createRouter(config: RouterConfiguration): RouterInstance; +export function streamRouter(params: StreamParams & RouterConfiguration): StreamResult; +export function generateRouter(params: GenerationParams & RouterConfiguration): Promise; export interface GeminiPart { text?: string; @@ -109,16 +85,10 @@ export interface GeminiPart { inlineData?: { mimeType: string; data: string }; fileData?: { mimeType: string; fileUri: string }; } - -export interface GeminiContent { - role: 'user' | 'model'; - parts: GeminiPart[]; -} - +export interface GeminiContent { role: 'user' | 'model'; parts: GeminiPart[] } export function convertMessages(messages: Message[]): GeminiContent[]; export function convertTools(tools: Tools): Array<{ name: string; description: string; parameters: Record }>; export function cleanSchema(schema: unknown): unknown; - export class GeminiError extends Error { name: 'GeminiError'; status?: number; diff --git a/index.js b/index.js index ce15e55..1f1621d 100644 --- a/index.js +++ b/index.js @@ -1,40 +1,37 @@ const { getClient } = require('./lib/client'); const { GeminiError, withRetry } = require('./lib/errors'); const { convertMessages, convertTools, cleanSchema, extractModelId, buildConfig } = require('./lib/convert'); +const { loadConfig } = require('./lib/config'); +const { route } = require('./lib/router'); +const { resolveTransformers, applyRequestTransformers } = require('./lib/transformers'); +const openaiProv = require('./lib/providers/openai'); function streamGemini({ model, system, messages, tools, onStepFinish, apiKey, temperature, maxOutputTokens, topP, topK, safetySettings }) { return { - fullStream: createFullStream({ model, system, messages, tools, onStepFinish, apiKey, - temperature, maxOutputTokens, topP, topK, safetySettings }), + fullStream: createFullStream({ model, system, messages, tools, onStepFinish, apiKey, temperature, maxOutputTokens, topP, topK, safetySettings }), warnings: Promise.resolve([]) }; } -async function* createFullStream({ model, system, messages, tools, onStepFinish, apiKey, - temperature, maxOutputTokens, topP, topK, safetySettings }) { +async function* createFullStream({ model, system, messages, tools, onStepFinish, apiKey, temperature, maxOutputTokens, topP, topK, safetySettings }) { const client = getClient(apiKey); const modelId = extractModelId(model); let contents = convertMessages(messages); const { config } = buildConfig({ system, tools, temperature, maxOutputTokens, topP, topK, safetySettings }); - while (true) { yield { type: 'start-step' }; try { - const allParts = await withRetry(async () => { - const stream = client.models.generateContentStream({ model: modelId, contents, config }); - const parts = []; - for await (const chunk of await stream) { - for (const candidate of (chunk.candidates || [])) { - for (const part of (candidate.content?.parts || [])) { - parts.push(part); - if (part.text && !part.thought) yield { type: 'text-delta', textDelta: part.text }; - } + const stream = await withRetry(() => client.models.generateContentStream({ model: modelId, contents, config })); + const allParts = []; + for await (const chunk of stream) { + for (const candidate of (chunk.candidates || [])) { + for (const part of (candidate.content?.parts || [])) { + allParts.push(part); + if (part.text && !part.thought) yield { type: 'text-delta', textDelta: part.text }; } } - return parts; - }); - + } const fcParts = allParts.filter(p => p.functionCall); if (fcParts.length === 0) { yield { type: 'finish-step', finishReason: 'stop' }; @@ -69,29 +66,22 @@ async function* createFullStream({ model, system, messages, tools, onStepFinish, } } -async function generateGemini({ model, system, messages, tools, apiKey, - temperature, maxOutputTokens, topP, topK, safetySettings }) { +async function generateGemini({ model, system, messages, tools, apiKey, temperature, maxOutputTokens, topP, topK, safetySettings }) { const client = getClient(apiKey); const modelId = extractModelId(model); let contents = convertMessages(messages); const { config } = buildConfig({ system, tools, temperature, maxOutputTokens, topP, topK, safetySettings }); - while (true) { - const response = await withRetry(() => - client.models.generateContent({ model: modelId, contents, config }) - ); + const response = await withRetry(() => client.models.generateContent({ model: modelId, contents, config })); const candidate = response.candidates?.[0]; if (!candidate) throw new GeminiError('No candidates returned', { retryable: false }); const allParts = candidate.content?.parts || []; const fcParts = allParts.filter(p => p.functionCall); - if (fcParts.length === 0) { const text = allParts.filter(p => p.text && !p.thought).map(p => p.text).join(''); return { text, parts: allParts, response }; } - const toolResultParts = []; - const toolResults = []; for (const part of fcParts) { const name = part.functionCall.name; const args = part.functionCall.args || {}; @@ -101,7 +91,6 @@ async function generateGemini({ model, system, messages, tools, apiKey, try { result = await toolDef.execute(args); } catch (e) { result = { error: true, message: e.message }; } } - toolResults.push({ name, args, result }); toolResultParts.push({ functionResponse: { name, response: result || {} } }); } contents.push({ role: 'model', parts: allParts }); @@ -109,4 +98,76 @@ async function generateGemini({ model, system, messages, tools, apiKey, } } -module.exports = { streamGemini, generateGemini, convertMessages, convertTools, cleanSchema, GeminiError }; +function isGeminiProvider(p) { + return p.name === 'gemini' || (p.api_base_url || '').includes('generativelanguage.googleapis.com'); +} + +function findProvider(providers, providerName, modelName) { + if (providerName) return providers.find(p => p.name === providerName); + if (modelName) return providers.find(p => (p.models || []).includes(modelName)); + return providers[0]; +} + +function buildOpenAIUrl(base) { + const clean = (base || '').replace(/\/$/g, ''); + return clean.includes('/completions') ? clean : clean + '/chat/completions'; +} + +function resolveForProvider(provider, model, customMap) { + const useList = provider.transformer?.[model]?.use || provider.transformer?.use || []; + return resolveTransformers(useList, customMap); +} + +async function* routerStream(params, resolver) { + const { provider, actualModel, transformers } = await resolver(params); + if (isGeminiProvider(provider)) { + yield* createFullStream({ ...params, model: actualModel, apiKey: provider.api_key || params.apiKey }); + } else { + const oaiMsgs = openaiProv.convertMessages(params.messages, params.system); + const oaiTools = openaiProv.convertTools(params.tools); + let req = { messages: oaiMsgs, model: actualModel, max_tokens: params.maxOutputTokens || 8192, temperature: params.temperature ?? 0.5 }; + if (oaiTools) req.tools = oaiTools; + req = applyRequestTransformers(req, transformers); + yield* openaiProv.streamOpenAI({ url: buildOpenAIUrl(provider.api_base_url), apiKey: provider.api_key, headers: req._extraHeaders, body: req, tools: params.tools, onStepFinish: params.onStepFinish }); + } +} + +function createRouter(config) { + const providers = config.Providers || config.providers || []; + const routerCfg = config.Router || {}; + async function resolve(params) { + const { providerName, modelName } = await route(params, routerCfg, config.customRouter); + const provider = findProvider(providers, providerName, modelName) || providers[0]; + if (!provider) throw new Error('[thebird] no provider configured'); + const actualModel = modelName || (provider.models || [])[0] || extractModelId(params.model) || 'gemini-2.0-flash'; + const transformers = resolveForProvider(provider, actualModel, config._transformers); + return { provider, actualModel, transformers }; + } + return { + stream(params) { return { fullStream: routerStream(params, resolve), warnings: Promise.resolve([]) }; }, + async generate(params) { + const { provider, actualModel, transformers } = await resolve(params); + if (isGeminiProvider(provider)) return generateGemini({ ...params, model: actualModel, apiKey: provider.api_key || params.apiKey }); + const oaiMsgs = openaiProv.convertMessages(params.messages, params.system); + const oaiTools = openaiProv.convertTools(params.tools); + let req = { messages: oaiMsgs, model: actualModel, max_tokens: params.maxOutputTokens || 8192, temperature: params.temperature ?? 0.5 }; + if (oaiTools) req.tools = oaiTools; + req = applyRequestTransformers(req, transformers); + return openaiProv.generateOpenAI({ url: buildOpenAIUrl(provider.api_base_url), apiKey: provider.api_key, headers: req._extraHeaders, body: req, tools: params.tools }); + } + }; +} + +function streamRouter(params) { + const config = loadConfig(params.configPath); + if (!(config.Providers || config.providers)?.length) return streamGemini(params); + return createRouter(config).stream(params); +} + +async function generateRouter(params) { + const config = loadConfig(params.configPath); + if (!(config.Providers || config.providers)?.length) return generateGemini(params); + return createRouter(config).generate(params); +} + +module.exports = { streamGemini, generateGemini, streamRouter, generateRouter, createRouter, convertMessages, convertTools, cleanSchema, GeminiError }; diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..f643beb --- /dev/null +++ b/lib/config.js @@ -0,0 +1,24 @@ +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +function interpolateEnv(val) { + if (typeof val === 'string') return val.replace(/\$\{([^}]+)\}|\$([A-Z_][A-Z0-9_]*)/g, (_, a, b) => process.env[a || b] || ''); + if (Array.isArray(val)) return val.map(interpolateEnv); + if (val && typeof val === 'object') { + const out = {}; + for (const [k, v] of Object.entries(val)) out[k] = interpolateEnv(v); + return out; + } + return val; +} + +function loadConfig(configPath) { + const fp = configPath || process.env.THEBIRD_CONFIG || path.join(os.homedir(), '.thebird', 'config.json'); + try { + const raw = JSON.parse(fs.readFileSync(fp, 'utf8')); + return interpolateEnv(raw); + } catch { return {}; } +} + +module.exports = { loadConfig, interpolateEnv }; diff --git a/lib/providers/openai.js b/lib/providers/openai.js new file mode 100644 index 0000000..d2deb7e --- /dev/null +++ b/lib/providers/openai.js @@ -0,0 +1,127 @@ +const { GeminiError } = require('../errors'); + +function convertMessages(messages, system) { + const result = []; + if (system) result.push({ role: 'system', content: typeof system === 'string' ? system : JSON.stringify(system) }); + for (const m of messages) { + if (typeof m.content === 'string') { result.push({ role: m.role, content: m.content }); continue; } + if (!Array.isArray(m.content)) continue; + const toolCalls = m.content.filter(b => b.type === 'tool_use'); + const toolResults = m.content.filter(b => b.type === 'tool_result'); + if (toolResults.length) { + for (const b of toolResults) { + const c = typeof b.content === 'string' ? b.content : JSON.stringify(b.content || ''); + result.push({ role: 'tool', tool_call_id: b.tool_use_id || b.id || b.name, content: c }); + } + continue; + } + const textParts = m.content.filter(b => b.type === 'text').map(b => b.text).join(''); + if (toolCalls.length) { + result.push({ role: 'assistant', content: textParts || null, + tool_calls: toolCalls.map(b => ({ id: b.id || ('call_' + Math.random().toString(36).slice(2,8)), type: 'function', + function: { name: b.name, arguments: JSON.stringify(b.input || {}) } })) }); + } else { + result.push({ role: m.role, content: textParts }); + } + } + return result; +} + +function convertTools(tools) { + if (!tools || typeof tools !== 'object') return undefined; + const list = Object.entries(tools).map(([name, t]) => ({ + type: 'function', function: { name, description: t.description || '', + parameters: t.parameters?.jsonSchema || t.parameters || { type: 'object' } } + })); + return list.length ? list : undefined; +} + +async function callOpenAI({ url, apiKey, headers, body }) { + const res = await fetch(url, { method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, ...(headers || {}) }, + body: JSON.stringify(body) }); + if (!res.ok) { const t = await res.text(); throw new GeminiError(t, { status: res.status, retryable: res.status === 429 || res.status >= 500 }); } + return res; +} + +async function* streamOpenAI({ url, apiKey, headers, body, tools, onStepFinish }) { + while (true) { + yield { type: 'start-step' }; + const res = await callOpenAI({ url, apiKey, headers, body: { ...body, stream: true } }); + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = '', toolCallsMap = {}; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop(); + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const d = line.slice(6).trim(); + if (d === '[DONE]') break; + let chunk; try { chunk = JSON.parse(d); } catch { continue; } + const delta = chunk.choices?.[0]?.delta; + if (!delta) continue; + if (delta.content) yield { type: 'text-delta', textDelta: delta.content }; + if (delta.tool_calls) { + for (const tc of delta.tool_calls) { + const idx = tc.index ?? 0; + if (!toolCallsMap[idx]) toolCallsMap[idx] = { id: tc.id || '', name: '', args: '' }; + if (tc.id) toolCallsMap[idx].id = tc.id; + if (tc.function?.name) toolCallsMap[idx].name += tc.function.name; + if (tc.function?.arguments) toolCallsMap[idx].args += tc.function.arguments; + } + } + } + } + } finally { reader.releaseLock(); } + + const pending = Object.values(toolCallsMap); + if (!pending.length) { + yield { type: 'finish-step', finishReason: 'stop' }; + if (onStepFinish) await onStepFinish(); + return; + } + const toolResultMsgs = []; + for (const tc of pending) { + let args; try { args = JSON.parse(tc.args || '{}'); } catch { args = {}; } + const toolDef = tools?.[tc.name]; + let result = toolDef ? null : { error: true, message: 'Tool not found: ' + tc.name }; + if (toolDef?.execute) try { result = await toolDef.execute(args, { toolCallId: tc.id }); } catch(e) { result = { error: true, message: e.message }; } + yield { type: 'tool-call', toolCallId: tc.id, toolName: tc.name, args }; + yield { type: 'tool-result', toolCallId: tc.id, toolName: tc.name, args, result }; + toolResultMsgs.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result ?? '') }); + } + yield { type: 'finish-step', finishReason: 'tool-calls' }; + if (onStepFinish) await onStepFinish(); + body = { ...body, messages: [...body.messages, + { role: 'assistant', content: null, tool_calls: pending.map(tc => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: tc.args } })) }, + ...toolResultMsgs + ]}; + toolCallsMap = {}; + } +} + +async function generateOpenAI({ url, apiKey, headers, body, tools }) { + while (true) { + const res = await callOpenAI({ url, apiKey, headers, body: { ...body, stream: false } }); + const data = await res.json(); + const msg = data.choices?.[0]?.message; + if (!msg) throw new GeminiError('No message in response', { retryable: false }); + if (!msg.tool_calls?.length) return { text: msg.content || '', response: data }; + const toolResultMsgs = []; + for (const tc of msg.tool_calls) { + let args; try { args = JSON.parse(tc.function?.arguments || '{}'); } catch { args = {}; } + const toolDef = tools?.[tc.function?.name]; + let result = toolDef ? null : { error: true, message: 'Tool not found: ' + tc.function?.name }; + if (toolDef?.execute) try { result = await toolDef.execute(args); } catch(e) { result = { error: true, message: e.message }; } + toolResultMsgs.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result ?? '') }); + } + body = { ...body, messages: [...body.messages, msg, ...toolResultMsgs] }; + } +} + +module.exports = { streamOpenAI, generateOpenAI, convertMessages, convertTools }; diff --git a/lib/router.js b/lib/router.js new file mode 100644 index 0000000..692d058 --- /dev/null +++ b/lib/router.js @@ -0,0 +1,51 @@ +const { loadConfig } = require('./config'); + +const SUBAGENT_RE = /([^<]+)<\/CCR-SUBAGENT-MODEL>/; + +function estimateTokens(messages, system) { + let chars = typeof system === 'string' ? system.length : (system ? JSON.stringify(system).length : 0); + for (const m of (messages || [])) { + chars += typeof m.content === 'string' ? m.content.length : JSON.stringify(m.content || '').length; + } + return Math.ceil(chars / 4); +} + +function extractSubagentModel(messages) { + const first = messages?.[0]; + if (!first) return null; + const text = typeof first.content === 'string' ? first.content : + (Array.isArray(first.content) ? first.content.map(b => b.text || '').join('') : ''); + const m = SUBAGENT_RE.exec(text); + return m ? m[1].trim() : null; +} + +function parseProviderModel(str) { + const idx = str.indexOf(','); + if (idx === -1) return { providerName: null, modelName: str }; + return { providerName: str.slice(0, idx), modelName: str.slice(idx + 1) }; +} + +async function route(params, routerCfg, customRouterFn) { + const { messages, system, taskType } = params; + + if (customRouterFn) { + const custom = await customRouterFn(params, routerCfg); + if (custom) return parseProviderModel(custom); + } + + const subagent = extractSubagentModel(messages); + if (subagent) return parseProviderModel(subagent); + + if (taskType === 'background' && routerCfg.background) return parseProviderModel(routerCfg.background); + if (taskType === 'think' && routerCfg.think) return parseProviderModel(routerCfg.think); + if (taskType === 'webSearch' && routerCfg.webSearch) return parseProviderModel(routerCfg.webSearch); + if (taskType === 'image' && routerCfg.image) return parseProviderModel(routerCfg.image); + + const threshold = routerCfg.longContextThreshold || 60000; + if (routerCfg.longContext && estimateTokens(messages, system) > threshold) return parseProviderModel(routerCfg.longContext); + + if (routerCfg.default) return parseProviderModel(routerCfg.default); + return { providerName: null, modelName: null }; +} + +module.exports = { route, estimateTokens, parseProviderModel }; diff --git a/lib/transformers.js b/lib/transformers.js new file mode 100644 index 0000000..96b3994 --- /dev/null +++ b/lib/transformers.js @@ -0,0 +1,93 @@ +function removeCacheControl(obj) { + if (!obj || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(removeCacheControl); + const out = {}; + for (const [k, v] of Object.entries(obj)) { + if (k === 'cache_control') continue; + out[k] = removeCacheControl(v); + } + return out; +} + +const BUILT_IN = { + cleancache: { + request(req) { return { ...req, messages: removeCacheControl(req.messages), system: removeCacheControl(req.system) }; } + }, + deepseek: { + request(req) { + const r = removeCacheControl(req); + if (r.system && typeof r.system !== 'string') { + r.system = (Array.isArray(r.system) ? r.system : [r.system]).map(b => b.text || '').join('\n'); + } + return r; + } + }, + openrouter: { + options: {}, + request(req, opts) { + const headers = { 'HTTP-Referer': 'https://github.com/AnEntrypoint/thebird', 'X-Title': 'thebird', ...(opts || {}).headers }; + if ((opts || {}).provider) req = { ...req, provider: (opts || {}).provider }; + return { ...req, _extraHeaders: { ...(req._extraHeaders || {}), ...headers } }; + } + }, + maxtoken: { + request(req, opts) { return { ...req, max_tokens: (opts || {}).max_tokens || req.max_tokens }; } + }, + tooluse: { + request(req) { + if (req.tools && req.tools.length > 0) return { ...req, tool_choice: { type: 'required' } }; + return req; + } + }, + reasoning: { + request(req) { return req; }, + response(res) { + if (!res.choices) return res; + return { + ...res, + choices: res.choices.map(c => { + if (!c.message) return c; + const msg = { ...c.message }; + if (msg.reasoning_content) { msg._reasoning = msg.reasoning_content; delete msg.reasoning_content; } + return { ...c, message: msg }; + }) + }; + } + }, + sampling: { + request(req) { + const r = { ...req }; + delete r.top_k; + delete r.repetition_penalty; + return r; + } + }, + groq: { + request(req) { + const r = { ...req }; + delete r.top_k; + return r; + } + } +}; + +function resolveTransformers(useList, customMap) { + if (!useList) return []; + return useList.map(entry => { + const name = Array.isArray(entry) ? entry[0] : entry; + const opts = Array.isArray(entry) ? entry[1] : undefined; + const t = (customMap && customMap[name]) || BUILT_IN[name]; + if (!t) { console.warn('[thebird] unknown transformer:', name); return null; } + return { transformer: t, opts }; + }).filter(Boolean); +} + +function applyRequestTransformers(req, transformers) { + return transformers.reduce((r, { transformer, opts }) => transformer.request ? transformer.request(r, opts) : r, req); +} + +function applyResponseTransformers(res, transformers) { + return transformers.reduce((r, { transformer, opts }) => transformer.response ? transformer.response(r, opts) : r, res); +} + +module.exports = { resolveTransformers, applyRequestTransformers, applyResponseTransformers, BUILT_IN }; diff --git a/package.json b/package.json index 52bffd8..c9d98bd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "thebird", - "version": "1.1.0", + "version": "1.2.0", "description": "Anthropic SDK to Gemini streaming bridge β€” drop-in proxy that translates Anthropic message format and tool calls to Google Gemini", "main": "index.js", "types": "index.d.ts", @@ -12,7 +12,22 @@ "default": "./index.js" } }, - "keywords": ["anthropic", "gemini", "google", "ai", "streaming", "proxy", "bridge", "tool-use", "vision", "multimodal"], + "keywords": [ + "anthropic", + "gemini", + "google", + "ai", + "streaming", + "proxy", + "bridge", + "tool-use", + "vision", + "multimodal", + "router", + "openai", + "deepseek", + "multi-provider" + ], "author": "AnEntrypoint", "license": "MIT", "repository": {