fix(qoder): exchange PAT for jt-* job token before Cosy chat (#4683) (#4884)

Integrated into release/v3.8.36 (fixes #4683)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-24 11:56:33 -03:00
committed by GitHub
parent b1b3069cd1
commit cc8557cedf
4 changed files with 259 additions and 10 deletions

View File

@@ -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, {

View File

@@ -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";

View File

@@ -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");

View 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();
}
});