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
This commit is contained in:
hangyu
2026-09-03 23:01:53 +07:00
parent c08efdbe2b
commit 2ab6a4c949
6 changed files with 176 additions and 11 deletions

View File

@@ -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

View File

@@ -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) {

View File

@@ -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 },

View File

@@ -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" },

View File

@@ -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",
};

View File

@@ -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", () => {