mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-17 20:52:15 +03:00
Merge remote-tracking branch 'origin/release/v3.8.47' into tmp/implement-prs-6697-b
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -306,6 +306,7 @@ export default function ProviderModelsSection({
|
||||
<PassthroughModelsSection
|
||||
providerAlias={providerAlias}
|
||||
modelAliases={modelAliases}
|
||||
catalogModels={models}
|
||||
availableModels={syncedAvailableModels}
|
||||
customModels={modelMeta.customModels}
|
||||
description={passthroughDescription}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
479
src/lib/skills/containerProvider.ts
Normal file
479
src/lib/skills/containerProvider.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -71,8 +71,6 @@ export const AGGREGATOR_PROVIDER_IDS = new Set([
|
||||
"laozhang",
|
||||
"vercel-ai-gateway",
|
||||
"agentrouter",
|
||||
"glhf",
|
||||
"cablyai",
|
||||
"thebai",
|
||||
"fenayai",
|
||||
"empower",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
98
src/sse/handlers/rejectedRequestUsage.ts
Normal file
98
src/sse/handlers/rejectedRequestUsage.ts
Normal 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(() => {});
|
||||
}
|
||||
Reference in New Issue
Block a user