From cc8557cedff10744160d4786c3565ac5405a0f2f Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Wed, 24 Jun 2026 11:56:33 -0300 Subject: [PATCH] fix(qoder): exchange PAT for jt-* job token before Cosy chat (#4683) (#4884) Integrated into release/v3.8.36 (fixes #4683) --- open-sse/executors/qoder.ts | 7 +- open-sse/services/qoderCli.ts | 109 ++++++++++++++- tests/unit/qoder-executor.test.ts | 24 +++- .../unit/qoder-jobtoken-exchange-4683.test.ts | 129 ++++++++++++++++++ 4 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 tests/unit/qoder-jobtoken-exchange-4683.test.ts diff --git a/open-sse/executors/qoder.ts b/open-sse/executors/qoder.ts index 3c96e6b61a..8e251d94ec 100644 --- a/open-sse/executors/qoder.ts +++ b/open-sse/executors/qoder.ts @@ -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, { diff --git a/open-sse/services/qoderCli.ts b/open-sse/services/qoderCli.ts index f2481cac38..507e92af8c 100644 --- a/open-sse/services/qoderCli.ts +++ b/open-sse/services/qoderCli.ts @@ -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(); + +type FetchLike = (input: string, init?: Record) => Promise; + +/** 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 { + 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"; diff --git a/tests/unit/qoder-executor.test.ts b/tests/unit/qoder-executor.test.ts index 60fc4b21c7..18e4a534c4 100644 --- a/tests/unit/qoder-executor.test.ts +++ b/tests/unit/qoder-executor.test.ts @@ -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"); diff --git a/tests/unit/qoder-jobtoken-exchange-4683.test.ts b/tests/unit/qoder-jobtoken-exchange-4683.test.ts new file mode 100644 index 0000000000..e3a24e4d0c --- /dev/null +++ b/tests/unit/qoder-jobtoken-exchange-4683.test.ts @@ -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) => { + 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) => { + 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(); + } +});