Compare commits

...
9 Commits
Author SHA1 Message Date
asepharyana 019337e795 feat: add dependabot-auto-merge.yml 2026-09-24 03:00:28 +07:00
asepharyana c3aaa1ba4d chore: update dependabot.yml 2026-09-24 03:00:26 +07:00
asepharyana 3de32a10f3 fix(translator): shape Anthropic non-stream responses from forced-streaming providers
The non-streaming Anthropic path leaked OpenAI chat.completion bodies to
Claude clients when the upstream provider forces streaming (forced
SSE->JSON path): parseSSEToOpenAIResponse yields choices[] which Claude
Code cannot parse. Convert to Claude Messages shape for sourceFormat
CLAUDE in sseToJsonHandler.

Shared toClaudeMessageShape/openAICompletionToClaudeMessage moved to
translator/concerns/claudeShape.js to avoid circular import between
nonStreamingHandler and sseToJsonHandler. 5 new tests: sse-to-json
reassembly + Claude guard, plus existing anthropic-nonstream-shape
updated to import from the concern.
2026-09-23 15:36:24 +07:00
asepharyana b239b2a8a9 chore: add .planning/ to .gitignore 2026-09-23 15:24:53 +07:00
asepharyana 9fd0fb0181 fix(anthropic): non-stream /v1/messages returned OpenAI shape; strip EOS sentinel from text deltas
BUG: Anthropic client (sourceFormat=CLAUDE) hitting a claude-target provider
got a raw OpenAI chat.completion body on non-streaming requests. The
needsTranslation(CLAUDE,CLAUDE) gate is false when target===source, so the
translator never ran; a claude-transport executor replying OpenAI JSON
(opencode/big-pickle) leaked choices[]/prompt_tokens to the client, which
Anthropic SDKs cannot parse (no content[] blocks, no type:"message").

FIX: shape-aware guard toClaudeMessageShape() in nonStreamingHandler — when
sourceFormat is CLAUDE, convert any OpenAI-shape body to a proper Claude
message (type, content blocks with thinking/text/tool_use, stop_reason via
finish mapping, usage input/output tokens). Claude-shaped bodies pass through.

Also: strip <|im_end|>/<|endoftext|>/<|eot_id|> EOS sentinels from Claude
text deltas in openai-to-claude and kiro-to-claude translators — the upstream
EOS token leaks into the final text_delta (observed 'OK<|im_end|>').

Tests: anthropic-nonstream-shape.test.js (6: eos strip + shape guard incl
tool_calls→tool_use, pass-through, finish mapping). 28/28 related tests green.
2026-09-23 15:24:15 +07:00
asepharyana e0eef05b6b feat(dashboard): show build git SHA in header + profile page
The version label now renders 'v0.5.86 (<shortsha>)' when the flake build
injects NEXT_PUBLIC_GIT_SHA (git commit identity of the deployed artifact),
so any running dashboard traces to its exact source commit. Falls back to
semver-only when the env var is absent (local dev). Also fixes the profile
page repo link back to asepharyana/9router (MIBP merge target), replacing
the mhiqrambg fork label left over from the merge.
2026-09-23 14:45:08 +07:00
asepharyana 3f0d904d1d chore(nix): version the build by git short SHA instead of package.json semver
flake.nix now uses self.shortRev (git HEAD short hash) as the derivation
version, so the store path is /nix/store/<hash>-9router-<shortsha> and any
deployed build traces to its exact source commit. Removes the package.json
version bump churn; the updater's deployed/current comparison switches to
git SHA.
2026-09-23 14:03:08 +07:00
asepharyana 8ae771b561 chore: ignore nix build result symlink 2026-09-23 11:56:19 +07:00
asepharyana 7001bd17ea chore(nix): add flake.nix + bun.lock so 9router builds/deploys from this repo
Migrate the Nix build + deploy source from /home/code/9router-repo (a pure
upstream mirror, now deleted) to this repo, which carries the MIBP fork
merge (freebuff, cline free models, proxy-pool fitness). flake.lock and
flake.nix copied verbatim from 9router-repo; bun.lock generated from the
merged dependency set. The cron updater (9router-update.sh) now points at
/home/code/9router with a keep-ours conflict policy.
2026-09-23 11:51:11 +07:00
17 changed files with 1872 additions and 61 deletions

