Merge origin/master (v0.5.69) into gitea/new_feature

This commit is contained in:
2026-09-07 14:10:11 +07:00
222 changed files with 12285 additions and 3371 deletions

View File

@@ -1,16 +1,16 @@
import { createHash, randomBytes, randomUUID } from "crypto";
import { CLAUDE_TOOL_SUFFIX, CC_DEFAULT_TOOLS } from "../config/appConstants.js";
import { CLAUDE_CLI_VERSION } from "../providers/shared.js";
const CLAUDE_VERSION = "2.1.92";
const CC_ENTRYPOINT = "sdk-cli";
// Generate billing header matching real Claude Code 2.1.92+ format:
// Generate the billing header expected from current Claude Code clients.
// x-anthropic-billing-header: cc_version=<ver>.<build>; cc_entrypoint=sdk-cli; cch=<hash>;
function generateBillingHeader(payload) {
const content = JSON.stringify(payload);
const cch = createHash("sha256").update(content).digest("hex").slice(0, 5);
const buildHash = randomBytes(2).toString("hex").slice(0, 3);
return `x-anthropic-billing-header: cc_version=${CLAUDE_VERSION}.${buildHash}; cc_entrypoint=${CC_ENTRYPOINT}; cch=${cch};`;
return `x-anthropic-billing-header: cc_version=${CLAUDE_CLI_VERSION}.${buildHash}; cc_entrypoint=${CC_ENTRYPOINT}; cch=${cch};`;
}
// Derive a deterministic UUID-v4-shaped string from a seed (stable per account)
@@ -19,7 +19,7 @@ function deriveUuid(seed) {
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${((parseInt(h[16], 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`;
}
// Generate fake user ID in Claude Code 2.1.92+ JSON format:
// Generate fake user ID in the current Claude Code JSON format:
// {"device_id":"<64hex>","account_uuid":"<uuid>","session_id":"<uuid>"}
// device_id/account_uuid derive from apiKey (stable per account), session_id per-conversation
function generateFakeUserID(sessionId, apiKey) {
@@ -31,8 +31,8 @@ function generateFakeUserID(sessionId, apiKey) {
/**
* Cloak tools before sending to Claude provider (anti-ban):
* - Rename non-CC client tools with _cc suffix in tools[] and messages[]
* - Skip tools that are already CC default names (they become decoys as-is)
* - Rename client tools with the CLAUDE_TOOL_SUFFIX ("_ide") in tools[] and messages[]
* - Skip tools that carry a `type` (server-side built-ins) — sent as-is
* - Inject CC_DECOY_TOOLS after client tools
* Returns { body, toolNameMap } where toolNameMap maps suffixed → original
* @param {object} body - Claude API request body
@@ -101,6 +101,33 @@ export function decloakToolNames(body, toolNameMap) {
return { ...body, content };
}
/**
* Decloak the tool name inside a single streamed Claude SSE event.
*
* Streaming counterpart of decloakToolNames(). Required for claude→claude
* proxying: translateResponse() returns same-format chunks untouched, so
* without this the client receives the cloaked ("_ide"-suffixed) tool name
* and rejects the call as an unknown tool. In a Claude SSE stream a tool
* name appears exactly once per call — on the content_block_start event of
* a tool_use block; argument deltas carry no name.
*
* Unknown names (e.g. a CC decoy tool the model called anyway) pass through
* unchanged, matching the non-streaming decloak behavior.
*
* @param {object|null} chunk - Parsed SSE event (may be null on stream flush)
* @param {Map|null} toolNameMap - Suffixed → original name map from cloakClaudeTools()
* @returns {object|null} The chunk, with the tool_use name restored when cloaked
*/
export function decloakStreamChunk(chunk, toolNameMap) {
if (!toolNameMap?.size || !chunk || typeof chunk !== "object") return chunk;
if (chunk.type !== "content_block_start") return chunk;
const block = chunk.content_block;
if (block?.type !== "tool_use" || typeof block.name !== "string") return chunk;
const original = toolNameMap.get(block.name);
if (!original) return chunk;
return { ...chunk, content_block: { ...block, name: original } };
}
// CC decoy tools — Claude Code native tool names, marked unavailable
const CC_DECOY_TOOLS = [
{ name: "Task", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } },

View File

@@ -0,0 +1,117 @@
// Claude tool type default self-check.
// Run: node open-sse/utils/claudeToolTypeSelfCheck.mjs
// No framework, no deps. Uses assert. Mirrors toolPairingSelfCheck.mjs style.
import { defaultClaudeToolType } from "../translator/concerns/toolCall.js";
const results = [];
function run(name, fn) {
try {
fn();
results.push({ name, ok: true });
} catch (err) {
results.push({ name, ok: false, err: err.message });
}
}
const assert = {
equal(a, b, msg) { if (a !== b) throw new Error(`${msg || ""} expected ${b}, got ${a}`); },
ok(v, msg) { if (!v) throw new Error(msg || "expected truthy"); },
};
// 1. Tool without `type` property → defaults to "custom"
run("Tool without type property defaults to custom", () => {
const tools = [{ name: "foo", description: "bar", input_schema: {} }];
const out = defaultClaudeToolType(tools);
assert.equal(out[0].type, "custom", "type defaulted");
assert.equal(out[0].name, "foo", "other fields preserved");
});
// 2. Tool with type:null → defaults to "custom" (the spread-order bug case)
run("Tool with type:null defaults to custom", () => {
const tools = [{ name: "foo", type: null, input_schema: {} }];
const out = defaultClaudeToolType(tools);
assert.equal(out[0].type, "custom", "null type overwritten to custom");
});
// 3. Tool with type:undefined → defaults to "custom"
run("Tool with type:undefined defaults to custom", () => {
const tools = [{ name: "foo", type: undefined, input_schema: {} }];
const out = defaultClaudeToolType(tools);
assert.equal(out[0].type, "custom", "undefined type overwritten to custom");
});
// 4. Tool with type:"" (empty string) → defaults to "custom"
run("Tool with type:empty-string defaults to custom", () => {
const tools = [{ name: "foo", type: "", input_schema: {} }];
const out = defaultClaudeToolType(tools);
assert.equal(out[0].type, "custom", "empty-string type overwritten to custom");
});
// 5. Built-in tool with type:"computer_use" → passed through untouched
run("Built-in tool (computer_use) passed through", () => {
const tools = [{ type: "computer_use", name: "computer", display_width: 1024 }];
const out = defaultClaudeToolType(tools);
assert.equal(out[0].type, "computer_use", "built-in type preserved");
assert.equal(out[0], tools[0], "same reference — not cloned");
});
// 6. Tool already with type:"custom" → passed through untouched
run("Tool already with type:custom passed through", () => {
const tools = [{ type: "custom", name: "foo", input_schema: {} }];
const out = defaultClaudeToolType(tools);
assert.equal(out[0].type, "custom", "existing custom type preserved");
assert.equal(out[0], tools[0], "same reference — not cloned");
});
// 7. Mixed: built-in + function tool → only function tool gets default
run("Mixed: built-in kept, function tool defaulted", () => {
const tools = [
{ type: "computer_use", name: "computer", display_width: 1024 },
{ name: "search", description: "search the web", input_schema: {} },
{ type: "web_search_20250305", name: "web_search" },
];
const out = defaultClaudeToolType(tools);
assert.equal(out[0].type, "computer_use", "built-in preserved");
assert.equal(out[1].type, "custom", "function tool defaulted");
assert.equal(out[2].type, "web_search_20250305", "web_search preserved");
});
// 8. Non-array input → returned unchanged
run("Non-array input returned unchanged", () => {
assert.equal(defaultClaudeToolType(null), null, "null returned as-is");
assert.equal(defaultClaudeToolType(undefined), undefined, "undefined returned as-is");
assert.equal(defaultClaudeToolType("not array"), "not array", "string returned as-is");
});
// 9. Empty array → empty array
run("Empty array returns empty array", () => {
const out = defaultClaudeToolType([]);
assert.equal(Array.isArray(out), true, "returns array");
assert.equal(out.length, 0, "empty array");
});
// 10. Original tools not mutated by reference (new objects for defaulted tools)
run("Original tools not mutated by reference", () => {
const original = { name: "foo", input_schema: {} };
const tools = [original];
defaultClaudeToolType(tools);
assert.equal(original.type, undefined, "original tool not mutated");
assert.ok(!("type" in original), "type property not added to original");
});
// 11. Array with null entry → defaults to { type: "custom" } (optional chaining guard)
// tool?.type returns undefined for null, and { ...null, type: "custom" } === { type: "custom" }
run("Array with null entry defaults to custom", () => {
const tools = [null];
const out = defaultClaudeToolType(tools);
assert.equal(out[0].type, "custom", "null tool gets type custom");
assert.equal(Object.keys(out[0]).length, 1, "no other keys from spread of null");
});
// Summary
const passed = results.filter(r => r.ok).length;
const total = results.length;
for (const r of results) {
console.log(`${r.ok ? "ok" : "FAIL"} - ${r.name}${r.ok ? "" : ` :: ${r.err}`}`);
}
console.log(`\n${passed}/${total} checks passed`);
if (passed !== total) process.exit(1);

View File

@@ -0,0 +1,20 @@
// Claude Code appends a bracketed context marker to the model name when the
// 1M-context beta is toggled on: `claude-opus-5` becomes `claude-opus-5[1m]`.
// The marker is a client-side annotation, not part of any model id: it never
// matches a combo name, an alias or a `provider/model` pair, so a request that
// carries it dies at model resolution with "Invalid model format".
//
// The capability itself travels in the `anthropic-beta: context-1m-2025-08-07`
// header, which is forwarded untouched — stripping the marker is enough to let
// the request route normally and still reach the upstream as a 1M request.
const CONTEXT_MARKER = /\[1m\]$/i;
// Returns { model, contextMarker } — contextMarker is null when there is none.
export function stripModelContextMarker(modelStr) {
if (typeof modelStr !== "string") return { model: modelStr, contextMarker: null };
const trimmed = modelStr.trim();
const match = trimmed.match(CONTEXT_MARKER);
if (!match) return { model: modelStr, contextMarker: null };
return { model: trimmed.slice(0, -match[0].length), contextMarker: match[0].slice(1, -1).toLowerCase() };
}

View File

@@ -94,6 +94,7 @@ const MAX_CONTINUATION_SESSIONS = 5000;
// Client headers/body fields that carry an upstream session id (priority order)
const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id"];
const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/;
const CLAUDE_CODE_SESSION_HEADER = "x-claude-code-session-id";
function sha16(text) {
return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
@@ -135,7 +136,10 @@ function extractAntigravitySession(body) {
}
function extractClientSessionId(headers, body, scope = "") {
const claude = extractClaudeCodeSession(body?.metadata?.user_id);
// Claude Code sends the session in a header AND in metadata.user_id; the header
// survives translation to formats that drop metadata (e.g. Responses API).
const claude = extractClaudeCodeSession(body?.metadata?.user_id)
|| headerValue(headers, CLAUDE_CODE_SESSION_HEADER);
if (claude) return `claude:${claude}`;
const antigravity = extractAntigravitySession(body);
if (antigravity) return `antigravity:${antigravity}`;

View File

@@ -49,7 +49,8 @@ export function createSSEStream(options = {}) {
connectionId = null,
body = null,
onStreamComplete = null,
apiKey = null
apiKey = null,
credentials = null
} = options;
let buffer = "";
@@ -59,7 +60,7 @@ export function createSSEStream(options = {}) {
const decoder = new TextDecoder("utf-8", { fatal: false });
const state = mode === STREAM_MODE.TRANSLATE
? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model }
? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model, sessionId: credentials?._clientSessionId || null }
: null;
let totalContentLength = 0;
@@ -75,6 +76,35 @@ export function createSSEStream(options = {}) {
let openAIResponsesTerminalSeen = false;
let openAIResponsesDoneSent = false;
let streamDoneSent = false; // track duplicate [DONE] across transform + flush
let finalized = false;
// Usage/logging tail, callable from transform() as well as flush(): a client that
// closes right after the terminal event cancels the reader, and flush() never runs.
const finalizeStream = () => {
if (finalized) return;
finalized = true;
const isPassthrough = mode === STREAM_MODE.PASSTHROUGH;
let finalUsage = isPassthrough ? usage : state?.usage;
if (!hasValidUsage(finalUsage) && totalContentLength > 0) {
finalUsage = estimateUsage(body, totalContentLength, isPassthrough ? FORMATS.OPENAI : sourceFormat);
if (isPassthrough) usage = finalUsage; else state.usage = finalUsage;
}
if (hasValidUsage(finalUsage)) {
logUsage(isPassthrough ? provider : (state?.provider || targetFormat), finalUsage, model, connectionId, apiKey);
} else {
appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
}
if (onStreamComplete) {
onStreamComplete({
content: accumulatedContent,
thinking: accumulatedThinking
}, finalUsage, ttftAt);
}
};
return new TransformStream({
transform(chunk, controller) {
@@ -105,6 +135,7 @@ export function createSSEStream(options = {}) {
if (mode === STREAM_MODE.PASSTHROUGH) {
let output;
let injectedUsage = false;
let responsesTerminal = false;
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
try {
@@ -168,6 +199,8 @@ export function createSSEStream(options = {}) {
usage = mergeUsage(usage, extracted);
}
responsesTerminal = isOpenAIResponsesTerminalEvent(currentOpenAIResponsesEvent, parsed);
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
if (isFinishChunk && !hasValidUsage(parsed.usage)) {
const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
@@ -202,6 +235,8 @@ export function createSSEStream(options = {}) {
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(sharedEncoder.encode(output));
// Responses clients (codex CLI) close on response.completed instead of [DONE]
if (responsesTerminal) finalizeStream();
continue;
}
@@ -292,6 +327,8 @@ export function createSSEStream(options = {}) {
controller.enqueue(sharedEncoder.encode(output));
currentOpenAIResponsesEvent = null;
sseEmittedCount++;
// Responses clients (codex) close on response.completed instead of [DONE]
if (openAIResponsesTerminalSeen) finalizeStream();
continue;
}
@@ -355,13 +392,6 @@ export function createSSEStream(options = {}) {
controller.enqueue(sharedEncoder.encode(output));
}
if (!hasValidUsage(usage) && totalContentLength > 0) {
usage = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
}
if (hasValidUsage(usage)) {
logUsage(provider, usage, model, connectionId, apiKey);
}
// IMPORTANT: In passthrough mode we still must terminate the SSE stream.
// Some clients (e.g. OpenClaw) expect the OpenAI-style sentinel:
// data: [DONE]\n\n
@@ -374,18 +404,26 @@ export function createSSEStream(options = {}) {
controller.enqueue(sharedEncoder.encode(doneOutput));
}
if (onStreamComplete) {
onStreamComplete({
content: accumulatedContent,
thinking: accumulatedThinking
}, usage, ttftAt);
}
finalizeStream();
return;
}
if (buffer.trim()) {
const parsed = parseSSELine(buffer.trim());
if (parsed && !parsed.done) {
// Same parse as the transform loop: without targetFormat this only
// accepts "data: " lines, so an NDJSON provider (Ollama) lost whatever
// arrived without its closing newline.
const parsed = parseSSELine(buffer.trim(), targetFormat);
// parseSSELine turns the SSE sentinel "data: [DONE]" into { done: true },
// which must not be translated. An Ollama chunk also carries done:true,
// but it is the real final chunk — it holds finish_reason and the token
// counts — so it has to go through.
const isDoneSentinel = parsed?.done && targetFormat !== FORMATS.OLLAMA;
if (parsed && !isDoneSentinel) {
// Same accumulation the transform loop does, so finalizeStream() can
// log a tail chunk's tokens instead of falling back to null.
const extracted = extractUsage(parsed);
if (extracted) state.usage = mergeUsage(state.usage, extracted);
const translated = translateResponse(targetFormat, sourceFormat, parsed, state);
if (translated?._openaiIntermediate) {
@@ -441,26 +479,16 @@ export function createSSEStream(options = {}) {
streamDoneSent = true;
}
if (!hasValidUsage(state?.usage) && totalContentLength > 0) {
state.usage = estimateUsage(body, totalContentLength, sourceFormat);
}
if (hasValidUsage(state?.usage)) {
logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKey);
}
if (onStreamComplete) {
onStreamComplete({
content: accumulatedContent,
thinking: accumulatedThinking
}, state?.usage, ttftAt);
}
finalizeStream();
} catch (error) {
console.log("Error in flush:", error);
finalizeStream();
}
}
});
}
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null) {
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null, credentials = null) {
return createSSEStream({
mode: STREAM_MODE.TRANSLATE,
targetFormat,
@@ -473,7 +501,8 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p
connectionId,
body,
onStreamComplete,
apiKey
apiKey,
credentials
});
}

View File

@@ -41,7 +41,8 @@ export function createStreamController({ onDisconnect, onError, log, provider, m
if (disconnected) return;
disconnected = true;
logStream("⚡", `DISCONNECT: ${reason}`);
// Debug-only: Responses API has no [DONE] sentinel, so codex/droid close the
// socket on every completed request. "📊 done" is the authoritative outcome line.
dbg("CTRL", `${provider}/${model} | disconnect=${reason} | dur=${Date.now() - startTime}ms`);
// Delay abort to allow cleanup

View File

@@ -190,7 +190,10 @@ export function canonicalizeUsage(usage) {
prompt = prompt + cached + cacheCreation;
} else {
// OpenAI/Gemini path (or already-canonical input): prompt already includes cached_tokens.
cached = num(usage.cached_tokens);
// Mirror the cacheCreation fallback above: buildUsage() only ever emits the
// nested prompt_tokens_details.cached_tokens shape, so without this the
// cache-read count is silently dropped on every buildUsage()-derived usage.
cached = num(usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens);
}
const result = {