Merge origin/master (v0.5.75) into gitea/new_feature
Resolve conflicts: - package.json / cli/package.json: take 0.5.75 - .gitignore: union both sides (upstream 9router-*/temp files + local state dirs) - CHANGELOG.md: keep both blocks, v0.5.75 above v0.5.70 - nonStreamingHandler.js: merge imports (unwrapClineEnvelope + tokensForDetail/shouldPersistRequestDetail); drop dead appendRequestLog - providers/[id]/page.js: union useState blocks (compatible-model states + importingClineModels) Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
@@ -5,7 +5,7 @@ import { OAUTH_ENDPOINTS, ANTIGRAVITY_HEADERS, AG_DEFAULT_TOOLS, AG_TOOL_SUFFIX,
|
||||
import { HTTP_STATUS } from "../config/runtimeConfig.js";
|
||||
import { resolveSessionId, toNumericSessionId } from "../utils/sessionManager.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import { cleanJSONSchemaForAntigravity } from "../translator/formats/gemini.js";
|
||||
import { cleanJSONSchemaForAntigravity, normalizeGeminiContents } from "../translator/formats/gemini.js";
|
||||
import { DEFAULT_THINKING_AG_SIGNATURE } from "../config/defaultThinkingSignature.js";
|
||||
import { getGeminiThoughtSignatureSync } from "../services/thoughtSignatureStore.js";
|
||||
|
||||
@@ -193,7 +193,7 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
|
||||
// ─── Standard (non-image) request ───
|
||||
// Fix contents for Claude models via Antigravity
|
||||
const contents = body.request?.contents?.map(c => {
|
||||
const rawContents = (body.request?.contents || []).map(c => {
|
||||
let role = c.role;
|
||||
// functionResponse must be role "user" for Claude models
|
||||
if (c.parts?.some(p => p.functionResponse)) {
|
||||
@@ -226,15 +226,13 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
return p;
|
||||
});
|
||||
|
||||
const partsChanged = parts?.length !== c.parts?.length || modifiedParts?.some((p, idx) => p !== c.parts[idx]);
|
||||
if (role !== c.role || partsChanged) {
|
||||
return {
|
||||
...c, role,
|
||||
parts: modifiedParts || parts,
|
||||
};
|
||||
}
|
||||
return c;
|
||||
return {
|
||||
...c,
|
||||
role,
|
||||
parts: modifiedParts || parts || [],
|
||||
};
|
||||
});
|
||||
const contents = normalizeGeminiContents(rawContents);
|
||||
|
||||
// Sanitize tool schemas and function names before sending to Antigravity.
|
||||
let tools = body.request?.tools;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getThinkingLevels } from "../providers/thinkingLevels.js";
|
||||
import { DEFAULT_RETRY_CONFIG, HTTP_STATUS, resolveRetryEntry } from "../config/runtimeConfig.js";
|
||||
import { dbg } from "../utils/debugLog.js";
|
||||
import { resolveSessionId } from "../utils/sessionManager.js";
|
||||
import { stripCodexUnsupportedPatterns } from "../utils/codexToolSchema.js";
|
||||
|
||||
// SSE error patterns inside 200-OK bodies. Some retry same account first; capacity rotates accounts.
|
||||
const CODEX_SSE_RETRY_PATTERNS = ["server_is_overloaded", "service_unavailable_error"];
|
||||
@@ -72,6 +73,9 @@ function stripStoredItemReferences(body) {
|
||||
function normalizeCodexTools(body) {
|
||||
if (!Array.isArray(body.tools)) return;
|
||||
const validNames = new Set();
|
||||
// Codex's schema validator has no Unicode property escapes; a `pattern`
|
||||
// carrying `\p{...}` 400s the whole request on every account (#3922).
|
||||
const patternStats = { removed: 0 };
|
||||
body.tools = body.tools.filter((tool) => {
|
||||
if (!tool || typeof tool !== "object" || Array.isArray(tool)) return false;
|
||||
const type = typeof tool.type === "string" ? tool.type : "";
|
||||
@@ -80,6 +84,9 @@ function normalizeCodexTools(body) {
|
||||
for (const st of tool.tools) {
|
||||
const n = typeof st?.name === "string" ? st.name.trim().slice(0, 128) : "";
|
||||
if (n) validNames.add(n);
|
||||
if (st?.parameters && typeof st.parameters === "object") {
|
||||
st.parameters = stripCodexUnsupportedPatterns(st.parameters, patternStats);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -101,10 +108,13 @@ function normalizeCodexTools(body) {
|
||||
tool.type = "function";
|
||||
tool.name = name.slice(0, 128);
|
||||
if (description) tool.description = description;
|
||||
tool.parameters = parameters;
|
||||
tool.parameters = stripCodexUnsupportedPatterns(parameters, patternStats);
|
||||
validNames.add(name);
|
||||
return true;
|
||||
});
|
||||
if (patternStats.removed > 0) {
|
||||
dbg("CODEX", `stripped ${patternStats.removed} unsupported tool schema pattern(s)`);
|
||||
}
|
||||
// Drop tool_choice if it references an unknown function name
|
||||
if (body.tool_choice && typeof body.tool_choice === "object" && !Array.isArray(body.tool_choice)) {
|
||||
if (body.tool_choice.type === "function") {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { PerplexityWebExecutor } from "./perplexity-web.js";
|
||||
import { OllamaLocalExecutor } from "./ollama-local.js";
|
||||
import { CommandCodeExecutor } from "./commandcode.js";
|
||||
import { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
|
||||
import { XiaomiMimoExecutor } from "./xiaomi-mimo.js";
|
||||
import { MimoFreeExecutor } from "./mimo-free.js";
|
||||
import { CodeBuddyExecutor } from "./codebuddy-cn.js";
|
||||
import { CodeBuddyIntlExecutor } from "./codebuddy-intl.js";
|
||||
@@ -50,6 +51,7 @@ const executors = {
|
||||
"ollama-local": new OllamaLocalExecutor(),
|
||||
commandcode: new CommandCodeExecutor(),
|
||||
"xiaomi-tokenplan": new XiaomiTokenplanExecutor(),
|
||||
"xiaomi-mimo": new XiaomiMimoExecutor(),
|
||||
"mimo-free": new MimoFreeExecutor(),
|
||||
mmf: new MimoFreeExecutor(), // Alias for mimo-free
|
||||
"codebuddy-cn": new CodeBuddyExecutor(),
|
||||
@@ -93,6 +95,7 @@ export { PerplexityWebExecutor } from "./perplexity-web.js";
|
||||
export { OllamaLocalExecutor } from "./ollama-local.js";
|
||||
export { CommandCodeExecutor } from "./commandcode.js";
|
||||
export { XiaomiTokenplanExecutor } from "./xiaomi-tokenplan.js";
|
||||
export { XiaomiMimoExecutor } from "./xiaomi-mimo.js";
|
||||
export { MimoFreeExecutor } from "./mimo-free.js";
|
||||
export { CodeBuddyExecutor } from "./codebuddy-cn.js";
|
||||
export { CodeBuddyIntlExecutor } from "./codebuddy-intl.js";
|
||||
|
||||
@@ -127,12 +127,18 @@ async function readResponsePrefix(response, signal, maxBytes, timeoutMs) {
|
||||
return decoder.decode(concatChunks(chunks, totalBytes));
|
||||
}
|
||||
|
||||
// The instruction goes into the current user turn, never into a top-level
|
||||
// `systemPrompt`: kiro.dev answers any body carrying that field with
|
||||
// 400 REQUEST_BODY_INVALID, so writing it here turned every repair retry into
|
||||
// a hard failure.
|
||||
function appendRepairInstruction(body, kind) {
|
||||
const repaired = structuredClone(body || {});
|
||||
const instruction = REPAIR_INSTRUCTIONS[kind] || "Retry the previous incomplete Kiro response.";
|
||||
repaired.systemPrompt = repaired.systemPrompt
|
||||
? `${repaired.systemPrompt}\n\n${instruction}`
|
||||
: instruction;
|
||||
const msg = repaired?.conversationState?.currentMessage?.userInputMessage;
|
||||
if (msg) {
|
||||
const content = typeof msg.content === "string" ? msg.content : "";
|
||||
msg.content = content ? `${content}\n\n${instruction}` : instruction;
|
||||
}
|
||||
return repaired;
|
||||
}
|
||||
|
||||
@@ -259,6 +265,19 @@ export class KiroExecutor extends BaseExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// CLIRO parity for the Amazon surfaces: the Kiro runtime accepts the
|
||||
// SSO bearer header + agent-mode marker. Without these the deprecated
|
||||
// path gateway answers REQUEST_BODY_INVALID for modern payloads.
|
||||
if (credentials?.accessToken) {
|
||||
headers["x-amz-sso-bearer"] = credentials.accessToken;
|
||||
}
|
||||
headers["x-amzn-kiro-agent-mode"] = "spec";
|
||||
headers["x-amzn-codewhisperer-machine-id"] = "kiro-desktop";
|
||||
const profileArn = credentials?.providerSpecificData?.profileArn;
|
||||
if (profileArn) {
|
||||
headers["x-amzn-codewhisperer-profile-arn"] = profileArn;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
@@ -285,9 +304,13 @@ export class KiroExecutor extends BaseExecutor {
|
||||
// 403 "bearer token invalid", so they must hit the CodeWhisperer
|
||||
// *.amazonaws.com surface, and in the region the token was minted in
|
||||
// (the baseUrls are hardcoded us-east-1).
|
||||
const isCodeWhispererSurface =
|
||||
authMethod === "api_key" || authMethod === "external_idp" || authMethod === "idc";
|
||||
if (!isCodeWhispererSurface) return baseUrls;
|
||||
// Kiro deprecated the legacy path-style GenerateAssistantResponse on
|
||||
// runtime.*.kiro.dev (IDE 1.0.228+ moved to POST / + x-amz-target). The
|
||||
// path gateway now answers valid modern payloads with 400
|
||||
// REQUEST_BODY_INVALID, and 400 is terminal in BaseExecutor, so kiro.dev
|
||||
// must never be the first surface for any auth method. Amazon surfaces
|
||||
// reject foreign tokens with 401/403, which DO fall through, so trying
|
||||
// q/codewhisperer first is safe for every auth method (CLIRO parity).
|
||||
|
||||
const region = (credentials?.providerSpecificData?.region || "us-east-1").trim();
|
||||
const regionalize = (u) =>
|
||||
@@ -297,20 +320,17 @@ export class KiroExecutor extends BaseExecutor {
|
||||
|
||||
const amazon = baseUrls.filter((u) => u.includes("amazonaws.com")).map(regionalize);
|
||||
const others = baseUrls.filter((u) => !u.includes("amazonaws.com"));
|
||||
if (authMethod === "api_key") {
|
||||
const q = amazon.filter((u) => u.includes("://q."));
|
||||
const remaining = amazon.filter((u) => !u.includes("://q."));
|
||||
return q.length > 0
|
||||
? [...q, ...remaining, ...others]
|
||||
: [...amazon, ...others];
|
||||
}
|
||||
|
||||
return amazon.length > 0 ? [...amazon, ...others] : baseUrls;
|
||||
const q = amazon.filter((u) => u.includes("://q."));
|
||||
const remaining = amazon.filter((u) => !u.includes("://q."));
|
||||
return q.length > 0
|
||||
? [...q, ...remaining, ...others]
|
||||
: [...amazon, ...others];
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
const baseUrls = this.getOrderedBaseUrls(credentials);
|
||||
return baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
|
||||
const url = baseUrls[urlIndex] || baseUrls[0] || this.config.baseUrl;
|
||||
return url;
|
||||
}
|
||||
|
||||
// Retry only endpoint/auth-surface failures. Payload-invalid HTTP 400 must be
|
||||
|
||||
@@ -32,14 +32,16 @@ import { SSE_DONE } from "../utils/sseConstants.js";
|
||||
import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
|
||||
import { resolveProviderTimeoutMs } from "../services/providerTimeout.js";
|
||||
import {
|
||||
QODER_CHAT_URL_ENCODED,
|
||||
QODER_CHAT_BASE_ALT,
|
||||
QODER_CHAT_SIG_PATH,
|
||||
QODER_MODEL_MAP,
|
||||
QODER_CONTEXT_TIER_ENV,
|
||||
qoderInferenceBase,
|
||||
} 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";
|
||||
import { createQoderSseCoalescer } from "../shared/qoder/sse.js";
|
||||
import { rewriteQoderMessageAttachments } from "../shared/qoder/attachments.js";
|
||||
import { resolveQoderContextTier, applyQoderContextTier } from "../shared/qoder/contextTier.js";
|
||||
|
||||
/**
|
||||
* Hoist role:"system" messages out of the messages array (Qoder rejects
|
||||
@@ -71,15 +73,16 @@ function normalizeMessages(messages) {
|
||||
*
|
||||
* 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
|
||||
* blocks are kept as OpenAI-style `image_url` parts. Native qodercli
|
||||
* uploads inlined bytes to `/api/v2/image/upload` first and then sends
|
||||
* the OSS URL — `buildQoderRequestBody` does that rewrite before this
|
||||
* runs. Tiny leftover data URIs are still accepted. 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.
|
||||
* `image_url`. File/document blocks that survived rewrite become short
|
||||
* stubs so 30MB PDFs never land in agent_chat_generation.
|
||||
*/
|
||||
function normalizeContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
@@ -89,10 +92,24 @@ function normalizeContent(content) {
|
||||
const blocks = [];
|
||||
const textParts = [];
|
||||
let hasImage = false;
|
||||
|
||||
const pushText = (text) => {
|
||||
if (!text) return;
|
||||
if (hasImage || blocks.length) blocks.push({ type: OPENAI_BLOCK.TEXT, text });
|
||||
else textParts.push(text);
|
||||
};
|
||||
|
||||
const imageUrlOf = (item) => {
|
||||
if (typeof item.image_url === "string" && item.image_url) return item.image_url;
|
||||
if (typeof item.image_url?.url === "string" && item.image_url.url) return item.image_url.url;
|
||||
return null;
|
||||
};
|
||||
|
||||
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 } });
|
||||
const imageUrl = item.type === OPENAI_BLOCK.IMAGE_URL ? imageUrlOf(item) : null;
|
||||
if (imageUrl) {
|
||||
blocks.push({ type: OPENAI_BLOCK.IMAGE_URL, image_url: { url: imageUrl } });
|
||||
hasImage = true;
|
||||
} else if (item.type === CLAUDE_BLOCK.IMAGE && item.source) {
|
||||
// Claude base64/url image → OpenAI image_url equivalent.
|
||||
@@ -104,13 +121,14 @@ function normalizeContent(content) {
|
||||
blocks.push({ type: OPENAI_BLOCK.IMAGE_URL, image_url: { url } });
|
||||
hasImage = true;
|
||||
}
|
||||
} else if (item.type === OPENAI_BLOCK.FILE) {
|
||||
const name = item.file?.filename || item.file?.name || "file";
|
||||
pushText(`[file omitted: ${name} — Qoder reads documents via its file API, not inlined bytes]`);
|
||||
} else if (item.type === CLAUDE_BLOCK.DOCUMENT) {
|
||||
const name = item.title || "document";
|
||||
pushText(`[file omitted: ${name} — Qoder reads documents via its file API, not inlined bytes]`);
|
||||
} 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);
|
||||
}
|
||||
pushText(item.text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +208,7 @@ function truncate(s, n) {
|
||||
/**
|
||||
* Map the OpenAI-style request body into the exact shape Qoder expects.
|
||||
*/
|
||||
async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal }) {
|
||||
async function buildQoderRequestBody({ model, body, credentials, log, proxyOptions, signal, uploadFn = null }) {
|
||||
const qoderKey = String(model || "").replace(/^qoder\//, "");
|
||||
|
||||
// Fetch model config from dynamic API instead of relying on static QODER_MODEL_MAP.
|
||||
@@ -209,7 +227,30 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
|
||||
modelConfig = { ...retried, key: qoderKey };
|
||||
}
|
||||
|
||||
const { messages, systemText } = normalizeMessages(body.messages || []);
|
||||
const incoming = Array.isArray(body.messages)
|
||||
? body.messages.map((m) => {
|
||||
if (!m || typeof m !== "object") return m;
|
||||
return {
|
||||
...m,
|
||||
content: Array.isArray(m.content)
|
||||
? m.content.map((b) => (b && typeof b === "object" ? { ...b } : b))
|
||||
: m.content,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
try {
|
||||
await rewriteQoderMessageAttachments(incoming, {
|
||||
credentials,
|
||||
log,
|
||||
proxyOptions,
|
||||
signal,
|
||||
uploadFn,
|
||||
});
|
||||
} catch (err) {
|
||||
log?.warn?.("QODER", `attachment rewrite failed: ${err.message}`);
|
||||
}
|
||||
|
||||
const { messages, systemText } = normalizeMessages(incoming);
|
||||
const tools = body.tools;
|
||||
const isReasoning = !!modelConfig.is_reasoning;
|
||||
const maxOutputTokens = Number(modelConfig.max_output_tokens) || 0;
|
||||
@@ -228,7 +269,21 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
|
||||
const sessionId = stableHash("qoder-session", psd.userId, qoderKey);
|
||||
const recordId = stableChatRecordId(qoderKey, messages, tools, maxTokens);
|
||||
|
||||
return {
|
||||
// Context-window tier (200K/400K/1M): the IDE picks one from model_config.context_config;
|
||||
// qodercli-style requests default to the smallest. Escalate when the prompt no longer fits.
|
||||
const tierChoice = resolveQoderContextTier(
|
||||
modelConfig,
|
||||
{ system: systemText, messages, tools },
|
||||
{ preference: process.env[QODER_CONTEXT_TIER_ENV] },
|
||||
);
|
||||
if (tierChoice) {
|
||||
log?.info?.(
|
||||
"QODER",
|
||||
`context tier ${tierChoice.tier.name} (${tierChoice.tier.tokenCount} tokens, ${tierChoice.reason}) for ~${tierChoice.estimatedTokens} prompt tokens`,
|
||||
);
|
||||
}
|
||||
|
||||
const built = {
|
||||
qoderKey,
|
||||
payload: {
|
||||
request_id: uuidv4(),
|
||||
@@ -276,6 +331,8 @@ async function buildQoderRequestBody({ model, body, credentials, log, proxyOptio
|
||||
},
|
||||
modelConfig,
|
||||
};
|
||||
if (tierChoice) applyQoderContextTier(built.payload, tierChoice.tier);
|
||||
return built;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,6 +396,11 @@ async function peekFirstQoderFrame(reader, decoder) {
|
||||
* response.text() which hangs until the socket closes — so on terminal
|
||||
* events we cancel the upstream reader and close our stream immediately.
|
||||
*
|
||||
* Usage: Qoder puts finish_reason on `delta` and sends token counts on a
|
||||
* later `choices: []` frame. Downstream OpenAI/Claude clients only read
|
||||
* usage from the finish chunk, so we coalesce those two frames (see
|
||||
* createQoderSseCoalescer) before forwarding.
|
||||
*
|
||||
* NEW: Peek first frame to detect billing blocks (code 112/10605/pricingUrl).
|
||||
* If detected, return 403 response so chatCore marks connection unavailable
|
||||
* and triggers combo fallback instead of leaking error text into chat.
|
||||
@@ -365,6 +427,11 @@ async function wrapQoderSSE(response, model) {
|
||||
const upstreamDrained = peek.upstreamDone === true;
|
||||
const encoder = new TextEncoder();
|
||||
let doneEmitted = false;
|
||||
const coalescer = createQoderSseCoalescer({ model, encoder, sseDone: SSE_DONE });
|
||||
|
||||
const syncDone = () => {
|
||||
if (coalescer.doneEmitted) doneEmitted = true;
|
||||
};
|
||||
|
||||
// Process one already-extracted SSE line (no trailing newline).
|
||||
const processLine = (line, controller) => {
|
||||
@@ -375,15 +442,17 @@ async function wrapQoderSSE(response, model) {
|
||||
|
||||
const data = trimmed.slice(5).trimStart();
|
||||
if (data === "[DONE]") {
|
||||
controller.enqueue(encoder.encode(SSE_DONE));
|
||||
doneEmitted = true;
|
||||
coalescer.flush(controller);
|
||||
syncDone();
|
||||
return;
|
||||
}
|
||||
|
||||
let envelope;
|
||||
try { envelope = JSON.parse(data); } catch { return; }
|
||||
const statusVal = typeof envelope.statusCodeValue === "number" ? envelope.statusCodeValue : 200;
|
||||
const inner = typeof envelope.body === "string" ? envelope.body : "";
|
||||
const inner = typeof envelope.body === "string"
|
||||
? envelope.body
|
||||
: envelope.body != null ? JSON.stringify(envelope.body) : "";
|
||||
if (statusVal !== 200) {
|
||||
const msg = inner || `upstream status ${statusVal}`;
|
||||
const errChunk = JSON.stringify({
|
||||
@@ -399,14 +468,8 @@ async function wrapQoderSSE(response, model) {
|
||||
return;
|
||||
}
|
||||
if (!inner) return;
|
||||
if (inner === "[DONE]") {
|
||||
controller.enqueue(encoder.encode(SSE_DONE));
|
||||
doneEmitted = true;
|
||||
return;
|
||||
}
|
||||
// Strip embedded newlines so the SSE frame stays a single event.
|
||||
const sanitized = inner.replace(/\r?\n/g, "");
|
||||
controller.enqueue(encoder.encode(`data: ${sanitized}\n\n`));
|
||||
coalescer.handleInner(inner, controller);
|
||||
syncDone();
|
||||
};
|
||||
|
||||
const stream = new ReadableStream({
|
||||
@@ -465,7 +528,7 @@ async function wrapQoderSSE(response, model) {
|
||||
} finally {
|
||||
if (!doneEmitted) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(SSE_DONE));
|
||||
coalescer.flush(controller);
|
||||
doneEmitted = true;
|
||||
} catch { /* already closed */ }
|
||||
}
|
||||
@@ -494,13 +557,7 @@ export class QoderExecutor extends BaseExecutor {
|
||||
}
|
||||
|
||||
buildUrl(credentials) {
|
||||
// Job-token (jt-...) traffic must hit api2.qoder.sh — api3 rejects jt-
|
||||
// with "Login expired" (403). Device tokens (dt-...) stay on api3.
|
||||
const raw = credentials?.apiKey || credentials?.accessToken;
|
||||
if (typeof raw === "string" && !raw.startsWith("pt-") && (raw.startsWith("jt-") || (credentials?.accessToken || "").startsWith("jt-"))) {
|
||||
return `${QODER_CHAT_BASE_ALT}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`;
|
||||
}
|
||||
return QODER_CHAT_URL_ENCODED;
|
||||
return `${qoderInferenceBase(credentials)}/algo${QODER_CHAT_SIG_PATH}?FetchKeys=llm_model_result&AgentId=agent_common&Encode=1`;
|
||||
}
|
||||
|
||||
// Override execute entirely — Qoder needs:
|
||||
|
||||
99
open-sse/executors/xiaomi-mimo.js
Normal file
99
open-sse/executors/xiaomi-mimo.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { DefaultExecutor } from "./default.js";
|
||||
import { getMimoAccountCookie, invalidateMimoAccountCookieCache, MIMO_API_BASE, MIMO_API_UA } from "../shared/mimoAccount.js";
|
||||
|
||||
// Desktop-exclusive Preview models. These are served by the account service's
|
||||
// /api/route proxy, authorized by the Xiaomi account session (NOT the sk- key).
|
||||
// See shared/mimoAccount.js for the session handshake.
|
||||
const PREVIEW_MODELS = new Set(["mimo-x-pro-preview", "mimo-x-flash-preview"]);
|
||||
|
||||
// Session cookie resolved in execute() (async) and read back by buildHeaders()
|
||||
// (sync — BaseExecutor.execute does not await it). Carried on the per-request
|
||||
// credentials object, same as runtimeTransport.
|
||||
const COOKIE_KEY = "__mimoAccountCookie";
|
||||
|
||||
// Upstream calls may hand us either the bare id or a `provider/model` ref.
|
||||
function bareModel(model) {
|
||||
const s = String(model || "");
|
||||
const i = s.indexOf("/");
|
||||
return i >= 0 ? s.slice(i + 1) : s;
|
||||
}
|
||||
|
||||
export class XiaomiMimoExecutor extends DefaultExecutor {
|
||||
constructor() {
|
||||
super("xiaomi-mimo");
|
||||
}
|
||||
|
||||
static isPreviewModel(model) {
|
||||
return PREVIEW_MODELS.has(bareModel(model));
|
||||
}
|
||||
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
// Preview models live on the account-service route, which is not one of the
|
||||
// declared transports — resolve it before the default runtimeTransport path.
|
||||
if (XiaomiMimoExecutor.isPreviewModel(model)) {
|
||||
return `${MIMO_API_BASE}/api/route/chat/completions`;
|
||||
}
|
||||
// Cloud API models keep default handling, so a Claude-format client reaches
|
||||
// the /anthropic/v1/messages transport.
|
||||
return super.buildUrl(model, stream, urlIndex, credentials);
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true, url, model) {
|
||||
if (XiaomiMimoExecutor.isPreviewModel(model) && credentials?.[COOKIE_KEY]) {
|
||||
// Preview models authenticate with the account-session cookie, not the key.
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
Accept: stream ? "text/event-stream" : "application/json",
|
||||
"User-Agent": MIMO_API_UA,
|
||||
Cookie: credentials[COOKIE_KEY],
|
||||
};
|
||||
}
|
||||
return super.buildHeaders(credentials, stream, url, model);
|
||||
}
|
||||
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
// super runs stripUnsupportedParams, which flattens Preview content-part
|
||||
// arrays (see the xiaomi-mimo rule in translator/concerns/paramSupport.js).
|
||||
const out = super.transformRequest(model, body, stream, credentials);
|
||||
|
||||
// Preview models: thinking/params get defaults only — never override what the
|
||||
// caller set explicitly. (body.model is already `xiaomi/<id>` via upstreamModelId.)
|
||||
if (XiaomiMimoExecutor.isPreviewModel(model)) {
|
||||
if (out.thinking == null) out.thinking = { type: "enabled" };
|
||||
if (out.temperature == null) out.temperature = 1.0;
|
||||
if (out.top_p == null) out.top_p = 0.95;
|
||||
if (!out.max_tokens) out.max_tokens = 4096;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
async execute(args) {
|
||||
const { model, credentials, proxyOptions = null } = args;
|
||||
if (!XiaomiMimoExecutor.isPreviewModel(model)) return super.execute(args);
|
||||
|
||||
const cookie = await getMimoAccountCookie(credentials?.providerSpecificData, proxyOptions);
|
||||
if (!cookie) {
|
||||
throw new Error(
|
||||
"Xiaomi MiMo account session unavailable. Sign in to MiMo Desktop once so its passToken is present, then retry.",
|
||||
);
|
||||
}
|
||||
credentials[COOKIE_KEY] = cookie;
|
||||
const result = await super.execute(args);
|
||||
|
||||
// A cached session can expire early — drop it and retry once with a fresh one.
|
||||
if (result.response.status === 401) {
|
||||
invalidateMimoAccountCookieCache();
|
||||
const fresh = await getMimoAccountCookie(credentials?.providerSpecificData, proxyOptions).catch(() => null);
|
||||
if (fresh) {
|
||||
credentials[COOKIE_KEY] = fresh;
|
||||
return super.execute(args);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const __test__ = { PREVIEW_MODELS, bareModel, COOKIE_KEY };
|
||||
|
||||
export default XiaomiMimoExecutor;
|
||||
Reference in New Issue
Block a user