Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions open-sse/executors/qoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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, {
Expand Down
109 changes: 107 additions & 2 deletions open-sse/services/qoderCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {},
Expand Down Expand Up @@ -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";

Expand Down
24 changes: 18 additions & 6 deletions tests/unit/qoder-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
Expand All @@ -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");
Expand Down
129 changes: 129 additions & 0 deletions tests/unit/qoder-jobtoken-exchange-4683.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
Loading