fix(qoder): Cosy auth fallback for PAT tokens + vision support for qwen3-vl-plus (#2629)

Integrated into release/v3.8.3
This commit is contained in:
Hernan Javier Ardila Sanchez
2026-05-23 22:06:35 +02:00
committed by GitHub
parent fcc21aaa61
commit 6bd247bbb4
4 changed files with 112 additions and 4 deletions

View File

@@ -838,7 +838,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
{ id: "qoder-rome-30ba3b", name: "Qoder ROME" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-max", name: "Qwen3 Max" },
{ id: "qwen3-vl-plus", name: "Qwen3 Vision Plus" },
{ id: "qwen3-vl-plus", name: "Qwen3 Vision Plus", supportsVision: true },
{ id: "kimi-k2-0905", name: "Kimi K2 0905" },
{ id: "qwen3-max-preview", name: "Qwen3 Max Preview" },
{ id: "kimi-k2", name: "Kimi K2" },

View File

@@ -11,6 +11,7 @@ import {
QODER_DEFAULT_USER_AGENT,
} from "../config/providerHeaderProfiles.ts";
import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts";
import { buildCosyHeadersForValidation } from "../services/qoderCli.ts";
function getAuthToken(credentials: ProviderCredentials): string {
if (typeof credentials.apiKey === "string" && credentials.apiKey.trim()) {
@@ -119,14 +120,73 @@ export class QoderExecutor extends BaseExecutor {
const bodyStr = JSON.stringify(payload);
try {
const response = await fetch(endpointUrl, {
let response = await fetch(endpointUrl, {
method: "POST",
headers,
body: bodyStr,
signal,
});
const newHeaders = new Headers(response.headers);
// 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);
const cosyEndpoint =
"https://api1.qoder.sh/algo/api/v2/service/pro/sse/agent_chat_generation?AgentId=agent_common";
const cosyRes = await fetch(cosyEndpoint, {
method: "POST",
headers: cosyHeaders,
body: bodyStr,
signal,
});
if (cosyRes.ok || cosyRes.status === 200) {
// Cosy SSE response - read full body and parse
const rawText = await cosyRes.text();
const lines = rawText.split("\n").filter(l => l.startsWith("data: "));
let fullContent = "";
for (const line of lines) {
try {
const jsonData = JSON.parse(line.slice(6));
const { extractTextFromQoderEnvelope } = await import("../services/qoderCli.ts");
const chunkText = extractTextFromQoderEnvelope(jsonData);
if (chunkText) fullContent += chunkText;
} catch {
// skip unparseable chunks
}
}
const { buildQoderCompletionPayload } = await import("../services/qoderCli.ts");
const cosyPayload = buildQoderCompletionPayload({ model: mappedModel || resolvedModel, text: fullContent });
return {
response: new Response(JSON.stringify(cosyPayload), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
url: cosyEndpoint,
headers: cosyHeaders,
transformedBody: payload,
};
}
// Cosy also failed - return the original 401 error
let errText = await cosyRes.text();
return {
response: new Response(
JSON.stringify({
error: {
message: `Qoder API (Cosy) failed with status ${cosyRes.status}: ${errText}. Your PAT token may not be valid for the chat API.` +
" Try using an OAuth token or a different auth method.",
type: "authentication_error",
code: "token_invalid",
},
}),
{ status: 401, headers: { "Content-Type": "application/json" } }
),
url: cosyEndpoint,
headers: cosyHeaders,
transformedBody: payload,
};
}
if (!response.ok) {
let errText = await response.text();
@@ -146,6 +206,7 @@ export class QoderExecutor extends BaseExecutor {
};
}
const newHeaders = new Headers(response.headers);
return {
response: new Response(response.body, {
status: response.status,

View File

@@ -348,7 +348,7 @@ MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
XcW+ML9FoCI6AOvOzwIDAQAB
-----END PUBLIC KEY-----`;
function buildCosyHeadersForValidation(bodyStr: string, token: string) {
export function buildCosyHeadersForValidation(bodyStr: string, token: string) {
const aesKeyBytes = crypto.randomBytes(16);
const aesKeyStr = aesKeyBytes.toString("hex").slice(0, 16);
const aesKeyBuf = Buffer.from(aesKeyStr, "utf8");

View File

@@ -278,6 +278,53 @@ test("QoderExecutor: non-stream calls target DashScope for non-PAT tokens and ma
}
});
test("QoderExecutor: PAT token falls back to Cosy auth when Bearer returns 401", async () => {
const executor = new QoderExecutor();
const originalFetch = globalThis.fetch;
let callCount = 0;
globalThis.fetch = async (url, options) => {
callCount++;
if (callCount === 1) {
// 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"));
assert.ok(options.headers["Cosy-Key"]);
assert.ok(options.headers["Cosy-User"]);
assert.ok(options.headers["Cosy-Date"]);
assert.ok(options.headers.Authorization.startsWith("Bearer COSY."));
return new Response(
"data: {\"message\":{\"content\":\"Hello from Cosy\"}}\n\ndata: [DONE]\n\n",
{ status: 200, headers: { "Content-Type": "text/event-stream" } }
);
};
try {
const { response, url } = await executor.execute({
model: "qwen3.5-plus",
body: { messages: [{ role: "user", content: "Reply with OK only." }] },
stream: false,
credentials: { apiKey: "pt-0pUI-test-token" },
});
assert.equal(callCount, 2, "Should have made 2 fetch calls (1 Bearer + 1 Cosy)");
assert.equal(response.status, 200);
const payload = await response.json();
assert.equal(payload.object, "chat.completion");
assert.equal(payload.choices[0].message.content, "Hello from Cosy");
} finally {
globalThis.fetch = originalFetch;
}
});
test("QoderExecutor: stream calls pass through successful SSE responses", async () => {
const executor = new QoderExecutor();
const originalFetch = globalThis.fetch;