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: 1 addition & 1 deletion docs-site/src/content/docs/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -1392,7 +1392,7 @@ OpenCodex provides official adapter support for Qoder through the `qoder` (Globa
- **Token usage:** Qoder CLI reports zero token counts unless its usage switch is on, so each preset turns it on in its scoped CLI child environment: `QODER_EXPOSE_TOKEN_USAGE=true` for Global and `QODERCN_EXPOSE_TOKEN_USAGE=true` for CN, which reads only its own prefix.
- **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.
- **Output guard:** If the CLI echoes OpenCodex tool-call/result replay envelopes into assistant text, the turn fails with `vendor_scaffold_detected` (502) instead of returning the envelope as a successful answer, and neither reasoning nor tool calls following the envelope are forwarded. Reasoning starts a fresh replay-text block, so earlier prose or code fences cannot hide an envelope in later text. If the CLI's turn already ended with its own error, that error is reported instead, still without the envelope. The proxy does not retry that failure automatically. Ordinary prose and closed fenced examples follow the shared envelope recognizer; an unverified overflowing fenced example is refused. Assistant text preserves Unicode characters split across CLI frames; unpaired surrogates are replaced with U+FFFD.
- **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.
- **Image Input:** Global continues forwarding supported images through the shared projection without a per-model vision allowlist. CN retains its vision-sidecar defaults. Its raw-image route admits only one canonical base64 PNG of at most 1 MiB decoded in a single user turn with `DeepSeek-Flash` or `Qwen3.8-Flash`. Use `qoderclicn 1.1.70`: [issue #7516](https://github.com/lidge-jun/opencodex/issues/7516) reports Windows 11 x64 live success for those models on both `--attachment` and inline `--input-format stream-json --output-format stream-json` stdin; OpenCodex uses only stdin. The adapter validates base64 length/padding, decoded PNG signature and basic IHDR fields before CLI discovery; it does not decompress pixels or validate every PNG chunk/CRC. The 1 MiB ceiling is an OpenCodex resource policy, not a verified vendor maximum: the reported live evidence covers a 96×96 PNG, whose RGBA raster is only 36 KiB. Larger inputs require separate verification. Other CLI versions have not been verified, and the adapter does not probe the installed version. CN refuses raw URL images, other image formats/models, multiple images, tool-result images and image-bearing history/multi-turn replay before starting the CLI with `unsupported_input_modality`; use the vision sidecar for those inputs. Injected CLI tests cover serialization and cancellation without claiming live upstream support. Qoder's supported projection keeps image blocks in conversation order and marks unsupported references as `[image omitted: unsupported reference]`. Global stateless replay can still associate historical images unreliably with their original text; CN does not enable that unverified replay surface.
- **PAT recovery:** For both Qoder presets, a multi-key API-key pool can switch to another PAT on a supported authentication rejection or account-level quota exhaustion, but only before response content or an event that makes replay unsafe. Recovery requires Qoder-specific classification and respects credential-hop and send budgets; an HTTP 401/429 or quota message alone is not permission to replay. Single-key configurations do not rotate. Sharing the CLI runner does not enable this API-key recovery for CodeBuddy or self-authenticated Claude CLI.
- **Quota:** No public quota API is used, so totals and reset times are unavailable. Codes 110 (account daily limit) and 118 (personal Credits exhaustion) report `insufficient_quota` and permit consideration of PAT recovery. Other supported quota codes also report `insufficient_quota` but do not authorize it. Numeric error codes take precedence: an unrecognized numeric code does not fall back to quota or authentication text. Without a numeric code, only the supported account-daily message beginning “You've reached your daily usage limit for Chat”, with no team/model/shared qualifier, grants quota recovery; generic credit-limit text does not. Only quota failures eligible for PAT recovery enter key cooldown handling. When such a failure has no reset time, the failed key uses the existing default cooldown; nonrecoverable quota classifications do not create a cooldown through this path, and no next-day reset is inferred.
- **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
3 changes: 3 additions & 0 deletions scripts/test-layout/layout-adapters.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
{
"qoder-image-input.test.ts": "providers",
"qoder-cn-image-validation.test.ts": "providers",
"coding-agent-history-projection.test.ts": "adapters",
"cursor-native-grep-globs.test.ts": "providers/cursor",
"tool-identity-hash.test.ts": "adapters",
"eventstream-fragmentation.test.ts": "responses",
Expand Down
28 changes: 19 additions & 9 deletions src/adapters/coding-agent/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -731,11 +731,16 @@ function imagePart(imageUrl: string): WireContentPart | undefined {
return undefined;
}

export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: ReadonlyMap<string, string>): string {
/** Render replay prose; only Qoder opts into unsupported-reference history markers. */
export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?: ReadonlyMap<string, string>, markUnsupportedImages = false): string {
if (message.role === "user") {
const text = typeof message.content === "string"
? message.content
: message.content.map(p => (p.type === "text" || p.type === "document" ? p.text : `[${p.type}]`)).join("\n");
: message.content.map(p => {
if (p.type === "text" || p.type === "document") return p.text;
if (markUnsupportedImages && p.type === "image" && !imagePart(p.imageUrl)) return "[image omitted: unsupported reference]";
return `[${p.type}]`;
}).join("\n");
return `USER:\n${text}`;
}
if (message.role === "assistant") {
Expand All @@ -758,7 +763,11 @@ export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?:
if (message.role === "toolResult") {
const text = typeof message.content === "string"
? message.content
: message.content.map(p => (p.type === "text" || p.type === "document" ? p.text : "[image]")).join("");
: message.content.map(p => {
if (p.type === "text" || p.type === "document") return p.text;
if (markUnsupportedImages && p.type === "image" && !imagePart(p.imageUrl)) return "[image omitted: unsupported reference]";
return "[image]";
}).join("");
const status = message.isError ? " (error)" : "";
return `TOOL RESULT (call_id: ${message.toolCallId})${status}:\n${text}`;
}
Expand All @@ -774,7 +783,7 @@ export function formatMessageForHistory(message: OcxMessage, vendorNameByWire?:
*/
export function buildInputLines(
message: OcxMessage,
options: { vendorNameByWire?: ReadonlyMap<string, string> } = {},
options: { vendorNameByWire?: ReadonlyMap<string, string>; markUnsupportedImages?: boolean } = {},
): string[] {
if (message.role === "developer") return [];

Expand All @@ -788,6 +797,7 @@ export function buildInputLines(
else if (part.type === "image") {
const image = imagePart(part.imageUrl);
if (image) content.push(image);
else if (options.markUnsupportedImages) content.push(textPart("[image omitted: unsupported reference]"));
} else if (part.type === "document") {
content.push(textPart(part.text));
} else {
Expand All @@ -796,7 +806,7 @@ export function buildInputLines(
}
}
} else {
const formatted = formatMessageForHistory(message, options.vendorNameByWire);
const formatted = formatMessageForHistory(message, options.vendorNameByWire, options.markUnsupportedImages);
if (formatted) content.push(textPart(formatted));
}

Expand Down Expand Up @@ -832,15 +842,15 @@ export function buildSystemPrompt(parsed: OcxParsedRequest): string | undefined
*/
export function buildConversationInput(
parsed: OcxParsedRequest,
options: { maxHistoryChars?: number; vendorNameByWire?: ReadonlyMap<string, string> } = {},
options: { maxHistoryChars?: number; vendorNameByWire?: ReadonlyMap<string, string>; markUnsupportedImages?: boolean } = {},
): string[] {
const nonDev = parsed.context.messages.filter(m => m.role !== "developer");
if (nonDev.length === 0) {
return [JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text: "" }] } })];
}

if (nonDev.length === 1 && nonDev[0]!.role === "user") {
return buildInputLines(nonDev[0]!, { vendorNameByWire: options.vendorNameByWire });
return buildInputLines(nonDev[0]!, options);
}

// Multi-turn conversation or history with tool results:
Expand Down Expand Up @@ -911,14 +921,14 @@ export function buildConversationInput(
const status = currentMessage.isError ? " (error)" : "";
currentRequestText = `TOOL RESULT (call_id: ${currentMessage.toolCallId})${status}:\n${text}\n\nPlease proceed based on the above tool result.`;
} else {
currentRequestText = formatMessageForHistory(currentMessage, options.vendorNameByWire);
currentRequestText = formatMessageForHistory(currentMessage, options.vendorNameByWire, options.markUnsupportedImages);
}

const imageBlocks: WireContentPart[] = [...historyImageBlocks, ...currentImageBlocks];

const maxHistoryChars = options.maxHistoryChars ?? MAX_PROJECTED_HISTORY_CHARS;
let historyText = historyMessages
.map(msg => formatMessageForHistory(msg, options.vendorNameByWire))
.map(msg => formatMessageForHistory(msg, options.vendorNameByWire, options.markUnsupportedImages))
.filter(Boolean)
.join("\n\n");
if (historyText.length > maxHistoryChars) {
Expand Down
24 changes: 9 additions & 15 deletions src/adapters/qoder/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { baseScopedEnv, runCodingAgentTurn, type CodingAgentDeps, type CodingAge
import { QODER_PROFILES, resolveQoderProfile, type QoderProfile } from "./profiles";
import { QoderScaffoldFilter, QODER_SCAFFOLD_ERROR_CODE, qoderScaffoldErrorMessage } from "./scaffold-guard";
import { ToolEnvelopeEchoFilter } from "../../lib/tool-envelope-echo-filter";
import { buildQoderConversationInput, qoderCnImageInputSupported } from "./image-input";

export type QoderAdapterDeps = CodingAgentDeps;

Expand Down Expand Up @@ -275,21 +276,13 @@ 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> {
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;
}
if (resolveQoderProfile(provider.baseUrl)?.region === "cn" && !qoderCnImageInputSupported(parsed)) {
emit({
type: "error", status: 400, errorType: "invalid_request_error",
code: "unsupported_input_modality", retryable: false,
message: "Qoder CN raw-image routing is verified only with qoderclicn 1.1.70, DeepSeek-Flash or Qwen3.8-Flash, and one base64 PNG in a single user turn on stream-json stdin. URL, multiple-image, tool-result and history replay surfaces remain unverified; use the vision sidecar for those inputs.",
});
return;
}
let catalog: ReturnType<typeof buildCodingAgentToolCatalog>;
try {
Expand Down Expand Up @@ -325,6 +318,7 @@ export function createQoderAdapter(provider: OcxProviderConfig, deps: QoderAdapt
const system = buildSystemPrompt(parsed);
await runCodingAgentTurn({
profiles: QODER_PROFILES,
conversationInput: buildQoderConversationInput,
classifyFailure: classifyQoderFailure,
provider,
parsed,
Expand Down
52 changes: 52 additions & 0 deletions src/adapters/qoder/image-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { OcxParsedRequest } from "../../types";
import { buildConversationInput } from "../coding-agent/protocol";

// Local resource policy: #7516 verifies a 96x96 PNG, not a vendor size maximum.
// 1 MiB leaves ample room over its 36 KiB RGBA raster while bounding stdin copies.
const MAX_CN_PNG_BYTES = 1024 * 1024;
const PNG_DATA_PREFIX = "data:image/png;base64,";
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const PNG_DEPTHS: Readonly<Record<number, readonly number[]>> = {
0: [1, 2, 4, 8, 16], 2: [8, 16], 3: [1, 2, 4, 8], 4: [8, 16], 6: [8, 16],
};

function boundedPngDataUrl(imageUrl: string): boolean {
// Bound encoded input before slicing, scanning, decoding or making stdin copies.
if (!imageUrl.startsWith(PNG_DATA_PREFIX)
|| imageUrl.length > PNG_DATA_PREFIX.length + 4 * Math.ceil(MAX_CN_PNG_BYTES / 3)) return false;
const data = imageUrl.slice(PNG_DATA_PREFIX.length);
if (!data.length || data.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(data)) return false;
const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
if (data.length / 4 * 3 - padding > MAX_CN_PNG_BYTES) return false;
const bytes = Buffer.from(data, "base64");
// Bun's decoder is permissive; round-trip also rejects nonzero padding bits.
if (bytes.toString("base64") !== data || bytes.length < 33
|| !bytes.subarray(0, 8).equals(PNG_SIGNATURE)
|| bytes.readUInt32BE(8) !== 13 || bytes.readUInt32BE(12) !== 0x49484452) return false;
const width = bytes.readUInt32BE(16);
const height = bytes.readUInt32BE(20);
// Minimal IHDR validation only; this does not decompress or validate all chunks.
return width > 0 && width <= 0x7fffffff && height > 0 && height <= 0x7fffffff
&& (PNG_DEPTHS[bytes[25]!] ?? []).includes(bytes[24]!)
&& bytes[26] === 0 && bytes[27] === 0 && (bytes[28] === 0 || bytes[28] === 1);
}

/** #7516 live evidence: qoderclicn 1.1.70, one user PNG on stream-json stdin. */
export function qoderCnImageInputSupported(parsed: OcxParsedRequest): boolean {
const messages = parsed.context.messages;
const imageMessages = messages.filter(message => Array.isArray(message.content)
&& message.content.some(part => part.type === "image"));
if (imageMessages.length === 0) return true;
const nonDev = messages.filter(message => message.role !== "developer");
if (imageMessages.length !== 1 || nonDev.length !== 1 || imageMessages[0] !== nonDev[0]
|| nonDev[0]!.role !== "user"
|| !["DeepSeek-Flash", "Qwen3.8-Flash"].includes(parsed.modelId)) return false;
const content = nonDev[0]!.content;
if (!Array.isArray(content)) return false;
const images = content.filter(part => part.type === "image");
return images.length === 1 && boundedPngDataUrl(images[0]!.imageUrl);
}

/** Keep Qoder omission markers out of CodeBuddy's and Claude's replay formats. */
export const buildQoderConversationInput: typeof buildConversationInput = (parsed, options = {}) =>
buildConversationInput(parsed, { ...options, markUnsupportedImages: true });
2 changes: 1 addition & 1 deletion structure/providers-and-adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Combo target `metered` metadata and configured-key opt-in follow the [runtime co
Devin combines only [consecutive same-ID tool results](adapters/registry.md#devin-consecutive-tool-results), using linear accumulation while preserving intervening message slots, image parts and error markers without mutating the parsed request.
Claude CLI replay, client tool capture and usage admission follow [Claude CLI replay](providers/claude-cli-replay.md). Anthropic account pause, model routes, quota labels, and the separate `anthropic2` pool (its helpers, quota, reset grants and management surfaces) follow the [Anthropic account-pool contract](providers/anthropic-account-pool.md). Devin Messages follows the [per-turn output ordering contract](clients/claude-desktop.md#devin-messages-output-ordering), preserving late signatures before text/tools without changing Responses or Chat ordering. The DeepSeek Harness account provider follows the [DSH account contract](providers/dsh-account.md). WorkBuddy AI subscription authentication, catalog, display-only credit balances and inference follow the [WorkBuddy contract](providers/workbuddy.md).

API-key credit-balance refusals follow the [Anthropic billing recovery contract](providers/anthropic-key-billing.md). Managed native Anthropic serving UUID and observed CLI header continuity follow [native Messages](data-planes/protocol-paths.md#managed-native-messages); generated Responses retain the adapter's compatibility fingerprint.
API-key credit-balance refusals follow the [Anthropic billing recovery contract](providers/anthropic-key-billing.md). Managed native Anthropic serving UUID and observed CLI header continuity follow [native Messages](data-planes/protocol-paths.md#managed-native-messages); generated Responses retain the adapter's compatibility fingerprint. Qoder CN image admission and Qoder-only omission markers follow the [coding-agent image contract](providers/chat-compat.md#unmapped-modalities-are-recorded-not-dropped).

Opus 5.5 commentary follows the [signed progress-update contract](providers/anthropic-progress.md). Per-account usage thresholds follow the [Anthropic account thresholds contract](providers/anthropic-account-thresholds.md). Routed Anthropic compaction preserves the client tool/system prefix and guards summary output under the [compaction contract](transports/responses-failover.md#compaction-routing-overrides); image normalization and HTTP 413 recovery retain their existing policy.
A shared-quota Anthropic 429 or classified pre-output account 403 records the served account's cooldown even when the request has used its allowed retry sends. That final account remains excluded on the next request; combo target cooling for 429 is skipped only after the matching account cooldown is present. Exact revoked-token 401s persist generation-fenced reauthentication under [the Anthropic recovery contract](providers/anthropic-account-pool.md#revoked-oauth-access-token-recovery).
Expand Down
Loading
Loading