diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 3f205516b7..03be4e2948 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -838,7 +838,7 @@ export const REGISTRY: Record = { { 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" }, diff --git a/open-sse/executors/qoder.ts b/open-sse/executors/qoder.ts index ad42fcfbce..60620bc776 100644 --- a/open-sse/executors/qoder.ts +++ b/open-sse/executors/qoder.ts @@ -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, diff --git a/open-sse/services/qoderCli.ts b/open-sse/services/qoderCli.ts index dc6d4bd9fb..5b596ed795 100644 --- a/open-sse/services/qoderCli.ts +++ b/open-sse/services/qoderCli.ts @@ -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"); diff --git a/tests/unit/qoder-executor.test.ts b/tests/unit/qoder-executor.test.ts index 9c91662c83..4a6c434e03 100644 --- a/tests/unit/qoder-executor.test.ts +++ b/tests/unit/qoder-executor.test.ts @@ -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;