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:
@@ -2,14 +2,15 @@
|
|||||||
ARG NODE_IMAGE=node:22-alpine
|
ARG NODE_IMAGE=node:22-alpine
|
||||||
FROM ${NODE_IMAGE} AS base
|
FROM ${NODE_IMAGE} AS base
|
||||||
WORKDIR /app
|
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
|
FROM base AS builder
|
||||||
|
|
||||||
RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers
|
RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers
|
||||||
|
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
RUN npm install --registry=https://registry.npmmirror.com
|
||||||
npm install
|
|
||||||
|
|
||||||
COPY . ./
|
COPY . ./
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|||||||
@@ -37,10 +37,13 @@ import {
|
|||||||
QODER_MODEL_MAP,
|
QODER_MODEL_MAP,
|
||||||
} from "../shared/qoder/constants.js";
|
} from "../shared/qoder/constants.js";
|
||||||
import { getQoderModelConfig, resolveQoderModels, isQoderPat, resolveQoderCredentials } from "../services/qoderModels.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
|
* 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) {
|
function normalizeMessages(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) {
|
if (!Array.isArray(messages) || messages.length === 0) {
|
||||||
@@ -50,18 +53,72 @@ function normalizeMessages(messages) {
|
|||||||
const out = [];
|
const out = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
if (!msg || typeof msg !== "object") continue;
|
if (!msg || typeof msg !== "object") continue;
|
||||||
const text = extractText(msg.content);
|
|
||||||
if (msg.role === "system") {
|
if (msg.role === "system") {
|
||||||
|
const text = extractText(msg.content);
|
||||||
if (text) systemParts.push(text);
|
if (text) systemParts.push(text);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cloned = { ...msg };
|
const cloned = { ...msg };
|
||||||
cloned.content = text;
|
cloned.content = normalizeContent(msg.content);
|
||||||
out.push(cloned);
|
out.push(cloned);
|
||||||
}
|
}
|
||||||
return { messages: out, systemText: systemParts.join("\n\n") };
|
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) {
|
function extractText(content) {
|
||||||
if (typeof content === "string") return content;
|
if (typeof content === "string") return content;
|
||||||
if (content == null) return "";
|
if (content == null) return "";
|
||||||
@@ -84,9 +141,9 @@ function extractText(content) {
|
|||||||
function lastUserText(messages) {
|
function lastUserText(messages) {
|
||||||
for (let i = messages.length - 1; i >= 0; i--) {
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
const m = messages[i];
|
const m = messages[i];
|
||||||
if (m?.role === "user" && typeof m.content === "string") {
|
if (m?.role !== "user") continue;
|
||||||
return m.content;
|
if (typeof m.content === "string") return m.content;
|
||||||
}
|
if (Array.isArray(m.content)) return extractText(m.content);
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
@@ -110,6 +167,11 @@ function stableChatRecordId(model, messages, tools, maxTokens) {
|
|||||||
if (m.role) { h.update("\0"); h.update(m.role); }
|
if (m.role) { h.update("\0"); h.update(m.role); }
|
||||||
if (typeof m.content === "string" && m.content) {
|
if (typeof m.content === "string" && m.content) {
|
||||||
h.update("\0"); h.update(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) {
|
if (tools) {
|
||||||
|
|||||||
@@ -206,6 +206,38 @@ export const PROVIDER_CAPABILITIES = {
|
|||||||
"deepseek-v4-flash": { vision: true, reasoning: true, thinkingFormat: "openai", thinkingCanDisable: false, contextWindow: 1000000, maxOutput: 50000 },
|
"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 },
|
"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 — OpenAI-compatible, all reasoning-capable (32K max output).
|
||||||
"poolside": {
|
"poolside": {
|
||||||
"laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 },
|
"laguna-s-2.1": { reasoning: true, thinkingFormat: "openai", contextWindow: 1000000, maxOutput: 32000 },
|
||||||
|
|||||||
@@ -30,12 +30,15 @@ export default {
|
|||||||
{ id: "auto", name: "Auto" },
|
{ id: "auto", name: "Auto" },
|
||||||
{ id: "performance", name: "Performance" },
|
{ id: "performance", name: "Performance" },
|
||||||
{ id: "efficient", name: "Efficient" },
|
{ 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_latest", name: "Qwen3.7-Max" },
|
||||||
{ id: "qmodel", name: "Qwen3.7-Plus" },
|
{ id: "qmodel", name: "Qwen3.7-Plus" },
|
||||||
|
{ id: "qfmodel", name: "Qwen3.8-Flash" },
|
||||||
{ id: "kmodel_latest", name: "Kimi-K3" },
|
{ id: "kmodel_latest", name: "Kimi-K3" },
|
||||||
{ id: "kmodel", name: "Kimi-K2.7-Code" },
|
{ 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: "dmodel", name: "DeepSeek-V4-Pro" },
|
||||||
{ id: "dfmodel", name: "DeepSeek-V4-Flash" },
|
{ id: "dfmodel", name: "DeepSeek-V4-Flash" },
|
||||||
{ id: "mmodel", name: "MiniMax-M3" },
|
{ id: "mmodel", name: "MiniMax-M3" },
|
||||||
|
|||||||
@@ -54,10 +54,13 @@ export const QODER_MODEL_MAP = {
|
|||||||
lite: "lite",
|
lite: "lite",
|
||||||
// Frontier models
|
// Frontier models
|
||||||
qmodel: "qmodel",
|
qmodel: "qmodel",
|
||||||
|
qfmodel: "qfmodel",
|
||||||
qmodel_latest: "qmodel_latest",
|
qmodel_latest: "qmodel_latest",
|
||||||
|
qmodel_38max: "qmodel_38max",
|
||||||
dmodel: "dmodel",
|
dmodel: "dmodel",
|
||||||
dfmodel: "dfmodel",
|
dfmodel: "dfmodel",
|
||||||
gm51model: "gm51model",
|
gmodel: "gmodel",
|
||||||
|
gfmodel: "gfmodel",
|
||||||
kmodel: "kmodel",
|
kmodel: "kmodel",
|
||||||
mmodel: "mmodel",
|
mmodel: "mmodel",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -367,6 +367,70 @@ describe("normalizeMessages", () => {
|
|||||||
expect(result.messages).toEqual([]);
|
expect(result.messages).toEqual([]);
|
||||||
expect(result.systemText).toBe("");
|
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", () => {
|
describe("wrapQoderSSE", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user