mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
feat(proxy): add scoped Cloudflare model routing
This commit is contained in:
@@ -66,6 +66,24 @@ normally — pxpipe compresses the *request* only, never the model's output.
|
||||
Recent turns stay text; the system prompt, tool docs, and older bulk history
|
||||
are imaged.
|
||||
|
||||
Route different models through different providers while keeping one client
|
||||
base URL:
|
||||
|
||||
```bash
|
||||
ANTHROPIC_MODELS=claude-fable-5 \
|
||||
OPENAI_MODELS=gpt-5.6-sol \
|
||||
CLOUDFLARE_MODELS=moonshotai/kimi-k3 \
|
||||
CLOUDFLARE_ACCOUNT_ID=... CLOUDFLARE_API_TOKEN=... \
|
||||
npx pxpipe-proxy
|
||||
```
|
||||
|
||||
The lists contain comma-separated exact model IDs. Listed models route to the
|
||||
corresponding Anthropic Messages, OpenAI Responses, or Cloudflare-compatible
|
||||
Chat Completions upstream; overlapping entries fail at startup. Unlisted
|
||||
models retain pxpipe's normal family routing, so Claude, OpenCode, and Codex
|
||||
can share the proxy. `OPENAPI_URL` and `OPENAPI_API` may replace the derived
|
||||
Cloudflare endpoint and token.
|
||||
|
||||
## Offline export (no proxy)
|
||||
|
||||
You can render text, files, or diffs to PNG pages without running the proxy or
|
||||
@@ -143,33 +161,38 @@ Every model row below uses the model's production profile unless a pure-image
|
||||
research arm is called out. Sol uses **5×8 Spleen + adjacent text factsheet**;
|
||||
the rejected RGB-overprint research remains documented under `eval/sol-profile`.
|
||||
Claude numbers are novel problems the model cannot have memorized. Sol and Grok
|
||||
quality use Codex’s Responses provider; Fable/Opus use Claude. Token deltas
|
||||
quality use Codex’s Responses provider; Kimi K3 uses Cloudflare's
|
||||
OpenAI-compatible transport through pxpipe; Fable/Opus use Claude. Token deltas
|
||||
compare matched input arms: negative saves tokens; positive costs more. The
|
||||
historical GSM8K run measured −38%, but it is a different corpus and is not
|
||||
used for these novel-arithmetic rows.
|
||||
|
||||
| test | model | N | text | pxpipe (image) | tokens |
|
||||
|---|---|---:|---:|---:|---|
|
||||
| novel arithmetic | `claude-fable-5` | 100 | 100% | **100%** | not measured |
|
||||
| novel arithmetic | `gpt-5.6-sol` | 100 | 100% | **98%** | **+32%** |
|
||||
| novel arithmetic | `claude-opus-4-8` | 100 | 100% | 93% | not measured |
|
||||
| novel arithmetic | `grok-4.5` | 100 | 100% | **82%** | **+7%** |
|
||||
| gist recall A/B (decisions, values, paths, names, negations; distractors; 15k–45k char sessions) | Fable 5 | 98/arm | 98/98 | **98/98** | not measured |
|
||||
| same gist corpus, production images + factsheet | `gpt-5.6-sol` | 98 | not measured | **79/93 completed; 1 session error** | not measured |
|
||||
| same gist corpus, production images + factsheet | `grok-4.5` | 98 | 98/98 | **83/98** | not measured |
|
||||
| state tracking (value mutated 3×, final/first/count) | Fable 5 | 18/arm | 18/18 | **18/18** | not measured |
|
||||
| same state-tracking corpus | `gpt-5.6-sol` | 18 | not measured | **18/18 latest** | not measured |
|
||||
| same state-tracking corpus | `grok-4.5` | 18 | 18/18 | **13/18** | not measured |
|
||||
| confabulation on never-stated facts (lower is better) | Fable 5 | 16/arm | 0/16 | **0/16** | not measured |
|
||||
| same never-stated probes (lower is better) | `gpt-5.6-sol` | 16 | not measured | **4/15 completed; 1 session error** | not measured |
|
||||
| same never-stated probes (lower is better) | `grok-4.5` | 16 | 0/16 | **0/16** | not measured |
|
||||
| verbatim 12-char hex, dense render | Opus | 15 | 15/15 | **0/15** | not measured |
|
||||
| verbatim 12-char hex, dense render | Fable 5 | 15 | not measured | **13/15** | not measured |
|
||||
| verbatim 12-char hex, same dense pages | `gpt-5.6-sol` | 15 | not measured | **0/15** | not measured |
|
||||
| verbatim 12-char hex, same dense pages | `grok-4.5` | 15 | 15/15 | **0/6 completed; 9 transport errors** | not measured |
|
||||
| test | model | N | pxpipe (image) |
|
||||
| --- | --- | ---: | ---: |
|
||||
| novel arithmetic | `claude-fable-5` | 100 | **100%** |
|
||||
| novel arithmetic | `gpt-5.6-sol` | 100 | **98%** |
|
||||
| novel arithmetic | `claude-opus-4-8` | 100 | 93% |
|
||||
| novel arithmetic | `grok-4.5` | 100 | **82%** |
|
||||
| novel arithmetic | `moonshotai/kimi-k3` | 100 | **79%** |
|
||||
| gist recall A/B (decisions, values, paths, names, negations; distractors; 15k–45k char sessions) | Fable 5 | 98/arm | **98/98** |
|
||||
| same gist corpus, production images + factsheet | `gpt-5.6-sol` | 98 | **79/93 completed; 1 session error** |
|
||||
| same gist corpus, production images + factsheet | `grok-4.5` | 98 | **83/98** |
|
||||
| state tracking (value mutated 3×, final/first/count) | Fable 5 | 18/arm | **18/18** |
|
||||
| same state-tracking corpus | `gpt-5.6-sol` | 18 | **18/18 latest** |
|
||||
| same state-tracking corpus | `grok-4.5` | 18 | **13/18** |
|
||||
| confabulation on never-stated facts (lower is better) | Fable 5 | 16/arm | **0/16** |
|
||||
| same never-stated probes (lower is better) | `gpt-5.6-sol` | 16 | **4/15 completed; 1 session error** |
|
||||
| same never-stated probes (lower is better) | `grok-4.5` | 16 | **0/16** |
|
||||
| verbatim 12-char hex, dense render | Opus | 15 | **0/15** |
|
||||
| verbatim 12-char hex, dense render | Fable 5 | 15 | **13/15** |
|
||||
| verbatim 12-char hex, same dense pages | `gpt-5.6-sol` | 15 | **0/15** |
|
||||
| verbatim 12-char hex, same dense pages | `grok-4.5` | 15 | **0/6 completed; 9 transport errors** |
|
||||
|
||||
**Harness split:** Fable/Opus quality and SWE-bench rows use **Claude**; Sol and Grok quality use
|
||||
**Codex’s Responses provider** (`OPENAI_BASE_URL`) — see
|
||||
**Codex’s Responses provider** (`OPENAI_BASE_URL`). Kimi K3
|
||||
used the same novel-arithmetic corpus and production renderer through pxpipe's
|
||||
Cloudflare Messages bridge — see the
|
||||
[`K3 receipt`](eval/sol-profile/model-moonshotai_kimi-k3-novel-arithmetic-results.json) and
|
||||
[`eval/grok-density/QUALITY_SUITE.md`](eval/grok-density/QUALITY_SUITE.md).
|
||||
|
||||
Sol receipts: [`eval/sol-profile/QUALITY_RESULTS.md`](eval/sol-profile/QUALITY_RESULTS.md).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,64 @@ export function responsesEndpoint() {
|
||||
return base.endsWith('/responses') ? base : `${base}/responses`;
|
||||
}
|
||||
|
||||
function isKimi(model) {
|
||||
return /kimi/i.test(model);
|
||||
}
|
||||
|
||||
function messagesEndpoint() {
|
||||
const base = (process.env.KIMI_QUALITY_BASE_URL || 'http://127.0.0.1:47821/v1').replace(/\/$/, '');
|
||||
return base.endsWith('/messages') ? base : `${base}/messages`;
|
||||
}
|
||||
|
||||
function anthropicContent(content) {
|
||||
return content.map((part) => {
|
||||
if (part.type === 'input_text') return { type: 'text', text: part.text };
|
||||
if (part.type === 'input_image' && typeof part.image_url === 'string') {
|
||||
const match = /^data:([^;]+);base64,(.*)$/.exec(part.image_url);
|
||||
if (!match) throw new Error('Kimi eval requires base64 data images');
|
||||
return {
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: match[1], data: match[2] },
|
||||
};
|
||||
}
|
||||
throw new Error(`unsupported Kimi eval content part: ${part.type}`);
|
||||
});
|
||||
}
|
||||
|
||||
async function readAnthropicStream(response) {
|
||||
if (!response.body) throw new Error('Kimi eval stream has no body');
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let text = '';
|
||||
let usage = null;
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value, { stream: !done }).replace(/\r\n|\r/g, '\n');
|
||||
let end;
|
||||
while ((end = buffer.indexOf('\n\n')) >= 0) {
|
||||
const block = buffer.slice(0, end);
|
||||
buffer = buffer.slice(end + 2);
|
||||
const payload = block.split('\n')
|
||||
.filter((line) => line.startsWith('data:'))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join('\n');
|
||||
if (!payload || payload === '[DONE]') continue;
|
||||
let event;
|
||||
try { event = JSON.parse(payload); } catch { continue; }
|
||||
if (event.type === 'content_block_delta' && event.delta?.type === 'text_delta') {
|
||||
text += event.delta.text || '';
|
||||
} else if (event.type === 'message_delta' && event.usage) {
|
||||
usage = event.usage;
|
||||
} else if (event.type === 'error') {
|
||||
throw new Error(event.error?.message || 'Kimi streaming error');
|
||||
}
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
return { text: text.trim(), usage };
|
||||
}
|
||||
|
||||
export function responseBody(model, content, maxOutputTokens) {
|
||||
const body = {
|
||||
model,
|
||||
@@ -21,6 +79,54 @@ export function responseBody(model, content, maxOutputTokens) {
|
||||
}
|
||||
|
||||
export async function callResponses({ model, content, maxOutputTokens, timeoutMs }) {
|
||||
if (isKimi(model)) {
|
||||
const key = process.env.KIMI_QUALITY_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN;
|
||||
if (!key) throw new Error('KIMI_QUALITY_API_KEY or ANTHROPIC_AUTH_TOKEN is required');
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const started = Date.now();
|
||||
try {
|
||||
const response = await fetch(messagesEndpoint(), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'anthropic-version': '2023-06-01',
|
||||
'x-api-key': key,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model.startsWith('claude-') ? model : `claude-${model}`,
|
||||
// K3 can spend many minutes in mandatory reasoning. Streaming keeps
|
||||
// Cloudflare's partner route alive instead of timing out near 100s.
|
||||
stream: true,
|
||||
// K3 always reasons; Moonshot's benchmark guidance requires at least
|
||||
// 16K so hidden reasoning does not consume the entire answer budget.
|
||||
max_tokens: Math.max(maxOutputTokens, Number(process.env.KIMI_QUALITY_MAX_TOKENS || 16000)),
|
||||
messages: [{ role: 'user', content: anthropicContent(content) }],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (response.ok && response.headers.get('content-type')?.includes('text/event-stream')) {
|
||||
const streamed = await readAnthropicStream(response);
|
||||
return { ...streamed, ms: Date.now() - started };
|
||||
}
|
||||
const raw = await response.text();
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`non-json HTTP ${response.status}: ${raw.slice(0, 160)}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${json?.error?.message || raw.slice(0, 160)}`);
|
||||
}
|
||||
const text = Array.isArray(json.content)
|
||||
? json.content.filter((part) => part?.type === 'text').map((part) => part.text).join('')
|
||||
: '';
|
||||
return { text: text.trim(), usage: json.usage || null, ms: Date.now() - started };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
const key = process.env.OPENAI_API_KEY;
|
||||
if (!key) throw new Error('OPENAI_API_KEY is required');
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
/**
|
||||
* Anthropic Messages wire compatibility for OpenAI Chat Completions upstreams.
|
||||
*
|
||||
* Lets Claude Code (which only speaks the Anthropic /v1/messages schema) drive
|
||||
* any OpenAI-compatible chat-completions endpoint — e.g. Kimi via Cloudflare
|
||||
* Workers AI, Moonshot, OpenRouter, Groq, Together. The target endpoint and key
|
||||
* are supplied by the host (OPENAPI_URL / OPENAPI_API); this module is pure wire
|
||||
* translation and knows nothing about the provider.
|
||||
*
|
||||
* Mirrors messages-responses-bridge.ts, but targets /v1/chat/completions rather
|
||||
* than the Responses API.
|
||||
*/
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
function invalidRequest(message: string): never {
|
||||
const error = new Error(message);
|
||||
error.name = 'MessagesBridgeInvalidRequest';
|
||||
throw error;
|
||||
}
|
||||
|
||||
function object(value: unknown): JsonObject | undefined {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as JsonObject
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function textFromBlocks(value: unknown): string {
|
||||
if (value === undefined) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
if (!Array.isArray(value)) invalidRequest('system must be a string or an array of text blocks');
|
||||
return value.map((part) => {
|
||||
const p = object(part);
|
||||
if (p?.type !== 'text' || typeof p.text !== 'string') {
|
||||
invalidRequest(`Unsupported system content block: ${String(p?.type ?? 'invalid')}`);
|
||||
}
|
||||
return p.text;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function imageUrl(source: unknown): string | undefined {
|
||||
const s = object(source);
|
||||
if (s?.type === 'base64' && typeof s.media_type === 'string' && typeof s.data === 'string') {
|
||||
return `data:${s.media_type};base64,${s.data}`;
|
||||
}
|
||||
if (s?.type === 'url' && typeof s.url === 'string' && /^https?:\/\//.test(s.url)) return s.url;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Build Chat Completions content parts from an Anthropic content array/string. */
|
||||
function contentParts(content: unknown, location: string): string | JsonObject[] {
|
||||
if (typeof content === 'string') return content;
|
||||
if (!Array.isArray(content)) invalidRequest(`${location} content must be a string or an array`);
|
||||
const out: JsonObject[] = [];
|
||||
for (const raw of content) {
|
||||
const part = object(raw);
|
||||
if (part?.type === 'text' && typeof part.text === 'string') {
|
||||
out.push({ type: 'text', text: part.text });
|
||||
} else if (part?.type === 'image') {
|
||||
const url = imageUrl(part.source);
|
||||
if (!url) invalidRequest(`Unsupported ${location} image source`);
|
||||
out.push({ type: 'image_url', image_url: { url } });
|
||||
} else {
|
||||
invalidRequest(`Unsupported ${location} content block: ${String(part?.type ?? 'invalid')}`);
|
||||
}
|
||||
}
|
||||
// Collapse a lone text part to a plain string — the shape most providers prefer.
|
||||
if (out.length === 1 && out[0]?.type === 'text') return String(out[0].text ?? '');
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Split a tool_result into text for the Chat `tool` message and visual parts
|
||||
* that must be sent in a following multimodal `user` message. */
|
||||
function toolResultContent(content: unknown, isError: boolean): {
|
||||
text: string;
|
||||
images: JsonObject[];
|
||||
} {
|
||||
const prefix = isError ? '[Tool execution failed]\n' : '';
|
||||
if (typeof content === 'string') return { text: prefix + content, images: [] };
|
||||
if (!Array.isArray(content)) {
|
||||
return { text: prefix + JSON.stringify(content ?? ''), images: [] };
|
||||
}
|
||||
const pieces: string[] = [];
|
||||
const images: JsonObject[] = [];
|
||||
for (const raw of content) {
|
||||
const part = object(raw);
|
||||
if (part?.type === 'text' && typeof part.text === 'string') pieces.push(part.text);
|
||||
else if (part?.type === 'image') {
|
||||
const url = imageUrl(part.source);
|
||||
if (!url) invalidRequest('Unsupported tool_result image source');
|
||||
images.push({ type: 'image_url', image_url: { url } });
|
||||
}
|
||||
else pieces.push(JSON.stringify(raw ?? ''));
|
||||
}
|
||||
return { text: prefix + pieces.join('\n'), images };
|
||||
}
|
||||
|
||||
/** Map Anthropic extended-thinking config to an OpenAI `reasoning_effort` bucket.
|
||||
* Claude Code sends `thinking: { type: 'enabled', budget_tokens: N }`; OpenAI-
|
||||
* compatible reasoning models (Kimi, etc.) take a coarse low/medium/high knob.
|
||||
* We use the universal three values — Kimi also accepts `max`, but staying in
|
||||
* the standard set keeps this provider-agnostic. */
|
||||
function reasoningEffort(value: unknown): string | undefined {
|
||||
const thinking = object(value);
|
||||
if (thinking?.type !== 'enabled') return undefined;
|
||||
const budget = typeof thinking.budget_tokens === 'number' ? thinking.budget_tokens : 0;
|
||||
if (budget >= 32000) return 'high';
|
||||
if (budget >= 8000) return 'medium';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
function mapToolChoice(value: unknown): unknown {
|
||||
const choice = object(value);
|
||||
if (!choice || typeof choice.type !== 'string') return undefined;
|
||||
if (choice.type === 'auto' || choice.type === 'none') return choice.type;
|
||||
if (choice.type === 'any') return 'required';
|
||||
if (choice.type === 'tool' && typeof choice.name === 'string') {
|
||||
return { type: 'function', function: { name: choice.name } };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Convert a Claude Code Messages request into an OpenAI Chat Completions request. */
|
||||
export function anthropicMessagesToOpenAIChat(body: Uint8Array, modelOverride?: string): Uint8Array {
|
||||
const req = JSON.parse(new TextDecoder().decode(body)) as JsonObject;
|
||||
const messages: JsonObject[] = [];
|
||||
|
||||
const instructions = textFromBlocks(req.system);
|
||||
if (instructions) messages.push({ role: 'system', content: instructions });
|
||||
|
||||
if (!Array.isArray(req.messages)) invalidRequest('messages must be an array');
|
||||
for (const rawMessage of req.messages) {
|
||||
const message = object(rawMessage);
|
||||
if (!message
|
||||
|| (message.role !== 'user' && message.role !== 'assistant' && message.role !== 'system')) {
|
||||
invalidRequest('Each message must have a user, assistant, or system role');
|
||||
}
|
||||
// Newer Claude Code builds inject system-role messages mid-conversation
|
||||
// (e.g. system reminders); map them to Chat Completions system messages.
|
||||
if (message.role === 'system') {
|
||||
const text = textFromBlocks(message.content);
|
||||
if (text) messages.push({ role: 'system', content: text });
|
||||
continue;
|
||||
}
|
||||
const role = message.role as 'user' | 'assistant';
|
||||
const content = message.content;
|
||||
|
||||
if (!Array.isArray(content)) {
|
||||
const parts = contentParts(content, `${role} message`);
|
||||
if (typeof parts === 'string' ? parts.length : parts.length) {
|
||||
messages.push({ role, content: parts });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
// Assistant turn: gather text/image into content, tool_use into tool_calls.
|
||||
const ordinary: JsonObject[] = [];
|
||||
const toolCalls: JsonObject[] = [];
|
||||
for (const rawPart of content) {
|
||||
const part = object(rawPart);
|
||||
if (part?.type === 'tool_use' && typeof part.id === 'string' && typeof part.name === 'string') {
|
||||
toolCalls.push({
|
||||
id: part.id,
|
||||
type: 'function',
|
||||
function: { name: part.name, arguments: JSON.stringify(part.input ?? {}) },
|
||||
});
|
||||
} else if (part?.type === 'text' && typeof part.text === 'string') {
|
||||
ordinary.push({ type: 'text', text: part.text });
|
||||
} else if (part?.type === 'image') {
|
||||
const url = imageUrl(part.source);
|
||||
if (!url) invalidRequest('Unsupported assistant image source');
|
||||
ordinary.push({ type: 'image_url', image_url: { url } });
|
||||
} else if (part?.type === 'thinking' || part?.type === 'redacted_thinking') {
|
||||
// Chat Completions has no portable representation for Anthropic's
|
||||
// signed thinking history. Keep the visible answer and tool calls.
|
||||
continue;
|
||||
} else {
|
||||
invalidRequest(`Unsupported assistant content block: ${String(part?.type ?? 'invalid')}`);
|
||||
}
|
||||
}
|
||||
const msg: JsonObject = { role: 'assistant' };
|
||||
const text = ordinary.filter((p) => p.type === 'text').map((p) => String(p.text ?? '')).join('');
|
||||
const hasImage = ordinary.some((p) => p.type === 'image_url');
|
||||
msg.content = hasImage ? ordinary : (text || (toolCalls.length ? null : ''));
|
||||
if (toolCalls.length) msg.tool_calls = toolCalls;
|
||||
messages.push(msg);
|
||||
continue;
|
||||
}
|
||||
|
||||
// User turn: emit every tool response contiguously after the assistant
|
||||
// tool_calls. Chat `tool` content is text-only, so returned images move to
|
||||
// the following multimodal user message with any ordinary user content.
|
||||
const ordinary: JsonObject[] = [];
|
||||
const toolMessages: JsonObject[] = [];
|
||||
const toolImages: JsonObject[] = [];
|
||||
for (const rawPart of content) {
|
||||
const part = object(rawPart);
|
||||
if (part?.type === 'tool_result' && typeof part.tool_use_id === 'string') {
|
||||
const result = toolResultContent(part.content, part.is_error === true);
|
||||
toolMessages.push({
|
||||
role: 'tool',
|
||||
tool_call_id: part.tool_use_id,
|
||||
content: result.text,
|
||||
});
|
||||
toolImages.push(...result.images);
|
||||
} else if (part?.type === 'text' && typeof part.text === 'string') {
|
||||
ordinary.push({ type: 'text', text: part.text });
|
||||
} else if (part?.type === 'image') {
|
||||
const url = imageUrl(part.source);
|
||||
if (!url) invalidRequest('Unsupported user image source');
|
||||
ordinary.push({ type: 'image_url', image_url: { url } });
|
||||
} else {
|
||||
invalidRequest(`Unsupported user content block: ${String(part?.type ?? 'invalid')}`);
|
||||
}
|
||||
}
|
||||
messages.push(...toolMessages);
|
||||
const userParts = [...toolImages, ...ordinary];
|
||||
if (userParts.length) {
|
||||
const sole = userParts.length === 1 ? userParts[0] : undefined;
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: sole?.type === 'text' ? String(sole.text ?? '') : userParts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// A blank override is treated as absent (consistent with the env-parsing
|
||||
// boundary), so the client-sent model id falls through.
|
||||
const out: JsonObject = { model: modelOverride || req.model, messages };
|
||||
if (typeof req.max_tokens === 'number') out.max_tokens = req.max_tokens;
|
||||
// top_k is in both Anthropic's schema and the Cloudflare Workers AI chat
|
||||
// schema (though not vanilla OpenAI's); forward it — unknown-field-tolerant
|
||||
// upstreams ignore it, and CF actually honors it.
|
||||
for (const key of ['stream', 'temperature', 'top_p', 'top_k', 'metadata']) {
|
||||
if (req[key] !== undefined) out[key] = req[key];
|
||||
}
|
||||
// OpenAI-compatible endpoints only report token usage on a streamed response
|
||||
// when include_usage is set. Anthropic's schema has no equivalent, so request
|
||||
// it ourselves — otherwise Claude Code sees zero usage for streaming turns.
|
||||
if (req.stream === true) out.stream_options = { include_usage: true };
|
||||
const effort = reasoningEffort(req.thinking);
|
||||
if (effort !== undefined) out.reasoning_effort = effort;
|
||||
if (Array.isArray(req.stop_sequences)) out.stop = req.stop_sequences;
|
||||
const toolChoice = mapToolChoice(req.tool_choice);
|
||||
if (toolChoice !== undefined) out.tool_choice = toolChoice;
|
||||
if (Array.isArray(req.tools)) {
|
||||
const tools = req.tools.flatMap((raw) => {
|
||||
const tool = object(raw);
|
||||
if (!tool || typeof tool.name !== 'string') return [];
|
||||
return [{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: tool.name,
|
||||
...(typeof tool.description === 'string' ? { description: tool.description } : {}),
|
||||
parameters: tool.input_schema ?? { type: 'object', properties: {} },
|
||||
},
|
||||
}];
|
||||
});
|
||||
if (tools.length) out.tools = tools;
|
||||
}
|
||||
return new TextEncoder().encode(JSON.stringify(out));
|
||||
}
|
||||
|
||||
function anthropicUsage(raw: unknown): JsonObject {
|
||||
const usage = object(raw) ?? {};
|
||||
const details = object(usage.prompt_tokens_details);
|
||||
const total = typeof usage.prompt_tokens === 'number' ? usage.prompt_tokens : 0;
|
||||
const cached = typeof details?.cached_tokens === 'number' ? details.cached_tokens : 0;
|
||||
return {
|
||||
input_tokens: Math.max(0, total - cached),
|
||||
output_tokens: typeof usage.completion_tokens === 'number' ? usage.completion_tokens : 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: cached,
|
||||
};
|
||||
}
|
||||
|
||||
function parseArguments(value: unknown): unknown {
|
||||
if (typeof value !== 'string' || value.trim() === '') return {};
|
||||
try { return JSON.parse(value); } catch {
|
||||
throw new Error('OpenAI returned malformed function-call arguments');
|
||||
}
|
||||
}
|
||||
|
||||
function stopReason(finish: unknown, hasToolUse: boolean): string {
|
||||
if (hasToolUse) return 'tool_use';
|
||||
switch (finish) {
|
||||
case 'length': return 'max_tokens';
|
||||
case 'tool_calls':
|
||||
case 'function_call': return 'tool_use';
|
||||
case 'content_filter': return 'refusal';
|
||||
case 'stop': return 'end_turn';
|
||||
default: return 'end_turn';
|
||||
}
|
||||
}
|
||||
|
||||
/** Convert one completed Chat Completions JSON object into Anthropic Messages JSON. */
|
||||
export function openAIChatToAnthropicMessage(response: unknown, fallbackModel: string): JsonObject {
|
||||
const r = object(response) ?? {};
|
||||
const choice = Array.isArray(r.choices) ? object(r.choices[0]) : undefined;
|
||||
const message = object(choice?.message) ?? {};
|
||||
const content: JsonObject[] = [];
|
||||
let hasToolUse = false;
|
||||
|
||||
if (typeof message.content === 'string' && message.content.length) {
|
||||
content.push({ type: 'text', text: message.content });
|
||||
} else if (Array.isArray(message.content)) {
|
||||
for (const raw of message.content) {
|
||||
const part = object(raw);
|
||||
if (part?.type === 'text' && typeof part.text === 'string') {
|
||||
content.push({ type: 'text', text: part.text });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(message.tool_calls)) {
|
||||
for (const raw of message.tool_calls) {
|
||||
const call = object(raw);
|
||||
const fn = object(call?.function);
|
||||
if (!fn || typeof fn.name !== 'string') continue;
|
||||
hasToolUse = true;
|
||||
content.push({
|
||||
type: 'tool_use',
|
||||
id: typeof call?.id === 'string' ? call.id : `call_${content.length}`,
|
||||
name: fn.name,
|
||||
input: parseArguments(fn.arguments),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (content.length === 0) content.push({ type: 'text', text: '' });
|
||||
|
||||
const id = typeof r.id === 'string' ? r.id.replace(/^chatcmpl[-_]/, 'msg_') : 'msg_pxpipe';
|
||||
return {
|
||||
id,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: typeof r.model === 'string' ? r.model : fallbackModel,
|
||||
content,
|
||||
stop_reason: stopReason(choice?.finish_reason, hasToolUse),
|
||||
stop_sequence: null,
|
||||
usage: anthropicUsage(r.usage),
|
||||
};
|
||||
}
|
||||
|
||||
function sse(event: string, data: JsonObject): string {
|
||||
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
interface StreamCall {
|
||||
index: number; // Anthropic content-block index
|
||||
started: boolean;
|
||||
}
|
||||
|
||||
interface StreamState {
|
||||
started: boolean;
|
||||
terminated: boolean;
|
||||
id: string;
|
||||
model: string;
|
||||
nextIndex: number;
|
||||
textIndex?: number;
|
||||
textOpen: boolean;
|
||||
sawTool: boolean;
|
||||
finish?: unknown;
|
||||
usage?: JsonObject;
|
||||
// OpenAI tool_calls stream by their own `index`; map it to our block state.
|
||||
calls: Map<number, StreamCall>;
|
||||
}
|
||||
|
||||
function chatStreamEvent(chunk: JsonObject, state: StreamState): string {
|
||||
let out = '';
|
||||
const ensureStart = (): void => {
|
||||
if (state.started) return;
|
||||
if (typeof chunk.id === 'string') state.id = chunk.id.replace(/^chatcmpl[-_]/, 'msg_');
|
||||
if (typeof chunk.model === 'string') state.model = chunk.model;
|
||||
state.started = true;
|
||||
out += sse('message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: state.id, type: 'message', role: 'assistant', model: state.model,
|
||||
content: [], stop_reason: null, stop_sequence: null, usage: anthropicUsage(undefined),
|
||||
},
|
||||
});
|
||||
};
|
||||
const openText = (): void => {
|
||||
ensureStart();
|
||||
if (state.textOpen) return;
|
||||
state.textIndex = state.nextIndex++;
|
||||
state.textOpen = true;
|
||||
out += sse('content_block_start', {
|
||||
type: 'content_block_start', index: state.textIndex, content_block: { type: 'text', text: '' },
|
||||
});
|
||||
};
|
||||
const closeText = (): void => {
|
||||
if (!state.textOpen || state.textIndex === undefined) return;
|
||||
out += sse('content_block_stop', { type: 'content_block_stop', index: state.textIndex });
|
||||
state.textOpen = false;
|
||||
};
|
||||
|
||||
if (chunk.usage) state.usage = anthropicUsage(chunk.usage);
|
||||
const choice = Array.isArray(chunk.choices) ? object(chunk.choices[0]) : undefined;
|
||||
if (!choice) { ensureStart(); return out; }
|
||||
if (choice.finish_reason != null) state.finish = choice.finish_reason;
|
||||
const delta = object(choice.delta) ?? {};
|
||||
|
||||
if (typeof delta.content === 'string' && delta.content.length) {
|
||||
openText();
|
||||
out += sse('content_block_delta', {
|
||||
type: 'content_block_delta', index: state.textIndex,
|
||||
delta: { type: 'text_delta', text: delta.content },
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(delta.tool_calls)) {
|
||||
for (const raw of delta.tool_calls) {
|
||||
const tc = object(raw);
|
||||
if (!tc) continue;
|
||||
const key = typeof tc.index === 'number' ? tc.index : state.calls.size;
|
||||
const fn = object(tc.function) ?? {};
|
||||
let call = state.calls.get(key);
|
||||
if (!call) {
|
||||
closeText();
|
||||
ensureStart();
|
||||
call = { index: state.nextIndex++, started: true };
|
||||
state.calls.set(key, call);
|
||||
state.sawTool = true;
|
||||
out += sse('content_block_start', {
|
||||
type: 'content_block_start', index: call.index,
|
||||
content_block: {
|
||||
type: 'tool_use',
|
||||
id: typeof tc.id === 'string' ? tc.id : `call_${key}`,
|
||||
name: typeof fn.name === 'string' ? fn.name : '',
|
||||
input: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (typeof fn.arguments === 'string' && fn.arguments.length) {
|
||||
out += sse('content_block_delta', {
|
||||
type: 'content_block_delta', index: call.index,
|
||||
delta: { type: 'input_json_delta', partial_json: fn.arguments },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function finalizeStream(state: StreamState): string {
|
||||
let out = '';
|
||||
if (!state.started) {
|
||||
out += sse('message_start', {
|
||||
type: 'message_start',
|
||||
message: {
|
||||
id: state.id, type: 'message', role: 'assistant', model: state.model,
|
||||
content: [], stop_reason: null, stop_sequence: null, usage: anthropicUsage(undefined),
|
||||
},
|
||||
});
|
||||
state.started = true;
|
||||
}
|
||||
if (state.textOpen && state.textIndex !== undefined) {
|
||||
out += sse('content_block_stop', { type: 'content_block_stop', index: state.textIndex });
|
||||
state.textOpen = false;
|
||||
}
|
||||
for (const call of state.calls.values()) {
|
||||
out += sse('content_block_stop', { type: 'content_block_stop', index: call.index });
|
||||
}
|
||||
out += sse('message_delta', {
|
||||
type: 'message_delta',
|
||||
delta: { stop_reason: stopReason(state.finish, state.sawTool), stop_sequence: null },
|
||||
usage: state.usage ?? anthropicUsage(undefined),
|
||||
});
|
||||
out += sse('message_stop', { type: 'message_stop' });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Incrementally translate Chat Completions SSE without buffering model output. */
|
||||
export function openAIChatStreamToAnthropic(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
fallbackModel: string,
|
||||
): ReadableStream<Uint8Array> {
|
||||
const decoder = new TextDecoder();
|
||||
const encoder = new TextEncoder();
|
||||
let buffer = '';
|
||||
let pendingCR = false;
|
||||
let done = false;
|
||||
const state: StreamState = {
|
||||
started: false, terminated: false, id: 'msg_pxpipe', model: fallbackModel,
|
||||
nextIndex: 0, textOpen: false, sawTool: false, calls: new Map(),
|
||||
};
|
||||
const process = (chunk: string, controller: TransformStreamDefaultController<Uint8Array>, final = false): void => {
|
||||
let normalized = chunk;
|
||||
if (pendingCR) { normalized = '\r' + normalized; pendingCR = false; }
|
||||
if (!final && normalized.endsWith('\r')) { normalized = normalized.slice(0, -1); pendingCR = true; }
|
||||
buffer += normalized.replace(/\r\n|\r/g, '\n');
|
||||
let end: number;
|
||||
while ((end = buffer.indexOf('\n\n')) >= 0) {
|
||||
const block = buffer.slice(0, end); buffer = buffer.slice(end + 2);
|
||||
const data: string[] = [];
|
||||
for (const line of block.split('\n')) {
|
||||
if (line.startsWith('data:')) data.push(line.slice(5).trimStart());
|
||||
}
|
||||
if (data.length === 0) continue;
|
||||
const payload = data.join('\n');
|
||||
if (payload === '[DONE]') { done = true; continue; }
|
||||
if (state.terminated) continue;
|
||||
try {
|
||||
const rendered = chatStreamEvent(JSON.parse(payload) as JsonObject, state);
|
||||
if (rendered) controller.enqueue(encoder.encode(rendered));
|
||||
} catch {
|
||||
controller.enqueue(encoder.encode(sse('error', {
|
||||
type: 'error', error: { type: 'api_error', message: 'OpenAI returned a malformed streaming event' },
|
||||
})));
|
||||
state.terminated = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
return body.pipeThrough(new TransformStream<Uint8Array, Uint8Array>({
|
||||
transform(chunk, controller) {
|
||||
process(decoder.decode(chunk, { stream: true }), controller);
|
||||
},
|
||||
flush(controller) {
|
||||
process(decoder.decode() + (pendingCR ? '\r' : '') + '\n\n', controller, true);
|
||||
pendingCR = false;
|
||||
if (!state.terminated) {
|
||||
controller.enqueue(encoder.encode(finalizeStream(state)));
|
||||
}
|
||||
void done;
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/** Translate a Chat Completions HTTP response to the Messages wire format. */
|
||||
export async function openAIChatToAnthropicResponse(
|
||||
response: Response,
|
||||
fallbackModel: string,
|
||||
): Promise<Response> {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.delete('content-length');
|
||||
const contentType = (headers.get('content-type') ?? '').toLowerCase();
|
||||
if (!response.ok) {
|
||||
let source: JsonObject = {};
|
||||
let fallbackMessage = response.statusText || `OpenAI request failed with status ${response.status}`;
|
||||
if (response.body) {
|
||||
if (contentType.includes('json')) {
|
||||
const raw = object(await response.json()) ?? {};
|
||||
source = object(raw.error) ?? raw;
|
||||
} else {
|
||||
const text = (await response.text()).trim();
|
||||
if (text) fallbackMessage = text.slice(0, 4096);
|
||||
}
|
||||
}
|
||||
const rawType = typeof source.type === 'string' ? source.type : 'api_error';
|
||||
const code = typeof source.code === 'string' ? source.code : '';
|
||||
const type = rawType === 'invalid_request_error' || rawType === 'invalid_request'
|
||||
? 'invalid_request_error' : rawType;
|
||||
const message = typeof source.message === 'string' ? source.message : code || fallbackMessage;
|
||||
headers.set('content-type', 'application/json');
|
||||
return new Response(JSON.stringify({ type: 'error', error: { type, message } }), {
|
||||
status: response.status, statusText: response.statusText, headers,
|
||||
});
|
||||
}
|
||||
if (!response.body) return response;
|
||||
if (contentType.includes('text/event-stream')) {
|
||||
headers.set('content-type', 'text/event-stream; charset=utf-8');
|
||||
return new Response(openAIChatStreamToAnthropic(response.body, fallbackModel), {
|
||||
status: response.status, statusText: response.statusText, headers,
|
||||
});
|
||||
}
|
||||
const converted = openAIChatToAnthropicMessage(await response.json(), fallbackModel);
|
||||
headers.set('content-type', 'application/json');
|
||||
return new Response(JSON.stringify(converted), {
|
||||
status: response.status, statusText: response.statusText, headers,
|
||||
});
|
||||
}
|
||||
|
||||
/** Build the chat-completions endpoint URL from a user-supplied base. Accepts a
|
||||
* bare host, a `/v1` base, or the full `/chat/completions` URL. */
|
||||
export function chatCompletionsUrl(base: string): string {
|
||||
const b = base.replace(/\/+$/, '');
|
||||
if (/\/chat\/completions$/.test(b)) return b;
|
||||
if (/\/v\d+$/.test(b)) return `${b}/chat/completions`;
|
||||
return `${b}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
/** Resolve the OpenAI-compatible chat upstream + bearer key from env inputs.
|
||||
* Explicit OPENAPI_URL/OPENAPI_API always win. Otherwise, if a Cloudflare
|
||||
* account id AND API token are both present, derive the Workers AI endpoint so
|
||||
* Kimi (etc.) works with zero extra config. The Cloudflare token is only paired
|
||||
* with the derived Cloudflare endpoint — never a user-supplied OPENAPI_URL. */
|
||||
export function resolveChatUpstream(env: {
|
||||
openapiUrl?: string;
|
||||
openapiApi?: string;
|
||||
cloudflareAccountId?: string;
|
||||
cloudflareApiToken?: string;
|
||||
}): { chatUpstream: string | undefined; chatApiKey: string | undefined } {
|
||||
const explicitUrl = env.openapiUrl?.trim() || undefined;
|
||||
const explicitKey = env.openapiApi?.trim() || undefined;
|
||||
const account = env.cloudflareAccountId?.trim();
|
||||
const token = env.cloudflareApiToken?.trim();
|
||||
const cloudflareBase = account && token
|
||||
? `https://api.cloudflare.com/client/v4/accounts/${account}/ai/v1`
|
||||
: undefined;
|
||||
return {
|
||||
chatUpstream: explicitUrl || cloudflareBase,
|
||||
chatApiKey: explicitKey || (explicitUrl ? undefined : token),
|
||||
};
|
||||
}
|
||||
|
||||
/** Model stamped on bridged chat requests when the account catalog doesn't
|
||||
* surface a usable OpenAI-compatible Kimi model. Matches the Cloudflare
|
||||
* OpenAI-compat chat/completions example (model: moonshotai/kimi-k3). */
|
||||
export const DEFAULT_CHAT_MODEL = 'moonshotai/kimi-k3';
|
||||
|
||||
/** Pick the best chat model from an account's model catalog. Only considers
|
||||
* partner-catalog Kimi models — legacy Workers AI "@cf/…" builds are never
|
||||
* auto-selected because they reject the full OpenAI chat schema (400 on
|
||||
* system/tool role messages). Among candidates: general builds over "-code"
|
||||
* variants, then the newest version (numeric-aware descending sort). */
|
||||
export function pickChatModel(names: string[]): string | undefined {
|
||||
const kimi = names.filter((n) => /kimi/i.test(n) && !n.startsWith('@cf/'));
|
||||
if (kimi.length === 0) return undefined;
|
||||
const rank = (n: string) => (/-code$/i.test(n) ? 1 : 0);
|
||||
return [...kimi].sort(
|
||||
(a, b) => rank(a) - rank(b) || b.localeCompare(a, undefined, { numeric: true }),
|
||||
)[0];
|
||||
}
|
||||
|
||||
/** Lazily discover which model to stamp on bridged requests by asking the
|
||||
* Cloudflare account what it exposes (ai/models/search). Success (including
|
||||
* "no Kimi in catalog") is cached for the process lifetime; network failures
|
||||
* are NOT cached, so a transient error retries on the next request. Built
|
||||
* only when the operator provided Cloudflare creds without OPENAPI_MODEL. */
|
||||
export function makeCloudflareModelResolver(opts: {
|
||||
accountId: string;
|
||||
apiToken: string;
|
||||
log?: (msg: string) => void;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): () => Promise<string | undefined> {
|
||||
let cached: Promise<string | undefined> | undefined;
|
||||
const doFetch = opts.fetchImpl ?? fetch;
|
||||
return () => {
|
||||
cached ??= (async () => {
|
||||
const url =
|
||||
`https://api.cloudflare.com/client/v4/accounts/${opts.accountId}` +
|
||||
`/ai/models/search?search=kimi&per_page=100`;
|
||||
const res = await doFetch(url, {
|
||||
headers: { authorization: `Bearer ${opts.apiToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`models/search → HTTP ${res.status}`);
|
||||
const json = (await res.json()) as { result?: Array<{ name?: string }> };
|
||||
const names = (json.result ?? [])
|
||||
.map((m) => m.name)
|
||||
.filter((n): n is string => typeof n === 'string');
|
||||
const picked = pickChatModel(names);
|
||||
if (picked !== undefined) {
|
||||
opts.log?.(`auto-selected Cloudflare model → ${picked} (override with OPENAPI_MODEL)`);
|
||||
return picked;
|
||||
}
|
||||
opts.log?.(
|
||||
`no OpenAI-compatible Kimi model in catalog (${names.length} entries); ` +
|
||||
`defaulting to ${DEFAULT_CHAT_MODEL} (override with OPENAPI_MODEL)`,
|
||||
);
|
||||
return DEFAULT_CHAT_MODEL;
|
||||
})().catch((err: unknown) => {
|
||||
cached = undefined; // transient failure — retry on the next request
|
||||
opts.log?.(
|
||||
`model auto-discovery failed (${err instanceof Error ? err.message : String(err)}); ` +
|
||||
`using ${DEFAULT_CHAT_MODEL} for this request`,
|
||||
);
|
||||
return DEFAULT_CHAT_MODEL;
|
||||
});
|
||||
return cached;
|
||||
};
|
||||
}
|
||||
@@ -107,8 +107,16 @@ export function anthropicMessagesToOpenAIResponses(body: Uint8Array): Uint8Array
|
||||
if (Array.isArray(req.messages)) {
|
||||
for (const rawMessage of req.messages) {
|
||||
const message = object(rawMessage);
|
||||
if (!message || (message.role !== 'user' && message.role !== 'assistant')) {
|
||||
invalidRequest('Each message must have a user or assistant role');
|
||||
if (!message
|
||||
|| (message.role !== 'user' && message.role !== 'assistant' && message.role !== 'system')) {
|
||||
invalidRequest('Each message must have a user, assistant, or system role');
|
||||
}
|
||||
// Newer Claude Code builds inject system-role messages mid-conversation
|
||||
// (e.g. system reminders); map them to Responses system input items.
|
||||
if (message.role === 'system') {
|
||||
const text = textFromBlocks(message.content);
|
||||
if (text) input.push({ role: 'system', content: [{ type: 'input_text', text }] });
|
||||
continue;
|
||||
}
|
||||
const content = message.content;
|
||||
if (!Array.isArray(content)) {
|
||||
|
||||
+64
-18
@@ -123,6 +123,17 @@ export function isGrokModel(model: string | null | undefined): boolean {
|
||||
return (model ?? '').toLowerCase().startsWith('grok-');
|
||||
}
|
||||
|
||||
/** Name-shaped test for models that speak the OpenAI *Responses* API
|
||||
* (GPT/o-series/codex, plus Grok's OpenAI-compatible surface). Routing must
|
||||
* be name-based here: PXPIPE_MODELS is one scope shared by every family, so
|
||||
* "allowed" alone cannot tell a GPT entry from a Kimi one. Anything not
|
||||
* name-matched (Kimi, DeepSeek, …) falls through to the Chat Completions
|
||||
* bridge / passthrough instead of being forced onto the Responses bridge. */
|
||||
export function isGptResponsesModel(model: string | null | undefined): boolean {
|
||||
const m = (model ?? '').toLowerCase();
|
||||
return m.startsWith('gpt-') || m.startsWith('codex') || /^o\d/.test(m) || isGrokModel(m);
|
||||
}
|
||||
|
||||
/** Measured 2026-07-09 on grok-4.5: image-token delta ≈ 1000 per megapixel
|
||||
* across several page sizes (768x336 → 268, 764x980 → 748, etc.). */
|
||||
export const GROK_TOKENS_PER_MEGAPIXEL = 1000;
|
||||
@@ -395,27 +406,60 @@ function isFlatFunctionTool(tool: unknown): tool is ResponsesFlatTool {
|
||||
);
|
||||
}
|
||||
|
||||
/** Full doc (prose + compact schema JSON) for one tool. On this path the docs are
|
||||
* IMAGED, so carrying the schema here is compression, not duplication: the imaged
|
||||
* copy keeps param docs readable while tools[] ships the stripped skeleton.
|
||||
* (Contrast transform.ts renderToolDoc: text reference → prose only.) */
|
||||
/** Render only schema annotations removed from the native tool definition.
|
||||
* Structural JSON and the top-level tool description remain native, so putting
|
||||
* them in the image too spends vision tokens without replacing any text. */
|
||||
function schemaAnnotationLines(node: unknown, path = '$', depth = 0): string[] {
|
||||
if (!node || typeof node !== 'object' || depth > 20) return [];
|
||||
if (Array.isArray(node)) {
|
||||
return node.flatMap((value, i) => schemaAnnotationLines(value, `${path}[${i}]`, depth + 1));
|
||||
}
|
||||
const obj = node as Record<string, unknown>;
|
||||
const out: string[] = [];
|
||||
for (const key of ['description', 'title', 'examples', 'default', '$comment']) {
|
||||
if (obj[key] !== undefined) out.push(`${path} ${key}: ${JSON.stringify(obj[key])}`);
|
||||
}
|
||||
if (typeof obj.format === 'string' && obj.format.length > 32) {
|
||||
out.push(`${path} format: ${JSON.stringify(obj.format)}`);
|
||||
}
|
||||
for (const key of ['properties', 'patternProperties', 'definitions', '$defs']) {
|
||||
const children = obj[key];
|
||||
if (!children || typeof children !== 'object' || Array.isArray(children)) continue;
|
||||
for (const [name, child] of Object.entries(children as Record<string, unknown>)) {
|
||||
out.push(...schemaAnnotationLines(child, `${path}.${name}`, depth + 1));
|
||||
}
|
||||
}
|
||||
for (const key of ['oneOf', 'anyOf', 'allOf']) {
|
||||
const children = obj[key];
|
||||
if (!Array.isArray(children)) continue;
|
||||
children.forEach((child, i) => {
|
||||
out.push(...schemaAnnotationLines(child, `${path}.${key}[${i}]`, depth + 1));
|
||||
});
|
||||
}
|
||||
for (const key of [
|
||||
'items', 'additionalProperties', 'not', 'contains', 'propertyNames',
|
||||
'unevaluatedItems', 'unevaluatedProperties', 'if', 'then', 'else',
|
||||
]) {
|
||||
if (obj[key] !== undefined) {
|
||||
out.push(...schemaAnnotationLines(obj[key], `${path}.${key}`, depth + 1));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderToolDoc(tool: OpenAIFunctionTool): string {
|
||||
const f = tool.function;
|
||||
const parts = [`## Tool: ${f.name ?? '?'}`];
|
||||
if (typeof f.description === 'string' && f.description.length > 0) parts.push(f.description);
|
||||
if (f.parameters !== undefined) {
|
||||
parts.push('```json\n' + JSON.stringify(f.parameters) + '\n```');
|
||||
}
|
||||
return parts.join('\n');
|
||||
const annotations = schemaAnnotationLines(f.parameters);
|
||||
return annotations.length
|
||||
? [`## Tool parameter annotations: ${f.name ?? '?'}`, ...annotations].join('\n')
|
||||
: '';
|
||||
}
|
||||
|
||||
function renderFlatToolDoc(tool: ResponsesFlatTool): string {
|
||||
const parts = [`## Tool: ${tool.name ?? '?'}`];
|
||||
if (typeof tool.description === 'string' && tool.description.length > 0) parts.push(tool.description);
|
||||
if (tool.parameters !== undefined) {
|
||||
parts.push('```json\n' + JSON.stringify(tool.parameters) + '\n```');
|
||||
}
|
||||
return parts.join('\n');
|
||||
const annotations = schemaAnnotationLines(tool.parameters);
|
||||
return annotations.length
|
||||
? [`## Tool parameter annotations: ${tool.name ?? '?'}`, ...annotations].join('\n')
|
||||
: '';
|
||||
}
|
||||
|
||||
function rewriteToolsForGpt(tools: unknown[] | undefined): {
|
||||
@@ -427,7 +471,8 @@ function rewriteToolsForGpt(tools: unknown[] | undefined): {
|
||||
let changed = false;
|
||||
const rewritten = tools.map((tool) => {
|
||||
if (!isFunctionTool(tool)) return tool;
|
||||
docs.push(renderToolDoc(tool));
|
||||
const doc = renderToolDoc(tool);
|
||||
if (doc) docs.push(doc);
|
||||
if (tool.function.parameters === undefined) return tool;
|
||||
changed = true;
|
||||
return {
|
||||
@@ -450,7 +495,8 @@ function rewriteFlatToolsForGpt(tools: unknown[] | undefined): {
|
||||
let changed = false;
|
||||
const rewritten = tools.map((tool) => {
|
||||
if (!isFlatFunctionTool(tool)) return tool;
|
||||
docs.push(renderFlatToolDoc(tool));
|
||||
const doc = renderFlatToolDoc(tool);
|
||||
if (doc) docs.push(doc);
|
||||
if (tool.parameters === undefined) return tool;
|
||||
changed = true;
|
||||
return {
|
||||
|
||||
+177
-21
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { transformRequest, type TransformOptions, type TransformInfo } from './transform.js';
|
||||
import { isClaudeModel, transformOpenAIChatCompletions, transformOpenAIResponses } from './openai.js';
|
||||
import { isClaudeModel, isGptResponsesModel, transformOpenAIChatCompletions, transformOpenAIResponses } from './openai.js';
|
||||
import { isAnthropicMessagesPath, isPxpipeSupportedGptModel, isPxpipeSupportedModel } from './applicability.js';
|
||||
import {
|
||||
buildBaselineCountTokensBody,
|
||||
@@ -15,6 +15,11 @@ import {
|
||||
anthropicMessagesToOpenAIResponses,
|
||||
openAIResponsesToAnthropicResponse,
|
||||
} from './messages-responses-bridge.js';
|
||||
import {
|
||||
anthropicMessagesToOpenAIChat,
|
||||
chatCompletionsUrl,
|
||||
openAIChatToAnthropicResponse,
|
||||
} from './messages-chat-bridge.js';
|
||||
|
||||
export interface ProxyConfig {
|
||||
/** 'cloudflare-ai-gateway': routes both families through gatewayBaseUrl;
|
||||
@@ -32,6 +37,28 @@ export interface ProxyConfig {
|
||||
openAIUpstream?: string;
|
||||
/** Override or supply an OpenAI API key. If unset, we forward Authorization. */
|
||||
openAIApiKey?: string;
|
||||
/** OpenAI-compatible chat-completions endpoint for non-Claude, non-GPT models
|
||||
* (e.g. Kimi via Cloudflare Workers AI / Moonshot / OpenRouter). When set,
|
||||
* a /v1/messages request for such a model is bridged to Chat Completions and
|
||||
* forwarded here. Base URL, `/v1` base, or full …/chat/completions all work. */
|
||||
chatUpstream?: string;
|
||||
/** Bearer key for chatUpstream. */
|
||||
chatApiKey?: string;
|
||||
/** OPENAPI_MODEL: force ALL /v1/messages traffic to chatUpstream and stamp this
|
||||
* model id upstream, regardless of the model name the client sends. Lets Claude
|
||||
* Code keep its own model label while every request goes to Kimi/etc. When
|
||||
* unset, routing stays name-based (Claude names pass through to Anthropic). */
|
||||
chatModel?: string;
|
||||
/** Lazy model discovery for chatUpstream: consulted only for bridged
|
||||
* requests whose client-sent model is a Claude name and no chatModel is
|
||||
* set (a claude-* id forwarded verbatim to a non-Anthropic upstream can
|
||||
* never succeed). The resolver caches its own result. */
|
||||
resolveChatModel?: () => Promise<string | undefined>;
|
||||
/** Exact model ids routed to each provider from the Messages compatibility
|
||||
* endpoint. Unlisted models retain the normal name-based routing behavior. */
|
||||
anthropicModels?: string[];
|
||||
openAIModels?: string[];
|
||||
cloudflareModels?: string[];
|
||||
/** Pass a function to inject dynamic values per-request (e.g. live charsPerToken);
|
||||
* static object for Workers/tests. */
|
||||
transform?: TransformOptions | (() => TransformOptions);
|
||||
@@ -95,6 +122,26 @@ function readModelField(body: Uint8Array): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Claude Code only admits gateway-discovered ids beginning with "claude" or
|
||||
// "anthropic". Prefix provider ids for discovery, then remove that compatibility
|
||||
// prefix before PXPIPE_MODELS matching and upstream routing.
|
||||
const CLAUDE_GATEWAY_MODEL_PREFIX = 'claude-';
|
||||
|
||||
function claudeGatewayModelId(model: string): string {
|
||||
if (model.startsWith('claude-') || model.startsWith('anthropic')) return model;
|
||||
return CLAUDE_GATEWAY_MODEL_PREFIX + model;
|
||||
}
|
||||
|
||||
function resolveClaudeGatewayModelId(model: string | null): string | undefined {
|
||||
if (!model?.startsWith(CLAUDE_GATEWAY_MODEL_PREFIX)) return undefined;
|
||||
const providerModel = model.slice(CLAUDE_GATEWAY_MODEL_PREFIX.length);
|
||||
return providerModel.includes('/') ? providerModel : undefined;
|
||||
}
|
||||
|
||||
function chatModelDisplayName(model: string): string {
|
||||
return /kimi-k3/i.test(model) ? 'Kimi K3 (Cloudflare)' : model;
|
||||
}
|
||||
|
||||
/** Gzip via CompressionStream — available in Node 18+ and Cloudflare Workers. */
|
||||
async function gzipBytes(body: Uint8Array): Promise<Uint8Array> {
|
||||
// Cast: TS doesn't model Response(Uint8Array) even though it works in both runtimes.
|
||||
@@ -607,6 +654,26 @@ export function parseGatewayHeaders(spec: string | undefined): Record<string, st
|
||||
|
||||
/** Build the proxy fetch handler. */
|
||||
export function createProxy(config: ProxyConfig = {}) {
|
||||
const modelRoutes = new Map<string, 'anthropic' | 'openai' | 'cloudflare'>();
|
||||
for (const [provider, models] of [
|
||||
['anthropic', config.anthropicModels],
|
||||
['openai', config.openAIModels],
|
||||
['cloudflare', config.cloudflareModels],
|
||||
] as const) {
|
||||
for (const model of models ?? []) {
|
||||
const id = model.trim();
|
||||
if (!id) continue;
|
||||
const existing = modelRoutes.get(id);
|
||||
if (existing && existing !== provider) {
|
||||
throw new Error(`model ${id} is configured for both ${existing} and ${provider}`);
|
||||
}
|
||||
modelRoutes.set(id, provider);
|
||||
}
|
||||
}
|
||||
if ((config.cloudflareModels?.length ?? 0) > 0 && config.chatUpstream === undefined) {
|
||||
throw new Error('cloudflareModels requires a Cloudflare or OPENAPI chat upstream');
|
||||
}
|
||||
const scopedRouting = modelRoutes.size > 0;
|
||||
const routes = resolveUpstreams(config);
|
||||
const upstream = routes.anthropic;
|
||||
const openAIUpstream = routes.openai;
|
||||
@@ -624,6 +691,30 @@ export function createProxy(config: ProxyConfig = {}) {
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname + url.search;
|
||||
|
||||
// Reversibly disguise the configured upstream id for Claude Code's model
|
||||
// picker, matching CLIProxyAPI. The id decodes back to the exact provider
|
||||
// model on /v1/messages, so discovery and routing cannot drift apart.
|
||||
if (req.method === 'GET' && url.pathname === '/v1/models' && config.chatUpstream !== undefined) {
|
||||
const configuredModels = config.cloudflareModels?.filter(Boolean) ?? [];
|
||||
const discoveredModel = config.chatModel ?? await config.resolveChatModel?.();
|
||||
const models = (configuredModels.length > 0
|
||||
? configuredModels
|
||||
: discoveredModel ? [discoveredModel] : []).map((model) => ({
|
||||
id: claudeGatewayModelId(model),
|
||||
type: 'model',
|
||||
display_name: chatModelDisplayName(model),
|
||||
created_at: '1970-01-01T00:00:00Z',
|
||||
}));
|
||||
return new Response(JSON.stringify({
|
||||
data: models,
|
||||
has_more: false,
|
||||
first_id: models[0]?.id ?? '',
|
||||
last_id: models.at(-1)?.id ?? '',
|
||||
}), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// reqBodyBytes: kept for lazy gzip on 4xx. reqBodySha8: computed eagerly for correlation.
|
||||
let reqBodyBytes: Uint8Array | undefined;
|
||||
let reqBodySha8: string | undefined;
|
||||
@@ -700,9 +791,10 @@ export function createProxy(config: ProxyConfig = {}) {
|
||||
// Responses-shaped even though they are not canonical `/v1/*` paths.
|
||||
// Classify by the parsed wire route, otherwise the dashboard ignores
|
||||
// GPT image/baseline telemetry and renders As text / Saved as dashes.
|
||||
accountingProvider: isOpenAIChat || isOpenAIResponses || bridgedGptMessages
|
||||
? 'openai'
|
||||
: 'anthropic',
|
||||
accountingProvider:
|
||||
isOpenAIChat || isOpenAIResponses || bridgedGptMessages || bridgedChatMessages
|
||||
? 'openai'
|
||||
: 'anthropic',
|
||||
status,
|
||||
durationMs: Date.now() - t0,
|
||||
firstByteMs,
|
||||
@@ -735,6 +827,7 @@ export function createProxy(config: ProxyConfig = {}) {
|
||||
let info: TransformInfo | undefined;
|
||||
let requestModel: string | undefined;
|
||||
let bridgedGptMessages = false;
|
||||
let bridgedChatMessages = false;
|
||||
|
||||
// Two count_tokens probes on the pre-compression body (see docs/HISTORY_CACHE_MODEL.md):
|
||||
// baselinePromise → full-body input_tokens
|
||||
@@ -757,23 +850,72 @@ export function createProxy(config: ProxyConfig = {}) {
|
||||
// renderer or Anthropic count_tokens merely because the route is
|
||||
// Messages-shaped. Enabled GPT models take the standalone
|
||||
// Messages→Responses bridge; unsupported models still fail closed.
|
||||
const messagesAnthropic = isMessages && isClaudeModel(model);
|
||||
bridgedGptMessages = isMessages && !messagesAnthropic && isPxpipeSupportedGptModel(model);
|
||||
// chatUpstream defined (OPENAPI_URL, or derived from CLOUDFLARE_ACCOUNT_ID
|
||||
// + CLOUDFLARE_API_TOKEN) → force EVERY Messages request onto it: the
|
||||
// operator declared "this is my endpoint", so routing stops being
|
||||
// name-based entirely. OPENAPI_MODEL is optional on top and only stamps
|
||||
// the outgoing model id (below) — that is what lets Claude Code keep
|
||||
// showing "Opus" while all traffic actually goes to Kimi/etc. Without
|
||||
// it, the client-sent model name is forwarded verbatim.
|
||||
const decodedChatModel = resolveClaudeGatewayModelId(model);
|
||||
const routedModel = decodedChatModel ?? model;
|
||||
const modelRoute = routedModel ? modelRoutes.get(routedModel) : undefined;
|
||||
// OPENAPI_URL historically forced all Messages requests. Preserve that
|
||||
// behavior only when no explicit model routing scope is configured.
|
||||
const forceChat = isMessages && config.chatUpstream !== undefined
|
||||
&& (modelRoute === 'cloudflare' || (!scopedRouting && modelRoute === undefined));
|
||||
const messagesAnthropic = isMessages
|
||||
&& (modelRoute === 'anthropic' || (modelRoute === undefined && !forceChat && isClaudeModel(model)));
|
||||
// No chat upstream configured → name-based routing. The Responses
|
||||
// bridge is only for GPT/Grok-SHAPED names: PXPIPE_MODELS is one scope
|
||||
// for all families, so scope membership alone would misroute a listed
|
||||
// Kimi model here; anything else fails closed to passthrough.
|
||||
bridgedGptMessages =
|
||||
isMessages && !messagesAnthropic && !forceChat
|
||||
&& (modelRoute === 'openai'
|
||||
|| (modelRoute === undefined && isGptResponsesModel(model) && isPxpipeSupportedGptModel(model)));
|
||||
// Messages → Chat Completions bridge toward chatUpstream (e.g. Kimi via
|
||||
// Cloudflare Workers AI). Kimi is an unvalidated imaged reader, so this
|
||||
// path never compresses — it is a pure wire-format bridge (modelOk
|
||||
// stays false → compress:false below).
|
||||
bridgedChatMessages = forceChat;
|
||||
// Model to stamp on the bridged chat request: explicit OPENAPI_MODEL
|
||||
// wins; otherwise, when the client sent a Claude-shaped name, ask the
|
||||
// lazy resolver (Cloudflare account catalog, cached). Non-Claude
|
||||
// client ids still forward verbatim so the exact upstream model can
|
||||
// be chosen from the client side.
|
||||
const chatStamp = bridgedChatMessages
|
||||
? scopedRouting
|
||||
? routedModel
|
||||
: config.chatModel ??
|
||||
routedModel ??
|
||||
(isClaudeModel(model) && config.resolveChatModel !== undefined
|
||||
? await config.resolveChatModel()
|
||||
: undefined)
|
||||
: undefined;
|
||||
const effectiveModel = chatStamp ?? model;
|
||||
const modelOk = isMessages
|
||||
? (messagesAnthropic && isPxpipeSupportedModel(model)) || bridgedGptMessages
|
||||
? (messagesAnthropic && isPxpipeSupportedModel(model))
|
||||
|| bridgedGptMessages
|
||||
|| (bridgedChatMessages && isPxpipeSupportedGptModel(effectiveModel))
|
||||
: isPxpipeSupportedGptModel(model);
|
||||
// Unsupported model → a true passthrough: no break-even compression
|
||||
// (a text-only model may not accept injected image blocks at all).
|
||||
// Compression eligibility and telemetry follow the model that actually
|
||||
// receives the request, not Claude Code's local gateway alias.
|
||||
if (bridgedChatMessages && effectiveModel) requestModel = effectiveModel;
|
||||
const effectiveOpts = modelOk
|
||||
? transformOpts
|
||||
: { ...transformOpts, compress: false };
|
||||
const bridgeBody = bridgedGptMessages
|
||||
? anthropicMessagesToOpenAIResponses(bodyIn)
|
||||
: bodyIn;
|
||||
: bridgedChatMessages
|
||||
? anthropicMessagesToOpenAIChat(bodyIn, chatStamp ?? undefined)
|
||||
: bodyIn;
|
||||
const r = isMessages
|
||||
? bridgedGptMessages
|
||||
? await transformOpenAIResponses(bridgeBody, effectiveOpts)
|
||||
: await transformRequest(bodyIn, effectiveOpts)
|
||||
: bridgedChatMessages
|
||||
? await transformOpenAIChatCompletions(bridgeBody, effectiveOpts)
|
||||
: await transformRequest(bodyIn, effectiveOpts)
|
||||
: isOpenAIChat
|
||||
? await transformOpenAIChatCompletions(bodyIn, effectiveOpts)
|
||||
: await transformOpenAIResponses(bodyIn, effectiveOpts);
|
||||
@@ -812,7 +954,7 @@ export function createProxy(config: ProxyConfig = {}) {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (bridgedGptMessages && (e as Error).name === 'MessagesBridgeInvalidRequest') {
|
||||
if ((bridgedGptMessages || bridgedChatMessages) && (e as Error).name === 'MessagesBridgeInvalidRequest') {
|
||||
fire(400, undefined, `invalid_request: ${(e as Error).message}`);
|
||||
return new Response(JSON.stringify({
|
||||
type: 'error',
|
||||
@@ -830,17 +972,20 @@ export function createProxy(config: ProxyConfig = {}) {
|
||||
}
|
||||
|
||||
const outHeaders = filterHeaders(req.headers, STRIP_REQ_HEADERS);
|
||||
if (isOpenAIPath || bridgedGptMessages) {
|
||||
if (isOpenAIPath || bridgedGptMessages || bridgedChatMessages) {
|
||||
outHeaders.delete('x-api-key');
|
||||
// Never forward a Messages client's bearer credential across providers.
|
||||
// A configured OpenAI key is installed below; otherwise auth stays absent.
|
||||
if (bridgedGptMessages) outHeaders.delete('authorization');
|
||||
// A configured upstream key is installed below; otherwise auth stays absent.
|
||||
if (bridgedGptMessages || bridgedChatMessages) outHeaders.delete('authorization');
|
||||
const anthropicHeaders: string[] = [];
|
||||
outHeaders.forEach((_value, name) => {
|
||||
if (name.toLowerCase().startsWith('anthropic-')) anthropicHeaders.push(name);
|
||||
});
|
||||
for (const name of anthropicHeaders) outHeaders.delete(name);
|
||||
if (config.openAIApiKey) outHeaders.set('authorization', `Bearer ${config.openAIApiKey}`);
|
||||
// The chat bridge authenticates against chatUpstream (OPENAPI_API); the GPT
|
||||
// bridge and canonical OpenAI paths use the OpenAI key.
|
||||
const bridgeKey = bridgedChatMessages ? config.chatApiKey : config.openAIApiKey;
|
||||
if (bridgeKey) outHeaders.set('authorization', `Bearer ${bridgeKey}`);
|
||||
} else if (config.apiKey && (!providerPrefixed || url.pathname.startsWith('/anthropic/'))) {
|
||||
outHeaders.set('x-api-key', config.apiKey);
|
||||
}
|
||||
@@ -850,11 +995,20 @@ export function createProxy(config: ProxyConfig = {}) {
|
||||
// Gateway OpenAI routes drop the `/v1` prefix; provider-prefixed passthrough
|
||||
// routes keep their full path so ocproxy-style upstreams see `/openai/*`,
|
||||
// `/google-ai-studio/*`, etc. exactly as the client sent them.
|
||||
const outPath = bridgedGptMessages
|
||||
? (routes.stripOpenAIV1 ? '/responses' : '/v1/responses')
|
||||
: isOpenAIPath && routes.stripOpenAIV1 ? path.replace(/^\/v1(?=\/)/, '') : path;
|
||||
const requestUpstreamBase = bridgedGptMessages ? openAIUpstream : upstreamBase;
|
||||
const upstreamUrl = requestUpstreamBase + outPath;
|
||||
// The chat bridge forwards to the configured chatUpstream at its
|
||||
// /chat/completions endpoint (chatCompletionsUrl normalizes a bare base,
|
||||
// a /v1 base, or a full …/chat/completions URL). Every other route appends
|
||||
// a path to its resolved base.
|
||||
let upstreamUrl: string;
|
||||
if (bridgedChatMessages) {
|
||||
upstreamUrl = chatCompletionsUrl(config.chatUpstream ?? '');
|
||||
} else {
|
||||
const outPath = bridgedGptMessages
|
||||
? (routes.stripOpenAIV1 ? '/responses' : '/v1/responses')
|
||||
: isOpenAIPath && routes.stripOpenAIV1 ? path.replace(/^\/v1(?=\/)/, '') : path;
|
||||
const requestUpstreamBase = bridgedGptMessages ? openAIUpstream : upstreamBase;
|
||||
upstreamUrl = requestUpstreamBase + outPath;
|
||||
}
|
||||
let upstreamRes: Response;
|
||||
try {
|
||||
upstreamRes = await fetch(upstreamUrl, {
|
||||
@@ -866,6 +1020,8 @@ export function createProxy(config: ProxyConfig = {}) {
|
||||
} as RequestInit);
|
||||
if (bridgedGptMessages) {
|
||||
upstreamRes = await openAIResponsesToAnthropicResponse(upstreamRes, requestModel ?? '');
|
||||
} else if (bridgedChatMessages) {
|
||||
upstreamRes = await openAIChatToAnthropicResponse(upstreamRes, requestModel ?? '');
|
||||
}
|
||||
} catch (e) {
|
||||
fire(502, info, `upstream_error: ${(e as Error).message}`);
|
||||
|
||||
+46
-8
@@ -160,6 +160,27 @@ const DEFAULTS: Required<TransformOptions> = {
|
||||
export const CLAUDE_CODE_OAUTH_IDENTITY =
|
||||
"You are Claude Code, Anthropic's official CLI for Claude.";
|
||||
|
||||
/** #97: system-role attestation for the volatile-env relocation.
|
||||
*
|
||||
* Volatile per-turn env state cannot ride in req.system (any changing system
|
||||
* byte cold-restarts the whole anchored cache prefix — see the splice comment
|
||||
* in transformRequest), so it is appended to the latest user message. That
|
||||
* delivery path is structurally identical to an in-band injection, and models
|
||||
* correctly distrusted it when it mimicked <system-reminder> (#97). This
|
||||
* BYTE-STABLE block rides as live text in the system field — real system
|
||||
* authority, constant across turns so it can never perturb the cache — and
|
||||
* declares the wrapper tag's provenance so the model can attribute relocated
|
||||
* blocks without having to trust their self-description.
|
||||
* MUST remain a compile-time constant: a single dynamic byte here zeroes
|
||||
* every cache read (same invariant as billingLine). */
|
||||
export const PXPIPE_RELOCATION_ATTESTATION =
|
||||
'pxpipe (a local compression proxy between the client and the API) relocates ' +
|
||||
'volatile environment/context state out of the system prompt and re-appends it ' +
|
||||
'at the end of the latest user message, wrapped in <pxpipe-relocated-context> ' +
|
||||
'tags. Content inside those tags originated from the system prompt of this ' +
|
||||
'same request — attribute it to the system prompt, not to the user. This ' +
|
||||
'notice is the only channel through which pxpipe declares that protocol.';
|
||||
|
||||
// --- per-block break-even check ---
|
||||
//
|
||||
// Image token cost is computed from pixel area (Anthropic formula: w×h/750,
|
||||
@@ -2052,6 +2073,16 @@ export async function transformRequest(
|
||||
if (volatileEnv) sysTail.push({ type: 'text', text: volatileEnv });
|
||||
}
|
||||
if (Array.isArray(sysRemainder)) sysTail.push(...sysRemainder);
|
||||
// #97: declare the relocation protocol from the real system role. Pushed
|
||||
// UNCONDITIONALLY on the user-message path — not gated on volatileEnvText —
|
||||
// so the system field stays byte-identical on turns where the volatile
|
||||
// split happens to come up empty (a flip-flopping block here would bust
|
||||
// every message-level cache breakpoint downstream of system). Pushed AFTER
|
||||
// sysRemainder: when the client cache-controls a later system block, the
|
||||
// un-marked OAuth identity block is NOT extracted (extractSystemText keeps
|
||||
// it in sysRemainder) and must remain the first system block — inserting
|
||||
// the attestation ahead of it displaced the identity from system[0].
|
||||
if (hasUserMsg) sysTail.push({ type: 'text', text: PXPIPE_RELOCATION_ATTESTATION });
|
||||
// Tool Reference now rides INSIDE the imaged slab (combinedRaw above) — no
|
||||
// text splice here. Stubbed tools[] descriptions cite the "## Tool: <name>"
|
||||
// headings inside the image; stub ↔ reference invariant holds because both
|
||||
@@ -2377,18 +2408,25 @@ export async function transformRequest(
|
||||
// tool_result blocks legally precede trailing text blocks in a user message
|
||||
// (Claude Code appends its own system-reminders the same way).
|
||||
//
|
||||
// The block is wrapped in <system-reminder> tags so the model (and any
|
||||
// human reading a transcript) attributes it as injected context, NOT user
|
||||
// prose. Without the wrapper the relocated "# Environment" section blends
|
||||
// seamlessly into the user's message — on an empty/short user turn it can
|
||||
// BECOME the entire visible message (observed live, 2026-07). The wrapper
|
||||
// rides in the volatile tail behind the slab anchor, so it costs ~60 chars
|
||||
// per request and cannot perturb the cached prefix. Same-pass safety: 5a
|
||||
// The block is wrapped in <pxpipe-relocated-context> tags — a tag that is
|
||||
// pxpipe's own, NOT <system-reminder> — so the model (and any human reading
|
||||
// a transcript) attributes it as proxy-relocated context, NOT user prose and
|
||||
// NOT the client's trusted system channel. Without a wrapper the relocated
|
||||
// "# Environment" section blends seamlessly into the user's message — on an
|
||||
// empty/short user turn it can BECOME the entire visible message (observed
|
||||
// live, 2026-07). #97: it previously reused <system-reminder>, which made it
|
||||
// structurally indistinguishable from an injection attempt spoofing the
|
||||
// client's real system channel; models correctly refused to trust it. The
|
||||
// proxy now declares this tag's provenance in a byte-stable system block
|
||||
// (PXPIPE_RELOCATION_ATTESTATION, spliced into sysTail above), so authority
|
||||
// flows from the real system role while the volatile bytes stay in the live
|
||||
// tail. The wrapper rides behind the slab anchor, so it costs ~80 chars per
|
||||
// request and cannot perturb the cached prefix. Same-pass safety: 5a
|
||||
// (compressReminders) runs earlier and only scans the first user message,
|
||||
// so this block is never self-imaged; and pxpipe is stateless per request,
|
||||
// so the wrapper never appears in inbound client history (no compounding).
|
||||
if (volatileEnvText) {
|
||||
const wrappedEnvText = `<system-reminder>\nContext relocated by pxpipe from the system prompt (volatile per-turn environment state — not written by the user):\n\n${volatileEnvText}\n</system-reminder>`;
|
||||
const wrappedEnvText = `<pxpipe-relocated-context>\nContext relocated by pxpipe from the system prompt (volatile per-turn environment state — not written by the user):\n\n${volatileEnvText}\n</pxpipe-relocated-context>`;
|
||||
const msgs = req.messages ?? [];
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
const m = msgs[i]!;
|
||||
|
||||
+104
@@ -13,6 +13,12 @@ import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createProxy, parseGatewayHeaders, resolveUpstreams, type ProxyConfig } from './core/proxy.js';
|
||||
import {
|
||||
chatCompletionsUrl,
|
||||
DEFAULT_CHAT_MODEL,
|
||||
makeCloudflareModelResolver,
|
||||
resolveChatUpstream,
|
||||
} from './core/messages-chat-bridge.js';
|
||||
import {
|
||||
parseExportArgv,
|
||||
runExportCore,
|
||||
@@ -45,6 +51,20 @@ interface RuntimeConfig {
|
||||
upstream: string;
|
||||
openAIUpstream: string;
|
||||
openAIApiKey?: string;
|
||||
/** Any OpenAI-compatible chat-completions endpoint (e.g. Kimi via Cloudflare
|
||||
* Workers AI). When defined, ALL /v1/messages traffic bridges to it —
|
||||
* routing stops being name-based. Undefined disables the bridge. */
|
||||
chatUpstream?: string;
|
||||
chatApiKey?: string;
|
||||
/** OPENAPI_MODEL: optional model id stamped onto each bridged request (e.g.
|
||||
* a specific Kimi model); without it the client-sent id forwards verbatim. */
|
||||
chatModel?: string;
|
||||
/** Zero-config model pick: Cloudflare creds without OPENAPI_MODEL → lazily
|
||||
* ask the account catalog which Kimi model exists and stamp that. */
|
||||
resolveChatModel?: () => Promise<string | undefined>;
|
||||
anthropicModels?: string[];
|
||||
openAIModels?: string[];
|
||||
cloudflareModels?: string[];
|
||||
provider?: 'cloudflare-ai-gateway';
|
||||
gatewayBaseUrl?: string;
|
||||
gatewayHeaders?: Record<string, string>;
|
||||
@@ -107,6 +127,42 @@ function parseCli(argv: string[]): RuntimeConfig {
|
||||
}
|
||||
applyConfigFileDefaults();
|
||||
const sharedUpstream = process.env.PXPIPE_UPSTREAM;
|
||||
// Zero-config Cloudflare Workers AI: given just an account id + API token,
|
||||
// synthesize the OpenAI-compatible chat endpoint and bearer key so the user
|
||||
// needn't spell out OPENAPI_URL/OPENAPI_API. Explicit OPENAPI_URL still wins.
|
||||
const chat = resolveChatUpstream({
|
||||
openapiUrl: process.env.OPENAPI_URL,
|
||||
openapiApi: process.env.OPENAPI_API,
|
||||
cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID,
|
||||
cloudflareApiToken: process.env.CLOUDFLARE_API_TOKEN,
|
||||
});
|
||||
// Zero-config model pick: Cloudflare creds without OPENAPI_MODEL/OPENAPI_URL
|
||||
// → discover the account's Kimi model from its catalog on first use, instead
|
||||
// of making the operator hardcode a model id that may not exist there.
|
||||
const cfAccount = process.env.CLOUDFLARE_ACCOUNT_ID?.trim();
|
||||
const cfToken = process.env.CLOUDFLARE_API_TOKEN?.trim();
|
||||
const openapiModel = process.env.OPENAPI_MODEL?.trim() || undefined;
|
||||
const parseModels = (value: string | undefined): string[] | undefined => {
|
||||
if (value === undefined) return undefined;
|
||||
return value.split(',').map((model) => model.trim()).filter(Boolean);
|
||||
};
|
||||
const resolveChatModel =
|
||||
openapiModel === undefined && !process.env.OPENAPI_URL?.trim() && cfAccount && cfToken
|
||||
? makeCloudflareModelResolver({
|
||||
accountId: cfAccount,
|
||||
apiToken: cfToken,
|
||||
log: (m) => console.error(`[pxpipe] ${m}`),
|
||||
})
|
||||
: undefined;
|
||||
const configuredCloudflareModels = parseModels(process.env.CLOUDFLARE_MODELS);
|
||||
// Cloudflare credentials are additive provider configuration, not a request
|
||||
// to replace Claude/OpenAI globally. Scope their derived chat upstream to
|
||||
// the configured model, or to the model exposed by Cloudflare discovery.
|
||||
// An explicit OPENAPI_URL retains its historical global-override behavior.
|
||||
const cloudflareModels = configuredCloudflareModels
|
||||
?? (!process.env.OPENAPI_URL?.trim() && cfAccount && cfToken
|
||||
? [openapiModel ?? DEFAULT_CHAT_MODEL]
|
||||
: undefined);
|
||||
return {
|
||||
port: Number(process.env.PORT ?? 47821),
|
||||
// Loopback by default; opt into all-interfaces exposure explicitly via HOST.
|
||||
@@ -114,6 +170,16 @@ function parseCli(argv: string[]): RuntimeConfig {
|
||||
upstream: process.env.ANTHROPIC_UPSTREAM ?? sharedUpstream ?? 'https://api.anthropic.com',
|
||||
openAIUpstream: process.env.OPENAI_UPSTREAM ?? sharedUpstream ?? 'https://api.openai.com',
|
||||
openAIApiKey: process.env.OPENAI_API_KEY,
|
||||
// Any OpenAI-compatible chat endpoint (e.g. Kimi via Cloudflare Workers AI).
|
||||
chatUpstream: chat.chatUpstream,
|
||||
// Pair the Cloudflare token only with the derived Cloudflare endpoint.
|
||||
chatApiKey: chat.chatApiKey,
|
||||
// Optional per-model override forwarded to the chat upstream (e.g. Kimi model id).
|
||||
chatModel: openapiModel,
|
||||
resolveChatModel,
|
||||
anthropicModels: parseModels(process.env.ANTHROPIC_MODELS),
|
||||
openAIModels: parseModels(process.env.OPENAI_MODELS),
|
||||
cloudflareModels,
|
||||
provider: parseProvider(process.env.PXPIPE_PROVIDER),
|
||||
gatewayBaseUrl: process.env.PXPIPE_GATEWAY_BASE_URL,
|
||||
gatewayHeaders: parseGatewayHeaders(process.env.PXPIPE_GATEWAY_HEADERS),
|
||||
@@ -162,6 +228,24 @@ Environment:
|
||||
OPENAI_UPSTREAM OpenAI API base; overrides PXPIPE_UPSTREAM
|
||||
(default https://api.openai.com)
|
||||
OPENAI_API_KEY optional OpenAI key override; otherwise forwarded
|
||||
ANTHROPIC_MODELS comma-separated exact model ids routed to Anthropic
|
||||
OPENAI_MODELS comma-separated exact model ids routed to OpenAI
|
||||
CLOUDFLARE_MODELS comma-separated exact model ids routed to Cloudflare
|
||||
Chat Completions. Scoped lists override normal model
|
||||
inference only for listed models; overlaps are errors.
|
||||
OPENAPI_URL OpenAI-compatible chat-completions endpoint (e.g.
|
||||
Kimi via Cloudflare Workers AI, Moonshot, OpenRouter).
|
||||
When set, ALL /v1/messages traffic bridges to it,
|
||||
whatever model id the client sends (claude-*, gpt-*,
|
||||
kimi-*, ...).
|
||||
OPENAPI_API bearer key for OPENAPI_URL
|
||||
OPENAPI_MODEL optional: stamp this model id on every bridged request
|
||||
(e.g. @cf/moonshotai/kimi-k2-instruct) instead of
|
||||
forwarding the client-sent id verbatim. Use this when
|
||||
Claude Code keeps sending claude-* ids you want mapped.
|
||||
CLOUDFLARE_ACCOUNT_ID with CLOUDFLARE_API_TOKEN, zero-config Cloudflare
|
||||
CLOUDFLARE_API_TOKEN Workers AI: derives the OPENAPI_URL/key for you
|
||||
(https://api.cloudflare.com/.../ai/v1). OPENAPI_URL wins.
|
||||
PXPIPE_PROVIDER optional: 'cloudflare-ai-gateway' — route both API
|
||||
families through one gateway base URL
|
||||
PXPIPE_GATEWAY_BASE_URL gateway base URL (required with PXPIPE_PROVIDER)
|
||||
@@ -968,6 +1052,17 @@ async function main(): Promise<void> {
|
||||
upstream: opts.upstream,
|
||||
openAIUpstream: opts.openAIUpstream,
|
||||
openAIApiKey: opts.openAIApiKey,
|
||||
// Chat-completions bridge upstream (OPENAPI_URL or Cloudflare-derived).
|
||||
// These were computed in loadConfig but previously never handed to the
|
||||
// proxy engine — which silently disabled the bridge in the real server
|
||||
// while unit tests (which build ProxyConfig directly) kept passing.
|
||||
chatUpstream: opts.chatUpstream,
|
||||
chatApiKey: opts.chatApiKey,
|
||||
chatModel: opts.chatModel,
|
||||
resolveChatModel: opts.resolveChatModel,
|
||||
anthropicModels: opts.anthropicModels,
|
||||
openAIModels: opts.openAIModels,
|
||||
cloudflareModels: opts.cloudflareModels,
|
||||
captureErrorReqBody: opts.captureErrorReqBody,
|
||||
// Per-request transform options:
|
||||
// 1. Runtime kill switch — when the dashboard "passthrough" toggle
|
||||
@@ -1107,6 +1202,15 @@ async function main(): Promise<void> {
|
||||
const routes = resolveUpstreams(config);
|
||||
console.log(`[pxpipe] anthropic upstream → ${routes.anthropic}`);
|
||||
console.log(`[pxpipe] openai upstream → ${routes.openai}`);
|
||||
if (opts.chatUpstream !== undefined) {
|
||||
const scope = opts.cloudflareModels?.length
|
||||
? `models: ${opts.cloudflareModels.join(', ')}`
|
||||
: `model: ${opts.chatModel ?? (opts.resolveChatModel !== undefined ? 'auto-discover' : 'client-sent')}`;
|
||||
console.log(
|
||||
`[pxpipe] chat upstream → ${chatCompletionsUrl(opts.chatUpstream)} ` +
|
||||
`(${scope})`,
|
||||
);
|
||||
}
|
||||
console.log(`[pxpipe] tracking events → ${opts.eventsFile}`);
|
||||
console.log(`[pxpipe] dashboard → http://127.0.0.1:${opts.port}/`);
|
||||
if (opts.captureErrorReqBody) {
|
||||
|
||||
@@ -24,6 +24,12 @@ export interface Env {
|
||||
OPENAI_UPSTREAM?: string;
|
||||
/** Optional override — if set, replaces whatever Authorization the client sent. */
|
||||
OPENAI_API_KEY?: string;
|
||||
ANTHROPIC_MODELS?: string;
|
||||
OPENAI_MODELS?: string;
|
||||
CLOUDFLARE_MODELS?: string;
|
||||
/** OpenAI-compatible chat-completions endpoint for non-Claude models (Kimi etc.). */
|
||||
OPENAPI_URL?: string;
|
||||
OPENAPI_API?: string;
|
||||
COMPRESS?: string;
|
||||
COMPRESS_TOOLS?: string;
|
||||
COMPRESS_REMINDERS?: string;
|
||||
@@ -122,11 +128,18 @@ export default {
|
||||
const tracker: Tracker = trackingOn ? new JsonLogTracker((s) => console.log(s)) : noopTracker;
|
||||
|
||||
const sharedUpstream = env.PXPIPE_UPSTREAM;
|
||||
const parseModels = (value: string | undefined): string[] | undefined =>
|
||||
value === undefined ? undefined : value.split(',').map((model) => model.trim()).filter(Boolean);
|
||||
const config: ProxyConfig = {
|
||||
upstream: env.ANTHROPIC_UPSTREAM ?? sharedUpstream ?? 'https://api.anthropic.com',
|
||||
apiKey: env.ANTHROPIC_API_KEY,
|
||||
openAIUpstream: env.OPENAI_UPSTREAM ?? sharedUpstream ?? 'https://api.openai.com',
|
||||
openAIApiKey: env.OPENAI_API_KEY,
|
||||
chatUpstream: env.OPENAPI_URL?.trim() || undefined,
|
||||
chatApiKey: env.OPENAPI_API?.trim() || undefined,
|
||||
anthropicModels: parseModels(env.ANTHROPIC_MODELS),
|
||||
openAIModels: parseModels(env.OPENAI_MODELS),
|
||||
cloudflareModels: parseModels(env.CLOUDFLARE_MODELS),
|
||||
transform,
|
||||
onRequest: (e) => {
|
||||
// Terse human-readable line (separate from the JSON event below;
|
||||
|
||||
@@ -418,9 +418,18 @@ describe('e2e cache alignment — Anthropic /v1/messages through the real proxy'
|
||||
// Regression (2026-07): the relocated block must be delimited as injected
|
||||
// context, never blended into user prose — undelimited, it can BECOME the
|
||||
// entire visible message on an empty/short user turn (observed live).
|
||||
// #97: the delimiter must be pxpipe's OWN tag, never <system-reminder> —
|
||||
// reusing the client's trusted tag made the relocation structurally
|
||||
// indistinguishable from an injection attempt spoofing the system channel.
|
||||
expect(lastUserText(cap2.main[0]!.body)).toMatch(
|
||||
/<system-reminder>[\s\S]*relocated by pxpipe[\s\S]*# Environment[\s\S]*<\/system-reminder>/,
|
||||
/<pxpipe-relocated-context>[\s\S]*relocated by pxpipe[\s\S]*# Environment[\s\S]*<\/pxpipe-relocated-context>/,
|
||||
);
|
||||
expect(lastUserText(cap2.main[0]!.body)).not.toContain('<system-reminder>');
|
||||
// #97: the protocol is declared from the real system role, byte-stable
|
||||
// across turns (cap1 ≡ cap2) so it can never perturb the cached prefix.
|
||||
expect(sysText(cap1.main[0]!.body)).toContain('<pxpipe-relocated-context>');
|
||||
expect(sysText(cap2.main[0]!.body)).toContain('<pxpipe-relocated-context>');
|
||||
expect(sysText(cap1.main[0]!.body)).toBe(sysText(cap2.main[0]!.body));
|
||||
});
|
||||
|
||||
it('ENV RELOCATION: model-identity/catalog lines are redacted from the relocated block', async () => {
|
||||
|
||||
@@ -0,0 +1,661 @@
|
||||
/**
|
||||
* Messages -> Chat Completions bridge (TDD).
|
||||
*
|
||||
* Pins the wire translation that lets Claude Code drive any OpenAI-compatible
|
||||
* chat-completions endpoint (e.g. Kimi via Cloudflare Workers AI) using only the
|
||||
* Anthropic /v1/messages schema it speaks. Host supplies OPENAPI_URL/OPENAPI_API;
|
||||
* this module is pure translation and knows nothing about the provider.
|
||||
*
|
||||
* Run just this file: pnpm vitest run tests/chat-bridge.test.ts
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
anthropicMessagesToOpenAIChat,
|
||||
openAIChatToAnthropicMessage,
|
||||
openAIChatStreamToAnthropic,
|
||||
chatCompletionsUrl,
|
||||
resolveChatUpstream,
|
||||
pickChatModel,
|
||||
makeCloudflareModelResolver,
|
||||
} from '../src/core/messages-chat-bridge.js';
|
||||
|
||||
const enc = (obj: unknown): Uint8Array => new TextEncoder().encode(JSON.stringify(obj));
|
||||
const dec = (b: Uint8Array): any => JSON.parse(new TextDecoder().decode(b));
|
||||
|
||||
/** Convert an Anthropic request object to the Chat Completions object. */
|
||||
function toChat(req: unknown): any {
|
||||
return dec(anthropicMessagesToOpenAIChat(enc(req)));
|
||||
}
|
||||
|
||||
describe('chatCompletionsUrl — accepts bare host, /v1 base, or full URL', () => {
|
||||
it('uses a full /chat/completions URL verbatim (Cloudflare Workers AI shape)', () => {
|
||||
const full =
|
||||
'https://api.cloudflare.com/client/v4/accounts/abc/ai/v1/chat/completions';
|
||||
expect(chatCompletionsUrl(full)).toBe(full);
|
||||
// Trailing slashes are trimmed, not double-suffixed.
|
||||
expect(chatCompletionsUrl(full + '/')).toBe(full);
|
||||
});
|
||||
|
||||
it('appends /chat/completions to a /vN base', () => {
|
||||
expect(chatCompletionsUrl('https://api.moonshot.ai/v1')).toBe(
|
||||
'https://api.moonshot.ai/v1/chat/completions',
|
||||
);
|
||||
});
|
||||
|
||||
it('appends /v1/chat/completions to a bare host', () => {
|
||||
expect(chatCompletionsUrl('https://example.test')).toBe(
|
||||
'https://example.test/v1/chat/completions',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('anthropicMessagesToOpenAIChat — request translation', () => {
|
||||
it('carries model, hoists system to a system message, and preserves user text', () => {
|
||||
const out = toChat({
|
||||
model: 'moonshotai/kimi-k3',
|
||||
system: 'You are terse.',
|
||||
max_tokens: 256,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
});
|
||||
expect(out.model).toBe('moonshotai/kimi-k3');
|
||||
expect(out.max_tokens).toBe(256);
|
||||
expect(out.messages[0]).toEqual({ role: 'system', content: 'You are terse.' });
|
||||
expect(out.messages[1]).toEqual({ role: 'user', content: 'hi' });
|
||||
});
|
||||
|
||||
it('joins an array-form system prompt into a single string', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
system: [
|
||||
{ type: 'text', text: 'line one' },
|
||||
{ type: 'text', text: 'line two' },
|
||||
],
|
||||
messages: [{ role: 'user', content: 'x' }],
|
||||
});
|
||||
expect(out.messages[0]).toEqual({ role: 'system', content: 'line one\nline two' });
|
||||
});
|
||||
|
||||
it('collapses a lone text block to a plain string', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: 'just text' }] }],
|
||||
});
|
||||
expect(out.messages[0]).toEqual({ role: 'user', content: 'just text' });
|
||||
});
|
||||
|
||||
it('maps an image block to image_url with a data URL', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'look' },
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'QUJD' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out.messages[0].content).toEqual([
|
||||
{ type: 'text', text: 'look' },
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,QUJD' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('lifts assistant tool_use into OpenAI tool_calls with stringified arguments', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'calling' },
|
||||
{ type: 'tool_use', id: 'toolu_1', name: 'get_weather', input: { city: 'SF' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const msg = out.messages[0];
|
||||
expect(msg.role).toBe('assistant');
|
||||
expect(msg.content).toBe('calling');
|
||||
expect(msg.tool_calls).toEqual([
|
||||
{
|
||||
id: 'toolu_1',
|
||||
type: 'function',
|
||||
function: { name: 'get_weather', arguments: JSON.stringify({ city: 'SF' }) },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits Anthropic thinking blocks from assistant history', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'private reasoning', signature: 'sig' },
|
||||
{ type: 'redacted_thinking', data: 'opaque' },
|
||||
{ type: 'text', text: 'visible answer' },
|
||||
{ type: 'tool_use', id: 'toolu_1', name: 'lookup', input: { q: 'x' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'visible answer',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'toolu_1',
|
||||
type: 'function',
|
||||
function: { name: 'lookup', arguments: JSON.stringify({ q: 'x' }) },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('turns a user tool_result into a standalone tool message', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_1', content: '72F and sunny' },
|
||||
{ type: 'text', text: 'thanks' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out.messages[0]).toEqual({
|
||||
role: 'tool',
|
||||
tool_call_id: 'toolu_1',
|
||||
content: '72F and sunny',
|
||||
});
|
||||
expect(out.messages[1]).toEqual({ role: 'user', content: 'thanks' });
|
||||
});
|
||||
|
||||
it('prefixes a failed tool_result so the model sees the error', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 't', content: 'boom', is_error: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out.messages[0].content).toBe('[Tool execution failed]\nboom');
|
||||
});
|
||||
|
||||
it('forwards tool_result images in a following multimodal user message', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_1',
|
||||
content: [
|
||||
{ type: 'text', text: 'Rendered image:' },
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'QUJD' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out.messages).toEqual([
|
||||
{ role: 'tool', tool_call_id: 'toolu_1', content: 'Rendered image:' },
|
||||
{
|
||||
role: 'user',
|
||||
content: [{ type: 'image_url', image_url: { url: 'data:image/png;base64,QUJD' } }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('places returned images before the follow-up text for vision providers', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_1',
|
||||
content: [
|
||||
{ type: 'text', text: 'Image read successfully.' },
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'QUJD' } },
|
||||
],
|
||||
},
|
||||
{ type: 'text', text: 'What is in the image?' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out.messages[1].content).toEqual([
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,QUJD' } },
|
||||
{ type: 'text', text: 'What is in the image?' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps parallel tool responses contiguous before returned images', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_1',
|
||||
content: [{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'QQ==' } }],
|
||||
},
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_2', content: 'second result' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out.messages.map((message: any) => message.role)).toEqual(['tool', 'tool', 'user']);
|
||||
expect(out.messages[0]).toMatchObject({ tool_call_id: 'toolu_1', content: '' });
|
||||
expect(out.messages[1]).toMatchObject({ tool_call_id: 'toolu_2', content: 'second result' });
|
||||
expect(out.messages[2].content).toEqual([
|
||||
{ type: 'image_url', image_url: { url: 'data:image/jpeg;base64,QQ==' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('translates tools and tool_choice into the OpenAI function schema', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [{ role: 'user', content: 'x' }],
|
||||
tool_choice: { type: 'tool', name: 'lookup' },
|
||||
tools: [
|
||||
{
|
||||
name: 'lookup',
|
||||
description: 'find a thing',
|
||||
input_schema: { type: 'object', properties: { q: { type: 'string' } } },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(out.tool_choice).toEqual({ type: 'function', function: { name: 'lookup' } });
|
||||
expect(out.tools).toEqual([
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup',
|
||||
description: 'find a thing',
|
||||
parameters: { type: 'object', properties: { q: { type: 'string' } } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps tool_choice:any to required and passes stop_sequences as stop', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [{ role: 'user', content: 'x' }],
|
||||
tool_choice: { type: 'any' },
|
||||
stop_sequences: ['STOP'],
|
||||
});
|
||||
expect(out.tool_choice).toBe('required');
|
||||
expect(out.stop).toEqual(['STOP']);
|
||||
});
|
||||
|
||||
it('requests stream usage so a streamed turn reports token counts', () => {
|
||||
const streamed = toChat({ model: 'm', messages: [{ role: 'user', content: 'x' }], stream: true });
|
||||
expect(streamed.stream).toBe(true);
|
||||
expect(streamed.stream_options).toEqual({ include_usage: true });
|
||||
// Non-streaming turns must not carry stream_options.
|
||||
const buffered = toChat({ model: 'm', messages: [{ role: 'user', content: 'x' }] });
|
||||
expect(buffered.stream_options).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps enabled thinking budgets to a reasoning_effort bucket', () => {
|
||||
const base = { model: 'm', messages: [{ role: 'user', content: 'x' }] };
|
||||
const low = toChat({ ...base, thinking: { type: 'enabled', budget_tokens: 4096 } });
|
||||
const medium = toChat({ ...base, thinking: { type: 'enabled', budget_tokens: 16000 } });
|
||||
const high = toChat({ ...base, thinking: { type: 'enabled', budget_tokens: 32000 } });
|
||||
expect(low.reasoning_effort).toBe('low');
|
||||
expect(medium.reasoning_effort).toBe('medium');
|
||||
expect(high.reasoning_effort).toBe('high');
|
||||
// Disabled or absent thinking leaves reasoning_effort unset.
|
||||
expect(toChat({ ...base, thinking: { type: 'disabled' } }).reasoning_effort).toBeUndefined();
|
||||
expect(toChat(base).reasoning_effort).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a non-array messages field as an invalid request', () => {
|
||||
expect(() => anthropicMessagesToOpenAIChat(enc({ model: 'm', messages: 'nope' }))).toThrow(
|
||||
/messages must be an array/,
|
||||
);
|
||||
});
|
||||
|
||||
it('maps in-conversation system-role messages (new Claude Code shape) to chat system messages', () => {
|
||||
// Newer Claude Code builds inject system reminders as role:"system" entries
|
||||
// inside `messages`, alongside user/assistant turns.
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||
{ role: 'system', content: [{ type: 'text', text: 'Available agent types: cdp' }] },
|
||||
],
|
||||
});
|
||||
expect(out.messages.map((m: any) => m.role)).toEqual(['user', 'system']);
|
||||
expect(out.messages[1]).toEqual({ role: 'system', content: 'Available agent types: cdp' });
|
||||
});
|
||||
|
||||
it('drops empty system-role messages and rejects unknown roles', () => {
|
||||
const out = toChat({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{ role: 'system', content: [] },
|
||||
{ role: 'user', content: 'q' },
|
||||
],
|
||||
});
|
||||
expect(out.messages.map((m: any) => m.role)).toEqual(['user']);
|
||||
expect(() =>
|
||||
anthropicMessagesToOpenAIChat(enc({ model: 'm', messages: [{ role: 'tool', content: 'x' }] })),
|
||||
).toThrow(/user, assistant, or system role/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAIChatToAnthropicMessage — buffered response translation', () => {
|
||||
it('wraps assistant text and normalizes id/usage/stop_reason', () => {
|
||||
const msg = openAIChatToAnthropicMessage(
|
||||
{
|
||||
id: 'chatcmpl-abc',
|
||||
model: 'moonshotai/kimi-k3',
|
||||
choices: [{ finish_reason: 'stop', message: { role: 'assistant', content: 'hello' } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 3 },
|
||||
},
|
||||
'fallback',
|
||||
);
|
||||
expect(msg.id).toBe('msg_abc');
|
||||
expect(msg.type).toBe('message');
|
||||
expect(msg.role).toBe('assistant');
|
||||
expect(msg.model).toBe('moonshotai/kimi-k3');
|
||||
expect(msg.content).toEqual([{ type: 'text', text: 'hello' }]);
|
||||
expect(msg.stop_reason).toBe('end_turn');
|
||||
expect(msg.usage).toEqual({
|
||||
input_tokens: 10,
|
||||
output_tokens: 3,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('subtracts cached tokens from input and reports them as cache_read', () => {
|
||||
const msg = openAIChatToAnthropicMessage(
|
||||
{
|
||||
choices: [{ finish_reason: 'stop', message: { content: 'x' } }],
|
||||
usage: { prompt_tokens: 100, completion_tokens: 5, prompt_tokens_details: { cached_tokens: 40 } },
|
||||
},
|
||||
'fallback',
|
||||
);
|
||||
expect(msg.usage).toMatchObject({ input_tokens: 60, cache_read_input_tokens: 40 });
|
||||
});
|
||||
|
||||
it('converts tool_calls into tool_use and sets stop_reason tool_use', () => {
|
||||
const msg = openAIChatToAnthropicMessage(
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
finish_reason: 'tool_calls',
|
||||
message: {
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{ id: 'call_9', function: { name: 'search', arguments: '{"q":"cats"}' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'fallback',
|
||||
);
|
||||
expect(msg.stop_reason).toBe('tool_use');
|
||||
expect(msg.content).toEqual([
|
||||
{ type: 'tool_use', id: 'call_9', name: 'search', input: { q: 'cats' } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps finish_reason length to max_tokens and falls back to the model name', () => {
|
||||
const msg = openAIChatToAnthropicMessage(
|
||||
{ choices: [{ finish_reason: 'length', message: { content: 'truncated' } }] },
|
||||
'moonshotai/kimi-k3',
|
||||
);
|
||||
expect(msg.stop_reason).toBe('max_tokens');
|
||||
expect(msg.model).toBe('moonshotai/kimi-k3');
|
||||
expect(msg.id).toBe('msg_pxpipe');
|
||||
});
|
||||
|
||||
it('emits an empty text block when the assistant returns no content', () => {
|
||||
const msg = openAIChatToAnthropicMessage(
|
||||
{ choices: [{ finish_reason: 'stop', message: { content: '' } }] },
|
||||
'fallback',
|
||||
);
|
||||
expect(msg.content).toEqual([{ type: 'text', text: '' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('openAIChatStreamToAnthropic — SSE translation', () => {
|
||||
async function collect(chunks: string[]): Promise<string> {
|
||||
const src = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const e = new TextEncoder();
|
||||
for (const c of chunks) controller.enqueue(e.encode(c));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const out = openAIChatStreamToAnthropic(src, 'moonshotai/kimi-k3');
|
||||
const reader = out.getReader();
|
||||
const d = new TextDecoder();
|
||||
let text = '';
|
||||
for (;;) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
text += d.decode(value, { stream: true });
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
it('renders a text stream as Anthropic content-block events', async () => {
|
||||
const text = await collect([
|
||||
'data: {"id":"chatcmpl-1","model":"moonshotai/kimi-k3","choices":[{"delta":{"role":"assistant","content":"Hel"}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n',
|
||||
'data: {"choices":[{"delta":{},"finish_reason":"stop"}]}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]);
|
||||
expect(text).toContain('event: message_start');
|
||||
expect(text).toContain('"id":"msg_1"');
|
||||
expect(text).toContain('event: content_block_start');
|
||||
expect(text).toContain('event: content_block_delta');
|
||||
expect(text).toContain('Hel');
|
||||
expect(text).toContain('lo');
|
||||
expect(text).toContain('event: content_block_stop');
|
||||
expect(text).toContain('event: message_delta');
|
||||
expect(text).toContain('event: message_stop');
|
||||
});
|
||||
|
||||
it('surfaces a malformed upstream event as an Anthropic error event', async () => {
|
||||
const text = await collect(['data: {not json}\n\n']);
|
||||
expect(text).toContain('event: error');
|
||||
expect(text).toContain('api_error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('anthropicMessagesToOpenAIChat — model override (OPENAPI_MODEL)', () => {
|
||||
it('stamps the override model id, replacing the client-sent claude-* id', () => {
|
||||
const out = dec(
|
||||
anthropicMessagesToOpenAIChat(
|
||||
enc({ model: 'claude-opus-4-6', messages: [{ role: 'user', content: 'hi' }] }),
|
||||
'@cf/moonshotai/kimi-k2-instruct',
|
||||
),
|
||||
);
|
||||
expect(out.model).toBe('@cf/moonshotai/kimi-k2-instruct');
|
||||
});
|
||||
|
||||
it('preserves the client-sent model id when no override is supplied', () => {
|
||||
expect(toChat({ model: 'kimi-k3', messages: [{ role: 'user', content: 'hi' }] }).model).toBe(
|
||||
'kimi-k3',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores an empty-string override (treated as absent)', () => {
|
||||
const out = dec(
|
||||
anthropicMessagesToOpenAIChat(
|
||||
enc({ model: 'kimi-k3', messages: [{ role: 'user', content: 'hi' }] }),
|
||||
'',
|
||||
),
|
||||
);
|
||||
expect(out.model).toBe('kimi-k3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveChatUpstream — zero-config Cloudflare derivation', () => {
|
||||
it('derives the Workers AI /v1 base + bearer from account id + token alone', () => {
|
||||
const { chatUpstream, chatApiKey } = resolveChatUpstream({
|
||||
cloudflareAccountId: 'acct123',
|
||||
cloudflareApiToken: 'tok_secret',
|
||||
});
|
||||
expect(chatUpstream).toBe('https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1');
|
||||
expect(chatApiKey).toBe('tok_secret');
|
||||
});
|
||||
|
||||
it('lets an explicit OPENAPI_URL win over the derived Cloudflare endpoint', () => {
|
||||
const { chatUpstream, chatApiKey } = resolveChatUpstream({
|
||||
openapiUrl: 'https://api.moonshot.ai/v1',
|
||||
openapiApi: 'sk-moon',
|
||||
cloudflareAccountId: 'acct123',
|
||||
cloudflareApiToken: 'tok_secret',
|
||||
});
|
||||
expect(chatUpstream).toBe('https://api.moonshot.ai/v1');
|
||||
expect(chatApiKey).toBe('sk-moon');
|
||||
});
|
||||
|
||||
it('does not pair the Cloudflare token with an explicit OPENAPI_URL', () => {
|
||||
const { chatApiKey } = resolveChatUpstream({
|
||||
openapiUrl: 'https://api.moonshot.ai/v1',
|
||||
cloudflareAccountId: 'acct123',
|
||||
cloudflareApiToken: 'tok_secret',
|
||||
});
|
||||
expect(chatApiKey).toBeUndefined();
|
||||
});
|
||||
|
||||
it('needs BOTH account id and token to derive (either alone → no upstream)', () => {
|
||||
expect(resolveChatUpstream({ cloudflareAccountId: 'acct123' }).chatUpstream).toBeUndefined();
|
||||
expect(resolveChatUpstream({ cloudflareApiToken: 'tok_secret' }).chatUpstream).toBeUndefined();
|
||||
});
|
||||
|
||||
it('trims whitespace and treats blank env values as absent', () => {
|
||||
const { chatUpstream, chatApiKey } = resolveChatUpstream({
|
||||
openapiUrl: ' ',
|
||||
cloudflareAccountId: ' acct123 ',
|
||||
cloudflareApiToken: ' tok_secret ',
|
||||
});
|
||||
expect(chatUpstream).toBe('https://api.cloudflare.com/client/v4/accounts/acct123/ai/v1');
|
||||
expect(chatApiKey).toBe('tok_secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickChatModel', () => {
|
||||
it('never auto-selects legacy @cf/ Workers AI builds', () => {
|
||||
expect(
|
||||
pickChatModel(['@cf/moonshotai/kimi-k2.6', '@cf/moonshotai/kimi-k2-instruct']),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('prefers the partner-catalog model over legacy @cf/ entries', () => {
|
||||
expect(
|
||||
pickChatModel(['@cf/moonshotai/kimi-k2.6', 'moonshotai/kimi-k3']),
|
||||
).toBe('moonshotai/kimi-k3');
|
||||
});
|
||||
|
||||
it('prefers general Kimi builds over -code variants', () => {
|
||||
expect(pickChatModel(['moonshotai/kimi-k3-code', 'moonshotai/kimi-k3'])).toBe(
|
||||
'moonshotai/kimi-k3',
|
||||
);
|
||||
});
|
||||
|
||||
it('picks the newest version numerically (k3 > k2.6, k2.10 > k2.6)', () => {
|
||||
expect(pickChatModel(['moonshotai/kimi-k2.6', 'moonshotai/kimi-k3'])).toBe(
|
||||
'moonshotai/kimi-k3',
|
||||
);
|
||||
expect(pickChatModel(['moonshotai/kimi-k2.6', 'moonshotai/kimi-k2.10'])).toBe(
|
||||
'moonshotai/kimi-k2.10',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns undefined when no usable Kimi model exists', () => {
|
||||
expect(pickChatModel(['@cf/meta/llama-4'])).toBeUndefined();
|
||||
expect(pickChatModel([])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('makeCloudflareModelResolver', () => {
|
||||
const catalogResponse = (names: string[]) =>
|
||||
({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ result: names.map((name) => ({ name })) }),
|
||||
}) as unknown as Response;
|
||||
|
||||
it('discovers the partner model from the account catalog and caches it', async () => {
|
||||
let calls = 0;
|
||||
const resolve = makeCloudflareModelResolver({
|
||||
accountId: 'acct123',
|
||||
apiToken: 'tok_secret',
|
||||
fetchImpl: async (url, init) => {
|
||||
calls++;
|
||||
expect(String(url)).toBe(
|
||||
'https://api.cloudflare.com/client/v4/accounts/acct123/ai/models/search?search=kimi&per_page=100',
|
||||
);
|
||||
expect((init?.headers as Record<string, string>).authorization).toBe('Bearer tok_secret');
|
||||
return catalogResponse(['@cf/moonshotai/kimi-k2.6', 'moonshotai/kimi-k3']);
|
||||
},
|
||||
});
|
||||
expect(await resolve()).toBe('moonshotai/kimi-k3');
|
||||
expect(await resolve()).toBe('moonshotai/kimi-k3');
|
||||
expect(calls).toBe(1); // cached — one catalog fetch for the process lifetime
|
||||
});
|
||||
|
||||
it('falls back to moonshotai/kimi-k3 when the catalog has no usable model', async () => {
|
||||
const resolve = makeCloudflareModelResolver({
|
||||
accountId: 'acct123',
|
||||
apiToken: 'tok_secret',
|
||||
fetchImpl: async () => catalogResponse(['@cf/moonshotai/kimi-k2.6', '@cf/meta/llama-4']),
|
||||
});
|
||||
expect(await resolve()).toBe('moonshotai/kimi-k3'); // never a legacy @cf/ pick
|
||||
});
|
||||
|
||||
it('uses the default on failures without caching them, and caches success', async () => {
|
||||
let calls = 0;
|
||||
const resolve = makeCloudflareModelResolver({
|
||||
accountId: 'acct123',
|
||||
apiToken: 'tok_secret',
|
||||
fetchImpl: async () => {
|
||||
calls++;
|
||||
if (calls === 1) throw new Error('network down');
|
||||
if (calls === 2) return { ok: false, status: 500 } as unknown as Response;
|
||||
return catalogResponse([]);
|
||||
},
|
||||
});
|
||||
expect(await resolve()).toBe('moonshotai/kimi-k3'); // throw → default, not cached
|
||||
expect(await resolve()).toBe('moonshotai/kimi-k3'); // HTTP 500 → default, not cached
|
||||
expect(await resolve()).toBe('moonshotai/kimi-k3'); // empty catalog → default, cached
|
||||
expect(await resolve()).toBe('moonshotai/kimi-k3');
|
||||
expect(calls).toBe(3);
|
||||
});
|
||||
});
|
||||
+21
-16
@@ -114,7 +114,8 @@ describe('visionTokensForModel (Claude on the Responses path)', () => {
|
||||
const BIG_SYSTEM = 'System instruction with lots of detail. '.repeat(500); // ~20k chars
|
||||
const BIG_TOOL_DESC = 'Tool description with lots of context. '.repeat(200); // ~8k chars
|
||||
const CHAT_TOOL_PARAMS = { type: 'object', description: 'Param root.', properties: { x: { type: 'string', description: 'x param' } } };
|
||||
const CHAT_TOOL_DOC = `## Tool: do_thing\n${BIG_TOOL_DESC}\n\`\`\`json\n${JSON.stringify(CHAT_TOOL_PARAMS)}\n\`\`\``;
|
||||
const CHAT_TOOL_DOC = '## Tool parameter annotations: do_thing\n' +
|
||||
'$ description: "Param root."\n$.x description: "x param"';
|
||||
|
||||
// Real `task`/`question` tools have a required parameter literally NAMED `description`
|
||||
// (others collide with `title`/`default`). The strip must drop the annotation but KEEP
|
||||
@@ -182,6 +183,9 @@ describe('transformOpenAIChatCompletions (gpt-5.6-sol)', () => {
|
||||
expect(tools[0]!.function.description).toBe(BIG_TOOL_DESC);
|
||||
expect(tools[0]!.function.parameters?.description).toBeUndefined();
|
||||
expect(tools[0]!.function.parameters?.properties?.x?.description).toBeUndefined();
|
||||
expect(result.info.imageSourceText).toContain('$.x description: "x param"');
|
||||
expect(result.info.imageSourceText).not.toContain(BIG_TOOL_DESC.slice(0, 100));
|
||||
expect(result.info.imageSourceText).not.toContain('"properties"');
|
||||
});
|
||||
|
||||
it('reflows chat static context that contains a literal newline sentinel', async () => {
|
||||
@@ -208,7 +212,7 @@ describe('transformOpenAIChatCompletions (gpt-5.6-sol)', () => {
|
||||
expect(source).not.toMatch(/↵$/);
|
||||
});
|
||||
|
||||
it('images GPT tool definitions even when there is no instruction context', async () => {
|
||||
it('does not image duplicated native tool structure without instruction context', async () => {
|
||||
const body = enc.encode(JSON.stringify({
|
||||
model: 'gpt-5.6-sol',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
@@ -223,12 +227,12 @@ describe('transformOpenAIChatCompletions (gpt-5.6-sol)', () => {
|
||||
}));
|
||||
|
||||
const result = await transformOpenAIChatCompletions(body, { charsPerToken: 1, minCompressChars: 1 });
|
||||
expect(result.info.compressed).toBe(true);
|
||||
expect(result.info.compressed).toBe(false);
|
||||
expect(result.info.origChars).toBe(CHAT_TOOL_DOC.length);
|
||||
expect(result.info.compressedChars).toBe(CHAT_TOOL_DOC.length);
|
||||
expect(result.info.compressedChars).toBe(0);
|
||||
const out = JSON.parse(dec.decode(result.body)) as any;
|
||||
expect(out.tools[0].function.description).toBe(BIG_TOOL_DESC);
|
||||
expect(out.tools[0].function.parameters.description).toBeUndefined();
|
||||
expect(out.tools[0].function.parameters.description).toBe('Param root.');
|
||||
});
|
||||
|
||||
it('keeps a parameter literally named "description" (task-tool regression)', async () => {
|
||||
@@ -242,7 +246,7 @@ describe('transformOpenAIChatCompletions (gpt-5.6-sol)', () => {
|
||||
}));
|
||||
|
||||
const result = await transformOpenAIChatCompletions(body, { charsPerToken: 1, minCompressChars: 1 });
|
||||
expect(result.info.compressed).toBe(true);
|
||||
expect(result.info.compressed).toBe(false);
|
||||
const out = JSON.parse(dec.decode(result.body)) as any;
|
||||
const params = out.tools[0].function.parameters;
|
||||
// Property NAMES survive — including ones that collide with annotation keywords.
|
||||
@@ -250,10 +254,10 @@ describe('transformOpenAIChatCompletions (gpt-5.6-sol)', () => {
|
||||
// `required` still points at real, present properties (this is what GPT failed before).
|
||||
expect(params.required).toEqual(['description', 'prompt']);
|
||||
for (const name of params.required) expect(params.properties[name]).toBeDefined();
|
||||
// …but the verbose annotation inside each property is gone (it lives in the image).
|
||||
// The annotation-only image was not profitable, so the original schema is untouched.
|
||||
expect(params.properties.description.type).toBe('string');
|
||||
expect(params.properties.description.description).toBeUndefined();
|
||||
expect(params.properties.title.description).toBeUndefined();
|
||||
expect(params.properties.description.description).toBeDefined();
|
||||
expect(params.properties.title.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns compressed=false with not_profitable reason for small input', async () => {
|
||||
@@ -276,7 +280,8 @@ describe('transformOpenAIChatCompletions (gpt-5.6-sol)', () => {
|
||||
const BIG_INSTRUCTIONS = 'These are detailed instructions. '.repeat(600); // ~20k chars
|
||||
const BIG_FLAT_TOOL_DESC = 'Flat tool description with lots of context. '.repeat(200); // ~8k chars
|
||||
const RESPONSES_TOOL_PARAMS = { type: 'object', description: 'Param root.', properties: { x: { type: 'string', description: 'x param' } } };
|
||||
const RESPONSES_TOOL_DOC = `## Tool: do_thing\n${BIG_FLAT_TOOL_DESC}\n\`\`\`json\n${JSON.stringify(RESPONSES_TOOL_PARAMS)}\n\`\`\``;
|
||||
const RESPONSES_TOOL_DOC = '## Tool parameter annotations: do_thing\n' +
|
||||
'$ description: "Param root."\n$.x description: "x param"';
|
||||
|
||||
describe('transformOpenAIResponses (gpt-5.6-sol)', () => {
|
||||
it('records original Responses composition with local o200k buckets', async () => {
|
||||
@@ -401,7 +406,7 @@ describe('transformOpenAIResponses (gpt-5.6-sol)', () => {
|
||||
expect(JSON.stringify(dev.content)).not.toContain('These are detailed');
|
||||
});
|
||||
|
||||
it('images GPT Responses tool definitions even when there is no instruction context', async () => {
|
||||
it('does not image duplicated native Responses tool structure without instructions', async () => {
|
||||
const body = enc.encode(JSON.stringify({
|
||||
model: 'gpt-5.6-sol',
|
||||
input: [{ role: 'user', content: 'Please do the thing.' }],
|
||||
@@ -414,12 +419,12 @@ describe('transformOpenAIResponses (gpt-5.6-sol)', () => {
|
||||
}));
|
||||
|
||||
const result = await transformOpenAIResponses(body, { charsPerToken: 1, minCompressChars: 1 });
|
||||
expect(result.info.compressed).toBe(true);
|
||||
expect(result.info.compressed).toBe(false);
|
||||
expect(result.info.origChars).toBe(RESPONSES_TOOL_DOC.length);
|
||||
expect(result.info.compressedChars).toBe(RESPONSES_TOOL_DOC.length);
|
||||
expect(result.info.compressedChars).toBe(0);
|
||||
const out = JSON.parse(dec.decode(result.body)) as any;
|
||||
expect(out.tools[0].description).toBe(BIG_FLAT_TOOL_DESC);
|
||||
expect(out.tools[0].parameters.description).toBeUndefined();
|
||||
expect(out.tools[0].parameters.description).toBe('Param root.');
|
||||
});
|
||||
|
||||
it('keeps a parameter literally named "description" (task-tool regression)', async () => {
|
||||
@@ -430,14 +435,14 @@ describe('transformOpenAIResponses (gpt-5.6-sol)', () => {
|
||||
}));
|
||||
|
||||
const result = await transformOpenAIResponses(body, { charsPerToken: 1, minCompressChars: 1 });
|
||||
expect(result.info.compressed).toBe(true);
|
||||
expect(result.info.compressed).toBe(false);
|
||||
const out = JSON.parse(dec.decode(result.body)) as any;
|
||||
const params = out.tools[0].parameters;
|
||||
expect(Object.keys(params.properties).sort()).toEqual(['description', 'prompt', 'title']);
|
||||
expect(params.required).toEqual(['description', 'prompt']);
|
||||
for (const name of params.required) expect(params.properties[name]).toBeDefined();
|
||||
expect(params.properties.description.type).toBe('string');
|
||||
expect(params.properties.description.description).toBeUndefined();
|
||||
expect(params.properties.description.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('handles bare string input (wraps into user item with images)', async () => {
|
||||
|
||||
@@ -360,6 +360,65 @@ describe('proxy usage extraction', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('forwards Messages tool-result images through the Chat Completions upstream', async () => {
|
||||
let upstream: Request | undefined;
|
||||
const restore = mockUpstream((req) => {
|
||||
upstream = req.clone();
|
||||
return new Response(JSON.stringify({
|
||||
id: 'chatcmpl_vision', model: 'moonshotai/kimi-k3',
|
||||
choices: [{ finish_reason: 'stop', message: { role: 'assistant', content: 'PXPIPE-NONCE' } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 2 },
|
||||
}), { headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const proxy = createProxy({
|
||||
chatUpstream: 'https://api.cloudflare.test/ai/v1',
|
||||
chatApiKey: 'cf-test',
|
||||
chatModel: 'moonshotai/kimi-k3',
|
||||
transform: { compress: false },
|
||||
});
|
||||
const response = await proxy(new Request('http://localhost/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', authorization: 'Bearer anthropic-secret' },
|
||||
body: JSON.stringify({
|
||||
model: 'claude-moonshotai/kimi-k3', max_tokens: 128,
|
||||
messages: [
|
||||
{ role: 'assistant', content: [
|
||||
{ type: 'tool_use', id: 'toolu_image', name: 'read', input: { path: 'nonce.png' } },
|
||||
] },
|
||||
{ role: 'user', content: [
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_image', content: [
|
||||
{ type: 'text', text: 'Image read successfully.' },
|
||||
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'YWJj' } },
|
||||
] },
|
||||
{ type: 'text', text: 'Return the visible nonce.' },
|
||||
] },
|
||||
],
|
||||
}),
|
||||
}));
|
||||
const converted = await response.json() as any;
|
||||
restore();
|
||||
|
||||
expect(upstream?.url).toBe('https://api.cloudflare.test/ai/v1/chat/completions');
|
||||
expect(upstream?.headers.get('authorization')).toBe('Bearer cf-test');
|
||||
const sent = JSON.parse(await upstream!.text());
|
||||
expect(sent.model).toBe('moonshotai/kimi-k3');
|
||||
expect(sent.messages).toEqual([
|
||||
{
|
||||
role: 'assistant', content: null,
|
||||
tool_calls: [{
|
||||
id: 'toolu_image', type: 'function',
|
||||
function: { name: 'read', arguments: '{"path":"nonce.png"}' },
|
||||
}],
|
||||
},
|
||||
{ role: 'tool', tool_call_id: 'toolu_image', content: 'Image read successfully.' },
|
||||
{ role: 'user', content: [
|
||||
{ type: 'image_url', image_url: { url: 'data:image/png;base64,YWJj' } },
|
||||
{ type: 'text', text: 'Return the visible nonce.' },
|
||||
] },
|
||||
]);
|
||||
expect(converted.content).toEqual([{ type: 'text', text: 'PXPIPE-NONCE' }]);
|
||||
});
|
||||
|
||||
it('rejects Messages blocks that the Responses bridge cannot preserve', async () => {
|
||||
let called = false;
|
||||
const restore = mockUpstream(() => {
|
||||
@@ -404,6 +463,226 @@ describe('proxy usage extraction', () => {
|
||||
expect(requests.some((request) => request.url.includes('/v1/responses'))).toBe(false);
|
||||
});
|
||||
|
||||
it('routes a scope-listed Kimi model to chatUpstream, not the Responses bridge', async () => {
|
||||
// Scope alone must not classify a model as GPT: kimi is listed here, yet
|
||||
// it has to take the Chat Completions bridge because its NAME is not
|
||||
// GPT/Grok-shaped. Regression for the shared-PXPIPE_MODELS routing hole.
|
||||
const prior = process.env.PXPIPE_MODELS;
|
||||
process.env.PXPIPE_MODELS = 'claude-fable-5,gpt-5.6-sol,moonshotai/kimi-k3';
|
||||
let upstream: Request | undefined;
|
||||
const restore = mockUpstream((req) => {
|
||||
upstream = req.clone();
|
||||
return new Response(JSON.stringify({
|
||||
id: 'chatcmpl_1', model: 'moonshotai/kimi-k3',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 3, completion_tokens: 1 },
|
||||
}), { headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const proxy = createProxy({
|
||||
openAIUpstream: 'https://openai.test',
|
||||
chatUpstream: 'https://kimi.test/v1', chatApiKey: 'tok_kimi',
|
||||
transform: { compress: false },
|
||||
});
|
||||
const res = await proxy(new Request('http://localhost/v1/messages', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'moonshotai/kimi-k3', max_tokens: 64,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
}));
|
||||
const body = await res.json() as any;
|
||||
restore();
|
||||
if (prior === undefined) delete process.env.PXPIPE_MODELS;
|
||||
else process.env.PXPIPE_MODELS = prior;
|
||||
expect(upstream?.url).toBe('https://kimi.test/v1/chat/completions');
|
||||
expect(upstream?.headers.get('authorization')).toBe('Bearer tok_kimi');
|
||||
const sent = JSON.parse(await upstream!.text());
|
||||
expect(sent.model).toBe('moonshotai/kimi-k3');
|
||||
expect(Array.isArray(sent.messages)).toBe(true);
|
||||
expect(sent.input).toBeUndefined(); // Responses-bridge shape must not leak in.
|
||||
expect(body).toMatchObject({ type: 'message', role: 'assistant' });
|
||||
});
|
||||
|
||||
it('advertises a reversible Claude-safe id for gateway model discovery', async () => {
|
||||
const proxy = createProxy({
|
||||
chatUpstream: 'https://kimi.test/v1',
|
||||
resolveChatModel: async () => 'moonshotai/kimi-k3',
|
||||
});
|
||||
const res = await proxy(new Request('http://localhost/v1/models?limit=1000'));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
data: [{
|
||||
id: 'claude-moonshotai/kimi-k3',
|
||||
type: 'model',
|
||||
display_name: 'Kimi K3 (Cloudflare)',
|
||||
created_at: '1970-01-01T00:00:00Z',
|
||||
}],
|
||||
has_more: false,
|
||||
first_id: 'claude-moonshotai/kimi-k3',
|
||||
last_id: 'claude-moonshotai/kimi-k3',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an empty discovery list when a generic chat upstream has no configured model', async () => {
|
||||
const proxy = createProxy({ chatUpstream: 'https://chat.test/v1' });
|
||||
expect(await (await proxy(new Request('http://localhost/v1/models'))).json()).toEqual({
|
||||
data: [], has_more: false, first_id: '', last_id: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the resolved Kimi model for compression eligibility and telemetry', async () => {
|
||||
const prior = process.env.PXPIPE_MODELS;
|
||||
process.env.PXPIPE_MODELS = 'moonshotai/kimi-k3';
|
||||
let sent: any;
|
||||
let captured: ProxyEvent | undefined;
|
||||
const restore = mockUpstream(async (req) => {
|
||||
sent = JSON.parse(await req.text());
|
||||
return new Response(JSON.stringify({
|
||||
id: 'chatcmpl_3', model: 'moonshotai/kimi-k3',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 3, completion_tokens: 1 },
|
||||
}), { headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const proxy = createProxy({
|
||||
chatUpstream: 'https://kimi.test/v1',
|
||||
resolveChatModel: async () => 'moonshotai/kimi-k3',
|
||||
transform: { compress: true, minCompressChars: 1 },
|
||||
onRequest: (event) => { captured = event; },
|
||||
});
|
||||
const res = await proxy(new Request('http://localhost/v1/messages', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'claude-moonshotai/kimi-k3', max_tokens: 64,
|
||||
system: 'Long system context. '.repeat(200),
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
}));
|
||||
await res.text();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
restore();
|
||||
if (prior === undefined) delete process.env.PXPIPE_MODELS;
|
||||
else process.env.PXPIPE_MODELS = prior;
|
||||
expect(sent.model).toBe('moonshotai/kimi-k3');
|
||||
expect(captured?.model).toBe('moonshotai/kimi-k3');
|
||||
expect(captured?.info?.reason).not.toBe('unsupported_model');
|
||||
});
|
||||
|
||||
it('decodes the selected gateway id without consulting model discovery', async () => {
|
||||
let sentModel = '';
|
||||
const restore = mockUpstream(async (req) => {
|
||||
sentModel = JSON.parse(await req.text()).model;
|
||||
return new Response(JSON.stringify({
|
||||
choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
}), { headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const proxy = createProxy({
|
||||
chatUpstream: 'https://chat.test/v1',
|
||||
resolveChatModel: async () => { throw new Error('must not run'); },
|
||||
transform: { compress: false },
|
||||
});
|
||||
await (await proxy(new Request('http://localhost/v1/messages', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'claude-moonshotai/kimi-k3',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
}),
|
||||
}))).text();
|
||||
restore();
|
||||
expect(sentModel).toBe('moonshotai/kimi-k3');
|
||||
});
|
||||
|
||||
it('routes ALL Messages traffic to chatUpstream once it is configured', async () => {
|
||||
// OPENAPI_URL (or the Cloudflare-derived URL) declares "this is my
|
||||
// endpoint": routing stops being name-based, so GPT-named and even
|
||||
// claude-named requests bridge to chatUpstream instead of the Responses
|
||||
// bridge / Anthropic passthrough. Model ids forward verbatim because no
|
||||
// chatModel (OPENAPI_MODEL) override is set.
|
||||
const seen: Request[] = [];
|
||||
const restore = mockUpstream((req) => {
|
||||
seen.push(req.clone());
|
||||
return new Response(JSON.stringify({
|
||||
id: 'chatcmpl_2', model: 'whatever',
|
||||
choices: [{ index: 0, message: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 3, completion_tokens: 1 },
|
||||
}), { headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const proxy = createProxy({
|
||||
openAIUpstream: 'https://openai.test',
|
||||
chatUpstream: 'https://kimi.test/v1', chatApiKey: 'tok_kimi',
|
||||
transform: { compress: false },
|
||||
});
|
||||
for (const model of ['gpt-5.6-sol', 'claude-fable-5']) {
|
||||
await (await proxy(new Request('http://localhost/v1/messages', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model, max_tokens: 64, messages: [{ role: 'user', content: 'hi' }] }),
|
||||
}))).text();
|
||||
}
|
||||
restore();
|
||||
expect(seen).toHaveLength(2);
|
||||
for (const req of seen) expect(req.url).toBe('https://kimi.test/v1/chat/completions');
|
||||
const sentModels = await Promise.all(seen.map(async (req) => JSON.parse(await req.text()).model));
|
||||
expect(sentModels).toEqual(['gpt-5.6-sol', 'claude-fable-5']);
|
||||
});
|
||||
|
||||
it('routes Messages requests by explicit provider model scopes', async () => {
|
||||
const seen: Request[] = [];
|
||||
const restore = mockUpstream(async (req) => {
|
||||
seen.push(req.clone());
|
||||
if (req.url.includes('/chat/completions')) {
|
||||
return new Response(JSON.stringify({
|
||||
id: 'chatcmpl_scoped', model: 'moonshotai/kimi-k3',
|
||||
choices: [{ message: { role: 'assistant', content: 'ok' }, finish_reason: 'stop' }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1 },
|
||||
}), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
if (req.url.includes('/responses')) {
|
||||
return new Response(JSON.stringify({
|
||||
id: 'resp_scoped', status: 'completed', model: 'custom-codex', output: [],
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}), { headers: { 'content-type': 'application/json' } });
|
||||
}
|
||||
return new Response(JSON.stringify({
|
||||
id: 'msg_scoped', type: 'message', role: 'assistant', model: 'custom-claude',
|
||||
content: [{ type: 'text', text: 'ok' }], usage: { input_tokens: 1, output_tokens: 1 },
|
||||
}), { headers: { 'content-type': 'application/json' } });
|
||||
});
|
||||
const proxy = createProxy({
|
||||
upstream: 'https://anthropic.test',
|
||||
openAIUpstream: 'https://openai.test',
|
||||
chatUpstream: 'https://cloudflare.test/ai/v1',
|
||||
chatModel: 'legacy-global-override',
|
||||
anthropicModels: ['custom-claude'],
|
||||
openAIModels: ['custom-codex'],
|
||||
cloudflareModels: ['moonshotai/kimi-k3'],
|
||||
transform: { compress: false },
|
||||
});
|
||||
for (const model of ['custom-claude', 'custom-codex', 'moonshotai/kimi-k3']) {
|
||||
await (await proxy(new Request('http://localhost/v1/messages', {
|
||||
method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model, messages: [{ role: 'user', content: 'hi' }] }),
|
||||
}))).text();
|
||||
}
|
||||
restore();
|
||||
expect(seen.some((req) => req.url === 'https://anthropic.test/v1/messages')).toBe(true);
|
||||
expect(seen.some((req) => req.url === 'https://openai.test/v1/responses')).toBe(true);
|
||||
const cloudflareRequest = seen.find((req) => req.url.includes('/chat/completions'))!;
|
||||
expect(cloudflareRequest.url).toBe('https://cloudflare.test/ai/v1/chat/completions');
|
||||
expect(JSON.parse(await cloudflareRequest.text()).model).toBe('moonshotai/kimi-k3');
|
||||
});
|
||||
|
||||
it('rejects overlapping provider model scopes', () => {
|
||||
expect(() => createProxy({
|
||||
anthropicModels: ['shared-model'],
|
||||
openAIModels: ['shared-model'],
|
||||
})).toThrow('model shared-model is configured for both anthropic and openai');
|
||||
});
|
||||
|
||||
it('requires a chat upstream when Cloudflare models are scoped', () => {
|
||||
expect(() => createProxy({ cloudflareModels: ['moonshotai/kimi-k3'] }))
|
||||
.toThrow('cloudflareModels requires a Cloudflare or OPENAPI chat upstream');
|
||||
});
|
||||
|
||||
it('correlates interleaved parallel tool deltas and recovers sparse terminal calls', async () => {
|
||||
const response = { id: 'resp_parallel', model: 'gpt-5.6-sol', status: 'in_progress' };
|
||||
const events = [
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Messages -> Responses bridge: message-role handling.
|
||||
*
|
||||
* Pins the role validation that newer Claude Code builds exercise by injecting
|
||||
* role:"system" entries inside `messages` (system reminders). The bridge must
|
||||
* map them to Responses system input items rather than rejecting the request.
|
||||
*
|
||||
* Run just this file: pnpm vitest run tests/responses-bridge-roles.test.ts
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { anthropicMessagesToOpenAIResponses } from '../src/core/messages-responses-bridge.js';
|
||||
|
||||
const enc = (obj: unknown): Uint8Array => new TextEncoder().encode(JSON.stringify(obj));
|
||||
const dec = (b: Uint8Array): any => JSON.parse(new TextDecoder().decode(b));
|
||||
const toResponses = (req: unknown): any => dec(anthropicMessagesToOpenAIResponses(enc(req)));
|
||||
|
||||
describe('anthropicMessagesToOpenAIResponses — message roles', () => {
|
||||
it('maps in-conversation system-role messages to system input items', () => {
|
||||
const out = toResponses({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||
{ role: 'system', content: [{ type: 'text', text: 'Available agent types: cdp' }] },
|
||||
],
|
||||
});
|
||||
const system = out.input.filter((item: any) => item.role === 'system');
|
||||
expect(system).toEqual([
|
||||
{ role: 'system', content: [{ type: 'input_text', text: 'Available agent types: cdp' }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops empty system-role messages and rejects unknown roles', () => {
|
||||
const out = toResponses({
|
||||
model: 'm',
|
||||
messages: [
|
||||
{ role: 'system', content: [] },
|
||||
{ role: 'user', content: 'q' },
|
||||
],
|
||||
});
|
||||
expect(out.input.some((item: any) => item.role === 'system')).toBe(false);
|
||||
expect(() =>
|
||||
toResponses({ model: 'm', messages: [{ role: 'tool', content: 'x' }] }),
|
||||
).toThrow(/user, assistant, or system role/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user