Compare commits
7
Commits
8ae771b561
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
019337e795 | ||
|
|
c3aaa1ba4d | ||
|
|
3de32a10f3 | ||
|
|
b239b2a8a9 | ||
|
|
9fd0fb0181 | ||
|
|
e0eef05b6b | ||
|
|
3f0d904d1d |
No files matched your search
@@ -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
@@ -1,2 +1,18 @@
|
||||
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"
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
.planning/
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
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 = [
|
||||
@@ -27,7 +34,7 @@
|
||||
in {
|
||||
packages.default = pkgs.stdenv.mkDerivation {
|
||||
pname = "9router";
|
||||
version = "0.5.86";
|
||||
version = rev;
|
||||
src = ./.;
|
||||
|
||||
nativeBuildInputs = [
|
||||
@@ -53,6 +60,9 @@
|
||||
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 ==="
|
||||
|
||||
@@ -1,68 +1,18 @@
|
||||
import { FORMATS } from "../../translator/formats.js";
|
||||
import { needsTranslation } from "../../translator/index.js";
|
||||
import { fromOpenAIFinish } from "../../translator/concerns/finishReason.js";
|
||||
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
|
||||
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
|
||||
import { createErrorResult } from "../../utils/error.js";
|
||||
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
||||
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.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 { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
|
||||
import { decloakToolNames } from "../../utils/claudeCloaking.js";
|
||||
import { restoreToolNames } from "../../utils/opencodeFingerprint.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
|
||||
* 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 });
|
||||
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)
|
||||
: 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";
|
||||
// Responses-format translation produces a `object:"response"` body with no
|
||||
// `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 { HTTP_STATUS } from "../../config/runtimeConfig.js";
|
||||
import { FORMATS } from "../../translator/formats.js";
|
||||
import { toClaudeMessageShape } from "../../translator/concerns/claudeShape.js";
|
||||
import { PROVIDERS } from "../../config/providers.js";
|
||||
import { buildRequestDetail, extractRequestConfig, saveUsageStats, formatDoneLine } from "./requestDetail.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
|
||||
// nonStreamingHandler.js) to avoid a circular import: nonStreamingHandler
|
||||
// already imports parseSSEToOpenAIResponse from this module.
|
||||
const finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES
|
||||
let finalBody = sourceFormat === FORMATS.OPENAI_RESPONSES
|
||||
? chatCompletionToResponses(parsed, customToolNames)
|
||||
: 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": "*" } }) };
|
||||
} catch (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 };
|
||||
@@ -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 { FORMATS } from "../formats.js";
|
||||
import { stripEosSentinel } from "../concerns/eosStrip.js";
|
||||
|
||||
function stopThinkingBlock(state, results) {
|
||||
if (!state.thinkingBlockStarted) return;
|
||||
@@ -153,7 +154,7 @@ export function kiroToClaudeResponse(chunk, state) {
|
||||
results.push({
|
||||
type: "content_block_delta",
|
||||
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 { fromOpenAIFinish } from "../concerns/finishReason.js";
|
||||
import { extractReasoningText } from "../concerns/reasoning.js";
|
||||
import { stripEosSentinel } from "../concerns/eosStrip.js";
|
||||
|
||||
// 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
|
||||
@@ -175,7 +176,7 @@ export function openaiToClaudeResponse(chunk, state) {
|
||||
results.push({
|
||||
type: "content_block_delta",
|
||||
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 */}
|
||||
<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>
|
||||
<a
|
||||
href="https://github.com/mhiqrambg/9router-mibp-version"
|
||||
href="https://github.com/asepharyana/9router"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-block mt-1 text-[11px] hover:text-primary transition-colors"
|
||||
>
|
||||
MIBP Edition
|
||||
asepharyana/9router · MIBP merge
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,7 +128,7 @@ export default function Sidebar({ onClose }) {
|
||||
<h1 className="text-lg font-semibold tracking-tight text-text-main">
|
||||
{APP_CONFIG.name}
|
||||
</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>
|
||||
</Link>
|
||||
{updateInfo && (
|
||||
|
||||
@@ -5,6 +5,10 @@ export const APP_CONFIG = {
|
||||
name: "9Router Proxy",
|
||||
description: "AI Infrastructure Management",
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user