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

-


```
@@ -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}`);