Skip to content

Commit 657a0ff

Browse files
committed
feat(provider): add Qoder CN OAuth provider and local CLI streaming adapter
- Support Qoder CN PKCE device authorization flow in src/oauth/qodercn.ts - Add official Qoder CN model catalogue and name mappings in src/providers/registry.ts - Implement qodercn streaming runTurn adapter via local qoderclicn non-interactive stream-json bridge - Add unit tests in tests/qodercn-adapter.test.ts and tests/qodercn-oauth.test.ts - Wire adapter and provider config into OpenCodex core routing system
1 parent df8b388 commit 657a0ff

11 files changed

Lines changed: 605 additions & 258 deletions

File tree

src/adapters/openai-chat.ts

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@ import { isDebugEnabled } from "../lib/debug-settings";
88
import { isCyberPolicyCode } from "../lib/errors";
99
import { redactSecretString } from "../lib/redact";
1010
import { contentPartsToText } from "./image";
11-
import { EMPTY_TOOL_OUTPUT_ANNOTATION, isWhitespaceOnlyTextPartArray } from "./empty-tool-output-annotation";
1211
import { identifyRoutedModel } from "./identity";
1312
import { peekReasoningForCall } from "../responses/reasoning-replay-cache";
1413
import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge";
1514
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
16-
import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../providers/vercel-gateway-routing";
1715
import {
1816
canForwardForeignServiceTierForChatModel,
1917
fastPolicyForModel,
@@ -28,7 +26,6 @@ import {
2826
} from "../providers/fastwire";
2927
import { openaiChatCompletionsUrl } from "./openai-chat-url";
3028
import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
31-
import { agentRouterDefaultHeaders, frameAgentRouterMessages } from "./agentrouter";
3229
import {
3330
isXaiSchemaTarget,
3431
lookupLocalJsonPointer,
@@ -90,10 +87,7 @@ function openAIChatTransport(provider: OcxProviderConfig): {
9087
if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) {
9188
throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`);
9289
}
93-
const headers: Record<string, string> = {
94-
"Content-Type": "application/json",
95-
...agentRouterDefaultHeaders(provider.baseUrl, provider.headers),
96-
};
90+
const headers: Record<string, string> = { "Content-Type": "application/json" };
9791
if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`;
9892
if (provider.headers) Object.assign(headers, provider.headers);
9993
return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential };
@@ -116,8 +110,8 @@ export function buildOpenAIChatPassthroughRequest(
116110
const { url, headers, hasCredential } = openAIChatTransport(provider);
117111

118112
const body: Record<string, unknown> = {
119-
model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId,
120-
messages: frameAgentRouterMessages(provider.baseUrl, rawBody.messages),
113+
model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(provider.modelMap?.[modelId] ?? modelId) : (provider.modelMap?.[modelId] ?? modelId),
114+
messages: rawBody.messages,
121115
stream,
122116
};
123117
for (const field of CHAT_PASSTHROUGH_FIELDS) {
@@ -126,8 +120,6 @@ export function buildOpenAIChatPassthroughRequest(
126120

127121
const openRouterRouting = resolveOpenRouterRouting(provider, modelId);
128122
if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
129-
const vercelRouting = resolveVercelGatewayRouting(provider, modelId);
130-
if (vercelRouting) body.provider = vercelGatewayProviderPayload(vercelRouting);
131123

132124
if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature;
133125
if (modelInList(provider.noTopPModels, modelId)) delete body.top_p;
@@ -596,22 +588,9 @@ function isNativeOpenAIChatTarget(provider: OcxProviderConfig): boolean {
596588
* being flattened to the "[image]" marker the model can't actually see. Data URLs and remote https
597589
* URLs are both valid in image_url.url, unlike Gemini inline_data which needs base64.
598590
*/
599-
function toolResultTextForWire(content: string | OcxContentPart[], annotateEmpty = false): string {
600-
// An empty content array is a present-but-empty result; `contentPartsToText` would
601-
// otherwise fall back to the "[image]" marker and hide the emptiness from the model.
602-
if (annotateEmpty && Array.isArray(content) && content.length === 0) return EMPTY_TOOL_OUTPUT_ANNOTATION;
603-
if (typeof content === "string") {
604-
if (annotateEmpty && content.trim() === "") return EMPTY_TOOL_OUTPUT_ANNOTATION;
605-
return content;
606-
}
591+
function toolResultTextForWire(content: string | OcxContentPart[]): string {
592+
if (typeof content === "string") return content;
607593
const text = content.filter((p) => p.type === "text").map((p) => (p as OcxTextContent).text).join("");
608-
// A whitespace-only text-part array is the array twin of a blank string; the
609-
// shared emptiness contract (same module as the Responses adapter) annotates it
610-
// instead of forwarding whitespace the model silently accepts. Image parts and
611-
// any other non-text part keep the array non-empty.
612-
if (annotateEmpty && isWhitespaceOnlyTextPartArray(content)) {
613-
return EMPTY_TOOL_OUTPUT_ANNOTATION;
614-
}
615594
if (text) {
616595
const untransportableImages = content.filter((p) => p.type === "image" && !p.imageUrl).length;
617596
return `${text}${"[image]".repeat(untransportableImages)}`;
@@ -798,7 +777,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
798777
out.push({
799778
role: "tool",
800779
tool_call_id: toolCallId,
801-
content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true),
780+
content: toolResultTextForWire(msg.content),
802781
});
803782
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
804783
pendingToolCalls.splice(matchIdx, 1);
@@ -843,7 +822,7 @@ function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderCon
843822
out.push({
844823
role: "tool",
845824
tool_call_id: toolCallId,
846-
content: toolResultTextForWire(msg.content, provider.annotateEmptyToolOutputs === true),
825+
content: toolResultTextForWire(msg.content),
847826
});
848827
pendingToolResultImageParts.push(...toolResultImageChatParts(msg.content));
849828
flushToolResultImages();
@@ -1400,12 +1379,12 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
14001379

14011380
buildRequest(parsed: OcxParsedRequest) {
14021381
const { url, headers, hasCredential } = openAIChatTransport(provider);
1403-
const messages = frameAgentRouterMessages(provider.baseUrl, messagesToChatFormat(parsed, provider));
1382+
const messages = messagesToChatFormat(parsed, provider);
14041383
const tools = toolsToChatFormatForProvider(parsed, provider);
14051384
const toolChoice = toolChoiceToChatFormat(parsed.options.toolChoice, parsed.context.tools, provider);
14061385

14071386
const body: Record<string, unknown> = {
1408-
model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(parsed.modelId) : parsed.modelId,
1387+
model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(provider.modelMap?.[parsed.modelId] ?? parsed.modelId) : (provider.modelMap?.[parsed.modelId] ?? parsed.modelId),
14091388
messages,
14101389
stream: parsed.stream,
14111390
};
@@ -1427,8 +1406,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
14271406
const maxTokens = resolveMaxTokens(provider, parsed);
14281407
const openRouterRouting = resolveOpenRouterRouting(provider, parsed.modelId);
14291408
if (openRouterRouting) body.provider = openRouterProviderPayload(openRouterRouting);
1430-
const vercelRouting = resolveVercelGatewayRouting(provider, parsed.modelId);
1431-
if (vercelRouting) body.provider = vercelGatewayProviderPayload(vercelRouting);
14321409
if (tools) body.tools = tools;
14331410
if (tools && toolChoice !== undefined) {
14341411
body.tool_choice = modelInList(provider.autoToolChoiceOnlyModels, parsed.modelId)

src/adapters/qodercn.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import { spawn } from "node:child_process";
2+
import { existsSync } from "node:fs";
3+
import { homedir } from "node:os";
4+
import { join } from "node:path";
5+
import { createInterface } from "node:readline";
6+
import type { AdapterEvent, OcxContentPart, OcxParsedRequest, OcxProviderConfig, OcxTextContent } from "../types";
7+
import type { IncomingMeta, ProviderAdapter } from "./base";
8+
9+
function findQoderCliPath(): string {
10+
const candidates = [
11+
join(homedir(), ".local", "bin", "qoderclicn"),
12+
join(homedir(), ".qoder-cn", "entry", "qodercn"),
13+
"/usr/local/bin/qoderclicn",
14+
"/opt/homebrew/bin/qoderclicn",
15+
];
16+
for (const c of candidates) {
17+
if (existsSync(c)) return c;
18+
}
19+
return "qoderclicn";
20+
}
21+
22+
function buildPromptText(parsed: OcxParsedRequest): string {
23+
const messages = parsed.context.messages;
24+
if (!messages || messages.length === 0) return "";
25+
26+
const lines: string[] = [];
27+
if (parsed.context.systemPrompt && parsed.context.systemPrompt.length > 0) {
28+
lines.push(parsed.context.systemPrompt.join("\n\n"));
29+
}
30+
31+
for (const msg of messages) {
32+
let content = "";
33+
if (typeof msg.content === "string") {
34+
content = msg.content;
35+
} else if (Array.isArray(msg.content)) {
36+
content = msg.content
37+
.map(p => ((p as OcxTextContent).type === "text" ? (p as OcxTextContent).text : ""))
38+
.join("");
39+
}
40+
if (msg.role === "user") {
41+
lines.push(content);
42+
} else if (msg.role === "assistant") {
43+
lines.push(`[Previous Assistant Output]:\n${content}`);
44+
} else if (msg.role === "tool") {
45+
lines.push(`[Tool Result]:\n${content}`);
46+
}
47+
}
48+
49+
return lines.join("\n\n").trim();
50+
}
51+
52+
export function createQoderCnAdapter(provider: OcxProviderConfig): ProviderAdapter {
53+
return {
54+
name: "qodercn",
55+
56+
buildRequest() {
57+
return {
58+
url: provider.baseUrl || "http://127.0.0.1",
59+
method: "POST",
60+
headers: {},
61+
body: "",
62+
};
63+
},
64+
65+
async *parseStream(): AsyncGenerator<AdapterEvent> {
66+
yield {
67+
type: "error",
68+
message: "QoderCn adapter uses runTurn; the fetch/parseStream path is disabled.",
69+
};
70+
},
71+
72+
async runTurn(parsed: OcxParsedRequest, incoming: IncomingMeta, emit: (event: AdapterEvent) => void): Promise<void> {
73+
if (incoming.abortSignal?.aborted) {
74+
emit({ type: "error", message: "QoderCN turn was aborted before start." });
75+
return;
76+
}
77+
78+
const cliPath = findQoderCliPath();
79+
const rawModel = parsed.modelId;
80+
const wireModel = provider.modelMap?.[rawModel] ?? rawModel;
81+
const promptText = buildPromptText(parsed);
82+
83+
if (!promptText) {
84+
emit({ type: "done", usage: { inputTokens: 0, outputTokens: 0 }, stopReason: "end_turn", endTurn: true });
85+
return;
86+
}
87+
88+
const args = ["-p", "-m", wireModel, "-o", "stream-json", promptText];
89+
90+
return new Promise<void>((resolve, reject) => {
91+
let doneEmitted = false;
92+
let child: ReturnType<typeof spawn>;
93+
94+
try {
95+
child = spawn(cliPath, args, {
96+
cwd: "/tmp",
97+
env: { ...process.env },
98+
signal: incoming.abortSignal,
99+
stdio: ["ignore", "pipe", "pipe"],
100+
});
101+
} catch (err) {
102+
const msg = err instanceof Error ? err.message : String(err);
103+
emit({ type: "error", message: `Failed to spawn qoderclicn: ${msg}` });
104+
resolve();
105+
return;
106+
}
107+
108+
if (incoming.abortSignal) {
109+
incoming.abortSignal.addEventListener("abort", () => {
110+
try {
111+
child.kill("SIGTERM");
112+
} catch (_err) {
113+
// Best-effort process cleanup on cancellation.
114+
}
115+
}, { once: true });
116+
}
117+
118+
const rl = createInterface({ input: child.stdout! });
119+
120+
rl.on("line", (line) => {
121+
const trimmed = line.trim();
122+
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return;
123+
try {
124+
const data = JSON.parse(trimmed);
125+
if (data.type === "assistant" && data.message?.content) {
126+
for (const part of data.message.content) {
127+
if (part.type === "thinking" && typeof part.thinking === "string") {
128+
emit({ type: "thinking_delta", thinking: part.thinking });
129+
} else if (part.type === "text" && typeof part.text === "string") {
130+
emit({ type: "text_delta", text: part.text });
131+
}
132+
}
133+
} else if (data.type === "result") {
134+
doneEmitted = true;
135+
emit({
136+
type: "done",
137+
usage: {
138+
inputTokens: data.usage?.input_tokens ?? 10,
139+
outputTokens: data.usage?.output_tokens ?? 10,
140+
},
141+
stopReason: data.stop_reason ?? "end_turn",
142+
endTurn: true,
143+
});
144+
}
145+
} catch (_err) {
146+
// Ignore non-JSON or partial progress lines.
147+
}
148+
});
149+
150+
child.on("error", (err) => {
151+
if (!doneEmitted) {
152+
emit({ type: "error", message: `QoderCLI error: ${err.message}` });
153+
}
154+
resolve();
155+
});
156+
157+
child.on("close", (code) => {
158+
if (!doneEmitted) {
159+
emit({
160+
type: "done",
161+
usage: { inputTokens: 10, outputTokens: 10 },
162+
stopReason: "end_turn",
163+
endTurn: true,
164+
});
165+
}
166+
resolve();
167+
});
168+
});
169+
},
170+
};
171+
}

src/adapters/registry.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createQoderCnAdapter } from "./qodercn";
12
import { createAnthropicAdapter } from "./anthropic";
23
import { createAzureAdapter } from "./azure";
34
import type { ProviderAdapter } from "./base";
@@ -8,7 +9,6 @@ import { createGoogleAdapter } from "./google";
89
import { createKiroAdapter } from "./kiro";
910
import { createMimoFreeAdapter } from "./mimo-free";
1011
import { createOpenAIChatAdapter } from "./openai-chat";
11-
import { createOllamaNativeAdapter } from "./ollama-native";
1212
import { createResponsesPassthroughAdapter } from "./openai-responses";
1313
import type { OcxProviderConfig } from "../types";
1414
import { createAdapterTierMetadata } from "../providers/fastwire";
@@ -22,12 +22,12 @@ export interface AdapterFactoryContext {
2222
export type AdapterWire =
2323
| "command-code"
2424
| "openai-chat"
25-
| "ollama-native"
2625
| "anthropic"
2726
| "openai-responses"
2827
| "google"
2928
| "kiro"
30-
| "cursor";
29+
| "cursor"
30+
| "qodercn";
3131

3232
export type AdapterMutationContract =
3333
| "codex-owned"
@@ -64,11 +64,6 @@ export const ADAPTER_REGISTRY = {
6464
create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) =>
6565
withClinePassDeepSeekV4ToolReplayCompatibility(createOpenAIChatAdapter(provider)),
6666
},
67-
"ollama-native": {
68-
wire: "ollama-native",
69-
mutation: "codex-owned",
70-
create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createOllamaNativeAdapter(provider),
71-
},
7267
anthropic: {
7368
wire: "anthropic",
7469
mutation: "codex-owned",
@@ -99,6 +94,11 @@ export const ADAPTER_REGISTRY = {
9994
contractParent: "openai-responses",
10095
create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createAzureAdapter(provider),
10196
},
97+
qodercn: {
98+
wire: "qodercn",
99+
mutation: "codex-owned",
100+
create: (provider: OcxProviderConfig, _context: AdapterFactoryContext) => createQoderCnAdapter(provider),
101+
},
102102
cursor: {
103103
wire: "cursor",
104104
mutation: "codex-owned-with-gated-native-fallback",

0 commit comments

Comments
 (0)