diff --git a/.env.example b/.env.example index bbc8982..c5282e8 100644 --- a/.env.example +++ b/.env.example @@ -234,6 +234,16 @@ ZAI_MODEL=GLM-4.7 # DESCRIPTION: Max concurrent in-flight Z.AI requests. ZAI_MAX_CONCURRENT=2 +# ------------------------------------------------------------------------------ +# Baidu Qianfan (ERNIE) +# ------------------------------------------------------------------------------ +# DESCRIPTION: Baidu Qianfan API key (format: bce-v3/ALTAK-...). +# BAIDU_API_KEY=your-baidu-qianfan-api-key +# DESCRIPTION: Qianfan v2 OpenAI-compatible chat completions endpoint. +BAIDU_ENDPOINT=https://qianfan.baidubce.com/v2/chat/completions +# DESCRIPTION: Default Baidu ERNIE model. +BAIDU_MODEL=ernie-4.5-turbo-128k + # ------------------------------------------------------------------------------ # Codex (uses your ChatGPT subscription via local codex CLI) # ------------------------------------------------------------------------------ diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 130aacd..da4d7de 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -27,37 +27,7 @@ This directory contains GitHub Actions workflows for automated testing and CI/CD --- -### 2. Web Tools Tests (`web-tools-tests.yml`) - -**Purpose:** Run web search tool tests when related files change - -**Triggers:** -- Changes to web tools source files: - - `src/tools/web.js` - - `src/tools/web-client.js` - - `src/clients/retry.js` - - `src/config/index.js` - - `test/web-tools.test.js` - -**What it does:** -- Runs only the web tools test suite -- Generates test summary in GitHub Actions UI -- Faster feedback for web tools changes - -**Test Coverage:** -- HTML extraction (9 tests) -- HTTP keep-alive agent (2 tests) -- Retry logic with exponential backoff (2 tests) -- Configuration management (3 tests) -- Error handling (1 test) -- Performance validation (1 test) -- Body preview configuration (1 test) - -**Total:** 19 tests - ---- - -### 3. NPM Publish (`npm-publish.yml`) +### 2. NPM Publish (`npm-publish.yml`) **Purpose:** Automatically publish package to npm registry @@ -81,7 +51,7 @@ This directory contains GitHub Actions workflows for automated testing and CI/CD --- -### 4. Version Bump (`version-bump.yml`) +### 3. Version Bump (`version-bump.yml`) **Purpose:** Manual workflow to bump version and create releases @@ -104,7 +74,7 @@ This directory contains GitHub Actions workflows for automated testing and CI/CD --- -### 5. IndexNow Notification (`index.yml`) +### 4. IndexNow Notification (`index.yml`) **Purpose:** Notify search engines when documentation is updated @@ -124,7 +94,6 @@ Add these badges to your README.md: ```markdown ![CI Tests](https://github.com/vishalveerareddy123/Lynkr/actions/workflows/ci.yml/badge.svg) -![Web Tools Tests](https://github.com/vishalveerareddy123/Lynkr/actions/workflows/web-tools-tests.yml/badge.svg) ![npm version](https://img.shields.io/npm/v/lynkr.svg) ![npm downloads](https://img.shields.io/npm/dt/lynkr.svg) ``` @@ -137,10 +106,6 @@ Before pushing, run tests locally: # Run all unit tests npm run test:unit -# Run only web tools tests -DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com \ - node --test test/web-tools.test.js - # Run quick tests (routing only) npm run test:quick diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78f62f4..6dc9ca5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,6 @@ jobs: node --test \ test/routing.test.js \ test/hybrid-routing-integration.test.js \ - test/web-tools.test.js \ test/passthrough-mode.test.js \ test/openrouter-error-resilience.test.js \ test/format-conversion.test.js \ @@ -53,6 +52,5 @@ jobs: test/telemetry-routing.test.js \ test/distill.test.js \ test/large-payload.test.js \ - test/code-mode.test.js \ test/prompt-cache-injection.test.js timeout-minutes: 5 diff --git a/.github/workflows/web-tools-tests.yml b/.github/workflows/web-tools-tests.yml deleted file mode 100644 index bfce513..0000000 --- a/.github/workflows/web-tools-tests.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: Web Tools Tests - -on: - push: - paths: - - 'src/tools/web.js' - - 'src/tools/web-client.js' - - 'src/clients/retry.js' - - 'src/config/index.js' - - 'test/web-tools.test.js' - - '.github/workflows/web-tools-tests.yml' - pull_request: - paths: - - 'src/tools/web.js' - - 'src/tools/web-client.js' - - 'src/clients/retry.js' - - 'src/config/index.js' - - 'test/web-tools.test.js' - -jobs: - web-tools-test: - name: Web Tools Test Suite - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20.x' - cache: 'npm' - - - name: Install dependencies - run: npm ci - - - name: Run web tools tests - run: | - DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com \ - node --test test/web-tools.test.js - - - name: Test results summary - if: always() - run: | - echo "## Web Tools Test Results" >> $GITHUB_STEP_SUMMARY - echo "✅ All web tools tests passed!" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Coverage:" >> $GITHUB_STEP_SUMMARY - echo "- HTML extraction (9 tests)" >> $GITHUB_STEP_SUMMARY - echo "- HTTP keep-alive agent (2 tests)" >> $GITHUB_STEP_SUMMARY - echo "- Retry logic with exponential backoff (2 tests)" >> $GITHUB_STEP_SUMMARY - echo "- Configuration management (3 tests)" >> $GITHUB_STEP_SUMMARY - echo "- Error handling (1 test)" >> $GITHUB_STEP_SUMMARY - echo "- Performance validation (1 test)" >> $GITHUB_STEP_SUMMARY - echo "- Body preview configuration (1 test)" >> $GITHUB_STEP_SUMMARY diff --git a/README.md b/README.md index 8c80385..79811fd 100644 --- a/README.md +++ b/README.md @@ -218,8 +218,11 @@ Claude Code / Cursor / Codex / Cline / Continue | **OpenAI** | Cloud | GPT-4o, o3-mini | $$$ | | **Atlas Cloud** | Cloud | Qwen, DeepSeek, and other OpenAI-compatible models | $-$$$ | | **DeepSeek** | Cloud | DeepSeek R1, Reasoner | $ | +| **Z.ai** | Cloud | GLM-4.7, GLM-4.5-Air | $ | +| **Moonshot AI** | Cloud | Kimi K2.6, Kimi K3 | $ | +| **Baidu Qianfan** | Cloud | ERNIE 4.5 Turbo, ERNIE X1.1 | $ (unverified — not yet probed against a live key) | -**4 local providers** for 100% offline, free usage. **11+ cloud providers** for scale. +**4 local providers** for 100% offline, free usage. **14+ cloud providers** for scale.
diff --git a/bin/lynkr-init.js b/bin/lynkr-init.js index ca98a37..0280d01 100644 --- a/bin/lynkr-init.js +++ b/bin/lynkr-init.js @@ -170,12 +170,21 @@ const PROVIDERS = { extras: [], defaultModel: 'kimi-k2-turbo-preview', }, + baidu: { + label: 'Baidu Qianfan (ERNIE)', + local: false, + creds: [ + { key: 'BAIDU_API_KEY', label: 'Baidu Qianfan API key (bce-v3/ALTAK-...)', secret: true }, + ], + extras: [], + defaultModel: 'ernie-4.5-turbo-128k', + }, }; const PROVIDER_ORDER = [ 'ollama', 'llamacpp', 'lmstudio', 'azure-anthropic', 'azure-openai', 'openai', 'atlas', 'openrouter', 'edenai', - 'databricks', 'bedrock', 'vertex', 'zai', 'moonshot', + 'databricks', 'bedrock', 'vertex', 'zai', 'moonshot', 'baidu', ]; const TIERS = ['SIMPLE', 'MEDIUM', 'COMPLEX', 'REASONING']; @@ -330,6 +339,9 @@ const BASELINE_ENV = { MOONSHOT_API_KEY: '', MOONSHOT_ENDPOINT: 'https://api.moonshot.ai/v1/chat/completions', MOONSHOT_MODEL: 'kimi-k2.6', + BAIDU_API_KEY: '', + BAIDU_ENDPOINT: 'https://qianfan.baidubce.com/v2/chat/completions', + BAIDU_MODEL: 'ernie-4.5-turbo-128k', LLAMACPP_ENDPOINT: 'http://localhost:8080', LLAMACPP_MODEL: 'default', LLAMACPP_TIMEOUT_MS: '120000', diff --git a/documentation/providers.md b/documentation/providers.md index 5b038e5..b593405 100644 --- a/documentation/providers.md +++ b/documentation/providers.md @@ -963,6 +963,69 @@ curl -X POST http://localhost:8000/v1/chat/completions -H "Content-Type: applica --- +### 12. Baidu Qianfan / ERNIE (OpenAI-Compatible) + +**Best for:** Chinese-market deployments, ERNIE reasoning models, OpenAI-compatible API + +> **Note:** model roster and sampling-param behavior below are best-effort from public docs and have not yet been probed against a live key — treat as a starting point, not a confirmed-stable config. + +#### Configuration + +```env +MODEL_PROVIDER=baidu +BAIDU_API_KEY=bce-v3/ALTAK-your-baidu-qianfan-api-key +BAIDU_ENDPOINT=https://qianfan.baidubce.com/v2/chat/completions +BAIDU_MODEL=ernie-4.5-turbo-128k +``` + +#### Getting a Baidu Qianfan API Key + +1. Visit [Baidu AI Cloud Qianfan console](https://console.bce.baidu.com/qianfan/) +2. Sign up or log in (Baidu Cloud account) +3. Create an API key under the v2 / OpenAI-compatible access section — keys are formatted `bce-v3/ALTAK-...` +4. Add credits to your account + +#### Available Models + +```env +BAIDU_MODEL=ernie-4.5-turbo-128k # General-purpose, 128K context (recommended default) +BAIDU_MODEL=ernie-x1.1 # Reasoning model +BAIDU_MODEL=ernie-speed-8k # Smaller/cheaper, fast responses +``` + +#### How It Works + +Qianfan's v2 endpoint is an **OpenAI-compatible** chat completions API. Lynkr handles all format conversion automatically: + +1. Claude Code CLI sends Anthropic-format request to Lynkr +2. Lynkr converts Anthropic messages → OpenAI chat completions format +3. Request is sent to Qianfan's `/v2/chat/completions` endpoint +4. Qianfan response is converted back to Anthropic format +5. Claude Code CLI receives a standard Anthropic response + +#### Important Notes + +- **Streaming:** Buffered only for now — `baidu` is not yet in the SSE-transform provider list, pending confirmation that Qianfan's stream shape matches OpenAI's exactly. +- **Tool Calling:** Tool calling support via OpenAI function calling format (automatically converted from Anthropic format) — not yet exercised against a live key. +- **System Messages:** Qianfan's v2 endpoint supports the `system` role natively. + +#### Benefits + +- ✅ **OpenAI-compatible** — Standard chat completions API, drop-in wiring +- ✅ **Reasoning model available** — `ernie-x1.1` +- ✅ **System role support** — Native system message handling + +#### Test Connection + +```bash +curl -X POST https://qianfan.baidubce.com/v2/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $BAIDU_API_KEY" \ + -d '{"model":"ernie-4.5-turbo-128k","messages":[{"role":"user","content":"Hello"}]}' +``` + +--- + ## Tier-Based Routing & Fallback ### Intelligent 4-Tier Routing @@ -1025,7 +1088,7 @@ AZURE_OPENAI_API_KEY=your-key | Variable | Description | Default | |----------|-------------|---------| -| `MODEL_PROVIDER` | Primary provider (`databricks`, `bedrock`, `openrouter`, `ollama`, `llamacpp`, `azure-openai`, `azure-anthropic`, `openai`, `lmstudio`, `zai`, `moonshot`, `vertex`) | `databricks` | +| `MODEL_PROVIDER` | Primary provider (`databricks`, `bedrock`, `openrouter`, `ollama`, `llamacpp`, `azure-openai`, `azure-anthropic`, `openai`, `lmstudio`, `zai`, `moonshot`, `vertex`, `baidu`) | `databricks` | | `PORT` | HTTP port for proxy server | `8081` | | `WORKSPACE_ROOT` | Workspace directory path | `process.cwd()` | | `LOG_LEVEL` | Logging level (`error`, `warn`, `info`, `debug`) | `info` | @@ -1071,6 +1134,7 @@ _* Tool calling only supported by Claude models on Bedrock_ | **OpenAI** | GPT-4o | $2.50 | $10.00 | | **Azure OpenAI** | GPT-4o | $2.50 | $10.00 | | **Moonshot** | Kimi K2 Turbo | See moonshot.ai | See moonshot.ai | +| **Baidu Qianfan** | ERNIE 4.5 Turbo | See qianfan.baidubce.com | See qianfan.baidubce.com | | **Ollama** | Any model | **FREE** | **FREE** | | **llama.cpp** | Any model | **FREE** | **FREE** | | **LM Studio** | Any model | **FREE** | **FREE** | diff --git a/package.json b/package.json index e00c7ed..f53c0eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lynkr", - "version": "9.10.0", + "version": "9.12.0", "files": [ "index.js", "install.sh", @@ -37,7 +37,7 @@ "dev": "nodemon index.js", "lint": "eslint src index.js", "test": "npm run test:unit && npm run test:performance", - "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js", + "test:unit": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com LOG_FILE_ENABLED=false node --test test/routing.test.js test/hybrid-routing-integration.test.js test/retry-logic.test.js test/sse-transformer.test.js test/passthrough-stream.test.js test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js test/azure-openai-config.test.js test/azure-openai-format-conversion.test.js test/azure-openai-routing.test.js test/azure-openai-streaming.test.js test/azure-openai-error-resilience.test.js test/azure-openai-integration.test.js test/openai-integration.test.js test/atlas-integration.test.js test/toon-compression.test.js test/gcf-compression.test.js test/llamacpp-integration.test.js test/resilience.test.js test/telemetry-routing.test.js test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js test/distill.test.js test/large-payload.test.js test/prompt-cache-injection.test.js test/risk-analyzer.test.js test/interaction-block.test.js test/preflight.test.js test/token-reduction.test.js test/session-affinity.test.js test/cache-state.test.js test/cache-switch-cost.test.js test/lens-recommendations.test.js test/model-registry-cost.test.js test/output-format-guard.test.js test/tier-fallback.test.js test/wrap.test.js test/init.test.js test/tool-call-response-metadata.test.js test/degradation.test.js test/routing-telemetry-columns.test.js test/sticky-routing.test.js test/knn-ambiguous-escalate.test.js test/deescalator.test.js test/client-profiles.test.js test/strip-internal-fields.test.js test/complexity-tool-subtraction.test.js test/bandit.test.js test/routing-propensity.test.js test/reward-pipeline.test.js test/knn-cold-start.test.js test/calibration.test.js test/feedback-loop.test.js test/session-fingerprint.test.js test/side-request-guards.test.js test/verifier.test.js test/intent-score.test.js test/difficulty-classifier.test.js test/classifier-setup.test.js test/usage-stats.test.js test/loop-guard.test.js test/moonshot-model-mapping.test.js test/baidu-model-mapping.test.js", "test:memory": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/memory/store.test.js test/memory/surprise.test.js test/memory/extractor.test.js test/memory/search.test.js test/memory/retriever.test.js test/memory/distiller.test.js test/memory/distiller-freeze.test.js test/memory/wiki.test.js test/memory/skills-cache.test.js test/memory/tencentdb-launcher.test.js", "test:new-features": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node --test test/passthrough-mode.test.js test/openrouter-error-resilience.test.js test/format-conversion.test.js", "test:performance": "DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/hybrid-routing-performance.test.js && DATABRICKS_API_KEY=test-key DATABRICKS_API_BASE=http://test.com node test/performance-tests.js", diff --git a/src/api/openai-router.js b/src/api/openai-router.js index db86e84..c65f8d7 100644 --- a/src/api/openai-router.js +++ b/src/api/openai-router.js @@ -970,6 +970,18 @@ function getConfiguredProviders() { }); } + if (config.baidu?.apiKey) { + providers.push({ + name: "baidu", + type: "baidu-qianfan", + models: [ + config.baidu.model || "ernie-4.5-turbo-128k", + "ernie-4.5-turbo-128k", + "ernie-x1.1" + ] + }); + } + if (config.vertex?.projectId) { providers.push({ name: "vertex", diff --git a/src/api/providers-handler.js b/src/api/providers-handler.js index 2b86fd6..f406a8c 100644 --- a/src/api/providers-handler.js +++ b/src/api/providers-handler.js @@ -223,6 +223,21 @@ function getConfiguredProviders() { }); } + // Check Baidu Qianfan (ERNIE) + if (config.baidu?.apiKey) { + providers.push({ + name: "baidu", + type: "baidu-qianfan", + baseUrl: config.baidu.endpoint || "https://qianfan.baidubce.com/v2/chat/completions", + enabled: true, + models: [ + { id: config.baidu.model || "ernie-4.5-turbo-128k", name: "Configured Model" }, + { id: "ernie-4.5-turbo-128k", name: "ERNIE 4.5 Turbo 128K" }, + { id: "ernie-x1.1", name: "ERNIE X1.1 (reasoning)" }, + ] + }); + } + // Check Vertex AI (Google Cloud) if (config.vertex?.projectId) { const region = config.vertex.region || "us-east5"; diff --git a/src/clients/databricks.js b/src/clients/databricks.js index f0a9454..ba37cca 100644 --- a/src/clients/databricks.js +++ b/src/clients/databricks.js @@ -2300,6 +2300,129 @@ async function invokeMoonshot(body, incomingHeaders = {}) { return response; } +/** + * Baidu Qianfan (ERNIE) Provider + * + * Qianfan's v2 endpoint is an OpenAI-compatible Chat Completions API + * (bearer-token auth, key format "bce-v3/ALTAK-..."). Modeled on + * invokeMoonshot: request side reuses the shared openrouter-utils + * converters, response is converted to Anthropic shape locally so the + * orchestrator branch is a plain passthrough. + * + * NOTE: sampling-param quirks and the exact model roster below are best + * effort from public docs, not yet probed against a live key. Revisit the + * modelMap and pinned-params logic (see Moonshot's kimi-k* precedent) once + * real traffic surfaces 400s. + */ +async function invokeBaidu(body, incomingHeaders = {}) { + if (!config.baidu?.apiKey) { + throw new Error("Baidu API key is not configured. Set BAIDU_API_KEY in your .env file."); + } + + const { + convertAnthropicToolsToOpenRouter, + convertAnthropicMessagesToOpenRouter + } = require("./openrouter-utils"); + + const endpoint = config.baidu.endpoint || "https://qianfan.baidubce.com/v2/chat/completions"; + + // Model mapping: Anthropic names → Baidu ERNIE names. + // Starting point only — refine once TIER_* mappings and live-probe results + // are known (see file-level NOTE above). + const modelMap = { + "claude-sonnet-4-5-20250929": "ernie-4.5-turbo-128k", + "claude-sonnet-4-5": "ernie-4.5-turbo-128k", + "claude-sonnet-4.5": "ernie-4.5-turbo-128k", + "claude-3-5-sonnet": "ernie-4.5-turbo-128k", + "claude-opus-4-5": "ernie-x1.1", + "claude-haiku-4-5-20251001": "ernie-speed-8k", + "claude-haiku-4-5": "ernie-speed-8k", + "claude-3-haiku": "ernie-speed-8k", + }; + + const requestedModel = body._tierModel || body.model || config.baidu.model; + // Honor tier-selected ERNIE ids (e.g. TIER_REASONING=baidu:ernie-x1.1) + // instead of silently swapping in the .env default model. + const mappedModel = modelMap[requestedModel] + || (/^ernie-/i.test(requestedModel || "") ? requestedModel : null) + || config.baidu.model + || "ernie-4.5-turbo-128k"; + + const messages = convertAnthropicMessagesToOpenRouter(body.messages || []); + + // Qianfan's OpenAI-compatible endpoint supports the system role natively. + if (body.system) { + const systemContent = Array.isArray(body.system) + ? body.system.map(s => s.text || s).join("\n") + : body.system; + messages.unshift({ role: "system", content: systemContent }); + } + + const baiduBody = { + model: mappedModel, + messages, + max_tokens: body.max_tokens || 16384, + temperature: body.temperature ?? 0.7, + top_p: body.top_p ?? 1.0, + // Streaming honored once "baidu" is added to DEFAULT_OPENAI_SSE_PROVIDERS + // (sse-transformer.js) and confirmed to match OpenAI SSE shape. Buffered + // requests use the Anthropic conversion path below regardless. + stream: body.stream ?? false, + }; + + if (Array.isArray(body.tools) && body.tools.length > 0) { + baiduBody.tools = convertAnthropicToolsToOpenRouter(body.tools); + baiduBody.tool_choice = "auto"; + baiduBody.parallel_tool_calls = false; + } + + const headers = { + "Content-Type": "application/json", + "Authorization": `Bearer ${config.baidu.apiKey}`, + }; + + logger.debug({ + endpoint, + model: baiduBody.model, + originalModel: requestedModel, + messageCount: baiduBody.messages?.length || 0, + hasTools: !!baiduBody.tools, + toolCount: baiduBody.tools?.length || 0, + }, "=== Baidu Qianfan REQUEST ==="); + + const response = await performJsonRequest(endpoint, { + headers, + body: baiduBody, + retryableStatusesOverride: [500, 502, 503, 504], + }, "Baidu"); + + if (!response.ok && response.status === 429) { + const err = new Error(`Baidu Qianfan rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`); + err.status = 429; + throw err; + } + + // Streaming request: hand the raw stream to the orchestrator's stream + // branch. The Anthropic conversion below is buffered-only. + if (response?.stream) { + return response; + } + + if (response?.ok && response?.json) { + const anthropicJson = convertOpenAIToAnthropic(response.json); + return { + ok: response.ok, + status: response.status, + json: anthropicJson, + text: JSON.stringify(anthropicJson), + contentType: "application/json", + headers: response.headers, + }; + } + + return response; +} + /** * Convert OpenAI response to Anthropic format */ @@ -2872,6 +2995,7 @@ const PROVIDER_INVOKERS = { vertex: invokeVertex, moonshot: invokeMoonshot, codex: invokeCodex, + baidu: invokeBaidu, }; function invokeProvider(provider, body, incomingHeaders) { @@ -3654,6 +3778,7 @@ module.exports = { invokeZai, invokeOllama, invokeMoonshot, + invokeBaidu, invokeAtlas, PROVIDER_INVOKERS, stripLynkrBadges, diff --git a/src/config/index.js b/src/config/index.js index cddf79d..9e418c5 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -62,7 +62,7 @@ function resolveConfigPath(targetPath) { return path.resolve(normalised); } -const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot"]); +const SUPPORTED_MODEL_PROVIDERS = new Set(["databricks", "azure-anthropic", "ollama", "openrouter", "edenai", "azure-openai", "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot", "baidu"]); const rawModelProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase(); // Validate MODEL_PROVIDER early with a clear error message @@ -148,6 +148,11 @@ const moonshotApiKey = process.env.MOONSHOT_API_KEY?.trim() || null; const moonshotEndpoint = process.env.MOONSHOT_ENDPOINT?.trim() || "https://api.moonshot.ai/v1/chat/completions"; const moonshotModel = process.env.MOONSHOT_MODEL?.trim() || "kimi-k2-turbo-preview"; +// Baidu Qianfan (ERNIE) configuration - OpenAI-compatible v2 Chat Completions API +const baiduApiKey = process.env.BAIDU_API_KEY?.trim() || null; +const baiduEndpoint = process.env.BAIDU_ENDPOINT?.trim() || "https://qianfan.baidubce.com/v2/chat/completions"; +const baiduModel = process.env.BAIDU_MODEL?.trim() || "ernie-4.5-turbo-128k"; + // Vertex AI (Google Gemini) configuration const vertexApiKey = process.env.VERTEX_API_KEY?.trim() || process.env.GOOGLE_API_KEY?.trim() || null; const vertexModel = process.env.VERTEX_MODEL?.trim() || "gemini-2.0-flash"; @@ -653,6 +658,11 @@ var config = { endpoint: moonshotEndpoint, model: moonshotModel, }, + baidu: { + apiKey: baiduApiKey, + endpoint: baiduEndpoint, + model: baiduModel, + }, codex: { enabled: process.env.CODEX_ENABLED !== "false", binaryPath: process.env.CODEX_BINARY_PATH?.trim() || null, @@ -1114,6 +1124,8 @@ function reloadConfig() { config.vertex.model = process.env.VERTEX_MODEL?.trim() || "gemini-2.0-flash"; config.moonshot.apiKey = process.env.MOONSHOT_API_KEY?.trim() || null; config.moonshot.model = process.env.MOONSHOT_MODEL?.trim() || "kimi-k2-turbo-preview"; + config.baidu.apiKey = process.env.BAIDU_API_KEY?.trim() || null; + config.baidu.model = process.env.BAIDU_MODEL?.trim() || "ernie-4.5-turbo-128k"; // Model provider settings const newProvider = (process.env.MODEL_PROVIDER ?? "databricks").toLowerCase(); diff --git a/src/orchestrator/index.js b/src/orchestrator/index.js index e467f8d..0fbe9d8 100644 --- a/src/orchestrator/index.js +++ b/src/orchestrator/index.js @@ -58,6 +58,8 @@ function getDestinationUrl(providerType) { return config.vertex?.endpoint ?? 'unknown'; case 'moonshot': return config.moonshot?.endpoint ?? 'unknown'; + case 'baidu': + return config.baidu?.endpoint ?? 'unknown'; case 'codex': return 'codex://app-server (local process)'; default: @@ -1147,6 +1149,14 @@ function sanitizePayload(payload) { } else { clean.tools = ensureAnthropicToolFormat(clean.tools); } + } else if (providerType === "baidu") { + // Baidu Qianfan supports tools - keep them in Anthropic format + // They will be converted to OpenAI format in invokeBaidu + if (!Array.isArray(clean.tools) || clean.tools.length === 0) { + delete clean.tools; + } else { + clean.tools = ensureAnthropicToolFormat(clean.tools); + } } else if (providerType === "azure-openai" || providerType === "openai" || providerType === "atlas") { // Azure OpenAI / OpenAI-compatible providers support tools — keep Anthropic format; the // client converts to Chat Completions / Responses format. Without this @@ -2613,6 +2623,12 @@ IMPORTANT TOOL USAGE RULES: if (Array.isArray(anthropicPayload?.content)) { anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); } + } else if (actualProvider === "baidu") { + // Baidu Qianfan responses are already converted to Anthropic format in invokeBaidu + anthropicPayload = databricksResponse.json; + if (Array.isArray(anthropicPayload?.content)) { + anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content); + } } else if (actualProvider === "codex") { // Codex responses are already in Anthropic format from invokeCodex anthropicPayload = databricksResponse.json; diff --git a/src/routing/model-tiers.js b/src/routing/model-tiers.js index f84de2f..7b89fbd 100644 --- a/src/routing/model-tiers.js +++ b/src/routing/model-tiers.js @@ -351,6 +351,8 @@ class ModelTierSelector { return config.zai?.model || null; case 'moonshot': return config.moonshot?.model || null; + case 'baidu': + return config.baidu?.model || null; case 'codex': return config.codex?.model || null; case 'vertex': diff --git a/test/baidu-model-mapping.test.js b/test/baidu-model-mapping.test.js new file mode 100644 index 0000000..c1bebd0 --- /dev/null +++ b/test/baidu-model-mapping.test.js @@ -0,0 +1,132 @@ +/** + * Tests for Baidu Qianfan (ERNIE) model mapping (invokeBaidu). + * + * invokeBaidu is modeled on invokeMoonshot: Anthropic model names map to a + * live ERNIE model id, tier-selected ids (e.g. TIER_REASONING=baidu:ernie-x1.1) + * reach the wire unchanged, and the response is converted to Anthropic shape + * before returning. + * + * NOTE: the modelMap and sampling defaults in invokeBaidu are best-effort + * from public docs, not yet probed against a live key (see the NOTE at the + * top of invokeBaidu in src/clients/databricks.js). These tests pin current + * behavior, not confirmed-correct behavior. + */ + +process.env.DATABRICKS_API_KEY = process.env.DATABRICKS_API_KEY || "test-key"; +process.env.DATABRICKS_API_BASE = process.env.DATABRICKS_API_BASE || "http://test.com"; +process.env.BAIDU_API_KEY = process.env.BAIDU_API_KEY || "test-key"; + +const { describe, it, beforeEach, afterEach } = require("node:test"); +const assert = require("node:assert/strict"); + +const { invokeBaidu } = require("../src/clients/databricks"); + +let captured; +const realFetch = global.fetch; + +function okCompletion(model) { + return new Response( + JSON.stringify({ + id: "chatcmpl-test", + object: "chat.completion", + created: 0, + model, + choices: [ + { + index: 0, + message: { role: "assistant", content: "ok" }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +beforeEach(() => { + captured = null; + global.fetch = async (url, init) => { + captured = { url: String(url), body: JSON.parse(init.body), headers: init.headers }; + return okCompletion(captured.body.model); + }; +}); + +afterEach(() => { + global.fetch = realFetch; +}); + +const USER_MSG = [{ role: "user", content: "hi" }]; + +describe("baidu model mapping", () => { + it("passes tier-selected ERNIE ids through instead of the .env default", async () => { + await invokeBaidu({ _tierModel: "ernie-x1.1", model: "claude-sonnet-4-5", messages: USER_MSG }); + assert.equal(captured.body.model, "ernie-x1.1"); + }); + + it("maps claude sonnet names to ERNIE 4.5 Turbo", async () => { + await invokeBaidu({ model: "claude-sonnet-4-5", messages: USER_MSG }); + assert.equal(captured.body.model, "ernie-4.5-turbo-128k"); + }); + + it("maps claude haiku names to ERNIE Speed", async () => { + await invokeBaidu({ model: "claude-haiku-4-5", messages: USER_MSG }); + assert.equal(captured.body.model, "ernie-speed-8k"); + }); + + it("maps claude opus to the ERNIE reasoning model", async () => { + await invokeBaidu({ model: "claude-opus-4-5", messages: USER_MSG }); + assert.equal(captured.body.model, "ernie-x1.1"); + }); + + it("falls back to the .env default model for unrecognized names", async () => { + await invokeBaidu({ model: "some-unmapped-model", messages: USER_MSG }); + assert.equal(captured.body.model, "ernie-4.5-turbo-128k"); + }); +}); + +describe("baidu request shape", () => { + it("sends a bearer auth header with the configured API key", async () => { + await invokeBaidu({ model: "claude-sonnet-4-5", messages: USER_MSG }); + assert.equal(captured.headers.Authorization, "Bearer test-key"); + }); + + it("prepends system content as a system-role message", async () => { + await invokeBaidu({ model: "claude-sonnet-4-5", system: "be terse", messages: USER_MSG }); + assert.equal(captured.body.messages[0].role, "system"); + assert.equal(captured.body.messages[0].content, "be terse"); + }); + + it("converts Anthropic tools to OpenAI function-calling shape", async () => { + const tools = [ + { name: "get_weather", description: "get weather", input_schema: { type: "object", properties: {} } }, + ]; + await invokeBaidu({ model: "claude-sonnet-4-5", messages: USER_MSG, tools }); + assert.equal(captured.body.tools[0].type, "function"); + assert.equal(captured.body.tools[0].function.name, "get_weather"); + assert.equal(captured.body.tool_choice, "auto"); + assert.equal(captured.body.parallel_tool_calls, false); + }); + + it("throws a clear error when BAIDU_API_KEY is not configured", async () => { + const config = require("../src/config"); + const original = config.baidu.apiKey; + config.baidu.apiKey = null; + try { + await assert.rejects( + invokeBaidu({ model: "claude-sonnet-4-5", messages: USER_MSG }), + /Baidu API key is not configured/, + ); + } finally { + config.baidu.apiKey = original; + } + }); +}); + +describe("baidu response conversion", () => { + it("converts the OpenAI-shaped completion to Anthropic content blocks", async () => { + const response = await invokeBaidu({ model: "claude-sonnet-4-5", messages: USER_MSG }); + assert.equal(response.json.content[0].type, "text"); + assert.equal(response.json.content[0].text, "ok"); + }); +}); diff --git a/test/dispatch-registry.test.js b/test/dispatch-registry.test.js index b179f26..87b79f3 100644 --- a/test/dispatch-registry.test.js +++ b/test/dispatch-registry.test.js @@ -32,6 +32,7 @@ test('PROVIDER_INVOKERS covers every supported provider except databricks', () = const SUPPORTED = [ 'azure-anthropic', 'ollama', 'openrouter', 'edenai', 'azure-openai', 'openai', 'atlas', 'llamacpp', 'lmstudio', 'bedrock', 'zai', 'vertex', 'moonshot', + 'baidu', ]; const missing = SUPPORTED.filter((p) => typeof PROVIDER_INVOKERS[p] !== 'function'); diff --git a/test/init.test.js b/test/init.test.js index fce06a1..65a5529 100644 --- a/test/init.test.js +++ b/test/init.test.js @@ -44,6 +44,7 @@ describe("lynkr init", () => { const supported = [ "databricks", "azure-anthropic", "ollama", "openrouter", "azure-openai", "openai", "atlas", "llamacpp", "lmstudio", "bedrock", "zai", "vertex", "moonshot", + "baidu", ]; for (const key of supported) { assert.ok(init.PROVIDERS[key], `wizard missing provider entry for ${key}`);