No files matched your search

+17
View File
@@ -0,0 +1,17 @@
name: Dependabot Auto-Merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Enable auto-merge for Dependabot PR
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+17 -1
View File
@@ -1,2 +1,18 @@
version: 2 version: 2
updates: [] updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 10
groups:
production:
dependency-type: "production"
update-types:
- "minor"
- "patch"
development:
dependency-type: "development"
update-types:
- "minor"
- "patch"
+2 -1
View File
@@ -9,7 +9,7 @@
!.yarn/plugins !.yarn/plugins
!.yarn/releases !.yarn/releases
!.yarn/versions !.yarn/versions
.planning/
# testing # testing
/coverage /coverage
@@ -21,6 +21,7 @@ cli/.build-home/
product product
# production # production
/build /build
result
.idea/ .idea/
# misc # misc
+1403
View File
File diff suppressed because it is too large. Load diff
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1785301185,
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+103
View File
@@ -0,0 +1,103 @@
{
description = "9Router — AI routing gateway + Next.js dashboard (Nix build)";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
# Build identifier = the git commit this flake was built from, so the
# nix store path is "-9router-<shortsha>" and a deployed build can be
# traced back to its exact source commit (versioning by git SHA, no
# package.json version bumps needed). Falls back to "dirty" when the
# tree isn't a clean git checkout (self.shortRev is only defined when
# the flake lives in a git repo with no uncommitted changes).
rev = self.shortRev or self.rev or "dirty";
pkgs = import nixpkgs {
inherit system;
overlays = [
(self: super: {
bun = super.bun.overrideAttrs (old: rec {
version = "1.4.0";
src = super.fetchzip {
url = "https://github.com/oven-sh/bun/releases/download/bun-v1.4.0/bun-linux-x64.zip";
sha256 = "sha256:Poy0vf7yJ/hzk33QiQj5gnshI5Q7dfbaMD7xgwiyDKw=";
};
});
})
];
};
nodejs = pkgs.nodejs_22;
in {
packages.default = pkgs.stdenv.mkDerivation {
pname = "9router";
version = rev;
src = ./.;
nativeBuildInputs = [
pkgs.bun
pkgs.git
pkgs.python3
pkgs.gnumake
pkgs.gcc
pkgs.cacert
pkgs.makeBinaryWrapper
];
buildPhase = ''
export HOME=$TMPDIR/home
export npm_config_cache=$TMPDIR/npm-cache
mkdir -p "$npm_config_cache" "$HOME"
# SSL/TLS certs (Nix sandbox doesn't have system CA bundle)
export SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
export NODE_EXTRA_CA_CERTS=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt
echo "=== bun install ==="
bun install 2>&1
echo "=== bun run build (next build --webpack) ==="
# Inline the git SHA into the client bundle so the dashboard header
# shows "v0.5.86 (<shortsha>)" — traceable build identity in the UI.
export NEXT_PUBLIC_GIT_SHA="${rev}"
bun run build 2>&1
echo "=== Build complete ==="
'';
installPhase = ''
mkdir -p $out/bin $out/lib/9router
# Next.js standalone output (self-contained server + traced node_modules)
cp -r .next/standalone/* $out/lib/9router/
# Full node_modules from bun install — standalone trace can miss deps
# (e.g. undici for /v1/chat executor); overwrite traced subset
rm -rf $out/lib/9router/node_modules
cp -r node_modules $out/lib/9router/node_modules
# Full .next build (BUILD_ID, server chunks, static) — standalone output
# may omit it depending on Next version; the server requires it at runtime
cp -r .next $out/lib/9router/.next
rm -rf $out/lib/9router/.next/cache $out/lib/9router/.next/standalone
# Runtime engine + source dirs the standalone server traces/fetches
cp -r open-sse $out/lib/9router/open-sse 2>/dev/null || true
cp -r src $out/lib/9router/src 2>/dev/null || true
# Public assets
cp -r public $out/lib/9router/public 2>/dev/null || true
# Custom server (IP derivation, header sanitisation)
cp custom-server.js $out/lib/9router/custom-server.js
# Wrapper — cd into lib dir so process.cwd() resolves (Drizzle/relative lookups)
cat > $out/bin/9router << WRAPPER
#!${pkgs.runtimeShell}
export PATH=${nodejs}/bin:\$PATH
cd $out/lib/9router
exec ${nodejs}/bin/node custom-server.js
WRAPPER
chmod +x $out/bin/9router
'';
};
});
}
@@ -1,68 +1,18 @@
import { FORMATS } from "../../translator/formats.js"; import { FORMATS } from "../../translator/formats.js";
import { needsTranslation } from "../../translator/index.js"; import { needsTranslation } from "../../translator/index.js";
import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js";
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js"; import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js"; import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
import { createErrorResult } from "../../utils/error.js"; import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js"; import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
import { unwrapClineEnvelope } from "../../shared/clineEnvelope.js"; import { unwrapClineEnvelope } from "../../shared/clineEnvelope.js";
import { toClaudeMessageShape, openAICompletionToClaudeMessage } from "../../translator/concerns/claudeShape.js";
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js"; import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js"; import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { decloakToolNames } from "../../utils/claudeCloaking.js"; import { decloakToolNames } from "../../utils/claudeCloaking.js";
import { restoreToolNames } from "../../utils/opencodeFingerprint.js"; import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js"; import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
function parseToolArguments(value) {
if (!value) return {};
if (typeof value === "object") return value;
try {
return JSON.parse(value);
} catch {
return {};
}
}
function openAICompletionToClaudeMessage(responseBody) {
if (!responseBody?.choices?.[0]) return responseBody;
const choice = responseBody.choices[0];
const message = choice.message || {};
const content = [];
const reasoning = message.reasoning_content || message.provider_specific_fields?.reasoning_content || "";
if (reasoning) {
content.push({ type: "thinking", thinking: reasoning });
}
if (typeof message.content === "string" && message.content.length > 0) {
content.push({ type: "text", text: message.content });
}
for (const toolCall of message.tool_calls || []) {
const fn = toolCall.function || {};
content.push({
type: "tool_use",
id: toolCall.id || `toolu_${Date.now()}_${content.length}`,
name: fn.name || toolCall.name || "",
input: parseToolArguments(fn.arguments || toolCall.arguments),
});
}
if (content.length === 0) content.push({ type: "text", text: "" });
const usage = responseBody.usage || {};
return {
id: String(responseBody.id || `msg_${Date.now()}`).replace(/^chatcmpl-/, ""),
type: "message",
role: "assistant",
model: responseBody.model || "unknown",
content,
stop_reason: fromOpenAIFinish(choice.finish_reason, FORMATS.CLAUDE),
stop_sequence: null,
usage: {
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
},
};
}
/** /**
* Convert an OpenAI Chat Completions non-streaming response body into the * Convert an OpenAI Chat Completions non-streaming response body into the
* OpenAI Responses API shape. Used when a Responses-format client (e.g. Codex) * OpenAI Responses API shape. Used when a Responses-format client (e.g. Codex)
@@ -346,9 +296,19 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true }); saveUsageStats({ provider, model, tokens: usage, connectionId, apiKey, endpoint: clientRawRequest?.endpoint, silent: true });
if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } })); if (log?.line) log.line(reqTag, "📊", formatDoneLine({ usage, latency: { total: Date.now() - requestStartTime } }));
const translatedResponse = needsTranslation(targetFormat, sourceFormat) let translatedResponse = needsTranslation(targetFormat, sourceFormat)
? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat, customToolNames) ? translateNonStreamingResponse(responseBody, targetFormat, sourceFormat, customToolNames)
: responseBody; : responseBody;
// Shape-aware guard: an Anthropic client (sourceFormat=CLAUDE) must receive a
// Claude message even when the provider answered in OpenAI Chat shape. The
// needsTranslation(CLAUDE,CLAUDE) gate above is false when target===source
// (both CLAUDE), so the raw provider body is returned — and a claude-transport
// executor replying OpenAI JSON leaks a chat.completion body to the client
// (observed with opencode/big-pickle: no content[], no type:"message").
if (sourceFormat === FORMATS.CLAUDE) {
translatedResponse = toClaudeMessageShape(translatedResponse);
}
const isClaudeMessageResponse = sourceFormat === FORMATS.CLAUDE && translatedResponse?.type === "message"; const isClaudeMessageResponse = sourceFormat === FORMATS.CLAUDE && translatedResponse?.type === "message";
// Responses-format translation produces a `object:"response"` body with no // Responses-format translation produces a `object:"response"` body with no
// `choices`; skip the Chat-Completions-specific post-processing below for it. // `choices`; skip the Chat-Completions-specific post-processing below for it.
@@ -3,6 +3,7 @@ import { restoreToolNames } from "../../utils/opencodeFingerprint.js";
import { createErrorResult } from "../../utils/error.js"; import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js"; import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { FORMATS } from "../../translator/formats.js"; import { FORMATS } from "../../translator/formats.js";
import { toClaudeMessageShape } from "../../translator/concerns/claudeShape.js";
import { PROVIDERS } from "../../config/providers.js"; import { PROVIDERS } from "../../config/providers.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js"; import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.js";
import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js"; import { ROLE, RESPONSES_ITEM } from "../../translator/schema/index.js";
@@ -356,10 +357,17 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, ta
// lost on the non-streaming return path. Inlined (not imported from // lost on the non-streaming return path. Inlined (not imported from
// nonStreamingHandler.js) to avoid a circular import: nonStreamingHandler // nonStreamingHandler.js) to avoid a circular import: nonStreamingHandler
// already imports parseSSEToOpenAIResponse from this module. // already imports parseSSEToOpenAIResponse from this module.
const finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES let finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES
? chatCompletionToResponses(parsed, customToolNames) ? chatCompletionToResponses(parsed, customToolNames)
: parsed; : parsed;
// Anthropic client behind a forced-streaming chat provider: the SSE was
// OpenAI-shaped, so hand the client the Claude Messages shape (type:
// "message", content[] blocks, stop_reason) instead of leaking choices[].
if (sourceFormat === FORMATS.CLAUDE) {
finalBody = toClaudeMessageShape(finalBody);
}
return { success: true, response: new Response(JSON.stringify(restoreToolNames(finalBody, toolNameMap)), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) }; return { success: true, response: new Response(JSON.stringify(restoreToolNames(finalBody, toolNameMap)), { headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" } }) };
} catch (err) { } catch (err) {
console.error("[ChatCore] Chat Completions SSE→JSON failed:", err); console.error("[ChatCore] Chat Completions SSE→JSON failed:", err);
@@ -0,0 +1,74 @@
import { FORMATS } from "../formats.js";
import { fromOpenAIFinish } from "./finishReason.js";
// Shared OpenAI-Chat → Claude-Messages shape conversion.
//
// Lives in `concerns/` (not chatCore/) because BOTH the non-streaming handler
// and the forced-SSE→JSON handler need it, and importing it from
// nonStreamingHandler.js into sseToJsonHandler.js would create a circular
// import (nonStreamingHandler already imports parseSSEToOpenAIResponse from
// sseToJsonHandler).
function parseToolArguments(value) {
if (!value) return {};
if (typeof value === "object") return value;
try {
return JSON.parse(value);
} catch {
return {};
}
}
function openAICompletionToClaudeMessage(responseBody) {
if (!responseBody?.choices?.[0]) return responseBody;
const choice = responseBody.choices[0];
const message = choice.message || {};
const content = [];
const reasoning = message.reasoning_content || message.provider_specific_fields?.reasoning_content || "";
if (reasoning) {
content.push({ type: "thinking", thinking: reasoning });
}
if (typeof message.content === "string" && message.content.length > 0) {
content.push({ type: "text", text: message.content });
}
for (const toolCall of message.tool_calls || []) {
const fn = toolCall.function || {};
content.push({
type: "tool_use",
id: toolCall.id || `toolu_${Date.now()}_${content.length}`,
name: fn.name || toolCall.name || "",
input: parseToolArguments(fn.arguments || toolCall.arguments),
});
}
if (content.length === 0) content.push({ type: "text", text: "" });
const usage = responseBody.usage || {};
return {
id: String(responseBody.id || `msg_${Date.now()}`).replace(/^chatcmpl-/, ""),
type: "message",
role: "assistant",
model: responseBody.model || "unknown",
content,
stop_reason: fromOpenAIFinish(choice.finish_reason, FORMATS.CLAUDE),
stop_sequence: null,
usage: {
input_tokens: usage.prompt_tokens || usage.input_tokens || 0,
output_tokens: usage.completion_tokens || usage.output_tokens || 0,
},
};
}
/**
* Convert an OpenAI Chat Completions body into the Claude Messages shape when
* the client speaks Anthropic (sourceFormat=CLAUDE) but the provider replied
* OpenAI JSON. Shape-aware guard: Claude-shaped bodies pass through untouched.
*/
export function toClaudeMessageShape(responseBody) {
if (!responseBody || responseBody.type === "message") return responseBody;
if (responseBody?.choices) return openAICompletionToClaudeMessage(responseBody);
return responseBody;
}
/** Export the raw converter for tests that want it directly. */
export { openAICompletionToClaudeMessage };
+32
View File
@@ -0,0 +1,32 @@
// EOS sentinel strip for translated streaming text.
//
// Some upstream executors/providers (observed: opencode/big-pickle behind a
// claude transport) append their special end-of-stream token verbatim to the
// final text delta — e.g. "OK<|im_end|>" — because the tokenizer emits it and
// the executor's OpenAI-compatible decoder passes it through as content. An
// Anthropic client receiving such a delta renders the literal marker in the
// transcript (and a strict schema check flags the unknown character run).
// Stripping is safe: these tokens only ever appear exactly once, as a suffix
// of the final chunk, and never carry meaning in a translated stream.
const EOS_SENTINELS = [
"<|im_end|>",
"<|endoftext|>",
"<|eot_id|>",
"<|end|>",
];
/** Remove a trailing EOS sentinel from streaming text (suffix-only, once). */
export function stripEosSentinel(text) {
if (!text || typeof text !== "string") return text;
for (const sentinel of EOS_SENTINELS) {
if (text.endsWith(sentinel)) {
return text.slice(0, -sentinel.length);
}
}
// Some providers emit it mid-chunk boundary (sentinel split across two
// deltas) — strip any occurrence, since these tokens have no legitimate
// place in model output.
const hasAny = EOS_SENTINELS.find((s) => text.includes(s));
if (hasAny) return text.split(hasAny).join("");
return text;
}
@@ -15,6 +15,7 @@
*/ */
import { register } from "../index.js"; import { register } from "../index.js";
import { FORMATS } from "../formats.js"; import { FORMATS } from "../formats.js";
import { stripEosSentinel } from "../concerns/eosStrip.js";
function stopThinkingBlock(state, results) { function stopThinkingBlock(state, results) {
if (!state.thinkingBlockStarted) return; if (!state.thinkingBlockStarted) return;
@@ -153,7 +154,7 @@ export function kiroToClaudeResponse(chunk, state) {
results.push({ results.push({
type: "content_block_delta", type: "content_block_delta",
index: state.textBlockIndex, index: state.textBlockIndex,
delta: { type: "text_delta", text: delta.content }, delta: { type: "text_delta", text: stripEosSentinel(delta.content) },
}); });
} }
@@ -3,6 +3,7 @@ import { FORMATS } from "../formats.js";
import { ROLE, CLAUDE_BLOCK, MODEL_FALLBACK } from "../schema/index.js"; import { ROLE, CLAUDE_BLOCK, MODEL_FALLBACK } from "../schema/index.js";
import { fromOpenAIFinish } from "../concerns/finishReason.js"; import { fromOpenAIFinish } from "../concerns/finishReason.js";
import { extractReasoningText } from "../concerns/reasoning.js"; import { extractReasoningText } from "../concerns/reasoning.js";
import { stripEosSentinel } from "../concerns/eosStrip.js";
// Legacy "proxy_" prefix used by older request translators. Response strips it // Legacy "proxy_" prefix used by older request translators. Response strips it
// defensively so tool names from such turns resolve back (e.g. proxy_Read → Read // defensively so tool names from such turns resolve back (e.g. proxy_Read → Read
@@ -175,7 +176,7 @@ export function openaiToClaudeResponse(chunk, state) {
results.push({ results.push({
type: "content_block_delta", type: "content_block_delta",
index: state.textBlockIndex, index: state.textBlockIndex,
delta: { type: "text_delta", text: delta.content } delta: { type: "text_delta", text: stripEosSentinel(delta.content) }
}); });
} }
@@ -1685,15 +1685,15 @@ export default function ProfilePage() {
{/* App Info */} {/* App Info */}
<div className="text-center text-xs sm:text-sm text-text-muted py-4"> <div className="text-center text-xs sm:text-sm text-text-muted py-4">
<p>{APP_CONFIG.name} v{APP_CONFIG.version}</p> <p>{APP_CONFIG.name} v{APP_CONFIG.version}{APP_CONFIG.gitSha ? ` (${APP_CONFIG.gitSha})` : ""}</p>
<p className="mt-1">{isRemoteHost ? "Remote Mode" : "Local Mode - All data stored on your machine"}</p> <p className="mt-1">{isRemoteHost ? "Remote Mode" : "Local Mode - All data stored on your machine"}</p>
<a <a
href="https://github.com/mhiqrambg/9router-mibp-version" href="https://github.com/asepharyana/9router"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-block mt-1 text-[11px] hover:text-primary transition-colors" className="inline-block mt-1 text-[11px] hover:text-primary transition-colors"
> >
MIBP Edition asepharyana/9router · MIBP merge
</a> </a>
</div> </div>
</div> </div>
+1 -1
View File
@@ -128,7 +128,7 @@ export default function Sidebar({ onClose }) {
<h1 className="text-lg font-semibold tracking-tight text-text-main"> <h1 className="text-lg font-semibold tracking-tight text-text-main">
{APP_CONFIG.name} {APP_CONFIG.name}
</h1> </h1>
<span className="text-xs text-text-muted">v{APP_CONFIG.version}</span> <span className="text-xs text-text-muted">v{APP_CONFIG.version}{APP_CONFIG.gitSha ? ` (${APP_CONFIG.gitSha})` : ""}</span>
</div> </div>
</Link> </Link>
{updateInfo && ( {updateInfo && (
+4
View File
@@ -5,6 +5,10 @@ export const APP_CONFIG = {
name: "9Router Proxy", name: "9Router Proxy",
description: "AI Infrastructure Management", description: "AI Infrastructure Management",
version: pkg.version, version: pkg.version,
// Build identity = git short SHA, injected at build time by the Nix flake
// (NEXT_PUBLIC_GIT_SHA). Falls back to "dirty"/undefined in dev where the
// env var is absent, so the dashboard shows just the semver.
gitSha: process.env.NEXT_PUBLIC_GIT_SHA || null,
}; };
// GitHub configuration // GitHub configuration
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
const { stripEosSentinel } = await import("../../open-sse/translator/concerns/eosStrip.js");
const { toClaudeMessageShape } = await import("../../open-sse/translator/concerns/claudeShape.js");
// An OpenAI-shape body as returned by a claude-transport executor (opencode/
// big-pickle): no type:"message", choices[] present. This is what an Anthropic
// client request gets today via the CLAUDE→CLAUDE path needsTranslation skips.
const OPENAI_BODY = {
id: "chatcmpl-abc123",
object: "chat.completion",
created: 1789056634,
model: "big-pickle",
choices: [
{
index: 0,
message: { role: "assistant", content: "OK", tool_calls: null },
finish_reason: "stop",
},
],
usage: { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 },
};
describe("stripEosSentinel", () => {
it("removes trailing <|im_end|>", () => {
expect(stripEosSentinel("OK<|im_end|>")).toBe("OK");
});
it("removes trailing <|endoftext|> and <|eot_id|>", () => {
expect(stripEosSentinel("hi<|endoftext|>")).toBe("hi");
expect(stripEosSentinel("hi<|eot_id|>")).toBe("hi");
});
it("leaves normal text untouched", () => {
expect(stripEosSentinel("plain answer")).toBe("plain answer");
expect(stripEosSentinel("")).toBe("");
expect(stripEosSentinel(null)).toBeNull();
});
});
describe("toClaudeMessageShape (Anthropic non-stream guard)", () => {
it("converts OpenAI body to Claude message", () => {
const out = toClaudeMessageShape(structuredClone(OPENAI_BODY));
expect(out.type).toBe("message");
expect(out.content).toEqual([{ type: "text", text: "OK" }]);
expect(out.stop_reason).toBe("end_turn");
expect(out.stop_sequence).toBeNull();
expect(out.usage).toEqual({ input_tokens: 10, output_tokens: 2 });
});
it("leaves an already-Claude message untouched", () => {
const claudeBody = {
id: "msg_abc",
type: "message",
role: "assistant",
model: "claude",
content: [{ type: "text", text: "OK" }],
stop_reason: "end_turn",
};
expect(toClaudeMessageShape(claudeBody)).toBe(claudeBody);
});
it("converts tool_calls to tool_use blocks", () => {
const body = structuredClone(OPENAI_BODY);
body.choices[0].message.tool_calls = [
{ id: "call_1", type: "function", function: { name: "read", arguments: "{\"path\":\"/x\"}" } },
];
body.choices[0].finish_reason = "tool_calls";
const out = toClaudeMessageShape(body);
const toolBlock = out.content.find((c) => c.type === "tool_use");
expect(toolBlock.name).toBe("read");
expect(toolBlock.id).toBe("call_1");
expect(toolBlock.input).toEqual({ path: "/x" });
expect(out.stop_reason).toBe("tool_use");
});
});
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
const { parseSSEToOpenAIResponse } = await import("../../open-sse/handlers/chatCore/sseToJsonHandler.js");
const { toClaudeMessageShape } = await import("../../open-sse/translator/concerns/claudeShape.js");
// SSE fixture of an OpenAI chat streaming provider (eg big-pickle forced
// streaming): text deltas + reasoning + tool_calls + usage.
const OPENAI_SSE = [
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1000,\"model\":\"big-pickle\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"reasoning_content\":\"let me think\"},\"finish_reason\":null}]}",
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1000,\"model\":\"big-pickle\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hel\"},\"finish_reason\":null}]}",
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1000,\"model\":\"big-pickle\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"lo\"},\"finish_reason\":null}]}",
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1000,\"model\":\"big-pickle\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Jakarta\\\"}\"}}]},\"finish_reason\":null}]}",
"data: {\"id\":\"chatcmpl-abc\",\"object\":\"chat.completion.chunk\",\"created\":1000,\"model\":\"big-pickle\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":{\"prompt_tokens\":10,\"completion_tokens\":20,\"total_tokens\":30}}",
"data: [DONE]"
].join("\n");
describe("parseSSEToOpenAIResponse (forced streaming → JSON)", () => {
it("reassembles content, reasoning, tool_calls, finish_reason, usage", () => {
const parsed = parseSSEToOpenAIResponse(OPENAI_SSE, "big-pickle");
expect(parsed.object).toBe("chat.completion");
expect(parsed.choices[0].message.content).toBe("Hello");
expect(parsed.choices[0].message.reasoning_content).toBe("let me think");
expect(parsed.choices[0].message.tool_calls).toHaveLength(1);
expect(parsed.choices[0].message.tool_calls[0].function.name).toBe("get_weather");
expect(parsed.choices[0].message.tool_calls[0].function.arguments).toContain("Jakarta");
expect(parsed.choices[0].finish_reason).toBe("tool_calls");
expect(parsed.usage.total_tokens).toBe(30);
});
it("returns null for empty / malformed SSE", () => {
expect(parseSSEToOpenAIResponse("", "m")).toBeNull();
expect(parseSSEToOpenAIResponse("data: [DONE]", "m")).toBeNull();
});
});
describe("sseToJson Anthropic guard (sourceFormat=CLAUDE)", () => {
it("converts parsed OpenAI body to Claude message shape", () => {
const parsed = parseSSEToOpenAIResponse(OPENAI_SSE, "big-pickle");
const out = toClaudeMessageShape(parsed);
expect(out.type).toBe("message");
expect(out.role).toBe("assistant");
expect(Array.isArray(out.content)).toBe(true);
expect(out.content[0].type).toBe("thinking");
expect(out.content[0].thinking).toBe("let me think");
expect(out.content[1].type).toBe("text");
expect(out.content[1].text).toBe("Hello");
expect(out.content[2].type).toBe("tool_use");
expect(out.content[2].name).toBe("get_weather");
expect(out.stop_reason).toBe("tool_use");
expect(out.usage.input_tokens).toBe(10);
expect(out.usage.output_tokens).toBe(20);
});
});