test(translator): add data-driven coverage, bug-exposing cases, and real provider smoke

- matrix.js generates N providers x M models from PROVIDER_MODELS
- coverage-all-models + format-roundtrip for structural/semantic checks
- bugs-* files expose known translation issues via it.fails
- real/smoke-providers runs full handleChatCore path against live providers (RUN_REAL=1), concurrent
- registerAll.js eagerly imports translators (Vitest ESM require fix)
- vitest.config: array aliases for subpaths + maxConcurrency 60

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
decolua
2026-06-06 12:19:59 +07:00
co-authored by Cursor
parent 4758a00b44
commit 281f292f63
14 changed files with 965 additions and 6 deletions
+120
View File
@@ -0,0 +1,120 @@
# Translation Layer Tests
Tests for `open-sse/translator/`. Goals: (1) data-driven coverage of every provider/model, (2) expose bugs caused by using OpenAI as the intermediate format.
## 1. Translation layer structure (`open-sse/translator/`)
Pipeline uses **OpenAI as the intermediate format**:
- Request: `source → openai → target` (`translateRequest`)
- Response (SSE chunk): `target → openai → source` (`translateResponse`)
- If `source === target` → translation is skipped (passthrough).
Components:
- `index.js``translateRequest` / `translateResponse` / `register(from, to, requestFn, responseFn)` / registry.
- `formats.js``FORMATS` enum (openai, claude, gemini, gemini-cli, openai-responses, antigravity, kiro, cursor, commandcode, ollama, vertex).
- `request/<from>-to-<to>.js` — one-way request translation.
- `response/<from>-to-<to>.js` — one-way SSE response translation.
- `helpers/``openaiHelper.js` (filterToOpenAIFormat), `toolCallHelper.js` (id/arguments), `claudeHelper.js`, `geminiHelper.js`.
**OpenAI-bridge pitfalls** (source of most bugs): going through OpenAI easily loses `thinking`/`reasoning`, image URLs (non-base64), `input_audio`, `is_error`; tool `id`/`index` become unstable (parallel tool calls), non-text system blocks, `tool_choice:"none"`.
## 2. Test layout
| File | Role |
|---|---|
| `matrix.js` | Reads `PROVIDER_MODELS` → builds matrix (alias, model, targetFormat, strip, upstreamId). DRY core. |
| `registerAll.js` | Imports every translator to run `register()` side-effects. **Required** (see §5). |
| `coverage-all-models.test.js` | Tier 1: every model translates without throwing; strip applied correctly. |
| `format-roundtrip.test.js` | Tier 2: tool id/system/parallel survive the bridge. |
| `bugs-openai-bridge.test.js` | Exposes concrete bugs (with source file:line). |
## 3. Running
Always pass `--config tests/vitest.config.js` (the alias config lives there; without it vitest may not resolve `@/...` subpaths).
```bash
# no-cred (default, offline): translator-only files
cd app && npx vitest run --config tests/vitest.config.js "tests/translator/"
cd app && npx vitest run --config tests/vitest.config.js "tests/translator/bugs-openai-bridge.test.js"
# real (calls live providers using credentials from the local DB)
cd app && RUN_REAL=1 npx vitest run --config tests/vitest.config.js "tests/translator/real/"
```
No-cred tests make NO network calls and need NO creds. Real tests (`real/`, gated by `RUN_REAL=1`) read active connections from `~/.9router/db/data.sqlite`, send a tiny prompt per provider through `handleChatCore`, and assert valid SSE. Account/quota errors (401/402/403/429) are treated as credential issues and skipped, not failures.
## 4. Adding a new provider → tests cover it AUTOMATICALLY
Add a provider by adding a key to `open-sse/config/providerModels.js` `PROVIDER_MODELS` (e.g. `newprov: [{ id, targetFormat?, strip?, upstreamModelId? }]`) plus its config in `open-sse/config/providers.js`.
`coverage-all-models.test.js` **automatically** runs for the new models with **no test edits**. `matrix.js` reads config directly.
Only add a dedicated test when a provider has a special format that does not round-trip cleanly (see §7).
## 5. `registerAll.js` — why it is required
`translator/index.js` uses `require(...)` (bundler-only) to lazy-load translators. Under vitest/ESM, `require` **silently no-ops** → empty registry → `translateRequest` skips the translation step → **false pass** (data is lost but the test goes green by mistake).
→ Every test calling `translateRequest`/`translateResponse` MUST `import "./registerAll.js"` at the top of the file.
## 6. Bug-exposure convention — `it.fails`
- A bug confirmed in the app but NOT yet fixed → use `it.fails(...)`.
- `it.fails` **passes while the app still has the bug**, **turns red once the bug is fixed** → a reminder to update the test (switch `it.fails``it` and confirm correct behavior).
- Pattern for a new bug-exposure test: real input → assert the "should-be-kept" behavior → wrap in `it.fails` + a comment with the source `file:line`.
## 7. Special formats to watch
- `kiro` (binary AWS EventStream), `cursor` (protobuf ConnectRPC), `commandcode` (NDJSON) → responses do NOT round-trip cleanly through openai; test via their executors, not just the translator.
- Single-provider-two-formats (most fragile): `opencode-go` (minimax models → claude, others openai), `github` (escalates `/chat/completions``/responses` at runtime), `xiaomi-tokenplan` (claude alias).
- `gemini`/`gemini-cli`: only the LAST system message is kept → earlier system messages are lost.
## 8. Current known bugs (currently `it.fails`)
Grouped per CLI/provider test file. Each row is an `it.fails` case.
**Claude (`bugs-openai-bridge.test.js`, `bugs-claudeCode-context.test.js`)**
| Bug | Source |
|---|---|
| Claude image `source.type="url"` dropped (only base64) | `request/claude-to-openai.js:133-141` |
| `tool_result` image block → raw JSON | `request/claude-to-openai.js:155-173` |
| `tool_result.is_error` lost | `request/claude-to-openai.js:155-173` |
| `thinking`/`redacted_thinking` dropped via bridge | `request/claude-to-openai.js:128` |
**OpenAI → Claude (`bugs-toClaude-context.test.js`)**
| Bug | Source |
|---|---|
| Always injects "You are Claude Code" system prompt | `request/openai-to-claude.js:124-134` |
| `reasoning_content` not mapped to a thinking block | `request/openai-to-claude.js:268-273` |
| `tool_choice:"none"``auto` | `request/openai-to-claude.js:298` |
| `input_audio` dropped | `request/openai-to-claude.js` (no audio branch) |
**Codex Responses (`bugs-codexCli-responses.test.js`)**
| Bug | Source |
|---|---|
| Empty-name function_call can leave `tool_calls: []` | `request/openai-responses.js:103` |
| `arguments` not coerced to string | `request/openai-responses.js:109-110` |
| `input_image` uses `file_id` as raw url | `request/openai-responses.js:75-77` |
**Antigravity (`bugs-antigravity.test.js`)**
| Bug | Source |
|---|---|
| functionResponse + functionCall in same content → tool calls dropped | `request/antigravity-to-openai.js:177-189` |
| functionCall without id → random unstable id | `request/antigravity-to-openai.js:167` |
**Kiro (`bugs-kiro.test.js`)**
| Bug | Source |
|---|---|
| `JSON.parse(arguments)` throws on bad JSON (no try/catch) | `request/openai-to-kiro.js:214-216` |
| `max_tokens` hardcoded to 32000 | `request/openai-to-kiro.js:309` |
| Remote image → `[Image: url]` text | `request/openai-to-kiro.js:132-134` |
**Gemini / Cursor / CommandCode (`bugs-gemini-cursor-commandcode.test.js`)**
| Bug | Source |
|---|---|
| Only the last system message kept | `request/openai-to-gemini.js:92-96` |
| Cursor drops image content | `request/openai-to-cursor.js:12-24` |
| Cursor `max_tokens` hardcoded to 32000 | `request/openai-to-cursor.js:179` |
| CommandCode bad JSON args → `{}` silently | `request/openai-to-commandcode.js:53-57` |
| CommandCode image → `[image omitted]` | `request/openai-to-commandcode.js:41-42` |
Fixing a bug → rerun; the matching `it.fails` test turns RED → switch it to a regular `it` and verify correct behavior.
+54
View File
@@ -0,0 +1,54 @@
// Real Antigravity-MITM requests (Gemini-internal: { request: { contents, ... } }) → OpenAI.
import { describe, it, expect } from "vitest";
import "./registerAll.js";
import { translateRequest } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
const AG2O = (req) =>
translateRequest(FORMATS.ANTIGRAVITY, FORMATS.OPENAI, "m", { request: req }, true, null, null);
describe("Antigravity → OpenAI", () => {
// antigravity-to-openai.js:177-189 — content with BOTH functionResponse and functionCall/text
// returns toolResults early → drops the tool calls / text.
// KNOWN BUG
it.fails("functionResponse + functionCall in same content keeps both", () => {
const out = AG2O({
contents: [{
role: "model",
parts: [
{ functionResponse: { id: "c1", name: "prev", response: { result: "done" } } },
{ functionCall: { id: "c2", name: "next", args: {} } },
],
}],
});
const json = JSON.stringify(out);
expect(json, "functionCall lost when sharing content with functionResponse").toContain("\"next\"");
});
// antigravity-to-openai.js:167 — functionCall without id gets a random Date.now() id
// KNOWN BUG: unstable id breaks matching with its functionResponse
it.fails("functionCall without id keeps a stable matchable id", () => {
const out = AG2O({
contents: [
{ role: "model", parts: [{ functionCall: { name: "search", args: { q: "x" } } }] },
{ role: "user", parts: [{ functionResponse: { name: "search", response: { result: "r" } } }] },
],
});
const asst = out.messages.find((m) => m.tool_calls);
const tool = out.messages.find((m) => m.role === "tool");
expect(tool?.tool_call_id, "id mismatch between call and response").toBe(asst?.tool_calls?.[0]?.id);
});
// antigravity-to-openai.js:144-147 — signature-only part handling (regression guard)
it("signature-only part does not produce empty text", () => {
const out = AG2O({
contents: [{ role: "model", parts: [{ thoughtSignature: "sig", text: "" }] }],
});
const asst = out.messages.find((m) => m.role === "assistant");
const content = asst?.content;
const hasEmpty = Array.isArray(content)
? content.some((c) => c.type === "text" && c.text === "")
: content === "";
expect(hasEmpty, "empty text part emitted").toBe(false);
});
});
@@ -0,0 +1,73 @@
// Real Claude Code CLI requests (Claude format) → non-Claude provider via OpenAI bridge.
// Focuses on context components a real CLI sends: system arrays w/ cache_control, thinking
// signatures, tool_result with images, audio. KNOWN BUG = it.fails (source file:line in comments).
import { describe, it, expect } from "vitest";
import "./registerAll.js";
import { translateRequest } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
const T = (src, tgt, body, provider = null) =>
translateRequest(src, tgt, "m", body, true, null, provider);
describe("Claude Code CLI context → OpenAI", () => {
// claude-to-openai.js:24-27 — system array only maps .text; cache_control/non-text dropped
it("system array keeps all text parts", () => {
const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, {
system: [
{ type: "text", text: "You are Claude Code.", cache_control: { type: "ephemeral" } },
{ type: "text", text: "Follow repo conventions." },
],
messages: [{ role: "user", content: "hi" }],
});
const sys = out.messages.find((m) => m.role === "system");
expect(sys?.content).toContain("Claude Code");
expect(sys?.content).toContain("repo conventions");
});
// claude→claude is passthrough (same format) → thinking preserved. Guards against
// accidental routing through the OpenAI bridge for same-format requests.
it("assistant thinking block survives Claude→Claude passthrough", () => {
const out = T(FORMATS.CLAUDE, FORMATS.CLAUDE, {
messages: [
{ role: "assistant", content: [
{ type: "thinking", thinking: "step-by-step plan", signature: "abc123" },
{ type: "text", text: "done" },
] },
{ role: "user", content: "next" },
],
});
expect(JSON.stringify(out)).toContain("step-by-step plan");
});
// claude-to-openai.js:128 — redacted_thinking also dropped
// KNOWN BUG
it.fails("redacted_thinking block is not silently dropped", () => {
const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, {
messages: [
{ role: "assistant", content: [
{ type: "redacted_thinking", data: "ENCRYPTED_BLOB" },
{ type: "text", text: "answer" },
] },
{ role: "user", content: "go" },
],
});
expect(JSON.stringify(out)).toContain("ENCRYPTED_BLOB");
});
// claude-to-openai.js:155-173 — tool_result image block stringified into raw JSON
// KNOWN BUG
it.fails("tool_result image block is preserved", () => {
const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, {
messages: [
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "screenshot", input: {} }] },
{ role: "user", content: [
{ type: "tool_result", tool_use_id: "call_1", content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: "IMG" } },
] },
] },
],
});
const tool = out.messages.find((m) => m.role === "tool");
expect(tool?.content, "image turned into raw JSON").not.toMatch(/^\[/);
});
});
@@ -0,0 +1,63 @@
// Real Codex CLI requests (OpenAI Responses API: { input:[], instructions }) → providers.
import { describe, it, expect } from "vitest";
import "./registerAll.js";
import { translateRequest } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
const R2O = (body) => translateRequest(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, "m", body, true, null, null);
const O2R = (body) => translateRequest(FORMATS.OPENAI, FORMATS.OPENAI_RESPONSES, "m", body, true, null, null);
describe("Codex CLI Responses → OpenAI", () => {
// openai-responses.js:103 — function_call with empty name skipped, can leave tool_calls: []
// KNOWN BUG: empty tool_calls array is rejected by OpenAI/Codex
it.fails("assistant has no empty tool_calls array when all names are empty", () => {
const out = R2O({
input: [
{ type: "function_call", call_id: "c1", name: "", arguments: "{}" },
],
});
const asst = out.messages.find((m) => m.role === "assistant" && m.tool_calls);
expect(asst?.tool_calls?.length ?? 0, "empty tool_calls[] produced").toBeGreaterThan(0);
});
// openai-responses.js:109-110 — arguments passed through without ensuring string type
// KNOWN BUG
it.fails("function_call arguments end up as a string", () => {
const out = R2O({
input: [{ type: "function_call", call_id: "c1", name: "f", arguments: { a: 1 } }],
});
const asst = out.messages.find((m) => m.tool_calls);
expect(typeof asst.tool_calls[0].function.arguments).toBe("string");
});
// openai-responses.js:75-77 — input_image uses file_id as raw url
// KNOWN BUG
it.fails("input_image with file_id is not used as a raw url", () => {
const out = R2O({
input: [{ type: "message", role: "user", content: [
{ type: "input_image", file_id: "file-abc" },
] }],
});
const userMsg = out.messages.find((m) => m.role === "user");
const img = Array.isArray(userMsg?.content) ? userMsg.content.find((c) => c.type === "image_url") : null;
// A bare file_id is not a valid image URL
expect(img?.image_url?.url === "file-abc").toBe(false);
});
});
describe("OpenAI → Codex Responses (reverse)", () => {
// openai-responses.js:13 — clampCallId NOT applied on Responses→Chat; but here Chat→Responses must clamp
it("call_id longer than 64 chars is clamped", () => {
const longId = "call_" + "x".repeat(80);
const out = O2R({
messages: [
{ role: "assistant", content: null, tool_calls: [
{ id: longId, type: "function", function: { name: "f", arguments: "{}" } },
] },
{ role: "tool", tool_call_id: longId, content: "ok" },
],
});
const fc = out.input.find((i) => i.type === "function_call");
expect(fc.call_id.length).toBeLessThanOrEqual(64);
});
});
@@ -0,0 +1,76 @@
// OpenAI → Gemini / Cursor / CommandCode request translation.
import { describe, it, expect } from "vitest";
import "./registerAll.js";
import { translateRequest } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
const O2G = (body) => translateRequest(FORMATS.OPENAI, FORMATS.GEMINI, "m", body, true, null, "gemini");
const O2C = (body) => translateRequest(FORMATS.OPENAI, FORMATS.CURSOR, "m", body, true, null, "cursor");
const O2CC = (body) => translateRequest(FORMATS.OPENAI, FORMATS.COMMANDCODE, "m", body, true, null, "commandcode");
describe("OpenAI → Gemini", () => {
// openai-to-gemini.js:92-96 — each system message overwrites systemInstruction → only last kept
// KNOWN BUG
it.fails("multiple system messages are all kept", () => {
const out = O2G({
messages: [
{ role: "system", content: "RULE_ONE" },
{ role: "system", content: "RULE_TWO" },
{ role: "user", content: "hi" },
],
});
expect(JSON.stringify(out.systemInstruction), "earlier system lost").toContain("RULE_ONE");
});
});
describe("OpenAI → Cursor", () => {
// openai-to-cursor.js:12-24 — image content fully dropped (text only)
// KNOWN BUG
it.fails("image content is preserved", () => {
const out = O2C({
messages: [{ role: "user", content: [
{ type: "text", text: "look" },
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
] }],
});
expect(JSON.stringify(out), "image dropped").toContain("AAAA");
});
// openai-to-cursor.js:179 — max_tokens hardcoded to 32000
// KNOWN BUG
it.fails("respects client max_tokens", () => {
const out = O2C({ max_tokens: 200, messages: [{ role: "user", content: "hi" }] });
expect(out.max_tokens).toBe(200);
});
});
describe("OpenAI → CommandCode", () => {
// openai-to-commandcode.js:53-57 — safeParseJson returns {} on bad JSON (args silently lost)
// KNOWN BUG
it.fails("malformed tool arguments are not silently emptied", () => {
const out = O2CC({
messages: [
{ role: "user", content: "go" },
{ role: "assistant", content: "", tool_calls: [
{ id: "c1", type: "function", function: { name: "f", arguments: "{bad" } },
] },
{ role: "tool", tool_call_id: "c1", content: "r" },
],
});
const asst = out.params.messages.find((m) => m.role === "assistant");
const call = asst.content.find((b) => b.type === "tool-call");
expect(Object.keys(call.input).length, "arguments silently dropped to {}").toBeGreaterThan(0);
});
// openai-to-commandcode.js:41-42 — image becomes "[image omitted]"
// KNOWN BUG
it.fails("image content is preserved", () => {
const out = O2CC({
messages: [{ role: "user", content: [
{ type: "text", text: "look" },
{ type: "image_url", image_url: { url: "data:image/png;base64,BBBB" } },
] }],
});
expect(JSON.stringify(out), "image omitted").toContain("BBBB");
});
});
+45
View File
@@ -0,0 +1,45 @@
// OpenAI → Kiro (AWS CodeWhisperer) request translation.
import { describe, it, expect } from "vitest";
import "./registerAll.js";
import { translateRequest } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
const O2K = (body) => translateRequest(FORMATS.OPENAI, FORMATS.KIRO, "m", body, true, null, "kiro");
describe("OpenAI → Kiro", () => {
// openai-to-kiro.js:214-216 — JSON.parse(arguments) without try/catch → throws on bad JSON
// KNOWN BUG (severe: throws and breaks the whole request)
it.fails("malformed tool arguments do not throw the whole request", () => {
expect(() =>
O2K({
messages: [
{ role: "user", content: "go" },
{ role: "assistant", content: "", tool_calls: [
{ id: "c1", type: "function", function: { name: "f", arguments: "{not json" } },
] },
{ role: "tool", tool_call_id: "c1", content: "r" },
],
})
).not.toThrow();
});
// openai-to-kiro.js:309 — maxTokens hardcoded to 32000, ignores body.max_tokens
// KNOWN BUG
it.fails("respects client max_tokens", () => {
const out = O2K({ max_tokens: 100, messages: [{ role: "user", content: "hi" }] });
expect(out.inferenceConfig?.maxTokens, "client max_tokens ignored").toBe(100);
});
// openai-to-kiro.js:132-134 — remote http image becomes "[Image: url]" text (lost)
// KNOWN BUG
it.fails("remote image url is preserved as an image, not text", () => {
const out = O2K({
messages: [{ role: "user", content: [
{ type: "text", text: "see" },
{ type: "image_url", image_url: { url: "https://x.com/p.png" } },
] }],
});
const content = out.conversationState?.currentMessage?.userInputMessage?.content || "";
expect(content, "remote image flattened to text").not.toContain("[Image:");
});
});
+120
View File
@@ -0,0 +1,120 @@
// Expose bugs caused by OpenAI being the intermediate format: data lost/wrong on source → openai → target.
// Each test describes the EXPECTED-correct behavior. A FAIL is evidence of the bug (with source file:line).
import { describe, it, expect } from "vitest";
import "./registerAll.js";
import { translateRequest } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
const T = (src, tgt, body, provider = null) =>
translateRequest(src, tgt, "m", body, true, null, provider);
describe("bug: Claude → OpenAI bridge data loss", () => {
// claude-to-openai.js:133-141 — image source.type==="url" only handles base64
// KNOWN BUG: it.fails passes while app drops the url; flips to failing once fixed.
it.fails("image with source.type=url is preserved (NOT dropped)", () => {
const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, {
messages: [{ role: "user", content: [
{ type: "text", text: "look" },
{ type: "image", source: { type: "url", url: "https://x.com/a.png" } },
] }],
});
const json = JSON.stringify(out);
expect(json, "remote image url silently dropped").toContain("a.png");
});
// claude-to-openai.js:128 switch — missing thinking/redacted_thinking case
it("thinking block survives round-trip Claude→OpenAI→Claude", () => {
const body = {
messages: [{ role: "assistant", content: [
{ type: "thinking", thinking: "secret reasoning", signature: "sig" },
{ type: "text", text: "answer" },
] }, { role: "user", content: "go" }],
};
const out = T(FORMATS.CLAUDE, FORMATS.CLAUDE, body);
const json = JSON.stringify(out);
expect(json, "thinking content lost via OpenAI bridge").toContain("secret reasoning");
});
// claude-to-openai.js:155-173 — tool_result image block dropped (text only)
// KNOWN BUG
it.fails("tool_result with image block is not turned into raw JSON / dropped", () => {
const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, {
messages: [
{ role: "assistant", content: [
{ type: "tool_use", id: "call_1", name: "shot", input: {} },
] },
{ role: "user", content: [
{ type: "tool_result", tool_use_id: "call_1", content: [
{ type: "image", source: { type: "base64", media_type: "image/png", data: "ZZZ" } },
] },
] },
],
});
const toolMsg = out.messages.find((m) => m.role === "tool");
// Should keep the image; currently stringifies the whole array into raw JSON
expect(toolMsg?.content, "image in tool_result lost").not.toMatch(/^\[/);
});
// claude-to-openai.js:155-173 — is_error lost
// KNOWN BUG
it.fails("tool_result is_error flag is preserved", () => {
const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, {
messages: [
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "f", input: {} }] },
{ role: "user", content: [
{ type: "tool_result", tool_use_id: "call_1", is_error: true, content: "boom" },
] },
],
});
const json = JSON.stringify(out);
expect(json, "is_error dropped → model can't see tool failure").toContain("is_error");
});
// claude-to-openai.js:24-27 — system array only takes .text, drops cache_control/non-text
it("system array non-text parts are not silently dropped", () => {
const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, {
system: [
{ type: "text", text: "rule1", cache_control: { type: "ephemeral" } },
{ type: "text", text: "rule2" },
],
messages: [{ role: "user", content: "hi" }],
});
const sys = out.messages.find((m) => m.role === "system");
expect(sys?.content).toContain("rule1");
expect(sys?.content).toContain("rule2");
});
});
describe("bug: tool_call id stability across bridge", () => {
// toolCallHelper.js:29-31 — sanitize changes tc.id but tool_call_id in another message may drift
it("sanitized tool id stays matched between call and result", () => {
const out = T(FORMATS.OPENAI, FORMATS.OPENAI, {
messages: [
{ role: "assistant", tool_calls: [
{ id: "call/with:bad*chars", type: "function", function: { name: "f", arguments: "{}" } },
] },
{ role: "tool", tool_call_id: "call/with:bad*chars", content: "ok" },
],
});
const asst = out.messages.find((m) => m.role === "assistant");
const tool = out.messages.find((m) => m.role === "tool");
expect(tool.tool_call_id, "id mismatch after sanitize").toBe(asst.tool_calls[0].id);
});
});
describe("bug: empty content message handling", () => {
// openaiHelper.js:49-51,66-71 — empty content → {text:""} then filtered out
it("assistant message with only tool_calls is not dropped", () => {
const out = T(FORMATS.OPENAI, FORMATS.OPENAI, {
messages: [
{ role: "user", content: "do it" },
{ role: "assistant", content: "", tool_calls: [
{ id: "call_1", type: "function", function: { name: "f", arguments: "{}" } },
] },
{ role: "tool", tool_call_id: "call_1", content: "done" },
],
});
const asst = out.messages.find((m) => m.role === "assistant" && m.tool_calls);
expect(asst, "assistant tool_calls message dropped").toBeTruthy();
});
});
@@ -0,0 +1,65 @@
// OpenAI-format CLI → Claude provider. Context pollution + lossy mapping on the openai→claude leg.
import { describe, it, expect } from "vitest";
import "./registerAll.js";
import { translateRequest } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
// anthropic-compatible provider so prepareClaudeRequest runs the openai→claude path
const T = (body) =>
translateRequest(FORMATS.OPENAI, FORMATS.CLAUDE, "m", body, true, null, "anthropic-compatible-x");
describe("OpenAI → Claude context mapping", () => {
// openai-to-claude.js:124-134 — always injects CLAUDE_SYSTEM_PROMPT ("You are Claude Code")
// KNOWN BUG: pollutes requests for non-official Claude-compatible providers
it.fails("does not inject Claude Code system prompt for compatible providers", () => {
const out = T({ messages: [{ role: "user", content: "hi" }] });
expect(JSON.stringify(out.system), "Claude Code prompt injected").not.toContain("Claude Code");
});
// openai-to-claude.js:268-273 — assistant.reasoning_content not mapped to a thinking block
// KNOWN BUG
it.fails("assistant reasoning_content becomes a thinking block", () => {
const out = T({
messages: [
{ role: "user", content: "q" },
{ role: "assistant", content: "a", reasoning_content: "my hidden reasoning" },
{ role: "user", content: "next" },
],
});
expect(JSON.stringify(out), "reasoning_content lost").toContain("my hidden reasoning");
});
// openai-to-claude.js:298 — tool_choice "none" mapped to {type:"auto"} (loses "do not call" intent)
// KNOWN BUG
it.fails("tool_choice=none is not turned into auto", () => {
const out = T({
messages: [{ role: "user", content: "hi" }],
tools: [{ type: "function", function: { name: "f", parameters: { type: "object", properties: {} } } }],
tool_choice: "none",
});
expect(out.tool_choice?.type, "none became auto → model may call tools").not.toBe("auto");
});
// getContentBlocksFromMessage — no input_audio branch → audio dropped
// KNOWN BUG
it.fails("input_audio content is preserved", () => {
const out = T({
messages: [{ role: "user", content: [
{ type: "text", text: "transcribe" },
{ type: "input_audio", input_audio: { data: "AUDIO_B64", format: "wav" } },
] }],
});
expect(JSON.stringify(out), "audio dropped").toContain("AUDIO_B64");
});
// openai-to-claude.js:235-251 — remote http image_url is kept (regression guard)
it("remote http image_url is preserved", () => {
const out = T({
messages: [{ role: "user", content: [
{ type: "text", text: "see" },
{ type: "image_url", image_url: { url: "https://x.com/pic.png" } },
] }],
});
expect(JSON.stringify(out), "remote image dropped").toContain("pic.png");
});
});
@@ -0,0 +1,53 @@
// Tier 1 — Structural coverage: every model in PROVIDER_MODELS must translate
// without throwing, correct upstreamId, strip applied. Data-driven → new providers auto-covered.
import { describe, it, expect } from "vitest";
import "./registerAll.js";
import { translateRequest } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
import { buildProviderGroups, buildModelMatrix, resolveTargetFormat } from "./matrix.js";
// Base OpenAI-format request with text + tool + image (exercises strip + tool paths)
function baseBody(modelId) {
return {
model: modelId,
stream: true,
max_tokens: 64,
messages: [
{ role: "system", content: "You are a helper." },
{
role: "user",
content: [
{ type: "text", text: "Hello" },
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } },
],
},
],
tools: [
{ type: "function", function: { name: "get_time", description: "x", parameters: { type: "object", properties: {} } } },
],
};
}
const groups = buildProviderGroups();
describe("coverage: every model translates without throwing", () => {
it.each(groups)("$alias: all models OpenAI→target", ({ alias, models }) => {
for (const m of models) {
const target = resolveTargetFormat(alias, m.id);
const body = baseBody(m.id);
// source = openai (lingua franca); exercise openai → target path
const out = translateRequest(FORMATS.OPENAI, target, m.id, body, true, null, alias);
expect(out, `${alias}/${m.id}${target} returned falsy`).toBeTruthy();
}
});
});
const stripModels = buildModelMatrix().filter((r) => r.strip.includes("image"));
describe.skipIf(stripModels.length === 0)("coverage: image-strip models drop image content", () => {
it.each(stripModels)("$alias/$modelId strips image when strip=[image]", (row) => {
const body = baseBody(row.modelId);
const out = translateRequest(FORMATS.OPENAI, row.targetFormat, row.modelId, body, true, null, row.alias, null, row.strip);
const json = JSON.stringify(out);
expect(json).not.toContain("data:image/png");
});
});
+76
View File
@@ -0,0 +1,76 @@
// Tier 2 — Format-pair: for each CLI source format, translate to openai and verify
// core parts (text, tool, system) survive. Exposes bridge data loss.
import { describe, it, expect } from "vitest";
import "./registerAll.js";
import { translateRequest } from "../../open-sse/translator/index.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
const T = (src, tgt, body, provider = null) =>
translateRequest(src, tgt, "m", body, true, null, provider);
describe("roundtrip: Claude source preserves core fields → OpenAI", () => {
const body = {
system: "sys",
max_tokens: 100,
messages: [
{ role: "user", content: "question" },
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "search", input: { q: "x" } }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: "result" }] },
],
};
const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, body);
it("system → system role", () => {
expect(out.messages.some((m) => m.role === "system" && m.content === "sys")).toBe(true);
});
it("tool_use → assistant.tool_calls with matching id", () => {
const asst = out.messages.find((m) => m.tool_calls);
expect(asst?.tool_calls?.[0]?.id).toBe("call_1");
});
it("tool_result → tool message with matching id", () => {
const tool = out.messages.find((m) => m.role === "tool");
expect(tool?.tool_call_id).toBe("call_1");
expect(tool?.content).toContain("result");
});
it("tool arguments are valid JSON string", () => {
const asst = out.messages.find((m) => m.tool_calls);
expect(() => JSON.parse(asst.tool_calls[0].function.arguments)).not.toThrow();
});
});
describe("roundtrip: OpenAI tools → Claude → keeps tool name", () => {
const out = T(FORMATS.OPENAI, FORMATS.CLAUDE, {
messages: [{ role: "user", content: "hi" }],
tools: [{ type: "function", function: { name: "my_tool", description: "d", parameters: { type: "object", properties: {} } } }],
}, "anthropic-compatible-x");
it("tool name survives openai→claude", () => {
expect(JSON.stringify(out)).toContain("my_tool");
});
});
describe("roundtrip: parallel tool calls keep distinct ids", () => {
// Claude assistant with 2 parallel tool_use → openai must keep 2 distinct ids
const out = T(FORMATS.CLAUDE, FORMATS.OPENAI, {
messages: [
{ role: "assistant", content: [
{ type: "tool_use", id: "call_a", name: "f1", input: {} },
{ type: "tool_use", id: "call_b", name: "f2", input: {} },
] },
{ role: "user", content: [
{ type: "tool_result", tool_use_id: "call_a", content: "ra" },
{ type: "tool_result", tool_use_id: "call_b", content: "rb" },
] },
],
});
it("two tool_calls, two distinct ids", () => {
const asst = out.messages.find((m) => m.tool_calls);
const ids = asst.tool_calls.map((tc) => tc.id);
expect(new Set(ids).size).toBe(2);
});
it("each tool_call has a matching tool result", () => {
const toolMsgs = out.messages.filter((m) => m.role === "tool");
expect(toolMsgs.length).toBe(2);
});
});
+67
View File
@@ -0,0 +1,67 @@
// Data-driven test matrix built from PROVIDER_MODELS (single source of truth).
// Adding a new provider/model to config auto-extends coverage — no test edits needed.
import {
PROVIDER_MODELS,
PROVIDER_ID_TO_ALIAS,
getModelTargetFormat,
getModelStrip,
getModelUpstreamId,
} from "../../open-sse/config/providerModels.js";
import { PROVIDERS } from "../../open-sse/config/providers.js";
import { FORMATS } from "../../open-sse/translator/formats.js";
// Reverse alias → providerId to resolve provider-level config.format
const ALIAS_TO_PROVIDER_ID = Object.fromEntries(
Object.entries(PROVIDER_ID_TO_ALIAS).map(([id, alias]) => [alias, id])
);
// Provider-level format fallback (mirrors getTargetFormat without compat-url logic)
function providerFormat(alias) {
const providerId = ALIAS_TO_PROVIDER_ID[alias] || alias;
return PROVIDERS[providerId]?.format || FORMATS.OPENAI;
}
// Resolve effective target format for an (alias, model): model override → provider format
export function resolveTargetFormat(alias, modelId) {
return getModelTargetFormat(alias, modelId) || providerFormat(alias);
}
// Flat matrix of every model across every provider
export function buildModelMatrix() {
const rows = [];
for (const [alias, models] of Object.entries(PROVIDER_MODELS)) {
if (!Array.isArray(models)) continue;
for (const m of models) {
rows.push({
alias,
providerId: ALIAS_TO_PROVIDER_ID[alias] || alias,
modelId: m.id,
type: m.type || "llm",
targetFormat: resolveTargetFormat(alias, m.id),
strip: getModelStrip(alias, m.id),
upstreamId: getModelUpstreamId(alias, m.id),
});
}
}
return rows;
}
// Distinct provider list (one representative llm model each) for grouped assertions
export function buildProviderGroups() {
const groups = [];
for (const [alias, models] of Object.entries(PROVIDER_MODELS)) {
if (!Array.isArray(models) || models.length === 0) continue;
const llm = models.filter((m) => (m.type || "llm") === "llm");
groups.push({ alias, models: llm.length ? llm : models });
}
return groups;
}
// CLI source formats that real clients emit (the "specials" the user cares about)
export const CLI_SOURCE_FORMATS = [
FORMATS.CLAUDE,
FORMATS.OPENAI_RESPONSES,
FORMATS.GEMINI,
FORMATS.OPENAI,
FORMATS.ANTIGRAVITY,
];
@@ -0,0 +1,123 @@
// REAL integration smoke test: sends a tiny prompt to EVERY provider that has an
// active credential in the local DB, through the full production path (handleChatCore).
// Gated by RUN_REAL=1 so the default `vitest run` never touches the network.
//
// RUN_REAL=1 npx vitest run "tests/translator/real/"
//
// Each provider becomes its own test; providers without an llm model or without an
// active credential are skipped automatically.
import { describe, it, expect, beforeAll } from "vitest";
import { getProviderConnections } from "../../../src/lib/localDb.js";
import { getProviderCredentials } from "../../../src/sse/services/auth.js";
import { checkAndRefreshToken } from "../../../src/sse/services/tokenRefresh.js";
import { handleChatCore } from "../../../open-sse/handlers/chatCore.js";
import { getModelsByProviderId } from "../../../open-sse/config/providerModels.js";
const RUN_REAL = process.env.RUN_REAL === "1";
const MAX_TOKENS = 32;
const TIMEOUT_MS = 90000;
// Optional comma-separated filter: REAL_PROVIDERS=kiro,codex,antigravity
const PROVIDER_FILTER = (process.env.REAL_PROVIDERS || "")
.split(",").map((s) => s.trim()).filter(Boolean);
// Pick the first plain llm model for a provider (skip image/tts/embedding/etc).
function firstLlmModel(providerId) {
const models = getModelsByProviderId(providerId);
const llm = models.find((m) => (m.type || "llm") === "llm");
return llm?.id || null;
}
// Drain the full Web Response SSE body into raw text.
async function drainSSE(response) {
if (!response?.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let out = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
out += decoder.decode(value, { stream: true });
}
return out;
}
let providerIds = [];
beforeAll(async () => {
if (!RUN_REAL) return;
providerIds = targetProviders();
});
describe.skipIf(!RUN_REAL).concurrent("REAL provider smoke", () => {
it("has active providers in DB", () => {
expect(providerIds.length).toBeGreaterThan(0);
});
// One concurrent test per provider; resolved lazily inside the test.
for (const providerId of (RUN_REAL ? targetProviders() : [])) {
it.concurrent(
`${providerId}: responds to a short prompt`,
async () => {
const model = firstLlmModel(providerId);
if (!model) return expect(true).toBe(true); // no llm model → skip silently
const credentials = await getProviderCredentials(providerId, new Set(), model);
if (!credentials || credentials.allRateLimited) {
console.warn(`[skip] ${providerId}: no usable credential`);
return expect(true).toBe(true);
}
const refreshed = await checkAndRefreshToken(providerId, credentials);
const result = await handleChatCore({
body: {
model: `${providerId}/${model}`,
stream: true,
max_tokens: MAX_TOKENS,
messages: [{ role: "user", content: "Reply with the single word: hi" }],
},
modelInfo: { provider: providerId, model },
credentials: refreshed,
connectionId: credentials.connectionId,
});
if (!result.success) {
// Account/quota/auth problems are credential issues, not translation bugs → skip.
const credIssue = [401, 402, 403, 429].includes(Number(result.status));
if (credIssue) {
console.warn(`[skip] ${providerId}: ${result.status} (credential/quota)`);
return expect(true).toBe(true);
}
throw new Error(`${providerId} failed: ${result.status} ${result.error}`);
}
const raw = await drainSSE(result.response);
// Minimal sanity: got SSE data and a terminal signal or content.
expect(raw.length, `${providerId}: empty response`).toBeGreaterThan(0);
expect(/data:|finish_reason|"delta"|"content"|event:/.test(raw), `${providerId}: not SSE`).toBe(true);
},
TIMEOUT_MS
);
}
});
// Read the DB file directly (sync) at module-eval time so vitest can generate one
// test per provider before beforeAll runs. Applies REAL_PROVIDERS filter.
// Tolerates any failure (returns []).
function targetProviders() {
try {
const Database = require("better-sqlite3");
const os = require("os");
const path = require("path");
const dbPath = process.env.DATA_DIR
? path.join(process.env.DATA_DIR, "db", "data.sqlite")
: path.join(os.homedir(), ".9router", "db", "data.sqlite");
const db = new Database(dbPath, { readonly: true });
const rows = db.prepare("SELECT DISTINCT provider FROM providerConnections WHERE isActive = 1").all();
db.close();
let list = rows.map((r) => r.provider).sort();
if (PROVIDER_FILTER.length) list = list.filter((p) => PROVIDER_FILTER.includes(p));
return list;
} catch {
return [];
}
}
+22
View File
@@ -0,0 +1,22 @@
// Eagerly import every translator so register() side-effects run under ESM/vitest.
// translator/index.js uses require() (bundler-only) which no-ops in vitest → import directly.
import "../../open-sse/translator/request/claude-to-openai.js";
import "../../open-sse/translator/request/openai-to-claude.js";
import "../../open-sse/translator/request/gemini-to-openai.js";
import "../../open-sse/translator/request/openai-to-gemini.js";
import "../../open-sse/translator/request/openai-to-vertex.js";
import "../../open-sse/translator/request/antigravity-to-openai.js";
import "../../open-sse/translator/request/openai-responses.js";
import "../../open-sse/translator/request/openai-to-kiro.js";
import "../../open-sse/translator/request/openai-to-cursor.js";
import "../../open-sse/translator/request/openai-to-ollama.js";
import "../../open-sse/translator/request/openai-to-commandcode.js";
import "../../open-sse/translator/response/claude-to-openai.js";
import "../../open-sse/translator/response/openai-to-claude.js";
import "../../open-sse/translator/response/gemini-to-openai.js";
import "../../open-sse/translator/response/openai-to-antigravity.js";
import "../../open-sse/translator/response/openai-responses.js";
import "../../open-sse/translator/response/kiro-to-openai.js";
import "../../open-sse/translator/response/cursor-to-openai.js";
import "../../open-sse/translator/response/ollama-to-openai.js";
import "../../open-sse/translator/response/commandcode-to-openai.js";
+8 -6
View File
@@ -9,15 +9,17 @@ export default defineConfig({
environment: "node",
globals: true,
include: ["**/*.test.js"],
// Allow many it.concurrent cases (real provider smoke runs ~50 providers in parallel)
maxConcurrency: 60,
// Suppress noisy console output from handlers under test
silent: false,
},
resolve: {
alias: {
// Resolve open-sse/* imports to the actual local package
"open-sse": resolve(__dirname, "../open-sse"),
// Resolve @/* imports to src directory
"@": resolve(__dirname, "../src"),
},
// Use array form so subpath aliases (e.g. "@/lib/db/index.js") resolve correctly.
alias: [
{ find: /^open-sse\//, replacement: resolve(__dirname, "../open-sse") + "/" },
{ find: "open-sse", replacement: resolve(__dirname, "../open-sse") },
{ find: /^@\//, replacement: resolve(__dirname, "../src") + "/" },
],
},
});