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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,8 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# QODER_PERSONAL_ACCESS_TOKEN=
# QODER_CLI_WORKSPACE=
# OMNIROUTE_QODER_WORKSPACE=
# Override the Qoder CLI config dir (isolated PAT session, avoids clobbering a browser login).
# QODER_CLI_CONFIG_DIR=

# ── Blackbox Web validated-token override (issue #2252) ──
# Used by: open-sse/executors/blackbox-web.ts. Blackbox `/api/chat` rejects
Expand Down
6 changes: 4 additions & 2 deletions config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@
"_rebaseline_2026_06_20_4389_thinking_toolchoice": "Re-baseline base.ts 1387->1399 (#4389): tool_choice-forced thinking guard at the existing Claude wire-image injection chokepoint (effThinking gate avoids the Anthropic 400 when tool_choice forces a tool). Cohesive guard; structural shrink tracked in #3501.",
"cap": 800,
"frozen": {
"_rebaseline_2026_07_02_5816_qoder": "PR #5816 (@AgentKiller45, qoder PAT via qodercli): qoderCli.ts 666->989, new-above-cap frozen (owner-approved baseline freeze). The growth is the legitimate PAT job-token exchange + quota parsing CLI transport (the pure-JS Cosy path 500'd on every PAT request); extracting the spawn/parse helpers now would just add indirection to a contributor PR mid-merge. Test frozen also raised for this PR's coverage growth: providers-page-utils.test.ts 1052->1092. Additionally clears an inherited base-red from the already-merged #5933 (codex json_schema->text.format): translator-openai-responses-req.test.ts 1097->1172 (+75 regression tests, no offending branch left). All remain frozen (cannot grow further); release captain's rebaseline-at-release supersedes.",
"open-sse/services/qoderCli.ts": 989,
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
"open-sse/config/providerRegistry.ts": 4731,
"open-sse/executors/antigravity.ts": 1813,
Expand Down Expand Up @@ -297,7 +299,7 @@
"tests/unit/provider-models-route.test.ts": 1752,
"tests/unit/provider-validation-specialty.test.ts": 2874,
"_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.",
"tests/unit/providers-page-utils.test.ts": 1052,
"tests/unit/providers-page-utils.test.ts": 1092,
"tests/unit/reasoning-cache.test.ts": 980,
"tests/unit/route-edge-coverage.test.ts": 1234,
"tests/unit/search-handler-extended.test.ts": 1124,
Expand All @@ -306,7 +308,7 @@
"tests/unit/token-refresh-service.test.ts": 1353,
"tests/unit/translator-friendly-test-bench.test.tsx": 848,
"tests/unit/translator-helper-branches.test.ts": 870,
"tests/unit/translator-openai-responses-req.test.ts": 1097,
"tests/unit/translator-openai-responses-req.test.ts": 1172,
"tests/unit/translator-openai-to-gemini.test.ts": 1579,
"tests/unit/translator-openai-to-kiro.test.ts": 1088,
"tests/unit/translator-resp-gemini-to-openai.test.ts": 1234,
Expand Down
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,7 @@ Built-in credentials for **localhost development**. For remote deployments, regi
| `QODER_PERSONAL_ACCESS_TOKEN` | Qoder | Direct API key fallback (bypasses OAuth). |
| `QODER_CLI_WORKSPACE` | Qoder | Workspace ID for Qoder CLI. |
| `OMNIROUTE_QODER_WORKSPACE` | Qoder | Alias for `QODER_CLI_WORKSPACE`. |
| `QODER_CLI_CONFIG_DIR` | Qoder | Override the Qoder CLI config dir (isolated PAT session, avoids clobbering a browser login). |
| `BLACKBOX_WEB_VALIDATED_TOKEN` | Blackbox Web | Frontend `tk` token to send as `validated` on `/api/chat`. Required when Blackbox enforces token matching; otherwise OmniRoute falls back to a random UUID. See issue #2252. |
| `VISION_BRIDGE_BASE_URL` | Vision Bridge guardrail | OpenAI-compatible base URL for non-Anthropic vision-bridge calls. Defaults to the legacy OpenAI URL env or api.openai.com. Point at OmniRoute's `/v1` self-loop or any OpenAI-compat endpoint (Gemini OpenAI-compat, OpenRouter). Issue #2232. |
| `VISION_BRIDGE_API_KEY` | Vision Bridge guardrail | API key for the URL above. Overrides per-provider OpenAI / Google env vars for non-Anthropic vision-bridge calls. Anthropic models keep their dedicated Anthropic key path. Issue #2232. |
Expand Down
2 changes: 2 additions & 0 deletions open-sse/config/providers/registry/qoder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export const qoderProvider: RegistryEntry = {
},
models: [
{ id: "qoder-rome-30ba3b", name: "Qoder ROME" },
{ id: "glm-5.2", name: "GLM-5.2" },
{ id: "minimax-m3", name: "MiniMax M3" },
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
{ id: "qwen3-max", name: "Qwen3 Max" },
{ id: "qwen3-vl-plus", name: "Qwen3 Vision Plus", supportsVision: true },
Expand Down
233 changes: 149 additions & 84 deletions open-sse/executors/qoder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,51 @@ import {
getQoderDashscopeCompatHeaders,
QODER_DEFAULT_USER_AGENT,
} from "../config/providerHeaderProfiles.ts";
import { randomUUID } from "node:crypto";
import { sanitizeQwenThinkingToolChoice } from "../services/qwenThinking.ts";
import { buildCosyHeadersForValidation, resolveQoderJobToken } from "../services/qoderCli.ts";
import {
buildQoderChunk,
buildQoderCompletionPayload,
buildQoderPrompt,
createQoderErrorResponse,
parseQoderCliFailure,
parseQoderCliResult,
runQoderCli,
} from "../services/qoderCli.ts";
import { sanitizeErrorMessage } from "../utils/error.ts";

function truncate(text: string, max: number): string {
if (text.length <= max) return text;
return `${text.slice(0, max)}…`;
}

/**
* Wrap a full qodercli reply as an OpenAI-compatible SSE stream (role chunk →
* content chunk → stop chunk → [DONE]). qodercli's `--print` mode returns the
* whole answer at once, so there are no incremental deltas to forward.
*/
function buildQoderCliSseStream(model: string, text: string): ReadableStream<Uint8Array> {
const id = `chatcmpl-${randomUUID()}`;
const created = Math.floor(Date.now() / 1000);
const encoder = new TextEncoder();
const send = (obj: unknown) => encoder.encode(`data: ${JSON.stringify(obj)}\n\n`);
return new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(
send(buildQoderChunk({ id, model, created, delta: { role: "assistant", content: "" } }))
);
if (text) {
controller.enqueue(send(buildQoderChunk({ id, model, created, delta: { content: text } })));
}
controller.enqueue(
send(buildQoderChunk({ id, model, created, delta: {}, finishReason: "stop" }))
);
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
controller.close();
},
});
}

