Merge remote-tracking branch 'origin/release/v3.8.47' into tmp/implement-prs-6697-b

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-09 16:56:37 -03:00
69 changed files with 2472 additions and 897 deletions

View File

@@ -0,0 +1,52 @@
import Tooltip from "@/shared/components/Tooltip";
type TranslationFn = {
(key: string): string;
has?: (key: string) => boolean;
};
type Props = {
config: Record<string, any>;
setConfig: (config: Record<string, any>) => void;
t: TranslationFn;
};
function getI18nOrFallback(t: TranslationFn, key: string, fallback: string): string {
try {
if (typeof t.has === "function" && t.has(key)) return t(key);
} catch {}
return fallback;
}
export default function ReasoningTokenBufferToggle({ config, setConfig, t }: Props) {
return (
<div className="flex items-center gap-2 py-1">
<input
type="checkbox"
id="reasoningTokenBufferEnabled"
data-testid="combo-reasoning-token-buffer-enabled"
checked={config.reasoningTokenBufferEnabled !== false}
onChange={(e) => setConfig({ ...config, reasoningTokenBufferEnabled: e.target.checked })}
className="w-3.5 h-3.5 rounded border border-black/20 dark:border-white/20 accent-primary cursor-pointer"
/>
<label
htmlFor="reasoningTokenBufferEnabled"
className="text-xs text-text-muted cursor-pointer select-none"
>
{getI18nOrFallback(t, "reasoningTokenBuffer", "Reasoning token buffer")}
</label>
<Tooltip
position="bottom"
content={getI18nOrFallback(
t,
"advancedHelp.reasoningTokenBuffer",
"When enabled (default), OmniRoute may increase max_tokens for reasoning-capable models so they have headroom to think. Turn this off if you need this combo to preserve the client's exact max_tokens."
)}
>
<span className="material-symbols-outlined text-[12px] text-text-muted cursor-help">
help
</span>
</Tooltip>
</div>
);
}

View File

