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:
2026-09-17 14:14:43 +07:00
113 changed files with 6634 additions and 482 deletions

View File

@@ -28,7 +28,7 @@ import { compressWithPxpipe } from "../rtk/pxpipe.js";
import { getCapabilitiesForModel } from "../providers/capabilities.js";
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
import { defaultClaudeToolType } from "../translator/concerns/toolCall.js";
import { defaultClaudeToolType, shouldDefaultClaudeToolType } from "../translator/concerns/toolCall.js";
import { resolveSessionId } from "../utils/sessionManager.js";
import { maybeRejectEarlyStreamError } from "../utils/streamErrorPeek.js";
@@ -246,7 +246,11 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
// Claude tool schema requires `type` to be explicitly set; strict gateways (e.g., MiniMax)
// reject legacy payloads that omit it with HTTP 400. Default to "custom" when missing.
if (finalFormat === FORMATS.CLAUDE && Array.isArray(translatedBody.tools)) {
// Provider-scoped via quirks (shouldDefaultClaudeToolType): only gateways that declare
// requireClaudeToolType get the explicit type. Applying it unconditionally breaks
// Claude-format endpoints that only accept the legacy typeless tool shape — DeepSeek's
// Anthropic-compatible endpoint 400s with "unknown variant `custom`" (#3905).
if (shouldDefaultClaudeToolType(provider, finalFormat, translatedBody.tools, PROVIDERS)) {
translatedBody.tools = defaultClaudeToolType(translatedBody.tools);
}

View File

@@ -6,6 +6,7 @@ import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTrackin
import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
import { unwrapClineEnvelope } from "../../shared/clineEnvelope.js";
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats, formatDoneLine, tokensForDetail, shouldPersistRequestDetail } from "./requestDetail.js";
import { saveRequestDetail } from "@/lib/usageDb.js";
import { matchStreamErrorPatterns } from "../../utils/streamErrorPatterns.js";
@@ -305,6 +306,11 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
}
}
// Unwrap before any consumer reads choices/usage so non-stream clients get a
// bare OpenAI body and usage tracking sees data.usage. No-op unless the
// provider opts in via transport.quirks.clineEnvelope.
responseBody = unwrapClineEnvelope(responseBody, provider);
reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody);
if (onRequestSuccess) {
Promise.resolve()

View File

@@ -2,13 +2,21 @@
import { randomUUID } from "node:crypto";
import { nowSec } from "./_base.js";
import { PROVIDERS } from "../../config/providers.js";
import { CODEX_CLI_VERSION } from "../../config/appConstants.js";
const CODEX_RESPONSES_URL = PROVIDERS["codex"].baseUrl;
const CODEX_USER_AGENT = "codex_cli_rs/0.136.0";
const CODEX_VERSION = "0.136.0";
const CODEX_USER_AGENT = `codex_cli_rs/${CODEX_CLI_VERSION}`;
const CODEX_ORIGINATOR = "codex_cli_rs";
const CODEX_MODEL_SUFFIX = "-image";
const CODEX_REF_DETAIL = "high";
const CODEX_IMAGES_MAIN_MODEL = "gpt-5.5";
const CODEX_TOOL_IMAGE_MODELS = new Set([
"gpt-image-1.5",
"gpt-image-2",
"gpt-image-2.5",
"gpt-image-2.5-flare",
"gpt-image-2.5-sunburst",
]);
function decodeAccountId(idToken) {
try {
@@ -27,6 +35,13 @@ function stripImageSuffix(model) {
return model.endsWith(CODEX_MODEL_SUFFIX) ? model.slice(0, -CODEX_MODEL_SUFFIX.length) : model;
}
function resolveCodexImageModels(model) {
if (CODEX_TOOL_IMAGE_MODELS.has(model)) {
return { responsesModel: CODEX_IMAGES_MAIN_MODEL, toolModel: model };
}
return { responsesModel: stripImageSuffix(model), toolModel: null };
}
function toDataUrl(input) {
if (!input || typeof input !== "string") return null;
if (/^data:image\//i.test(input) || /^https?:\/\//i.test(input)) return input;
@@ -157,7 +172,7 @@ export default {
"originator": CODEX_ORIGINATOR,
"session_id": randomUUID(),
"user-agent": CODEX_USER_AGENT,
"version": CODEX_VERSION,
"version": CODEX_CLI_VERSION,
"x-client-request-id": randomUUID(),
};
},
@@ -167,21 +182,26 @@ export default {
const single = toDataUrl(body.image);
if (single) refs.push(single);
const detail = body.image_detail || CODEX_REF_DETAIL;
const { responsesModel, toolModel } = resolveCodexImageModels(model);
const imgTool = { type: "image_generation", output_format: (body.output_format || "png").toLowerCase() };
if (toolModel) {
imgTool.action = refs.length > 0 ? "edit" : "generate";
imgTool.model = toolModel;
}
if (body.size && body.size !== "") imgTool.size = body.size;
if (body.quality && body.quality !== "") imgTool.quality = body.quality;
if (body.background && body.background !== "") imgTool.background = body.background;
return {
model: stripImageSuffix(model),
model: responsesModel,
instructions: "",
input: [{ type: "message", role: "user", content: buildContent(body.prompt, refs, detail) }],
tools: [imgTool],
tool_choice: "auto",
tool_choice: toolModel ? { type: "image_generation" } : "auto",
parallel_tool_calls: false,
prompt_cache_key: randomUUID(),
stream: true,
store: false,
reasoning: null,
reasoning: toolModel ? { effort: "medium", summary: "auto" } : null,
};
},
// Custom: codex parses SSE → either pipe to client or collect b64

View File

@@ -2,6 +2,7 @@ import { createErrorResult } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { refreshTokenByProvider } from "../services/tokenRefresh.js";
import { PROVIDER_MEDIA } from "../providers/index.js";
import { getVideoAdapter } from "./videoProviders/index.js";
// Upstream fetch deadline for video job submission/polling (the job itself is
// async upstream — this only bounds the HTTP round-trip, not video rendering).
@@ -94,21 +95,49 @@ export async function handleVideoProxyCore({
return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Unknown video action: ${action}`);
}
const method = requestId ? "GET" : "POST";
const url = buildUpstreamUrl(config, action, requestId);
const adapter = getVideoAdapter(provider);
const fetchSignal = combineSignals(signal, timeoutMs);
const doFetch = (token) =>
fetch(url, {
// Default (xAI shape) request plan; adapters override URL/method/headers/body.
const defaultPlan = () => {
const method = requestId ? "GET" : "POST";
return {
method,
headers: buildHeaders({ token, contentType: method === "POST" ? contentType : null, idempotencyKey: method === "POST" ? idempotencyKey : null }),
url: buildUpstreamUrl(config, action, requestId),
headers: buildHeaders({
token: credentials?.accessToken || credentials?.apiKey,
contentType: method === "POST" ? contentType : null,
idempotencyKey: method === "POST" ? idempotencyKey : null,
}),
body: method === "POST" ? rawBody : undefined,
signal: fetchSignal,
});
};
};
// Rebuilt per attempt so the auth retry below picks up the refreshed token.
const doFetch = async () => {
const plan = adapter
? await adapter.buildRequest({
config, action, requestId, rawBody, contentType, idempotencyKey, credentials, log,
token: credentials?.accessToken || credentials?.apiKey,
})
: defaultPlan();
if (plan.error) return { planError: plan.error };
return {
response: await fetch(plan.url, {
method: plan.method,
headers: plan.headers,
body: plan.body,
signal: fetchSignal,
}),
};
};
const method = requestId ? "GET" : "POST";
let upstream;
try {
upstream = await doFetch(credentials?.accessToken || credentials?.apiKey);
const first = await doFetch();
if (first.planError) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `[${provider}] ${first.planError}`);
upstream = first.response;
} catch (error) {
if (error?.name === "AbortError" || error?.name === "TimeoutError") {
return createErrorResult(HTTP_STATUS.REQUEST_TIMEOUT, `[${provider}] video ${method} aborted: ${error.message}`);
@@ -136,7 +165,9 @@ export async function handleVideoProxyCore({
await upstream.body?.cancel?.();
} catch { /* noop */ }
try {
upstream = await doFetch(credentials.accessToken || credentials.apiKey);
const retry = await doFetch();
if (retry.planError) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `[${provider}] ${retry.planError}`);
upstream = retry.response;
} catch (error) {
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, sanitizeSecrets(`[${provider}] video retry after refresh failed: ${error.message}`, credentials));
}
@@ -152,13 +183,25 @@ export async function handleVideoProxyCore({
return createErrorResult(upstream.status, `[${provider}] ${message.slice(0, 2000)}`);
}
// Success: pass the upstream JSON through untouched (request_id / status / video.url).
// Success: pass the upstream JSON through untouched (request_id / status / video.url),
// unless the adapter maps a provider-native shape onto it (Vertex operations).
let outBody = bodyText;
let outType = upstream.headers.get("content-type") || "application/json";
if (adapter?.transformResponse) {
try {
outBody = JSON.stringify(adapter.transformResponse(JSON.parse(bodyText)));
outType = "application/json";
} catch {
// Non-JSON or unexpected shape — fall back to the raw upstream body.
}
}
return {
success: true,
response: new Response(bodyText, {
response: new Response(outBody, {
status: upstream.status,
headers: {
"Content-Type": upstream.headers.get("content-type") || "application/json",
"Content-Type": outType,
"Access-Control-Allow-Origin": "*",
},
}),

View File

@@ -0,0 +1,13 @@
// Video provider adapters.
//
// Default (no adapter) = xAI shape: raw body forwarded to {baseUrl}/{action},
// polled at {baseUrl}/{id}, upstream JSON passed through verbatim.
// A provider only needs an adapter when its wire format differs from that.
import openrouter from "./openrouter.js";
import vertex from "./vertex.js";
const ADAPTERS = { openrouter, vertex };
export function getVideoAdapter(provider) {
return ADAPTERS[provider] || null;
}

View File

@@ -0,0 +1,39 @@
// OpenRouter video jobs — https://openrouter.ai/docs/api/api-reference/videos
//
// Same async shape as xAI (POST → { id, status }, GET → status/unsigned_urls),
// two differences only: creation POSTs to the collection root (no `/generations`
// suffix) and the account headers come from the registry entry.
// Response bodies are passed through verbatim.
// ponytail: generations only — OpenRouter has no edits/extensions endpoint today.
const SUPPORTED_ACTIONS = new Set(["generations"]);
function headers(config, token) {
return {
Accept: "application/json",
...(config.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export default {
buildRequest({ config, action, requestId, rawBody, contentType, token }) {
const base = config.baseUrl.replace(/\/$/, "");
if (requestId) {
return { method: "GET", url: `${base}/${encodeURIComponent(requestId)}`, headers: headers(config, token) };
}
if (!SUPPORTED_ACTIONS.has(action)) {
return { error: `OpenRouter video supports 'generations' only (got '${action}')` };
}
if (contentType && !contentType.includes("application/json")) {
return { error: "OpenRouter video requires an application/json body" };
}
return {
method: "POST",
url: base,
headers: { ...headers(config, token), "Content-Type": "application/json" },
body: rawBody,
};
},
};

View File

@@ -0,0 +1,159 @@
// Vertex AI (Veo) video jobs.
//
// Vertex does NOT speak the OpenAI-ish /v1/videos shape, so unlike OpenRouter
// this adapter translates both directions:
// create → POST {model}:predictLongRunning { instances[], parameters{} } → { name }
// poll → POST {model}:fetchPredictOperation { operationName } → { done, response }
// Docs: https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/veo-video-generation
//
// The operation name is a resource path (contains "/"), so it is base64url-encoded
// into the job id returned to the client — GET /v1/videos/{id} stays a flat path.
import { parseVertexSaJson, refreshVertexToken } from "../../services/tokenRefresh.js";
const DEFAULT_LOCATION = "us-central1";
const encodeJobId = (name) => Buffer.from(name, "utf8").toString("base64url");
// Operation name shape: projects/{p}/locations/{l}/publishers/{pub}/models/{m}/operations/{op}.
// Anchored and single-segment-per-field so a decoded path can never carry `..` or a
// host-changing prefix into the request URL.
const OPERATION_NAME_RE = /^projects\/[^/]+\/locations\/[^/]+\/publishers\/[^/]+\/models\/[^/]+\/operations\/[^/]+$/;
function modelPathOf(operationName) {
return operationName.slice(0, operationName.indexOf("/operations/"));
}
function decodeJobId(id) {
const raw = String(id ?? "");
// Buffer.from(x, "base64url") silently drops invalid characters instead of
// throwing, so only ids that re-encode byte-for-byte are accepted.
if (!raw || raw.length > 1024 || !/^[A-Za-z0-9_-]+$/.test(raw)) return null;
const decoded = Buffer.from(raw, "base64url").toString("utf8");
if (Buffer.from(decoded, "utf8").toString("base64url") !== raw) return null;
return OPERATION_NAME_RE.test(decoded) ? decoded : null;
}
async function resolveAuth(credentials, log) {
const saJson = parseVertexSaJson(credentials?.apiKey);
const projectId =
saJson?.project_id ||
credentials?.projectId ||
credentials?.providerSpecificData?.projectId;
const location = credentials?.providerSpecificData?.location || DEFAULT_LOCATION;
if (!projectId) {
return { error: "Vertex video requires a project_id — use Service Account JSON or set providerSpecificData.projectId" };
}
let token = credentials?.accessToken;
if (saJson) {
const minted = await refreshVertexToken(saJson, log);
if (!minted?.accessToken) return { error: "Vertex video: failed to mint access token from service account JSON" };
token = minted.accessToken;
}
if (!token) return { error: "Vertex video requires Service Account JSON or an OAuth access token (raw API keys are not supported)" };
return { token, projectId, location };
}
/** OpenAI-ish video body → Vertex predictLongRunning body. */
function toVertexBody(body) {
const instance = { prompt: body.prompt };
// Image-to-video: accept the Vertex-native shape or a bare data URL / base64 string.
const image = body.image ?? body.image_url;
if (image && typeof image === "object") {
instance.image = image;
} else if (typeof image === "string") {
const match = image.match(/^data:([^;]+);base64,(.*)$/s);
instance.image = match
? { bytesBase64Encoded: match[2], mimeType: match[1] }
: { gcsUri: image };
}
if (body.video && typeof body.video === "object") instance.video = body.video;
const parameters = {};
if (body.n != null) parameters.sampleCount = Number(body.n);
if (body.duration != null) parameters.durationSeconds = Number(body.duration);
if (body.aspect_ratio) parameters.aspectRatio = body.aspect_ratio;
if (body.resolution) parameters.resolution = body.resolution;
if (body.seed != null) parameters.seed = body.seed;
if (body.negative_prompt) parameters.negativePrompt = body.negative_prompt;
// Without storageUri Vertex returns inline base64 bytes; a GCS bucket keeps
// the poll response small and is what production callers want.
if (body.storage_uri) parameters.storageUri = body.storage_uri;
if (body.generate_audio != null) parameters.generateAudio = !!body.generate_audio;
return { instances: [instance], ...(Object.keys(parameters).length ? { parameters } : {}) };
}
/** Vertex operation → the async-job shape 9Router clients already poll for. */
function fromVertexOperation(json) {
if (!json?.name) return json;
const id = encodeJobId(json.name);
if (json.error) {
return { id, request_id: id, status: "failed", error: json.error };
}
if (!json.done) {
return { id, request_id: id, status: "pending" };
}
const samples =
json.response?.videos ||
json.response?.generateVideoResponse?.generatedSamples ||
[];
const videos = samples.map((s) => ({
url: s.gcsUri || s.video?.uri || s.uri || null,
b64_json: s.bytesBase64Encoded || s.video?.bytesBase64Encoded || null,
mime_type: s.mimeType || s.video?.mimeType || "video/mp4",
}));
return { id, request_id: id, status: "completed", video: videos[0] || null, videos };
}
export default {
async buildRequest({ config, action, requestId, rawBody, contentType, credentials, log }) {
if (contentType && !contentType.includes("application/json")) {
return { error: "Vertex video requires an application/json body" };
}
const auth = await resolveAuth(credentials, log);
if (auth.error) return { error: auth.error };
const { token, projectId, location } = auth;
const base = (config.baseUrl || "https://aiplatform.googleapis.com").replace(/\/$/, "");
const headers = { Accept: "application/json", "Content-Type": "application/json", Authorization: `Bearer ${token}` };
if (requestId) {
const operationName = decodeJobId(requestId);
if (!operationName) return { error: "Invalid Vertex video job id" };
return {
method: "POST",
url: `${base}/v1/${modelPathOf(operationName)}:fetchPredictOperation`,
headers,
body: JSON.stringify({ operationName }),
};
}
if (action !== "generations") {
// ponytail: Veo extend/edit go through generations with `video`/`image` in the body.
return { error: `Vertex video supports 'generations' only (got '${action}')` };
}
let body;
try {
body = JSON.parse(typeof rawBody === "string" ? rawBody : rawBody.toString("utf8"));
} catch {
return { error: "Invalid JSON body" };
}
if (!body.model) return { error: "Vertex video requires a model (e.g. vertex/veo-3.1-generate-preview)" };
// Plain model id only — a path segment carrying "/" or ".." would rewrite the URL.
if (!/^[A-Za-z0-9._-]+$/.test(body.model)) return { error: "Invalid Vertex video model id" };
if (!body.prompt && !body.image && !body.image_url) return { error: "Vertex video requires a prompt or an image" };
return {
method: "POST",
url: `${base}/v1/projects/${projectId}/locations/${location}/publishers/google/models/${body.model}:predictLongRunning`,
headers,
body: JSON.stringify(toVertexBody(body)),
};
},
transformResponse: fromVertexOperation,
};