mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
Integrated into release/v3.8.36 (fixes #4683)
This commit is contained in:
committed by
GitHub
parent
b1b3069cd1
commit
cc8557cedf
@@ -11,7 +11,7 @@ import {
|
||||
QODER_DEFAULT_USER_AGENT,
|
||||
} from "../config/providerHeaderProfiles.ts";
|
||||
import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts";
|
||||
import { buildCosyHeadersForValidation } from "../services/qoderCli.ts";
|
||||
import { buildCosyHeadersForValidation, resolveQoderJobToken } from "../services/qoderCli.ts";
|
||||
import { sanitizeErrorMessage } from "../utils/error.ts";
|
||||
|
||||
function getAuthToken(credentials: ProviderCredentials): string {
|
||||
@@ -131,7 +131,10 @@ export class QoderExecutor extends BaseExecutor {
|
||||
// PAT tokens (pt-*) are not accepted as Bearer tokens by api.qoder.com/v1/chat/completions.
|
||||
// They return 401 TOKEN_INVALID. Fallback to Cosy auth against api1.qoder.sh.
|
||||
if (!response.ok && response.status === 401 && isPatToken) {
|
||||
const cosyHeaders = buildCosyHeadersForValidation(bodyStr, token);
|
||||
// #4683: exchange the PAT (pt-*) for a job token (jt-*) before the Cosy call;
|
||||
// Cosy rejects a raw pt-* in security_oauth_token with a generic 500.
|
||||
const cosyToken = await resolveQoderJobToken(token, { signal });
|
||||
const cosyHeaders = buildCosyHeadersForValidation(bodyStr, cosyToken);
|
||||
const cosyEndpoint =
|
||||
"https://api1.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?AgentId=agent_common";
|
||||
const cosyRes = await fetch(cosyEndpoint, {
|
||||
|
||||
@@ -393,6 +393,109 @@ export function buildCosyHeadersForValidation(bodyStr: string, token: string) {
|
||||
};
|
||||
}
|
||||
|
||||
// #4683: Qoder PATs (`pt-*`) cannot be used directly as the Cosy
|
||||
// `security_oauth_token`. The official qodercli performs a two-step flow: it first
|
||||
// exchanges the PAT for a short-lived job token (`jt-*`) at
|
||||
// `openapi.qoder.sh/api/v1/jobToken/exchange`, then carries that `jt-*` in the Cosy
|
||||
// envelope for chat. Passing the raw `pt-*` makes Cosy return a generic 500, which
|
||||
// OmniRoute mis-surfaced as "PAT may not be valid for the chat API". We mirror the
|
||||
// exchange here and cache the `jt-*` for its lifetime.
|
||||
const QODER_JOB_TOKEN_EXCHANGE_URL = "https://openapi.qoder.sh/api/v1/jobToken/exchange";
|
||||
// Refresh a little before the ~24h expiry to avoid using a just-expired token.
|
||||
const QODER_JOB_TOKEN_DEFAULT_TTL_MS = 23 * 60 * 60 * 1000;
|
||||
const QODER_JOB_TOKEN_MIN_TTL_MS = 60 * 1000;
|
||||
|
||||
type QoderJobTokenCacheEntry = { jobToken: string; expiresAt: number };
|
||||
const qoderJobTokenCache = new Map<string, QoderJobTokenCacheEntry>();
|
||||
|
||||
type FetchLike = (input: string, init?: Record<string, unknown>) => Promise<Response>;
|
||||
|
||||
/** A Qoder Personal Access Token is the only credential that needs the exchange. */
|
||||
export function isQoderPatToken(token: string): boolean {
|
||||
return typeof token === "string" && token.trim().startsWith("pt-");
|
||||
}
|
||||
|
||||
/** Pull a `jt-*` job token out of the (loosely-specified) exchange response. */
|
||||
export function parseQoderJobTokenResponse(json: unknown): {
|
||||
jobToken: string;
|
||||
expiresInMs: number;
|
||||
} | null {
|
||||
const root = asRecord(json);
|
||||
const data = asRecord(root.data);
|
||||
const candidates = [
|
||||
root.job_token,
|
||||
root.jobToken,
|
||||
root.jt,
|
||||
root.token,
|
||||
data.job_token,
|
||||
data.jobToken,
|
||||
data.jt,
|
||||
data.token,
|
||||
];
|
||||
const jobToken = candidates.map(getString).find((v) => v.trim().startsWith("jt-")) || "";
|
||||
if (!jobToken) return null;
|
||||
|
||||
const expiresRaw = [root.expires_in, root.expiresIn, data.expires_in, data.expiresIn].find(
|
||||
(v) => typeof v === "number" && Number.isFinite(v) && (v as number) > 0
|
||||
) as number | undefined;
|
||||
// Qoder reports expiry in seconds; fall back to the default ~24h window.
|
||||
const expiresInMs = expiresRaw ? expiresRaw * 1000 : QODER_JOB_TOKEN_DEFAULT_TTL_MS;
|
||||
return { jobToken, expiresInMs: Math.max(expiresInMs, QODER_JOB_TOKEN_MIN_TTL_MS) };
|
||||
}
|
||||
|
||||
/** Exchange a `pt-*` PAT for a short-lived `jt-*` job token (no caching). */
|
||||
export async function exchangeQoderJobToken(
|
||||
pat: string,
|
||||
options: { fetchImpl?: FetchLike; signal?: AbortSignal | null } = {}
|
||||
): Promise<{ jobToken: string; expiresInMs: number } | null> {
|
||||
const fetchImpl = options.fetchImpl || (fetch as unknown as FetchLike);
|
||||
const res = await fetchImpl(QODER_JOB_TOKEN_EXCHANGE_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ personal_token: pat }),
|
||||
signal: options.signal || AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res || !res.ok) return null;
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return parseQoderJobTokenResponse(json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the token to carry in the Cosy envelope. For a `pt-*` PAT this returns a
|
||||
* cached/freshly-exchanged `jt-*` job token; any other token (already a `jt-*`, or a
|
||||
* non-PAT credential) is returned unchanged. Exchange failures fall back to the
|
||||
* original token so behavior is no worse than before the fix.
|
||||
*/
|
||||
export async function resolveQoderJobToken(
|
||||
token: string,
|
||||
options: { fetchImpl?: FetchLike; signal?: AbortSignal | null; now?: number } = {}
|
||||
): Promise<string> {
|
||||
const trimmed = (token || "").trim();
|
||||
if (!isQoderPatToken(trimmed)) return trimmed;
|
||||
|
||||
const now = options.now ?? Date.now();
|
||||
const cached = qoderJobTokenCache.get(trimmed);
|
||||
if (cached && cached.expiresAt > now) return cached.jobToken;
|
||||
|
||||
const exchanged = await exchangeQoderJobToken(trimmed, options);
|
||||
if (!exchanged) return trimmed; // graceful fallback — keep prior behavior
|
||||
qoderJobTokenCache.set(trimmed, {
|
||||
jobToken: exchanged.jobToken,
|
||||
expiresAt: now + exchanged.expiresInMs,
|
||||
});
|
||||
return exchanged.jobToken;
|
||||
}
|
||||
|
||||
/** Test-only: clear the job-token cache so unit tests don't leak state. */
|
||||
export function __clearQoderJobTokenCache(): void {
|
||||
qoderJobTokenCache.clear();
|
||||
}
|
||||
|
||||
export async function validateQoderCliPat({
|
||||
apiKey,
|
||||
providerSpecificData = {},
|
||||
@@ -461,8 +564,10 @@ export async function validateQoderCliPat({
|
||||
};
|
||||
}
|
||||
|
||||
// Step 2: Auth validation — send a minimal request with the PAT
|
||||
const headers = buildCosyHeadersForValidation(bodyStr, resolvedToken);
|
||||
// Step 2: Auth validation — exchange the PAT for a job token (#4683), then send a
|
||||
// minimal request with the `jt-*` (Cosy rejects a raw `pt-*` with a generic 500).
|
||||
const cosyToken = await resolveQoderJobToken(resolvedToken);
|
||||
const headers = buildCosyHeadersForValidation(bodyStr, cosyToken);
|
||||
const endpoint =
|
||||
"https://api1.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?AgentId=agent_common";
|
||||
|
||||
|
||||
@@ -285,18 +285,26 @@ test("QoderExecutor: PAT token falls back to Cosy auth when Bearer returns 401",
|
||||
|
||||
globalThis.fetch = async (url, options) => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
const u = String(url);
|
||||
if (u === "https://api.qoder.com/v1/chat/completions") {
|
||||
// First call to api.qoder.com returns 401 TOKEN_INVALID
|
||||
assert.equal(String(url), "https://api.qoder.com/v1/chat/completions");
|
||||
assert.equal(options.headers.Authorization, "Bearer pt-0pUI-test-token");
|
||||
return new Response(JSON.stringify({ code: "TOKEN_INVALID", message: "invalid apikey" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
// Second call to api1.qoder.sh (Cosy fallback) returns SSE response
|
||||
assert.ok(String(url).includes("api1.qoder.sh"));
|
||||
assert.ok(String(url).includes("agent_chat_generation"));
|
||||
if (u.includes("/jobToken/exchange")) {
|
||||
// #4683: the PAT is exchanged for a short-lived jt-* job token before the Cosy call.
|
||||
assert.deepEqual(JSON.parse(String(options.body)), { personal_token: "pt-0pUI-test-token" });
|
||||
return new Response(JSON.stringify({ job_token: "jt-from-exchange", expires_in: 86400 }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
// Cosy fallback call to api1.qoder.sh returns SSE response
|
||||
assert.ok(u.includes("api1.qoder.sh"));
|
||||
assert.ok(u.includes("agent_chat_generation"));
|
||||
assert.ok(options.headers["Cosy-Key"]);
|
||||
assert.ok(options.headers["Cosy-User"]);
|
||||
assert.ok(options.headers["Cosy-Date"]);
|
||||
@@ -315,7 +323,11 @@ test("QoderExecutor: PAT token falls back to Cosy auth when Bearer returns 401",
|
||||
credentials: { apiKey: "pt-0pUI-test-token" },
|
||||
});
|
||||
|
||||
assert.equal(callCount, 2, "Should have made 2 fetch calls (1 Bearer + 1 Cosy)");
|
||||
assert.equal(
|
||||
callCount,
|
||||
3,
|
||||
"Should have made 3 fetch calls (1 Bearer + 1 jobToken exchange + 1 Cosy)"
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.object, "chat.completion");
|
||||
|
||||
129
tests/unit/qoder-jobtoken-exchange-4683.test.ts
Normal file
129
tests/unit/qoder-jobtoken-exchange-4683.test.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #4683: Qoder PAT (`pt-*`) chat requests failed with a Cosy 500 because OmniRoute
|
||||
// injected the raw `pt-*` PAT into the Cosy `security_oauth_token`. The official
|
||||
// qodercli uses a TWO-step flow: exchange the PAT for a short-lived `jt-*` job token
|
||||
// at openapi.qoder.sh/api/v1/jobToken/exchange, then carry the `jt-*` in the Cosy
|
||||
// envelope. These tests assert the exchange now happens and the `jt-*` is what flows
|
||||
// downstream — the raw `pt-*` must never be the Cosy token anymore.
|
||||
|
||||
const {
|
||||
parseQoderJobTokenResponse,
|
||||
exchangeQoderJobToken,
|
||||
resolveQoderJobToken,
|
||||
isQoderPatToken,
|
||||
validateQoderCliPat,
|
||||
__clearQoderJobTokenCache,
|
||||
} = await import("../../open-sse/services/qoderCli.ts");
|
||||
|
||||
function jsonResponse(body: unknown, init: { ok?: boolean; status?: number } = {}) {
|
||||
return {
|
||||
ok: init.ok ?? true,
|
||||
status: init.status ?? 200,
|
||||
json: async () => body,
|
||||
text: async () => (typeof body === "string" ? body : JSON.stringify(body)),
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
test("#4683 isQoderPatToken only matches pt-* tokens", () => {
|
||||
assert.equal(isQoderPatToken("pt-abc"), true);
|
||||
assert.equal(isQoderPatToken("jt-abc"), false);
|
||||
assert.equal(isQoderPatToken("sk-abc"), false);
|
||||
assert.equal(isQoderPatToken(""), false);
|
||||
});
|
||||
|
||||
test("#4683 parseQoderJobTokenResponse extracts jt-* across response shapes", () => {
|
||||
assert.equal(parseQoderJobTokenResponse({ job_token: "jt-1" })?.jobToken, "jt-1");
|
||||
assert.equal(parseQoderJobTokenResponse({ data: { jobToken: "jt-2" } })?.jobToken, "jt-2");
|
||||
// expires_in is reported in seconds -> milliseconds.
|
||||
assert.equal(
|
||||
parseQoderJobTokenResponse({ job_token: "jt-3", expires_in: 86400 })?.expiresInMs,
|
||||
86400 * 1000
|
||||
);
|
||||
// No jt-* anywhere -> null.
|
||||
assert.equal(parseQoderJobTokenResponse({ token: "pt-nope" }), null);
|
||||
assert.equal(parseQoderJobTokenResponse(null), null);
|
||||
});
|
||||
|
||||
test("#4683 exchangeQoderJobToken POSTs the PAT to the exchange endpoint", async () => {
|
||||
const calls: { url: string; body: unknown }[] = [];
|
||||
const fetchImpl = async (url: string, init?: Record<string, unknown>) => {
|
||||
calls.push({ url, body: JSON.parse(String(init?.body ?? "{}")) });
|
||||
return jsonResponse({ job_token: "jt-from-exchange", expires_in: 86400 });
|
||||
};
|
||||
|
||||
const result = await exchangeQoderJobToken("pt-secret", { fetchImpl });
|
||||
assert.equal(result?.jobToken, "jt-from-exchange");
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].url, /openapi\.qoder\.sh\/api\/v1\/jobToken\/exchange/);
|
||||
assert.deepEqual(calls[0].body, { personal_token: "pt-secret" });
|
||||
});
|
||||
|
||||
test("#4683 resolveQoderJobToken exchanges a pt-* once and caches the jt-*", async () => {
|
||||
__clearQoderJobTokenCache();
|
||||
let fetchCount = 0;
|
||||
const fetchImpl = async () => {
|
||||
fetchCount += 1;
|
||||
return jsonResponse({ job_token: "jt-cached", expires_in: 86400 });
|
||||
};
|
||||
|
||||
const first = await resolveQoderJobToken("pt-x", { fetchImpl, now: 1_000 });
|
||||
const second = await resolveQoderJobToken("pt-x", { fetchImpl, now: 2_000 });
|
||||
assert.equal(first, "jt-cached");
|
||||
assert.equal(second, "jt-cached");
|
||||
assert.equal(fetchCount, 1, "second resolve must hit the cache, not re-exchange");
|
||||
__clearQoderJobTokenCache();
|
||||
});
|
||||
|
||||
test("#4683 resolveQoderJobToken passes a jt-* through without exchanging", async () => {
|
||||
__clearQoderJobTokenCache();
|
||||
let fetchCount = 0;
|
||||
const fetchImpl = async () => {
|
||||
fetchCount += 1;
|
||||
return jsonResponse({ job_token: "jt-unused" });
|
||||
};
|
||||
const resolved = await resolveQoderJobToken("jt-already", { fetchImpl });
|
||||
assert.equal(resolved, "jt-already");
|
||||
assert.equal(fetchCount, 0);
|
||||
});
|
||||
|
||||
test("#4683 resolveQoderJobToken falls back to the PAT when the exchange fails", async () => {
|
||||
__clearQoderJobTokenCache();
|
||||
const fetchImpl = async () => jsonResponse({ error: "nope" }, { ok: false, status: 500 });
|
||||
const resolved = await resolveQoderJobToken("pt-y", { fetchImpl });
|
||||
assert.equal(resolved, "pt-y", "graceful fallback keeps prior behavior");
|
||||
__clearQoderJobTokenCache();
|
||||
});
|
||||
|
||||
test("#4683 validateQoderCliPat performs the jobToken exchange before the Cosy chat call", async () => {
|
||||
__clearQoderJobTokenCache();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const urls: string[] = [];
|
||||
// @ts-ignore - test stub
|
||||
globalThis.fetch = async (url: string, init?: Record<string, unknown>) => {
|
||||
urls.push(String(url));
|
||||
if (String(url).includes("/ping")) return jsonResponse({ ok: true });
|
||||
if (String(url).includes("/jobToken/exchange")) {
|
||||
assert.deepEqual(JSON.parse(String(init?.body ?? "{}")), { personal_token: "pt-live" });
|
||||
return jsonResponse({ job_token: "jt-live", expires_in: 86400 });
|
||||
}
|
||||
// agent_chat_generation -> accept (valid)
|
||||
return jsonResponse({ success: true }, { ok: true, status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await validateQoderCliPat({ apiKey: "pt-live" });
|
||||
assert.equal(res.valid, true);
|
||||
const exchangeIdx = urls.findIndex((u) => u.includes("/jobToken/exchange"));
|
||||
const chatIdx = urls.findIndex((u) => u.includes("agent_chat_generation"));
|
||||
assert.ok(
|
||||
exchangeIdx >= 0,
|
||||
"the PAT->job-token exchange step must run (was skipped before #4683)"
|
||||
);
|
||||
assert.ok(chatIdx >= 0 && exchangeIdx < chatIdx, "exchange must precede the Cosy chat call");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
__clearQoderJobTokenCache();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user