@@ -15,6 +15,7 @@ import Tooltip from "@/shared/components/Tooltip";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { FieldLabelWithHelp, WeightTotalBar } from "./parts";
import { ResponseValidationEditor, type ResponseValidationValue } from "./ResponseValidationEditor";
import ReasoningTokenBufferToggle from "./ReasoningTokenBufferToggle";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useNotificationStore } from "@/store/notificationStore";
@@ -217,10 +218,6 @@ function sanitizeComboRuntimeConfig(config) {
);
}
// Build the next combo config when a Fusion tuning field changes. Prunes empty /
// non-finite entries and drops the whole `fusionTuning` object when no field is
// set, so an empty `{}` is never persisted (sanitizeComboRuntimeConfig keeps any
// non-null object as-is).
function updateFusionTuning(config, field, rawValue) {
const value = rawValue === "" ? undefined : Number(rawValue);
const next = { ...(config.fusionTuning || {}), [field]: value };
@@ -522,9 +519,7 @@ function getStrategyBadgeClass(strategy) {
function getI18nOrFallback(t, key, fallback) {
try {
if (typeof t.has === "function" && t.has(key)) return t(key);
} catch {
// Some translations require ICU variables; fallback keeps optional helper text safe.
}
} catch {}
return fallback;
}
@@ -1023,7 +1018,6 @@ export default function CombosPage() {
return (
<div className="flex flex-col gap-6">
{/* Header */}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-semibold">{t("title")}</h1>
@@ -2063,7 +2057,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
}
}, [builderStage, comboBuilderStages]);
// DnD state
const hasPricingForModel = useCallback(
(modelValue) => {
const parsed = parseQualifiedModel(modelValue);
@@ -2752,9 +2745,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
saveData.description = null;
}
// Include config only if any values are set
const configToSave = sanitizeComboRuntimeConfig(config);
// Add round-robin specific fields to config
if (strategy === "round-robin") {
if (config.concurrencyPerModel !== undefined)
configToSave.concurrencyPerModel = config.concurrencyPerModel;
@@ -3740,7 +3731,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
/>
</div>
</div>
{/* failoverBeforeRetry + maxSetRetries + setRetryDelayMs */}
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-black/5 dark:border-white/5">
<div className="col-span-2">
<div className="flex items-center gap-2 py-1">
@@ -3776,6 +3766,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
</Tooltip>
</div>
</div>
<div className="col-span-2">
<ReasoningTokenBufferToggle config={config} setConfig={setConfig} t={t} />
</div>
<div>
<FieldLabelWithHelp
label={t("maxSetRetries")}
@@ -4166,7 +4159,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
</div>
<div>
<FieldLabelWithHelp
label={getI18nOrFallback(t, "fusionStragglerGraceMs", "Straggler grace (ms)")}
label={getI18nOrFallback(
t,
"fusionStragglerGraceMs",
"Straggler grace (ms)"
)}
help={getI18nOrFallback(
t,
"fusionStragglerGraceMsHelp",
@@ -4181,7 +4178,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
value={config.fusionTuning?.stragglerGraceMs ?? ""}
placeholder="8000"
onChange={(e) =>
setConfig(updateFusionTuning(config, "stragglerGraceMs", e.target.value))
setConfig(
updateFusionTuning(config, "stragglerGraceMs", e.target.value)
)
}
className="w-full text-xs py-1.5 px-2 rounded border border-black/10 dark:border-white/10 bg-transparent focus:border-primary focus:outline-none"
/>
@@ -4580,7 +4579,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
</div>
)}
{/* Actions */}
{isExpertMode ? (
<div className="flex gap-2 pt-1">
<Button onClick={onClose} variant="ghost" fullWidth size="sm">

View File

@@ -227,6 +227,68 @@ describe("PassthroughModelRow — render smoke test", () => {
});
});
describe("PassthroughModelsSection — catalog model fallback", () => {
let container: HTMLElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
});
it("renders built-in catalog models even when no models were imported", async () => {
const { default: PassthroughModelsSection } =
await import("../components/PassthroughModelsSection");
await act(async () => {
root.render(
<PassthroughModelsSection
providerAlias="synthetic"
providerId="synthetic"
connectionId=""
modelAliases={{}}
catalogModels={[
{
id: "hf:zai-org/GLM-5.2",
name: "zai-org/GLM-5.2",
aliases: ["syn:large:text"],
},
]}
availableModels={[]}
customModels={[]}
description="Synthetic accepts provider-native model IDs."
inputLabel="Model ID"
inputPlaceholder="hf:zai-org/GLM-5.2"
copied={undefined}
onCopy={vi.fn()}
onSetAlias={vi.fn().mockResolvedValue(undefined)}
onDeleteAlias={vi.fn()}
t={(k) => k}
effectiveModelNormalize={() => false}
effectiveModelPreserveDeveloper={() => true}
getUpstreamHeadersRecord={() => ({})}
saveModelCompatFlags={vi.fn().mockResolvedValue(undefined)}
isModelHidden={() => false}
onToggleHidden={vi.fn().mockResolvedValue(undefined)}
onBulkToggleHidden={vi.fn().mockResolvedValue(undefined)}
/>
);
});
expect(container.textContent).toContain("synthetic/syn:large:text");
expect(container.textContent).toContain("syn:large:text");
expect(container.textContent).toContain("Built-in");
});
});
describe("ModelVisibilityToolbar — render smoke test", () => {
let container: HTMLElement;
let root: ReturnType<typeof createRoot>;

View File

@@ -48,6 +48,7 @@ export type ModelCompatSavePatchPassthrough = {
export interface PassthroughModelsSectionProps {
providerAlias: string;
modelAliases: Record<string, string>;
catalogModels?: CompatModelRow[];
availableModels?: CompatModelRow[];
customModels?: CompatModelRow[];
description: string;
@@ -80,6 +81,11 @@ export interface PassthroughModelsSectionProps {
onAutoHideFailedChange?: (v: boolean) => void;
}
function getDefaultModelAlias(model: CompatModelRow): string | null {
const [firstAlias] = model.aliases || [];
return typeof firstAlias === "string" && firstAlias.trim() ? firstAlias.trim() : null;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
@@ -87,6 +93,7 @@ export interface PassthroughModelsSectionProps {
export default function PassthroughModelsSection({
providerAlias,
modelAliases,
catalogModels = [],
availableModels = [],
customModels = [],
description,
@@ -237,11 +244,13 @@ export default function PassthroughModelsSection({
const addModel = (model: CompatModelRow, source: string) => {
if (!model?.id || seenModelIds.has(model.id)) return;
const fullModel = fullModelByModelId.get(model.id) || `${providerAlias}/${model.id}`;
const defaultAlias = getDefaultModelAlias(model);
const fullModel =
fullModelByModelId.get(model.id) || `${providerAlias}/${defaultAlias || model.id}`;
rows.push({
modelId: model.id,
fullModel,
alias: aliasByModelId.get(model.id) || null,
alias: aliasByModelId.get(model.id) || defaultAlias,
displayName: model.name || model.id,
source,
isFree:
@@ -258,6 +267,10 @@ export default function PassthroughModelsSection({
addModel(model, "imported");
}
for (const model of catalogModels) {
addModel(model, "system");
}
for (const model of customModels) {
addModel(
model,
@@ -291,6 +304,7 @@ export default function PassthroughModelsSection({
return rows;
}, [
availableModels,
catalogModels,
customModelMap,
customModels,
isModelHidden,

View File

@@ -306,6 +306,7 @@ export default function ProviderModelsSection({
<PassthroughModelsSection
providerAlias={providerAlias}
modelAliases={modelAliases}
catalogModels={models}
availableModels={syncedAvailableModels}
customModels={modelMeta.customModels}
description={passthroughDescription}

View File

@@ -64,6 +64,7 @@ export type CompatByProtocolMap = Partial<
export type CompatModelRow = {
id?: string;
name?: string;
/** optional registry aliases for display/import */ aliases?: readonly string[];
source?: string;
apiFormat?: string;
supportedEndpoints?: string[];
@@ -77,7 +78,6 @@ export type CompatModelRow = {
};
export type CompatModelMap = Map<string, CompatModelRow>;
export type HeaderDraftRow = { id: string; name: string; value: string };
// ---------------------------------------------------------------------------
@@ -85,7 +85,6 @@ export type HeaderDraftRow = { id: string; name: string; value: string };
// outside the .tsx). Returns the i18n key for a targetFormat value, or null when the
// value is unknown (the caller then renders the raw value verbatim).
// ---------------------------------------------------------------------------
const TARGET_FORMAT_BADGE_I18N_KEYS: Record<string, string> = {
openai: "compatProtocolOpenAI",
"openai-responses": "compatProtocolOpenAIResponses",

View File

@@ -292,8 +292,7 @@ export default function ComboDefaultsTab() {
// Filtered provider list — excludes already-added ones, filtered by search query
const filteredProviders = availableProviders.filter(
(p) =>
!providerOverrides[p.provider] && matchesSearch(p.provider, searchQuery)
(p) => !providerOverrides[p.provider] && matchesSearch(p.provider, searchQuery)
);
const handleDropdownKeyDown = (e: React.KeyboardEvent) => {

View File

@@ -131,14 +131,6 @@ export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> =
authPrefix: "Bearer ",
parseResponse: (data) => data.data || [],
},
glhf: {
url: "https://glhf.chat/api/openai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
aimlapi: {
// #5570: AI/ML API's live catalog (400+ models) lives at the public,
// auth-free /models database endpoint (NOT /v1/models). The registry has no

View File

@@ -11,6 +11,7 @@ import { upsertAgentBridgeState } from "@/lib/db/agentBridgeState";
import { getCachedPassword } from "@/mitm/manager";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { createErrorResponse } from "@/lib/api/errorResponse";
import { ALL_TARGETS } from "@/mitm/targets/index";
type Params = { params: { id: string } };
@@ -33,6 +34,12 @@ export async function POST(request: Request, { params }: Params): Promise<Respon
});
}
// Validate the agent ID maps to a known target.
const target = ALL_TARGETS.find((t) => t.id === id);
if (!target) {
return createErrorResponse({ status: 404, message: `Unknown agent: ${id}` });
}
const { enabled } = parsed.data;
const raw = body as Record<string, unknown>;
const sudoPassword =
@@ -40,9 +47,9 @@ export async function POST(request: Request, { params }: Params): Promise<Respon
try {
if (enabled) {
await addDNSEntry(sudoPassword);
await addDNSEntry(sudoPassword, id);
} else {
await removeDNSEntry(sudoPassword);
await removeDNSEntry(sudoPassword, id);
}
upsertAgentBridgeState({ agent_id: id, dns_enabled: enabled });

View File

@@ -107,11 +107,16 @@ export { isRetryableProxyTarget, isSecurityBlockError } from "./validation/trans
export async function validateWebCookieProvider({
provider,
apiKey,
providerSpecificData = {},
}: any) {
providerSpecificData: _providerSpecificData = {},
}: {
provider: string;
apiKey?: string;
providerSpecificData?: Record<string, unknown>;
}) {
try {
const entry = getRegistryEntry(provider);
if (!entry) {
const cookieProvider = WEB_COOKIE_PROVIDERS[provider as keyof typeof WEB_COOKIE_PROVIDERS];
if (!entry && !cookieProvider) {
return { valid: false, error: "Provider not found in registry", unsupported: true };
}
@@ -121,9 +126,26 @@ export async function validateWebCookieProvider({
return { valid: false, error: "Cookie required for web-cookie provider", unsupported: false };
}
if (!entry) {
// Providers listed in WEB_COOKIE_PROVIDERS without a providerRegistry entry (e.g.
// lmarena, gemini-business, poe-web, venice-web, v0-vercel-web) only expose a
// marketing website URL, not a real API host. Probing `${website}/models`
// does not reliably signal session validity for these —
// live verification showed most return redirects or SPA 200s regardless of
// cookie validity, which would silently report an expired/garbage cookie as
// "OK" (worse than an honest "not supported"). Until each of these providers
// has a verified, side-effect-free auth probe against its real API host, report
// unsupported instead of a false positive.
return {
valid: false,
error: "Provider validation not supported",
unsupported: true,
};
}
// Attempt a minimal request to check if the session is valid
// Use /models endpoint or a minimal completion request depending on the provider
const baseUrl = entry.baseUrl || "";
const baseUrl = normalizeBaseUrl(entry.baseUrl || "");
const testUrl = `${baseUrl}/models`;
const res = await directHttpsRequest(
@@ -132,6 +154,7 @@ export async function validateWebCookieProvider({
method: "GET",
headers: {
"User-Agent": STANDARD_USER_AGENT,
Cookie: cookie,
},
},
10_000
@@ -150,7 +173,7 @@ export async function validateWebCookieProvider({
// a 401/403 from the /models probe is the only definitive "session expired" signal
// for web-cookie auth, so a non-auth status is treated as a valid session.
return { valid: true, error: null, unsupported: false };
} catch (error: any) {
} catch (error: unknown) {
return toValidationErrorResult(error);
}
}

View File

@@ -0,0 +1,479 @@
/**
* Container runtime providers for the OmniRoute skill sandbox.
*
* The sandbox historically hardcoded the `docker` CLI. This module abstracts
* the container runtime so OmniRoute can pick the most performant / native
* runtime available on each host:
*
* - macOS: Apple Container (`container` CLI) > OrbStack (docker shim) > Podman > Docker
* - Windows: WSL Container (`wslc` CLI) > Docker Desktop > Podman
* - Linux: Podman (rootless, daemonless) > Docker
*
* The user can override the auto-detected choice with `SKILLS_SANDBOX_RUNTIME`
* (`auto | docker | apple | wsl | orbstack | podman`). Each provider maps the
* sandbox's intent (resource caps, network isolation, capability drops,
* read-only fs, tmpfs workspaces) onto the runtime's native flag set.
*/
import { createRequire } from "module";
import os from "os";
const require = createRequire(import.meta.url);
const childProcess = require("child_process") as typeof import("child_process");
export type SandboxRuntimeId = "docker" | "apple" | "wsl" | "orbstack" | "podman";
export interface SandboxConfig {
cpuLimit: number;
memoryLimit: number;
timeout: number;
networkEnabled: boolean;
readOnly: boolean;
}
export interface ResolvedContainerCommand {
/** Absolute command to spawn (e.g. `"docker"`, `"container"`, `"wslc"`). */
command: string;
/** Arguments for the command. */
args: string[];
/** Arguments appended for the `kill` cleanup path. */
killArgs: (containerName: string) => string[];
}
export interface ContainerProvider {
readonly id: SandboxRuntimeId;
readonly displayName: string;
/** Returns true when this runtime is installed and usable on the host. */
detect(): boolean;
/** Build a run command for the given image, command, and config. */
buildRun(
image: string,
command: string[],
sandboxId: string,
config: SandboxConfig,
): ResolvedContainerCommand;
/** Build a kill/stop command for a running container. */
killCommand: string;
buildKillArgs(name: string): string[];
}
// ----------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------
const SANDBOX_NAME = (sandboxId: string) => `omniroute-${sandboxId}`;
/**
* Probe whether a CLI binary exists on PATH.
* Uses `where` on Windows, `which` on *nix — both via spawnSync so existing
* test mocks on `spawn` (but not `spawnSync`) are not disturbed.
*/
function probeCommand(binary: string): boolean {
const args =
process.platform === "win32" ? ["where", binary] : ["which", binary];
const r = childProcess.spawnSync(args[0], args.slice(1), {
encoding: "utf8",
stdio: "ignore",
});
return r.status === 0;
}
/**
* Probe whether a binary responds to `--version` with exit 0 and
* a plausible version string.
*/
function probeVersion(binary: string, expects = "v"): boolean {
const r = childProcess.spawnSync(binary, ["--version"], {
encoding: "utf8",
stdio: "pipe",
});
return r.status === 0 && !!r.stdout?.trim()?.includes(expects);
}
// ----------------------------------------------------------------
// DockerProvider
// ----------------------------------------------------------------
class DockerProvider implements ContainerProvider {
readonly id: SandboxRuntimeId = "docker";
readonly displayName = "Docker";
readonly killCommand = "docker";
detect(): boolean {
return probeCommand("docker") && probeVersion("docker");
}
buildRun(
image: string,
command: string[],
sandboxId: string,
config: SandboxConfig,
): ResolvedContainerCommand {
const args = [
"run",
"--rm",
"--name",
SANDBOX_NAME(sandboxId),
"--cpus",
`${config.cpuLimit / 100}`,
"--memory",
`${config.memoryLimit}m`,
"--network",
config.networkEnabled ? "bridge" : "none",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--pids-limit",
"100",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs",
"/workspace:rw,noexec,nosuid,size=64m",
"--workdir",
"/workspace",
];
if (config.readOnly) args.push("--read-only");
args.push(image, ...command);
return {
command: "docker",
args,
killArgs: (name) => ["kill", name],
};
}
buildKillArgs(name: string): string[] {
return ["kill", name];
}
}
// ----------------------------------------------------------------
// AppleContainerProvider (native Apple Container on macOS)
// ----------------------------------------------------------------
class AppleContainerProvider implements ContainerProvider {
readonly id: SandboxRuntimeId = "apple";
readonly displayName = "Apple Container";
readonly killCommand = "container";
detect(): boolean {
return probeCommand("container") && probeVersion("container", "c");
}
buildRun(
image: string,
command: string[],
sandboxId: string,
config: SandboxConfig,
): ResolvedContainerCommand {
const args = [
"run",
"--rm",
"--name",
SANDBOX_NAME(sandboxId),
"--cpus",
`${config.cpuLimit}`,
"--memory",
`${config.memoryLimit}m`,
"--network",
config.networkEnabled ? "bridge" : "none",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs",
"/workspace:rw,noexec,nosuid,size=64m",
"--workdir",
"/workspace",
];
if (config.readOnly) args.push("--read-only");
args.push(image, ...command);
return {
command: "container",
args,
killArgs: (name) => ["kill", name],
};
}
buildKillArgs(name: string): string[] {
return ["kill", name];
}
}
// ----------------------------------------------------------------
// WslContainerProvider (WSL 2 container CLI on Windows)
// ----------------------------------------------------------------
class WslContainerProvider implements ContainerProvider {
readonly id: SandboxRuntimeId = "wsl";
readonly displayName = "WSL Container";
readonly killCommand = "wslc";
detect(): boolean {
return probeCommand("wslc") && probeVersion("wslc");
}
buildRun(
image: string,
command: string[],
sandboxId: string,
config: SandboxConfig,
): ResolvedContainerCommand {
const args = [
"run",
"--rm",
"--name",
SANDBOX_NAME(sandboxId),
"--cpus",
`${config.cpuLimit}`,
"--memory",
`${config.memoryLimit}m`,
"--network",
config.networkEnabled ? "bridge" : "none",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs",
"/workspace:rw,noexec,nosuid,size=64m",
"--workdir",
"/workspace",
];
if (config.readOnly) args.push("--read-only");
args.push(image, ...command);
return {
command: "wslc",
args,
killArgs: (name) => ["kill", name],
};
}
buildKillArgs(name: string): string[] {
return ["kill", name];
}
}
// ----------------------------------------------------------------
// OrbStackProvider (high-perf Linux VM on macOS)
// ----------------------------------------------------------------
class OrbStackProvider implements ContainerProvider {
readonly id: SandboxRuntimeId = "orbstack";
readonly displayName = "OrbStack";
readonly killCommand = "orbstack";
detect(): boolean {
return probeCommand("orbstack") && probeVersion("orbstack");
}
buildRun(
image: string,
command: string[],
sandboxId: string,
config: SandboxConfig,
): ResolvedContainerCommand {
// OrbStack wraps Docker inside a Linux VM. We invoke the `orbstack`
// binary which shims `docker` transparently.
const args = [
"run",
"--rm",
"--name",
SANDBOX_NAME(sandboxId),
"--cpus",
`${config.cpuLimit}`,
"--memory",
`${config.memoryLimit}m`,
"--network",
config.networkEnabled ? "bridge" : "none",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs",
"/workspace:rw,noexec,nosuid,size=64m",
"--workdir",
"/workspace",
];
if (config.readOnly) args.push("--read-only");
args.push(image, ...command);
return {
command: "orbstack",
args,
killArgs: (name) => ["kill", name],
};
}
buildKillArgs(name: string): string[] {
return ["kill", name];
}
}
// ----------------------------------------------------------------
// PodmanProvider (rootless Linux alternative)
// ----------------------------------------------------------------
class PodmanProvider implements ContainerProvider {
readonly id: SandboxRuntimeId = "podman";
readonly displayName = "Podman";
readonly killCommand = "podman";
detect(): boolean {
return probeCommand("podman") && probeVersion("podman");
}
buildRun(
image: string,
command: string[],
sandboxId: string,
config: SandboxConfig,
): ResolvedContainerCommand {
const args = [
"run",
"--rm",
"--name",
SANDBOX_NAME(sandboxId),
"--cpus",
`${config.cpuLimit / 100}`,
"--memory",
`${config.memoryLimit}m`,
"--network",
config.networkEnabled ? "bridge" : "none",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs",
"/workspace:rw,noexec,nosuid,size=64m",
"--workdir",
"/workspace",
];
if (config.readOnly) args.push("--read-only");
args.push(image, ...command);
return {
command: "podman",
args,
killArgs: (name) => ["kill", name],
};
}
buildKillArgs(name: string): string[] {
return ["kill", name];
}
}
// ----------------------------------------------------------------
// Registry & auto-detection
// ----------------------------------------------------------------
export const ALL_PROVIDERS: ContainerProvider[] = [
new DockerProvider(),
new AppleContainerProvider(),
new WslContainerProvider(),
new OrbStackProvider(),
new PodmanProvider(),
];
export const PROVIDER_BY_ID = new Map<SandboxRuntimeId, ContainerProvider>(
ALL_PROVIDERS.map((p) => [p.id, p]),
);
/** Priority order for auto-detection on each platform. */
export function platformPriority(): SandboxRuntimeId[] {
switch (os.platform()) {
case "darwin":
// Apple Container is the native micro-VM runtime on Apple Silicon —
// fastest startup, lowest overhead. OrbStack provides a Docker shim
// inside a tuned Linux VM; better than stock Docker Desktop.
return ["apple", "orbstack", "podman", "docker"];
case "win32":
// WSL Container CLI (wslc.exe) is Windows-native via WSL 2.
return ["wsl", "docker", "podman"];
default:
// Linux — podman is rootless + daemonless and therefore preferred.
return ["podman", "docker"];
}
}
// Detect-once memoization
let detectionInFlight: Promise<void> | null = null;
const detectionCache = new Map<SandboxRuntimeId, boolean>();
function clearDetectionCache(): void {
detectionInFlight = null;
detectionCache.clear();
}
async function runDetection(): Promise<void> {
// Run all probes in parallel for speed
await Promise.all(
ALL_PROVIDERS.map(async (provider) => {
const ok = await Promise.resolve(provider.detect());
detectionCache.set(provider.id, ok);
}),
);
}
function normaliseRuntimeOverride(
raw: string | undefined,
): SandboxRuntimeId | null {
if (!raw || raw === "auto") return null;
const lowered = raw.toLowerCase().trim();
if (PROVIDER_BY_ID.has(lowered as SandboxRuntimeId))
return lowered as SandboxRuntimeId;
return null;
}
/**
* Resolves which runtime the sandbox should use for the current host.
*
* Resolution rules (in order):
* 1. Explicit override via `SKILLS_SANDBOX_RUNTIME`.
* 2. Auto-detect: walk the platform priority list and pick the first
* runtime whose `detect()` succeeds.
* 3. Fall back to the Docker provider (the historical default) even if
* detection fails — the spawn will surface a clear "docker not
* found" error if Docker really is missing.
*/
export async function resolveProvider(): Promise<ContainerProvider> {
if (!detectionInFlight) {
detectionInFlight = runDetection();
}
await detectionInFlight;
const override = normaliseRuntimeOverride(
process.env.SKILLS_SANDBOX_RUNTIME,
);
if (override) {
const provider = PROVIDER_BY_ID.get(override)!;
if (detectionCache.get(provider.id)) return provider;
// Honour the explicit override even if detection failed — the user may
// be running inside an environment where the runtime is reachable but
// our probe failed (e.g. very locked-down CI).
return provider;
}
for (const id of platformPriority()) {
if (detectionCache.get(id)) return PROVIDER_BY_ID.get(id)!;
}
return PROVIDER_BY_ID.get("docker")!;
}
/** Exposed for tests — forces a fresh detection pass. */
export function _resetProviderCacheForTests(): void {
clearDetectionCache();
}
/**
* Returns the kill command for the given provider, parameterised with the
* sandbox's container name. Used by SandboxRunner.kill/killAll.
*/
export function buildKillCommand(
provider: ContainerProvider,
sandboxId: string,
): { command: string; args: string[] } {
const name = SANDBOX_NAME(sandboxId);
return {
command: provider.killCommand,
args: provider.buildKillArgs(name),
};
}

View File

@@ -1,20 +1,20 @@
import { createRequire } from "module";
import type { ChildProcess } from "child_process";
import { randomUUID } from "crypto";
import {
resolveProvider,
buildKillCommand,
type ContainerProvider,
type SandboxConfig,
type SandboxRuntimeId,
} from "./containerProvider.ts";
const require = createRequire(import.meta.url);
const childProcess = require("child_process") as typeof import("child_process");
interface SandboxConfig {
cpuLimit: number;
memoryLimit: number;
timeout: number;
networkEnabled: boolean;
readOnly: boolean;
}
interface SandboxResult {
id: string;
runtime: SandboxRuntimeId;
exitCode: number | null;
stdout: string;
stderr: string;
@@ -34,6 +34,7 @@ class SandboxRunner {
private static instance: SandboxRunner;
private runningContainers: Map<string, ChildProcess> = new Map();
private config: SandboxConfig;
private cachedProvider: ContainerProvider | null = null;
private constructor(config: Partial<SandboxConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
@@ -50,6 +51,19 @@ class SandboxRunner {
this.config = { ...this.config, ...config };
}
/**
* Returns the container provider that the next `run()` call will use.
* Resolution is async (it shells out to probe installed runtimes) so the
* caller must `await`. The result is cached on the runner for the
* remainder of the process so subsequent `run()` calls stay sync-friendly.
*/
async getProvider(): Promise<ContainerProvider> {
if (!this.cachedProvider) {
this.cachedProvider = await resolveProvider();
}
return this.cachedProvider;
}
async run(
image: string,
command: string[],
@@ -59,40 +73,11 @@ class SandboxRunner {
const sandboxId = randomUUID();
const startTime = Date.now();
const config = { ...this.config, ...configOverride };
const dockerArgs = [
"run",
"--rm",
"--name",
`omniroute-sandbox-${sandboxId}`,
"--cpus",
`${config.cpuLimit / 1000}`,
"--memory",
`${config.memoryLimit}m`,
"--network",
config.networkEnabled ? "bridge" : "none",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--pids-limit",
"100",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=64m",
"--tmpfs",
"/workspace:rw,noexec,nosuid,size=64m",
"--workdir",
"/workspace",
];
if (config.readOnly) {
dockerArgs.push("--read-only");
}
dockerArgs.push(image, ...command);
const provider = await this.getProvider();
const resolved = provider.buildRun(image, command, sandboxId, config);
return new Promise((resolve) => {
const proc = childProcess.spawn("docker", dockerArgs, {
const proc = childProcess.spawn(resolved.command, resolved.args, {
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
@@ -120,6 +105,7 @@ class SandboxRunner {
resolve({
id: sandboxId,
runtime: provider.id,
exitCode: code,
stdout,
stderr,
@@ -134,6 +120,7 @@ class SandboxRunner {
resolve({
id: sandboxId,
runtime: provider.id,
exitCode: -1,
stdout,
stderr: err.message,
@@ -149,18 +136,32 @@ class SandboxRunner {
if (proc) {
proc.kill("SIGTERM");
this.runningContainers.delete(sandboxId);
childProcess.spawn("docker", ["kill", `omniroute-sandbox-${sandboxId}`], {
stdio: "ignore",
});
const provider = this.cachedProvider;
if (provider) {
const kill = buildKillCommand(provider, sandboxId);
childProcess.spawn(kill.command, kill.args, { stdio: "ignore" });
} else {
childProcess.spawn("docker", ["kill", `omniroute-${sandboxId}`], {
stdio: "ignore",
});
}
return true;
}
return false;
}
killAll(): void {
const provider = this.cachedProvider;
for (const [id, proc] of this.runningContainers) {
proc.kill("SIGTERM");
childProcess.spawn("docker", ["kill", `omniroute-sandbox-${id}`], { stdio: "ignore" });
if (provider) {
const kill = buildKillCommand(provider, id);
childProcess.spawn(kill.command, kill.args, { stdio: "ignore" });
} else {
childProcess.spawn("docker", ["kill", `omniroute-${id}`], {
stdio: "ignore",
});
}
}
this.runningContainers.clear();
}
@@ -175,4 +176,4 @@ class SandboxRunner {
}
export const sandboxRunner = SandboxRunner.getInstance();
export type { SandboxConfig, SandboxResult };
export type { SandboxConfig, SandboxResult };

View File

@@ -8,6 +8,7 @@ import {
quotePowerShell,
runElevatedPowerShell,
} from "../systemCommands.ts";
import { ALL_TARGETS } from "../targets/index.ts";
// Legacy Antigravity defaults preserved for backward compat.
const ANTIGRAVITY_HOSTS = [
@@ -17,6 +18,12 @@ const ANTIGRAVITY_HOSTS = [
"autopush-cloudcode-pa.sandbox.googleapis.com",
];
function resolveHostsForAgent(agentId?: string): string[] {
if (!agentId) return ANTIGRAVITY_HOSTS;
const target = ALL_TARGETS.find((t) => t.id === agentId);
return target?.hosts ?? ANTIGRAVITY_HOSTS;
}
const IS_WIN = process.platform === "win32";
const HOSTS_FILE = IS_WIN
? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts")
@@ -108,9 +115,13 @@ function hasHostEntry(hostsContent: string, hostname: string): boolean {
* Add /etc/hosts entries for every hostname in `hosts`.
* Idempotent — existing entries are not duplicated.
* Complies with Hard Rule #13: no string interpolation in shell commands.
*
* On Windows, all missing entries are batched into a single elevated PowerShell
* invocation so the user gets one UAC prompt instead of one per line.
*/
export async function addDNSEntries(hosts: string[], sudoPassword: string): Promise<void> {
const hostsContent = readHostsFile();
const missingEntries: string[] = [];
for (const hostname of hosts) {
const lines = dnsLines(hostname);
@@ -122,29 +133,23 @@ export async function addDNSEntries(hosts: string[], sudoPassword: string): Prom
return parts.length >= 2 && parts[0] === ip && parts.includes(host);
});
});
missingEntries.push(...missing);
}
for (const entry of missing) {
if (IS_WIN) {
// HR#13: build PowerShell command via concat (not template literal) so grep
// for `\${` inside script bodies returns zero hits. Values pass through
// `quotePowerShell()` for single-quote escaping — safe against injection
// since both HOSTS_FILE (OS const) and entry (internal `IP host` string)
// are non-user-supplied.
const cmd =
"Add-Content -LiteralPath " +
quotePowerShell(HOSTS_FILE) +
" -Value " +
quotePowerShell(entry);
await runElevatedPowerShell(cmd);
} else {
// Hard Rule #13: entry is passed as stdin data, not interpolated into the command.
await execFileWithPassword(
"sudo",
["-S", "tee", "-a", HOSTS_FILE],
sudoPassword,
`${entry}\n`
);
}
if (missingEntries.length === 0) return;
if (IS_WIN) {
const psHostsFile = quotePowerShell(HOSTS_FILE);
const psEntries = missingEntries.map((e) => quotePowerShell(e)).join(", ");
const script = "Add-Content -LiteralPath " + psHostsFile + " -Value " + psEntries;
await runElevatedPowerShell(script);
for (const entry of missingEntries) {
console.log(`[DNS] Added entry: ${entry}`);
}
} else {
const data = missingEntries.map((e) => `${e}\n`).join("");
await execFileWithPassword("sudo", ["-S", "tee", "-a", HOSTS_FILE], sudoPassword, data);
for (const entry of missingEntries) {
console.log(`[DNS] Added entry: ${entry}`);
}
}
@@ -168,46 +173,43 @@ fs.writeFileSync(filePath, filtered.join("\\n").replace(/\\n*$/, "\\n"));
* Remove /etc/hosts entries for every hostname in `hosts`.
* Idempotent — silently skips hosts that are not present.
* Complies with Hard Rule #13: HOSTS_FILE and hostname are passed as argv, not interpolated.
*
* On Windows, all hostnames are filtered in a single elevated PowerShell
* invocation so the user gets one UAC prompt instead of one per host.
*/
export async function removeDNSEntries(hosts: string[], sudoPassword: string): Promise<void> {
const hostsContent = readHostsFile();
const presentHosts = hosts.filter((h) => hasHostEntry(hostsContent, h));
for (const hostname of hosts) {
if (!hasHostEntry(hostsContent, hostname)) {
console.log(`[DNS] Entry for ${hostname} not present — skipping`);
continue;
}
if (presentHosts.length === 0) return;
try {
if (IS_WIN) {
// HR#13: build PowerShell script via concat (not template literal) so grep
// for `\${` inside script bodies returns zero hits. `psHostsFile` and
// `psTargetHost` are quotePowerShell-escaped values (single-quote escape).
const psHostsFile = quotePowerShell(HOSTS_FILE);
const psTargetHost = quotePowerShell(hostname);
const script =
"\n $hostsFile = " +
psHostsFile +
";\n $targetHost = " +
psTargetHost +
";\n $lines = Get-Content -LiteralPath $hostsFile;\n" +
" $filtered = $lines | Where-Object {\n" +
" $parts = ($_ -split '\\s+') | Where-Object { $_ };\n" +
" -not (($parts.Length -ge 2) -and ($parts -contains $targetHost))\n" +
" };\n" +
" Set-Content -LiteralPath $hostsFile -Value $filtered;\n ";
await runElevatedPowerShell(script);
} else {
// Hard Rule #13: HOSTS_FILE and hostname are argv arguments, not interpolated.
await execFileWithPassword(
"sudo",
["-S", process.execPath, "-e", REMOVE_HOSTS_ENTRY_SCRIPT, HOSTS_FILE, hostname],
sudoPassword
);
}
if (IS_WIN) {
const psHostsFile = quotePowerShell(HOSTS_FILE);
const psTargets = presentHosts.map((h) => quotePowerShell(h)).join(", ");
const script =
"$hostsFile = " +
psHostsFile +
";\n $targetHosts = @(" +
psTargets +
");\n" +
" $lines = Get-Content -LiteralPath $hostsFile;\n" +
" $filtered = $lines | Where-Object {\n" +
" $part = ($_ -split '\\s+') | Where-Object { $_ };\n" +
" -not ($part.Length -ge 2 -and ($targetHosts -contains $part[1]))\n" +
" };\n" +
" Set-Content -LiteralPath $hostsFile -Value $filtered;\n ";
await runElevatedPowerShell(script);
for (const hostname of presentHosts) {
console.log(`[DNS] Removed entries for ${hostname}`);
}
} else {
for (const hostname of presentHosts) {
await execFileWithPassword(
"sudo",
["-S", process.execPath, "-e", REMOVE_HOSTS_ENTRY_SCRIPT, HOSTS_FILE, hostname],
sudoPassword
);
console.log(`[DNS] Removed entries for ${hostname}`);
} catch (error) {
throw new Error(`Failed to remove DNS entry for ${hostname}: ${getErrorMessage(error)}`);
}
}
}
@@ -226,17 +228,19 @@ export function checkDNSEntry(): boolean {
}
/**
* Add DNS entries for the Antigravity default hosts.
* Add DNS entries for the Antigravity default hosts, or for a specific agent
* when `agentId` is provided.
* Delegates to `addDNSEntries` — backward compat wrapper.
*/
export async function addDNSEntry(sudoPassword: string): Promise<void> {
await addDNSEntries(ANTIGRAVITY_HOSTS, sudoPassword);
export async function addDNSEntry(sudoPassword: string, agentId?: string): Promise<void> {
await addDNSEntries(resolveHostsForAgent(agentId), sudoPassword);
}
/**
* Remove DNS entries for the Antigravity default hosts.
* Remove DNS entries for the Antigravity default hosts, or for a specific agent
* when `agentId` is provided.
* Delegates to `removeDNSEntries` — backward compat wrapper.
*/
export async function removeDNSEntry(sudoPassword: string): Promise<void> {
await removeDNSEntries(ANTIGRAVITY_HOSTS, sudoPassword);
export async function removeDNSEntry(sudoPassword: string, agentId?: string): Promise<void> {
await removeDNSEntries(resolveHostsForAgent(agentId), sudoPassword);
}

View File

@@ -552,7 +552,12 @@ async function startMitmInternal(
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
if (!fs.existsSync(certPath)) {
log.info("Generating SSL certificate...");
await generateCert();
try {
await generateCert();
} catch (err) {
log.error({ err }, "Failed to generate SSL certificate");
throw err;
}
}
// 2. Install certificate to system keychain. A failure here must NOT abort the
@@ -576,7 +581,11 @@ async function startMitmInternal(
// 3. Add DNS entries: Antigravity defaults + all agents with dns_enabled=true +
// all custom hosts with enabled=true. Best-effort — see provisionDnsEntries.
log.info("Adding DNS entries...");
await provisionDnsEntries(sudoPassword);
try {
await provisionDnsEntries(sudoPassword);
} catch (err) {
log.error({ err }, "DNS provisioning threw unexpectedly (continuing)");
}
// 4. Start MITM server
log.info("Starting MITM server...");
@@ -619,9 +628,13 @@ async function startMitmInternal(
const proc = serverProcess;
serverPid = proc.pid ?? null;
// Save PID to file
// Save PID to file — best-effort, must not orphan spawned child process
if (serverPid !== null) {
fs.writeFileSync(PID_FILE, String(serverPid));
try {
fs.writeFileSync(PID_FILE, String(serverPid));
} catch (err) {
log.error({ err, pid: serverPid }, "Failed to write MITM PID file (continuing)");
}
}
// Buffer recent stderr so a startup failure can be reported with its real

View File

@@ -98,7 +98,6 @@ const KNOWN_SVGS = new Set([
"iflytek",
"sparkdesk",
"arcee-ai",
"inclusionai",
"liquid",
"monsterapi",
"nomic",
@@ -161,7 +160,10 @@ const ProviderIcon = memo(function ProviderIcon({
// without requiring `images.remotePatterns` allow-listing for arbitrary domains.
if (trimmedSrc && !remoteSrcFailed) {
return (
<span className={className} style={{ display: "inline-flex", alignItems: "center", ...style }}>
<span
className={className}
style={{ display: "inline-flex", alignItems: "center", ...style }}
>
{/* eslint-disable-next-line @next/next/no-img-element -- operator-supplied remote URL, not a static/known asset */}
<img
src={trimmedSrc}

View File

@@ -15,6 +15,57 @@ export const DEFAULT_PRICING_INFERENCE = {
cache_creation: 2.0,
},
},
synthetic: {
"hf:openai/gpt-oss-120b": {
input: 0.1,
output: 0.1,
cached: 0.1,
reasoning: 0,
cache_creation: 0,
},
"hf:zai-org/GLM-5.2": {
input: 1.4,
output: 4.4,
cached: 1.4,
reasoning: 0,
cache_creation: 0,
},
"hf:moonshotai/Kimi-K2.7-Code": {
input: 0.95,
output: 4,
cached: 0.95,
reasoning: 0,
cache_creation: 0,
},
"hf:Qwen/Qwen3.6-27B": {
input: 0.45,
output: 3.6,
cached: 0.45,
reasoning: 0,
cache_creation: 0,
},
"hf:MiniMaxAI/MiniMax-M3": {
input: 0.6,
output: 1.2,
cached: 0.6,
reasoning: 0,
cache_creation: 0,
},
"hf:zai-org/GLM-4.7-Flash": {
input: 0.1,
output: 0.5,
cached: 0.1,
reasoning: 0,
cache_creation: 0,
},
"hf:nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-NVFP4": {
input: 0.3,
output: 1,
cached: 0.3,
reasoning: 0,
cache_creation: 0,
},
},
groq: {
"openai/gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 },
"gpt-oss-120b": { input: 0, output: 0, cached: 0, reasoning: 0, cache_creation: 0 },

View File

@@ -71,8 +71,6 @@ export const AGGREGATOR_PROVIDER_IDS = new Set([
"laozhang",
"vercel-ai-gateway",
"agentrouter",
"glhf",
"cablyai",
"thebai",
"fenayai",
"empower",

View File

@@ -14,8 +14,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
website: "https://hyper.charm.land",
hasFree: true,
freeNote: "100 free monthly Hypercredits on signup",
apiHint:
"Create an API key at https://hyper.charm.land, then paste it here as a Bearer token.",
apiHint: "Create an API key at https://hyper.charm.land, then paste it here as a Bearer token.",
},
agentrouter: {
id: "agentrouter",
@@ -275,23 +274,6 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
apiHint:
"Works without API key (use 'unused' as key). Get free token at token.llm7.io for higher limits.",
},
kluster: {
id: "kluster",
alias: "kluster",
name: "Kluster AI",
icon: "hub",
color: "#8B5CF6",
textIcon: "KL",
website: "https://kluster.ai",
hasFree: false,
freeNote: "Discontinued 2026 — kluster.ai sunset (2026-06-09); no free tier.",
apiHint: "Get API key at https://kluster.ai/dashboard/api-keys",
subscriptionRisk: true,
riskNoticeVariant: "deprecated",
deprecated: true,
deprecationReason:
"kluster.ai shut down (2026-06-09); api.kluster.ai no longer resolves (sweep 2026-06-19). Use another OpenAI-compatible provider.",
},
llamagate: {
id: "llamagate",
alias: "llamagate",
@@ -391,40 +373,6 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
website: "https://api.laozhang.ai",
passthroughModels: true,
},
glhf: {
id: "glhf",
alias: "glhf",
name: "GLHF Chat",
icon: "hub",
color: "#10B981",
textIcon: "GH",
website: "https://glhf.chat",
authHint: "Bearer API key for the GLHF OpenAI-compatible gateway.",
hasFree: false,
freeNote: "Discontinued 2026 — glhf.chat free beta ended; no free tier.",
passthroughModels: true,
subscriptionRisk: true,
riskNoticeVariant: "deprecated",
deprecated: true,
deprecationReason:
"glhf.chat shut down (2026); its api.laf.run gateway no longer serves the catalog (sweep 2026-06-19).",
},
cablyai: {
id: "cablyai",
alias: "cablyai",
name: "CablyAI",
icon: "hub",
color: "#FF4081",
textIcon: "CA",
website: "https://cablyai.com",
authHint: "Bearer API key for the CablyAI OpenAI-compatible gateway.",
passthroughModels: true,
subscriptionRisk: true,
riskNoticeVariant: "deprecated",
deprecated: true,
deprecationReason:
"cablyai.com no longer resolves (DNS NXDOMAIN, verified 2026-06-30) — the domain is gone and every request fails with a DNS error (#5568).",
},
thebai: {
id: "thebai",
alias: "thebai",

View File

@@ -337,24 +337,6 @@ export const APIKEY_PROVIDERS_REGIONAL = {
passthroughModels: true,
authHint: "Get API key at console.xfyun.cn",
},
inclusionai: {
id: "inclusionai",
alias: "inclusion",
name: "InclusionAI",
icon: "psychology",
color: "#10B981",
textIcon: "IA",
website: "https://inclusionai.com",
hasFree: true,
freeNote: "Free Ling-2.6-flash model (1T-param MoE, 262K context). No credit card required.",
passthroughModels: true,
authHint: "Get API key at inclusionai.com",
subscriptionRisk: true,
riskNoticeVariant: "deprecated",
deprecated: true,
deprecationReason:
"api.inclusionai.tech no longer resolves (sweep 2026-06-19); the inference API appears discontinued.",
},
hcnsec: {
id: "hcnsec",
alias: "hcnsec",

View File

@@ -584,6 +584,16 @@ export const getKnownToolPaths = (toolId: string): string[] => {
if (localAppData) {
paths.push(path.join(localAppData, "Programs", "Claude", "claude.exe"));
paths.push(path.join(localAppData, "claude-code", "claude.exe"));
paths.push(
path.join(
localAppData,
"Microsoft",
"WinGet",
"Packages",
"Anthropic.ClaudeCode_Microsoft.Winget.Source_8wekyb3d8bbwe",
"claude.exe"
)
);
}
}

View File

@@ -889,27 +889,25 @@ export async function handleChat(
// Record telemetry
recordTelemetry(telemetry);
// Log combo failures that bypassed handleChatCore (e.g. all targets skipped by circuit breaker)
// Log combo failures that bypassed handleChatCore (e.g. all targets skipped by circuit breaker).
// Records BOTH a call_logs row (dashboard/logs) AND a usage_history row attributed to the api key
// (success:false) so gate/breaker-rejected traffic is counted per key — support-mesh 2026-07-08.
if (!response.ok) {
try {
const { saveCallLog } = await import("@/lib/usageDb");
saveCallLog({
id: undefined,
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
const { recordRejectedRequestUsage } = await import("./rejectedRequestUsage");
await recordRejectedRequestUsage({
status: response.status,
model: body?.model || resolvedModelStr,
requestedModel: body?.model || resolvedModelStr,
provider: "-",
connectionId: undefined,
duration: Date.now() - (telemetry?.startTime || Date.now()),
tokens: {},
endpoint: clientRawRequest?.endpoint,
error: `[${response.status}] Combo "${combo.name}" failed — all targets exhausted`,
comboName: combo.name,
comboStepId: null,
comboExecutionKey: null,
apiKeyId: apiKeyInfo?.id ?? null,
apiKeyName: apiKeyInfo?.name ?? null,
correlationId: reqId,
}).catch(() => {});
startTime: telemetry?.startTime,
});
} catch {}
}
return withCorrelationId(withSessionHeader(response, sessionId), reqId);
@@ -1113,26 +1111,26 @@ async function handleSingleModelChat(
...(bypassReason ? { bypassReason } : {}),
});
if (gate) {
// Log the rejected request so it appears in /dashboard/logs
// Log the rejected request so it appears in /dashboard/logs AND is counted in the
// per-api-key usage analytics (usage_history, success:false) — otherwise a key whose
// traffic is entirely gate/breaker-rejected shows "zero requests" (support-mesh 2026-07-08).
try {
const { saveCallLog } = await import("@/lib/usageDb");
saveCallLog({
id: undefined,
method: "POST",
path: clientRawRequest?.endpoint || "/v1/chat/completions",
const { recordRejectedRequestUsage } = await import("./rejectedRequestUsage");
await recordRejectedRequestUsage({
status: gate.status,
model,
requestedModel: body?.model || modelStr,
provider,
connectionId: undefined,
duration: Date.now() - (telemetry?.startTime || Date.now()),
tokens: {},
endpoint: clientRawRequest?.endpoint,
error: `[${gate.status}] Pipeline gate rejected`,
comboName: isCombo ? comboName : null,
comboStepId: isCombo ? (runtimeOptions?.comboStepId ?? null) : null,
comboExecutionKey: isCombo ? (runtimeOptions?.comboExecutionKey ?? null) : null,
apiKeyId: apiKeyInfo?.id ?? null,
apiKeyName: apiKeyInfo?.name ?? null,
correlationId: runtimeOptions?.correlationId ?? null,
}).catch(() => {});
startTime: telemetry?.startTime,
});
} catch {}
return gate;
}

View File

@@ -0,0 +1,98 @@
/**
* Records a request that was rejected BEFORE reaching handleChatCore — i.e. a
* pipeline-gate rejection (provider circuit breaker OPEN / model cooldown) or a
* combo whose targets were all exhausted. These paths short-circuit in
* `chat.ts` and used to write only a `call_logs` row via `saveCallLog`, which
* kept them visible in /dashboard/logs but left them absent from `usage_history`
* — the table `getApiKeyUsageRows` reads. The effect was an API key whose
* traffic was entirely gate-rejected showing "zero requests" despite real
* usage (support-mesh escalation, 2026-07-08).
*
* This helper writes BOTH:
* 1. the `call_logs` row (unchanged dashboard/logs visibility), and
* 2. a `usage_history` row attributed to the api key with `success: false`,
* mirroring `persistFailureUsage` in the post-executor failure path,
* so rejected traffic is counted per key just like executor-level failures.
*
* Best-effort: both writes swallow their own errors — logging a rejection must
* never turn into a second failure on the response path.
*/
import { saveCallLog, saveRequestUsage } from "@/lib/usageDb";
export interface RejectedRequestUsageInput {
status: number;
model: string;
requestedModel?: string;
provider: string;
endpoint?: string | null;
error?: string | null;
comboName?: string | null;
comboStepId?: string | null;
comboExecutionKey?: string | null;
correlationId?: string | null;
apiKeyId?: string | null;
apiKeyName?: string | null;
connectionId?: string | null;
/** When the request started, for the duration/latency columns. */
startTime?: number;
}
export async function recordRejectedRequestUsage(input: RejectedRequestUsageInput): Promise<void> {
const {
status,
model,
requestedModel,
provider,
endpoint,
error,
comboName = null,
comboStepId = null,
comboExecutionKey = null,
correlationId = null,
apiKeyId = null,
apiKeyName = null,
connectionId = undefined,
startTime,
} = input;
const now = Date.now();
const duration = typeof startTime === "number" ? now - startTime : 0;
// 1. call_logs — preserves /dashboard/logs visibility (unchanged behavior).
saveCallLog({
id: undefined,
method: "POST",
path: endpoint || "/v1/chat/completions",
status,
model,
requestedModel: requestedModel || model,
provider,
connectionId,
duration,
tokens: {},
error: error || null,
comboName,
comboStepId,
comboExecutionKey,
apiKeyId,
apiKeyName,
correlationId,
}).catch(() => {});
// 2. usage_history — so the per-api-key usage counter reflects rejected
// traffic (success:false), matching persistFailureUsage semantics.
await saveRequestUsage({
provider,
model,
connectionId: connectionId ?? null,
apiKeyId,
apiKeyName,
tokens: {},
serviceTier: "standard",
status: String(status),
success: false,
latencyMs: duration,
comboStrategy: comboName || null,
endpoint: endpoint || "/v1/chat/completions",
}).catch(() => {});
}