From b28522118f54f63171fd00e6943229abbd14f509 Mon Sep 17 00:00:00 2001 From: vishal veerareddy Date: Sat, 29 Aug 2026 23:24:55 -0700 Subject: [PATCH 1/3] feat: Support Claude Desktop --- bin/cli.js | 19 +- bin/lynkr-desktop-token.js | 108 ++++++++++++ bin/lynkr-restart.js | 125 +++++++++++++ documentation/README.md | 3 +- documentation/claude-desktop.md | 150 ++++++++++++++++ nodemon.json | 4 +- scripts/claude-desktop.js | 212 ++++++++++++++++++++++ src/api/claude-desktop-gateway.js | 123 +++++++++++++ src/api/openai-router.js | 90 ++++++++++ src/api/router.js | 40 +++++ src/clients/databricks.js | 156 ++++++++++++++--- src/clients/gpt-utils.js | 57 +++++- src/clients/provider-capabilities.js | 27 +++ src/context/compression.js | 108 ++++++++++-- src/orchestrator/azure-responses-sse.js | 223 ++++++++++++++++++++++++ src/orchestrator/index.js | 27 +++ src/orchestrator/sse-transformer.js | 33 +++- src/routing/model-slots.js | 41 +++++ src/routing/openai-model-slots.js | 82 +++++++++ src/tools/web-search-exec.js | 182 +++++++++++++++++++ test/azure-responses-sse.test.js | 205 ++++++++++++++++++++++ test/gpt-utils.test.js | 58 ++++++ test/web-search-exec.test.js | 149 ++++++++++++++++ 23 files changed, 2168 insertions(+), 54 deletions(-) create mode 100644 bin/lynkr-desktop-token.js create mode 100644 bin/lynkr-restart.js create mode 100644 documentation/claude-desktop.md create mode 100644 scripts/claude-desktop.js create mode 100644 src/api/claude-desktop-gateway.js create mode 100644 src/orchestrator/azure-responses-sse.js create mode 100644 src/routing/model-slots.js create mode 100644 src/routing/openai-model-slots.js create mode 100644 src/tools/web-search-exec.js create mode 100644 test/azure-responses-sse.test.js create mode 100644 test/gpt-utils.test.js create mode 100644 test/web-search-exec.test.js diff --git a/bin/cli.js b/bin/cli.js index 6320209..3638b48 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -6,12 +6,14 @@ const pkg = require('../package.json'); // Subcommands. Dispatched before server boot so `lynkr usage` / `lynkr trajectory` // don't start the proxy. Add new subcommands here, not in scattered binaries. const SUBCOMMANDS = { - usage: path.join(__dirname, "lynkr-usage.js"), - stats: path.join(__dirname, "lynkr-usage.js"), - trajectory: path.join(__dirname, "lynkr-trajectory.js"), - wrap: path.join(__dirname, "wrap.js"), - init: path.join(__dirname, "lynkr-init.js"), - reset: path.join(__dirname, "lynkr-reset.js"), + usage: path.join(__dirname, "lynkr-usage.js"), + stats: path.join(__dirname, "lynkr-usage.js"), + trajectory: path.join(__dirname, "lynkr-trajectory.js"), + wrap: path.join(__dirname, "wrap.js"), + init: path.join(__dirname, "lynkr-init.js"), + reset: path.join(__dirname, "lynkr-reset.js"), + "desktop-token": path.join(__dirname, "lynkr-desktop-token.js"), + restart: path.join(__dirname, "lynkr-restart.js"), }; const sub = process.argv[2]; @@ -51,6 +53,11 @@ Usage: lynkr start [options] Alias for the above lynkr init [options] Interactive setup wizard (writes .env, pulls classifier model) lynkr wrap [options] Wrap CLI tools through Lynkr proxy + lynkr desktop-token Apply a Claude Pro/Max OAuth token to Claude Desktop's + gateway profile (macOS only) + lynkr desktop-token --restore Restore Claude Desktop to talking to Anthropic directly + (undoes the above; macOS only) + lynkr restart Restart the local Lynkr server (pick up code/config changes) lynkr usage [options] Show AI spend report and tier-routing savings lynkr stats [options] Shareable savings-receipt card (also: lynkr usage --card) lynkr trajectory [options] Export agent trajectories as JSONL training data diff --git a/bin/lynkr-desktop-token.js b/bin/lynkr-desktop-token.js new file mode 100644 index 0000000..e3c41ca --- /dev/null +++ b/bin/lynkr-desktop-token.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * `lynkr desktop-token ` / `lynkr desktop-token --restore` + * + * Apply a freshly-minted Claude Pro/Max OAuth token to Claude Desktop's + * Lynkr gateway profile — or restore Desktop back to talking to Anthropic + * directly. + * + * This does NOT mint or refresh the token — that stays a manual, one-time + * `claude setup-token` (interactive browser approval) every time it expires. + * It does NOT restart Lynkr, either: the token lives entirely in Desktop's + * own config file (read at Desktop's launch, not Lynkr's), and Lynkr just + * forwards whatever bearer shows up on each request — it has no token state + * of its own to refresh. Restarting only matters when Lynkr's own code + * changed; use `lynkr restart` for that, separately. + * + * `lynkr desktop-token `: + * 1. Installs the token into the Desktop "3p" gateway profile + * (delegates to scripts/claude-desktop.js --install) + * 2. Prints --status so you can see the profile actually took + * + * `lynkr desktop-token --restore`: + * Delegates to scripts/claude-desktop.js --restore — puts Desktop's + * deployment mode and applied-profile bookkeeping back to what they were + * before Lynkr's gateway profile was installed, and removes the profile + * + backup files. Safe to run even if nothing was ever installed (a + * no-op restore, not an error). + * + * Neither variant touches .env TIER_* values, and neither quits or + * relaunches Claude Desktop itself (that would close your open chat + * windows) — both just print the command to do so when you're ready. + * + * Usage: + * lynkr desktop-token sk-ant-oat01-... + * lynkr desktop-token --restore + */ + +const path = require("path"); +const fs = require("fs"); +const { execFileSync } = require("child_process"); + +const ROOT = path.join(__dirname, ".."); +const ENV_PATH = path.join(ROOT, ".env"); +const CLAUDE_DESKTOP_JS = path.join(ROOT, "scripts", "claude-desktop.js"); + +function fail(msg) { + console.error(msg); + process.exit(1); +} + +function readPort() { + try { + const env = fs.readFileSync(ENV_PATH, "utf8"); + const m = env.match(/^PORT=(\d+)/m); + if (m) return Number(m[1]); + } catch (err) { + console.error(`Couldn't read PORT from ${ENV_PATH} (${err.message}) — defaulting to 8081.`); + } + return 8081; +} + +function main() { + const token = process.argv[2]; + if (!token || token === "-h" || token === "--help") { + fail("Usage: lynkr desktop-token | --restore"); + } + if (token === "--restore") { + if (process.platform !== "darwin") { + fail("Claude Desktop profile management is only supported on macOS."); + } + console.log("Restoring Claude Desktop to the usual Claude profile..."); + execFileSync("node", [CLAUDE_DESKTOP_JS, "--restore"], { stdio: "inherit" }); + console.log( + "\nWhen you're ready (this would close your open chats if done automatically):" + + "\n killall Claude && open -a Claude" + ); + return; + } + if (!token.startsWith("sk-ant-oat")) { + fail( + `That doesn't look like a Claude Code OAuth access token (expected it to start ` + + `with "sk-ant-oat"). Got: ${token.slice(0, 12)}...\n` + + `Mint one with: claude setup-token` + ); + } + if (process.platform !== "darwin") { + fail("Claude Desktop profile management is only supported on macOS."); + } + + const url = `http://127.0.0.1:${readPort()}`; + + console.log("Step 1/2 — installing token into Claude Desktop's Lynkr gateway profile..."); + execFileSync("node", [CLAUDE_DESKTOP_JS, "--install", "--url", url, "--key", token], { + stdio: "inherit", + }); + + console.log("\nStep 2/2 — status:"); + execFileSync("node", [CLAUDE_DESKTOP_JS, "--status"], { stdio: "inherit" }); + + console.log( + "\nNext (manual, on purpose — this would close your open chats if done for you):" + + "\n killall Claude && open -a Claude" + + "\n\nWhen Anthropic calls start 401ing: claude setup-token, then: lynkr desktop-token " + + "\nIf you've also changed Lynkr's own code, that's a separate step: lynkr restart" + ); +} + +main(); diff --git a/bin/lynkr-restart.js b/bin/lynkr-restart.js new file mode 100644 index 0000000..e068325 --- /dev/null +++ b/bin/lynkr-restart.js @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * `lynkr restart` + * + * Stop whatever's listening on Lynkr's configured port and start a fresh + * `npm start`, detached so it survives this command exiting. Use this after + * changing Lynkr's own code (routing, config, etc.) — it has nothing to do + * with Claude Desktop's token or profile, which Desktop reads from disk at + * its own launch time, independent of whether Lynkr's process restarts. + * See `lynkr desktop-token` for that. + * + * Usage: + * lynkr restart + */ + +const fs = require("fs"); +const path = require("path"); +const http = require("http"); +const { execFileSync, spawn } = require("child_process"); + +const ROOT = path.join(__dirname, ".."); +const ENV_PATH = path.join(ROOT, ".env"); +const LOG_PATH = path.join(ROOT, "data", "logs", "lynkr-desktop.log"); + +function fail(msg) { + console.error(msg); + process.exit(1); +} + +function readPort() { + try { + const env = fs.readFileSync(ENV_PATH, "utf8"); + const m = env.match(/^PORT=(\d+)/m); + if (m) return Number(m[1]); + } catch (err) { + console.error(`Couldn't read PORT from ${ENV_PATH} (${err.message}) — defaulting to 8081.`); + } + return 8081; +} + +function findListeningPid(port) { + try { + const out = execFileSync("lsof", ["-iTCP:" + port, "-sTCP:LISTEN", "-t"], { + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + return out ? out.split("\n").map((s) => s.trim()).filter(Boolean) : []; + } catch { + return []; // lsof exits non-zero when nothing matches + } +} + +function healthCheck(port) { + return new Promise((resolve) => { + const req = http.get({ host: "127.0.0.1", port, path: "/health", timeout: 1500 }, (res) => { + let body = ""; + res.on("data", (d) => (body += d)); + res.on("end", () => { + try { + resolve(res.statusCode === 200 && JSON.parse(body).status === "ok"); + } catch { + resolve(false); + } + }); + }); + req.on("error", () => resolve(false)); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + }); +} + +async function waitForHealth(port, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await healthCheck(port)) return true; + await new Promise((r) => setTimeout(r, 500)); + } + return false; +} + +async function main() { + if (process.argv[2] === "-h" || process.argv[2] === "--help") { + console.log("Usage: lynkr restart"); + process.exit(0); + } + + const port = readPort(); + const pids = findListeningPid(port); + if (pids.length) { + console.log(`Stopping existing Lynkr process (pid ${pids.join(", ")}) on port ${port}...`); + for (const pid of pids) { + try { + process.kill(Number(pid), "SIGTERM"); + } catch (err) { + console.warn(` couldn't signal pid ${pid}: ${err.message}`); + } + } + const freedByDeadline = Date.now() + 5000; + while (Date.now() < freedByDeadline && findListeningPid(port).length) { + await new Promise((r) => setTimeout(r, 250)); + } + } else { + console.log(`Nothing currently listening on port ${port}.`); + } + + fs.mkdirSync(path.dirname(LOG_PATH), { recursive: true }); + const logFd = fs.openSync(LOG_PATH, "a"); + console.log(`Starting Lynkr (npm start), logging to ${LOG_PATH}...`); + const child = spawn("npm", ["start"], { + cwd: ROOT, + stdio: ["ignore", logFd, logFd], + detached: true, + }); + child.unref(); + + const up = await waitForHealth(port, 15000); + if (!up) { + fail(`Lynkr didn't answer /health on port ${port} within 15s.\nCheck ${LOG_PATH} for errors.`); + } + console.log(`Lynkr is up on http://127.0.0.1:${port}.`); +} + +main().catch((err) => fail(err.stack || String(err))); diff --git a/documentation/README.md b/documentation/README.md index 8628545..0cde041 100644 --- a/documentation/README.md +++ b/documentation/README.md @@ -19,6 +19,7 @@ New to Lynkr? Start here: Connect Lynkr to your development tools: - **[Claude Code CLI Setup](claude-code-cli.md)** - Configure Claude Code CLI to use Lynkr +- **[Claude Desktop Setup](claude-desktop.md)** - Route the Claude Desktop app through Lynkr (macOS gateway profile, model picker as tier selector) - **[Codex CLI Setup](codex-cli.md)** - Configure OpenAI Codex CLI with Lynkr (config.toml, wire_api, troubleshooting) - **[OpenClaw Integration](openclaw-integration.md)** - Use OpenClaw with Lynkr as its AI backend - **[Cursor IDE Integration](cursor-integration.md)** - Full Cursor IDE setup with troubleshooting @@ -72,7 +73,7 @@ Get help and contribute: ## Quick Navigation by Topic ### Setup & Configuration -- [Installation](installation.md) | [Providers](providers.md) | [Claude Code](claude-code-cli.md) | [Codex CLI](codex-cli.md) | [OpenClaw](openclaw-integration.md) | [Cursor](cursor-integration.md) | [Embeddings](embeddings.md) +- [Installation](installation.md) | [Providers](providers.md) | [Claude Code](claude-code-cli.md) | [Claude Desktop](claude-desktop.md) | [Codex CLI](codex-cli.md) | [OpenClaw](openclaw-integration.md) | [Cursor](cursor-integration.md) | [Embeddings](embeddings.md) ### Features & Optimization - [Features](features.md) | [Routing](routing.md) | [Memory System](memory-system.md) | [Token Optimization](token-optimization.md) | [Headroom](headroom.md) | [Tools](tools.md) diff --git a/documentation/claude-desktop.md b/documentation/claude-desktop.md new file mode 100644 index 0000000..a143483 --- /dev/null +++ b/documentation/claude-desktop.md @@ -0,0 +1,150 @@ +# Claude Desktop Integration + +This guide explains how to route the **Claude Desktop app** (macOS) through Lynkr so its conversations use your configured providers (local models, Azure OpenAI, Bedrock, Databricks, etc.) instead of talking to `api.anthropic.com` directly. + +--- + +## Overview + +Claude Desktop has an undocumented "third-party gateway" mode (the same mechanism [Ollama's `claude-desktop` launcher](https://github.com/ollama/ollama) uses) that redirects its Anthropic Messages API traffic to a local URL instead of Anthropic's servers. Lynkr already speaks that API on `/v1/messages`, so pointing Desktop at Lynkr gets you: + +- **Local models** (Ollama, llama.cpp, LM Studio) for free, private conversations +- **Enterprise providers** (Azure OpenAI, Bedrock, Databricks) behind the same Desktop UI +- Lynkr's **tier routing**, **token optimization**, and **caching** +- A **model picker that doubles as a tier selector** (see [Model Picker](#model-picker--tier-selector) below) + +This is macOS-only — the mechanism was reverse-engineered from Desktop's own config files, which only exist in that form on macOS. + +--- + +## Quick Start + +```bash +# 1. Make sure Lynkr is running +cd /path/to/lynkr && npm start + +# 2. Mint a Claude Pro/Max OAuth token (one-time, interactive browser approval) +claude setup-token + +# 3. Install it into Desktop's gateway profile +lynkr desktop-token sk-ant-oat01-... + +# 4. Relaunch Desktop (not automatic — this would close open chats) +killall Claude && open -a Claude +``` + +Desktop's chat traffic now flows through Lynkr. Check any response for the `*[Lynkr] TIER → provider (model)*` badge Lynkr injects to confirm. + +### Undoing it + +```bash +lynkr desktop-token --restore +killall Claude && open -a Claude +``` + +Puts Desktop's deployment mode and profile registry back to what they were before, and removes the installed profile + backup files. Safe to run even if nothing was ever installed. + +--- + +## Why a token is needed + +In its normal mode, Desktop manages its own Anthropic login and never needs anything from you. Once redirected to Lynkr's gateway (deploymentMode `"3p"`), Desktop no longer talks to Anthropic at all — **Lynkr** does, on Desktop's behalf, whenever a conversation routes to the OAuth-subscription tier. That's the `sk-ant-oat...` access token `lynkr desktop-token` installs: it becomes the bearer key Desktop sends Lynkr, which Lynkr forwards to Anthropic for subscription-tier requests. It is *not* minted or refreshed by Lynkr — re-run `claude setup-token` yourself whenever it expires (Anthropic calls will start 401ing) and reinstall with `lynkr desktop-token `. + +Requests that don't route to the subscription tier (local models, Azure, Bedrock, etc.) don't need this token to be valid — but Desktop still requires *some* bearer value to be configured before it'll use the gateway at all. + +--- + +## What actually gets changed + +`lynkr desktop-token` / `scripts/claude-desktop.js` edit three JSON files under `~/Library/Application Support/`: + +| File | Purpose | +|------|---------| +| `Claude/claude_desktop_config.json` | `deploymentMode: "1p"` (stock) or `"3p"` (gateway) | +| `Claude-3p/claude_desktop_config.json` | Same flag, third-party profile root | +| `Claude-3p/configLibrary/_meta.json` | Profile registry — which profile id is applied | +| `Claude-3p/configLibrary/.json` | The Lynkr profile itself: gateway URL, bearer key, display name | + +A first install also writes `Claude-3p/configLibrary/.lynkr-backup.json` — whatever `deploymentMode`/`appliedId` existed *before* Lynkr touched anything, so `--restore` puts it back exactly rather than just guessing `"1p"`. + +None of this touches Lynkr's own `.env` or `TIER_*` settings, and neither install nor restore quits/relaunches Desktop automatically — both print the `killall Claude && open -a Claude` command instead, since that would otherwise close your open chat windows without warning. + +Check current state anytime: + +```bash +node scripts/claude-desktop.js --status +``` + +``` +deploymentMode: 3p +Lynkr profile installed: true +Lynkr profile applied: true +gateway URL: http://127.0.0.1:8081 + +Claude Desktop is routed through Lynkr. +``` + +--- + +## Model Picker → Tier Selector + +Desktop's model dropdown lists whatever `GET /v1/messages` (with an `anthropic-version` header, which Desktop always sends) returns from `src/api/claude-desktop-gateway.js`. Lynkr advertises five fixed entries — Desktop validates model ids against its own known catalog, so these reuse real Claude family names rather than inventing ids like `lynkr-simple`: + +| Desktop picker entry | Pins tier | Notes | +|---|---|---| +| Lynkr Auto (`claude-fable-5`) | — (no pin) | Falls through to normal content-based scoring | +| `claude-opus-5` | REASONING | | +| `claude-sonnet-5` | COMPLEX | | +| `claude-sonnet-4-6` | MEDIUM | | +| `claude-haiku-4-5-20251001` | SIMPLE | | + +Picking anything other than "Lynkr Auto" **pins** that tier explicitly — Lynkr skips content scoring entirely for that request (`src/api/router.js`'s model-id-pin check, source: `src/routing/model-slots.js`). Tiers left unset in `.env` are skipped from the list; if that leaves a family with no default, the first surviving entry of that family is promoted so the picker always has a selectable default. + +The list is dynamic — labels for non-default entries show the actual configured model, e.g. `Lynkr MEDIUM (gpt-5.6-sol)`, pulled live from `config.modelTiers`. + +Disable the picker (fall back to whatever Desktop's default model list would otherwise be) with: + +```bash +CLAUDE_DESKTOP_GATEWAY=0 +``` + +Adjust the advertised `max_tokens` per model entry (default 32768) with: + +```bash +CLAUDE_DESKTOP_GATEWAY_MAX_TOKENS=65536 +``` + +--- + +## Detection & Gating + +The gateway model list only intercepts callers that look like Desktop specifically — anything sending an `anthropic-version` header, or an explicit `?format=anthropic` query param. Everything else (Claude Code CLI, curl, other Anthropic-format clients hitting the same `/v1/messages` route) falls through to the normal OpenAI-format model list from `src/api/openai-router.js` untouched. + +Separately, `src/routing/client-profiles.js`'s `detectClient()` recognizes Desktop by the same `claude-cli/` user-agent family Claude Code CLI uses (Desktop and the CLI share a base client library) — this is what lets Lynkr apply Desktop-appropriate behavior (like tool-loadout expectations) without the model-picker gating above being involved at all. + +--- + +## Troubleshooting + +| Issue | Cause | Solution | +|---|---|---| +| Desktop doesn't show the Lynkr model entries | Gateway intercept didn't fire | Confirm `deploymentMode: "3p"` via `--status`; confirm `CLAUDE_DESKTOP_GATEWAY` isn't set to `0` | +| Model list shows "hasn't loaded" | An unrecognized/invented model id was advertised | Only use ids from `MODEL_SLOTS` (`src/routing/model-slots.js`) — Desktop validates against its own fixed catalog | +| 401s after a while | The installed OAuth token expired | `claude setup-token`, then `lynkr desktop-token ` | +| Changes don't take effect | Desktop caches its config at launch | `killall Claude && open -a Claude` after any install/restore | +| Web search / web fetch tool calls fail | Desktop is a client Lynkr doesn't have a native profile for on some paths | See `src/tools/web-search-exec.js` — Lynkr can auto-resolve `web_search`/`web_fetch` server-side for unrecognized-client traffic | +| Caveman/brevity mode behaving oddly | `CAVEMAN_ENABLED` state stale in a long-running process | `.env` changes need `lynkr restart` (or a fresh `npm run dev` boot) to take effect — Node doesn't hot-reload `.env` | +| Connection refused | Lynkr not running, or wrong port in the installed profile | `npm start` in the Lynkr directory; re-run `lynkr desktop-token` if the port changed | + +--- + +## Related Documentation + +- **[Claude Code CLI Setup](claude-code-cli.md)** — the terminal counterpart; shares the `claude-cli/` user-agent family with Desktop +- **[Codex CLI Setup](codex-cli.md)** — same third-party-gateway concept, for OpenAI's Codex CLI/Desktop instead +- **[Routing & Model Tiering](routing.md)** — how tier scoring and pins work generally +- **[Troubleshooting Guide](troubleshooting.md)** — issues not specific to Desktop + +--- + +**Need help?** Visit [GitHub Discussions](https://github.com/Fast-Editor/Lynkr/discussions) or check the [FAQ](faq.md). diff --git a/nodemon.json b/nodemon.json index 2787a20..ac8bd4c 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,3 +1,5 @@ { - "ignore": ["data/*", ".lynkr/*", "*.db"] + "ignore": ["data/*", ".lynkr/*", "*.db"], + "ext": "js,mjs,cjs,json", + "watch": [".", ".env"] } diff --git a/scripts/claude-desktop.js b/scripts/claude-desktop.js new file mode 100644 index 0000000..ed762db --- /dev/null +++ b/scripts/claude-desktop.js @@ -0,0 +1,212 @@ +#!/usr/bin/env node +/** + * Install or remove the Lynkr profile for Claude Desktop's native + * third-party inference mode (macOS only). + * + * Claude Desktop supports a "3p" deployment mode in which it sends its + * Anthropic Messages API traffic to a local gateway instead of + * api.anthropic.com. The mechanism (reverse-engineered from Ollama's + * cmd/launch/claude_desktop.go, which uses the same route) is three JSON + * files: + * + * ~/Library/Application Support/Claude/claude_desktop_config.json + * deploymentMode: "1p" | "3p" + * ~/Library/Application Support/Claude-3p/claude_desktop_config.json + * deploymentMode again, for the third-party profile root + * ~/Library/Application Support/Claude-3p/configLibrary/_meta.json + * profile registry: { appliedId, entries: [{id, name}] } + * ~/Library/Application Support/Claude-3p/configLibrary/.json + * the gateway profile itself (base URL, bearer key, display name) + * + * Usage: + * node scripts/claude-desktop.js --install [--url http://127.0.0.1:8081] [--key lynkr] + * node scripts/claude-desktop.js --restore + * node scripts/claude-desktop.js --status + * + * WARNING: this is undocumented Claude Desktop surface observed via + * Ollama's public integration; a Desktop update may change the key names. + * --restore (or `ollama launch claude-desktop --restore`) always brings + * back the stock Anthropic profile by flipping deploymentMode to "1p". + */ + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFileSync } = require("child_process"); + +const PROFILE_ID = "00000000-0000-4000-8000-000000008081"; +const PROFILE_NAME = "Lynkr"; +const DEFAULT_URL = "http://127.0.0.1:8081"; +const DEFAULT_KEY = "lynkr"; + +const appSupport = path.join(os.homedir(), "Library", "Application Support"); +const PATHS = { + normalConfig: path.join(appSupport, "Claude", "claude_desktop_config.json"), + tpConfig: path.join(appSupport, "Claude-3p", "claude_desktop_config.json"), + meta: path.join(appSupport, "Claude-3p", "configLibrary", "_meta.json"), + profile: path.join(appSupport, "Claude-3p", "configLibrary", `${PROFILE_ID}.json`), + backup: path.join(appSupport, "Claude-3p", "configLibrary", ".lynkr-backup.json"), +}; + +function readJson(file) { + try { + return JSON.parse(fs.readFileSync(file, "utf8")); + } catch (err) { + if (err.code === "ENOENT") return {}; + throw new Error(`Cannot parse ${file}: ${err.message}`); + } +} + +function writeJson(file, obj) { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify(obj, null, 2) + "\n"); +} + +function setDeploymentMode(file, mode) { + const cfg = readJson(file); + cfg.deploymentMode = mode; + writeJson(file, cfg); +} + +function claudeDesktopRunning() { + try { + execFileSync("pgrep", ["-x", "Claude"], { stdio: "pipe" }); + return true; + } catch { + return false; + } +} + +function install(opts) { + if (!fs.existsSync(path.join(appSupport, "Claude"))) { + fail("Claude Desktop does not appear to be installed (no ~/Library/Application Support/Claude)."); + } + + // First install: remember what we're replacing so --restore is faithful. + if (!fs.existsSync(PATHS.backup)) { + const meta = readJson(PATHS.meta); + const normal = readJson(PATHS.normalConfig); + writeJson(PATHS.backup, { + previousAppliedId: meta.appliedId ?? null, + previousDeploymentMode: normal.deploymentMode ?? "1p", + }); + } + + // Profile: same keys Ollama's launcher writes, pointed at Lynkr. + const profile = readJson(PATHS.profile); + Object.assign(profile, { + inferenceProvider: "gateway", + inferenceGatewayBaseUrl: opts.url, + inferenceGatewayApiKey: opts.key, + inferenceGatewayAuthScheme: "bearer", + deploymentDisplayName: PROFILE_NAME, + chatTabEnabled: true, + disableDeploymentModeChooser: true, + coworkEgressAllowedHosts: ["*"], + disableEssentialTelemetry: true, + disableNonessentialTelemetry: true, + autoModeEnabled: true, + }); + delete profile.inferenceModels; + writeJson(PATHS.profile, profile); + + // Registry: apply our profile, dedupe any stale entry for our id. + const meta = readJson(PATHS.meta); + meta.appliedId = PROFILE_ID; + meta.entries = (Array.isArray(meta.entries) ? meta.entries : []) + .filter((e) => !(e && e.id === PROFILE_ID)) + .concat([{ id: PROFILE_ID, name: PROFILE_NAME }]); + writeJson(PATHS.meta, meta); + + setDeploymentMode(PATHS.normalConfig, "3p"); + setDeploymentMode(PATHS.tpConfig, "3p"); + + console.log(`Claude Desktop profile changed to ${PROFILE_NAME}.`); + console.log(`Gateway: ${opts.url} (bearer key: ${opts.key})`); + console.log("To restore the usual Claude profile: node scripts/claude-desktop.js --restore"); + if (claudeDesktopRunning()) { + console.log("\nClaude Desktop is running — quit and reopen it for the change to take effect."); + } + console.log("\nReminder: Lynkr must be running and restarted with the claude-desktop-gateway"); + console.log("models endpoint (src/api/claude-desktop-gateway.js) for model discovery to work."); +} + +function restore() { + const backup = readJson(PATHS.backup); + + setDeploymentMode(PATHS.normalConfig, backup.previousDeploymentMode || "1p"); + setDeploymentMode(PATHS.tpConfig, backup.previousDeploymentMode || "1p"); + + const meta = readJson(PATHS.meta); + meta.entries = (Array.isArray(meta.entries) ? meta.entries : []).filter( + (e) => !(e && e.id === PROFILE_ID) + ); + if (meta.appliedId === PROFILE_ID) { + if (backup.previousAppliedId) meta.appliedId = backup.previousAppliedId; + else delete meta.appliedId; + } + writeJson(PATHS.meta, meta); + + for (const file of [PATHS.profile, PATHS.backup]) { + try { + fs.unlinkSync(file); + } catch (err) { + if (err.code !== "ENOENT") { + console.warn(`Couldn't remove ${file}: ${err.message}`); + } + } + } + + console.log("Claude Desktop restored to the usual Claude profile."); + if (claudeDesktopRunning()) { + console.log("Claude Desktop is running — quit and reopen it for the change to take effect."); + } +} + +function status() { + const normal = readJson(PATHS.normalConfig); + const meta = readJson(PATHS.meta); + const profile = readJson(PATHS.profile); + const mode = normal.deploymentMode || "1p"; + const applied = meta.appliedId === PROFILE_ID; + console.log(`deploymentMode: ${mode}`); + console.log(`Lynkr profile installed: ${fs.existsSync(PATHS.profile)}`); + console.log(`Lynkr profile applied: ${applied}`); + if (profile.inferenceGatewayBaseUrl) { + console.log(`gateway URL: ${profile.inferenceGatewayBaseUrl}`); + } + console.log( + mode === "3p" && applied + ? "\nClaude Desktop is routed through Lynkr." + : "\nClaude Desktop is on the stock Anthropic profile." + ); +} + +function fail(msg) { + console.error(msg); + process.exit(1); +} + +function main() { + if (process.platform !== "darwin") { + fail("Claude Desktop profile management is only supported on macOS (matching the upstream integration)."); + } + const args = process.argv.slice(2); + const getFlag = (name, fallback) => { + const i = args.indexOf(name); + return i !== -1 && args[i + 1] ? args[i + 1] : fallback; + }; + + if (args.includes("--install")) { + install({ url: getFlag("--url", DEFAULT_URL), key: getFlag("--key", DEFAULT_KEY) }); + } else if (args.includes("--restore")) { + restore(); + } else if (args.includes("--status")) { + status(); + } else { + console.log("Usage: node scripts/claude-desktop.js --install [--url URL] [--key KEY] | --restore | --status"); + process.exit(args.length === 0 ? 0 : 1); + } +} + +main(); diff --git a/src/api/claude-desktop-gateway.js b/src/api/claude-desktop-gateway.js new file mode 100644 index 0000000..17aebd8 --- /dev/null +++ b/src/api/claude-desktop-gateway.js @@ -0,0 +1,123 @@ +/** + * Claude Desktop third-party gateway support. + * + * Claude Desktop has a native "3p" deployment mode in which it sends its + * normal Anthropic Messages API traffic to a local gateway URL instead of + * api.anthropic.com (this is the route Ollama's `ollama launch + * claude-desktop` integration uses). Lynkr already speaks the Messages API + * on /v1/messages, so the only missing surface is model discovery: Desktop + * lists the gateway's models and maps them into its model picker using two + * non-standard fields observed in Ollama's gateway implementation + * (internal/proxy/claude_desktop.go): `anthropic_family_tier` and + * `is_family_default`. + * + * This router serves that list, derived from Lynkr's TIER_* config so the + * Desktop model picker doubles as a tier selector: + * + * SIMPLE -> haiku family (default) + * MEDIUM -> sonnet family + * COMPLEX -> sonnet family (default) + * REASONING -> opus family (default) + * + * The selected id PINS routing: src/api/router.js resolves the request's + * `model` field against ../routing/model-slots.js and, when it matches one + * of these ids (anything but "Lynkr Auto" / claude-fable-5), skips content + * scoring entirely and forces that tier. Only "Lynkr Auto" and unrecognized + * ids fall through to Lynkr's normal per-message scoring, same as Claude + * Code traffic gets. + * + * Gating: GET /v1/models is already served in OpenAI format by + * openai-router.js (mounted after this router). We only intercept callers + * that identify as Anthropic API clients via the `anthropic-version` header + * (Claude Desktop always sends it) or an explicit ?format=anthropic. Set + * CLAUDE_DESKTOP_GATEWAY=0 to disable the intercept entirely. + * + * Profile installation lives in scripts/claude-desktop.js. + */ + +const express = require("express"); +const config = require("../config"); +const logger = require("../logger"); +const { MODEL_SLOTS } = require("../routing/model-slots"); + +const router = express.Router(); + +// Fixed timestamp: Anthropic's list endpoint dates models by release, not by +// request time, and a stable value keeps repeated calls cache-friendly. +const CREATED_AT = "2026-01-01T00:00:00Z"; + +const DEFAULT_MAX_TOKENS = 32768; + +// MODEL_SLOTS lives in ../routing/model-slots.js — shared with router.js, +// which resolves an incoming request's `model` field back to a tier so an +// explicit pick in Desktop's dropdown pins routing instead of only being +// advisory (see the model-id-pin block in router.js). + +function gatewayEnabled() { + return process.env.CLAUDE_DESKTOP_GATEWAY !== "0"; +} + +function maxTokens() { + const raw = Number(process.env.CLAUDE_DESKTOP_GATEWAY_MAX_TOKENS); + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_MAX_TOKENS; +} + +/** + * Build the Anthropic-shaped model list from configured tiers. Tiers that + * are unset in .env are skipped; if that leaves a family with no default, + * the first surviving entry of that family is promoted so Desktop always + * has a selectable default per family. + */ +function buildGatewayModels() { + const entries = []; + for (const slot of MODEL_SLOTS) { + let label = slot.label; + if (!label) { + const configured = config.modelTiers?.[slot.tier]; + if (!configured) continue; // tier unset in .env — skip this slot + const [provider, ...modelParts] = configured.split(":"); + const model = modelParts.join(":") || provider; + label = `Lynkr ${slot.tier} (${model})`; + } + entries.push({ + type: "model", + id: slot.id, + display_name: label, + created_at: slot.createdAt || CREATED_AT, + max_tokens: maxTokens(), + anthropic_family_tier: slot.family, + is_family_default: slot.isDefault, + }); + } + return entries; +} + +router.get("/models", (req, res, next) => { + const wantsAnthropic = + req.headers["anthropic-version"] || req.query.format === "anthropic"; + if (!gatewayEnabled() || !wantsAnthropic) return next(); + + try { + const data = buildGatewayModels(); + if (data.length === 0) return next(); // no tiers configured — fall through + logger.debug( + { modelCount: data.length, ua: req.headers["user-agent"] }, + "[ClaudeDesktopGateway] Listed tier models (Anthropic format)" + ); + res.json({ + data, + first_id: data[0].id, + last_id: data[data.length - 1].id, + has_more: false, + }); + } catch (error) { + logger.error( + { error: error.message }, + "[ClaudeDesktopGateway] Failed to list models" + ); + next(); // fall through to the OpenAI-format handler rather than 500 + } +}); + +module.exports = router; +module.exports._buildGatewayModels = buildGatewayModels; // test hook diff --git a/src/api/openai-router.js b/src/api/openai-router.js index 6b730ba..a21be60 100644 --- a/src/api/openai-router.js +++ b/src/api/openai-router.js @@ -403,6 +403,8 @@ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel) let emittedTools = 0; let finishReason = null; let started = false; + let inputTokens = null; + let outputTokens = null; const decoder = new TextDecoder(); try { @@ -420,6 +422,12 @@ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel) case "message_start": captureUsage(ev.message?.usage); if (!started) { started = true; res.write(chunk({ role: "assistant", content: "" })); } + if (typeof ev.message?.usage?.input_tokens === "number") { + inputTokens = ev.message.usage.input_tokens; + } + if (typeof ev.message?.usage?.output_tokens === "number") { + outputTokens = ev.message.usage.output_tokens; + } break; case "content_block_start": if (ev.content_block?.type === "tool_use") { @@ -446,6 +454,14 @@ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel) case "message_delta": captureUsage(ev.usage); if (ev.delta?.stop_reason) finishReason = mapStop(ev.delta.stop_reason); + // The final, authoritative output_tokens count arrives here — + // overwrite, don't merge, the message_start placeholder. + if (typeof ev.usage?.output_tokens === "number") { + outputTokens = ev.usage.output_tokens; + } + if (typeof ev.usage?.input_tokens === "number") { + inputTokens = ev.usage.input_tokens; + } break; case "message_stop": res.write(chunk({}, finishReason || "stop", finalUsage())); @@ -568,6 +584,35 @@ router.post("/chat/completions", async (req, res) => { logger.debug({ err: err.message }, "[OpenAI Router] client profile detection failed"); } + // Explicit model/effort pin: Codex's model picker (or any OpenAI-format + // caller) can name a real OpenAI model + reasoning.effort combo that + // resolveTierForOpenAIModel recognizes. Mirrors router.js's model-id-pin + // block for the Anthropic /v1/messages route — this one is needed + // separately because these OpenAI-shaped handlers call + // orchestrator.processMessage() directly and never go through router.js's + // pin/classifier code at all. See ../routing/openai-model-slots.js. + try { + const { resolveTierForOpenAIModel } = require("../routing/openai-model-slots"); + const _effort = req.body?.reasoning?.effort ?? req.body?.reasoning_effort ?? null; + const _pinTier = resolveTierForOpenAIModel(req.body?.model, _effort); + if (_pinTier) { + const { getModelTierSelector } = require("../routing"); + const _sel = getModelTierSelector().selectModel(_pinTier, null); + anthropicRequest._forceProvider = _sel.provider; + if (_sel.model) anthropicRequest._tierModel = _sel.model; + anthropicRequest._tierName = _pinTier; + anthropicRequest._forcedMethod = "openai_model_effort_pin"; + logger.debug({ + model: req.body?.model, + effort: _effort, + tier: _pinTier, + provider: _sel.provider, + }, "[OpenAI Router] Explicit model/effort pin — scoring bypassed"); + } + } catch (err) { + logger.debug({ err: err.message }, "[OpenAI Router] model/effort pin failed"); + } + const session = getSession(sessionId); if (req.body.stream) { @@ -768,6 +813,25 @@ router.post("/chat/completions", async (req, res) => { logger.debug({ chunk: "finish", finishReason: openaiResponse.choices[0].finish_reason }, "Sending finish chunk"); res.write(`data: ${JSON.stringify(finishChunk)}\n\n`); + + // Trailing usage chunk (choices: [], usage populated) — same shape + // as the Phase 2b live-stream path above. Previously this buffered + // synthesis only ever logged openaiResponse.usage (see below) and + // never wrote it to the client, so streaming clients on this path + // saw zero token/context usage too. + if (openaiResponse.usage) { + const usageChunk = { + id: openaiResponse.id, + object: "chat.completion.chunk", + created: openaiResponse.created, + model: streamModel, + system_fingerprint: "fp_lynkr", + choices: [], + usage: openaiResponse.usage, + }; + res.write(`data: ${JSON.stringify(usageChunk)}\n\n`); + } + res.write("data: [DONE]\n\n"); logger.debug({ contentLength: content.length, contentPreview: content.substring(0, 50) }, "=== SSE STREAM COMPLETE ==="); @@ -1831,6 +1895,32 @@ router.post("/responses", async (req, res) => { logger.debug({ err: err.message }, "[OpenAI Router] client profile detection failed"); } + // Explicit model/effort pin — see the identical block in /chat/completions + // above for the full rationale. This is the route Codex Desktop actually + // uses (wire_api="responses"), so this is the one that matters for its + // model picker specifically. + try { + const { resolveTierForOpenAIModel } = require("../routing/openai-model-slots"); + const _effort = req.body?.reasoning?.effort ?? req.body?.reasoning_effort ?? null; + const _pinTier = resolveTierForOpenAIModel(req.body?.model, _effort); + if (_pinTier) { + const { getModelTierSelector } = require("../routing"); + const _sel = getModelTierSelector().selectModel(_pinTier, null); + anthropicRequest._forceProvider = _sel.provider; + if (_sel.model) anthropicRequest._tierModel = _sel.model; + anthropicRequest._tierName = _pinTier; + anthropicRequest._forcedMethod = "openai_model_effort_pin"; + logger.debug({ + model: req.body?.model, + effort: _effort, + tier: _pinTier, + provider: _sel.provider, + }, "[OpenAI Router] Explicit model/effort pin (responses) — scoring bypassed"); + } + } catch (err) { + logger.debug({ err: err.message }, "[OpenAI Router] model/effort pin failed (responses)"); + } + const session = getSession(sessionId); if (req.body.stream) { diff --git a/src/api/router.js b/src/api/router.js index 31f4d1a..fe9926b 100644 --- a/src/api/router.js +++ b/src/api/router.js @@ -7,7 +7,9 @@ const logger = require("../logger"); const { createRateLimiter } = require("./middleware/rate-limiter"); const openaiRouter = require("./openai-router"); const providersRouter = require("./providers-handler"); +const claudeDesktopGatewayRouter = require("./claude-desktop-gateway"); const { getRoutingHeaders, getRoutingStats, analyzeComplexity, getModelTierSelector, analyzeRisk, checkSessionPin, writeSessionPin, checkPinScoreDrift } = require("../routing"); +const { resolveTierForModelId } = require("../routing/model-slots"); // Upstream streams can die without a clean end (reader.read() never // resolves on a dropped socket), hanging the client forever. Every @@ -1117,6 +1119,37 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => { // in a session skip the whole window-scored intent pipeline. let tier; const pinCheck = checkSessionPin(req.body); + + // Explicit model-id pin: Claude Desktop's model picker (or any Anthropic- + // format caller) can name one of the fixed ids claude-desktop-gateway.js + // advertises (see ../routing/model-slots.js). Unlike every signal below, + // this one is EXPLICIT user intent, not an inference — it wins over + // session pins, side-request detection, and content scoring alike. + // "Lynkr Auto" (claude-fable-5) and any unrecognized id resolve to null + // and fall through to the existing cascade unchanged. + const modelPinTier = resolveTierForModelId(req.body?.model); + if (modelPinTier) { + const _sel = getModelTierSelector().selectModel(modelPinTier, null); + tier = { + tier: modelPinTier, + provider: _sel.provider, + model: _sel.model || null, + score: null, + method: 'model_id_pin', + reason: 'client_selected_model', + base_tier: null, + escalation_source: null, + pinned: false, + switch_reason: null, + propensity: 1.0, + candidates: [{ provider: _sel.provider, model: _sel.model || null }], + }; + logger.debug({ + model: req.body.model, + tier: modelPinTier, + provider: _sel.provider, + }, "[Routing] Explicit model-id pin — scoring bypassed"); + } else { // Side-request detection. Claude Code fires internal background calls // (title generation, summarization, memory extraction, suggestion-mode // autocomplete) that REPLAY the conversation — so they share the @@ -1343,6 +1376,7 @@ router.post("/v1/messages", rateLimiter, async (req, res, next) => { }, "OAuth intent — side request (no tools), pin write skipped"); } } + } // end else — no explicit model-id pin, scored normally above // Subscription-only fork: anti-abuse stealth passthrough when the picked // tier resolves to azure-anthropic. Bypasses the orchestrator entirely @@ -2090,6 +2124,12 @@ router.get("/api/tokens/stats", (req, res) => { } }); +// Claude Desktop third-party gateway: intercepts GET /v1/models for +// Anthropic-API clients (anthropic-version header) and advertises Lynkr's +// tiers as Claude model families. Mounted BEFORE openaiRouter so the +// intercept wins; non-Anthropic callers fall through unchanged. +router.use("/v1", claudeDesktopGatewayRouter); + // Mount OpenAI-compatible endpoints for Cursor IDE support router.use("/v1", openaiRouter); diff --git a/src/clients/databricks.js b/src/clients/databricks.js index fab8737..74a8c12 100644 --- a/src/clients/databricks.js +++ b/src/clients/databricks.js @@ -77,11 +77,26 @@ function _stripInternalFields(body) { return cleaned || body; } -async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride, maxRetriesOverride }, providerLabel) { +async function performJsonRequest(url, { headers = {}, body, retryableStatusesOverride, maxRetriesOverride, timeoutMs }, providerLabel) { const agent = url.startsWith('https:') ? httpsAgent : httpAgent; body = _stripInternalFields(body); const isStreaming = body.stream === true; + // Optional per-call abort timeout (opt-in via timeoutMs) — most callers + // omit it and keep the historical unbounded-fetch behavior. Added for + // llama.cpp (see invokeLlamaCpp): a dead upstream that accepts the TCP + // connection but never answers at the application layer hangs fetch() + // indefinitely — live-observed at ~75s, riding an incidental OS/tunnel + // socket timeout rather than any deliberate bound. This makes the + // already-configured (but previously unused) LLAMACPP_TIMEOUT_MS real. + // A fresh signal is created per attempt below (not hoisted here) — + // AbortSignal.timeout()'s clock starts at construction, so one signal + // shared across withRetry's attempts would leave later retries with a + // shrinking (or already-expired) budget instead of a full timeout each. + const makeSignal = () => (typeof timeoutMs === "number" && timeoutMs > 0 + ? AbortSignal.timeout(timeoutMs) + : undefined); + // Streaming requests can't be retried, so handle them directly if (isStreaming) { const response = await fetch(url, { @@ -89,6 +104,7 @@ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOv headers, body: JSON.stringify(body), agent, + signal: makeSignal(), }); logger.debug({ @@ -122,6 +138,7 @@ async function performJsonRequest(url, { headers = {}, body, retryableStatusesOv headers, body: JSON.stringify(body), agent, + signal: makeSignal(), }); const text = await response.text(); @@ -829,7 +846,21 @@ async function invokeOpenRouter(body, _incomingHeaders = {}) { }, "Sending tools to OpenRouter"); } - return performJsonRequest(endpoint, { headers, body: openRouterBody }, "OpenRouter"); + // Same pre-return 429 check as invokeMoonshot/invokeBaidu — see the comment + // on invokeOpenAI's equivalent check for why this matters for streaming. + const response = await performJsonRequest(endpoint, { + headers, + body: openRouterBody, + retryableStatusesOverride: [500, 502, 503, 504], + }, "OpenRouter"); + + if (!response.ok && response.status === 429) { + const err = new Error(`OpenRouter rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`); + err.status = 429; + throw err; + } + + return response; } // Eden AI is an OpenAI-compatible gateway (provider/model naming, EU/GDPR). @@ -1229,17 +1260,34 @@ async function invokeAzureOpenAI(body, _incomingHeaders = {}) { // the conversation's first user message — stable across every turn of // the same task. Keep per-key traffic under ~15 req/min. ...(isGpt5 ? { prompt_cache_key: derivePromptCacheKey(body) } : {}), - stream: false + stream: body.stream ?? false }; logger.debug({ format: "responses", inputCount: responsesBody.input?.length, model: responsesBody.model, - hasTools: !!responsesBody.tools + hasTools: !!responsesBody.tools, + streaming: responsesBody.stream, }, "Using Responses API format"); const result = await performJsonRequest(endpoint, { headers, body: responsesBody }, "Azure OpenAI Responses"); + // Streaming: hand back a synthetic OpenAI-Chat-Completions-shaped SSE + // stream (see src/orchestrator/azure-responses-sse.js for why that shape + // rather than Anthropic SSE directly) so the orchestrator's existing + // sseTransform.openaiToAnthropicSSE call site — already wired for + // moonshot/baidu/openai's raw streams — handles this one identically, + // with zero changes needed there. Skips the whole buffered + // output-array/dedup/usage-remap conversion below entirely; that logic + // only makes sense for a complete, already-materialized response. + if (result.stream) { + const { azureResponsesToOpenAIChunks } = require("../orchestrator/azure-responses-sse"); + return { + ...result, + stream: azureResponsesToOpenAIChunks(result.stream, { model: responsesBody.model }), + }; + } + // Convert Responses API response to Chat Completions format if (result.ok && result.json?.output) { const outputArray = result.json.output || []; @@ -1306,6 +1354,8 @@ async function invokeAzureOpenAI(body, _incomingHeaders = {}) { toolCallNames: toolCalls.map(tc => tc.function.name) }, "Parsing Responses API output"); + const _rawUsage = result.json.usage; + result.json = { id: result.json.id, object: "chat.completion", @@ -1322,15 +1372,15 @@ async function invokeAzureOpenAI(body, _incomingHeaders = {}) { }], // Responses API usage is {input,output}_tokens; downstream reads // Chat Completions' {prompt,completion}_tokens. - usage: result.json.usage ? { - prompt_tokens: result.json.usage.prompt_tokens ?? result.json.usage.input_tokens ?? 0, - completion_tokens: result.json.usage.completion_tokens ?? result.json.usage.output_tokens ?? 0, - total_tokens: result.json.usage.total_tokens - ?? ((result.json.usage.input_tokens ?? 0) + (result.json.usage.output_tokens ?? 0)), + usage: _rawUsage ? { + prompt_tokens: _rawUsage.prompt_tokens ?? _rawUsage.input_tokens ?? 0, + completion_tokens: _rawUsage.completion_tokens ?? _rawUsage.output_tokens ?? 0, + total_tokens: _rawUsage.total_tokens + ?? ((_rawUsage.input_tokens ?? 0) + (_rawUsage.output_tokens ?? 0)), // Provider-side prompt-cache hits (Responses API: input_tokens_details, // Chat Completions: prompt_tokens_details) — telemetry reads this name. - cache_read_input_tokens: result.json.usage.input_tokens_details?.cached_tokens - ?? result.json.usage.prompt_tokens_details?.cached_tokens + cache_read_input_tokens: _rawUsage.input_tokens_details?.cached_tokens + ?? _rawUsage.prompt_tokens_details?.cached_tokens ?? null, } : undefined }; @@ -1437,7 +1487,23 @@ async function invokeOpenAI(body, _incomingHeaders = {}) { max_tokens: openAIBody.max_tokens, }, "=== OPENAI REQUEST ==="); - return performJsonRequest(endpoint, { headers, body: openAIBody }, "OpenAI"); + // Same pre-return 429 check as invokeMoonshot/invokeBaidu: performJsonRequest's + // streaming branch never throws on a bad status (retry is skipped entirely for + // stream:true), so without this a 429 was silently handed back as a "successful" + // stream/response and tier-fallback never got a chance to climb. + const response = await performJsonRequest(endpoint, { + headers, + body: openAIBody, + retryableStatusesOverride: [500, 502, 503, 504], + }, "OpenAI"); + + if (!response.ok && response.status === 429) { + const err = new Error(`OpenAI rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`); + err.status = 429; + throw err; + } + + return response; } async function invokeAtlas(body) { @@ -1495,12 +1561,26 @@ async function invokeAtlas(body) { // Chat completions are billable POSTs, so Atlas requests are never replayed // automatically. Callers can retry explicitly with their own idempotency policy. - return performJsonRequest(endpoint, { + // maxRetriesOverride/retryableStatusesOverride stay at 0/[] — this does NOT + // retry the Atlas request itself, it only decides whether invokeModel's + // caller escalates to a DIFFERENT provider/tier on 429, same as + // invokeMoonshot/invokeBaidu/invokeOpenAI/invokeOpenRouter already do. + // Without this, a 429 here was silently handed back as a "successful" + // response and tier-fallback never got a chance to climb. + const response = await performJsonRequest(endpoint, { headers, body: atlasBody, maxRetriesOverride: 0, retryableStatusesOverride: [], }, "Atlas Cloud"); + + if (!response.ok && response.status === 429) { + const err = new Error(`Atlas Cloud rate-limited: ${String(response.json?.error?.message || '').slice(0, 120)}`); + err.status = 429; + throw err; + } + + return response; } async function invokeLlamaCpp(body, _incomingHeaders = {}) { @@ -1580,10 +1660,18 @@ async function invokeLlamaCpp(body, _incomingHeaders = {}) { }, 'llama.cpp: Removed consecutive duplicate roles from message sequence'); } + // max_tokens floor: reasoning-capable local models (live-confirmed on this + // deployment's GPT-OSS build) can spend their entire budget on + // `reasoning_content` before writing any visible answer — a caller- + // supplied tiny budget (e.g. the caveman probe's max_tokens:1 seen in + // production logs) then returns finish_reason:"length" with zero visible + // text. Same fix already shipped for Ollama as LYNKR_OLLAMA_MIN_MAX_TOKENS; + // mirrored here rather than inventing a different mechanism. + const llamacppMinMaxTokens = Number(process.env.LYNKR_LLAMACPP_MIN_MAX_TOKENS) || 1536; const llamacppBody = { messages: deduplicated, temperature: body.temperature ?? 0.7, - max_tokens: body.max_tokens ?? 16384, + max_tokens: Math.max(body.max_tokens ?? 16384, llamacppMinMaxTokens), top_p: body.top_p ?? 1.0, stream: body.stream ?? false }; @@ -1633,7 +1721,11 @@ async function invokeLlamaCpp(body, _incomingHeaders = {}) { })) }, "=== LLAMA.CPP REQUEST ==="); - const result = await performJsonRequest(endpoint, { headers, body: llamacppBody }, "llama.cpp"); + const result = await performJsonRequest( + endpoint, + { headers, body: llamacppBody, timeoutMs: config.llamacpp.timeout }, + "llama.cpp", + ); // Context overflow is a CAPACITY error, not a request error: the local // model's slot is simply too small for this conversation (llama-server @@ -2063,11 +2155,23 @@ async function invokeZai(body, _incomingHeaders = {}) { zaiBody = { ...body }; zaiBody.model = mappedModel; - // Force buffered mode: with stream:true this endpoint returns ANTHROPIC - // SSE, but the downstream transformer parses OPENAI SSE — every chunk is - // unreadable and the client receives an empty completion. Buffered JSON - // converts correctly; the router synthesizes client-side SSE as usual. - zaiBody.stream = false; + // Honor body.stream as-is here. The ONLY caller that ever sets it true + // for this branch is passthrough-stream.js's handleNativeStream — zai + // isn't in sse-transformer.js's DEFAULT_OPENAI_SSE_PROVIDERS, so the + // normal buffered orchestrator (orchestrator/index.js) always sets + // cleanPayload.stream=false before invoking any provider not on that + // allowlist, and never reaches this branch with stream:true itself. + // Forcing false unconditionally (as this used to do) broke exactly that + // caller: _anthropicNative() advertises this endpoint as native-passthrough + // eligible, handleNativeStream sets stream:true and expects real + // Anthropic SSE bytes back, but got a buffered JSON body instead — + // handleNativeStream then saw no `.stream` field, silently gave up, and + // fell back to the buffered orchestrator path, which called zai AGAIN. + // Every native-passthrough request to this endpoint was double-invoking + // the real API. If a future caller other than handleNativeStream ever + // needs stream:true routed through the OpenAI-SSE transformer instead, + // that's a different bug — add zai to DEFAULT_OPENAI_SSE_PROVIDERS then, + // don't reintroduce this override. // Inject standard tools if client didn't send any (passthrough mode) if (!Array.isArray(zaiBody.tools) || zaiBody.tools.length === 0) { @@ -2222,12 +2326,18 @@ async function invokeMoonshot(body, _incomingHeaders = {}) { // Only the moonshot-v1-* models accept caller-supplied values. const isKimiPinned = /^kimi-k/i.test(mappedModel); + const { resolveThinkingParam } = require("./provider-capabilities"); const moonshotBody = { model: mappedModel, messages, max_tokens: body.max_tokens || 16384, temperature: isKimiPinned ? 1 : (body.temperature ?? 0.7), top_p: isKimiPinned ? 0.95 : (body.top_p ?? 1.0), + // kimi-k3 emits verbose reasoning_content by default, sharing the same + // token budget as the answer — without this, reasoning alone can (and + // did, live-confirmed) consume the whole max_tokens before any answer + // is written. See resolveThinkingParam's doc comment for the full story. + thinking: resolveThinkingParam(body), // Streaming honored since the Phase-2b sse-transformer landed: the raw // OpenAI SSE stream is returned below and reshaped in flight by the // orchestrator (moonshot is in DEFAULT_OPENAI_SSE_PROVIDERS). Buffered @@ -2371,12 +2481,18 @@ async function invokeBaidu(body, _incomingHeaders = {}) { messages.unshift({ role: "system", content: systemContent }); } + const { resolveThinkingParam } = require("./provider-capabilities"); const baiduBody = { model: mappedModel, messages, max_tokens: body.max_tokens || 16384, temperature: body.temperature ?? 0.7, top_p: body.top_p ?? 1.0, + // glm-5.2 emits verbose reasoning_content by default, sharing the same + // token budget as the answer — without this, reasoning alone can (and + // did, live-confirmed) consume the whole max_tokens before any answer + // is written. See resolveThinkingParam's doc comment for the full story. + thinking: resolveThinkingParam(body), // 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. diff --git a/src/clients/gpt-utils.js b/src/clients/gpt-utils.js index e80f6a7..4f550a5 100644 --- a/src/clients/gpt-utils.js +++ b/src/clients/gpt-utils.js @@ -38,6 +38,28 @@ function stringSimilarity(s1, s2) { return union.size > 0 ? intersection.size / union.size : 0; } +// Common argument keys a Read-style tool might carry its target path under, +// across different clients/harnesses. +const FILE_PATH_ARG_KEYS = ['file_path', 'path', 'filePath', 'filename', 'file']; + +/** + * Pull a file-path argument out of a (possibly stringified) args object, + * trying the common key spellings different clients use. + * @param {string|Object} args + * @returns {string|null} + */ +function extractFilePathArg(args) { + let obj = args; + if (typeof obj === 'string') { + try { obj = JSON.parse(obj); } catch { return null; } + } + if (!obj || typeof obj !== 'object') return null; + for (const key of FILE_PATH_ARG_KEYS) { + if (typeof obj[key] === 'string' && obj[key]) return obj[key]; + } + return null; +} + /** * Check if two tool calls are semantically similar * @param {Object} call1 - First tool call {name, arguments} @@ -59,17 +81,38 @@ function areSimilarToolCalls(call1, call2) { if (argsStr1 === argsStr2) return true; + const toolName = (name1 || '').toLowerCase(); + + // Read-style tools: NOT Jaccard-fuzzy-matched (see below for why), but a + // re-read of the exact SAME file at a different offset/limit is still a + // duplicate worth counting — exact-match above only catches identical + // args, so re-reads with different paging windows slipped through + // entirely (live incident: an agent re-read one file at overlapping + // offsets ~10 times, restating the same conclusion each time, and never + // hit the loop guard because every offset produced a "new" signature). + // Comparing only the path argument (ignoring offset/limit) catches that + // without resurrecting the bug below — this is exact path equality, not + // fuzzy token overlap, so it can't confuse two DIFFERENT files that merely + // share path segments. + if (toolName.includes('read')) { + const path1 = extractFilePathArg(args1); + const path2 = extractFilePathArg(args2); + if (path1 && path2 && path1 === path2) { + logger.debug({ tool: name1, path: path1 }, "Same-file re-read detected"); + return true; + } + return false; + } + // Only search-style tools get fuzzy matching; mutating tools with // near-identical args may be intentional repeats. - // 'read' is deliberately NOT fuzzy-matched: its argument is a file path, and - // absolute paths in one repo share nearly every Jaccard token - // (/Users/x/project/src/…), so reads of DIFFERENT files scored ≥0.8 and - // merged into one "repeated call" signature (live incident: an opencode + // 'read' is handled above, deliberately NOT via Jaccard: its argument is a + // file path, and absolute paths in one repo share nearly every Jaccard + // token (/Users/x/project/src/…), so reads of DIFFERENT files scored ≥0.8 + // and merged into one "repeated call" signature (live incident: an opencode // code-trace reading server.js, openai-router.js and orchestrator/index.js - // was flagged as a loop). A genuine re-read of the same file is caught by - // the exact-match branch above. + // was flagged as a loop). const searchTools = ['grep', 'glob', 'search', 'find', 'bash', 'shell']; - const toolName = (name1 || '').toLowerCase(); const isSearchTool = searchTools.some(t => toolName.includes(t)); if (isSearchTool) { diff --git a/src/clients/provider-capabilities.js b/src/clients/provider-capabilities.js index eef1fde..c105cb0 100644 --- a/src/clients/provider-capabilities.js +++ b/src/clients/provider-capabilities.js @@ -32,8 +32,35 @@ function getThinkingBehavior(providerType, model) { return "none"; } +/** + * Anthropic-shaped `thinking` request param to send upstream, honoring an + * explicit client request and otherwise defaulting to disabled. + * + * Confirmed live (2026-08-27) against both endpoints directly: Baidu + * Qianfan's `glm-5.2` and Moonshot's Kimi (`kimi-k3`) both emit verbose + * `reasoning_content` on EVERY call by default, sharing the same token + * budget as the visible answer — with no instruction at all, a baseline + * call to either returned ~500-600 chars of reasoning_content, an EMPTY + * `content`, and `finish_reason:"length"` (the whole max_tokens budget + * spent on reasoning, none left for the actual answer). The GLM/Qwen + * convention `enable_thinking:false` does NOT suppress this on either + * endpoint — only this Anthropic-shaped `{type:"disabled"}` param does + * (also confirmed live, on both). Not a caveman-specific issue — the same + * budget waste happens on every request to these two providers; caveman's + * elaborate constraint set just makes it worse, since the model has more to + * visibly deliberate against. + * + * @param {Object} body - incoming Anthropic-format request body + * @returns {Object} the `thinking` param to send upstream + */ +function resolveThinkingParam(body) { + if (body?.thinking && typeof body.thinking === "object") return body.thinking; + return { type: "disabled" }; +} + module.exports = { supportsNativeThinking, supportsReasoningContent, getThinkingBehavior, + resolveThinkingParam, }; diff --git a/src/context/compression.js b/src/context/compression.js index 73aa9aa..f14e1b4 100644 --- a/src/context/compression.js +++ b/src/context/compression.js @@ -126,31 +126,56 @@ function summarizeOldHistory(messages) { let hasUserInput = false; let hasAssistantOutput = false; + // Index tool_result blocks by tool_use_id so each tool_use below can carry + // a one-line recap of what it actually found. Previously this summary kept + // only the tool NAME ("Assistant used tools: bash") and threw away the + // result entirely — which meant a long tool-calling session forgot its own + // diagnosis every time it aged past the recent-turns window and had to + // re-derive it from scratch (live symptom: an agent stuck re-reading the + // same file and re-fetching the same issue instead of ever writing a fix). + const resultByToolUseId = new Map(); + for (const msg of messages) { + if (!Array.isArray(msg.content)) continue; + for (const block of msg.content) { + if (block?.type === 'tool_result' && block.tool_use_id) { + resultByToolUseId.set(block.tool_use_id, extractResultSnippet(block)); + } + } + } + for (const msg of messages) { if (msg.role === 'user') { hasUserInput = true; const content = extractTextContent(msg); - if (content.length < 200) { - keyPoints.push(`User: ${content}`); - } else { - // Compress long user messages - keyPoints.push(`User: ${content.substring(0, 150)}...`); + if (content) { + // compressText keeps head AND tail (unlike a flat substring(0, N)), + // which matters here too: the actionable part of a long user + // message is often at the end ("...so please fix X and open a PR"). + keyPoints.push(`User: ${compressText(content, 200)}`); } } else if (msg.role === 'assistant') { hasAssistantOutput = true; const content = extractTextContent(msg); - // Extract tool uses - const toolUses = extractToolUses(msg); - if (toolUses.length > 0) { - keyPoints.push(`Assistant used tools: ${toolUses.join(', ')}`); + // Extract tool uses WITH their outcome, not just the tool name. + const toolUseBlocks = extractToolUseBlocks(msg); + for (const use of toolUseBlocks) { + const argsSnippet = summarizeToolArgs(use.input); + const resultSnippet = resultByToolUseId.get(use.id); + keyPoints.push( + resultSnippet + ? `Assistant called ${use.name}(${argsSnippet}) → ${resultSnippet}` + : `Assistant called ${use.name}(${argsSnippet})` + ); } - // Add assistant text if meaningful - if (content.length > 20 && content.length < 200) { - keyPoints.push(`Assistant: ${content}`); - } else if (content.length >= 200) { - keyPoints.push(`Assistant: ${content.substring(0, 150)}...`); + // Add assistant text if meaningful. compressText (head+tail) instead + // of a head-only substring(0, 150) — conclusions ("Cause: ...", + // "Findings: ...") are usually written at the END of a reasoning + // turn, so a head-only cut was silently discarding exactly the part + // that mattered for remembering what was already diagnosed. + if (content.length > 20) { + keyPoints.push(`Assistant: ${compressText(content, 300)}`); } } } @@ -168,6 +193,51 @@ function summarizeOldHistory(messages) { }; } +/** + * Short, single-line recap of a tool_result's content — used so the + * compacted history shows what a tool call actually found, not just that it + * was called. Reuses compressText's head+tail truncation. + * + * @param {Object} block - tool_result block + * @returns {string} snippet, or '' if the result had no usable text + */ +function extractResultSnippet(block) { + const RESULT_SNIPPET_MAX = 160; + let text = ''; + if (typeof block.content === 'string') { + text = block.content; + } else if (Array.isArray(block.content)) { + text = block.content + .filter(item => typeof item === 'string' || item?.type === 'text') + .map(item => (typeof item === 'string' ? item : item.text || '')) + .join(' '); + } + text = text.trim(); + if (!text) return ''; + const prefix = block.is_error ? 'error: ' : ''; + return prefix + compressText(text, RESULT_SNIPPET_MAX); +} + +/** + * Compact, single-line rendering of a tool_use's arguments for the summary + * (e.g. `Read(file_path=".../types.go", offset=390)`), not the full JSON. + * + * @param {Object} input - tool_use input object + * @returns {string} + */ +function summarizeToolArgs(input) { + const ARGS_SNIPPET_MAX = 80; + if (!input || typeof input !== 'object') return ''; + try { + const flat = Object.entries(input) + .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`) + .join(', '); + return compressText(flat, ARGS_SNIPPET_MAX); + } catch { + return ''; + } +} + /** * Compress a single message * @@ -382,17 +452,19 @@ function extractTextContent(message) { } /** - * Extract tool names used in message + * Extract tool_use blocks (id/name/input) from a message — the fuller + * counterpart to extractToolUses(), which only returns names. Used by + * summarizeOldHistory() to pair each call with its outcome. * * @param {Object} message - Message object - * @returns {Array} Tool names + * @returns {Array<{id: string, name: string, input: Object}>} */ -function extractToolUses(message) { +function extractToolUseBlocks(message) { if (!Array.isArray(message.content)) return []; return message.content .filter(block => block.type === 'tool_use') - .map(block => block.name); + .map(block => ({ id: block.id, name: block.name, input: block.input })); } /** diff --git a/src/orchestrator/azure-responses-sse.js b/src/orchestrator/azure-responses-sse.js new file mode 100644 index 0000000..c9c02cb --- /dev/null +++ b/src/orchestrator/azure-responses-sse.js @@ -0,0 +1,223 @@ +/** + * Azure OpenAI Responses API — streaming adapter. + * + * The Responses API's streaming event vocabulary (response.created, + * response.output_item.added, response.content_part.added, + * response.output_text.delta/.done, response.function_call_arguments.delta/ + * .done, response.output_item.done, response.completed) is structurally + * different from OpenAI Chat Completions' generic `choices[0].delta` chunk + * shape, which is what sse-transformer.js's `_openaiToAnthropicEvents` + * understands. + * + * Rather than duplicate that function's Anthropic-SSE emission logic (badge + * injection, message_start/message_stop, the mid-stream-error and + * no-finish-reason safety branches, usage extraction) for a second wire + * format, this module translates Responses-API events into SYNTHETIC OpenAI + * Chat-Completions SSE TEXT (`data: {"choices":[...]}\n\n`) and hands that + * off unchanged to the existing, already-tested transformer. Net effect: + * `invokeAzureOpenAI` returns a stream from this module exactly the way + * invokeMoonshot/invokeBaidu/invokeOpenAI already return a raw upstream + * stream — the orchestrator's existing `sseTransform.openaiToAnthropicSSE` + * call site needs zero changes to consume it. + * + * Verified against two live captures from this deployment's actual Azure + * endpoint (2026-08-26, api-version=2025-04-01-preview, model gpt-5.6-sol): + * one plain-text response, one forced tool-call response. Re-verify against + * a live capture if Azure's preview API version changes this shape. + * + * Tool-call dedup: the buffered Responses-API conversion in databricks.js + * (invokeAzureOpenAI) filters out exact duplicate {name, arguments} + * function-call signatures within one response (an observed GPT-5.x quirk). + * To preserve that guarantee here without needing the FULL response in hand + * first, this module buffers a function_call's argument fragments locally + * (never emitting them) until that call's response.function_call_arguments.done + * fires, checks the signature, and only then emits the accumulated deltas — + * as one burst — or silently drops them if it's a repeat. This means + * function-call arguments have a small, per-call delay (between that call's + * first and last argument fragment) while text streams fully live with zero + * delay — a deliberate tradeoff to keep the existing dedup guarantee, not an + * oversight. + */ + +const logger = require("../logger"); +const { _sseDataLines } = require("./sse-transformer"); + +/** + * @param {ReadableStream|AsyncIterable} rawResponsesStream - raw Azure + * Responses API SSE byte stream (stream:true was set on the request). + * @param {Object} [opts] + * @param {string} [opts.model] - fallback model id for synthesized chunks. + * @returns {ReadableStream} synthetic OpenAI Chat-Completions SSE byte stream. + */ +function azureResponsesToOpenAIChunks(rawResponsesStream, opts = {}) { + const generator = _toOpenAIChunkText(rawResponsesStream, opts); + const encoder = new TextEncoder(); + return new ReadableStream({ + async pull(controller) { + try { + const { value, done } = await generator.next(); + if (done) { + controller.close(); + return; + } + controller.enqueue(encoder.encode(value)); + } catch (err) { + controller.error(err); + } + }, + async cancel() { + try { await generator.return(); } catch { /* already finished */ } + }, + }); +} + +async function* _toOpenAIChunkText(rawResponsesStream, opts) { + const id = `chatcmpl-azresp-${Date.now()}`; + const created = Math.floor(Date.now() / 1000); + const model = opts.model || "gpt-5.6-sol"; + + const chunk = (delta, extra = {}) => `data: ${JSON.stringify({ + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta, finish_reason: null }], + ...extra, + })}\n\n`; + + const finishChunk = (finishReason, usage) => `data: ${JSON.stringify({ + id, object: "chat.completion.chunk", created, model, + choices: [{ index: 0, delta: {}, finish_reason: finishReason }], + ...(usage ? { usage } : {}), + })}\n\n`; + + // output_index -> {kind: "message"|"function_call", openaiToolIndex, + // name, callId, argBuf: string}. argBuf only used for function_call. + const items = new Map(); + let nextToolIndex = 0; + const seenToolSignatures = new Set(); + let sawFunctionCall = false; + let sawAnyOutput = false; + + try { + for await (const payload of _sseDataLines(rawResponsesStream)) { + if (payload === "[DONE]") break; + let ev; + try { ev = JSON.parse(payload); } catch { continue; } + + switch (ev.type) { + case "response.output_item.added": { + const item = ev.item || {}; + if (item.type === "function_call") { + items.set(ev.output_index, { + kind: "function_call", + openaiToolIndex: nextToolIndex++, + name: item.name || "", + callId: item.call_id || item.id || "", + argBuf: "", + }); + } else { + items.set(ev.output_index, { kind: "message" }); + } + break; + } + + case "response.output_text.delta": { + sawAnyOutput = true; + if (ev.delta) yield chunk({ content: ev.delta }); + break; + } + + case "response.function_call_arguments.delta": { + const state = items.get(ev.output_index); + if (state && state.kind === "function_call") { + state.argBuf += ev.delta || ""; + } + break; + } + + case "response.function_call_arguments.done": { + const state = items.get(ev.output_index); + if (!state || state.kind !== "function_call") break; + sawAnyOutput = true; + sawFunctionCall = true; + const finalArgs = typeof ev.arguments === "string" ? ev.arguments : state.argBuf; + const signature = `${state.name}:${finalArgs}`; + if (seenToolSignatures.has(signature)) { + logger.warn({ + name: state.name, + argsPreview: finalArgs.slice(0, 120), + }, "[AzureResponsesSSE] Filtered duplicate streamed tool call"); + break; + } + seenToolSignatures.add(signature); + // First delta for this tool carries id/name (empty arguments); + // second carries the complete arguments as one fragment — matches + // real OpenAI streaming shape, which sse-transformer.js's toolAcc + // merge logic already expects (id/name first-non-null-wins, args + // concatenated across fragments). + yield chunk({ + tool_calls: [{ + index: state.openaiToolIndex, + id: state.callId, + type: "function", + function: { name: state.name, arguments: "" }, + }], + }); + yield chunk({ + tool_calls: [{ + index: state.openaiToolIndex, + function: { arguments: finalArgs }, + }], + }); + break; + } + + case "response.completed": { + const usageRaw = ev.response?.usage; + const usage = usageRaw ? { + prompt_tokens: usageRaw.input_tokens ?? 0, + completion_tokens: usageRaw.output_tokens ?? 0, + total_tokens: usageRaw.total_tokens + ?? ((usageRaw.input_tokens ?? 0) + (usageRaw.output_tokens ?? 0)), + prompt_tokens_details: { + cached_tokens: usageRaw.input_tokens_details?.cached_tokens ?? 0, + }, + } : undefined; + const finishReason = sawFunctionCall ? "tool_calls" : "stop"; + yield finishChunk(finishReason, usage); + yield "data: [DONE]\n\n"; + return; + } + + case "response.failed": + case "response.incomplete": { + logger.warn({ + type: ev.type, + error: ev.response?.error || ev.error || null, + }, "[AzureResponsesSSE] Upstream reported failure mid-stream"); + // No finish_reason chunk, no [DONE] — matches sse-transformer.js's + // own no-finish-reason safety branch (its EOF-without-finish_reason + // case), which synthesizes an Anthropic error event rather than a + // false-success message_stop when downstream sees the SSE end here. + return; + } + + default: + break; // response.created/.in_progress, content_part.*, output_item.done — no chunk needed + } + } + } catch (err) { + logger.warn({ err: err.message }, "[AzureResponsesSSE] Upstream stream failed mid-flight"); + return; + } + + // Stream ended without response.completed and without sawAnyOutput ever + // firing a finish — same "don't fake success" discipline as above. + if (!sawAnyOutput) { + logger.warn("[AzureResponsesSSE] Upstream stream ended with no output and no response.completed"); + } +} + +module.exports = { + azureResponsesToOpenAIChunks, + // Exported for unit tests. + _toOpenAIChunkText, +}; diff --git a/src/orchestrator/index.js b/src/orchestrator/index.js index ea34b78..b508044 100644 --- a/src/orchestrator/index.js +++ b/src/orchestrator/index.js @@ -2231,6 +2231,33 @@ IMPORTANT TOOL USAGE RULES: } if (toolCalls.length > 0) { + // Auto-resolve web_search/web_fetch server-side, but ONLY for clients + // src/routing/client-profiles.js's detectClient() didn't recognize — + // i.e. we have no signal the caller can fulfill these itself. Known + // harnesses (Claude Code AND Claude Desktop — both present as + // claude-cli/... since Desktop's gateway mode runs the same agent-sdk; + // also Cursor, goose, Codex) already execute these client-side and + // must keep doing so unchanged — this branch never fires for them. + // Does NOT reintroduce general server-mode tool execution (removed + // 2026-07-22, commit b32e988): only these two tool names, only for + // unrecognized clients, and only when EVERY call in this batch is one + // we can resolve (a mixed batch falls through to the normal + // forward-to-client path below, untouched). + const clientProfile = cleanPayload._clientProfile || null; + if (!clientProfile) { + const webSearchExec = require("../tools/web-search-exec"); + if (webSearchExec.canAutoResolveAll(toolCalls)) { + logger.info({ + sessionId: session?.id ?? null, + step: steps, + tools: toolCalls.map((tc) => tc.function?.name ?? tc.name), + }, "[web-search-exec] Auto-resolving web_search/web_fetch for unrecognized client"); + await webSearchExec.autoResolve(toolCalls, cleanPayload.messages); + steps++; + continue; + } + } + // Convert OpenAI/OpenRouter format to Anthropic format for session storage let sessionContent; if (providerType === "azure-anthropic") { diff --git a/src/orchestrator/sse-transformer.js b/src/orchestrator/sse-transformer.js index b29ae1d..077e4d3 100644 --- a/src/orchestrator/sse-transformer.js +++ b/src/orchestrator/sse-transformer.js @@ -28,6 +28,14 @@ const logger = require("../logger"); // stream:false predated this transformer). Caveat: reasoning_content deltas // (kimi thinking) are not reshaped — thinking text is dropped from streamed // responses; the buffered path still lifts it into thinking blocks. +// baidu (Qianfan's /v2/chat/completions) already returns the raw stream the +// same way moonshot does (invokeBaidu's `if (response?.stream) return +// response;`) and its endpoint is documented as OpenAI-compatible — but +// unlike moonshot this hasn't been E2E-verified against a live key as of +// this addition. If Qianfan's SSE deltas turn out not to match +// choices[0].delta exactly, remove it from this list (or set +// LYNKR_STREAM_TRANSFORM_PROVIDERS to exclude it) rather than patching the +// shared transformer for one provider's quirk. const DEFAULT_OPENAI_SSE_PROVIDERS = [ "openai", "atlas", @@ -37,12 +45,30 @@ const DEFAULT_OPENAI_SSE_PROVIDERS = [ "lmstudio", "llamacpp", "moonshot", + "baidu", ]; +// llama.cpp specific: reasoning-capable local builds (live-confirmed on this +// deployment's GPT-OSS model) emit `delta.reasoning_content` before any +// `delta.content`. This transformer only reads delta.content (see the +// per-chunk loop below) — reasoning deltas are silently dropped, so a client +// streaming through this path sees nothing at all during the thinking phase +// and, if reasoning consumes the whole max_tokens budget, nothing ever. +// Default OFF (buffered instead) for exactly the same reason Ollama already +// defaults to buffering (LYNKR_OLLAMA_BUFFER_RESPONSES) — the buffered path's +// convertOpenRouterResponseToAnthropic already lifts reasoning_content into a +// proper `thinking` block, so no data is lost there. Opt into live streaming +// (and accept dropped thinking text) via LYNKR_LLAMACPP_BUFFER_RESPONSES=false. function _transformProviders() { const env = process.env.LYNKR_STREAM_TRANSFORM_PROVIDERS; + if (env) { + return new Set(env.split(",").map((s) => s.trim()).filter(Boolean)); + } + const bufferLlamacpp = process.env.LYNKR_LLAMACPP_BUFFER_RESPONSES !== "false"; return new Set( - env ? env.split(",").map((s) => s.trim()).filter(Boolean) : DEFAULT_OPENAI_SSE_PROVIDERS, + bufferLlamacpp + ? DEFAULT_OPENAI_SSE_PROVIDERS.filter((p) => p !== "llamacpp") + : DEFAULT_OPENAI_SSE_PROVIDERS, ); } @@ -497,4 +523,9 @@ module.exports = { // Exported for unit tests. _openaiToAnthropicEvents, _anthropicToOpenaiEvents, + // Exported for reuse by other SSE adapters (e.g. azure-responses-sse.js) — + // generic, provider-agnostic SSE byte-stream parsing with no OpenAI-specific + // behavior, worth sharing rather than reimplementing per adapter. + _iterateStream, + _sseDataLines, }; diff --git a/src/routing/model-slots.js b/src/routing/model-slots.js new file mode 100644 index 0000000..b55502c --- /dev/null +++ b/src/routing/model-slots.js @@ -0,0 +1,41 @@ +/** + * Model id → tier mapping for Claude Desktop's model picker. + * + * Claude Desktop VALIDATES model ids against a fixed set — arbitrary ids + * (e.g. "lynkr-simple") make it show "model list hasn't loaded". These five + * slots mirror ollama's internal/proxy/claude_desktop_models.go, the ids + * Desktop currently accepts. + * + * Shared by: + * - src/api/claude-desktop-gateway.js — advertises these as the Anthropic- + * format model list Desktop's picker renders. + * - src/api/router.js — resolves an incoming request's `model` field back + * to a tier, so an explicit pick in Desktop's dropdown pins routing + * instead of only being advisory. + * + * @module routing/model-slots + */ + +const MODEL_SLOTS = [ + { id: "claude-fable-5", family: "fable", createdAt: "2026-06-09T00:00:00Z", isDefault: true, tier: null, label: "Lynkr Auto (tier routing)" }, + { id: "claude-opus-5", family: "opus", createdAt: "2026-07-24T00:00:00Z", isDefault: true, tier: "REASONING" }, + { id: "claude-sonnet-5", family: "sonnet", createdAt: "2026-06-30T00:00:00Z", isDefault: true, tier: "COMPLEX" }, + { id: "claude-sonnet-4-6", family: "sonnet", createdAt: "2025-11-18T00:00:00Z", isDefault: false, tier: "MEDIUM" }, + { id: "claude-haiku-4-5-20251001", family: "haiku", createdAt: "2025-10-01T00:00:00Z", isDefault: true, tier: "SIMPLE" }, +]; + +/** + * Resolve a client-supplied model id to the tier it pins. + * + * @param {string} modelId + * @returns {string|null} one of SIMPLE/MEDIUM/COMPLEX/REASONING, or null if + * the id is unrecognized or maps to "Auto" (no pin — caller should fall + * through to content-based scoring). + */ +function resolveTierForModelId(modelId) { + if (!modelId) return null; + const slot = MODEL_SLOTS.find((s) => s.id === modelId); + return slot?.tier || null; +} + +module.exports = { MODEL_SLOTS, resolveTierForModelId }; diff --git a/src/routing/openai-model-slots.js b/src/routing/openai-model-slots.js new file mode 100644 index 0000000..91eec81 --- /dev/null +++ b/src/routing/openai-model-slots.js @@ -0,0 +1,82 @@ +/** + * Model + reasoning-effort → tier mapping for Codex/ChatGPT desktop's model + * picker (the "5.6 Sol Light" dropdown in the app UI). + * + * This is the OpenAI/Responses-API-shaped counterpart to ./model-slots.js. + * That module works because Claude Desktop lets a gateway advertise + * completely made-up model ids ("claude-fable-5" etc.) via its own /v1/models + * list. Codex has no such gateway hook — its dropdown is populated from + * OpenAI's own real model catalog, and it always sends a real OpenAI model + * id (e.g. "gpt-5.6-sol", "gpt-5.6-mini") plus a `reasoning.effort` field + * (OpenAI's documented enum: minimal | low | medium | high). So instead of + * inventing ids, this pins on (model, effort) combinations Codex's dropdown + * can actually produce. + * + * Verification status: CONFIRMED live (2026-08-28) via a temp diagnostic log + * (since removed) that captured a real Codex Desktop request. A screenshot + * of the picker showed model options "5.6 Sol / 5.6 Terra / 5.6 Luna / 5.5 / + * 5.2" (five peer entries, no visible size/tier hint — an earlier version of + * this file guessed "luna" meant a cheap/distilled variant; that guess had + * no evidence and has been removed) and an "Effort" row labeled "Light". + * The captured request, sent with that exact picker state, carried + * `reasoning: {"effort":"low","summary":"detailed","context":"all_turns"}` + * — so the UI label "Light" is confirmed to be OpenAI's documented public + * enum value "low" verbatim, not a distinct string. resolveTierForOpenAIModel() + * stays conservative regardless: any unrecognized model or effort value + * returns null (no pin), falling through to normal content-based scoring. + * + * Scope note (also observed live in the same captured session): the pin + * only governs the FIRST model call of a turn. Multi-step agentic turns + * (tool calls, follow-up steps) re-score by content on each subsequent + * step — this is inherent to the shared `_forceProvider` mechanism + * (orchestrator/index.js deletes it after the first read), identical to + * how router.js's own Claude-Desktop-picker pin already behaves. Not a bug + * introduced here; a pre-existing property of the shared pin plumbing. + * + * Shared by: + * - src/api/openai-router.js — resolves an incoming /chat/completions or + * /responses request's `model` + `reasoning.effort` fields to a tier, + * mirroring router.js's model-id-pin block (which openai-router.js's + * handlers never go through — they call orchestrator.processMessage() + * directly). + * + * @module routing/openai-model-slots + */ + +// OpenAI's publicly documented naming convention for distilled/cheap model +// variants — NOT confirmed present in this app's specific dropdown (its +// real options are Sol/Terra/Luna/5.5/5.2, no mini/nano seen), kept only +// because it's a well-established public-catalog convention that costs +// nothing to check if some other Codex build or client does send one. +const SMALL_MODEL_PATTERN = /-(mini|nano)\b/i; + +// reasoning.effort → tier. Confirmed live 2026-08-28: Codex Desktop's +// "Light" picker label sends the literal wire value "low", not "light" — +// OpenAI's publicly documented Responses API enum, verbatim. See the module +// doc comment above for the captured request that confirmed this. +const EFFORT_TIER = { + minimal: "SIMPLE", + low: "MEDIUM", + medium: "COMPLEX", + high: "REASONING", +}; + +/** + * Resolve a Codex-style model id + reasoning effort to a Lynkr tier. + * + * @param {string|null|undefined} model - e.g. "gpt-5.6-sol" + * @param {string|null|undefined} effort - e.g. "low" (from body.reasoning.effort) + * @returns {string|null} one of SIMPLE/MEDIUM/COMPLEX/REASONING, or null when + * unrecognized — caller should fall through to normal content scoring. + */ +function resolveTierForOpenAIModel(model, effort) { + if (typeof model === "string" && SMALL_MODEL_PATTERN.test(model)) { + return "SIMPLE"; + } + if (typeof effort === "string" && EFFORT_TIER[effort.toLowerCase()]) { + return EFFORT_TIER[effort.toLowerCase()]; + } + return null; +} + +module.exports = { EFFORT_TIER, SMALL_MODEL_PATTERN, resolveTierForOpenAIModel }; diff --git a/src/tools/web-search-exec.js b/src/tools/web-search-exec.js new file mode 100644 index 0000000..65f3e51 --- /dev/null +++ b/src/tools/web-search-exec.js @@ -0,0 +1,182 @@ +/** + * Server-side web_search / web_fetch execution — narrowly scoped resurrection. + * + * Context: src/tools/web.js used to implement this (SearXNG-backed search, + * host-allowlisted fetch) and was deleted 2026-07-22 in commit b32e988, + * "Remove server-mode tool execution: Lynkr always forwards tool calls to + * the client." That was a deliberate architecture pivot, not a bug — Lynkr + * stopped executing ANY tool itself. + * + * This does NOT reintroduce general server-mode tool execution. It only + * auto-resolves `web_search`/`web_fetch` calls, and ONLY when the calling + * client is unrecognized by src/routing/client-profiles.js's detectClient() + * — i.e. Lynkr has no signal the caller can fulfill the call itself. + * Known harnesses (Claude Code / Claude Desktop's shared agent-sdk — both + * present as `claude-cli/...`, Cursor, goose, Codex) already execute these + * client-side today and are never routed through this module; see the call + * site in src/orchestrator/index.js's runAgentLoop. + * + * @module tools/web-search-exec + */ + +const config = require("../config"); +const logger = require("../logger"); + +const AUTO_RESOLVABLE_NAMES = new Set(["web_search", "websearch", "web_fetch", "webfetch"]); + +/** + * @param {string} name + * @returns {boolean} + */ +function isAutoResolvable(name) { + return AUTO_RESOLVABLE_NAMES.has(String(name || "").toLowerCase()); +} + +/** + * True only when every call in this batch is one we can resolve ourselves — + * a mixed batch (one auto-resolvable + one arbitrary client tool) falls + * through to the normal "forward to client" path untouched, since we can't + * partially resolve a batch the model expects answered together. + * + * @param {Array} toolCalls - normalized {id, function:{name}} or {name} calls + * @returns {boolean} + */ +function canAutoResolveAll(toolCalls) { + return Array.isArray(toolCalls) && toolCalls.length > 0 + && toolCalls.every((tc) => isAutoResolvable(tc?.function?.name ?? tc?.name)); +} + +function isHostAllowed(urlStr) { + if (config.webSearch?.allowAllHosts) return true; + try { + const host = new URL(urlStr).hostname.toLowerCase(); + const allowed = config.webSearch?.allowedHosts; + return Array.isArray(allowed) && allowed.includes(host); + } catch { + return false; + } +} + +async function _withTimeout(fn, timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fn(controller.signal); + } finally { + clearTimeout(timer); + } +} + +/** + * SearXNG-backed search. Retries transient failures per config.webSearch + * (retryEnabled/maxRetries) — mirrors the retry policy the deleted web.js + * had, since a single dropped connection to a local sidecar shouldn't fail + * the whole tool call. + * + * @param {string} query + * @returns {Promise} { query, results: [{title,url,snippet}] } or { error } + */ +async function searchWeb(query) { + const endpoint = config.webSearch?.endpoint || "http://localhost:8888/search"; + const timeoutMs = config.webSearch?.timeoutMs || 10000; + const maxRetries = config.webSearch?.retryEnabled ? (config.webSearch?.maxRetries ?? 2) : 0; + const url = `${endpoint}?q=${encodeURIComponent(query || "")}&format=json`; + + let lastErr = null; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + const res = await _withTimeout( + (signal) => fetch(url, { signal }), + timeoutMs, + ); + if (!res.ok) { + lastErr = `search backend returned HTTP ${res.status}`; + continue; + } + const json = await res.json(); + const results = (Array.isArray(json.results) ? json.results : []) + .slice(0, 8) + .map((r) => ({ title: r.title, url: r.url, snippet: r.content })); + return { query, results }; + } catch (err) { + lastErr = err.message; + } + } + logger.warn({ query, endpoint, err: lastErr }, "[web-search-exec] search failed after retries"); + return { query, error: `search failed: ${lastErr}` }; +} + +/** + * Generic URL fetch, host-allowlisted per config.webSearch (allowAllHosts / + * allowedHosts), truncated per config.webSearch.bodyPreviewMax. + * + * @param {string} targetUrl + * @returns {Promise} + */ +async function fetchUrl(targetUrl) { + if (!targetUrl) return { error: "no url provided" }; + if (!isHostAllowed(targetUrl)) { + logger.warn({ url: targetUrl }, "[web-search-exec] fetch blocked — host not allowed"); + return { url: targetUrl, error: "host not in allowedHosts (WEB_SEARCH_ALLOW_ALL=false)" }; + } + const maxPreview = config.webSearch?.bodyPreviewMax || 10000; + const timeoutMs = config.webSearch?.timeoutMs || 10000; + try { + const res = await _withTimeout( + (signal) => fetch(targetUrl, { signal }), + timeoutMs, + ); + const text = await res.text(); + return { + url: targetUrl, + status: res.status, + content: text.slice(0, maxPreview), + truncated: text.length > maxPreview, + }; + } catch (err) { + logger.warn({ url: targetUrl, err: err.message }, "[web-search-exec] fetch failed"); + return { url: targetUrl, error: `fetch failed: ${err.message}` }; + } +} + +/** + * Execute every call in this (pre-checked via canAutoResolveAll) batch and + * append the matching assistant tool_use + user tool_result turns to + * `messages`, mirroring exactly what a real client round-trip would send + * back — so the next agent-loop iteration behaves identically to a normal + * client-fulfilled tool exchange, and the client never sees the exchange. + * + * @param {Array} toolCalls + * @param {Array} messages - mutated in place (push only, never rewritten) + */ +async function autoResolve(toolCalls, messages) { + const toolUseBlocks = []; + const toolResultBlocks = []; + + for (const tc of toolCalls) { + const rawName = tc.function?.name ?? tc.name ?? ""; + const name = String(rawName).toLowerCase(); + let input = {}; + try { + const raw = tc.function?.arguments ?? tc.input ?? {}; + input = typeof raw === "string" ? JSON.parse(raw) : (raw || {}); + } catch { /* leave input empty — result will just reflect an empty query/url */ } + + toolUseBlocks.push({ type: "tool_use", id: tc.id, name: rawName, input }); + + const result = (name === "web_search" || name === "websearch") + ? await searchWeb(input.query || input.q || "") + : await fetchUrl(input.url || ""); + + toolResultBlocks.push({ + type: "tool_result", + tool_use_id: tc.id, + content: JSON.stringify(result), + }); + } + + messages.push({ role: "assistant", content: toolUseBlocks }); + messages.push({ role: "user", content: toolResultBlocks }); +} + +module.exports = { canAutoResolveAll, autoResolve, searchWeb, fetchUrl, isAutoResolvable }; diff --git a/test/azure-responses-sse.test.js b/test/azure-responses-sse.test.js new file mode 100644 index 0000000..2821ae8 --- /dev/null +++ b/test/azure-responses-sse.test.js @@ -0,0 +1,205 @@ +/** + * Azure OpenAI Responses API streaming adapter — regression tests. + * + * Fixtures below are VERBATIM captures from a live request against this + * deployment's actual Azure endpoint (2026-08-26, api-version= + * 2025-04-01-preview, model gpt-5.6-sol) — a plain-text response and a + * forced tool-call response. Not synthetic/hand-built payloads: if Azure + * changes this event shape, re-capture rather than hand-edit these. + */ +const assert = require('node:assert/strict'); +const { describe, it } = require('node:test'); +const { azureResponsesToOpenAIChunks, _toOpenAIChunkText } = require('../src/orchestrator/azure-responses-sse'); +const { _openaiToAnthropicEvents } = require('../src/orchestrator/sse-transformer'); + +const TEXT_FIXTURE = `event: response.created +data: {"type":"response.created","response":{"id":"resp_0c6f54e263876a2e006a8fb61175888190ba969326864f1ea2","object":"response","created_at":1787803153,"status":"in_progress","output":[],"usage":null},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0c6f54e263876a2e006a8fb61175888190ba969326864f1ea2","status":"in_progress","output":[],"usage":null},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"msg_0c6f54e263876a2e006a8fb61204288190a3870ff794a0f9da","type":"message","status":"in_progress","content":[],"role":"assistant"},"output_index":0,"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_0c6f54e263876a2e006a8fb61204288190a3870ff794a0f9da","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"banana","item_id":"msg_0c6f54e263876a2e006a8fb61204288190a3870ff794a0f9da","output_index":0,"sequence_number":4} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_0c6f54e263876a2e006a8fb61204288190a3870ff794a0f9da","output_index":0,"sequence_number":5,"text":"banana"} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_0c6f54e263876a2e006a8fb61204288190a3870ff794a0f9da","output_index":0,"part":{"type":"output_text","text":"banana"},"sequence_number":6} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"msg_0c6f54e263876a2e006a8fb61204288190a3870ff794a0f9da","type":"message","status":"completed","content":[{"type":"output_text","text":"banana"}],"role":"assistant"},"output_index":0,"sequence_number":7} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_0c6f54e263876a2e006a8fb61175888190ba969326864f1ea2","status":"completed","output":[{"id":"msg_0c6f54e263876a2e006a8fb61204288190a3870ff794a0f9da","type":"message","status":"completed","content":[{"type":"output_text","text":"banana"}],"role":"assistant"}],"usage":{"input_tokens":14,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":5,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":19}},"sequence_number":8} + +`; + +const TOOLCALL_FIXTURE = `event: response.created +data: {"type":"response.created","response":{"id":"resp_0666285fb13ce6f8006a8fb6470cf48194be1c7472353dabc4","status":"in_progress","output":[],"usage":null},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_0666285fb13ce6f8006a8fb6470cf48194be1c7472353dabc4","status":"in_progress","output":[],"usage":null},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","type":"function_call","status":"in_progress","arguments":"","call_id":"call_GJVC5hJMlt5zONEzCXHo8dij","name":"calculator"},"output_index":0,"sequence_number":2} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"{\\"","item_id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","output_index":0,"sequence_number":3} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"a","item_id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","output_index":0,"sequence_number":4} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"\\":","item_id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","output_index":0,"sequence_number":5} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"918","item_id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","output_index":0,"sequence_number":6} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"21","item_id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","output_index":0,"sequence_number":7} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":",\\"","item_id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","output_index":0,"sequence_number":8} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"b","item_id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","output_index":0,"sequence_number":9} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"\\":","item_id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","output_index":0,"sequence_number":10} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"3","item_id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","output_index":0,"sequence_number":11} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"}","item_id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","output_index":0,"sequence_number":12} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","arguments":"{\\"a\\":91821,\\"b\\":3}","item_id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","output_index":0,"sequence_number":13} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","type":"function_call","status":"completed","arguments":"{\\"a\\":91821,\\"b\\":3}","call_id":"call_GJVC5hJMlt5zONEzCXHo8dij","name":"calculator"},"output_index":0,"sequence_number":14} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_0666285fb13ce6f8006a8fb6470cf48194be1c7472353dabc4","status":"completed","output":[{"id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","type":"function_call","status":"completed","arguments":"{\\"a\\":91821,\\"b\\":3}","call_id":"call_GJVC5hJMlt5zONEzCXHo8dij","name":"calculator"}],"usage":{"input_tokens":63,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":23,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":86}},"sequence_number":15} + +`; + +async function collect(asyncGen) { + const out = []; + for await (const v of asyncGen) out.push(v); + return out; +} + +function textStreamFrom(str) { + return (async function* () { yield new TextEncoder().encode(str); })(); +} + +describe('azureResponsesToOpenAIChunks — Responses API -> OpenAI chunk shape', () => { + it('text response: emits content deltas and a stop finish with usage', async () => { + const chunks = await collect(_toOpenAIChunkText(textStreamFrom(TEXT_FIXTURE), { model: 'gpt-5.6-sol' })); + const parsed = chunks + .filter((c) => c.startsWith('data: ') && !c.includes('[DONE]')) + .map((c) => JSON.parse(c.slice(6))); + + const contentDeltas = parsed.filter((c) => c.choices[0].delta.content); + assert.equal(contentDeltas.map((c) => c.choices[0].delta.content).join(''), 'banana'); + + const last = parsed[parsed.length - 1]; + assert.equal(last.choices[0].finish_reason, 'stop'); + assert.equal(last.usage.prompt_tokens, 14); + assert.equal(last.usage.completion_tokens, 5); + assert.equal(last.usage.total_tokens, 19); + + assert.ok(chunks.some((c) => c.includes('[DONE]'))); + }); + + it('tool-call response: emits one complete tool_calls delta pair with correct args and finish_reason tool_calls', async () => { + const chunks = await collect(_toOpenAIChunkText(textStreamFrom(TOOLCALL_FIXTURE), { model: 'gpt-5.6-sol' })); + const parsed = chunks + .filter((c) => c.startsWith('data: ') && !c.includes('[DONE]')) + .map((c) => JSON.parse(c.slice(6))); + + const toolChunks = parsed.filter((c) => c.choices[0].delta.tool_calls); + assert.equal(toolChunks.length, 2, 'expected exactly one id/name chunk + one args chunk'); + assert.equal(toolChunks[0].choices[0].delta.tool_calls[0].function.name, 'calculator'); + assert.equal(toolChunks[0].choices[0].delta.tool_calls[0].id, 'call_GJVC5hJMlt5zONEzCXHo8dij'); + assert.equal(toolChunks[1].choices[0].delta.tool_calls[0].function.arguments, '{"a":91821,"b":3}'); + + const last = parsed[parsed.length - 1]; + assert.equal(last.choices[0].finish_reason, 'tool_calls'); + assert.equal(last.usage.prompt_tokens, 63); + assert.equal(last.usage.completion_tokens, 23); + }); + + it('drops a duplicate tool-call signature within one response', async () => { + // Same function_call_arguments.done fired twice for the same item — the + // real observed GPT-5.x quirk the buffered path's dedup was built for. + const duped = TOOLCALL_FIXTURE.replace( + 'event: response.completed', + `event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","arguments":"{\\"a\\":91821,\\"b\\":3}","item_id":"fc_0666285fb13ce6f8006a8fb64849a08194a57868782e3654a2","output_index":0,"sequence_number":13} + +event: response.completed`, + ); + const chunks = await collect(_toOpenAIChunkText(textStreamFrom(duped), { model: 'gpt-5.6-sol' })); + const parsed = chunks + .filter((c) => c.startsWith('data: ') && !c.includes('[DONE]')) + .map((c) => JSON.parse(c.slice(6))); + const toolChunks = parsed.filter((c) => c.choices[0].delta.tool_calls); + // Still exactly one id/name + one args pair — the repeat was dropped, not duplicated. + assert.equal(toolChunks.length, 2); + }); + + it('mid-stream response.failed does not emit a false-success finish/[DONE]', async () => { + const failed = TEXT_FIXTURE + .split('event: response.completed')[0] + + 'event: response.failed\ndata: {"type":"response.failed","response":{"error":{"message":"boom"}}}\n\n'; + const chunks = await collect(_toOpenAIChunkText(textStreamFrom(failed), { model: 'gpt-5.6-sol' })); + assert.ok(!chunks.some((c) => c.includes('[DONE]')), 'must not fake a clean completion'); + }); + + it('produces a real ReadableStream via azureResponsesToOpenAIChunks', async () => { + const stream = azureResponsesToOpenAIChunks(textStreamFrom(TEXT_FIXTURE), { model: 'gpt-5.6-sol' }); + assert.equal(typeof stream.getReader, 'function'); + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let text = ''; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value); + } + assert.ok(text.includes('banana')); + assert.ok(text.includes('[DONE]')); + }); +}); + +describe('azure-responses-sse -> sse-transformer end-to-end (Anthropic SSE)', () => { + it('text response reshapes into a valid Anthropic content_block_delta stream', async () => { + const openaiStream = azureResponsesToOpenAIChunks(textStreamFrom(TEXT_FIXTURE), { model: 'gpt-5.6-sol' }); + const anthropicEvents = await collect(_openaiToAnthropicEvents(openaiStream, { model: 'gpt-5.6-sol' })); + const text = anthropicEvents.join(''); + assert.ok(text.includes('"type":"message_start"')); + assert.ok(text.includes('banana')); + assert.ok(text.includes('"type":"message_stop"')); + // usage should have made it all the way through + assert.ok(text.includes('"output_tokens":5') || text.includes('output_tokens": 5')); + }); + + it('tool-call response reshapes into a single complete Anthropic tool_use block', async () => { + const openaiStream = azureResponsesToOpenAIChunks(textStreamFrom(TOOLCALL_FIXTURE), { model: 'gpt-5.6-sol' }); + const anthropicEvents = await collect(_openaiToAnthropicEvents(openaiStream, { model: 'gpt-5.6-sol' })); + const text = anthropicEvents.join(''); + assert.ok(text.includes('"type":"tool_use"')); + assert.ok(text.includes('calculator')); + // Anthropic clients must never see partial tool JSON — full args in one shot. + assert.ok(text.includes('{\\"a\\":91821,\\"b\\":3}') || text.includes('"a":91821')); + }); +}); diff --git a/test/gpt-utils.test.js b/test/gpt-utils.test.js new file mode 100644 index 0000000..0f1ac2f --- /dev/null +++ b/test/gpt-utils.test.js @@ -0,0 +1,58 @@ +const assert = require('assert'); +const { describe, it } = require('node:test'); +const { areSimilarToolCalls } = require('../src/clients/gpt-utils'); + +describe('areSimilarToolCalls', () => { + describe('read-tool same-file detection', () => { + it('flags re-reads of the SAME file at different offsets as similar', () => { + // Mirrors the live transcript: repeated Read of types.go at + // overlapping offsets, none of which matched exactly, so the loop + // guard never fired. + const a = { name: 'Read', input: { file_path: '/tmp/ollama/api/types.go', offset: 390, limit: 90 } }; + const b = { name: 'Read', input: { file_path: '/tmp/ollama/api/types.go', offset: 410, limit: 80 } }; + assert.equal(areSimilarToolCalls(a, b), true); + }); + + it('does NOT flag reads of DIFFERENT files as similar', () => { + // The original false-positive this whole exclusion was built to avoid + // (server.js / openai-router.js / orchestrator/index.js all read in + // one code-trace) — must stay refuted. + const a = { name: 'Read', input: { file_path: '/repo/src/server.js', offset: 0, limit: 100 } }; + const b = { name: 'Read', input: { file_path: '/repo/src/api/openai-router.js', offset: 0, limit: 100 } }; + assert.equal(areSimilarToolCalls(a, b), false); + }); + + it('handles OpenAI-shape calls (function.arguments as a JSON string)', () => { + const a = { function: { name: 'read', arguments: JSON.stringify({ path: '/a/b.go', offset: 1 }) } }; + const b = { function: { name: 'read', arguments: JSON.stringify({ path: '/a/b.go', offset: 200 }) } }; + assert.equal(areSimilarToolCalls(a, b), true); + }); + + it('does not merge when the path argument is missing entirely', () => { + const a = { name: 'Read', input: { offset: 1 } }; + const b = { name: 'Read', input: { offset: 2 } }; + assert.equal(areSimilarToolCalls(a, b), false); + }); + }); + + describe('existing behavior, unaffected', () => { + it('still treats identical args as similar regardless of tool', () => { + const a = { name: 'bash', input: { command: 'ls -la' } }; + const b = { name: 'bash', input: { command: 'ls -la' } }; + assert.equal(areSimilarToolCalls(a, b), true); + }); + + it('still fuzzy-matches near-identical search-tool args', () => { + // 9/11 shared tokens (~0.82 Jaccard) — above the 0.8 threshold. + const a = { name: 'grep', input: { pattern: 'alpha beta gamma delta epsilon zeta eta theta iota kappa' } }; + const b = { name: 'grep', input: { pattern: 'alpha beta gamma delta epsilon zeta eta theta iota lambda' } }; + assert.equal(areSimilarToolCalls(a, b), true); + }); + + it('does not match calls with different tool names', () => { + const a = { name: 'Read', input: { file_path: '/a.js' } }; + const b = { name: 'Write', input: { file_path: '/a.js' } }; + assert.equal(areSimilarToolCalls(a, b), false); + }); + }); +}); diff --git a/test/web-search-exec.test.js b/test/web-search-exec.test.js new file mode 100644 index 0000000..bb69063 --- /dev/null +++ b/test/web-search-exec.test.js @@ -0,0 +1,149 @@ +/** + * web-search-exec — narrowly-scoped server-side web_search/web_fetch + * execution for clients client-profiles.js doesn't recognize. + */ +const assert = require('node:assert/strict'); +const { describe, it, beforeEach, afterEach } = require('node:test'); +const { + isAutoResolvable, + canAutoResolveAll, + searchWeb, + fetchUrl, + autoResolve, +} = require('../src/tools/web-search-exec'); + +describe('isAutoResolvable / canAutoResolveAll', () => { + it('recognizes web_search and web_fetch names, case-insensitively', () => { + assert.equal(isAutoResolvable('web_search'), true); + assert.equal(isAutoResolvable('WebSearch'), true); + assert.equal(isAutoResolvable('web_fetch'), true); + assert.equal(isAutoResolvable('WebFetch'), true); + }); + + it('rejects arbitrary tool names', () => { + assert.equal(isAutoResolvable('Bash'), false); + assert.equal(isAutoResolvable('Read'), false); + assert.equal(isAutoResolvable(undefined), false); + }); + + it('resolves an all-web-tool batch', () => { + assert.equal(canAutoResolveAll([{ name: 'WebSearch' }, { function: { name: 'web_fetch' } }]), true); + }); + + it('refuses a mixed batch — never partially resolves', () => { + assert.equal(canAutoResolveAll([{ name: 'web_search' }, { name: 'Bash' }]), false); + }); + + it('refuses an empty batch', () => { + assert.equal(canAutoResolveAll([]), false); + }); +}); + +describe('searchWeb / fetchUrl (fetch mocked — no live SearXNG dependency)', () => { + let originalFetch; + beforeEach(() => { originalFetch = global.fetch; }); + afterEach(() => { global.fetch = originalFetch; }); + + it('searchWeb returns normalized results on success', async () => { + global.fetch = async (url) => { + assert.ok(String(url).includes('format=json')); + return { + ok: true, + status: 200, + json: async () => ({ + results: [ + { title: 'A', url: 'https://a.example', content: 'snippet a' }, + { title: 'B', url: 'https://b.example', content: 'snippet b' }, + ], + }), + }; + }; + const result = await searchWeb('test query'); + assert.equal(result.results.length, 2); + assert.equal(result.results[0].title, 'A'); + assert.equal(result.error, undefined); + }); + + it('searchWeb reports an error (not a throw) on non-ok response', async () => { + global.fetch = async () => ({ ok: false, status: 503 }); + const result = await searchWeb('test query'); + assert.ok(result.error); + assert.ok(result.error.includes('503')); + }); + + it('searchWeb reports an error on network failure without throwing', async () => { + global.fetch = async () => { throw new Error('ECONNREFUSED'); }; + const result = await searchWeb('test query'); + assert.ok(result.error.includes('ECONNREFUSED')); + }); + + it('fetchUrl returns truncated content within the configured preview max', async () => { + global.fetch = async () => ({ + ok: true, + status: 200, + text: async () => 'x'.repeat(50000), + }); + const result = await fetchUrl('https://example.com/big-page'); + assert.equal(result.status, 200); + assert.ok(result.content.length <= 50000); + assert.equal(result.truncated, true); + }); + + it('fetchUrl with no url returns an error, not a throw', async () => { + const result = await fetchUrl(''); + assert.ok(result.error); + }); +}); + +describe('autoResolve — message shape mirrors a real client round-trip', () => { + let originalFetch; + beforeEach(() => { + originalFetch = global.fetch; + global.fetch = async () => ({ + ok: true, + status: 200, + json: async () => ({ results: [{ title: 'T', url: 'https://x.example', content: 'c' }] }), + }); + }); + afterEach(() => { global.fetch = originalFetch; }); + + it('appends one assistant tool_use turn and one user tool_result turn', async () => { + const messages = [{ role: 'user', content: 'search for something' }]; + const toolCalls = [{ id: 'call_1', function: { name: 'web_search', arguments: '{"query":"something"}' } }]; + await autoResolve(toolCalls, messages); + + assert.equal(messages.length, 3); + const [, assistantTurn, userTurn] = messages; + assert.equal(assistantTurn.role, 'assistant'); + assert.equal(assistantTurn.content[0].type, 'tool_use'); + assert.equal(assistantTurn.content[0].id, 'call_1'); + assert.equal(assistantTurn.content[0].name, 'web_search'); + + assert.equal(userTurn.role, 'user'); + assert.equal(userTurn.content[0].type, 'tool_result'); + assert.equal(userTurn.content[0].tool_use_id, 'call_1'); + const parsed = JSON.parse(userTurn.content[0].content); + assert.equal(parsed.results[0].title, 'T'); + }); + + it('handles multiple calls in one batch, each getting its own tool_result', async () => { + const messages = []; + const toolCalls = [ + { id: 'call_a', name: 'web_search', input: { query: 'a' } }, + { id: 'call_b', name: 'web_search', input: { query: 'b' } }, + ]; + await autoResolve(toolCalls, messages); + const [assistantTurn, userTurn] = messages; + assert.equal(assistantTurn.content.length, 2); + assert.equal(userTurn.content.length, 2); + assert.equal(userTurn.content[0].tool_use_id, 'call_a'); + assert.equal(userTurn.content[1].tool_use_id, 'call_b'); + }); + + it('tolerates unparseable arguments without throwing', async () => { + const messages = []; + const toolCalls = [{ id: 'call_1', function: { name: 'web_search', arguments: 'not json' } }]; + await assert.doesNotReject(() => autoResolve(toolCalls, messages)); + assert.equal(messages.length, 2); + }); +}); From 8c6fbec92af4e8d59a336844e9b408058c2197e7 Mon Sep 17 00:00:00 2001 From: vishal veerareddy Date: Sat, 29 Aug 2026 23:30:14 -0700 Subject: [PATCH 2/3] fix: remove dead inputTokens/outputTokens vars in stream forwarder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lint failure on PR #98 (no-unused-vars). Leftover from resolving the merge conflict against upstream's captureUsage()/finalUsage() usage- accounting rewrite in forwardAnthropicStreamAsOpenAIChunks — I kept upstream's version but missed that these two local vars, and their assignments in message_start/message_delta, became dead once finalUsage() reads from usageAcc instead. Co-Authored-By: Claude Sonnet 5 --- src/api/openai-router.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/api/openai-router.js b/src/api/openai-router.js index a21be60..a0220ea 100644 --- a/src/api/openai-router.js +++ b/src/api/openai-router.js @@ -403,8 +403,6 @@ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel) let emittedTools = 0; let finishReason = null; let started = false; - let inputTokens = null; - let outputTokens = null; const decoder = new TextDecoder(); try { @@ -422,12 +420,6 @@ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel) case "message_start": captureUsage(ev.message?.usage); if (!started) { started = true; res.write(chunk({ role: "assistant", content: "" })); } - if (typeof ev.message?.usage?.input_tokens === "number") { - inputTokens = ev.message.usage.input_tokens; - } - if (typeof ev.message?.usage?.output_tokens === "number") { - outputTokens = ev.message.usage.output_tokens; - } break; case "content_block_start": if (ev.content_block?.type === "tool_use") { @@ -454,14 +446,6 @@ async function forwardAnthropicStreamAsOpenAIChunks(res, stream, requestedModel) case "message_delta": captureUsage(ev.usage); if (ev.delta?.stop_reason) finishReason = mapStop(ev.delta.stop_reason); - // The final, authoritative output_tokens count arrives here — - // overwrite, don't merge, the message_start placeholder. - if (typeof ev.usage?.output_tokens === "number") { - outputTokens = ev.usage.output_tokens; - } - if (typeof ev.usage?.input_tokens === "number") { - inputTokens = ev.usage.input_tokens; - } break; case "message_stop": res.write(chunk({}, finishReason || "stop", finalUsage())); From a40eb3e1f725d53a5e2aa1151b63ba74f8faf34a Mon Sep 17 00:00:00 2001 From: vishal veerareddy Date: Sat, 29 Aug 2026 23:51:01 -0700 Subject: [PATCH 3/3] added --- src/clients/databricks.js | 40 ++++++++++-- src/clients/provider-capabilities.js | 25 ++++++- src/orchestrator/index.js | 7 +- src/tools/web-search-exec.js | 98 ++++++++++++++++++++++++---- 4 files changed, 152 insertions(+), 18 deletions(-) diff --git a/src/clients/databricks.js b/src/clients/databricks.js index 74a8c12..3e918fa 100644 --- a/src/clients/databricks.js +++ b/src/clients/databricks.js @@ -1272,6 +1272,20 @@ async function invokeAzureOpenAI(body, _incomingHeaders = {}) { const result = await performJsonRequest(endpoint, { headers, body: responsesBody }, "Azure OpenAI Responses"); + // Same pre-return 429 check as invokeMoonshot/invokeBaidu/invokeOpenRouter + // — but placed BEFORE the stream branch below, unlike those. When + // body.stream is true, performJsonRequest returns {ok:false, status:429, + // stream} without throwing (streaming responses aren't retried), so if + // this check ran after the stream branch it would never fire: the 429 + // would get wrapped as a normal (broken) SSE stream and returned as if + // successful, and invokeModel's fallback catch would never see it — + // the next tier never gets a chance. + if (!result.ok && result.status === 429) { + const err = new Error(`Azure OpenAI rate-limited: ${String(result.json?.error?.message || '').slice(0, 120)}`); + err.status = 429; + throw err; + } + // Streaming: hand back a synthetic OpenAI-Chat-Completions-shaped SSE // stream (see src/orchestrator/azure-responses-sse.js for why that shape // rather than Anthropic SSE directly) so the orchestrator's existing @@ -1668,12 +1682,25 @@ async function invokeLlamaCpp(body, _incomingHeaders = {}) { // text. Same fix already shipped for Ollama as LYNKR_OLLAMA_MIN_MAX_TOKENS; // mirrored here rather than inventing a different mechanism. const llamacppMinMaxTokens = Number(process.env.LYNKR_LLAMACPP_MIN_MAX_TOKENS) || 1536; + // Enforce the buffer-by-default policy HERE, at the actual invocation + // point, rather than trusting body.stream — CodeRabbit correctly flagged + // that sse-transformer.js's _transformProviders() gate only affects the + // INITIAL provider decision (computed once in orchestrator/index.js + // before invokeModel). If a transform-eligible provider later falls back + // to llamacpp (tier-fallback / cascade in this same file reuses + // body.stream for fallback candidates), that inherited stream:true would + // bypass the gate entirely and llamacpp would stream live — dropping + // reasoning_content exactly like the bug this was meant to fix. Checking + // the same env var again right here covers the initial-pick path AND + // every fallback path uniformly, with no dependency on how llamacpp was + // reached. + const bufferLlamacpp = process.env.LYNKR_LLAMACPP_BUFFER_RESPONSES !== "false"; const llamacppBody = { messages: deduplicated, temperature: body.temperature ?? 0.7, max_tokens: Math.max(body.max_tokens ?? 16384, llamacppMinMaxTokens), top_p: body.top_p ?? 1.0, - stream: body.stream ?? false + stream: bufferLlamacpp ? false : (body.stream ?? false) }; // Inject standard tools if client didn't send any @@ -2336,8 +2363,10 @@ async function invokeMoonshot(body, _incomingHeaders = {}) { // kimi-k3 emits verbose reasoning_content by default, sharing the same // token budget as the answer — without this, reasoning alone can (and // did, live-confirmed) consume the whole max_tokens before any answer - // is written. See resolveThinkingParam's doc comment for the full story. - thinking: resolveThinkingParam(body), + // is written. See resolveThinkingParam's doc comment for the full story, + // including why mappedModel is passed (kimi-k2.7-code needs the field + // omitted entirely, not disabled). + thinking: resolveThinkingParam(body, mappedModel), // Streaming honored since the Phase-2b sse-transformer landed: the raw // OpenAI SSE stream is returned below and reshaped in flight by the // orchestrator (moonshot is in DEFAULT_OPENAI_SSE_PROVIDERS). Buffered @@ -2492,7 +2521,10 @@ async function invokeBaidu(body, _incomingHeaders = {}) { // token budget as the answer — without this, reasoning alone can (and // did, live-confirmed) consume the whole max_tokens before any answer // is written. See resolveThinkingParam's doc comment for the full story. - thinking: resolveThinkingParam(body), + // mappedModel is passed for signature parity with invokeMoonshot's call + // site — Baidu's ERNIE ids never match the Moonshot-specific exception, + // so behavior here is unchanged. + thinking: resolveThinkingParam(body, mappedModel), // 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. diff --git a/src/clients/provider-capabilities.js b/src/clients/provider-capabilities.js index c105cb0..83a4b2a 100644 --- a/src/clients/provider-capabilities.js +++ b/src/clients/provider-capabilities.js @@ -50,11 +50,32 @@ function getThinkingBehavior(providerType, model) { * elaborate constraint set just makes it worse, since the model has more to * visibly deliberate against. * + * Model-specific exception, also live-verified (2026-08-30) directly + * against api.moonshot.ai: `kimi-k2.7-code` (incl. -highspeed) rejects + * `{type:"disabled"}` outright — HTTP 400 "invalid thinking: only + * type=enabled is allowed for this model", since that model's thinking is + * always-on and cannot be turned off. Omit the field entirely for it rather + * than send a value the model 400s on. + * + * A web-sourced claim that `kimi-k3` similarly rejects `thinking` (and + * needs `reasoning_effort` instead) was checked the same way and is FALSE + * for the real live endpoint as of this writing — `kimi-k3` returns a + * clean 200 with `{type:"disabled"}`, exactly like the doc comment above + * already said. Don't reintroduce that "fix" without a fresh live probe; + * docs and reality disagreed here once already. + * * @param {Object} body - incoming Anthropic-format request body - * @returns {Object} the `thinking` param to send upstream + * @param {string} [mappedModel] - the provider-native model id actually + * being requested (e.g. "kimi-k2.7-code"), for the exception above. + * Providers without a model-specific exception can omit this. + * @returns {Object|undefined} the `thinking` param to send upstream, or + * `undefined` to omit the field entirely (JSON.stringify drops it). */ -function resolveThinkingParam(body) { +function resolveThinkingParam(body, mappedModel) { if (body?.thinking && typeof body.thinking === "object") return body.thinking; + if (typeof mappedModel === "string" && /^kimi-k2\.7-code/i.test(mappedModel)) { + return undefined; + } return { type: "disabled" }; } diff --git a/src/orchestrator/index.js b/src/orchestrator/index.js index b508044..db5cc0c 100644 --- a/src/orchestrator/index.js +++ b/src/orchestrator/index.js @@ -2252,8 +2252,13 @@ IMPORTANT TOOL USAGE RULES: step: steps, tools: toolCalls.map((tc) => tc.function?.name ?? tc.name), }, "[web-search-exec] Auto-resolving web_search/web_fetch for unrecognized client"); + // steps was already incremented for this iteration at the top of + // the loop (line ~2228) — incrementing again here double-counted + // it, so with the default maxSteps:2 the loop exited right after + // the FIRST web tool result, before the model ever saw it + // (max_steps_exceeded with no answer). Confirmed against the + // while(steps < settings.maxSteps) loop guard above. await webSearchExec.autoResolve(toolCalls, cleanPayload.messages); - steps++; continue; } } diff --git a/src/tools/web-search-exec.js b/src/tools/web-search-exec.js index 65f3e51..a135596 100644 --- a/src/tools/web-search-exec.js +++ b/src/tools/web-search-exec.js @@ -106,10 +106,61 @@ async function searchWeb(query) { return { query, error: `search failed: ${lastErr}` }; } +const MAX_REDIRECT_HOPS = 5; + +/** + * Read up to `maxBytes` (well, chars — decoded text) from a Response body, + * without ever buffering more than that in memory. Node's global fetch + * always provides a ReadableStream body; the non-streaming fallback exists + * only for defensiveness. + * + * @param {Response} response + * @param {number} maxChars + * @returns {Promise<{text: string, truncated: boolean}>} + */ +async function _readBounded(response, maxChars) { + const reader = response.body?.getReader?.(); + if (!reader) { + const full = await response.text(); + return { text: full.slice(0, maxChars), truncated: full.length > maxChars }; + } + const decoder = new TextDecoder(); + let text = ""; + try { + while (text.length < maxChars) { + const { done, value } = await reader.read(); + if (done) return { text, truncated: false }; + text += decoder.decode(value, { stream: true }); + } + // Hit the cap — confirm there was more data waiting (vs. landing exactly + // on the boundary) so `truncated` is accurate either way. + const over = text.length > maxChars; + if (over) text = text.slice(0, maxChars); + const { done } = await reader.read().catch(() => ({ done: true })); + return { text, truncated: over || !done }; + } finally { + try { await reader.cancel(); } catch { /* best-effort; body may already be spent */ } + } +} + /** * Generic URL fetch, host-allowlisted per config.webSearch (allowAllHosts / * allowedHosts), truncated per config.webSearch.bodyPreviewMax. * + * Redirects are followed manually, not via fetch's default redirect:"follow" + * — Node's global fetch would otherwise follow a redirect from an allowlisted + * endpoint straight to a blocked/private address without ever re-checking + * allowedHosts, defeating the allowlist entirely. Every hop's resolved + * Location is validated the same way the original targetUrl was. + * + * The same per-hop abort timer stays alive through body consumption too — + * previously it was cleared the moment fetch() resolved (headers received), + * before the body was ever read, so a slow or hostile allowed endpoint could + * hold the response open indefinitely with no timeout, and `.slice()` only + * trimmed the string AFTER it had already been fully buffered in memory. + * _readBounded() now runs inside the same _withTimeout(signal) call as the + * fetch itself, so an abort mid-read is a real abort, not just a display cap. + * * @param {string} targetUrl * @returns {Promise} */ @@ -121,18 +172,43 @@ async function fetchUrl(targetUrl) { } const maxPreview = config.webSearch?.bodyPreviewMax || 10000; const timeoutMs = config.webSearch?.timeoutMs || 10000; + + let currentUrl = targetUrl; try { - const res = await _withTimeout( - (signal) => fetch(targetUrl, { signal }), - timeoutMs, - ); - const text = await res.text(); - return { - url: targetUrl, - status: res.status, - content: text.slice(0, maxPreview), - truncated: text.length > maxPreview, - }; + for (let hop = 0; ; hop++) { + if (hop > MAX_REDIRECT_HOPS) { + return { url: targetUrl, error: `too many redirects (>${MAX_REDIRECT_HOPS})` }; + } + + const outcome = await _withTimeout(async (signal) => { + const response = await fetch(currentUrl, { signal, redirect: "manual" }); + if (response.status >= 300 && response.status < 400) { + return { redirect: true, status: response.status, location: response.headers.get("location") }; + } + const { text, truncated } = await _readBounded(response, maxPreview); + return { redirect: false, status: response.status, text, truncated }; + }, timeoutMs); + + if (outcome.redirect) { + if (!outcome.location) { + return { url: currentUrl, status: outcome.status, error: "redirect with no Location header" }; + } + const nextUrl = new URL(outcome.location, currentUrl).toString(); + if (!isHostAllowed(nextUrl)) { + logger.warn({ from: currentUrl, to: nextUrl }, "[web-search-exec] blocked redirect to disallowed host"); + return { url: targetUrl, error: "redirect target host not in allowedHosts" }; + } + currentUrl = nextUrl; + continue; + } + + return { + url: currentUrl, + status: outcome.status, + content: outcome.text, + truncated: outcome.truncated, + }; + } } catch (err) { logger.warn({ url: targetUrl, err: err.message }, "[web-search-exec] fetch failed"); return { url: targetUrl, error: `fetch failed: ${err.message}` };