merge: integrate origin/master (v0.5.50) into gitea/new_feature
- Resolve conflicts in chatCore handlers: keep apiKey/streamErrorPatterns from the details-filters feature, adopt origin's stripContinuityFields, customToolNames, cache-inclusive usage accounting, and Responses-API SSE→JSON conversion - Adopt origin's provider usage handlers (codebuddy-intl, qoder creds) and modality detection (audio/video inputs) - Keep requestDetails apiKey column (schema v2) + masked key persistence Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
This commit is contained in:
@@ -4,6 +4,7 @@ import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import { dbg } from "../utils/debugLog.js";
|
||||
import { resolveProviderTimeoutMs } from "../services/providerTimeout.js";
|
||||
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
|
||||
import { resolveOpenAICompatibleApiType } from "../services/provider.js";
|
||||
|
||||
/**
|
||||
* BaseExecutor - Base class for provider executors
|
||||
@@ -31,7 +32,7 @@ export class BaseExecutor {
|
||||
if (this.provider?.startsWith?.("openai-compatible-")) {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE;
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
|
||||
const path = resolveOpenAICompatibleApiType(this.provider, credentials) === "responses" ? "/responses" : "/chat/completions";
|
||||
return `${normalized}${path}`;
|
||||
}
|
||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||
@@ -127,7 +128,7 @@ export class BaseExecutor {
|
||||
for (let urlIndex = 0; urlIndex < fallbackCount; urlIndex++) {
|
||||
const url = this.buildUrl(model, stream, urlIndex, credentials);
|
||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||
const headers = this.buildHeaders(credentials, stream, url);
|
||||
const headers = this.buildHeaders(credentials, stream, url, model);
|
||||
|
||||
if (!retryAttemptsByUrl[urlIndex]) retryAttemptsByUrl[urlIndex] = 0;
|
||||
|
||||
|
||||
@@ -18,6 +18,35 @@ export class CodeBuddyExecutor extends DefaultExecutor {
|
||||
const transformed = super.transformRequest(model, body, stream, credentials);
|
||||
transformed.stream = true;
|
||||
|
||||
// Tencent's content filter flags CLI agent system prompts ("You are Claude
|
||||
// Code, Anthropic's official CLI...") as prompt injection / sensitive content
|
||||
// and rejects the whole request. Detect agent system prompts (length catch-all
|
||||
// + identity-marker regex) and replace them with a neutral one, while leaving
|
||||
// legitimate user system prompts untouched. content may be a string or typed
|
||||
// blocks ([{type:"text",text}]) depending on the incoming client format, so
|
||||
// flatten before matching and preserve the original shape on replacement.
|
||||
const NEUTRAL_PROMPT = "You are a helpful AI assistant that helps with software engineering tasks.";
|
||||
const AGENT_PATTERN = /you are claude code|claude.?code.+official.+cli|anthropic.+official.+cli|anxthxropic.+official.+cli|you are (?:cursor|windsurf|cline|aider|continue|copilot|cody)|you are an? (?:ai )?(?:coding |code )?agent|cc_entrypoint\s*=\s*(?:cli|vscode|jetbrains|gui)|claude.?code.+issues|give feedback.+claude.?code|you are .{0,30}(?:powerful )?ai agent|orchestration capabilities|OhMyOpenCode|<agent-identity>|<Role>|<Behavior_Instructions>/i;
|
||||
const flatten = (content) =>
|
||||
typeof content === "string"
|
||||
? content
|
||||
: Array.isArray(content)
|
||||
? content.map((b) => (b && typeof b.text === "string" ? b.text : "")).join("\n")
|
||||
: "";
|
||||
if (Array.isArray(transformed.messages)) {
|
||||
transformed.messages = transformed.messages.map((message) => {
|
||||
if (!message || message.role !== "system") return message;
|
||||
const text = flatten(message.content);
|
||||
if (!text) return message;
|
||||
if (text.length > 2000 || AGENT_PATTERN.test(text)) {
|
||||
return typeof message.content === "string"
|
||||
? { ...message, content: NEUTRAL_PROMPT }
|
||||
: { ...message, content: [{ type: "text", text: NEUTRAL_PROMPT }] };
|
||||
}
|
||||
return message;
|
||||
});
|
||||
}
|
||||
|
||||
// CodeBuddy only surfaces model reasoning when the request carries the CLI's
|
||||
// OpenAI-style params: reasoning_effort + reasoning_summary:"auto". 9router's
|
||||
// thinking pipeline sets reasoning_effort only when the client asks, and never
|
||||
|
||||
@@ -23,6 +23,20 @@ export class CodeBuddyIntlExecutor extends DefaultExecutor {
|
||||
} else if (eff) {
|
||||
transformed.reasoning_summary = "auto";
|
||||
}
|
||||
|
||||
// CodeBuddy rejects plain OpenAI shape (11101 invalid request): needs a
|
||||
// leading system prompt + user content as typed blocks, not a bare string.
|
||||
const source = Array.isArray(transformed.messages) ? transformed.messages : [];
|
||||
transformed.messages = [{ role: "system", content: "You are CodeBuddy Code." }];
|
||||
for (const message of source) {
|
||||
if (!message || typeof message !== "object" || ["system", "developer"].includes(message.role)) continue;
|
||||
if (message.role === "user" && typeof message.content === "string") {
|
||||
transformed.messages.push({ ...message, content: [{ type: "text", text: message.content }] });
|
||||
} else {
|
||||
transformed.messages.push({ ...message });
|
||||
}
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { normalizeResponsesInput } from "../translator/formats/responsesApi.js";
|
||||
import { fetchImageAsBase64 } from "../translator/concerns/image.js";
|
||||
import { getModelUpstreamId } from "../config/providerModels.js";
|
||||
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";
|
||||
@@ -124,8 +125,12 @@ function resolveCacheSessionId(body, credentials) {
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeReasoningEffort(value) {
|
||||
return value === "max" ? "xhigh" : value;
|
||||
function normalizeReasoningEffort(model, value) {
|
||||
const supportedLevels = getThinkingLevels("codex", model);
|
||||
if (supportedLevels?.includes(value)) return value;
|
||||
if (value === "ultra" && supportedLevels?.includes("max")) return "max";
|
||||
if (value === "max" || value === "ultra") return "xhigh";
|
||||
return value;
|
||||
}
|
||||
|
||||
function findNestedMessage(value, depth = 0) {
|
||||
@@ -440,10 +445,10 @@ export class CodexExecutor extends BaseExecutor {
|
||||
|
||||
// Priority: explicit reasoning.effort > reasoning_effort param > model suffix > default (medium)
|
||||
if (!body.reasoning) {
|
||||
const effort = normalizeReasoningEffort(body.reasoning_effort || modelEffort || 'low');
|
||||
const effort = normalizeReasoningEffort(body.model, body.reasoning_effort || modelEffort || 'low');
|
||||
body.reasoning = { effort, summary: "auto" };
|
||||
} else {
|
||||
body.reasoning.effort = normalizeReasoningEffort(body.reasoning.effort);
|
||||
body.reasoning.effort = normalizeReasoningEffort(body.model, body.reasoning.effort);
|
||||
if (!body.reasoning.summary) body.reasoning.summary = "auto";
|
||||
}
|
||||
delete body.reasoning_effort;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js";
|
||||
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
|
||||
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE, selectAnthropicBeta } from "../providers/shared.js";
|
||||
import { resolveOpenAICompatibleApiType } from "../services/provider.js";
|
||||
import { OAUTH_ENDPOINTS, buildKimiHeaders } from "../config/appConstants.js";
|
||||
import { buildClineHeaders } from "../shared/clineAuth.js";
|
||||
import { getCachedClaudeHeaders } from "../utils/claudeHeaderCache.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import { injectReasoningContent } from "../utils/reasoningContentInjector.js";
|
||||
import { stripUnsupportedParams } from "../translator/concerns/paramSupport.js";
|
||||
@@ -42,21 +42,6 @@ const HEADER_HOOKS = {
|
||||
kimiHeaders: (h, c) => Object.assign(h, buildKimiHeaders(c?.providerSpecificData?.deviceId)),
|
||||
clineHeaders: (h, c) => Object.assign(h, buildClineHeaders(c.apiKey || c.accessToken)),
|
||||
kilocodeOrg: (h, c) => { if (c.providerSpecificData?.orgId) h["X-Kilocode-OrganizationID"] = c.providerSpecificData.orgId; },
|
||||
claudeOverlay: (h) => {
|
||||
const cached = getCachedClaudeHeaders();
|
||||
if (!cached) return;
|
||||
for (const lcKey of Object.keys(cached)) {
|
||||
const titleKey = lcKey.replace(/(^|-)([a-z])/g, (_, sep, ch) => sep + ch.toUpperCase());
|
||||
if (lcKey === "anthropic-beta") {
|
||||
const staticBetaStr = h[titleKey] || h[lcKey] || "";
|
||||
const flags = new Set(staticBetaStr.split(",").map(f => f.trim()).filter(Boolean));
|
||||
for (const f of cached[lcKey].split(",").map(f => f.trim()).filter(Boolean)) flags.add(f);
|
||||
cached[lcKey] = Array.from(flags).join(",");
|
||||
}
|
||||
if (titleKey !== lcKey && h[titleKey] !== undefined) delete h[titleKey];
|
||||
}
|
||||
Object.assign(h, cached);
|
||||
},
|
||||
};
|
||||
|
||||
// Config-driven OAuth refresh grants — derived from registry oauth.refresh.
|
||||
@@ -125,7 +110,7 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
if (this.provider?.startsWith?.("openai-compatible-")) {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || OPENAI_COMPAT_BASE;
|
||||
const normalized = baseUrl.replace(/\/$/, "");
|
||||
const path = this.provider.includes("responses") ? "/responses" : "/chat/completions";
|
||||
const path = resolveOpenAICompatibleApiType(this.provider, credentials) === "responses" ? "/responses" : "/chat/completions";
|
||||
return `${normalized}${path}`;
|
||||
}
|
||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||
@@ -161,14 +146,18 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
return BEARER;
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true) {
|
||||
buildHeaders(credentials, stream = true, url, model) {
|
||||
const rt = credentials?.runtimeTransport;
|
||||
const headers = { "Content-Type": "application/json", ...(rt ? rt.headers : this.config.headers) };
|
||||
const desc = rt?.auth || AUTH_DESCRIPTORS[this.provider] || this.resolveAuthDescriptor();
|
||||
// Hooks run BEFORE auth so dynamic overlays (claude cached headers) can't clobber the token.
|
||||
// Hooks run BEFORE auth so dynamic overlays can't clobber the token.
|
||||
for (const hook of desc.hooks || []) HEADER_HOOKS[hook]?.(headers, credentials);
|
||||
applyAuth(headers, desc, credentials);
|
||||
|
||||
if (this.provider === "claude" && model) {
|
||||
headers["Anthropic-Beta"] = selectAnthropicBeta(model);
|
||||
}
|
||||
|
||||
// Strip first-party Claude Code identity headers for non-Anthropic anthropic-compatible upstreams
|
||||
if (this.provider?.startsWith?.("anthropic-compatible-")) {
|
||||
const baseUrl = credentials?.providerSpecificData?.baseUrl || "";
|
||||
@@ -222,7 +211,6 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
const refreshers = {
|
||||
claude: () => this.refreshFromGrant(credentials, proxyOptions),
|
||||
codex: () => this.refreshFromGrant(credentials, proxyOptions),
|
||||
qwen: () => this.refreshWithForm(OAUTH_ENDPOINTS.qwen.token, { grant_type: "refresh_token", refresh_token: credentials.refreshToken, client_id: PROVIDERS.qwen.clientId }, proxyOptions),
|
||||
iflow: () => this.refreshIflow(credentials.refreshToken, proxyOptions),
|
||||
gemini: () => this.refreshFromGrant(credentials, proxyOptions),
|
||||
kiro: () => this.refreshKiro(credentials.refreshToken, proxyOptions),
|
||||
|
||||
@@ -9,7 +9,6 @@ import { KimchiExecutor } from "./kimchi.js";
|
||||
import { CodexExecutor } from "./codex.js";
|
||||
import { CursorExecutor } from "./cursor.js";
|
||||
import { VertexExecutor } from "./vertex.js";
|
||||
import { QwenExecutor } from "./qwen.js";
|
||||
import { OpenCodeExecutor } from "./opencode.js";
|
||||
import { OpenCodeGoExecutor } from "./opencode-go.js";
|
||||
import { GrokWebExecutor } from "./grok-web.js";
|
||||
@@ -41,7 +40,6 @@ const executors = {
|
||||
cu: new CursorExecutor(), // Alias for cursor
|
||||
vertex: new VertexExecutor("vertex"),
|
||||
"vertex-partner": new VertexExecutor("vertex-partner"),
|
||||
qwen: new QwenExecutor(),
|
||||
opencode: new OpenCodeExecutor(),
|
||||
"opencode-go": new OpenCodeGoExecutor(),
|
||||
"grok-web": new GrokWebExecutor(),
|
||||
@@ -87,7 +85,6 @@ export { CodexExecutor } from "./codex.js";
|
||||
export { CursorExecutor } from "./cursor.js";
|
||||
export { VertexExecutor } from "./vertex.js";
|
||||
export { DefaultExecutor } from "./default.js";
|
||||
export { QwenExecutor } from "./qwen.js";
|
||||
export { OpenCodeExecutor } from "./opencode.js";
|
||||
export { OpenCodeGoExecutor } from "./opencode-go.js";
|
||||
export { GrokWebExecutor } from "./grok-web.js";
|
||||
|
||||
@@ -33,13 +33,11 @@ import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
|
||||
import { resolveProviderTimeoutMs } from "../services/providerTimeout.js";
|
||||
import {
|
||||
QODER_CHAT_URL_ENCODED,
|
||||
QODER_JOB_TOKEN_EXCHANGE_URL,
|
||||
QODER_USERINFO_URL,
|
||||
QODER_CHAT_BASE_ALT,
|
||||
QODER_CHAT_SIG_PATH,
|
||||
QODER_MODEL_MAP,
|
||||
QODER_IDE_VERSION,
|
||||
QODER_CLIENT_TYPE,
|
||||
} from "../shared/qoder/constants.js";
|
||||
import { getQoderModelConfig, resolveQoderModels } from "../services/qoderModels.js";
|
||||
import { getQoderModelConfig, resolveQoderModels, isQoderPat, resolveQoderCredentials } from "../services/qoderModels.js";
|
||||
|
||||
/**
|
||||
* Hoist role:"system" messages out of the messages array (Qoder rejects
|
||||
@@ -343,98 +341,18 @@ function wrapQoderSSE(response, model) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── PAT (Personal Access Token) → job-token exchange ───────────────────────
|
||||
// PATs (pt-...) cannot sign COSY requests directly. Exchange them for a
|
||||
// short-lived job token (jt-...) via /api/v1/jobToken/exchange (plain JSON,
|
||||
// not COSY-signed), then resolve the userId from userinfo. Mirrors the
|
||||
// official qodercli flow. Cached per-PAT until near-expiry.
|
||||
const PAT_PREFIX = "pt-";
|
||||
const PAT_REFRESH_BUFFER_MS = 5 * 60 * 1000;
|
||||
const patJobCache = new Map();
|
||||
|
||||
export function isQoderPat(token) {
|
||||
return typeof token === "string" && token.startsWith(PAT_PREFIX);
|
||||
}
|
||||
|
||||
async function exchangeJobToken(pat, proxyOptions = null, signal = null) {
|
||||
const res = await proxyAwareFetch(
|
||||
QODER_JOB_TOKEN_EXCHANGE_URL,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "qodercli/1.0.0",
|
||||
"Cosy-Version": QODER_IDE_VERSION,
|
||||
"Cosy-ClientType": QODER_CLIENT_TYPE,
|
||||
},
|
||||
body: JSON.stringify({ personal_token: pat }),
|
||||
signal,
|
||||
},
|
||||
proxyOptions,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`qoder PAT exchange failed: ${res.status} ${text.slice(0, 200)}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.token) throw new Error("qoder PAT exchange returned no job token");
|
||||
|
||||
let expiresAt = Date.now() + 24 * 60 * 60 * 1000;
|
||||
if (data.expires_at) {
|
||||
const parsed = Date.parse(data.expires_at);
|
||||
if (!Number.isNaN(parsed)) expiresAt = parsed;
|
||||
} else if (typeof data.expires_in === "number" && data.expires_in > 0) {
|
||||
expiresAt = Date.now() + data.expires_in;
|
||||
}
|
||||
return { jobToken: data.token, jobRefreshToken: data.refresh_token || "", expiresAt };
|
||||
}
|
||||
|
||||
async function fetchUserIdForJobToken(jobToken, proxyOptions = null, signal = null) {
|
||||
try {
|
||||
const res = await proxyAwareFetch(
|
||||
QODER_USERINFO_URL,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${jobToken}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "qodercli/1.0.0",
|
||||
},
|
||||
signal,
|
||||
},
|
||||
proxyOptions,
|
||||
);
|
||||
if (!res.ok) return "";
|
||||
const info = await res.json().catch(() => ({}));
|
||||
return info.id || info.userId || info.user_id || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a PAT for a job token + userId, caching until near-expiry so repeat
|
||||
* chat requests don't re-exchange. Returns { accessToken, userId }.
|
||||
*/
|
||||
async function resolvePatCredential(pat, proxyOptions = null, signal = null) {
|
||||
const cached = patJobCache.get(pat);
|
||||
if (cached && cached.expiresAt - Date.now() > PAT_REFRESH_BUFFER_MS) {
|
||||
return cached;
|
||||
}
|
||||
const { jobToken, expiresAt } = await exchangeJobToken(pat, proxyOptions, signal);
|
||||
const userId = await fetchUserIdForJobToken(jobToken, proxyOptions, signal);
|
||||
const entry = { accessToken: jobToken, userId, expiresAt };
|
||||
patJobCache.set(pat, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
export class QoderExecutor extends BaseExecutor {
|
||||
constructor() {
|
||||
super("qoder", PROVIDERS.qoder);
|
||||
}
|
||||
|
||||
buildUrl() {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -444,36 +362,24 @@ export class QoderExecutor extends BaseExecutor {
|
||||
// - COSY headers built from the *encoded* body bytes
|
||||
// - response stream re-wrapped from {statusCodeValue, body} to OpenAI SSE
|
||||
async execute({ model, body, stream, credentials, signal, log, proxyOptions = null }) {
|
||||
const url = this.buildUrl();
|
||||
|
||||
// PAT (pt-...) → exchange for short-lived job token + resolve userId so
|
||||
// downstream COSY signing + catalog fetch work. Device tokens (dt-...) and
|
||||
// job tokens (jt-...) skip this and are used directly.
|
||||
const rawToken = credentials?.apiKey || credentials?.accessToken;
|
||||
if (isQoderPat(rawToken)) {
|
||||
try {
|
||||
const resolved = await resolvePatCredential(rawToken, proxyOptions, signal);
|
||||
credentials = {
|
||||
...credentials,
|
||||
accessToken: resolved.accessToken,
|
||||
apiKey: undefined,
|
||||
providerSpecificData: {
|
||||
authMethod: "pat",
|
||||
...(credentials?.providerSpecificData || {}),
|
||||
userId: resolved.userId || credentials?.providerSpecificData?.userId || "",
|
||||
machineId: credentials?.providerSpecificData?.machineId || "",
|
||||
},
|
||||
};
|
||||
credentials = await resolveQoderCredentials(credentials, proxyOptions, signal);
|
||||
} catch (err) {
|
||||
log?.error?.("QODER", `PAT exchange failed: ${err.message}`);
|
||||
const fakeResp = new Response(
|
||||
JSON.stringify({ error: { message: `qoder PAT exchange failed: ${err.message}` } }),
|
||||
{ status: 401, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
return { response: fakeResp, url, headers: {}, transformedBody: body };
|
||||
return { response: fakeResp, url: this.buildUrl(credentials), headers: {}, transformedBody: body };
|
||||
}
|
||||
}
|
||||
|
||||
const url = this.buildUrl(credentials);
|
||||
const psd = credentials?.providerSpecificData || {};
|
||||
if (!psd.userId) {
|
||||
// No user id → no way to sign. Surface a 401 so the dashboard nudges
|
||||
@@ -591,6 +497,4 @@ export const __test__ = {
|
||||
normalizeMessages,
|
||||
wrapQoderSSE,
|
||||
buildQoderRequestBody,
|
||||
isQoderPat,
|
||||
resolvePatCredential,
|
||||
};
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { DefaultExecutor } from "./default.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { OAUTH_ENDPOINTS } from "../config/appConstants.js";
|
||||
|
||||
/** portal.qwen.ai — static fingerprint matching stable Qwen Code release */
|
||||
const QWEN_USER_AGENT = "QwenCode/0.12.3 (linux; x64)";
|
||||
const QWEN_STAINLESS = {
|
||||
os: "Linux",
|
||||
arch: "x64",
|
||||
lang: "js",
|
||||
runtime: "node",
|
||||
runtimeVersion: "v18.19.1",
|
||||
packageVersion: "5.11.0",
|
||||
retryCount: "1"
|
||||
};
|
||||
const QWEN_DEFAULT_SYSTEM_MESSAGE = {
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "", cache_control: { type: "ephemeral" } }]
|
||||
};
|
||||
|
||||
function ensureQwenSystemMessage(body) {
|
||||
if (!body || typeof body !== "object") return body;
|
||||
const next = { ...body };
|
||||
if (Array.isArray(next.messages)) {
|
||||
next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE, ...next.messages];
|
||||
} else {
|
||||
next.messages = [QWEN_DEFAULT_SYSTEM_MESSAGE];
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function isQwenThinkingActive(body) {
|
||||
const thinking = body?.thinking;
|
||||
if (thinking === true || body?.enable_thinking === true) return true;
|
||||
return typeof thinking === "object" && thinking !== null && !Array.isArray(thinking) && thinking.type === "enabled";
|
||||
}
|
||||
|
||||
// Qwen rejects tool_choice="required" or object forms when thinking is active; neutralize to "auto".
|
||||
function sanitizeQwenThinkingToolChoice(body) {
|
||||
if (!isQwenThinkingActive(body)) return body;
|
||||
const tc = body.tool_choice;
|
||||
const incompatible = tc === "required" || (typeof tc === "object" && tc !== null);
|
||||
if (!incompatible) return body;
|
||||
return { ...body, tool_choice: "auto" };
|
||||
}
|
||||
|
||||
function buildQwenUpstreamHeaders(credentials, stream = true) {
|
||||
const token = credentials?.apiKey || credentials?.accessToken || "";
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": QWEN_USER_AGENT,
|
||||
"X-DashScope-AuthType": "qwen-oauth",
|
||||
"X-DashScope-CacheControl": "enable",
|
||||
"X-DashScope-UserAgent": QWEN_USER_AGENT,
|
||||
"X-Stainless-Arch": QWEN_STAINLESS.arch,
|
||||
"X-Stainless-Lang": QWEN_STAINLESS.lang,
|
||||
"X-Stainless-Os": QWEN_STAINLESS.os,
|
||||
"X-Stainless-Package-Version": QWEN_STAINLESS.packageVersion,
|
||||
"X-Stainless-Retry-Count": QWEN_STAINLESS.retryCount,
|
||||
"X-Stainless-Runtime": QWEN_STAINLESS.runtime,
|
||||
"X-Stainless-Runtime-Version": QWEN_STAINLESS.runtimeVersion,
|
||||
Connection: "keep-alive",
|
||||
"Accept-Language": "*",
|
||||
"Sec-Fetch-Mode": "cors"
|
||||
};
|
||||
headers.Accept = stream ? "text/event-stream" : "application/json";
|
||||
return headers;
|
||||
}
|
||||
|
||||
export class QwenExecutor extends DefaultExecutor {
|
||||
constructor() {
|
||||
super("qwen");
|
||||
}
|
||||
|
||||
// Qwen tokens are bound to a resource_url returned at OAuth time.
|
||||
// Using portal.qwen.ai when the token is issued for another shard returns 401/403.
|
||||
buildUrl(model, stream, urlIndex = 0, credentials = null) {
|
||||
const resourceUrl = credentials?.providerSpecificData?.resourceUrl;
|
||||
const host = resourceUrl ? resourceUrl.replace(/^https?:\/\//, "").replace(/\/$/, "") : "portal.qwen.ai";
|
||||
return `https://${host}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
buildHeaders(credentials, stream = true) {
|
||||
return buildQwenUpstreamHeaders(credentials, stream);
|
||||
}
|
||||
|
||||
transformRequest(model, body, stream, credentials) {
|
||||
let next = body && typeof body === "object" ? { ...body } : body;
|
||||
if (stream && next?.messages && !next.stream_options && !next.thinking && !next.enable_thinking && next.stream !== false) {
|
||||
next.stream_options = { include_usage: true };
|
||||
}
|
||||
next = sanitizeQwenThinkingToolChoice(next);
|
||||
return ensureQwenSystemMessage(next);
|
||||
}
|
||||
|
||||
// Override to capture resource_url from refresh response (required for buildUrl).
|
||||
async refreshCredentials(credentials, log) {
|
||||
if (!credentials?.refreshToken) return null;
|
||||
try {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.qwen.token, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: credentials.refreshToken,
|
||||
client_id: PROVIDERS.qwen.clientId
|
||||
})
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const tokens = await response.json();
|
||||
log?.info?.("TOKEN", "qwen refreshed");
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || credentials.refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: {
|
||||
...(credentials.providerSpecificData || {}),
|
||||
...(tokens.resource_url ? { resourceUrl: tokens.resource_url } : {})
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN", `qwen refresh error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default QwenExecutor;
|
||||
Reference in New Issue
Block a user