feat(providers): add AgentRouter support and per-model health checks

Register AgentRouter across the provider registry, pricing, docs, and
dashboard metadata so it appears as a first-class OpenAI-compatible
passthrough option.

Add a dedicated `/api/models/test` endpoint and provider-page controls
for on-demand single-model diagnostics, including latency feedback and
success/error status, to help verify mappings without triggering broader
connection tests or rate limits.

Align header casing expectations in provider validation tests with the
current registry contract.
This commit is contained in:
diegosouzapw
2026-04-25 09:19:20 -03:00
parent e4a27c41a6
commit 8684d43ebd
12 changed files with 473 additions and 5 deletions

View File

@@ -7,6 +7,8 @@
- **fix(mitm):** Compile MITM utilities as NodeNext ESM during prepublish, copy the CommonJS MITM server into the standalone artifact, and resolve MITM data paths without relying on Next.js aliases in packaged runtime.
- **fix(build):** Move the local `.tmp/wine32` Wine prefix out of the isolated Next.js build path so Windows Electron packaging artifacts cannot trigger `EACCES` scans during Node 24 builds.
- **fix(build):** Copy the `wreq-js` native runtime directory into the isolated Next.js standalone output so packaged Playwright/E2E starts can load the instrumentation hook on Linux.
- **feat(providers):** Integrate AgentRouter as a new OpenAI-compatible passthrough provider with $200 free credits via sign-up (Issue #1572).
- **feat(ui):** Implement on-demand per-model testing in the provider dashboard, allowing single-token diagnostic checks without triggering rate-limits (Issue #1532).
- **fix(api):** Validate the Codex Responses websocket bridge and `/v1/batches` JSON payloads with Zod before use, keeping `request.json()` route validation green and returning explicit 400 responses for invalid bodies.
- **fix(providers):** Add explicit typing to provider alias and category helpers so the strict `typecheck:noimplicit:core` CI gate passes.
- **fix(ui):** Keep the upstream proxy provider detail page labeled with a fallback "Managed via Upstream Proxy Settings" management surface when translations are unavailable.

View File

@@ -1160,6 +1160,7 @@ When minimized, OmniRoute lives in your system tray with quick actions:
| | xAI Grok-4 (standard) | $0.20/$1.50 per 1M 🆕 | None | Reasoning flagship from xAI |
| | Mistral | Free trial + paid | Rate limited | European AI |
| | OpenRouter | Pay-per-use | None | 100+ models aggr. |
| | AgentRouter 🆕 | Pay-per-use | None | $200 free credits at signup |
| **💰 CHEAP** | GLM-5 (via Z.AI) 🆕 | $0.5/1M | Daily 10AM | 128K output, newest flagship |
| | GLM-4.7 | $0.6/1M | Daily 10AM | Budget backup |
| | MiniMax M2.5 🆕 | $0.3/1M input | 5-hour rolling | Reasoning + agentic tasks |

View File

@@ -848,6 +848,22 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},
agentrouter: {
id: "agentrouter",
alias: "agentrouter",
format: "openai",
executor: "default",
baseUrl: "https://agentrouter.org/v1/chat/completions",
authType: "apikey",
authHeader: "bearer",
defaultContextLength: 128000,
headers: {
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "OmniRoute",
},
models: [{ id: "auto", name: "Auto (Best Available)" }],
},
openrouter: {
id: "openrouter",
alias: "openrouter",

View File

@@ -0,0 +1,170 @@
const fs = require('fs');
const path = 'src/app/(dashboard)/dashboard/providers/[id]/page.tsx';
let content = fs.readFileSync(path, 'utf8');
// 1. Add ModelRowProps fields
content = content.replace(
' togglingHidden?: boolean;\n}',
' togglingHidden?: boolean;\n onTestModel?: (modelId: string, fullModel: string) => Promise<void>;\n testStatus?: "ok" | "error" | null;\n testingModel?: boolean;\n}'
);
// 2. Add PassthroughModelRowProps fields
content = content.replace(
' togglingHidden?: boolean;\n}',
' togglingHidden?: boolean;\n onTestModel?: (modelId: string, fullModel: string) => Promise<void>;\n testStatus?: "ok" | "error" | null;\n testingModel?: boolean;\n}'
);
// 3. Add PassthroughModelsSectionProps fields
content = content.replace(
' togglingModelId?: string | null;\n}',
' togglingModelId?: string | null;\n onTestModel?: (modelId: string, fullModel: string) => Promise<void>;\n modelTestStatus?: Record<string, "ok" | "error" | null>;\n testingModelId?: string | null;\n}'
);
// 4. Add CompatibleModelsSectionProps fields
content = content.replace(
' togglingModelId?: string | null;\n}',
' togglingModelId?: string | null;\n onTestModel?: (modelId: string, fullModel: string) => Promise<void>;\n modelTestStatus?: Record<string, "ok" | "error" | null>;\n testingModelId?: string | null;\n}'
);
// 5. Update ModelRow
content = content.replace(
' compatDisabled,\n onToggleHidden,\n togglingHidden,\n}: ModelRowProps) {',
' compatDisabled,\n onToggleHidden,\n togglingHidden,\n onTestModel,\n testStatus,\n testingModel,\n}: ModelRowProps) {'
);
content = content.replace(
' <div className="flex shrink-0 items-center gap-1">',
` <div className="flex shrink-0 items-center gap-1">
{onTestModel && (
<button
onClick={() => onTestModel(model.id, fullModel)}
disabled={testingModel}
className={\`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed \${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}\`}
title={testingModel ? t("testingModel", "Testing...") : testStatus === "ok" ? "OK" : testStatus === "error" ? "Error" : t("testModel", "Test Model")}
>
{testingModel ? (
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
) : testStatus === "ok" ? (
<span className="material-symbols-outlined text-sm">check_circle</span>
) : testStatus === "error" ? (
<span className="material-symbols-outlined text-sm">error</span>
) : (
<span className="material-symbols-outlined text-sm">play_circle</span>
)}
</button>
)}`
);
// 6. Update PassthroughModelRow
content = content.replace(
' compatDisabled,\n onToggleHidden,\n togglingHidden,\n}: PassthroughModelRowProps) {',
' compatDisabled,\n onToggleHidden,\n togglingHidden,\n onTestModel,\n testStatus,\n testingModel,\n}: PassthroughModelRowProps) {'
);
content = content.replace(
' <div className="flex shrink-0 items-center gap-1 self-start">',
` <div className="flex shrink-0 items-center gap-1 self-start">
{onTestModel && (
<button
onClick={() => onTestModel(modelId, fullModel)}
disabled={testingModel}
className={\`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed \${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}\`}
title={testingModel ? t("testingModel", "Testing...") : testStatus === "ok" ? "OK" : testStatus === "error" ? "Error" : t("testModel", "Test Model")}
>
{testingModel ? (
<span className="material-symbols-outlined text-sm animate-spin">progress_activity</span>
) : testStatus === "ok" ? (
<span className="material-symbols-outlined text-sm">check_circle</span>
) : testStatus === "error" ? (
<span className="material-symbols-outlined text-sm">error</span>
) : (
<span className="material-symbols-outlined text-sm">play_circle</span>
)}
</button>
)}`
);
// 7. Update PassthroughModelsSection component passing props down
content = content.replace(
' togglingModelId,\n}: PassthroughModelsSectionProps)',
' togglingModelId,\n onTestModel,\n modelTestStatus,\n testingModelId,\n}: PassthroughModelsSectionProps)'
);
content = content.replace(
' saveModelCompatFlags={saveModelCompatFlags}\n compatDisabled={compatSavingModelId === cm.id}',
' saveModelCompatFlags={saveModelCompatFlags}\n compatDisabled={compatSavingModelId === cm.id}\n onTestModel={onTestModel}\n testStatus={modelTestStatus?.[cm.id] || null}\n testingModel={testingModelId === cm.id}'
);
content = content.replace(
' compatDisabled={compatSavingModelId === alias}\n onToggleHidden={onToggleHidden}',
' compatDisabled={compatSavingModelId === alias}\n onToggleHidden={onToggleHidden}\n onTestModel={onTestModel}\n testStatus={modelTestStatus?.[alias] || null}\n testingModel={testingModelId === alias}'
);
// 8. Update CompatibleModelsSection component passing props down
content = content.replace(
' togglingModelId,\n}: CompatibleModelsSectionProps) {',
' togglingModelId,\n onTestModel,\n modelTestStatus,\n testingModelId,\n}: CompatibleModelsSectionProps) {'
);
content = content.replace(
' saveModelCompatFlags={saveModelCompatFlags}\n compatDisabled={compatSavingModelId === cm.id}',
' saveModelCompatFlags={saveModelCompatFlags}\n compatDisabled={compatSavingModelId === cm.id}\n onTestModel={onTestModel}\n testStatus={modelTestStatus?.[cm.id] || null}\n testingModel={testingModelId === cm.id}'
);
content = content.replace(
' compatDisabled={compatSavingModelId === model.id}\n onToggleHidden={(modelId, hidden)',
' compatDisabled={compatSavingModelId === model.id}\n onTestModel={onTestModel}\n testStatus={modelTestStatus?.[model.id] || null}\n testingModel={testingModelId === model.id}\n onToggleHidden={(modelId, hidden)'
);
// 9. Update ProviderDetailPage
content = content.replace(
' const [togglingModelId, setTogglingModelId] = useState<string | null>(null);',
' const [togglingModelId, setTogglingModelId] = useState<string | null>(null);\n const [testingModelId, setTestingModelId] = useState<string | null>(null);\n const [modelTestStatus, setModelTestStatus] = useState<Record<string, "ok" | "error">>({});'
);
// Add the onTestModel function
content = content.replace(
' const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => {',
` const onTestModel = async (modelId: string, fullModel: string) => {
setTestingModelId(modelId);
setModelTestStatus(prev => ({ ...prev, [modelId]: undefined }));
try {
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ providerId: selectedConnection?.provider || providerNode?.id || providerId, modelId: fullModel })
});
const data = await res.json();
if (res.ok && data.status === "ok") {
notify.success(providerText(t, "testModelSuccess", \`Model \${modelId} is working. Latency: \${data.latencyMs}ms\`, { modelId, latencyMs: data.latencyMs }));
setModelTestStatus(prev => ({ ...prev, [modelId]: "ok" }));
} else {
notify.error(data.error || "Model test failed");
setModelTestStatus(prev => ({ ...prev, [modelId]: "error" }));
}
} catch (err) {
notify.error("Network error testing model");
setModelTestStatus(prev => ({ ...prev, [modelId]: "error" }));
} finally {
setTestingModelId(null);
}
};
const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => {`
);
// Pass to PassthroughModelsSection in ProviderDetailPage
content = content.replace(
' togglingModelId={togglingModelId}\n />',
' togglingModelId={togglingModelId}\n onTestModel={onTestModel}\n modelTestStatus={modelTestStatus}\n testingModelId={testingModelId}\n />'
);
// Pass to CompatibleModelsSection in ProviderDetailPage
content = content.replace(
' togglingModelId={togglingModelId}\n />',
' togglingModelId={togglingModelId}\n onTestModel={onTestModel}\n modelTestStatus={modelTestStatus}\n testingModelId={testingModelId}\n />'
);
// Pass to ModelRow in ProviderDetailPage (fallback/normal models list)
content = content.replace(
' togglingHidden={togglingModelId === model.id}\n />',
' togglingHidden={togglingModelId === model.id}\n onTestModel={onTestModel}\n testStatus={modelTestStatus[model.id] || null}\n testingModel={testingModelId === model.id}\n />'
);
fs.writeFileSync(path, content);
console.log("Successfully updated page.tsx");

View File

@@ -346,6 +346,9 @@ interface ModelRowProps {
compatDisabled?: boolean;
onToggleHidden?: (modelId: string, hidden: boolean) => Promise<void>;
togglingHidden?: boolean;
onTestModel?: (modelId: string, fullModel: string) => Promise<void>;
testStatus?: "ok" | "error" | null;
testingModel?: boolean;
}
interface PassthroughModelRowProps {
@@ -365,6 +368,9 @@ interface PassthroughModelRowProps {
compatDisabled?: boolean;
onToggleHidden?: (modelId: string, hidden: boolean) => Promise<void>;
togglingHidden?: boolean;
onTestModel?: (modelId: string, fullModel: string) => Promise<void>;
testStatus?: "ok" | "error" | null;
testingModel?: boolean;
}
interface PassthroughModelsSectionProps {
@@ -393,6 +399,9 @@ interface PassthroughModelsSectionProps {
onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise<void>;
bulkTogglePending?: boolean;
togglingModelId?: string | null;
onTestModel?: (modelId: string, fullModel: string) => Promise<void>;
modelTestStatus?: Record<string, "ok" | "error" | null>;
testingModelId?: string | null;
}
interface CustomModelsSectionProps {
@@ -440,6 +449,9 @@ interface CompatibleModelsSectionProps {
onBulkToggleHidden: (modelIds: string[], hidden: boolean) => Promise<void>;
bulkTogglePending?: boolean;
togglingModelId?: string | null;
onTestModel?: (modelId: string, fullModel: string) => Promise<void>;
modelTestStatus?: Record<string, "ok" | "error" | null>;
testingModelId?: string | null;
}
interface CooldownTimerProps {
@@ -995,6 +1007,8 @@ export default function ProviderDetailPage() {
const [compatSavingModelId, setCompatSavingModelId] = useState<string | null>(null);
const [modelFilter, setModelFilter] = useState("");
const [togglingModelId, setTogglingModelId] = useState<string | null>(null);
const [testingModelId, setTestingModelId] = useState<string | null>(null);
const [modelTestStatus, setModelTestStatus] = useState<Record<string, "ok" | "error">>({});
const [bulkVisibilityAction, setBulkVisibilityAction] = useState<"select" | "deselect" | null>(
null
);
@@ -1254,6 +1268,41 @@ export default function ProviderDetailPage() {
}
}, [loading, connections, loadConnProxies]);
const onTestModel = async (modelId: string, fullModel: string) => {
setTestingModelId(modelId);
setModelTestStatus((prev) => ({ ...prev, [modelId]: undefined }));
try {
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
providerId: selectedConnection?.provider || providerNode?.id || providerId,
modelId: fullModel,
}),
});
const data = await res.json();
if (res.ok && data.status === "ok") {
notify.success(
providerText(
t,
"testModelSuccess",
`Model ${modelId} is working. Latency: ${data.latencyMs}ms`,
{ modelId, latencyMs: data.latencyMs }
)
);
setModelTestStatus((prev) => ({ ...prev, [modelId]: "ok" }));
} else {
notify.error(data.error || "Model test failed");
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
}
} catch (err) {
notify.error("Network error testing model");
setModelTestStatus((prev) => ({ ...prev, [modelId]: "error" }));
} finally {
setTestingModelId(null);
}
};
const handleSetAlias = async (modelId, alias, providerAliasOverride = providerAlias) => {
const fullModel = `${providerAliasOverride}/${modelId}`;
try {
@@ -2417,6 +2466,9 @@ export default function ProviderDetailPage() {
}
bulkTogglePending={bulkVisibilityAction !== null}
togglingModelId={togglingModelId}
onTestModel={onTestModel}
modelTestStatus={modelTestStatus}
testingModelId={testingModelId}
/>
</div>
);
@@ -2464,6 +2516,9 @@ export default function ProviderDetailPage() {
}
bulkTogglePending={bulkVisibilityAction !== null}
togglingModelId={togglingModelId}
onTestModel={onTestModel}
modelTestStatus={modelTestStatus}
testingModelId={testingModelId}
/>
</div>
);
@@ -2559,6 +2614,9 @@ export default function ProviderDetailPage() {
handleToggleModelHidden(providerId, modelId, hidden)
}
togglingHidden={togglingModelId === model.id}
onTestModel={onTestModel}
testStatus={modelTestStatus[model.id] || null}
testingModel={testingModelId === model.id}
/>
);
})}
@@ -3416,6 +3474,9 @@ function ModelRow({
compatDisabled,
onToggleHidden,
togglingHidden,
onTestModel,
testStatus,
testingModel,
}: ModelRowProps) {
const isHidden = Boolean(model.isHidden);
return (
@@ -3446,6 +3507,34 @@ function ModelRow({
</button>
</div>
<div className="flex shrink-0 items-center gap-1">
{onTestModel && (
<button
onClick={() => onTestModel(model.id, fullModel)}
disabled={testingModel}
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
title={
testingModel
? t("testingModel", "Testing...")
: testStatus === "ok"
? "OK"
: testStatus === "error"
? "Error"
: t("testModel", "Test Model")
}
>
{testingModel ? (
<span className="material-symbols-outlined text-sm animate-spin">
progress_activity
</span>
) : testStatus === "ok" ? (
<span className="material-symbols-outlined text-sm">check_circle</span>
) : testStatus === "error" ? (
<span className="material-symbols-outlined text-sm">error</span>
) : (
<span className="material-symbols-outlined text-sm">play_circle</span>
)}
</button>
)}
{onToggleHidden && (
<button
onClick={() => onToggleHidden(model.id, !isHidden)}
@@ -3579,6 +3668,9 @@ function PassthroughModelsSection({
onBulkToggleHidden,
bulkTogglePending,
togglingModelId,
onTestModel,
modelTestStatus,
testingModelId,
}: PassthroughModelsSectionProps) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
@@ -3764,6 +3856,9 @@ function PassthroughModelRow({
compatDisabled,
onToggleHidden,
togglingHidden,
onTestModel,
testStatus,
testingModel,
}: PassthroughModelRowProps) {
return (
<div
@@ -3798,6 +3893,34 @@ function PassthroughModelRow({
</div>
</div>
<div className="flex shrink-0 items-center gap-1 self-start">
{onTestModel && (
<button
onClick={() => onTestModel(modelId, fullModel)}
disabled={testingModel}
className={`rounded p-0.5 hover:bg-sidebar transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${testStatus === "ok" ? "text-green-500" : testStatus === "error" ? "text-red-500" : "text-text-muted hover:text-primary"}`}
title={
testingModel
? t("testingModel", "Testing...")
: testStatus === "ok"
? "OK"
: testStatus === "error"
? "Error"
: t("testModel", "Test Model")
}
>
{testingModel ? (
<span className="material-symbols-outlined text-sm animate-spin">
progress_activity
</span>
) : testStatus === "ok" ? (
<span className="material-symbols-outlined text-sm">check_circle</span>
) : testStatus === "error" ? (
<span className="material-symbols-outlined text-sm">error</span>
) : (
<span className="material-symbols-outlined text-sm">play_circle</span>
)}
</button>
)}
{onToggleHidden && (
<button
onClick={() => onToggleHidden(modelId, !isHidden)}
@@ -4406,6 +4529,9 @@ function CompatibleModelsSection({
onBulkToggleHidden,
bulkTogglePending,
togglingModelId,
onTestModel,
modelTestStatus,
testingModelId,
}: CompatibleModelsSectionProps) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);

View File

@@ -0,0 +1,135 @@
import { randomUUID } from "node:crypto";
import { NextResponse } from "next/server";
import { buildComboTestRequestBody, extractComboTestResponseText } from "@/lib/combos/testHealth";
import { z } from "zod";
const testModelSchema = z.object({
providerId: z.string().min(1),
modelId: z.string().min(1),
});
/**
* Get the base URL for internal requests (VPS-safe: respects reverse proxy headers)
*/
function getBaseUrl(request: Request) {
const fwdHost = request.headers.get("x-forwarded-host");
const fwdProto = request.headers.get("x-forwarded-proto") || "https";
if (fwdHost) return `${fwdProto}://${fwdHost}`;
const url = new URL(request.url);
return `${url.protocol}//${url.host}`;
}
export async function POST(request: Request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = testModelSchema.safeParse(rawBody);
if (!validation.success) {
return NextResponse.json({ error: validation.error.format() }, { status: 400 });
}
const { providerId, modelId } = validation.data;
// Construct target format (providerId/modelId)
// Some models (like free alias models) might not need the prefix if it's an alias.
// However, the wildcard router expects provider/model.
let fullModelStr = modelId;
if (!fullModelStr.includes("/")) {
fullModelStr = `${providerId}/${modelId}`;
}
const baseInternalUrl = getBaseUrl(request);
const startTime = Date.now();
const isEmbedding =
fullModelStr.toLowerCase().includes("embedding") ||
fullModelStr.toLowerCase().includes("bge-") ||
fullModelStr.toLowerCase().includes("text-embed");
const internalUrl = `${baseInternalUrl}/v1/${isEmbedding ? "embeddings" : "chat/completions"}`;
const testBody = buildComboTestRequestBody(fullModelStr, isEmbedding);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
let res: Response;
try {
res = await fetch(internalUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Internal-Test": "model-health-check",
"X-OmniRoute-No-Cache": "true",
"X-Request-Id": `model-test-${randomUUID()}`,
},
body: JSON.stringify(testBody),
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
const latencyMs = Date.now() - startTime;
if (res.ok) {
let responseBody = null;
try {
responseBody = await res.json();
} catch {
responseBody = null;
}
const responseText = extractComboTestResponseText(responseBody);
if (!responseText && !isEmbedding) {
return NextResponse.json(
{
status: "error",
statusCode: res.status,
error: "Provider returned HTTP 200 but no text content.",
latencyMs,
},
{ status: 400 }
);
}
return NextResponse.json({ status: "ok", latencyMs, responseText });
}
let errorMsg = "";
try {
const errBody = await res.json();
errorMsg = errBody?.error?.message || errBody?.error || res.statusText;
} catch {
errorMsg = res.statusText;
}
return NextResponse.json(
{
status: "error",
statusCode: res.status,
error: errorMsg,
latencyMs,
},
{ status: res.status }
);
} catch (error: any) {
return NextResponse.json(
{
status: "error",
error: error.name === "AbortError" ? "Timeout (20s)" : error.message,
},
{ status: 500 }
);
}
}

View File

@@ -491,6 +491,9 @@
"noActiveProvidersDesc": "Please add and connect providers first to configure CLI tools.",
"mapModels": "Map Models",
"testConnection": "Test Connection",
"testModel": "Test Model",
"testingModel": "Testing...",
"testModelSuccess": "Model {modelId} working. Latency: {latencyMs}ms",
"connectionStatus": "Connection Status",
"configureEndpoint": "Configure Endpoint",
"instructions": "Instructions",

View File

@@ -18,6 +18,7 @@ export const API_ENDPOINTS = {
// Provider API endpoints (for display only)
export const PROVIDER_ENDPOINTS = {
agentrouter: "https://agentrouter.org/v1/chat/completions",
openrouter: "https://openrouter.ai/api/v1/chat/completions",
glm: "https://api.z.ai/api/anthropic/v1/messages",
glmt: "https://api.z.ai/api/anthropic/v1/messages",

View File

@@ -797,6 +797,9 @@ export const DEFAULT_PRICING = {
},
// OpenRouter
agentrouter: {
auto: { input: 2.0, output: 8.0 },
},
openrouter: {
auto: {
input: 2.0,

View File

@@ -141,6 +141,17 @@ export const WEB_COOKIE_PROVIDERS = {
// API Key Providers
export const APIKEY_PROVIDERS = {
agentrouter: {
id: "agentrouter",
alias: "agentrouter",
name: "AgentRouter",
icon: "router",
color: "#10B981",
textIcon: "AR",
passthroughModels: true,
website: "https://agentrouter.org",
apiHint: "Get $200 free credits at https://agentrouter.org/register — no credit card required.",
},
openrouter: {
id: "openrouter",
alias: "openrouter",

View File

@@ -79,7 +79,7 @@ test("kimi-coding-apikey validation uses Kimi Coding messages endpoint", async (
assert.equal(calls[0].url, "https://api.kimi.com/coding/v1/messages");
assert.equal(calls[0].method, "POST");
assert.equal(calls[0].headers["x-api-key"], "sk-kimi-test");
assert.equal(calls[0].headers["anthropic-version"], "2023-06-01");
assert.equal(calls[0].headers["Anthropic-Version"], "2023-06-01");
for (const call of calls) {
assert.equal(call.url.includes("?beta=true/messages"), false);

View File

@@ -33,10 +33,10 @@ test("T20: gemini CLI fingerprint uses 0.31.0 and preserves darwin platform name
test("T25: anthropic API-key config includes the full Anthropic beta header set", () => {
const anthropic = REGISTRY.anthropic;
assert.equal(anthropic.headers["anthropic-version"], "2023-06-01");
assert.ok(anthropic.headers["anthropic-beta"]?.includes("advanced-tool-use-2025-11-20"));
assert.ok(anthropic.headers["anthropic-beta"]?.includes("structured-outputs-2025-12-15"));
assert.ok(anthropic.headers["anthropic-beta"]?.includes("token-efficient-tools-2026-03-28"));
assert.equal(anthropic.headers["Anthropic-Version"], "2023-06-01");
assert.ok(anthropic.headers["Anthropic-Beta"]?.includes("advanced-tool-use-2025-11-20"));
assert.ok(anthropic.headers["Anthropic-Beta"]?.includes("structured-outputs-2025-12-15"));
assert.ok(anthropic.headers["Anthropic-Beta"]?.includes("token-efficient-tools-2026-03-28"));
});
test("T22: github headers include updated editor/plugin versions and required fields", () => {