/**
* Peek at the first SSE event from a Qoder response to detect upstream errors
* that Qoder wraps inside an HTTP 200 SSE envelope ({statusCodeValue, body}).
Expand Down Expand Up @@ -174,22 +210,23 @@ export class QoderExecutor extends BaseExecutor {

const resolvedModel = model || "qwen3-coder-plus";

// Detect token type: PAT (Personal Access Token) starts with "pt-"
// Detect token type: PAT (Personal Access Token) starts with "pt-".
// PATs are driven through the local qodercli binary (see executeViaQoderCli);
// only the qodercli binary can produce the WASM-signed Cosy request the raw
// HTTP path can no longer replicate.
const isPatToken = token.startsWith("pt-");
if (isPatToken) {
return this.executeViaQoderCli({ model: resolvedModel, body, stream, token, signal });
}

// Non-PAT tokens (OAuth apiKey / DashScope key) → DashScope OpenAI-compatible API.
let mappedModel = resolvedModel;
let endpointUrl: string;

if (isPatToken) {
endpointUrl = "https://api.qoder.com/v1/chat/completions";
} else {
if (resolvedModel === "qwen3.5-plus" || resolvedModel === "qwen3.6-plus") {
mappedModel = "coder-model";
} else if (resolvedModel === "vision-model") {
mappedModel = "qwen3-vl-plus";
}
endpointUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";
if (resolvedModel === "qwen3.5-plus" || resolvedModel === "qwen3.6-plus") {
mappedModel = "coder-model";
} else if (resolvedModel === "vision-model") {
mappedModel = "qwen3-vl-plus";
}
let endpointUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions";

// Check for custom API base via credentials (overrides the default)
let credentialsApiBase: unknown;
Expand All @@ -207,91 +244,23 @@ export class QoderExecutor extends BaseExecutor {
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(isPatToken ? {} : getQoderDashscopeCompatHeaders()),
...getQoderDashscopeCompatHeaders(),
};

mergeUpstreamExtraHeaders(headers, upstreamExtraHeaders);

const payload = this.transformRequest(mappedModel, body, stream, credentials);
const payload = this.transformRequest(mappedModel, body);

const bodyStr = JSON.stringify(payload);

try {
let response = await fetch(endpointUrl, {
const response = await fetch(endpointUrl, {
method: "POST",
headers,
body: bodyStr,
signal,
});

// 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) {
// #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, {
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();
return {
Expand Down Expand Up @@ -341,6 +310,102 @@ export class QoderExecutor extends BaseExecutor {
};
}
}

/**
* Drive a PAT (`pt-*`) completion through the local qodercli binary. The CLI
* performs Qoder's WASM-signed Cosy auth internally, so this is the only path
* that works for PATs now that the pure-HTTP Cosy reimplementation is dead.
*/
private async executeViaQoderCli({
model,
body,
stream,
token,
signal,
}: {
model: string;
body: unknown;
stream: boolean;
token: string;
signal?: AbortSignal | null;
}): Promise<{
response: Response;
url: string;
headers: Record<string, string>;
transformedBody: unknown;
}> {
const url = "qodercli://stdio";
const prompt = buildQoderPrompt(body);

const run = await runQoderCli({ token, prompt, stream: false, model, signal });

// Honor client cancellation the same way the HTTP path does.
if (signal?.aborted) {
const abortError = new Error("Aborted");
abortError.name = "AbortError";
throw abortError;
}

if (run.error && /enoent|not found|no such file|spawn/i.test(run.error)) {
return {
response: createQoderErrorResponse({
status: 502,
message:
`Qoder CLI (qodercli) was not found on the OmniRoute host (${run.error}). ` +
"Install it from https://qoder.com or set CLI_QODER_BIN to its path.",
code: "cli_not_found",
}),
url,
headers: {},
transformedBody: body,
};
}

if (!run.ok) {
return {
response: createQoderErrorResponse(parseQoderCliFailure(run.stderr, run.stdout)),
url,
headers: {},
transformedBody: body,
};
}

const { text, isError, errorMessage } = parseQoderCliResult(run.stdout);
if (isError) {
return {
response: createQoderErrorResponse(parseQoderCliFailure(errorMessage)),
url,
headers: {},
transformedBody: body,
};
}

if (stream) {
return {
response: new Response(buildQoderCliSseStream(model, text), {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
}),
url,
headers: {},
transformedBody: body,
};
}

return {
response: new Response(JSON.stringify(buildQoderCompletionPayload({ model, text })), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
url,
headers: {},
transformedBody: body,
};
}
}

export default QoderExecutor;
Expand Down
Loading
Loading