From 2ab6a4c949dba6b24ddfd6985b17f64822102957 Mon Sep 17 00:00:00 2001 From: hangyu Date: Thu, 3 Sep 2026 23:01:53 +0700 Subject: [PATCH] feat(qoder): refresh model catalog, add capability mapping and image pass-through - Registry/constants: drop qmodel_preview/gm51model, add lite, qmodel_38max (Qwen3.8-Max), qfmodel (Qwen3.8-Flash), gmodel (GLM-5.3), gfmodel (GLM-5.3-Flash) - capabilities: add PROVIDER_CAPABILITIES['qoder'] so opaque internal ids resolve to their real models' context windows and limits - executor: preserve image blocks instead of flattening away, convert Claude-style image blocks, and hash images into chat_record_id - tests: cover image preservation, data-URI and Claude-block conversion - build(docker): use CN mirrors for apk and npm --- Dockerfile | 5 +- open-sse/executors/qoder.js | 74 +++++++++++++++++++++++++--- open-sse/providers/capabilities.js | 32 ++++++++++++ open-sse/providers/registry/qoder.js | 7 ++- open-sse/shared/qoder/constants.js | 5 +- tests/unit/qoder.test.js | 64 ++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 9e41ee2f..dfcee598 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,14 +2,15 @@ ARG NODE_IMAGE=node:22-alpine FROM ${NODE_IMAGE} AS base WORKDIR /app +# CN mirror for apk (used by builder and runner stages) +RUN sed -i 's|dl-cdn.alpinelinux.org|mirrors.aliyun.com|g' /etc/apk/repositories FROM base AS builder RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers COPY package.json ./ -RUN --mount=type=cache,target=/root/.npm \ - npm install +RUN npm install --registry=https://registry.npmmirror.com COPY . ./ ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index e52a00df..86055aba 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -37,10 +37,13 @@ import { QODER_MODEL_MAP, } from "../shared/qoder/constants.js"; import { getQoderModelConfig, resolveQoderModels, isQoderPat, resolveQoderCredentials } from "../services/qoderModels.js"; +import { OPENAI_BLOCK, CLAUDE_BLOCK } from "../translator/schema/blocks.js"; +import { encodeDataUri } from "../translator/concerns/image.js"; /** * Hoist role:"system" messages out of the messages array (Qoder rejects - * system in messages) and flatten any multipart content arrays. + * system in messages) and flatten multipart content arrays — EXCEPT image + * blocks, which are preserved (see normalizeContent). */ function normalizeMessages(messages) { if (!Array.isArray(messages) || messages.length === 0) { @@ -50,18 +53,72 @@ function normalizeMessages(messages) { const out = []; for (const msg of messages) { if (!msg || typeof msg !== "object") continue; - const text = extractText(msg.content); if (msg.role === "system") { + const text = extractText(msg.content); if (text) systemParts.push(text); continue; } const cloned = { ...msg }; - cloned.content = text; + cloned.content = normalizeContent(msg.content); out.push(cloned); } return { messages: out, systemText: systemParts.join("\n\n") }; } +/** + * Normalize one message's content for Qoder. + * + * Text-only content is flattened to a plain string (Qoder's historical + * shape). When images are present the content stays an array and image + * blocks are kept as OpenAI-style `image_url` parts — verified against the + * upstream: it accepts both http(s) URLs and inline base64 data: URIs + * directly, no pre-upload to the /image/upload OSS flow required (that is + * a qodercli client-side choice, not a protocol requirement). The legacy + * top-level `image_urls` / `chat_context.imageUrls` slots stay null — + * qodercli leaves them null too. + * + * Claude-style `{type:"image", source:{...}}` blocks are converted to + * `image_url` so claude-format clients also round-trip. + */ +function normalizeContent(content) { + if (typeof content === "string") return content; + if (content == null) return ""; + if (!Array.isArray(content)) return String(content); + + const blocks = []; + const textParts = []; + let hasImage = false; + for (const item of content) { + if (!item || typeof item !== "object") continue; + if (item.type === OPENAI_BLOCK.IMAGE_URL && typeof item.image_url?.url === "string" && item.image_url.url) { + blocks.push({ type: OPENAI_BLOCK.IMAGE_URL, image_url: { url: item.image_url.url } }); + hasImage = true; + } else if (item.type === CLAUDE_BLOCK.IMAGE && item.source) { + // Claude base64/url image → OpenAI image_url equivalent. + const src = item.source; + const url = src.type === "base64" && src.data + ? encodeDataUri(src.media_type || "image/png", src.data) + : typeof src.url === "string" && src.url ? src.url : null; + if (url) { + blocks.push({ type: OPENAI_BLOCK.IMAGE_URL, image_url: { url } }); + hasImage = true; + } + } else if (typeof item.text === "string" && item.text) { + if (hasImage || blocks.length) { + // Keep ordering faithful once images are in play. + blocks.push({ type: OPENAI_BLOCK.TEXT, text: item.text }); + } else { + textParts.push(item.text); + } + } + } + + if (!hasImage) return textParts.join("\n"); + // Prepend any text collected before the first image block. + if (textParts.length) blocks.unshift({ type: OPENAI_BLOCK.TEXT, text: textParts.join("\n") }); + return blocks; +} + function extractText(content) { if (typeof content === "string") return content; if (content == null) return ""; @@ -84,9 +141,9 @@ function extractText(content) { function lastUserText(messages) { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]; - if (m?.role === "user" && typeof m.content === "string") { - return m.content; - } + if (m?.role !== "user") continue; + if (typeof m.content === "string") return m.content; + if (Array.isArray(m.content)) return extractText(m.content); } return ""; } @@ -110,6 +167,11 @@ function stableChatRecordId(model, messages, tools, maxTokens) { if (m.role) { h.update("\0"); h.update(m.role); } if (typeof m.content === "string" && m.content) { h.update("\0"); h.update(m.content); + } else if (Array.isArray(m.content)) { + // Include image refs so the same prompt with a different image gets + // a distinct chat_record_id. + h.update("\0"); + try { h.update(JSON.stringify(m.content)); } catch {} } } if (tools) { diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 5a0b7271..91c6266b 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -206,6 +206,38 @@ export const PROVIDER_CAPABILITIES = { "deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 }, "deepseek-v3-2-volc": { reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 96000, maxOutput: 32000 }, }, + // Qoder — upstream exposes opaque internal ids (dfmodel, kmodel, …); the + // registry `name` is display-only and capability lookup matches on the raw + // id, so every qoder model would fall through to DEFAULT_CAPABILITIES + // (200K) without this map. contextWindow follows the real model family's + // spec: the /algo/api/v2/model/list max_input_tokens under-reports some + // windows (GLM-5.3 / Kimi-K3 / Qwen3.8-Max claim 180K but accept more). + // max_output_tokens arrives as 0 for every model, so outputs are + // best-guess from the real model family. Vision tags below follow the + // upstream is_vl flag per explicit request, even though the executor + // currently sends image_urls:null (image pass-through over the agent_chat + // SSE protocol is unverified). reasoning:true on all of them — every model can + // reason; the upstream is_reasoning flag only drives model_config selection. + // thinkingFormat keeps the true-model family for documentation/UI, but + // thinkingCanDisable:false everywhere: the executor only forwards + // messages/tools/max_tokens, and thinking is fixed upstream via + // modelConfig.is_reasoning — client thinking intent is dropped, so "none" + // must never be offered as an option. + "qoder": { + "ultimate": { vision: true, reasoning: true, thinkingFormat: "claude-adaptive", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // Claude Opus 5 + "performance": { vision: true, reasoning: true, thinkingFormat: "claude-adaptive", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // Claude Sonnet 5 + "dmodel": { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // DeepSeek-V4-Pro + "dfmodel": { reasoning: true, thinkingFormat: "deepseek", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // DeepSeek-V4-Flash + "gmodel": { reasoning: true, thinkingFormat: "zai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // GLM-5.3 + "gfmodel": { vision: true, reasoning: true, thinkingFormat: "zai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 128000 }, // GLM-5.3-Flash + "kmodel_latest": { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Kimi-K3 + "kmodel": { vision: true, reasoning: true, thinkingFormat: "kimi", thinkingCanDisable: false, contextWindow: 256000, maxOutput: 65536 }, // Kimi-K2.7-Code + "mmodel": { reasoning: true, thinkingFormat: "minimax", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 512000 }, // MiniMax-M3 + "qmodel_latest": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.7-Max + "qmodel": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.7-Plus + "qfmodel": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.8-Flash + "qmodel_38max": { vision: true, reasoning: true, thinkingFormat: "qwen", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 65536 }, // Qwen3.8-Max + }, // Poolside Laguna — OpenAI-compatible, all reasoning-capable (32K max output). "poolside": { "laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 }, diff --git a/open-sse/providers/registry/qoder.js b/open-sse/providers/registry/qoder.js index fe76fd72..042c83ae 100644 --- a/open-sse/providers/registry/qoder.js +++ b/open-sse/providers/registry/qoder.js @@ -30,12 +30,15 @@ export default { { id: "auto", name: "Auto" }, { id: "performance", name: "Performance" }, { id: "efficient", name: "Efficient" }, - { id: "qmodel_preview", name: "Qwen3.8-Max-Preview" }, + { id: "lite", name: "Lite" }, + { id: "qmodel_38max", name: "Qwen3.8-Max" }, { id: "qmodel_latest", name: "Qwen3.7-Max" }, { id: "qmodel", name: "Qwen3.7-Plus" }, + { id: "qfmodel", name: "Qwen3.8-Flash" }, { id: "kmodel_latest", name: "Kimi-K3" }, { id: "kmodel", name: "Kimi-K2.7-Code" }, - { id: "gm51model", name: "GLM-5.2" }, + { id: "gmodel", name: "GLM-5.3" }, + { id: "gfmodel", name: "GLM-5.3-Flash" }, { id: "dmodel", name: "DeepSeek-V4-Pro" }, { id: "dfmodel", name: "DeepSeek-V4-Flash" }, { id: "mmodel", name: "MiniMax-M3" }, diff --git a/open-sse/shared/qoder/constants.js b/open-sse/shared/qoder/constants.js index e2635f40..861e8f30 100644 --- a/open-sse/shared/qoder/constants.js +++ b/open-sse/shared/qoder/constants.js @@ -54,10 +54,13 @@ export const QODER_MODEL_MAP = { lite: "lite", // Frontier models qmodel: "qmodel", + qfmodel: "qfmodel", qmodel_latest: "qmodel_latest", + qmodel_38max: "qmodel_38max", dmodel: "dmodel", dfmodel: "dfmodel", - gm51model: "gm51model", + gmodel: "gmodel", + gfmodel: "gfmodel", kmodel: "kmodel", mmodel: "mmodel", }; diff --git a/tests/unit/qoder.test.js b/tests/unit/qoder.test.js index d8fce1ae..04ce1c45 100644 --- a/tests/unit/qoder.test.js +++ b/tests/unit/qoder.test.js @@ -367,6 +367,70 @@ describe("normalizeMessages", () => { expect(result.messages).toEqual([]); expect(result.systemText).toBe(""); }); + + it("preserves image_url blocks (http URL) instead of dropping them", () => { + const result = normalizeMessages([ + { + role: "user", + content: [ + { type: "text", text: "describe" }, + { type: "image_url", image_url: { url: "https://example.com/a.png" } }, + ], + }, + ]); + const content = result.messages[0].content; + expect(Array.isArray(content)).toBe(true); + expect(content).toContainEqual({ type: "text", text: "describe" }); + expect(content).toContainEqual({ type: "image_url", image_url: { url: "https://example.com/a.png" } }); + }); + + it("preserves base64 data: URI images (no OSS upload needed)", () => { + const dataUri = "data:image/png;base64,iVBORw0KGgo="; + const result = normalizeMessages([ + { + role: "user", + content: [ + { type: "image_url", image_url: { url: dataUri } }, + { type: "text", text: "what color?" }, + ], + }, + ]); + const content = result.messages[0].content; + expect(Array.isArray(content)).toBe(true); + expect(content[0]).toEqual({ type: "image_url", image_url: { url: dataUri } }); + expect(content.some((b) => b.type === "text" && b.text === "what color?")).toBe(true); + }); + + it("converts claude-style base64 image blocks to image_url data URIs", () => { + const result = normalizeMessages([ + { + role: "user", + content: [ + { type: "text", text: "see this" }, + { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "AAAA" } }, + ], + }, + ]); + const content = result.messages[0].content; + expect(content).toContainEqual({ + type: "image_url", + image_url: { url: "data:image/jpeg;base64,AAAA" }, + }); + }); + + it("drops image blocks with no usable url but keeps the text", () => { + const result = normalizeMessages([ + { + role: "user", + content: [ + { type: "text", text: "hi" }, + { type: "image_url", image_url: {} }, + { type: "image", source: { type: "base64" } }, + ], + }, + ]); + expect(result.messages[0].content).toBe("hi"); + }); }); describe("wrapQoderSSE", () => {