The merged Qoder work also rewrote shared translator/handler code so that /v1/responses clients got token usage on response.completed. That changed behaviour for every provider, not just Qoder: proxies saw input tokens rise by the 2000-token context buffer, and the plain token mapping was replaced by one that always adds input_tokens_details. A probe confirms the Qoder benefit does not depend on those edits: the executor's coalescer already emits one include_usage-style finish chunk, so a Claude client receives input_tokens and cache_read_input_tokens with every shared file at its original state. Only the Responses path relies on the shared translator, and that path has no Qoder-owned seam to put it in. Reverts the shared files to their pre-PR state and drops the Responses usage test. The Cline envelope unwrap in nonStreamingHandler.js, which landed after the PR in the same file, is kept.
266 lines
8.8 KiB
JavaScript
266 lines
8.8 KiB
JavaScript
import { register } from "../index.js";
|
|
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";
|
|
|
|
// 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
|
|
// for arg sanitization). Current request translator emits no prefix ("") — strip
|
|
// is then a no-op. Kept intentionally; do NOT couple to request's empty prefix.
|
|
const CLAUDE_OAUTH_TOOL_PREFIX = "proxy_";
|
|
|
|
// Sanitize tool call arguments to fix bad params from non-Anthropic models
|
|
function sanitizeToolArgs(toolName, argsJson) {
|
|
try {
|
|
const args = JSON.parse(argsJson);
|
|
const name = toolName.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)
|
|
? toolName.slice(CLAUDE_OAUTH_TOOL_PREFIX.length)
|
|
: toolName;
|
|
if (name === "Read") sanitizeReadArgs(args);
|
|
return JSON.stringify(args);
|
|
} catch {
|
|
return argsJson;
|
|
}
|
|
}
|
|
|
|
function sanitizeReadArgs(args) {
|
|
if (typeof args.limit === "string" && /^\d+$/.test(args.limit)) args.limit = Number(args.limit);
|
|
if (typeof args.offset === "string" && /^-?\d+$/.test(args.offset)) args.offset = Number(args.offset);
|
|
|
|
if (typeof args.limit === "number") {
|
|
if (args.limit > 2000) args.limit = 2000;
|
|
if (args.limit < 1) delete args.limit;
|
|
}
|
|
if (typeof args.offset === "number" && args.offset < 0) args.offset = 0;
|
|
|
|
if ("pages" in args && !isValidPdfPagesArg(args.file_path, args.pages)) {
|
|
delete args.pages;
|
|
}
|
|
}
|
|
|
|
function isValidPdfPagesArg(filePath, pages) {
|
|
return typeof filePath === "string" &&
|
|
filePath.toLowerCase().endsWith(".pdf") &&
|
|
typeof pages === "string" &&
|
|
/^\d+(?:-\d+)?$/.test(pages);
|
|
}
|
|
|
|
// Helper: stop thinking block if started
|
|
function stopThinkingBlock(state, results) {
|
|
if (!state.thinkingBlockStarted) return;
|
|
results.push({
|
|
type: "content_block_stop",
|
|
index: state.thinkingBlockIndex
|
|
});
|
|
state.thinkingBlockStarted = false;
|
|
}
|
|
|
|
// Helper: stop text block if started
|
|
function stopTextBlock(state, results) {
|
|
if (!state.textBlockStarted || state.textBlockClosed) return;
|
|
state.textBlockClosed = true;
|
|
results.push({
|
|
type: "content_block_stop",
|
|
index: state.textBlockIndex
|
|
});
|
|
state.textBlockStarted = false;
|
|
}
|
|
|
|
// Convert OpenAI stream chunk to Claude format
|
|
export function openaiToClaudeResponse(chunk, state) {
|
|
if (!chunk || !chunk.choices?.[0]) return null;
|
|
|
|
const results = [];
|
|
const choice = chunk.choices[0];
|
|
const delta = choice.delta;
|
|
|
|
// Track usage from OpenAI chunk if available
|
|
if (chunk.usage && typeof chunk.usage === "object") {
|
|
const promptTokens = typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0;
|
|
const outputTokens = typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0;
|
|
|
|
// Extract cache tokens from prompt_tokens_details
|
|
const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens;
|
|
const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens;
|
|
const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0;
|
|
const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0;
|
|
|
|
// input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens
|
|
// Because OpenAI's prompt_tokens includes all prompt-side tokens
|
|
const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens;
|
|
|
|
state.usage = {
|
|
input_tokens: inputTokens,
|
|
output_tokens: outputTokens
|
|
};
|
|
|
|
// Add cache_read_input_tokens if present
|
|
if (cacheReadTokens > 0) {
|
|
state.usage.cache_read_input_tokens = cacheReadTokens;
|
|
}
|
|
|
|
// Add cache_creation_input_tokens if present
|
|
if (cacheCreateTokens > 0) {
|
|
state.usage.cache_creation_input_tokens = cacheCreateTokens;
|
|
}
|
|
|
|
// Note: completion_tokens_details.reasoning_tokens is already included in output_tokens
|
|
// No need to add separately as Claude expects total output_tokens
|
|
}
|
|
|
|
// First chunk - ALWAYS send message_start first
|
|
if (!state.messageStartSent) {
|
|
state.messageStartSent = true;
|
|
state.messageId = chunk.id?.replace("chatcmpl-", "") || `msg_${Date.now()}`;
|
|
if (!state.messageId || state.messageId === "chat" || state.messageId.length < 8) {
|
|
state.messageId = chunk.extend_fields?.requestId ||
|
|
chunk.extend_fields?.traceId ||
|
|
`msg_${Date.now()}`;
|
|
}
|
|
state.model = chunk.model || MODEL_FALLBACK;
|
|
state.nextBlockIndex = 0;
|
|
results.push({
|
|
type: "message_start",
|
|
message: {
|
|
id: state.messageId,
|
|
type: "message",
|
|
role: ROLE.ASSISTANT,
|
|
model: state.model,
|
|
content: [],
|
|
stop_reason: null,
|
|
stop_sequence: null,
|
|
usage: { input_tokens: 0, output_tokens: 0 }
|
|
}
|
|
});
|
|
}
|
|
|
|
// Handle reasoning (thinking) across vendor shapes - GLM/DeepSeek/Qwen/MiniMax/etc.
|
|
const reasoningContent = extractReasoningText(delta);
|
|
if (reasoningContent) {
|
|
stopTextBlock(state, results);
|
|
|
|
if (!state.thinkingBlockStarted) {
|
|
state.thinkingBlockIndex = state.nextBlockIndex++;
|
|
state.thinkingBlockStarted = true;
|
|
results.push({
|
|
type: "content_block_start",
|
|
index: state.thinkingBlockIndex,
|
|
content_block: { type: CLAUDE_BLOCK.THINKING, thinking: "" }
|
|
});
|
|
}
|
|
|
|
results.push({
|
|
type: "content_block_delta",
|
|
index: state.thinkingBlockIndex,
|
|
delta: { type: "thinking_delta", thinking: reasoningContent }
|
|
});
|
|
}
|
|
|
|
// Handle regular content
|
|
if (delta?.content) {
|
|
stopThinkingBlock(state, results);
|
|
|
|
if (!state.textBlockStarted) {
|
|
state.textBlockIndex = state.nextBlockIndex++;
|
|
state.textBlockStarted = true;
|
|
state.textBlockClosed = false;
|
|
results.push({
|
|
type: "content_block_start",
|
|
index: state.textBlockIndex,
|
|
content_block: { type: CLAUDE_BLOCK.TEXT, text: "" }
|
|
});
|
|
}
|
|
|
|
results.push({
|
|
type: "content_block_delta",
|
|
index: state.textBlockIndex,
|
|
delta: { type: "text_delta", text: delta.content }
|
|
});
|
|
}
|
|
|
|
// Tool calls
|
|
if (delta?.tool_calls) {
|
|
for (const tc of delta.tool_calls) {
|
|
const idx = tc.index ?? 0;
|
|
|
|
// GLM/fireworks repeats id+null-name on every arg chunk; open block once per idx
|
|
if (tc.id && !state.toolCalls.has(idx)) {
|
|
stopThinkingBlock(state, results);
|
|
stopTextBlock(state, results);
|
|
|
|
const toolBlockIndex = state.nextBlockIndex++;
|
|
state.toolCalls.set(idx, { id: tc.id, name: tc.function?.name || "", blockIndex: toolBlockIndex });
|
|
|
|
// Strip prefix from tool name for response
|
|
let toolName = tc.function?.name || "";
|
|
if (toolName.startsWith(CLAUDE_OAUTH_TOOL_PREFIX)) {
|
|
toolName = toolName.slice(CLAUDE_OAUTH_TOOL_PREFIX.length);
|
|
}
|
|
|
|
results.push({
|
|
type: "content_block_start",
|
|
index: toolBlockIndex,
|
|
content_block: {
|
|
type: CLAUDE_BLOCK.TOOL_USE,
|
|
id: tc.id,
|
|
name: toolName,
|
|
input: {}
|
|
}
|
|
});
|
|
}
|
|
|
|
if (tc.function?.arguments) {
|
|
const toolInfo = state.toolCalls.get(idx);
|
|
if (toolInfo) {
|
|
// Buffer args instead of streaming — sanitize at finish to fix bad params
|
|
if (!state.toolArgBuffers) state.toolArgBuffers = new Map();
|
|
state.toolArgBuffers.set(idx, (state.toolArgBuffers.get(idx) || "") + tc.function.arguments);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Finish
|
|
if (choice.finish_reason) {
|
|
stopThinkingBlock(state, results);
|
|
stopTextBlock(state, results);
|
|
|
|
for (const [idx, toolInfo] of state.toolCalls) {
|
|
// Emit buffered + sanitized args as single delta before stop
|
|
const buffered = state.toolArgBuffers?.get(idx);
|
|
if (buffered) {
|
|
const sanitized = sanitizeToolArgs(toolInfo.name, buffered);
|
|
results.push({
|
|
type: "content_block_delta",
|
|
index: toolInfo.blockIndex,
|
|
delta: { type: "input_json_delta", partial_json: sanitized }
|
|
});
|
|
}
|
|
results.push({
|
|
type: "content_block_stop",
|
|
index: toolInfo.blockIndex
|
|
});
|
|
}
|
|
|
|
// Mark finish for later usage injection in stream.js
|
|
state.finishReason = choice.finish_reason;
|
|
|
|
// Use tracked usage (will be estimated in stream.js if not valid)
|
|
const finalUsage = state.usage || { input_tokens: 0, output_tokens: 0 };
|
|
results.push({
|
|
type: "message_delta",
|
|
delta: { stop_reason: convertFinishReason(choice.finish_reason) },
|
|
usage: finalUsage
|
|
});
|
|
results.push({ type: "message_stop" });
|
|
}
|
|
|
|
return results.length > 0 ? results : null;
|
|
}
|
|
|
|
const convertFinishReason = (reason) => fromOpenAIFinish(reason, "claude");
|
|
|
|
// Register
|
|
register(FORMATS.OPENAI, FORMATS.CLAUDE, null, openaiToClaudeResponse);
|