Files
OmniRoute/tests/unit/refactor-buildHeaders-opencode.test.ts
CyrixJD115 9222528bdd fix(opencode): session stability, free-tier routing, and CLI defaults (#10571)
* fix(opencode): session stability, free-tier routing, and CLI defaults

- Wire generateSessionId() into opencodeHeaders so x-opencode-session
  is a deterministic fingerprint instead of randomUUID() per request,
  enabling upstream prompt caching across a conversation
- Thread request body through buildHeaders() so session fingerprint
  has access to model, system, messages, and tools
- Default CLI header synthesis to ON (opt-out via false), align
  values with 9router proven defaults (opencode/desktop/global)
- Auto-echo listing-valid model names for noAuth providers so
  response.model matches /v1/models listing
- Short-circuit free-tier model resolution to opencode provider first
  to prevent prefix inference misrouting when catalog is unreachable

* fix(opencode): make free-tier default flip self-consistent + add coverage

PR #10571 flipped OPENCODE_SYNTHESIZE_CLI_HEADERS to on-by-default and
changed the synthesized UA/client/project default values, but shipped
with 2 broken assertions in the existing #5997 regression test and no
coverage for the new session-fingerprinting, free-tier routing, or
noAuth echoModel logic (Hard Rule #18).

- Update tests/unit/opencode-cli-headers-synthesis-5997.test.ts to match
  the new on-by-default behavior and new default values; add an explicit
  opt-out coverage test so the forward-only path is still guarded.
- Fix 20 further test failures in tests/unit/opencode-executor.test.ts
  and tests/unit/refactor-buildHeaders-opencode.test.ts caused by the
  same default flip (pin OPENCODE_SYNTHESIZE_CLI_HEADERS=false for the
  characterization suites that predate #10571; use a genuinely
  CLI-looking UA where the preserved-UA test requires one).
- Fix a real bug found via TDD while adding the mandated free-tier
  routing regression test: the big-pickle/*-free short-circuit in
  open-sse/services/model.ts checked activeProviders?.has("opencode")
  literally, but getActiveProviderSet() canonicalizes every connection's
  provider id through resolveProviderAlias(), which rewrites "opencode"
  to "opencode-zen" via a manual override — so an active no-auth
  opencode connection could never satisfy the check. Now checks both
  opencode-family candidate ids. Proven with a test that fails on the
  original code and passes with the fix (both connections active with a
  stale synced catalog omitting big-pickle).
- Extract the noAuth-provider echoModel aliasing in chatCore.ts into a
  pure, directly-testable helper (open-sse/handlers/chatCore/noAuthEchoModel.ts),
  matching the existing chatCore god-file decomposition pattern.
- Add regression tests for generateSessionId()-based x-opencode-session
  fingerprinting (stable within a conversation, changes on model/message
  changes), the free-tier routing short-circuit, and the noAuth echoModel
  aliasing.
- Add the changelog.d/ fragment and sync docs/reference/ENVIRONMENT.md's
  OPENCODE_SYNTHESIZE_CLI_HEADERS/OPENCODE_USER_AGENT/OPENCODE_CLIENT/
  OPENCODE_PROJECT rows to the new defaults.

Does NOT resolve whether flipping OPENCODE_SYNTHESIZE_CLI_HEADERS's
default was the right call, and does NOT touch the separate open PR
#10357 which flips the same flag with a different literal default value
- that decision is left to the maintainer at merge time.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:52:33 -03:00

213 lines
9.3 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 an opencode-cli-like client User-Agent when provided", () => {
// Since #10571 flips CLI-header synthesis to on-by-default, a non-CLI-looking client UA
// (e.g. "opencode/1.17.12") is now REPLACED by the synthesized default (see the
// #5997/#10571 non-CLI-UA-replaced test in opencode-cli-headers-synthesis-5997.test.ts).
// Only a UA that already looks like the real OpenCode CLI ("opencode-cli/…") is preserved.
const executor = new OpencodeExecutor("opencode");
const headers = executor.buildHeaders({ apiKey: "key-1" }, true, {
"User-Agent": "opencode-cli/1.17.12",
});
assert.equal(headers["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/
);
});