Skip to content
Closed
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: 4 additions & 3 deletions docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -1079,7 +1079,8 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa
([Global](https://qoder.com/account/integrations), [CN](https://qoder.cn/account/integrations)) and paste it as the provider's API key. The stored key reaches the CLI only as `QODER_PERSONAL_ACCESS_TOKEN` (Global) or `QODERCN_PERSONAL_ACCESS_TOKEN` (CN) in a scoped child environment.
- **Region Isolation:** Each preset accepts only its canonical destination (`https://qoder.com` or `https://qoder.cn`) and resolves its own executable. Credentials, model cache, usage, and health are independent; neither region falls back to the other. An older custom provider named `qoder` with a different destination keeps its existing adapter and URL.
- **Model Discovery:** `qoder --list-models` is the authoritative entitlement roster for the current PAT. The cache is bound to an irreversible fingerprint of the token, so switching accounts never reuses another account's roster. If discovery fails, the provider degrades to a stale cache and then the documented static seed.
- **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server; image input fails explicitly.
- **Tool Ownership:** The CLI runs single-turn `stream-json` with `--tools ""`, `--strict-mcp-config`, setting sources disabled, and session persistence disabled, so Codex keeps exclusive tool ownership. Client-declared tools are exposed through an isolated request-scoped MCP server.
- **Image Input:** Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter to the CLI without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its existing sidecar classification, and any residual raw-image request that reaches the CN adapter is rejected as `unsupported_input_modality`. OpenCodex serializes history before the current turn, but Qoder does not persist sessions, so prior images are replayed on later turns. In observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied.
- **Quota:** No public quota API is used, so totals and reset times are unavailable. Insufficient-credit errors (vendor code 118) surface as HTTP 429 `insufficient_quota`.
- **Operators:** Qoder Global is operated by BRIGHT ZENITH PRIVATE LIMITED under the [product service terms](https://qoder.com/product-service); Qoder CN by 通义云启(杭州)信息技术有限公司 with Alibaba Cloud. Verify `ocx provider test qoder` (or `qoder-cn`) after configuring.

Expand Down Expand Up @@ -1123,8 +1124,8 @@ CLI headlessly (`claude -p`, `stream-json`) once per turn:
giving several people their own Claude usage needs one proxy user per sign-in.
- **Input media:** the row publishes its models as text-only for v1. The CLI accepts an image frame
on its stream-json input, but no headless turn has been shown to hand those bytes to the model, so
an image sent straight to this provider is refused (`unsupported_input_modality`, the same
refusal the Qoder presets make) instead of being silently dropped and answered blind. With the
an image sent straight to this provider is refused (`unsupported_input_modality`) instead of
being silently dropped and answered blind. With the
vision sidecar on the request path, images are captioned into text before they reach the row.
- **Isolation:** every turn runs in a scoped child environment with no inherited `ANTHROPIC_*`
variable (a `claude` already pointed at this proxy therefore cannot loop back into it), telemetry,
Expand Down
30 changes: 16 additions & 14 deletions src/adapters/qoder/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { mapReasoningEffort } from "../../reasoning-effort";
import { buildSystemPrompt, MAX_TOOL_BLOCK_STARTS } from "../coding-agent/protocol";
import { buildCodingAgentToolCatalog, CodingAgentToolCatalogError } from "../coding-agent/tool-catalog";
import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, type CodingAgentToolBridgeInput } from "../coding-agent/turn";
import { QODER_PROFILES, type QoderProfile } from "./profiles";
import { QODER_PROFILES, resolveQoderProfile, type QoderProfile } from "./profiles";
import { QoderScaffoldFilter, QODER_SCAFFOLD_ERROR_CODE, qoderScaffoldErrorMessage } from "./scaffold-guard";

export type QoderAdapterDeps = CodingAgentDeps;
Expand Down Expand Up @@ -114,19 +114,21 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt
yield { type: "error", message: "Qoder adapter uses runTurn; the fetch/parseStream path is disabled." };
},
async runTurn(parsed, incoming, emit): Promise<void> {
const hasImage = parsed.context.messages.some(message =>
Array.isArray(message.content) && message.content.some(part => part.type === "image"),
);
if (hasImage) {
emit({
type: "error",
message: "Qoder image input is not enabled because the CLI provider route has no verified multimodal contract.",
status: 400,
errorType: "invalid_request_error",
code: "unsupported_input_modality",
retryable: false,
});
return;
if (resolveQoderProfile(provider.baseUrl)?.region === "cn") {
const hasImage = parsed.context.messages.some(message =>
Array.isArray(message.content) && message.content.some(part => part.type === "image"),
);
if (hasImage) {
emit({
type: "error",
message: "Qoder image input is not enabled because the CLI provider route has no verified multimodal contract.",
status: 400,
errorType: "invalid_request_error",
code: "unsupported_input_modality",
retryable: false,
});
return;
}
}
let catalog: ReturnType<typeof buildCodingAgentToolCatalog>;
try {
Expand Down
1 change: 0 additions & 1 deletion src/providers/registry/entries-extended.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1468,7 +1468,6 @@ export const PROVIDER_REGISTRY_EXTENDED: readonly ProviderRegistryEntry[] = [
models: [...QODER_GLOBAL_MODELS],
liveModels: true,
reasoningEfforts: [...QODER_REASONING_EFFORTS],
noVisionModels: [...QODER_GLOBAL_MODELS],
note: "Official Qoder Global CLI using QODER_PERSONAL_ACCESS_TOKEN. Models are discovered per account with `qoder --list-models`; the documented roster is a degraded fallback. The CLI runs single-turn with tools, MCP, settings hooks, and session persistence disabled. Requires `npm install -g @qoder-ai/qodercli`.",
},
{
Expand Down
9 changes: 3 additions & 6 deletions structure/providers/chat-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -581,15 +581,12 @@ file part whose inline base64 bytes now have a lossless carrier; the native Chat
unchanged. No audio transport and no automatic URL fetch is added, and no client filename,
payload, URL or metadata is included in the new error messages.

The shared coding-agent projection (CodeBuddy, Qoder) carries tool-result images as
real image blocks rather than flattening them to the text `[image]`, and orders image
blocks chronologically — history before current — so attachment order matches the
prose the model reads beside them. Vendor tool execution stays disabled on both
The shared coding-agent projection in `src/adapters/coding-agent/` (CodeBuddy, Qoder) carries supported tool-result image references as
real image blocks. Current tool-result prose marks supported images as `[image attached below]` and unsupported references as `[image omitted: unsupported reference]`; historical tool-result prose retains `[image]` while supported references are also emitted as image blocks. In multi-turn projection, image blocks follow conversation order — history before current. Vendor tool execution stays disabled on both
adapters. CodeBuddy refuses an unquoted, line-oriented full-width-bar DSML `calls`
container followed by a named bare or namespaced invoke control line in either output channel; it
preserves preceding answer text, never promotes vendor prose into execution authority,
and leaves discussed or quoted literals and code examples untouched. Qoder's explicit
refusal of original images is unchanged.
and leaves discussed or quoted literals and code examples untouched. Qoder Global accepts image input through the Qoder CLI. OpenCodex forwards supported image inputs that reach the Global adapter in `src/adapters/qoder/` through the shared coding-agent projection without maintaining a per-model vision capability allowlist there. Image compatibility and any model-specific preprocessing or routing are handled by Qoder. OpenCodex does not use the static discovery fallback roster to classify per-model vision capability. Qoder CN keeps its sidecar classification and rejects residual raw-image requests that reach the adapter. OpenCodex serializes image blocks in conversation order, with history before the current turn. Because Qoder runs with `--no-session-persistence`, prior images are re-projected on later turns; in observed multi-turn image conversations, replayed images can become unreliably associated or effectively ordered relative to the historical text they originally accompanied, a known limitation of the current stateless Qoder projection path.

Canonical Responses identity sanitation and narrowly scoped pre-output combo recovery follow [request-local target compatibility](../runtime.md#request-local-target-compatibility); other adapter contracts remain unchanged.

Expand Down
57 changes: 54 additions & 3 deletions tests/providers/qoder-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type { CodingAgentDeps } from "../../src/adapters/coding-agent/turn";
import { buildCodingAgentToolCatalog, CODING_AGENT_TOOL_LIMITS } from "../../src/adapters/coding-agent/tool-catalog";
import { buildResponseJSON } from "../../src/bridge/response-json";
import { clearQoderBinaryCache, QODER_CN_PROFILE, QODER_GLOBAL_PROFILE, resolveQoderProfile } from "../../src/adapters/qoder/profiles";
import { getProviderRegistryEntry } from "../../src/providers/registry";
import { isModelVisionSidecarConsumer } from "../../src/vision/eligibility";
import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../src/types";
import { createTestTranslatorBudget } from "../helpers/translator-budget";

Expand Down Expand Up @@ -561,14 +563,63 @@ describe("qoder adapter", () => {
expect(events[0]).toMatchObject({ type: "error", code: "non_canonical_destination" });
});

test("rejects unverified image input instead of silently dropping or forwarding it", async () => {
test("Global discovery fallback does not classify models as vision sidecar consumers", () => {
const global = getProviderRegistryEntry("qoder")!;
expect(global.models).toContain("Qwen3.8-Max");
expect(global.noVisionModels).toBeUndefined();
expect(global.modelInputModalities).toBeUndefined();
expect(isModelVisionSidecarConsumer(global, "Qwen3.8-Max")).toBe(false);
expect(isModelVisionSidecarConsumer(global, "unknown-live-id")).toBe(false);
});

test("keeps residual Qoder CN image input rejected before spawn", async () => {
let spawned = 0;
const adapter = createQoderAdapter(provider(), { which: () => "/bin/qoder", spawn: () => { spawned++; return fakeChild([]); } });
const adapter = createQoderAdapter(provider({ baseUrl: "https://qoder.cn" }), {
which: () => "/bin/qodercn",
spawn: () => {
spawned++;
return fakeChild([]);
},
});
const request = parsed({ context: { messages: [{ role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,AA==" }], timestamp: 0 }] } });
const events: AdapterEvent[] = [];
await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event));
expect(spawned).toBe(0);
expect(events[0]).toMatchObject({ type: "error", code: "unsupported_input_modality" });
expect(events[0]).toMatchObject({
type: "error",
status: 400,
code: "unsupported_input_modality",
retryable: false,
});
});

test("forwards Qoder Global raw image input through the shared coding-agent projection", async () => {
let spawned = 0;
let stdin = "";
const child = fakeChild(['{"type":"result","subtype":"success","is_error":false}\n']);
child.stdin = new Writable({
write(chunk, _encoding, callback) {
stdin += chunk.toString();
callback();
},
});
const adapter = createQoderAdapter(provider(), {
which: () => "/bin/qoder",
spawn: () => {
spawned++;
return child;
},
});
const request = parsed({ modelId: "unknown-live-id", context: { messages: [{ role: "user", content: [{ type: "image", imageUrl: "data:image/png;base64,AA==" }], timestamp: 0 }] } });
const events: AdapterEvent[] = [];
await adapter.runTurn!(request, { headers: new Headers(), translatorBudget: createTestTranslatorBudget() }, event => events.push(event));
expect(spawned).toBe(1);
const wire = JSON.parse(stdin.trim());
expect(wire.message.content).toContainEqual({
type: "image",
source: { type: "base64", media_type: "image/png", data: "AA==" },
});
expect(events.at(-1)).toMatchObject({ type: "done" });
});

test("maps Qoder credit exhaustion to a non-retryable 429", async () => {
Expand Down
Loading