mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 13:23:50 +03:00
Match the upstream OpenCode free-tier request contract (issue #13935): canonical ses_/msg_ identity ids, versioned User-Agent, and the measured body requirements (stream:true + non-empty tools) with a learn-and-reuse tool-name cache, so no-auth oc/* requests stop being refused with 403 FreeTierError. Supersedes #13937 (session regex and minimum-version rule kept, credited below). Complements #14011 (refusal classification) and #13819 (stream_options strip), both already merged. Closes #13935 Co-authored-by: AStupidBear <16422976+AStupidBear@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
217 lines
9.4 KiB
TypeScript
217 lines
9.4 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { OpencodeExecutor } from "../../open-sse/executors/opencode.ts";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// OpencodeExecutor.buildHeaders — request format auth switch
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("OpencodeExecutor.buildHeaders: default format uses Bearer Authorization", () => {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
// _requestFormat defaults to null → default Bearer path
|
|
const headers = executor.buildHeaders({ apiKey: "sk-oc-1" }, true);
|
|
assert.equal(headers["Authorization"], "Bearer sk-oc-1");
|
|
assert.equal(headers["x-api-key"], undefined);
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: claude format uses x-api-key header", () => {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
executor._requestFormat = "claude";
|
|
const headers = executor.buildHeaders({ apiKey: "sk-claude-1" }, true);
|
|
assert.equal(headers["x-api-key"], "sk-claude-1");
|
|
assert.equal(headers["Authorization"], undefined);
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: claude format sets anthropic-version header", () => {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
executor._requestFormat = "claude";
|
|
const headers = executor.buildHeaders({ apiKey: "key-1" }, true);
|
|
assert.equal(headers["anthropic-version"], "2023-06-01");
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: non-claude format omits anthropic-version", () => {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
executor._requestFormat = "openai";
|
|
const headers = executor.buildHeaders({ apiKey: "key-1" }, true);
|
|
assert.equal(headers["anthropic-version"], undefined);
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: stream=true sets Accept text/event-stream", () => {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
const headers = executor.buildHeaders({ apiKey: "key-1" }, true);
|
|
assert.equal(headers["Accept"], "text/event-stream");
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: stream=false omits Accept header", () => {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
const headers = executor.buildHeaders({ apiKey: "key-1" }, false);
|
|
assert.equal(headers["Accept"], undefined);
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: uses accessToken when apiKey is absent", () => {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
const headers = executor.buildHeaders({ accessToken: "tok-oc" }, true);
|
|
assert.equal(headers["Authorization"], "Bearer tok-oc");
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: apiKey takes precedence over accessToken", () => {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
const headers = executor.buildHeaders({ apiKey: "sk-pri", accessToken: "tok-sec" }, true);
|
|
assert.equal(headers["Authorization"], "Bearer sk-pri");
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: claude format with accessToken still uses x-api-key", () => {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
executor._requestFormat = "claude";
|
|
const headers = executor.buildHeaders({ apiKey: "sk-a", accessToken: "tok-b" }, true);
|
|
assert.equal(headers["x-api-key"], "sk-a");
|
|
assert.equal(headers["Authorization"], undefined);
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: Content-Type always application/json", () => {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
const headers = executor.buildHeaders({ apiKey: "key-1" }, true);
|
|
assert.equal(headers["Content-Type"], "application/json");
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: omits User-Agent when no client UA and synthesis is explicitly off", () => {
|
|
// Forward-only contract (see opencode-executor.test.ts) when the operator opts OUT via
|
|
// OPENCODE_SYNTHESIZE_CLI_HEADERS=false. PR #10571 flipped the default to ON (see
|
|
// tests/unit/opencode-cli-headers-synthesis-5997.test.ts) — the forward-only path is now
|
|
// opt-out rather than the default.
|
|
const saved = process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS;
|
|
process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS = "false";
|
|
try {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
const headers = executor.buildHeaders({ apiKey: "key-1" }, true);
|
|
assert.equal(headers["User-Agent"], undefined);
|
|
} finally {
|
|
if (saved === undefined) delete process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS;
|
|
else process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS = saved;
|
|
}
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: preserves a client User-Agent that satisfies the upstream contract", () => {
|
|
// The rule is now the upstream one: a UA carrying `opencode/<version >= 1.17>` is kept,
|
|
// anything else is replaced by the synthesized default. `opencode-cli/…` carries no
|
|
// parsable version, and the free tier refuses it, so it is no longer preserved.
|
|
const executor = new OpencodeExecutor("opencode");
|
|
const kept = executor.buildHeaders({ apiKey: "key-1" }, true, {
|
|
"User-Agent": "opencode/1.17.12",
|
|
});
|
|
assert.equal(kept["User-Agent"], "opencode/1.17.12");
|
|
|
|
const replaced = executor.buildHeaders({ apiKey: "key-1" }, true, {
|
|
"User-Agent": "opencode-cli/1.17.12",
|
|
});
|
|
assert.notEqual(replaced["User-Agent"], "opencode-cli/1.17.12");
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: omits x-opencode-client when absent and synthesis is explicitly off", () => {
|
|
// x-opencode-client / x-opencode-project fabrication is opt-out (see above) since #10571.
|
|
const saved = process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS;
|
|
process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS = "false";
|
|
try {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
const headers = executor.buildHeaders({ apiKey: "key-1" }, true);
|
|
assert.equal(headers["x-opencode-client"], undefined);
|
|
} finally {
|
|
if (saved === undefined) delete process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS;
|
|
else process.env.OPENCODE_SYNTHESIZE_CLI_HEADERS = saved;
|
|
}
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: preserves x-opencode-client from client headers", () => {
|
|
const executor = new OpencodeExecutor("opencode");
|
|
const headers = executor.buildHeaders({ apiKey: "key-1" }, true, {
|
|
"x-opencode-client": "desktop",
|
|
});
|
|
assert.equal(headers["x-opencode-client"], "desktop");
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// #8467 — Extra API Keys rotation (resolveEffectiveKey)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
test("OpencodeExecutor.buildHeaders: rotates extra API keys (Bearer)", () => {
|
|
const executor = new OpencodeExecutor("opencode-zen");
|
|
const credentials = {
|
|
apiKey: "primary-key",
|
|
connectionId: "opencode-rotation-bearer",
|
|
providerSpecificData: { extraApiKeys: ["extra-key"] } as Record<string, unknown>,
|
|
};
|
|
|
|
// Clear sticky selectedKeyId between calls so getValidApiKey round-robin is exercised.
|
|
const seen = new Set<string>();
|
|
for (let i = 0; i < 4; i++) {
|
|
delete credentials.providerSpecificData.selectedKeyId;
|
|
const headers = executor.buildHeaders(credentials, true);
|
|
const token = headers["Authorization"]?.replace(/^Bearer /, "");
|
|
assert.ok(token === "primary-key" || token === "extra-key", `unexpected token: ${token}`);
|
|
seen.add(token ?? "");
|
|
}
|
|
assert.ok(seen.has("primary-key"));
|
|
assert.ok(seen.has("extra-key"));
|
|
assert.ok(
|
|
typeof credentials.providerSpecificData.selectedKeyId === "string" &&
|
|
credentials.providerSpecificData.selectedKeyId.length > 0,
|
|
"selectedKeyId should be persisted after rotation"
|
|
);
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: rotates extra API keys (claude x-api-key)", () => {
|
|
const executor = new OpencodeExecutor("opencode-zen");
|
|
executor._requestFormat = "claude";
|
|
const credentials = {
|
|
apiKey: "primary-key",
|
|
connectionId: "opencode-rotation-claude",
|
|
providerSpecificData: { extraApiKeys: ["extra-key"] } as Record<string, unknown>,
|
|
};
|
|
|
|
const seen = new Set<string>();
|
|
for (let i = 0; i < 4; i++) {
|
|
delete credentials.providerSpecificData.selectedKeyId;
|
|
const headers = executor.buildHeaders(credentials, true);
|
|
const key = headers["x-api-key"];
|
|
assert.ok(key === "primary-key" || key === "extra-key", `unexpected x-api-key: ${key}`);
|
|
seen.add(key ?? "");
|
|
}
|
|
assert.ok(seen.has("primary-key"));
|
|
assert.ok(seen.has("extra-key"));
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: empty primary + extras still sends Authorization", () => {
|
|
const executor = new OpencodeExecutor("opencode-go");
|
|
const headers = executor.buildHeaders(
|
|
{
|
|
apiKey: "",
|
|
connectionId: "opencode-empty-primary",
|
|
providerSpecificData: { extraApiKeys: ["only-extra-key"] },
|
|
},
|
|
true
|
|
);
|
|
assert.equal(headers["Authorization"], "Bearer only-extra-key");
|
|
});
|
|
|
|
test("OpencodeExecutor.buildHeaders: #8467 guard — override uses resolveEffectiveKey path", async () => {
|
|
// Source-level guard: OpencodeExecutor overrides buildHeaders and must not
|
|
// reintroduce a direct credentials.apiKey read that bypasses extra-keys rotation.
|
|
const fs = await import("node:fs");
|
|
const path = await import("node:path");
|
|
const { fileURLToPath } = await import("node:url");
|
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
const source = fs.readFileSync(
|
|
path.resolve(here, "../../open-sse/executors/opencode.ts"),
|
|
"utf8"
|
|
);
|
|
const buildHeadersStart = source.indexOf("buildHeaders(");
|
|
assert.ok(buildHeadersStart >= 0);
|
|
const buildHeadersBody = source.slice(buildHeadersStart, buildHeadersStart + 1200);
|
|
assert.match(buildHeadersBody, /resolveEffectiveKey\s*\(/);
|
|
assert.doesNotMatch(
|
|
buildHeadersBody,
|
|
/credentials\?\.apiKey\s*\|\|\s*credentials\?\.accessToken/
|
|
);
|
|
});
|