mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-19 21:32:20 +03:00
* fix(sse): release native Codex turn pin when pinned model is model-scoped unusable (#13564) Long-running Codex sessions die with 400 NATIVE_CODEX_PINNED_MODEL_UNAVAILABLE whenever the model pinned to the current turn becomes model-scoped unusable mid-session (per-model quota lockout, connection cooldown, exhausted accounts). Claude Code has no equivalent pin and already falls back to the next healthy combo model; Codex now matches. Release the turn pin when all pinned provider+model targets are model-scoped unusable and fall through to full combo routing, re-pinning to whichever model succeeds. Preserve the pin on provider-wide outages (circuit breaker OPEN, provider cooldown) and when the pinned target is still healthy. Also prunes the stale ESLint suppression entry for combo.ts that this change orphaned (createPinnedModelUnavailableResponse import dropped; pre-existing getBootstrapLatencyMs remains the sole residual unused var). * fix(api): resolve codex-settings apiKey via canonical resolver instead of 400 (#13563) Applying Codex settings from /dashboard/cli-code/codex always failed with 400 "baseUrl, apiKey and model are required" when the dashboard sent an empty apiKey (cloud mode with no management key selected) — baseUrl and model are already Zod-gated, so that response could only ever fire on the empty key. The codex-settings route had diverged from the sibling CLI tools (cline/forge/ openclaw/grok-build/jcode): an inline if(!apiKey) 400 guard plus a hand-rolled getApiKeyById lookup, instead of the shared resolveApiKey(keyId, apiKey) helper which resolves by keyId, falls back to the submitted apiKey, then to sk_omniroute. This change makes codex-settings use the canonical resolver, so: - empty apiKey + valid keyId -> the real DB key is written to auth.json - empty apiKey + no keyId -> sk_omniroute default (config still applies) - explicit apiKey -> written verbatim (unchanged) * docs(changelog): add fragments for Codex turn-pin fallback and codex-settings apiKey resolution
116 lines
4.5 KiB
TypeScript
116 lines
4.5 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { SignJWT } from "jose";
|
|
|
|
/**
|
|
* Regression test for #13563.
|
|
*
|
|
* Applying Codex settings from /dashboard/cli-code/codex always failed with
|
|
* `400 baseUrl, apiKey and model are required` whenever the dashboard sent an
|
|
* empty apiKey — which it does in cloud mode (CLOUD_URL set) with no management
|
|
* key selected. `baseUrl` and `model` are already Zod-gated (min(1)), so this
|
|
* response could only ever fire on an empty apiKey, yet the error text points
|
|
* at all three fields.
|
|
*
|
|
* The codex-settings route had diverged from the canonical API-key resolution
|
|
* used by cline/forge/openclaw/grok-build/jcode-settings: an inline
|
|
* `if (!apiKey) return 400` guard plus a hand-rolled `getApiKeyById` lookup,
|
|
* instead of the shared `resolveApiKey(keyId, apiKey)`. That helper resolves by
|
|
* keyId first, then falls back to the submitted apiKey, then to `sk_omniroute`.
|
|
*
|
|
* This test drives the real POST handler end-to-end (real DB-backed API key,
|
|
* real JWT auth cookie) and asserts the value written into auth.json.
|
|
*/
|
|
|
|
const TEST_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "omr-codex-settings-key-"));
|
|
const TEST_HOME = path.join(TEST_ROOT, "fake-home");
|
|
fs.mkdirSync(TEST_HOME, { recursive: true });
|
|
|
|
const originalHome = os.homedir;
|
|
const originalDataDir = process.env.DATA_DIR;
|
|
const originalJwtSecret = process.env.JWT_SECRET;
|
|
const originalWriteFlag = process.env.CLI_ALLOW_CONFIG_WRITES;
|
|
|
|
os.homedir = () => TEST_HOME;
|
|
process.env.DATA_DIR = path.join(TEST_ROOT, "data");
|
|
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "codex-settings-key-api-secret";
|
|
process.env.CLI_ALLOW_CONFIG_WRITES = "true";
|
|
|
|
const core = await import("../../src/lib/db/core.ts");
|
|
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
|
const route = await import("../../src/app/api/cli-tools/codex-settings/route.ts");
|
|
|
|
const AUTH_PATH = path.join(TEST_HOME, ".codex", "auth.json");
|
|
|
|
test.after(async () => {
|
|
core.resetDbInstance();
|
|
os.homedir = originalHome;
|
|
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
|
else process.env.DATA_DIR = originalDataDir;
|
|
if (originalJwtSecret === undefined) delete process.env.JWT_SECRET;
|
|
else process.env.JWT_SECRET = originalJwtSecret;
|
|
if (originalWriteFlag === undefined) delete process.env.CLI_ALLOW_CONFIG_WRITES;
|
|
else process.env.CLI_ALLOW_CONFIG_WRITES = originalWriteFlag;
|
|
fs.rmSync(TEST_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
});
|
|
|
|
const authCookie = async (): Promise<string> => {
|
|
process.env.JWT_SECRET = "codex-settings-key-api-jwt";
|
|
const token = await new SignJWT({ authenticated: true, sub: "codex-settings-key-api-test" })
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setExpirationTime("1h")
|
|
.sign(new TextEncoder().encode(process.env.JWT_SECRET));
|
|
return `auth_token=${token}`;
|
|
};
|
|
|
|
const post = async (body: Record<string, unknown>) =>
|
|
route.POST(
|
|
new Request("http://localhost/api/cli-tools/codex-settings", {
|
|
method: "POST",
|
|
headers: {
|
|
cookie: await authCookie(),
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
baseUrl: "http://localhost:20128/api/v1",
|
|
model: "gpt-5.6-sol",
|
|
...body,
|
|
}),
|
|
})
|
|
);
|
|
|
|
const readWrittenApiKey = (): string | null => {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(AUTH_PATH, "utf8")).OPENAI_API_KEY ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
test("#13563: POST codex-settings with empty apiKey resolves the real key from keyId instead of 400", async () => {
|
|
const created = await apiKeysDb.createApiKey("codex-settings-key-test", "codex-settings-machine");
|
|
assert.ok(created.key && created.key.length > 0, "createApiKey must return a real plaintext key");
|
|
|
|
const response = await post({ apiKey: "", keyId: created.id });
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(readWrittenApiKey(), created.key);
|
|
});
|
|
|
|
test("#13563: POST codex-settings with empty apiKey and no keyId writes sk_omniroute instead of 400", async () => {
|
|
const response = await post({ apiKey: "" });
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(readWrittenApiKey(), "sk_omniroute");
|
|
});
|
|
|
|
test("#13563: POST codex-settings with an explicit apiKey still writes it verbatim", async () => {
|
|
const response = await post({ apiKey: "sk-test-explicit" });
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(readWrittenApiKey(), "sk-test-explicit");
|
|
});
|