Resolve conflicts

This commit is contained in:
diegosouzapw
2026-04-29 08:55:12 -03:00
76 changed files with 2676 additions and 499 deletions

View File

@@ -4,6 +4,19 @@
---
## [3.7.5] — 2026-04-29
### ✨ New Features
- **feat(tunnels):** integrate native ngrok tunnel support with dashboard UI parity (#1753)
### 🐛 Bug Fixes
- **fix(models):** apply blocked providers filter to non-chat catalog models (image, embedding, audio, etc.) (#1752)
- **fix(antigravity):** stabilize streaming payload parsing and deduplicate usage/model metadata refreshes (#1748)
---
## [3.7.4] — 2026-04-28
### ✨ New Features

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.7.4
version: 3.7.5
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.7.4",
"version": "3.7.5",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {

View File

@@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
**Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost.
**Current version:** 3.7.4
**Current version:** 3.7.5
## Tech Stack
@@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
└── .env.example # Environment variable template
```
## Key Features (v3.7.4)
## Key Features (v3.7.5)
### Core Proxy
- **160+ AI providers** with automatic format translation

View File

@@ -67,6 +67,7 @@ const nextConfig = {
"tls-client-node",
"koffi",
"tough-cookie",
"@ngrok/ngrok",
"child_process",
"fs",
"path",
@@ -100,6 +101,12 @@ const nextConfig = {
contextRegExp: /thread-stream/,
})
);
// Mark @ngrok/ngrok as external to prevent webpack from trying to bundle its .node binaries
config.externals = config.externals || [];
config.externals.push({
"@ngrok/ngrok": "commonjs @ngrok/ngrok",
});
// ── Turbopack / Next.js 16 module-hash patch (#394, #396, #398) ────────
//
// Next.js 16 (with or without Turbopack) compiles the instrumentation hook

View File

@@ -228,7 +228,7 @@ const CHAT_OPENAI_COMPAT_MODELS: Record<string, RegistryModel[]> = {
codestral: buildModels(["codestral-2405", "codestral-latest"]),
upstage: buildModels(["solar-pro", "solar-mini", "solar-docvision", "solar-embedding-1-large"]),
maritalk: buildModels(["sabia-3", "sabia-3-small"]),
"xiaomi-mimo": buildModels(["mimo-v2-pro", "mimo-v2-omni", "mimo-v2-tts"]),
"xiaomi-mimo": buildModels(["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni", "mimo-v2-flash"]),
"inference-net": buildModels([
"meta-llama/Llama-3.3-70B-Instruct",
"deepseek-ai/DeepSeek-R1",

View File

@@ -27,7 +27,7 @@ const AG_DEFAULT_TOOL_NAMES = [
const AG_DECOY_TOOL_NAMES = [
...AG_DEFAULT_TOOL_NAMES,
"mcp_sequential-thinking_sequentialthinking",
"mcp_sequential_thinking_sequentialthinking",
] as const;
export const AG_DEFAULT_TOOLS = new Set<string>(AG_DEFAULT_TOOL_NAMES);

View File

@@ -28,15 +28,29 @@ const CREDITS_EXHAUSTED_TTL_MS = 5 * 60 * 60 * 1000; // 5 hours
const BARE_PRO_IDS = new Set(["gemini-3.1-pro"]);
function cloneAntigravityRequestBody(body: unknown): unknown {
if (!body || typeof body !== "object") {
return body;
}
try {
return structuredClone(body);
} catch {
return JSON.parse(JSON.stringify(body));
}
}
function serializeAntigravityRequest(
provider: string,
headers: Record<string, string>,
body: unknown
): { headers: Record<string, string>; bodyString: string } {
const serializedBody = cloneAntigravityRequestBody(body);
if (!isCliCompatEnabled(provider)) {
return { headers, bodyString: JSON.stringify(body) };
return { headers, bodyString: JSON.stringify(serializedBody) };
}
return applyFingerprint(provider, { ...headers }, body);
return applyFingerprint(provider, { ...headers }, serializedBody);
}
type AntigravityCollectedStream = {
@@ -585,7 +599,11 @@ export class AntigravityExecutor extends BaseExecutor {
}
try {
const serializedRequest = serializeAntigravityRequest(this.provider, headers, transformedBody);
const serializedRequest = serializeAntigravityRequest(
this.provider,
headers,
transformedBody
);
const finalHeaders = serializedRequest.headers;
const response = await fetch(url, {

View File

@@ -12,6 +12,7 @@ import {
import { PROVIDERS } from "../config/constants.ts";
import { getCodexClientVersion, getCodexUserAgent } from "../config/codexClient.ts";
import { getAccessToken } from "../services/tokenRefresh.ts";
import { getRememberedResponseFunctionCalls } from "../services/responsesToolCallState.ts";
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
import { CORS_HEADERS } from "../utils/cors.ts";
import { createRequire } from "module";
@@ -359,16 +360,62 @@ function convertSystemToDeveloperRole(body: Record<string, unknown>): void {
* 3. Strips the "id" field from any object in input whose id matches a
* server-generated prefix (rs_, fc_, resp_, msg_) — so the content is
* preserved but the backend won't try to look it up
* 4. Always deletes previous_response_id (endpoint doesn't persist responses)
* 4. Rehydrates missing function_call items for stateful tool-output follow-ups
* using locally remembered response state, then deletes previous_response_id
*/
function stripStoredItemReferences(body: Record<string, unknown>): void {
const hasInput = Array.isArray(body.input) && body.input.length > 0;
const inputItems = Array.isArray(body.input) ? body.input : [];
const previousResponseId =
typeof body.previous_response_id === "string" ? body.previous_response_id : "";
const inputFunctionCallIds = new Set<string>();
const inputFunctionCallOutputIds = new Set<string>();
// Always strip previous_response_id IF we have input.
// The /codex/responses endpoint does not persist responses, so any reference
// to a previous response would cause a 404. However, if input is missing (e.g. Cursor
// trying to continue generation), stripping it leaves the payload empty causing a 400 Schema error.
// We leave it intact so Codex returns 404, which correctly triggers Cursor's fallback to resend history.
for (const item of inputItems) {
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
const record = item as Record<string, unknown>;
const type = typeof record.type === "string" ? record.type : "";
const callId = typeof record.call_id === "string" ? record.call_id : "";
if (!callId) continue;
if (type === "function_call") {
inputFunctionCallIds.add(callId);
continue;
}
if (type === "function_call_output") {
inputFunctionCallOutputIds.add(callId);
}
}
const missingFunctionCallIds = [...inputFunctionCallOutputIds].filter(
(callId) => !inputFunctionCallIds.has(callId)
);
if (hasInput && previousResponseId && missingFunctionCallIds.length > 0) {
const rememberedFunctionCalls = getRememberedResponseFunctionCalls(previousResponseId);
const injectedFunctionCalls = rememberedFunctionCalls
.filter((functionCall) => missingFunctionCallIds.includes(functionCall.call_id))
.filter((functionCall) => !inputFunctionCallIds.has(functionCall.call_id))
.map((functionCall) => ({
type: "function_call",
call_id: functionCall.call_id,
name: functionCall.name,
arguments: functionCall.arguments,
}));
if (injectedFunctionCalls.length > 0) {
body.input = [...injectedFunctionCalls, ...inputItems];
for (const functionCall of injectedFunctionCalls) {
inputFunctionCallIds.add(functionCall.call_id);
}
}
}
// Strip previous_response_id whenever the request already carries input items.
// Codex rejects this field outright, so stateful follow-up turns must be made
// self-contained via the local function_call replay above.
//
// If input is missing entirely (e.g. Cursor trying to continue generation), keep
// previous_response_id so upstream can decide whether to fall back.
if (hasInput) {
delete body.previous_response_id;
}
@@ -1133,6 +1180,11 @@ export class CodexExecutor extends BaseExecutor {
// whether the request came via native passthrough or translation.
delete body.max_tokens;
delete body.max_output_tokens;
// VS Code Copilot BYOK Responses requests include `truncation` (for example
// "auto" or "disabled"). The Codex /responses backend currently rejects this
// field entirely with 400 Unsupported parameter: truncation, so strip it for
// both native passthrough and translated requests.
delete body.truncation;
delete body.background; // Droid CLI sends this but Codex Responses API rejects it
// Inject prompt_cache_key for Codex prompt caching.

View File

@@ -2162,67 +2162,78 @@ export async function handleChatCore({
accountSemaphoreKey && accountSemaphoreMaxConcurrency != null
? await acquireAccountSemaphore(accountSemaphoreKey, {
maxConcurrency: accountSemaphoreMaxConcurrency,
signal: streamController.signal,
})
: () => {};
try {
const rawResult = await withRateLimit(provider, connectionId, modelToCall, async () => {
let attempts = 0;
const maxAttempts = provider === "qwen" ? 3 : 1;
const rawResult = await withRateLimit(
provider,
connectionId,
modelToCall,
async () => {
let attempts = 0;
const maxAttempts = provider === "qwen" ? 3 : 1;
while (attempts < maxAttempts) {
const res = await executor.execute({
model: modelToCall,
body: bodyToSend,
stream: upstreamStream,
credentials: executionCredentials,
signal: streamController.signal,
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
onCredentialsRefreshed,
});
while (attempts < maxAttempts) {
const res = await executor.execute({
model: modelToCall,
body: bodyToSend,
stream: upstreamStream,
credentials: executionCredentials,
signal: streamController.signal,
log,
extendedContext,
upstreamExtraHeaders: buildUpstreamHeadersForExecute(modelToCall),
clientHeaders: buildExecutorClientHeaders(clientRawRequest?.headers, userAgent),
onCredentialsRefreshed,
});
// Qwen 429 strict quota backoff (wait 1.5s, 3s and retry)
if (provider === "qwen" && res.response.status === 429 && attempts < maxAttempts - 1) {
const bodyPeek = await res.response
.clone()
.text()
.catch(() => "");
if (bodyPeek.toLowerCase().includes("exceeded your current quota")) {
const delay = 1500 * (attempts + 1);
log?.warn?.("QWEN_RETRY", `Quota 429 hit. Retrying in ${delay}ms...`);
await new Promise((r) => setTimeout(r, delay));
attempts++;
continue;
}
}
// For streaming: release the semaphore when the client drains or cancels the stream.
if (stream) {
const originalBody = res.response.body;
if (!originalBody) {
acquireAccountSemaphoreRelease();
return res;
// Qwen 429 strict quota backoff (wait 1.5s, 3s and retry)
if (
provider === "qwen" &&
res.response.status === 429 &&
attempts < maxAttempts - 1
) {
const bodyPeek = await res.response
.clone()
.text()
.catch(() => "");
if (bodyPeek.toLowerCase().includes("exceeded your current quota")) {
const delay = 1500 * (attempts + 1);
log?.warn?.("QWEN_RETRY", `Quota 429 hit. Retrying in ${delay}ms...`);
await new Promise((r) => setTimeout(r, delay));
attempts++;
continue;
}
}
return {
...res,
response: new Response(
wrapReadableStreamWithFinalize(originalBody, acquireAccountSemaphoreRelease),
{
status: res.response.status,
statusText: res.response.statusText,
headers: res.response.headers,
}
),
};
}
// For streaming: release the semaphore when the client drains or cancels the stream.
if (stream) {
const originalBody = res.response.body;
if (!originalBody) {
acquireAccountSemaphoreRelease();
return res;
}
return res;
}
});
return {
...res,
response: new Response(
wrapReadableStreamWithFinalize(originalBody, acquireAccountSemaphoreRelease),
{
status: res.response.status,
statusText: res.response.statusText,
headers: res.response.headers,
}
),
};
}
return res;
}
},
streamController.signal
);
if (stream) {
return rawResult;
@@ -3531,7 +3542,8 @@ export async function handleChatCore({
body,
onStreamComplete,
apiKeyInfo,
handleStreamFailure
handleStreamFailure,
clientResponseFormat
);
}

View File

@@ -7,15 +7,30 @@ import { MCP_TOOL_MAP, dbHealthCheckInput } from "../schemas/tools.ts";
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
const mockRunManagedDbHealthCheck = vi.hoisted(() => vi.fn());
vi.mock("../audit.ts", () => ({
logToolCall: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../../src/lib/db/core.ts", () => ({
runManagedDbHealthCheck: mockRunManagedDbHealthCheck,
}));
describe("omniroute_db_health_check MCP tool", () => {
let client: Client;
beforeEach(async () => {
mockFetch.mockReset();
mockRunManagedDbHealthCheck.mockReset();
mockRunManagedDbHealthCheck.mockReturnValue({
isHealthy: false,
issues: [{ type: "broken_reference", table: "combos", description: "broken", count: 1 }],
repairedCount: 1,
backupCreated: true,
autoRepair: true,
checkedAt: new Date().toISOString(),
});
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const server = createMcpServer();
await server.connect(serverTransport);
@@ -38,32 +53,19 @@ describe("omniroute_db_health_check MCP tool", () => {
expect(dbHealthCheckInput.safeParse({ autoRepair: "yes" }).success).toBe(false);
});
it("dispatches to /api/v1/db/health using POST when autoRepair=true", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: async () => ({
isHealthy: false,
issues: [{ type: "broken_reference", table: "combos", description: "broken", count: 1 }],
repairedCount: 1,
backupCreated: true,
autoRepair: true,
checkedAt: new Date().toISOString(),
}),
});
it("runs the database repair flow directly when autoRepair=true", async () => {
const result = await client.callTool({
name: "omniroute_db_health_check",
arguments: { autoRepair: true },
});
expect(result.isError).toBeFalsy();
expect(mockFetch).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/db/health"),
expect.objectContaining({ method: "POST" })
);
expect(mockFetch).not.toHaveBeenCalled();
expect(mockRunManagedDbHealthCheck).toHaveBeenCalledWith({ autoRepair: true });
const content = result.content[0] as { type: string; text: string };
const payload = JSON.parse(content.text);
expect(payload.autoRepair).toBe(true);
expect(payload.repairedCount).toBe(1);
expect(payload.backupCreated).toBe(true);
});

View File

@@ -882,7 +882,7 @@ export const dbHealthCheckTool: McpToolDefinition<
scopes: ["read:health", "write:resilience"],
auditLevel: "full",
phase: 2,
sourceEndpoints: ["/api/v1/db/health"],
sourceEndpoints: ["/api/db/health"],
};
// --- Tool 19: omniroute_sync_pricing ---

View File

@@ -893,11 +893,8 @@ export async function handleDbHealthCheck(args: { autoRepair?: boolean }) {
const autoRepair = args.autoRepair === true;
try {
const result = toRecord(
await apiFetch("/api/v1/db/health", {
method: autoRepair ? "POST" : "GET",
})
);
const { runManagedDbHealthCheck } = await import("../../../src/lib/db/core.ts");
const result = runManagedDbHealthCheck({ autoRepair });
await logToolCall(
"omniroute_db_health_check",

View File

@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "3.7.4",
"version": "3.7.5",
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",

View File

@@ -28,6 +28,7 @@ interface AccountGate {
export interface AcquireAccountSemaphoreOptions {
maxConcurrency?: number | null;
timeoutMs?: number;
signal?: AbortSignal | null;
}
export interface AccountSemaphoreStatsEntry {
@@ -168,18 +169,34 @@ function createSemaphoreTimeoutError(
return error;
}
function makeAbortError(signal: AbortSignal): Error {
const reason = signal.reason;
if (reason instanceof Error) return reason;
const err = new Error(typeof reason === "string" ? reason : "The operation was aborted");
err.name = "AbortError";
return err;
}
/**
* Acquire a slot for a provider/model/account tuple.
* Returns an idempotent release function that is safe to call in finally blocks.
*/
export function acquire(
semaphoreKey: string,
{ maxConcurrency = null, timeoutMs = DEFAULT_TIMEOUT_MS }: AcquireAccountSemaphoreOptions = {}
{
maxConcurrency = null,
timeoutMs = DEFAULT_TIMEOUT_MS,
signal = null,
}: AcquireAccountSemaphoreOptions = {}
): Promise<() => void> {
if (isBypassed(maxConcurrency)) {
return Promise.resolve(createNoopReleaseFn());
}
if (signal?.aborted) {
return Promise.reject(makeAbortError(signal));
}
const gate = ensureGate(semaphoreKey, maxConcurrency);
clearCleanupTimer(gate);
@@ -189,7 +206,16 @@ export function acquire(
}
return new Promise((resolve, reject) => {
let abortListener: (() => void) | null = null;
const cleanup = () => {
if (abortListener && signal) {
signal.removeEventListener("abort", abortListener);
}
};
const timer = setTimeout(() => {
cleanup();
const nextGate = gates.get(semaphoreKey);
if (!nextGate) {
reject(createSemaphoreTimeoutError(semaphoreKey, timeoutMs));
@@ -209,7 +235,49 @@ export function acquire(
}, timeoutMs);
timer.unref?.();
gate.queue.push({ resolve, reject, timer });
const queueItem: QueuedAcquire = {
resolve: (release) => {
cleanup();
resolve(release);
},
reject: (error) => {
cleanup();
reject(error);
},
timer,
};
gate.queue.push(queueItem);
if (signal) {
abortListener = () => {
cleanup();
clearTimeout(timer);
const nextGate = gates.get(semaphoreKey);
if (!nextGate) {
reject(makeAbortError(signal));
return;
}
const queueIndex = nextGate.queue.findIndex((item) => item.timer === timer);
if (queueIndex !== -1) {
nextGate.queue.splice(queueIndex, 1);
}
if (nextGate.running === 0 && nextGate.queue.length === 0) {
scheduleCleanup(semaphoreKey);
}
reject(makeAbortError(signal));
};
if (signal.aborted) {
abortListener();
} else {
signal.addEventListener("abort", abortListener);
}
}
});
}

View File

@@ -20,7 +20,8 @@ export const GEMINI_CLI_VERSION = "0.39.1";
export const GEMINI_SDK_VERSION = "1.30.0";
export const NODE_VERSION = "v22.21.1";
export const ANTIGRAVITY_LOAD_CODE_ASSIST_USER_AGENT = "google-api-nodejs-client/10.3.0";
export const ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT = "google-cloud-sdk vscode_cloudshelleditor/0.1";
export const ANTIGRAVITY_LOAD_CODE_ASSIST_API_CLIENT =
"google-cloud-sdk vscode_cloudshelleditor/0.1";
const LOAD_CODE_ASSIST_METADATA = Object.freeze({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
@@ -133,6 +134,4 @@ export function googApiClientHeader(): string {
return `google-genai-sdk/${GEMINI_SDK_VERSION} gl-node/${NODE_VERSION}`;
}
export {
ANTIGRAVITY_VERSION,
};
export { ANTIGRAVITY_VERSION };

View File

@@ -274,18 +274,57 @@ function getLimiter(provider, connectionId, model = null) {
* @param {string} connectionId - Connection ID
* @param {string} model - Model name (optional, for per-model limits)
* @param {Function} fn - The async function to execute (e.g., executor.execute)
* @param {AbortSignal} signal - Optional abort signal to cancel waiting
* @returns {Promise<unknown>} Result of fn()
*/
export async function withRateLimit(provider, connectionId, model, fn) {
export async function withRateLimit(provider, connectionId, model, fn, signal = null) {
if (!enabledConnections.has(connectionId)) {
return fn();
}
if (signal?.aborted) {
const reason = signal.reason;
if (reason instanceof Error) throw reason;
const err = new Error(typeof reason === "string" ? reason : "The operation was aborted");
err.name = "AbortError";
throw err;
}
const limiter = getLimiter(provider, connectionId, model);
const maxWaitMs = currentRequestQueueSettings.maxWaitMs;
const scheduleOpts = maxWaitMs && maxWaitMs > 0 ? { expiration: maxWaitMs } : {};
try {
return await limiter.schedule(scheduleOpts, fn);
if (signal) {
let abortListener: (() => void) | undefined;
const abortPromise = new Promise<never>((_, reject) => {
const onAbort = () => {
const reason = signal.reason;
const err =
reason instanceof Error
? reason
: new Error(typeof reason === "string" ? reason : "The operation was aborted");
err.name = "AbortError";
reject(err);
};
if (signal.aborted) {
onAbort();
return;
}
abortListener = onAbort;
signal.addEventListener("abort", abortListener, { once: true });
});
try {
return await Promise.race([limiter.schedule(scheduleOpts, fn), abortPromise]);
} finally {
if (abortListener) {
signal.removeEventListener("abort", abortListener);
}
}
} else {
return await limiter.schedule(scheduleOpts, fn);
}
} catch (err) {
// Bottleneck throws when a job exceeds its expiration timeout.
// Surface as a clear rate-limit timeout so callers can fallback.

View File

@@ -0,0 +1,108 @@
type JsonRecord = Record<string, unknown>;
type RememberedFunctionCall = {
call_id: string;
name: string;
arguments: string;
};
type RememberedResponseToolState = {
functionCalls: RememberedFunctionCall[];
expiresAt: number;
updatedAt: number;
};
const RESPONSE_TOOL_CALL_TTL_MS = 30 * 60 * 1000;
const RESPONSE_TOOL_CALL_CACHE_MAX_ENTRIES = 512;
const rememberedResponseToolCalls = new Map<string, RememberedResponseToolState>();
function toRecord(value: unknown): JsonRecord | null {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null;
}
function cleanupRememberedResponseToolCalls(now: number = Date.now()) {
for (const [responseId, entry] of rememberedResponseToolCalls.entries()) {
if (entry.expiresAt <= now) {
rememberedResponseToolCalls.delete(responseId);
}
}
if (rememberedResponseToolCalls.size <= RESPONSE_TOOL_CALL_CACHE_MAX_ENTRIES) {
return;
}
const oldestEntries = [...rememberedResponseToolCalls.entries()].sort(
(a, b) => a[1].updatedAt - b[1].updatedAt
);
while (rememberedResponseToolCalls.size > RESPONSE_TOOL_CALL_CACHE_MAX_ENTRIES) {
const oldest = oldestEntries.shift();
if (!oldest) break;
rememberedResponseToolCalls.delete(oldest[0]);
}
}
export function rememberResponseFunctionCalls(
responseId: unknown,
outputItems: readonly unknown[]
) {
const normalizedResponseId = typeof responseId === "string" ? responseId.trim() : "";
if (!normalizedResponseId || !Array.isArray(outputItems) || outputItems.length === 0) {
return;
}
const functionCalls: RememberedFunctionCall[] = [];
for (const item of outputItems) {
const record = toRecord(item);
if (!record || record.type !== "function_call") continue;
const callId = typeof record.call_id === "string" ? record.call_id.trim() : "";
const name = typeof record.name === "string" ? record.name.trim() : "";
const argumentsValue =
typeof record.arguments === "string"
? record.arguments
: JSON.stringify(record.arguments ?? {});
if (!callId || !name) continue;
functionCalls.push({
call_id: callId,
name,
arguments: argumentsValue,
});
}
if (functionCalls.length === 0) {
return;
}
cleanupRememberedResponseToolCalls();
rememberedResponseToolCalls.set(normalizedResponseId, {
functionCalls,
updatedAt: Date.now(),
expiresAt: Date.now() + RESPONSE_TOOL_CALL_TTL_MS,
});
}
export function getRememberedResponseFunctionCalls(responseId: unknown): RememberedFunctionCall[] {
cleanupRememberedResponseToolCalls();
const normalizedResponseId = typeof responseId === "string" ? responseId.trim() : "";
if (!normalizedResponseId) {
return [];
}
const entry = rememberedResponseToolCalls.get(normalizedResponseId);
if (!entry) {
return [];
}
return entry.functionCalls.map((functionCall) => ({ ...functionCall }));
}
export function clearRememberedResponseFunctionCallsForTesting() {
rememberedResponseToolCalls.clear();
}

View File

@@ -513,11 +513,7 @@ export async function refreshKiroToken(
// AWS SSO OIDC (Builder ID or IDC)
// If clientId and clientSecret exist, assume AWS SSO OIDC (default to builder-id if authMethod not specified)
if (clientId && clientSecret) {
const isIDC = authMethod === "idc";
const endpoint =
isIDC && region
? `https://oidc.${region}.amazonaws.com/token`
: "https://oidc.us-east-1.amazonaws.com/token";
const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;
const response = await runWithProxyContext(proxyConfig, () =>
fetch(endpoint, {

View File

@@ -30,12 +30,17 @@ function normalizeGeminiToolName(
options: GeminiToolSanitizationOptions = {}
): string {
const trimmed = name.trim();
if (!options.stripNamespace) {
return trimmed;
}
const namespaceStripped = !options.stripNamespace
? trimmed
: (() => {
const namespaceIndex = trimmed.indexOf(":");
return namespaceIndex >= 0 ? trimmed.slice(namespaceIndex + 1) : trimmed;
})();
const namespaceIndex = trimmed.indexOf(":");
return namespaceIndex >= 0 ? trimmed.slice(namespaceIndex + 1) : trimmed;
return namespaceStripped
.replace(/[^a-zA-Z0-9_]/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
}
function buildHashedGeminiToolName(

View File

@@ -7,6 +7,7 @@ import {
} from "../helpers/geminiHelper.ts";
import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.ts";
import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts";
import { capMaxOutputTokens } from "../../../src/lib/modelCapabilities.ts";
/**
* Direct Claude → Gemini request translator.
@@ -49,7 +50,7 @@ export function claudeToGeminiRequest(model, body, stream) {
result.generationConfig.topK = body.top_k;
}
if (body.max_tokens !== undefined) {
result.generationConfig.maxOutputTokens = body.max_tokens;
result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, body.max_tokens);
}
// ── System instruction ─────────────────────────────────────────
@@ -62,7 +63,7 @@ export function claudeToGeminiRequest(model, body, stream) {
}
if (systemText) {
result.systemInstruction = {
role: "user",
role: "system",
parts: [{ text: systemText }],
};
}

View File

@@ -186,7 +186,7 @@ function openaiToGeminiBase(model, body, stream, toolNameOptions: GeminiToolName
if (systemText) {
if (!result.systemInstruction) {
result.systemInstruction = {
role: "user",
role: "system",
parts: [{ text: systemText }],
};
} else {
@@ -413,7 +413,7 @@ function wrapInCloudCodeEnvelope(model, geminiCLI, credentials = null, isAntigra
if (envelope.request.systemInstruction?.parts) {
envelope.request.systemInstruction.parts.unshift(defaultPart);
} else {
envelope.request.systemInstruction = { role: "user", parts: [defaultPart] };
envelope.request.systemInstruction = { role: "system", parts: [defaultPart] };
}
// Add toolConfig for Antigravity
@@ -449,6 +449,28 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
const cleanModel = model.includes("/") ? model.split("/").pop()! : model;
const requestedMaxOutputTokens =
typeof claudeRequest.max_tokens === "number" && Number.isFinite(claudeRequest.max_tokens)
? claudeRequest.max_tokens
: 4096;
const generationConfig: GeminiGenerationConfig = {
temperature: claudeRequest.temperature || 1,
maxOutputTokens: capMaxOutputTokens(cleanModel, requestedMaxOutputTokens),
};
const thinkingBudget =
claudeRequest.thinking?.type === "enabled" &&
typeof claudeRequest.thinking.budget_tokens === "number" &&
Number.isFinite(claudeRequest.thinking.budget_tokens)
? Math.floor(claudeRequest.thinking.budget_tokens)
: null;
if (thinkingBudget && thinkingBudget > 0) {
generationConfig.thinkingConfig = {
thinkingBudget: capThinkingBudget(cleanModel, thinkingBudget),
includeThoughts: true,
};
}
const envelope: CloudCodeEnvelope = {
project: projectId,
model: cleanModel,
@@ -458,10 +480,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
request: {
sessionId: generateSessionId(),
contents: [],
generationConfig: {
temperature: claudeRequest.temperature || 1,
maxOutputTokens: claudeRequest.max_tokens || 4096,
},
generationConfig,
},
};
@@ -555,7 +574,7 @@ function wrapInCloudCodeEnvelopeForClaude(model, claudeRequest, credentials = nu
}
}
envelope.request.systemInstruction = { role: "user", parts: systemParts };
envelope.request.systemInstruction = { role: "system", parts: systemParts };
const changedToolNameMap = buildChangedToolNameMap(toolNameMap);
if (changedToolNameMap) {

View File

@@ -28,6 +28,7 @@ import {
sanitizeStreamingChunk,
extractThinkingFromContent,
} from "../handlers/responseSanitizer.ts";
import { rememberResponseFunctionCalls } from "../services/responsesToolCallState.ts";
import { buildErrorBody } from "./error.ts";
/**
@@ -80,6 +81,7 @@ type StreamOptions = {
mode?: string;
targetFormat?: string;
sourceFormat?: string;
clientResponseFormat?: string | null;
provider?: string | null;
reqLogger?: StreamLogger | null;
toolNameMap?: unknown;
@@ -476,6 +478,7 @@ export function createSSEStream(options: StreamOptions = {}) {
mode = STREAM_MODE.TRANSLATE,
targetFormat,
sourceFormat,
clientResponseFormat = null,
provider = null,
reqLogger = null,
toolNameMap = null,
@@ -487,6 +490,11 @@ export function createSSEStream(options: StreamOptions = {}) {
onFailure = null,
} = options;
const clientExpectsResponsesStream =
(mode === STREAM_MODE.PASSTHROUGH
? clientResponseFormat === FORMATS.OPENAI_RESPONSES
: sourceFormat === FORMATS.OPENAI_RESPONSES) === true;
let buffer = "";
let usage: UsageTokenRecord | null = null;
/** Passthrough (OpenAI CC shape): saw tool_calls in stream before finish_reason */
@@ -516,6 +524,8 @@ export function createSSEStream(options: StreamOptions = {}) {
// used to backfill `response.completed.response.output` when upstream returns it
// empty (which happens when `store: false` — see backfillResponsesCompletedOutput).
const passthroughResponsesOutputItems: unknown[] = [];
let passthroughResponsesId: string | null = null;
const passthroughResponsesReasoningSummarySeen = new Set<string>();
const streamStartedAt = Date.now();
// Guard against duplicate [DONE] events — ensures exactly one per stream
@@ -683,6 +693,101 @@ export function createSSEStream(options: StreamOptions = {}) {
controller.enqueue(encoder.encode(comment));
};
const getResponsesReasoningKey = (payload: Record<string, unknown>): string | null => {
if (typeof payload.item_id === "string" && payload.item_id) {
return payload.item_id;
}
const item =
payload.item && typeof payload.item === "object" && !Array.isArray(payload.item)
? (payload.item as Record<string, unknown>)
: null;
if (item && typeof item.id === "string" && item.id) {
return item.id;
}
const responseId =
typeof payload.response_id === "string" && payload.response_id
? payload.response_id
: passthroughResponsesId;
const outputIndex =
typeof payload.output_index === "number" && Number.isInteger(payload.output_index)
? payload.output_index
: null;
return responseId !== null && outputIndex !== null ? `${responseId}:${outputIndex}` : null;
};
const emitSyntheticResponsesReasoningSummary = (
controller: TransformStreamDefaultController,
payload: Record<string, unknown>
) => {
const item =
payload.item && typeof payload.item === "object" && !Array.isArray(payload.item)
? (payload.item as Record<string, unknown>)
: null;
if (!item || item.type !== "reasoning" || !Array.isArray(item.summary)) {
return;
}
const summaryText = item.summary
.map((part) => {
if (!part || typeof part !== "object" || Array.isArray(part)) {
return "";
}
return typeof (part as Record<string, unknown>).text === "string"
? ((part as Record<string, unknown>).text as string)
: "";
})
.join("");
if (!summaryText) {
return;
}
const reasoningKey = getResponsesReasoningKey(payload);
if (!reasoningKey || passthroughResponsesReasoningSummarySeen.has(reasoningKey)) {
return;
}
passthroughResponsesReasoningSummarySeen.add(reasoningKey);
const itemId = typeof item.id === "string" && item.id ? item.id : reasoningKey;
const outputIndex =
typeof payload.output_index === "number" && Number.isInteger(payload.output_index)
? payload.output_index
: 0;
const syntheticEvents = [
{
event: "response.reasoning_summary_text.delta",
body: {
type: "response.reasoning_summary_text.delta",
item_id: itemId,
output_index: outputIndex,
summary_index: 0,
delta: summaryText,
},
},
{
event: "response.reasoning_summary_part.done",
body: {
type: "response.reasoning_summary_part.done",
item_id: itemId,
output_index: outputIndex,
summary_index: 0,
part: { type: "summary_text", text: summaryText },
},
},
];
for (const syntheticEvent of syntheticEvents) {
clientPayloadCollector.push(syntheticEvent.body);
const output = `event: ${syntheticEvent.event}\ndata: ${JSON.stringify(syntheticEvent.body)}\n\n`;
reqLogger?.appendConvertedChunk?.(output);
controller.enqueue(encoder.encode(output));
}
};
return new TransformStream(
{
start(controller) {
@@ -809,6 +914,15 @@ export function createSSEStream(options: StreamOptions = {}) {
parsed.type === "error");
if (isResponsesSSE) {
const responseId =
typeof parsed.response?.id === "string"
? parsed.response.id
: typeof parsed.response_id === "string"
? parsed.response_id
: null;
if (responseId) {
passthroughResponsesId = responseId;
}
// Responses SSE: only extract usage, forward payload as-is
const extracted = extractUsage(parsed);
if (extracted) {
@@ -825,10 +939,21 @@ export function createSSEStream(options: StreamOptions = {}) {
if (parsed.type === "response.failed") {
failurePayload = normalizeStreamFailurePayload(parsed);
}
if (
parsed.type === "response.reasoning_summary_text.delta" ||
parsed.type === "response.reasoning_summary_text.done" ||
parsed.type === "response.reasoning_summary_part.done"
) {
const reasoningKey = getResponsesReasoningKey(parsed);
if (reasoningKey) {
passthroughResponsesReasoningSummarySeen.add(reasoningKey);
}
}
// Capture each completed output item so the final
// response.completed snapshot can be backfilled when upstream
// returns an empty `output` (happens with store: false).
if (parsed.type === "response.output_item.done" && parsed.item) {
emitSyntheticResponsesReasoningSummary(controller, parsed);
passthroughResponsesOutputItems.push(parsed.item);
}
// Two transport-level fixes for Responses passthrough:
@@ -1288,6 +1413,13 @@ export function createSSEStream(options: StreamOptions = {}) {
}
clearPendingPassthroughEvent();
if (passthroughResponsesId && passthroughResponsesOutputItems.length > 0) {
rememberResponseFunctionCalls(
passthroughResponsesId,
passthroughResponsesOutputItems
);
}
// Estimate usage if provider didn't return valid usage
if (!hasValidUsage(usage) && totalContentLength > 0) {
usage = estimateUsage(body, totalContentLength, sourceFormat || FORMATS.OPENAI);
@@ -1307,10 +1439,12 @@ export function createSSEStream(options: StreamOptions = {}) {
if (!doneSent) {
await emitFinalSseMetadata(controller, usage);
doneSent = true;
clientPayloadCollector.push({ done: true });
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(encoder.encode(doneOutput));
if (!clientExpectsResponsesStream) {
clientPayloadCollector.push({ done: true });
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(encoder.encode(doneOutput));
}
}
// Notify caller for call log persistence (include full response body with accumulated content)
if (onComplete) {
@@ -1499,10 +1633,12 @@ export function createSSEStream(options: StreamOptions = {}) {
if (!doneSent) {
await emitFinalSseMetadata(controller, state?.usage as Record<string, unknown> | null);
doneSent = true;
clientPayloadCollector.push({ done: true });
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(encoder.encode(doneOutput));
if (!clientExpectsResponsesStream) {
clientPayloadCollector.push({ done: true });
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(encoder.encode(doneOutput));
}
}
// Estimate usage if provider didn't return valid usage (for translate mode)
@@ -1632,7 +1768,8 @@ export function createPassthroughStreamWithLogger(
body: unknown = null,
onComplete: ((payload: StreamCompletePayload) => void) | null = null,
apiKeyInfo: unknown = null,
onFailure: ((payload: StreamFailurePayload) => void | Promise<void>) | null = null
onFailure: ((payload: StreamFailurePayload) => void | Promise<void>) | null = null,
clientResponseFormat: string | null = null
) {
return createSSEStream({
mode: STREAM_MODE.PASSTHROUGH,
@@ -1645,5 +1782,6 @@ export function createPassthroughStreamWithLogger(
body,
onComplete,
onFailure,
clientResponseFormat,
});
}

236
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.7.4",
"version": "3.7.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.7.4",
"version": "3.7.5",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -16,6 +16,7 @@
"@lobehub/icons": "^5.5.4",
"@modelcontextprotocol/sdk": "^1.27.1",
"@monaco-editor/react": "^4.7.0",
"@ngrok/ngrok": "^1.7.0",
"@swc/helpers": "0.5.21",
"axios": "^1.15.0",
"bcryptjs": "^3.0.3",
@@ -2322,6 +2323,235 @@
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok/-/ngrok-1.7.0.tgz",
"integrity": "sha512-P06o9TpxrJbiRbHQkiwy/rUrlXRupc+Z8KT4MiJfmcdWxvIdzjCaJOdnNkcOTs6DMyzIOefG5tvk/HLdtjqr0g==",
"license": "(MIT OR Apache-2.0)",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@ngrok/ngrok-android-arm64": "1.7.0",
"@ngrok/ngrok-darwin-arm64": "1.7.0",
"@ngrok/ngrok-darwin-universal": "1.7.0",
"@ngrok/ngrok-darwin-x64": "1.7.0",
"@ngrok/ngrok-freebsd-x64": "1.7.0",
"@ngrok/ngrok-linux-arm-gnueabihf": "1.7.0",
"@ngrok/ngrok-linux-arm64-gnu": "1.7.0",
"@ngrok/ngrok-linux-arm64-musl": "1.7.0",
"@ngrok/ngrok-linux-x64-gnu": "1.7.0",
"@ngrok/ngrok-linux-x64-musl": "1.7.0",
"@ngrok/ngrok-win32-arm64-msvc": "1.7.0",
"@ngrok/ngrok-win32-ia32-msvc": "1.7.0",
"@ngrok/ngrok-win32-x64-msvc": "1.7.0"
}
},
"node_modules/@ngrok/ngrok-android-arm64": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-android-arm64/-/ngrok-android-arm64-1.7.0.tgz",
"integrity": "sha512-8tco3ID6noSaNy+CMS7ewqPoIkIM6XO5COCzsUp3Wv3XEbMSyn65RN6cflX2JdqLfUCHcMyD0ahr9IEiHwqmbQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-darwin-arm64": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-arm64/-/ngrok-darwin-arm64-1.7.0.tgz",
"integrity": "sha512-+dmJSOzSO+MNDVrPOca2yYDP1W3KfP4qOlAkarIeFRIfqonQwq3QCBmcR7HAlZocLsSqEwyG6KP4RRvAuT0WGQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-darwin-universal": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-universal/-/ngrok-darwin-universal-1.7.0.tgz",
"integrity": "sha512-fDEfewyE2pWGFBhOSwQZObeHUkc65U1l+3HIgSOe094TMHsqmyJD0KTCgW9KSn0VP4OvDZbAISi1T3nvqgZYhQ==",
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-darwin-x64": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-darwin-x64/-/ngrok-darwin-x64-1.7.0.tgz",
"integrity": "sha512-+fwMi5uHd9G8BS42MMa9ye6exI5lwTcjUO6Ut497Vu0qgLONdVRenRqnEePV+Q3KtQR7NjqkMnomVfkr9MBjtw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-freebsd-x64": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-freebsd-x64/-/ngrok-freebsd-x64-1.7.0.tgz",
"integrity": "sha512-2OGgbrjy3yLRrqAz5N6hlUKIWIXSpR5RjQa2chtZMsSbszQ6c9dI+uVQfOKAeo05tHMUgrYAZ7FocC+ig0dzdQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-linux-arm-gnueabihf": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm-gnueabihf/-/ngrok-linux-arm-gnueabihf-1.7.0.tgz",
"integrity": "sha512-SN9YIfEQiR9xN90QVNvdgvAemqMLoFVSeTWZs779145hQMhvF9Qd9rnWi6J+2uNNK10OczdV1oc/nq1es7u/3g==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-linux-arm64-gnu": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-gnu/-/ngrok-linux-arm64-gnu-1.7.0.tgz",
"integrity": "sha512-KDMgzPKFU2kbpVSaA2RZBBia5IPdJEe063YlyVFnSMJmPYWCUnMwdybBsucXfV9u1Lw/ZjKTKotIlbTWGn3HGw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-linux-arm64-musl": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-arm64-musl/-/ngrok-linux-arm64-musl-1.7.0.tgz",
"integrity": "sha512-e66vUdVrBlQ0lT9ZdamB4U604zt5Gualt8/WVcUGzbu8s5LajWd6g/mzZCUjK4UepjvMpfgmCp1/+rX7Rk8d5A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-linux-x64-gnu": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-gnu/-/ngrok-linux-x64-gnu-1.7.0.tgz",
"integrity": "sha512-M6gF0DyOEFqXLfWxObfL3bxYZ4+PnKBHuyLVaqNfFN9Y5utY2mdPOn5422Ppbk4XoIK5/YkuhRqPJl/9FivKEw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-linux-x64-musl": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-linux-x64-musl/-/ngrok-linux-x64-musl-1.7.0.tgz",
"integrity": "sha512-4Ijm0dKeoyzZTMaYxR2EiNjtlK81ebflg/WYIO1XtleFrVy4UJEGnxtxEidYoT4BfCqi4uvXiK2Mx216xXKvog==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-win32-arm64-msvc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-arm64-msvc/-/ngrok-win32-arm64-msvc-1.7.0.tgz",
"integrity": "sha512-u7qyWIJI2/YG1HTBnHwUR1+Z2tyGfAsUAItJK/+N1G0FeWJhIWQvSIFJHlaPy4oW1Dc8mSDBX9qvVsiQgLaRFg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-win32-ia32-msvc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-ia32-msvc/-/ngrok-win32-ia32-msvc-1.7.0.tgz",
"integrity": "sha512-/UdYUsLNv/Q8j9YJsyIfq/jLCoD8WP+NidouucTUzSoDtmOsXBBT3itLrmPiZTEdEgKiFYLuC1Zon8XQQvbVLA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@ngrok/ngrok-win32-x64-msvc": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@ngrok/ngrok-win32-x64-msvc/-/ngrok-win32-x64-msvc-1.7.0.tgz",
"integrity": "sha512-UFJg/duEWzZlLkEs61Gz6/5nYhGaKI62I8dvUGdBR3NCtIMagehnFaFxmnXZldyHmCM8U0aCIFNpWRaKcrQkoA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -14972,7 +15202,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "3.7.4"
"version": "3.7.5"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.7.4",
"version": "3.7.5",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
@@ -110,6 +110,7 @@
"@lobehub/icons": "^5.5.4",
"@modelcontextprotocol/sdk": "^1.27.1",
"@monaco-editor/react": "^4.7.0",
"@ngrok/ngrok": "^1.7.0",
"@swc/helpers": "0.5.21",
"axios": "^1.15.0",
"bcryptjs": "^3.0.3",

View File

@@ -63,6 +63,26 @@ type TailscaleTunnelStatus = {
pid: number | null;
};
type NgrokTunnelPhase =
| "unsupported"
| "not_installed"
| "stopped"
| "needs_auth"
| "starting"
| "running"
| "error";
type NgrokTunnelStatus = {
supported: boolean;
installed: boolean;
running: boolean;
publicUrl: string | null;
apiUrl: string | null;
targetUrl: string;
phase: NgrokTunnelPhase;
lastError: string | null;
};
type TunnelNotice = {
type: "success" | "error" | "info";
message: string;
@@ -122,6 +142,11 @@ export default function APIPageClient({ machineId }) {
const [tailscalePassword, setTailscalePassword] = useState("");
const [showCloudflaredTunnel, setShowCloudflaredTunnel] = useState(true);
const [showTailscaleFunnel, setShowTailscaleFunnel] = useState(true);
const [ngrokStatus, setNgrokStatus] = useState<NgrokTunnelStatus | null>(null);
const [ngrokBusy, setNgrokBusy] = useState(false);
const [ngrokNotice, setNgrokNotice] = useState<TunnelNotice | null>(null);
const [ngrokToken, setNgrokToken] = useState("");
const [showNgrokTunnel, setShowNgrokTunnel] = useState(true);
const { copied, copy } = useCopyToClipboard();
@@ -218,6 +243,35 @@ export default function APIPageClient({ machineId }) {
[translateOrFallback]
);
const fetchNgrokStatus = useCallback(
async (silent = false) => {
try {
const res = await fetch("/api/tunnels/ngrok", { cache: "no-store" });
const data = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(
data?.error || translateOrFallback("ngrokRequestFailed", "Failed to load ngrok status")
);
}
setNgrokStatus(data);
return data as NgrokTunnelStatus;
} catch (error) {
if (!silent) {
setNgrokNotice({
type: "error",
message:
error instanceof Error
? error.message
: translateOrFallback("ngrokRequestFailed", "Failed to load ngrok status"),
});
}
return null;
}
},
[translateOrFallback]
);
useEffect(() => {
let mounted = true;
@@ -237,6 +291,7 @@ export default function APIPageClient({ machineId }) {
if (tunnelVisibility.showTailscaleFunnel) {
runEndpointBackgroundTask("tailscale-status", () => fetchTailscaleStatus(true));
}
runEndpointBackgroundTask("ngrok-status", () => fetchNgrokStatus(true));
};
void loadPage();
@@ -244,7 +299,7 @@ export default function APIPageClient({ machineId }) {
return () => {
mounted = false;
};
}, [fetchCloudflaredStatus, fetchTailscaleStatus]);
}, [fetchCloudflaredStatus, fetchTailscaleStatus, fetchNgrokStatus]);
const fetchModels = async () => {
setModelsLoading(true);
@@ -575,6 +630,52 @@ export default function APIPageClient({ machineId }) {
}
};
const handleNgrokAction = async (action: "enable" | "disable") => {
setNgrokBusy(true);
setNgrokNotice(null);
try {
const res = await fetch("/api/tunnels/ngrok", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, authToken: action === "enable" ? ngrokToken : undefined }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(
data?.error || translateOrFallback("ngrokRequestFailed", "Failed to update ngrok tunnel")
);
}
if (data?.status) {
setNgrokStatus(data.status);
}
setNgrokNotice({
type: "success",
message:
action === "enable"
? translateOrFallback("ngrokStarted", "ngrok tunnel started")
: translateOrFallback("ngrokStopped", "ngrok tunnel stopped"),
});
if (action === "enable") {
setNgrokToken("");
}
} catch (error) {
setNgrokNotice({
type: "error",
message:
error instanceof Error
? error.message
: translateOrFallback("ngrokRequestFailed", "Failed to update ngrok tunnel"),
});
} finally {
setNgrokBusy(false);
await fetchNgrokStatus(true);
}
};
const waitForTailscale = useCallback(
async (
predicate: (status: TailscaleTunnelStatus) => boolean,
@@ -964,6 +1065,42 @@ export default function APIPageClient({ machineId }) {
"Uses your Tailscale .ts.net address. Login and Funnel approval may be required on first use."
);
const ngrokPhase = ngrokStatus?.phase || "not_installed";
const ngrokPhaseMeta: Record<NgrokTunnelPhase, { label: string; className: string }> = {
running: {
label: translateOrFallback("ngrokRunning", "Running"),
className: "bg-green-500/10 border-green-500/30 text-green-400",
},
starting: {
label: translateOrFallback("ngrokStarting", "Starting"),
className: "bg-blue-500/10 border-blue-500/30 text-blue-400",
},
stopped: {
label: translateOrFallback("ngrokStoppedState", "Stopped"),
className: "bg-surface border-border/70 text-text-muted",
},
needs_auth: {
label: translateOrFallback("ngrokNeedsAuth", "Needs Auth"),
className: "bg-amber-500/10 border-amber-500/30 text-amber-400",
},
not_installed: {
label: translateOrFallback("ngrokNotInstalled", "Not installed"),
className: "bg-surface border-border/70 text-text-muted",
},
unsupported: {
label: translateOrFallback("ngrokUnsupported", "Unsupported"),
className: "bg-amber-500/10 border-amber-500/30 text-amber-400",
},
error: {
label: translateOrFallback("ngrokError", "Error"),
className: "bg-red-500/10 border-red-500/30 text-red-400",
},
};
const ngrokActionLabel = ngrokStatus?.running
? translateOrFallback("ngrokDisable", "Stop Tunnel")
: translateOrFallback("ngrokEnable", "Enable Tunnel");
const ngrokUrlNotice = translateOrFallback("ngrokUrlNotice", "Creates a public ngrok tunnel.");
return (
<div className="flex flex-col gap-8">
{/* Endpoint Card */}
@@ -1302,6 +1439,120 @@ export default function APIPageClient({ machineId }) {
</div>
</div>
)}
{showNgrokTunnel && (
<div
className={`${showCloudflaredTunnel || showTailscaleFunnel ? "mt-4 " : ""}rounded-xl border border-border/70 bg-surface/40 p-4`}
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">
{translateOrFallback("ngrokTitle", "ngrok Tunnel")}
</h3>
<span
className={`inline-flex items-center rounded-full border px-2 py-1 text-xs font-medium ${ngrokPhaseMeta[ngrokPhase].className}`}
>
{ngrokPhaseMeta[ngrokPhase].label}
</span>
</div>
</div>
{ngrokStatus?.supported !== false && (
<Button
size="sm"
variant={ngrokStatus?.running ? "secondary" : "primary"}
icon={ngrokStatus?.running ? "public_off" : "public"}
onClick={() => handleNgrokAction(ngrokStatus?.running ? "disable" : "enable")}
loading={ngrokBusy}
className={
ngrokStatus?.running
? "border-border/70! text-text-muted! hover:text-text!"
: "bg-linear-to-r from-blue-500 to-indigo-500 hover:from-blue-600 hover:to-indigo-600"
}
>
{ngrokActionLabel}
</Button>
)}
</div>
{ngrokNotice && (
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-2 text-sm ${
ngrokNotice.type === "success"
? "border-green-500/30 bg-green-500/10 text-green-400"
: ngrokNotice.type === "info"
? "border-blue-500/30 bg-blue-500/10 text-blue-400"
: "border-red-500/30 bg-red-500/10 text-red-400"
}`}
>
<span className="material-symbols-outlined text-[18px]">
{ngrokNotice.type === "success"
? "check_circle"
: ngrokNotice.type === "info"
? "info"
: "error"}
</span>
<span className="flex-1">{ngrokNotice.message}</span>
<button
onClick={() => setNgrokNotice(null)}
className="rounded p-0.5 transition-colors hover:bg-white/10"
>
<span className="material-symbols-outlined text-[16px]">close</span>
</button>
</div>
)}
<p className="text-xs text-text-muted">{ngrokUrlNotice}</p>
{ngrokStatus?.phase === "needs_auth" && (
<div className="flex flex-col gap-1">
<label className="text-xs text-text-muted">
{translateOrFallback(
"ngrokAuthTokenLabel",
"Authtoken (Required if NGROK_AUTHTOKEN not set)"
)}
</label>
<Input
type="password"
value={ngrokToken}
onChange={(event) => setNgrokToken(event.target.value)}
placeholder={translateOrFallback(
"ngrokAuthTokenPlaceholder",
"Enter your ngrok authtoken"
)}
disabled={ngrokBusy}
className="font-mono text-sm"
/>
</div>
)}
<div className="flex flex-col sm:flex-row gap-2">
<Input
value={ngrokStatus?.apiUrl || ""}
readOnly
placeholder="https://your-tunnel.ngrok-free.app/v1"
className="flex-1 min-w-0 font-mono text-sm"
/>
<Button
variant="secondary"
icon={copied === "ngrok_url" ? "check" : "content_copy"}
onClick={() => ngrokStatus?.apiUrl && copy(ngrokStatus.apiUrl, "ngrok_url")}
disabled={!ngrokStatus?.apiUrl}
className="shrink-0 self-start sm:self-auto"
>
{copied === "ngrok_url" ? tc("copied") : tc("copy")}
</Button>
</div>
{ngrokStatus?.lastError && (
<p className="text-xs text-red-400">
{translateOrFallback("ngrokLastError", "Last error: {error}", {
error: ngrokStatus.lastError,
})}
</p>
)}
</div>
</div>
)}
</Card>
<Card>

View File

@@ -81,7 +81,7 @@ export default function HealthPage() {
const fetchDbHealth = useCallback(async () => {
try {
const res = await fetch("/api/v1/db/health");
const res = await fetch("/api/db/health");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setDbHealth(json);
@@ -138,7 +138,7 @@ export default function HealthPage() {
const handleRepairDb = async () => {
setRepairingDb(true);
try {
const res = await fetch("/api/v1/db/health", { method: "POST" });
const res = await fetch("/api/db/health", { method: "POST" });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
setDbHealth(json);

View File

@@ -205,17 +205,21 @@ export default function MemoryPage() {
{health !== null && (
<span
className={`inline-block w-3 h-3 rounded-full ${health.working ? "bg-green-500" : "bg-red-500"}`}
title={health.working ? `Pipeline OK (${health.latencyMs}ms)` : "Pipeline error"}
title={
health.working
? t("pipelineOk", { latencyMs: health.latencyMs })
: t("pipelineError")
}
/>
)}
{health === null && !checkingHealth && (
<span
className="inline-block w-3 h-3 rounded-full bg-gray-400"
title="Health unknown"
title={t("healthUnknown")}
/>
)}
<Button variant="outline" size="sm" onClick={checkHealth} disabled={checkingHealth}>
{checkingHealth ? "Checking..." : "Check Health"}
{checkingHealth ? t("checkingHealth") : t("checkHealth")}
</Button>
</div>
</div>
@@ -321,7 +325,7 @@ export default function MemoryPage() {
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-500">
Page {page} of {totalPages} ({total} total)
{t("pageInfo", { page, totalPages, total })}
</div>
<div className="flex gap-2">
<Button
@@ -330,7 +334,7 @@ export default function MemoryPage() {
disabled={page === 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
Previous
{t("previous")}
</Button>
<Button
variant="outline"
@@ -338,7 +342,7 @@ export default function MemoryPage() {
disabled={page >= totalPages}
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
>
Next
{t("next")}
</Button>
</div>
</div>
@@ -356,47 +360,47 @@ export default function MemoryPage() {
onClick={() => setAddDialogOpen(false)}
disabled={isSubmitting}
>
Cancel
{t("cancel")}
</Button>
<Button
onClick={handleAddMemory}
loading={isSubmitting}
disabled={!newMemory.key || !newMemory.content}
>
Save
{t("save")}
</Button>
</>
}
>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Type</label>
<label className="block text-sm font-medium mb-1">{t("type")}</label>
<Select
value={newMemory.type}
onChange={(e) => setNewMemory({ ...newMemory, type: e.target.value as any })}
className="w-full"
>
<option value="factual">Factual</option>
<option value="episodic">Episodic</option>
<option value="procedural">Procedural</option>
<option value="semantic">Semantic</option>
<option value="factual">{t("factual")}</option>
<option value="episodic">{t("episodic")}</option>
<option value="procedural">{t("procedural")}</option>
<option value="semantic">{t("semantic")}</option>
</Select>
</div>
<div>
<label className="block text-sm font-medium mb-1">Key</label>
<label className="block text-sm font-medium mb-1">{t("key")}</label>
<Input
value={newMemory.key}
onChange={(e) => setNewMemory({ ...newMemory, key: e.target.value })}
placeholder="e.g., user_preference_theme"
placeholder={t("keyPlaceholder")}
className="w-full"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Content</label>
<label className="block text-sm font-medium mb-1">{t("content")}</label>
<Input
value={newMemory.content}
onChange={(e) => setNewMemory({ ...newMemory, content: e.target.value })}
placeholder="e.g., Prefers dark mode"
placeholder={t("contentPlaceholder")}
className="w-full"
/>
</div>

View File

@@ -460,7 +460,7 @@ interface CooldownTimerProps {
function getModelSourceBadgeClass(source?: string): string {
switch (normalizeModelCatalogSource(source)) {
case "api-sync":
case "imported":
return "border-sky-500/30 bg-sky-500/10 text-sky-300";
case "custom":
return "border-emerald-500/30 bg-emerald-500/10 text-emerald-300";
@@ -1041,12 +1041,12 @@ export default function ProviderDetailPage() {
const isOAuth = providerSupportsOAuth && !providerSupportsPat;
const registryModels = getModelsByProviderId(providerId);
// Prefer synced API-discovered models when available, then merge built-ins
// and user-managed custom/imported models without duplicating IDs.
// and user-managed custom models without duplicating IDs.
const models = useMemo(() => {
if (providerId === "gemini") {
return syncedAvailableModels.map((model: any) => ({
...model,
source: model?.source === "api-sync" ? "api-sync" : "api-sync",
source: "imported",
}));
}
@@ -1061,7 +1061,7 @@ export default function ProviderDetailPage() {
.map((model: any) => ({
id: model.id,
name: model.name || model.id,
source: "api-sync",
source: "imported",
}));
const knownIds = new Set([...registryIds, ...syncedExtras.map((model: any) => model.id)]);
const customExtras = modelMeta.customModels
@@ -1069,7 +1069,7 @@ export default function ProviderDetailPage() {
.map((cm: any) => ({
id: cm.id,
name: cm.name || cm.id,
source: cm.source === "api-sync" ? "api-sync" : "custom",
source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom",
}));
return [...builtInModels, ...syncedExtras, ...customExtras];
}, [providerId, registryModels, syncedAvailableModels, modelMeta.customModels]);
@@ -2097,6 +2097,9 @@ export default function ProviderDetailPage() {
typeof data.importedChanges?.total === "number"
? data.importedChanges.total
: importedCount;
const totalChangedCount =
changedCount +
(typeof data.customModelChanges?.total === "number" ? data.customModelChanges.total : 0);
if (importedModels.length === 0) {
setImportProgress((prev) => ({
@@ -2113,7 +2116,7 @@ export default function ProviderDetailPage() {
],
importedCount,
}));
if (changedCount > 0) {
if (totalChangedCount > 0) {
setTimeout(() => {
window.location.reload();
}, 2000);
@@ -2142,7 +2145,7 @@ export default function ProviderDetailPage() {
importedCount,
}));
if (changedCount > 0) {
if (totalChangedCount > 0) {
setTimeout(() => {
window.location.reload();
}, 2000);

View File

@@ -81,6 +81,8 @@ export async function GET(
}
const authData = generateAuthData(provider, null);
const startUrl = searchParams.get("startUrl");
const region = searchParams.get("region") || "us-east-1";
// Resolve proxy for this provider (provider-level → global → direct)
const proxy = await resolveProxyForProvider(provider);
@@ -95,7 +97,24 @@ export async function GET(
provider === "kilocode"
) {
// GitHub, Kiro/Amazon Q, Kimi Coding, and KiloCode don't use PKCE for device code
deviceData = await runWithProxyContext(proxy, () => (requestDeviceCode as any)(provider));
if ((provider === "kiro" || provider === "amazon-q") && startUrl) {
const providerOverrideConfig = {
...providerData.config,
startUrl,
region,
skipIssuerUrlForRegistration: true,
registerClientUrl: `https://oidc.${region}.amazonaws.com/client/register`,
deviceAuthUrl: `https://oidc.${region}.amazonaws.com/device_authorization`,
tokenUrl: `https://oidc.${region}.amazonaws.com/token`,
ssoOidcEndpoint: `https://oidc.${region}.amazonaws.com`,
};
deviceData = await runWithProxyContext(proxy, () =>
(requestDeviceCode as any)(provider, null, providerOverrideConfig)
);
} else {
deviceData = await runWithProxyContext(proxy, () => (requestDeviceCode as any)(provider));
}
} else {
// Qwen and other providers use PKCE
deviceData = await runWithProxyContext(proxy, () =>

View File

@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getAllCustomModels, getPricing } from "@/lib/localDb";
import { getAllCustomModels, getAllSyncedAvailableModels, getPricing } from "@/lib/localDb";
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value)
@@ -20,8 +20,9 @@ function asModelArray(value: unknown): Array<{ id?: string; name?: string }> {
* GET /api/pricing/models
* Returns the full model catalog merged from three sources:
* 1. providerRegistry (hardcoded)
* 2. customModels (DB — user-added or imported via /models)
* 3. pricing data (DB — models with pricing configured but not in sources 1/2)
* 2. syncedAvailableModels (DB — discovered/imported from provider /models)
* 3. customModels (DB — manually added models)
* 4. pricing data (DB — models with pricing configured but not in sources 1/2/3)
*/
export async function GET() {
try {
@@ -46,25 +47,14 @@ export async function GET() {
};
}
// ── 2. Custom models (DB) ───────────────────────────────────────
let customModelsMap: Record<string, unknown> = {};
try {
customModelsMap = asRecord(await getAllCustomModels());
} catch {
/* DB may not be ready */
}
for (const [providerId, rawModels] of Object.entries(customModelsMap)) {
const models = asModelArray(rawModels);
// Resolve alias — check if a registry entry maps this providerId
let alias = providerId;
const resolveAlias = (providerId: string) => {
for (const entry of Object.values(REGISTRY)) {
if (entry.id === providerId) {
alias = entry.alias || entry.id;
break;
}
if (entry.id === providerId) return entry.alias || entry.id;
}
return providerId;
};
const ensureCatalogProvider = (providerId: string, alias: string) => {
if (!catalog[alias]) {
catalog[alias] = {
id: providerId,
@@ -75,25 +65,52 @@ export async function GET() {
models: [],
};
}
return catalog[alias];
};
const appendDbModels = (providerId: string, rawModels: unknown) => {
const models = asModelArray(rawModels);
const alias = resolveAlias(providerId);
const providerCatalog = ensureCatalogProvider(providerId, alias);
const existingIds = new Set(providerCatalog.models.map((m) => m.id));
const existingIds = new Set(catalog[alias].models.map((m) => m.id));
for (const model of models) {
const modelId = typeof model.id === "string" ? model.id : null;
if (!modelId || existingIds.has(modelId)) {
continue;
}
if (!existingIds.has(modelId)) {
catalog[alias].models.push({
id: modelId,
name: typeof model.name === "string" && model.name.trim() ? model.name : modelId,
custom: true,
});
existingIds.add(modelId);
}
if (!modelId || existingIds.has(modelId)) continue;
providerCatalog.models.push({
id: modelId,
name: typeof model.name === "string" && model.name.trim() ? model.name : modelId,
custom: true,
});
existingIds.add(modelId);
}
};
// ── 2. Synced available models (DB) ─────────────────────────────
let syncedModelsMap: Record<string, unknown> = {};
try {
syncedModelsMap = asRecord(await getAllSyncedAvailableModels());
} catch {
/* DB may not be ready */
}
// ── 3. Pricing-only models (DB) ─────────────────────────────────
for (const [providerId, rawModels] of Object.entries(syncedModelsMap)) {
appendDbModels(providerId, rawModels);
}
// ── 3. Custom models (DB) ───────────────────────────────────────
let customModelsMap: Record<string, unknown> = {};
try {
customModelsMap = asRecord(await getAllCustomModels());
} catch {
/* DB may not be ready */
}
for (const [providerId, rawModels] of Object.entries(customModelsMap)) {
appendDbModels(providerId, rawModels);
}
// ── 4. Pricing-only models (DB) ─────────────────────────────────
let pricingData: Record<string, any> = {};
try {
pricingData = await getPricing();

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models";
import { getSyncedAvailableModelsForConnection } from "@/lib/db/models";
import {
importManagedModels,
type ManagedModelImportMode,
@@ -10,6 +11,7 @@ import {
buildModelSyncInternalHeaders,
isModelSyncInternalRequest,
} from "@/shared/services/modelSyncScheduler";
import { GET as getProviderModels } from "../models/route";
type JsonRecord = Record<string, unknown>;
@@ -28,8 +30,8 @@ function normalizeModelForComparison(model: unknown) {
const rawSource = toNonEmptyString(record.source)?.toLowerCase();
const source =
rawSource === "api-sync" || rawSource === "auto-sync" || rawSource === "imported"
? "api-sync"
: rawSource || "auto-sync";
? "imported"
: rawSource || "manual";
const apiFormat = toNonEmptyString(record.apiFormat) || "chat-completions";
const supportedEndpoints = Array.isArray(record.supportedEndpoints)
? Array.from(
@@ -56,6 +58,41 @@ function isManagedSyncedModel(model: unknown) {
return source === "api-sync" || source === "auto-sync" || source === "imported";
}
function getErrorMessageFromPayload(payload: JsonRecord): string | null {
const error = payload.error;
if (typeof error === "string" && error.trim().length > 0) {
return error.trim();
}
const errorRecord = asRecord(error);
return toNonEmptyString(errorRecord.message) || toNonEmptyString(payload.message);
}
async function readJsonResponse(response: Response): Promise<{
data: JsonRecord;
parseError: string | null;
}> {
const body = await response.text();
if (!body.trim()) {
return {
data: {},
parseError: "Empty response body from /models",
};
}
try {
return {
data: asRecord(JSON.parse(body)),
parseError: null,
};
} catch {
return {
data: {},
parseError: "Invalid JSON response from /models",
};
}
}
function summarizeModelChanges(previousModels: unknown, nextModels: unknown) {
const previousList = Array.isArray(previousModels) ? previousModels : [];
const nextList = Array.isArray(nextModels) ? nextModels : [];
@@ -117,13 +154,54 @@ function getModelSyncChannelLabel(connection: unknown) {
);
}
async function fetchProviderModelsForSync(request: Request, connectionId: string) {
// Construct a safe localhost URL from the incoming request's origin.
// The route only accepts authenticated or internal-scheduler requests,
// and the path is hardcoded — no user-controlled URL components reach fetch.
const SAFE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
const incomingUrl = new URL(request.url);
const safeOrigin = SAFE_HOSTS.has(incomingUrl.hostname)
? incomingUrl.origin
: `http://127.0.0.1:${process.env.PORT || "20128"}`;
const modelsPath = `/api/providers/${encodeURIComponent(connectionId)}/models?refresh=true`;
const headers = {
cookie: request.headers.get("cookie") || "",
...buildModelSyncInternalHeaders(),
};
try {
return await fetch(new URL(modelsPath, safeOrigin).href, {
method: "GET",
cache: "no-store",
headers,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[ModelSync] Internal /models self-fetch failed for ${connectionId.slice(
0,
8
)}; falling back to in-process route: ${message}`
);
return getProviderModels(
new Request(new URL(modelsPath, "http://localhost").href, {
method: "GET",
headers,
}),
{ params: { id: connectionId } }
);
}
}
/**
* POST /api/providers/[id]/sync-models
*
* Fetches the model list from a provider's /models endpoint and replaces the
* full custom models list for that provider while refreshing the per-connection
* discovery cache. Successful syncs only write a call log when the fetched
* channel actually changes the stored model list.
* Fetches the model list from a provider's /models endpoint, stores discovered
* models in the per-connection available-model cache, and removes matching
* upstream-discovered rows from the provider's custom model list. Successful
* syncs only write a call log when the fetched channel or custom model cleanup
* changes stored model state.
*
* Used by:
* - modelSyncScheduler (auto-sync on interval)
@@ -153,30 +231,21 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
logProvider = toNonEmptyString(connection.provider) || "unknown";
channelLabel = getModelSyncChannelLabel(connection);
const previousSyncedAvailableModelsForConnection = await getSyncedAvailableModelsForConnection(
logProvider,
id
);
// Fetch models from the existing /api/providers/[id]/models endpoint.
// Construct a safe localhost URL from the incoming request's origin.
// The route only accepts authenticated or internal-scheduler requests,
// and the path is hardcoded — no user-controlled URL components reach fetch.
const SAFE_HOSTS = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1"]);
const incomingUrl = new URL(request.url);
const safeOrigin = SAFE_HOSTS.has(incomingUrl.hostname)
? incomingUrl.origin
: `http://127.0.0.1:${process.env.PORT || "20128"}`;
const modelsPath = `/api/providers/${encodeURIComponent(id)}/models?refresh=true`;
const modelsRes = await fetch(new URL(modelsPath, safeOrigin).href, {
method: "GET",
cache: "no-store",
headers: {
cookie: request.headers.get("cookie") || "",
...buildModelSyncInternalHeaders(),
},
});
const modelsRes = await fetchProviderModelsForSync(request, id);
const duration = Date.now() - start;
const modelsData = await modelsRes.json();
const { data: modelsData, parseError } = await readJsonResponse(modelsRes);
const payloadError = getErrorMessageFromPayload(modelsData);
if (!modelsRes.ok) {
if (!modelsRes.ok || parseError) {
const responseStatus = modelsRes.ok ? 502 : modelsRes.status;
const logError = payloadError || parseError || `HTTP ${modelsRes.status}`;
const responseError = payloadError || parseError || "Failed to fetch models";
// Log the failed attempt
await saveCallLog({
method: "GET",
@@ -187,22 +256,35 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
sourceFormat: "-",
connectionId: id,
duration,
error: modelsData.error || `HTTP ${modelsRes.status}`,
error: logError,
requestType: "model-sync",
...(parseError
? {
responseBody: {
upstreamStatus: modelsRes.status,
parseError,
},
}
: {}),
});
return NextResponse.json(
{ error: modelsData.error || "Failed to fetch models" },
{ status: modelsRes.status }
{
error: responseError,
...(parseError ? { upstreamStatus: modelsRes.status } : {}),
},
{ status: responseStatus }
);
}
const fetchedModels = modelsData.models || [];
const {
previousModels,
previousSyncedAvailableModels,
persistedModels,
importedModels,
discoveredModels,
syncedAvailableModels,
syncedAliases,
importedChanges,
} = await importManagedModels({
@@ -210,22 +292,30 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
connectionId: id,
fetchedModels,
mode,
previousSyncedAvailableModels: previousSyncedAvailableModelsForConnection,
});
const modelChanges = summarizeModelChanges(previousModels, persistedModels);
const effectiveAvailableModels =
discoveredModels.length > 0 ? discoveredModels : syncedAvailableModels;
const modelChanges = summarizeModelChanges(
previousSyncedAvailableModels,
effectiveAvailableModels
);
const customModelChanges = summarizeModelChanges(previousModels, persistedModels);
const syncedModelsCount =
discoveredModels.length > 0
? discoveredModels.length
effectiveAvailableModels.length > 0
? effectiveAvailableModels.length
: persistedModels.filter((model) => isManagedSyncedModel(model)).length;
const availableModelsCount = new Set(
[...persistedModels, ...discoveredModels]
[...persistedModels, ...effectiveAvailableModels]
.map((model) => toNonEmptyString(asRecord(model).id))
.filter((modelId): modelId is string => Boolean(modelId))
).size;
const importedCount = importedChanges.added;
const updatedCount = importedChanges.updated;
const shouldLog = modelChanges.total > 0 || customModelChanges.total > 0;
if (modelChanges.total > 0) {
if (shouldLog) {
await saveCallLog({
method: "GET",
path: `/api/providers/${id}/models`,
@@ -243,6 +333,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
provider: logProvider,
channel: channelLabel,
modelChanges,
customModelChanges,
importedCount,
updatedCount,
mode,
@@ -258,10 +349,11 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
availableModelsCount,
syncedAliases,
modelChanges,
customModelChanges,
importedCount,
updatedCount,
importedChanges,
logged: modelChanges.total > 0,
logged: shouldLog,
models: persistedModels,
importedModels,
});

View File

@@ -0,0 +1,74 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
import { getNgrokTunnelStatus, startNgrokTunnel, stopNgrokTunnel } from "@/lib/ngrokTunnel";
export const dynamic = "force-dynamic";
const actionSchema = z.object({
action: z.enum(["enable", "disable"]),
authToken: z.string().optional(),
});
function unauthorized() {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
export async function GET(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return unauthorized();
}
try {
const status = await getNgrokTunnelStatus();
return NextResponse.json(status);
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to load ngrok tunnel status",
},
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return unauthorized();
}
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const validation = validateBody(actionSchema, rawBody);
if (isValidationFailure(validation)) {
return validation.response;
}
const parsed = validation.data;
try {
const status =
parsed.action === "enable"
? await startNgrokTunnel(parsed.authToken)
: await stopNgrokTunnel();
return NextResponse.json({
success: true,
action: parsed.action,
status,
});
} catch (error) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Failed to update ngrok tunnel",
},
{ status: 500 }
);
}
}

View File

@@ -478,6 +478,17 @@ export async function getUnifiedModelsResponse(
const isProviderActive = (provider: string) => {
if (activeAliases.size === 0) return false; // No active connections = show nothing
const alias = providerIdToAlias[provider] || provider;
const canonicalProviderId = FALLBACK_ALIAS_TO_PROVIDER[alias] || provider;
// FIX #1752: Ensure blocked providers are not returned for non-chat models
if (
blockedProviders.has(alias) ||
blockedProviders.has(canonicalProviderId) ||
blockedProviders.has(provider)
) {
return false;
}
return activeAliases.has(alias) || activeAliases.has(provider);
};

View File

@@ -1,5 +1,9 @@
import { PROVIDER_MODELS } from "@/shared/constants/models";
import { getAllCustomModels, getSyncedAvailableModels } from "@/lib/db/models";
import {
getAllCustomModels,
getAllSyncedAvailableModels,
getSyncedAvailableModels,
} from "@/lib/db/models";
import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";
import { getSyncedCapabilities } from "@/lib/modelsDevSync";
@@ -67,6 +71,46 @@ export async function GET() {
console.error("[v1beta/models] Error fetching synced Gemini models:", err);
}
const existingNames = new Set(models.map((model) => (model as any).name));
// Synced/imported models for non-Gemini providers
try {
const syncedModelsMap = await getAllSyncedAvailableModels();
for (const [providerId, syncedModels] of Object.entries(syncedModelsMap)) {
if (providerId === "gemini") continue;
if (!Array.isArray(syncedModels)) continue;
for (const m of syncedModels) {
if (!m || typeof m.id !== "string") continue;
const name = `models/${providerId}/${m.id}`;
if (existingNames.has(name)) continue;
const resolved = getResolvedModelCapabilities({
provider: providerId,
model: m.id,
});
models.push({
name,
displayName: m.name || m.id,
...(typeof m.description === "string" ? { description: m.description } : {}),
supportedGenerationMethods: ["generateContent"],
inputTokenLimit:
typeof m.inputTokenLimit === "number"
? m.inputTokenLimit
: resolved.maxInputTokens || resolved.contextWindow || 128000,
outputTokenLimit:
typeof m.outputTokenLimit === "number"
? m.outputTokenLimit
: resolved.maxOutputTokens || 8192,
...(m.supportsThinking === true || resolved.supportsThinking === true
? { thinking: true }
: {}),
});
existingNames.add(name);
}
}
} catch {
// Synced models are optional — skip on error
}
// Custom models (use stored metadata from provider APIs)
try {
const customModelsMap = (await getAllCustomModels()) as Record<string, unknown>;
@@ -83,8 +127,10 @@ export async function GET() {
provider: providerId,
model: String(m.id),
});
const name = `models/${providerId}/${m.id}`;
if (existingNames.has(name)) continue;
models.push({
name: `models/${providerId}/${m.id}`,
name,
displayName: m.name || m.id,
...(typeof m.description === "string" ? { description: m.description } : {}),
supportedGenerationMethods: ["generateContent"],
@@ -100,6 +146,7 @@ export async function GET() {
? { thinking: true }
: {}),
});
existingNames.add(name);
}
}
} catch {

View File

@@ -1981,7 +1981,24 @@
"tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.",
"tailscaleSudoPlaceholder": "Optional sudo password",
"tailscaleInstalling": "Installing",
"tailscaleSudoLabel": "Sudo Password (required on macOS/Linux)"
"tailscaleSudoLabel": "Sudo Password (required on macOS/Linux)",
"ngrokTitle": "ngrok Tunnel",
"ngrokRunning": "Running",
"ngrokStarting": "Starting",
"ngrokStoppedState": "Stopped",
"ngrokNeedsAuth": "Needs Auth",
"ngrokNotInstalled": "Not installed",
"ngrokUnsupported": "Unsupported",
"ngrokError": "Error",
"ngrokEnable": "Enable Tunnel",
"ngrokDisable": "Stop Tunnel",
"ngrokUrlNotice": "Creates a public ngrok tunnel.",
"ngrokAuthTokenLabel": "Authtoken (Required if NGROK_AUTHTOKEN not set)",
"ngrokAuthTokenPlaceholder": "Enter your ngrok authtoken",
"ngrokLastError": "Last error: {error}",
"ngrokStarted": "ngrok tunnel started",
"ngrokStopped": "ngrok tunnel stopped",
"ngrokRequestFailed": "Failed to update ngrok tunnel"
},
"endpoints": {
"tabProxy": "Endpoint Proxy",

View File

@@ -1903,7 +1903,24 @@
"tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.",
"tailscaleSudoPlaceholder": "Optional sudo password",
"tailscaleInstalling": "Installing",
"tailscaleSudoLabel": "Senha Sudo (obrigatória em macOS/Linux)"
"tailscaleSudoLabel": "Senha Sudo (obrigatória em macOS/Linux)",
"ngrokTitle": "Túnel ngrok",
"ngrokRunning": "Rodando",
"ngrokStarting": "Iniciando",
"ngrokStoppedState": "Parado",
"ngrokNeedsAuth": "Requer Autenticação",
"ngrokNotInstalled": "Não instalado",
"ngrokUnsupported": "Não suportado",
"ngrokError": "Erro",
"ngrokEnable": "Habilitar Túnel",
"ngrokDisable": "Parar Túnel",
"ngrokUrlNotice": "Cria um túnel público ngrok.",
"ngrokAuthTokenLabel": "Authtoken (Necessário caso NGROK_AUTHTOKEN não esteja definido)",
"ngrokAuthTokenPlaceholder": "Digite seu authtoken do ngrok",
"ngrokLastError": "Último erro: {error}",
"ngrokStarted": "Túnel ngrok iniciado",
"ngrokStopped": "Túnel ngrok parado",
"ngrokRequestFailed": "Falha ao atualizar túnel ngrok"
},
"endpoints": {
"tabProxy": "Proxy de terminal",

View File

@@ -1875,7 +1875,24 @@
"tailscaleInstallIntro": "Installs Tailscale on this machine and prepares OmniRoute to enable Funnel.",
"tailscaleInstallPasswordHint": "On macOS and Linux, sudo may be required for the package install and daemon start.",
"tailscaleSudoPlaceholder": "Optional sudo password",
"tailscaleInstalling": "Installing"
"tailscaleInstalling": "Installing",
"ngrokTitle": "Túnel ngrok",
"ngrokRunning": "Rodando",
"ngrokStarting": "Iniciando",
"ngrokStoppedState": "Parado",
"ngrokNeedsAuth": "Requer Autenticação",
"ngrokNotInstalled": "Não instalado",
"ngrokUnsupported": "Não suportado",
"ngrokError": "Erro",
"ngrokEnable": "Habilitar Túnel",
"ngrokDisable": "Parar Túnel",
"ngrokUrlNotice": "Cria um túnel público ngrok.",
"ngrokAuthTokenLabel": "Authtoken (Necessário caso NGROK_AUTHTOKEN não esteja definido)",
"ngrokAuthTokenPlaceholder": "Digite seu authtoken do ngrok",
"ngrokLastError": "Último erro: {error}",
"ngrokStarted": "Túnel ngrok iniciado",
"ngrokStopped": "Túnel ngrok parado",
"ngrokRequestFailed": "Falha ao atualizar túnel ngrok"
},
"endpoints": {
"tabProxy": "Endpoint Proxy",

View File

@@ -1335,13 +1335,17 @@
}
}
},
"autoConfiguredTab": "Auto Configured Tab",
"toolCategoriesDesc": "Tool Categories Desc",
"allToolsTab": "All Tools Tab",
"guidedClientsTab": "Guided Clients Tab",
"mitmClientsTab": "Mitm Clients Tab",
"toolCategories": "Tool Categories",
"visibleToolsCount": "Visible Tools Count"
"autoConfiguredTab": "自动配置",
"toolCategoriesDesc": "配置 AI 编程助手通过 OmniRoute 路由",
"allToolsTab": "所有工具",
"guidedClientsTab": "引导客户端",
"mitmClientsTab": "MITM 客户端",
"customCliTab": "自定义 CLI",
"toolCategories": "工具分类",
"visibleToolsCount": "{count} 个工具可用",
"installationGuide": "安装指南",
"whenToUseLabel": "何时使用",
"openToolDocs": "打开工具文档"
},
"combos": {
"title": "组合",
@@ -4325,7 +4329,9 @@
"addMemory": "添加记忆",
"type": "类型",
"key": "键",
"keyPlaceholder": "例如user_preference_theme",
"content": "内容",
"contentPlaceholder": "例如:偏好深色模式",
"created": "创建时间",
"actions": "操作",
"delete": "删除",
@@ -4333,7 +4339,16 @@
"episodic": "情景型",
"procedural": "程序型",
"semantic": "语义型",
"a": "A"
"previous": "上一页",
"next": "下一页",
"pageInfo": "第 {page} 页,共 {totalPages} 页(共 {total} 条)",
"checkingHealth": "检查中...",
"checkHealth": "检查健康",
"pipelineOk": "Pipeline 正常 ({latencyMs}ms)",
"pipelineError": "Pipeline 错误",
"healthUnknown": "健康状态未知",
"cancel": "取消",
"save": "保存"
},
"skills": {
"title": "技能",

View File

@@ -21,7 +21,7 @@ import type { RegistryModel } from "@omniroute/open-sse/config/providerRegistry.
type JsonRecord = Record<string, unknown>;
type BuilderModelSource = "api-sync" | "system" | "custom" | "fallback";
type BuilderModelSource = "imported" | "system" | "custom" | "fallback";
type BuilderConnectionStatus = "active" | "inactive" | "rate-limited" | "error";
type ProviderVisual = { icon: string; color: string; source: "system" | "provider-node" };
@@ -159,7 +159,7 @@ function isChatCapable(supportedEndpoints: string[] | undefined): boolean {
function getSourcePriority(source: BuilderModelSource): number {
switch (source) {
case "api-sync":
case "imported":
return 0;
case "system":
return 1;
@@ -412,7 +412,7 @@ export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPaylo
addModelOption(modelMap, providerId, {
id: toStringOrNull(model.id),
name: toStringOrNull(model.name),
source: "api-sync",
source: "imported",
supportedEndpoints: toStringArray(model.supportedEndpoints),
contextLength: toNumberOrNull(model.inputTokenLimit) ?? resolved.contextWindow,
outputTokenLimit: toNumberOrNull(model.outputTokenLimit) ?? resolved.maxOutputTokens,
@@ -440,8 +440,11 @@ export async function getComboBuilderOptions(): Promise<ComboBuilderOptionsPaylo
for (const model of customModels) {
if (model.isHidden === true) continue;
const source =
toStringOrNull(model.source) === "api-sync" ? "api-sync" : ("custom" as BuilderModelSource);
const source = ["api-sync", "auto-sync", "imported"].includes(
toStringOrNull(model.source)?.toLowerCase() || ""
)
? "imported"
: ("custom" as BuilderModelSource);
const resolved = getResolvedModelCapabilities({
provider: providerId,
model: toStringOrNull(model.id),

View File

@@ -234,6 +234,10 @@ function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function getKeyValue(row: unknown): { key: string | null; value: string | null } {
const record = asRecord(row);
return {
@@ -378,7 +382,7 @@ export async function addCustomModel(
}
/**
* Replace the entire custom models list for a provider (used by auto-sync).
* Replace the entire custom models list for a provider.
* Preserves per-model compatibility overrides for models that still exist.
*/
export async function replaceCustomModels(
@@ -397,7 +401,7 @@ export async function replaceCustomModels(
{ allowEmpty = false }: { allowEmpty?: boolean } = {}
) {
// Guard: skip destructive clear when the caller hasn't explicitly opted in.
// This prevents auto-sync from wiping manually-imported models when the
// This prevents callers from wiping manually added models when the
// upstream /models endpoint fails, times out, or returns an empty list.
if (models.length === 0 && !allowEmpty) {
const existing = await getCustomModels(providerId);
@@ -520,7 +524,7 @@ export async function removeCustomModel(providerId: string, modelId: string) {
export interface SyncedAvailableModel {
id: string;
name: string;
source: "api-sync";
source: "imported";
supportedEndpoints?: string[];
inputTokenLimit?: number;
outputTokenLimit?: number;
@@ -528,6 +532,57 @@ export interface SyncedAvailableModel {
supportsThinking?: boolean;
}
type SyncedAvailableModelInput = Omit<SyncedAvailableModel, "source"> & {
source?: string;
};
function normalizeSyncedAvailableModel(model: unknown): SyncedAvailableModel | null {
const record = asRecord(model);
const id =
toNonEmptyString(record.id) || toNonEmptyString(record.name) || toNonEmptyString(record.model);
if (!id) return null;
const name =
toNonEmptyString(record.name) ||
toNonEmptyString(record.displayName) ||
toNonEmptyString(record.model) ||
id;
const supportedEndpoints = Array.isArray(record.supportedEndpoints)
? Array.from(
new Set(
record.supportedEndpoints
.map((endpoint) => toNonEmptyString(endpoint))
.filter((endpoint): endpoint is string => Boolean(endpoint))
)
).sort()
: undefined;
return {
id,
name,
source: "imported",
...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}),
...(typeof record.inputTokenLimit === "number"
? { inputTokenLimit: record.inputTokenLimit }
: {}),
...(typeof record.outputTokenLimit === "number"
? { outputTokenLimit: record.outputTokenLimit }
: {}),
...(typeof record.description === "string" ? { description: record.description } : {}),
...(record.supportsThinking === true ? { supportsThinking: true } : {}),
};
}
function normalizeSyncedAvailableModels(models: unknown): SyncedAvailableModel[] {
if (!Array.isArray(models)) return [];
const deduped = new Map<string, SyncedAvailableModel>();
for (const model of models) {
const normalized = normalizeSyncedAvailableModel(model);
if (normalized) deduped.set(normalized.id, normalized);
}
return Array.from(deduped.values());
}
/**
* Get synced available models for a specific provider connection.
*/
@@ -544,7 +599,7 @@ export async function getSyncedAvailableModelsForConnection(
if (!value) return [];
try {
const models = JSON.parse(value);
return Array.isArray(models) ? models : [];
return normalizeSyncedAvailableModels(models);
} catch {
return [];
}
@@ -566,7 +621,7 @@ export async function getSyncedAvailableModels(
for (const row of rows) {
const { key, value } = getKeyValue(row);
if (!key || value === null) continue;
const models: SyncedAvailableModel[] = JSON.parse(value);
const models = normalizeSyncedAvailableModels(JSON.parse(value));
for (const m of models) {
if (m.id) map.set(m.id, m);
}
@@ -591,7 +646,7 @@ export async function getAllSyncedAvailableModels(): Promise<
if (!key || value === null) continue;
const providerId = key.split(":")[0];
if (!byProvider.has(providerId)) byProvider.set(providerId, new Map());
const models: SyncedAvailableModel[] = JSON.parse(value);
const models = normalizeSyncedAvailableModels(JSON.parse(value));
const map = byProvider.get(providerId)!;
for (const m of models) {
if (m.id) map.set(m.id, m);
@@ -611,18 +666,19 @@ export async function getAllSyncedAvailableModels(): Promise<
export async function replaceSyncedAvailableModelsForConnection(
providerId: string,
connectionId: string,
models: SyncedAvailableModel[]
models: SyncedAvailableModelInput[]
): Promise<SyncedAvailableModel[]> {
const db = getDbInstance();
const key = `${providerId}:${connectionId}`;
if (models.length === 0) {
const normalizedModels = normalizeSyncedAvailableModels(models);
if (normalizedModels.length === 0) {
db.prepare("DELETE FROM key_value WHERE namespace = 'syncedAvailableModels' AND key = ?").run(
key
);
} else {
db.prepare(
"INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('syncedAvailableModels', ?, ?)"
).run(key, JSON.stringify(models));
).run(key, JSON.stringify(normalizedModels));
}
backupDbFile("pre-write");
// Return the full unioned list for the provider

View File

@@ -77,15 +77,78 @@ function rowToMemory(row: MemoryRow): Memory {
}
/**
* Create a new memory entry
* Find existing memory by apiKeyId and key (for UPSERT logic)
*/
function findExistingMemory(
db: ReturnType<typeof getDbInstance>,
apiKeyId: string,
key: string
): MemoryRow | undefined {
if (!key) return undefined;
const stmt = db.prepare(
"SELECT * FROM memories WHERE api_key_id = ? AND key = ? ORDER BY created_at DESC LIMIT 1"
);
return stmt.get(apiKeyId, key) as MemoryRow | undefined;
}
/**
* Create a new memory entry (UPSERT: updates existing if same apiKeyId + key)
*/
export async function createMemory(
memory: Omit<Memory, "id" | "createdAt" | "updatedAt">
): Promise<Memory> {
const db = getDbInstance();
const id = crypto.randomUUID();
const now = new Date().toISOString();
// Check for existing memory with same apiKeyId + key (UPSERT logic)
const existing = memory.key ? findExistingMemory(db, memory.apiKeyId, memory.key) : undefined;
if (existing) {
// UPDATE existing record
const updatedMetadata = { ...parseJSON(existing.metadata), ...memory.metadata };
const stmt = db.prepare(
"UPDATE memories SET content = ?, metadata = ?, updated_at = ?, session_id = ?, type = ?, expires_at = ? WHERE id = ?"
);
stmt.run(
memory.content,
JSON.stringify(updatedMetadata),
now,
memory.sessionId,
memory.type,
memory.expiresAt ?? null,
existing.id
);
const updatedMemory: Memory = {
id: String(existing.id),
apiKeyId: memory.apiKeyId,
sessionId: memory.sessionId,
type: memory.type,
key: memory.key,
content: memory.content,
metadata: updatedMetadata,
createdAt: new Date(String(existing.created_at)),
updatedAt: new Date(now),
expiresAt: memory.expiresAt ?? null,
};
// Invalidate and update cache
invalidateMemoryCache(existing.id);
evictIfNeeded(_memoryCache);
_memoryCache.set(existing.id, { value: updatedMemory, timestamp: Date.now() });
log.info("memory.updated", {
apiKeyId: memory.apiKeyId,
type: memory.type,
id: existing.id,
key: memory.key,
});
return updatedMemory;
}
// INSERT new record if not exists
const id = crypto.randomUUID();
const stmt = db.prepare(
"INSERT INTO memories (id, api_key_id, session_id, type, key, content, metadata, created_at, updated_at, expires_at) " +
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"

142
src/lib/ngrokTunnel.ts Normal file
View File

@@ -0,0 +1,142 @@
import { getRuntimePorts } from "@/lib/runtime/ports";
export type TunnelPhase =
| "unsupported"
| "not_installed"
| "stopped"
| "needs_auth"
| "starting"
| "running"
| "error";
export type NgrokTunnelStatus = {
supported: boolean;
installed: boolean;
running: boolean;
publicUrl: string | null;
apiUrl: string | null;
targetUrl: string;
phase: TunnelPhase;
lastError: string | null;
};
// Next.js hot-reloading safe global storage for the listener
const globalForNgrok = globalThis as unknown as {
__ngrokListener: any;
};
let startPromise: Promise<NgrokTunnelStatus> | null = null;
function getLocalTargetUrl() {
const { apiPort } = getRuntimePorts();
return `http://127.0.0.1:${apiPort}`;
}
function getTunnelApiUrl(publicUrl: string | null) {
return publicUrl ? `${publicUrl.replace(/\/$/, "")}/v1` : null;
}
export async function getNgrokTunnelStatus(): Promise<NgrokTunnelStatus> {
const targetUrl = getLocalTargetUrl();
const tokenAvailable = !!(
process.env.NGROK_AUTHTOKEN && process.env.NGROK_AUTHTOKEN.trim() !== ""
);
const listener = globalForNgrok.__ngrokListener;
let currentUrl = null;
if (listener) {
try {
currentUrl = typeof listener.url === "function" ? listener.url() : listener.url;
} catch {
// Ignored
}
}
return {
supported: true,
installed: true,
running: currentUrl !== null,
publicUrl: currentUrl || null,
apiUrl: currentUrl ? getTunnelApiUrl(currentUrl) : null,
targetUrl,
phase: currentUrl === null ? (tokenAvailable ? "stopped" : "needs_auth") : "running",
lastError: null,
};
}
export async function startNgrokTunnel(inputAuthToken?: string): Promise<NgrokTunnelStatus> {
const current = await getNgrokTunnelStatus();
if (current.running) return current;
if (startPromise) return startPromise;
startPromise = (async () => {
try {
const authToken =
inputAuthToken && inputAuthToken.trim() !== ""
? inputAuthToken.trim()
: process.env.NGROK_AUTHTOKEN;
if (!authToken) {
return {
...(await getNgrokTunnelStatus()),
phase: "needs_auth",
lastError: "An ngrok authtoken is required.",
};
}
// Dynamically import ngrok so it doesn't break environments where native build fails if not used
const ngrok = await import("@ngrok/ngrok");
const targetUrl = getLocalTargetUrl();
const listenerOptions: any = { addr: targetUrl };
if (!inputAuthToken && process.env.NGROK_AUTHTOKEN) {
listenerOptions.authtoken_from_env = true;
} else {
listenerOptions.authtoken = authToken;
}
const listener = await ngrok.forward(listenerOptions);
globalForNgrok.__ngrokListener = listener;
let url = null;
try {
url = typeof listener.url === "function" ? listener.url() : listener.url;
} catch {
// Ignored
}
if (!url) {
await stopNgrokTunnel();
throw new Error("ngrok did not return a public URL.");
}
return await getNgrokTunnelStatus();
} catch (error) {
return {
...(await getNgrokTunnelStatus()),
phase: "error",
lastError: error instanceof Error ? error.message : String(error),
};
} finally {
startPromise = null;
}
})();
return startPromise;
}
export async function stopNgrokTunnel(): Promise<NgrokTunnelStatus> {
const listener = globalForNgrok.__ngrokListener;
if (listener) {
try {
if (typeof listener.close === "function") {
await listener.close();
}
} catch (e) {
// Ignore close errors
}
globalForNgrok.__ngrokListener = undefined;
}
return await getNgrokTunnelStatus();
}

View File

@@ -81,12 +81,12 @@ export async function exchangeTokens(providerName, code, redirectUri, codeVerifi
/**
* Request device code (for device_code flow)
*/
export async function requestDeviceCode(providerName, codeChallenge) {
export async function requestDeviceCode(providerName, codeChallenge, configOverride = null) {
const provider = getProvider(providerName);
if (provider.flowType !== "device_code") {
throw new Error(`Provider ${providerName} does not support device code flow`);
}
return await provider.requestDeviceCode(provider.config, codeChallenge);
return await provider.requestDeviceCode(configOverride || provider.config, codeChallenge);
}
/**

View File

@@ -4,6 +4,27 @@ export const kiro = {
config: KIRO_CONFIG,
flowType: "device_code",
requestDeviceCode: async (config) => {
const regionMatch = String(config.tokenUrl || "").match(/oidc\.([a-z0-9-]+)\.amazonaws\.com/i);
const resolvedRegion = regionMatch?.[1] || "us-east-1";
const registerPayload: {
clientName: string;
clientType: string;
scopes: string[];
grantTypes: string[];
issuerUrl?: string;
} = {
clientName: config.clientName,
clientType: config.clientType,
scopes: config.scopes,
grantTypes: config.grantTypes,
};
// For enterprise IDC custom startUrl flows, issuerUrl can differ per tenant.
// Sending a fixed issuerUrl often causes invalid_request during device auth.
if (config.issuerUrl && !config.skipIssuerUrlForRegistration) {
registerPayload.issuerUrl = config.issuerUrl;
}
// Step 1: Register client with AWS SSO OIDC
const registerRes = await fetch(config.registerClientUrl, {
method: "POST",
@@ -11,13 +32,7 @@ export const kiro = {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
clientName: config.clientName,
clientType: config.clientType,
scopes: config.scopes,
grantTypes: config.grantTypes,
issuerUrl: config.issuerUrl,
}),
body: JSON.stringify(registerPayload),
});
if (!registerRes.ok) {
@@ -57,10 +72,14 @@ export const kiro = {
interval: deviceData.interval || 5,
_clientId: clientInfo.clientId,
_clientSecret: clientInfo.clientSecret,
_region: resolvedRegion,
};
},
pollToken: async (config, deviceCode, codeVerifier, extraData) => {
const response = await fetch(config.tokenUrl, {
const tokenRegion = extraData?._region || "us-east-1";
const tokenUrl = `https://oidc.${tokenRegion}.amazonaws.com/token`;
const response = await fetch(tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -91,6 +110,7 @@ export const kiro = {
expires_in: data.expiresIn,
_clientId: extraData?._clientId,
_clientSecret: extraData?._clientSecret,
_region: tokenRegion,
},
};
}
@@ -110,6 +130,7 @@ export const kiro = {
providerSpecificData: {
clientId: tokens._clientId,
clientSecret: tokens._clientSecret,
region: tokens._region,
},
}),
};

View File

@@ -1,7 +1,10 @@
import {
getCustomModels,
getSyncedAvailableModelsForConnection,
mergeModelCompatOverride,
replaceCustomModels,
replaceSyncedAvailableModelsForConnection,
type ModelCompatPatch,
type SyncedAvailableModel,
} from "@/lib/db/models";
import {
@@ -9,7 +12,6 @@ import {
usesManagedAvailableModels,
} from "@/lib/providerModels/managedAvailableModels";
import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery";
import { getModelsByProviderId } from "@/shared/constants/models";
type JsonRecord = Record<string, unknown>;
@@ -18,7 +20,7 @@ export type ManagedModelImportMode = "merge" | "sync";
export type ManagedImportedModel = {
id: string;
name: string;
source: "api-sync";
source: "imported";
apiFormat: "chat-completions";
supportedEndpoints?: string[];
inputTokenLimit?: number;
@@ -34,42 +36,39 @@ function toNonEmptyString(value: unknown): string | null {
function normalizeManagedSource(source: unknown): string {
const normalized = toNonEmptyString(source)?.toLowerCase();
if (normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported") {
return "api-sync";
return "imported";
}
return normalized || "manual";
}
function normalizeImportedModels(
providerId: string,
fetchedModels: unknown
): ManagedImportedModel[] {
function normalizeImportedModels(fetchedModels: unknown): ManagedImportedModel[] {
const discovered = normalizeDiscoveredModels(fetchedModels);
const registryIds = new Set(getModelsByProviderId(providerId).map((model: any) => model.id));
return discovered
.filter((model) => !registryIds.has(model.id))
.map((model) => ({
id: model.id,
name: model.name || model.id,
source: "api-sync",
apiFormat: "chat-completions",
...(Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0
? { supportedEndpoints: model.supportedEndpoints }
: {}),
...(typeof model.inputTokenLimit === "number"
? { inputTokenLimit: model.inputTokenLimit }
: {}),
...(typeof model.outputTokenLimit === "number"
? { outputTokenLimit: model.outputTokenLimit }
: {}),
...(typeof model.description === "string" ? { description: model.description } : {}),
...(model.supportsThinking === true ? { supportsThinking: true } : {}),
}));
return discovered.map((model) => ({
id: model.id,
name: model.name || model.id,
source: "imported",
apiFormat: "chat-completions",
...(Array.isArray(model.supportedEndpoints) && model.supportedEndpoints.length > 0
? { supportedEndpoints: model.supportedEndpoints }
: {}),
...(typeof model.inputTokenLimit === "number"
? { inputTokenLimit: model.inputTokenLimit }
: {}),
...(typeof model.outputTokenLimit === "number"
? { outputTokenLimit: model.outputTokenLimit }
: {}),
...(typeof model.description === "string" ? { description: model.description } : {}),
...(model.supportsThinking === true ? { supportsThinking: true } : {}),
}));
}
function isManagedDiscoveredSource(source: unknown): boolean {
const normalized = toNonEmptyString(source)?.toLowerCase();
return normalized === "api-sync" || normalized === "auto-sync" || normalized === "imported";
function isImportedSource(source: unknown): boolean {
return normalizeManagedSource(source) === "imported";
}
function getModelId(model: JsonRecord): string | null {
return toNonEmptyString(model.id);
}
function summarizeImportedChanges(
@@ -86,9 +85,30 @@ function summarizeImportedChanges(
const toComparable = (model: JsonRecord | undefined) => {
if (!model) return null;
const id = toNonEmptyString(model.id) || "";
const supportedEndpoints = Array.isArray(model.supportedEndpoints)
? Array.from(
new Set(
model.supportedEndpoints
.map((endpoint) => toNonEmptyString(endpoint))
.filter((endpoint): endpoint is string => Boolean(endpoint))
)
).sort()
: ["chat"];
return {
...model,
id,
name: toNonEmptyString(model.name) || id,
source: normalizeManagedSource(model.source),
apiFormat: toNonEmptyString(model.apiFormat) || "chat-completions",
supportedEndpoints,
...(typeof model.inputTokenLimit === "number"
? { inputTokenLimit: model.inputTokenLimit }
: {}),
...(typeof model.outputTokenLimit === "number"
? { outputTokenLimit: model.outputTokenLimit }
: {}),
...(typeof model.description === "string" ? { description: model.description } : {}),
...(model.supportsThinking === true ? { supportsThinking: true } : {}),
};
};
@@ -125,37 +145,71 @@ function collectAddedImportedModels(
return importedModels.filter((model) => !previousIds.has(model.id));
}
function getCompatPatchFromCustomModel(model: JsonRecord): ModelCompatPatch | null {
const patch: ModelCompatPatch = {};
if (typeof model.normalizeToolCallId === "boolean") {
patch.normalizeToolCallId = model.normalizeToolCallId;
}
if (typeof model.preserveOpenAIDeveloperRole === "boolean") {
patch.preserveOpenAIDeveloperRole = model.preserveOpenAIDeveloperRole;
}
if (typeof model.isHidden === "boolean") {
patch.isHidden = model.isHidden;
}
if (model.compatByProtocol && typeof model.compatByProtocol === "object") {
patch.compatByProtocol = model.compatByProtocol as ModelCompatPatch["compatByProtocol"];
}
if (model.upstreamHeaders && typeof model.upstreamHeaders === "object") {
patch.upstreamHeaders = model.upstreamHeaders as Record<string, string>;
}
return Object.keys(patch).length > 0 ? patch : null;
}
function preserveRemovedCustomModelCompat(providerId: string, removedModels: JsonRecord[]) {
for (const model of removedModels) {
const modelId = getModelId(model);
if (!modelId) continue;
const patch = getCompatPatchFromCustomModel(model);
if (!patch) continue;
mergeModelCompatOverride(providerId, modelId, patch);
}
}
export async function importManagedModels({
providerId,
connectionId,
fetchedModels,
mode,
previousSyncedAvailableModels: previousSyncedAvailableModelsInput,
}: {
providerId: string;
connectionId: string;
fetchedModels: unknown;
mode: ManagedModelImportMode;
previousSyncedAvailableModels?: SyncedAvailableModel[];
}) {
const previousModels = (await getCustomModels(providerId)) as JsonRecord[];
const candidateImportedModels = normalizeImportedModels(providerId, fetchedModels);
const previousSyncedAvailableModels =
previousSyncedAvailableModelsInput ??
(await getSyncedAvailableModelsForConnection(providerId, connectionId));
const discoveredModels = normalizeDiscoveredModels(fetchedModels);
const candidateImportedModels = normalizeImportedModels(fetchedModels);
const importedIds = new Set(candidateImportedModels.map((model) => model.id));
const discoveredIds = new Set(discoveredModels.map((model) => model.id));
const nextModelsMap = new Map<string, JsonRecord>();
const removedCustomModels: JsonRecord[] = [];
if (mode === "merge") {
for (const model of previousModels) {
if (model?.id) nextModelsMap.set(String(model.id), model);
for (const model of previousModels) {
const modelId = getModelId(model);
if (!modelId) continue;
if (isImportedSource(model.source) || discoveredIds.has(modelId)) {
removedCustomModels.push(model);
continue;
}
} else {
for (const model of previousModels) {
if (!model?.id) continue;
if (isManagedDiscoveredSource(model.source)) continue;
nextModelsMap.set(String(model.id), model);
}
}
for (const model of candidateImportedModels) {
nextModelsMap.set(model.id, model);
nextModelsMap.set(modelId, model);
}
const persistedModels = (await replaceCustomModels(
@@ -170,11 +224,12 @@ export async function importManagedModels({
outputTokenLimit?: number;
description?: string;
supportsThinking?: boolean;
}>
}>,
{ allowEmpty: true }
)) as JsonRecord[];
preserveRemovedCustomModelCompat(providerId, removedCustomModels);
const discoveredModels = normalizeDiscoveredModels(fetchedModels);
let syncedAvailableModels: SyncedAvailableModel[] = [];
let syncedAvailableModels: SyncedAvailableModel[] = previousSyncedAvailableModels;
if (discoveredModels.length > 0) {
syncedAvailableModels = await replaceSyncedAvailableModelsForConnection(
providerId,
@@ -184,24 +239,28 @@ export async function importManagedModels({
}
let syncedAliases = 0;
if (usesManagedAvailableModels(providerId)) {
if (usesManagedAvailableModels(providerId) && (mode === "merge" || discoveredModels.length > 0)) {
const aliasSync = await syncManagedAvailableModelAliases(
providerId,
mode === "sync"
? persistedModels
.map((model) => toNonEmptyString(model.id))
.filter((modelId): modelId is string => Boolean(modelId))
: candidateImportedModels.map((model) => model.id),
discoveredModels.map((model) => model.id),
{ pruneMissing: mode === "sync" }
);
syncedAliases = aliasSync.assignedAliases.length;
}
const importedChanges = summarizeImportedChanges(previousModels, persistedModels, importedIds);
const importedModels = collectAddedImportedModels(previousModels, candidateImportedModels);
const importedChanges = summarizeImportedChanges(
previousSyncedAvailableModels as JsonRecord[],
discoveredModels as JsonRecord[],
importedIds
);
const importedModels = collectAddedImportedModels(
previousSyncedAvailableModels as JsonRecord[],
candidateImportedModels
);
return {
previousModels,
previousSyncedAvailableModels,
persistedModels,
importedModels,
discoveredModels,

View File

@@ -48,7 +48,7 @@ export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel
deduped.set(id, {
id,
name,
source: "api-sync",
source: "imported",
...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}),
...(typeof record.inputTokenLimit === "number"
? { inputTokenLimit: record.inputTokenLimit }

View File

@@ -138,17 +138,19 @@ export default function KiroAuthModal({
</div>
</button>
{/* AWS IAM Identity Center (IDC) - HIDDEN */}
{/* AWS IAM Identity Center (IDC) */}
<button
onClick={() => handleMethodSelect("idc")}
className="hidden w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors"
className="w-full p-4 text-left border border-border rounded-lg hover:bg-sidebar transition-colors"
>
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-primary mt-0.5">business</span>
<div className="flex-1">
<h3 className="font-semibold mb-1">AWS IAM Identity Center</h3>
<h3 className="font-semibold mb-1">
Your Organization (AWS IAM Identity Center)
</h3>
<p className="text-sm text-text-muted">
For enterprise users with custom AWS IAM Identity Center.
Use your company SSO start URL (example: https://your-org.awsapps.com/start).
</p>
</div>
</div>

View File

@@ -9,6 +9,7 @@ import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableMod
import {
getModelCatalogSourceLabel,
matchesModelCatalogQuery,
normalizeModelCatalogSource,
} from "@/shared/utils/modelCatalogSearch";
import {
OAUTH_PROVIDERS,
@@ -144,7 +145,7 @@ export default function ModelSelectModal({
name: cm.name || cm.id,
value: `${alias}/${cm.id}`,
isCustom: true,
source: cm.source === "api-sync" ? "api-sync" : "custom",
source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom",
}));
const allModels = [...aliasModels, ...customEntries];
@@ -198,7 +199,7 @@ export default function ModelSelectModal({
name: cm.name || cm.id,
value: `${nodePrefix}/${cm.id}`,
isCustom: true,
source: cm.source === "api-sync" ? "api-sync" : "custom",
source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom",
}));
const allModels = [...nodeModels, ...fallbackEntries, ...customEntries];
@@ -231,7 +232,7 @@ export default function ModelSelectModal({
name: cm.name || cm.id,
value: `${alias}/${cm.id}`,
isCustom: true,
source: cm.source === "api-sync" ? "api-sync" : "custom",
source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom",
}));
const allModels = [...systemEntries, ...customEntries];

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import PropTypes from "prop-types";
import { useTranslations } from "next-intl";
import Modal from "./Modal";
@@ -47,33 +47,36 @@ export default function OAuthModal({
const deviceVerificationUrl =
deviceData?.verification_uri_complete || deviceData?.verification_uri || "";
// State for client-only values to avoid hydration mismatch
const [isLocalhost, setIsLocalhost] = useState(false);
const [placeholderUrl, setPlaceholderUrl] = useState("/callback?code=...");
// Client-only runtime values
const runtimeLocation = useMemo(() => {
if (typeof window === "undefined") {
return {
isLocalhost: false,
isTrueLocalhost: false,
placeholderUrl: "/callback?code=...",
};
}
const hostname = window.location.hostname;
const isLocal =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname.startsWith("192.168.") ||
hostname.startsWith("10.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname);
const isTrulyLocal = hostname === "localhost" || hostname === "127.0.0.1";
return {
isLocalhost: isLocal,
isTrueLocalhost: isTrulyLocal,
placeholderUrl: `${window.location.origin}/callback?code=...`,
};
}, []);
const { isLocalhost, isTrueLocalhost, placeholderUrl } = runtimeLocation;
const callbackProcessedRef = useRef(false);
const flowStartedRef = useRef(false);
// Detect if running on true localhost vs LAN IP (client-side only)
// - True localhost (127.0.0.1/localhost): popup auto-callback works
// - LAN IPs (192.168.x, 10.x, 172.x): redirect URI uses localhost but callback
// won't resolve back to the VPS, so use manual paste mode
const [isTrueLocalhost, setIsTrueLocalhost] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
const hostname = window.location.hostname;
const isLocal =
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname.startsWith("192.168.") ||
hostname.startsWith("10.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname);
const isTrulyLocal = hostname === "localhost" || hostname === "127.0.0.1";
setIsLocalhost(isLocal);
setIsTrueLocalhost(isTrulyLocal);
setPlaceholderUrl(`${window.location.origin}/callback?code=...`);
}
}, []);
// Define all useCallback hooks BEFORE the useEffects that reference them
// Exchange tokens
@@ -217,7 +220,22 @@ export default function OAuthModal({
setIsDeviceCode(true);
setStep("waiting");
const res = await fetch(`/api/oauth/${provider}/device-code`);
const deviceCodeUrl = new URL(`/api/oauth/${provider}/device-code`, window.location.origin);
if (
(provider === "kiro" || provider === "amazon-q") &&
idcConfig &&
typeof idcConfig === "object"
) {
const idc = idcConfig as { startUrl?: string; region?: string };
if (typeof idc.startUrl === "string" && idc.startUrl.trim()) {
deviceCodeUrl.searchParams.set("startUrl", idc.startUrl.trim());
}
if (typeof idc.region === "string" && idc.region.trim()) {
deviceCodeUrl.searchParams.set("region", idc.region.trim());
}
}
const res = await fetch(deviceCodeUrl.toString());
const data = await res.json();
if (!res.ok) {
const errMsg =
@@ -237,7 +255,11 @@ export default function OAuthModal({
// Start polling - pass extraData for Kiro (contains _clientId, _clientSecret)
const extraData =
provider === "kiro" || provider === "amazon-q"
? { _clientId: data._clientId, _clientSecret: data._clientSecret }
? {
_clientId: data._clientId,
_clientSecret: data._clientSecret,
_region: data._region,
}
: null;
startPolling(data.device_code, data.codeVerifier, data.interval || 5, extraData);
return;
@@ -379,7 +401,15 @@ export default function OAuthModal({
setError(err.message);
setStep("error");
}
}, [provider, isLocalhost, isTrueLocalhost, startPolling, onSuccess, reauthConnection]);
}, [
provider,
isLocalhost,
isTrueLocalhost,
startPolling,
onSuccess,
reauthConnection,
idcConfig,
]);
// Reset guard when modal closes
useEffect(() => {

View File

@@ -1033,7 +1033,7 @@ export const APIKEY_PROVIDERS = {
icon: "devices",
color: "#EA580C",
textIcon: "MM",
website: "https://www.mi.com",
website: "https://mimo.mi.com",
},
"inference-net": {
id: "inference-net",
@@ -1542,7 +1542,8 @@ export const SEARCH_PROVIDERS = {
color: "#1A237E",
textIcon: "SX",
website: "https://docs.searxng.org",
authHint: "API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access.",
authHint:
"API key is optional. Set your SearXNG base URL. Some instances may require a bearer token for access.",
},
};

View File

@@ -717,6 +717,19 @@ const locateCommandCandidate = async (
reason: null,
};
}
if (result.installed && result.reason === "not_executable") {
return {
command: commands[0],
installed: true,
commandPath: result.commandPath,
reason: "not_executable",
};
}
if (result.reason && result.reason !== "not_found") {
return { command: commands[0], ...result };
}
}
}

View File

@@ -1,4 +1,4 @@
export type ModelCatalogSource = "system" | "custom" | "api-sync" | "fallback" | "alias";
export type ModelCatalogSource = "system" | "custom" | "imported" | "fallback" | "alias";
type ModelCatalogTarget = {
modelId?: string | null;
@@ -20,7 +20,7 @@ export function normalizeModelCatalogSource(source?: string | null): ModelCatalo
normalized === "auto-sync" ||
normalized === "imported"
) {
return "api-sync";
return "imported";
}
if (normalized === "fallback") return "fallback";
if (normalized === "alias") return "alias";
@@ -33,8 +33,8 @@ export function normalizeModelCatalogSource(source?: string | null): ModelCatalo
export function getModelCatalogSourceLabel(source?: string | null): string {
switch (normalizeModelCatalogSource(source)) {
case "api-sync":
return "Synced";
case "imported":
return "Imported";
case "custom":
return "Custom";
case "fallback":
@@ -49,7 +49,7 @@ export function getModelCatalogSourceLabel(source?: string | null): string {
function getModelCatalogSourceSearchText(source?: string | null): string {
switch (normalizeModelCatalogSource(source)) {
case "api-sync":
case "imported":
return "synced api imported discovered";
case "custom":
return "custom manual imported";

View File

@@ -167,7 +167,7 @@ async function mockHealthPageApis(page: Page) {
});
});
await page.route("**/api/v1/db/health", async (route) => {
await page.route("**/api/db/health", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",

View File

@@ -46,6 +46,10 @@ test("cloakAntigravityToolPayload cloaks custom tools, preserves native tools an
assert.ok(names.includes(`workspace_read${AG_TOOL_SUFFIX}`));
assert.ok(names.includes("run_command"));
assert.ok(names.includes("browser_subagent"));
assert.ok(names.includes("mcp_sequential_thinking_sequentialthinking"));
for (const name of names) {
assert.match(name, /^[a-zA-Z0-9_]+$/);
}
assert.equal(
result.body.request.contents[0].parts[0].functionCall.name,
`workspace_read${AG_TOOL_SUFFIX}`

View File

@@ -153,6 +153,7 @@ const cases: Case[] = [
expectedClass: "MANAGEMENT",
},
{ name: "/api/keys MANAGEMENT", path: "/api/keys", expectedClass: "MANAGEMENT" },
{ name: "/api/db/health MANAGEMENT", path: "/api/db/health", expectedClass: "MANAGEMENT" },
{ name: "/api/settings MANAGEMENT", path: "/api/settings", expectedClass: "MANAGEMENT" },
{ name: "/api/audit MANAGEMENT", path: "/api/audit", expectedClass: "MANAGEMENT" },

View File

@@ -174,6 +174,20 @@ test("runAuthzPipeline allows dashboard sessions to read model catalog aliases",
assert.equal(response.headers.get("x-omniroute-route-class"), "CLIENT_API");
});
test("runAuthzPipeline allows dashboard sessions to reach DB health management API", async () => {
await forceAuthRequired();
const response = await pipeline.runAuthzPipeline(
request("http://localhost/api/db/health", {
headers: { cookie: await dashboardCookie() },
}),
{ enforce: true }
);
assert.equal(response.status, 200);
assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT");
});
test("runAuthzPipeline refreshes dashboard JWTs near expiry", async () => {
await forceAuthRequired();
const secret = new TextEncoder().encode(process.env.JWT_SECRET);

View File

@@ -226,7 +226,7 @@ test("getCliRuntimeStatus ignores suspicious known-path binaries and symlink esc
const suspiciousStatus = await cliRuntime.getCliRuntimeStatus("qoder");
assert.equal(suspiciousStatus.installed, false);
assert.equal(suspiciousStatus.reason, "not_found");
assert.equal(suspiciousStatus.reason, "suspicious_size");
if (process.platform !== "win32") {
const escapePrefix = createTempDir("omniroute-cli-escape-");
@@ -246,7 +246,7 @@ test("getCliRuntimeStatus ignores suspicious known-path binaries and symlink esc
const escapedStatus = await escapedRuntime.getCliRuntimeStatus("qoder");
assert.equal(escapedStatus.installed, false);
assert.equal(escapedStatus.reason, "not_found");
assert.equal(escapedStatus.reason, "symlink_escape");
}
});

View File

@@ -3,29 +3,44 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { SignJWT } from "jose";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-health-route-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "task-303-api-key-secret";
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const routeModule = await import("../../src/app/api/v1/db/health/route.ts");
const routeModule = await import("../../src/app/api/db/health/route.ts");
const ORIGINAL_JWT_SECRET = process.env.JWT_SECRET;
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
const TEST_JWT_SECRET = "db-health-route-jwt-secret";
const TEST_INITIAL_PASSWORD = "db-health-route-password";
async function resetStorage() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
process.env.JWT_SECRET = TEST_JWT_SECRET;
process.env.INITIAL_PASSWORD = TEST_INITIAL_PASSWORD;
}
function makeRequest(method, token) {
return new Request("http://localhost/api/v1/db/health", {
function makeRequest(method, cookie) {
return new Request("http://localhost/api/db/health", {
method,
headers: token ? { Authorization: `Bearer ${token}` } : {},
headers: cookie ? { cookie } : {},
});
}
async function dashboardCookie() {
const secret = new TextEncoder().encode(TEST_JWT_SECRET);
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime("1h")
.sign(secret);
return `auth_token=${token}`;
}
function insertBrokenRows(db) {
db.prepare(
`INSERT INTO quota_snapshots
@@ -43,35 +58,27 @@ test.beforeEach(async () => {
test.after(async () => {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_JWT_SECRET === undefined) delete process.env.JWT_SECRET;
else process.env.JWT_SECRET = ORIGINAL_JWT_SECRET;
if (ORIGINAL_INITIAL_PASSWORD === undefined) delete process.env.INITIAL_PASSWORD;
else process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
});
test("GET /api/v1/db/health requires authentication", async () => {
const previousInitialPassword = process.env.INITIAL_PASSWORD;
process.env.INITIAL_PASSWORD = "route-health-auth";
test("GET /api/db/health requires authentication", async () => {
const response = await routeModule.GET(makeRequest("GET"));
const body = (await response.json()) as any;
try {
const response = await routeModule.GET(makeRequest("GET"));
const body = (await response.json()) as any;
assert.equal(response.status, 401);
assert.equal(body.error.message, "Authentication required");
} finally {
if (previousInitialPassword === undefined) {
delete process.env.INITIAL_PASSWORD;
} else {
process.env.INITIAL_PASSWORD = previousInitialPassword;
}
}
assert.equal(response.status, 401);
assert.equal(body.error.message, "Authentication required");
});
test("GET /api/v1/db/health diagnoses without mutating database rows", async () => {
const authKey = await apiKeysDb.createApiKey("Health Route", "machine-route-health");
test("GET /api/db/health diagnoses without mutating database rows", async () => {
const cookie = await dashboardCookie();
const db = core.getDbInstance();
insertBrokenRows(db);
const response = await routeModule.GET(makeRequest("GET", authKey.key));
const response = await routeModule.GET(makeRequest("GET", cookie));
const body = (await response.json()) as any;
assert.equal(response.status, 200);
@@ -81,12 +88,12 @@ test("GET /api/v1/db/health diagnoses without mutating database rows", async ()
assert.equal((db.prepare("SELECT COUNT(*) AS count FROM domain_budgets").get() as any).count, 1);
});
test("POST /api/v1/db/health repairs broken rows for authenticated callers", async () => {
const authKey = await apiKeysDb.createApiKey("Health Route", "machine-route-health");
test("POST /api/db/health repairs broken rows for authenticated callers", async () => {
const cookie = await dashboardCookie();
const db = core.getDbInstance();
insertBrokenRows(db);
const response = await routeModule.POST(makeRequest("POST", authKey.key));
const response = await routeModule.POST(makeRequest("POST", cookie));
const body = (await response.json()) as any;
assert.equal(response.status, 200);

View File

@@ -115,7 +115,7 @@ test("replaceCustomModels preserves compat fields and respects the empty-list gu
{
id: "gpt-4.1",
name: "GPT-4.1 Refreshed",
source: "api-sync",
source: "imported",
supportsThinking: true,
},
]);
@@ -147,12 +147,12 @@ test("removing a custom model also removes its compat override", async () => {
test("synced available models are unioned across connections and cleaned per connection", async () => {
await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-a", [
{ id: "gpt-4.1", name: "GPT-4.1", source: "api-sync" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "api-sync" },
{ id: "gpt-4.1", name: "GPT-4.1", source: "imported" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "imported" },
]);
const union = await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-b", [
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "api-sync" },
{ id: "o3-mini", name: "o3-mini", source: "api-sync" },
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "imported" },
{ id: "o3-mini", name: "o3-mini", source: "imported" },
]);
const remaining = await modelsDb.deleteSyncedAvailableModelsForConnection("openai", "conn-a");
const allProviders = await modelsDb.getAllSyncedAvailableModels();

View File

@@ -12,6 +12,10 @@ import {
isCodexResponsesWebSocketRequired,
parseCodexQuotaHeaders,
} from "../../open-sse/executors/codex.ts";
import {
clearRememberedResponseFunctionCallsForTesting,
rememberResponseFunctionCalls,
} from "../../open-sse/services/responsesToolCallState.ts";
import {
DEFAULT_THINKING_CONFIG,
setThinkingBudgetConfig,
@@ -22,6 +26,7 @@ import { CODEX_CHAT_DEFAULT_INSTRUCTIONS } from "../../open-sse/config/codexInst
test.afterEach(() => {
setThinkingBudgetConfig(DEFAULT_THINKING_CONFIG);
__setCodexWebSocketTransportForTesting(undefined);
clearRememberedResponseFunctionCallsForTesting();
});
async function withEnv(entries: Record<string, string | undefined>, fn: () => any) {
@@ -296,6 +301,48 @@ test("CodexExecutor.transformRequest preserves store-enabled responses state whe
assert.equal(result.previous_response_id, "resp_prev_123");
});
test("CodexExecutor.transformRequest rehydrates missing function_call items for stateful tool outputs", () => {
const executor = new CodexExecutor();
rememberResponseFunctionCalls("resp_prev_tool_123", [
{
type: "function_call",
call_id: "call_tool_123",
name: "workspace_read_file",
arguments: '{"path":"README.md"}',
},
]);
const body = {
_nativeCodexPassthrough: true,
previous_response_id: "resp_prev_tool_123",
input: [
{
type: "function_call_output",
call_id: "call_tool_123",
output: '{"ok":true}',
},
],
stream: false,
};
const result = executor.transformRequest("gpt-5.5-low", body, false, {
requestEndpointPath: "/responses",
});
assert.equal(result.previous_response_id, undefined);
assert.equal(result.store, false);
assert.deepEqual(result.input[0], {
type: "function_call",
call_id: "call_tool_123",
name: "workspace_read_file",
arguments: '{"path":"README.md"}',
});
assert.deepEqual(result.input[1], {
type: "function_call_output",
call_id: "call_tool_123",
output: '{"ok":true}',
});
});
test("CodexExecutor.transformRequest applies per-connection reasoning and service tier defaults", () => {
const executor = new CodexExecutor();
const result = executor.transformRequest(

View File

@@ -67,10 +67,10 @@ test("GET /api/memory filters by q and returns matching stats", async () => {
assert.equal(response.status, 200);
const body = (await response.json()) as any;
assert.deepEqual(
body.data.map((memory) => memory.key),
["typescript:tooling", "typescript:guide"]
);
assert.deepEqual(body.data.map((memory) => memory.key).sort(), [
"typescript:guide",
"typescript:tooling",
]);
assert.equal(body.total, 2);
assert.equal(body.stats.total, 2);
assert.deepEqual(body.stats.byType, { factual: 1, semantic: 1 });

View File

@@ -177,18 +177,21 @@ test("listMemories filters by api key, type and session while preserving newest-
test("listMemories supports limit and offset pagination even when only offset is provided", async () => {
insertMemoryRow({
id: "page-1",
key: "pagination:1",
content: "oldest",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
});
insertMemoryRow({
id: "page-2",
key: "pagination:2",
content: "middle",
createdAt: "2026-04-02T00:00:00.000Z",
updatedAt: "2026-04-02T00:00:00.000Z",
});
insertMemoryRow({
id: "page-3",
key: "pagination:3",
content: "newest",
createdAt: "2026-04-03T00:00:00.000Z",
updatedAt: "2026-04-03T00:00:00.000Z",
@@ -251,18 +254,21 @@ test("listMemories applies query filtering before pagination and type stats", as
test("listMemories supports page-based pagination (page 1)", async () => {
insertMemoryRow({
id: "pg-1",
key: "page:test:1",
content: "first",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
});
insertMemoryRow({
id: "pg-2",
key: "page:test:2",
content: "second",
createdAt: "2026-04-02T00:00:00.000Z",
updatedAt: "2026-04-02T00:00:00.000Z",
});
insertMemoryRow({
id: "pg-3",
key: "page:test:3",
content: "third",
createdAt: "2026-04-03T00:00:00.000Z",
updatedAt: "2026-04-03T00:00:00.000Z",
@@ -279,18 +285,21 @@ test("listMemories supports page-based pagination (page 1)", async () => {
test("listMemories supports page-based pagination (page 2 returns remainder)", async () => {
insertMemoryRow({
id: "pg-1",
key: "page:test:1",
content: "first",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
});
insertMemoryRow({
id: "pg-2",
key: "page:test:2",
content: "second",
createdAt: "2026-04-02T00:00:00.000Z",
updatedAt: "2026-04-02T00:00:00.000Z",
});
insertMemoryRow({
id: "pg-3",
key: "page:test:3",
content: "third",
createdAt: "2026-04-03T00:00:00.000Z",
updatedAt: "2026-04-03T00:00:00.000Z",
@@ -307,6 +316,7 @@ test("listMemories supports page-based pagination (page 2 returns remainder)", a
test("listMemories returns empty data for a page beyond the result set", async () => {
insertMemoryRow({
id: "pg-1",
key: "page:test:1",
content: "only entry",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
@@ -320,12 +330,14 @@ test("listMemories returns empty data for a page beyond the result set", async (
test("listMemories page parameter defaults to page 1 when omitted with limit", async () => {
insertMemoryRow({
id: "pg-1",
key: "page:test:1",
content: "first",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
});
insertMemoryRow({
id: "pg-2",
key: "page:test:2",
content: "second",
createdAt: "2026-04-02T00:00:00.000Z",
updatedAt: "2026-04-02T00:00:00.000Z",

View File

@@ -9,8 +9,8 @@ import {
test("model catalog source normalization groups manual and synced rows separately", () => {
assert.equal(normalizeModelCatalogSource("manual"), "custom");
assert.equal(normalizeModelCatalogSource("imported"), "api-sync");
assert.equal(normalizeModelCatalogSource("api-sync"), "api-sync");
assert.equal(normalizeModelCatalogSource("imported"), "imported");
assert.equal(normalizeModelCatalogSource("api-sync"), "imported");
assert.equal(normalizeModelCatalogSource("fallback"), "fallback");
assert.equal(normalizeModelCatalogSource("alias"), "alias");
assert.equal(normalizeModelCatalogSource(undefined), "system");
@@ -19,7 +19,7 @@ test("model catalog source normalization groups manual and synced rows separatel
test("model catalog source labels stay user-facing", () => {
assert.equal(getModelCatalogSourceLabel("system"), "Built-in");
assert.equal(getModelCatalogSourceLabel("custom"), "Custom");
assert.equal(getModelCatalogSourceLabel("api-sync"), "Synced");
assert.equal(getModelCatalogSourceLabel("imported"), "Imported");
assert.equal(getModelCatalogSourceLabel("fallback"), "Fallback");
assert.equal(getModelCatalogSourceLabel("alias"), "Alias");
});
@@ -29,7 +29,7 @@ test("model catalog query matches id, display name, alias and source label", ()
modelId: "qwen/qwen3-coder-480b-a35b-instruct",
modelName: "Qwen3 Coder 480B",
alias: "best-qwen",
source: "api-sync",
source: "imported",
};
assert.equal(matchesModelCatalogQuery("", target), true);

View File

@@ -5,9 +5,9 @@ const { getModelCatalogSourceLabel, normalizeModelCatalogSource } =
await import("../../src/shared/utils/modelCatalogSearch.ts");
test("model catalog source normalizes synced import variants consistently", () => {
assert.equal(normalizeModelCatalogSource("api-sync"), "api-sync");
assert.equal(normalizeModelCatalogSource("auto-sync"), "api-sync");
assert.equal(normalizeModelCatalogSource("imported"), "api-sync");
assert.equal(getModelCatalogSourceLabel("auto-sync"), "Synced");
assert.equal(getModelCatalogSourceLabel("imported"), "Synced");
assert.equal(normalizeModelCatalogSource("api-sync"), "imported");
assert.equal(normalizeModelCatalogSource("imported"), "imported");
assert.equal(normalizeModelCatalogSource("auto-sync"), "imported");
assert.equal(getModelCatalogSourceLabel("auto-sync"), "Imported");
assert.equal(getModelCatalogSourceLabel("imported"), "Imported");
});

View File

@@ -53,11 +53,11 @@ test("model sync route skips success log when fetched models do not change store
apiKey: "test-key",
});
await modelsDb.replaceCustomModels("openrouter", [
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [
{
id: "custom-model-1",
name: "Custom Model 1",
source: "auto-sync",
source: "imported",
},
]);
@@ -85,6 +85,7 @@ test("model sync route skips success log when fetched models do not change store
const body = (await response.json()) as any;
assert.equal(body.logged, false);
assert.deepEqual(body.modelChanges, { added: 0, removed: 0, updated: 0, total: 0 });
assert.deepEqual(body.models, []);
const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 });
assert.equal(logs.length, 0);
@@ -250,6 +251,45 @@ test("model sync route falls back to the upstream HTTP status when the models pa
assert.equal(logs[0].error, "HTTP 429");
});
test("model sync route reports invalid JSON /models responses without losing upstream status", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "openrouter",
authType: "apikey",
name: "Invalid JSON Sync",
apiKey: "test-key",
});
globalThis.fetch = async (url) => {
assert.equal(
String(url),
`http://localhost/api/providers/${connection.id}/models?refresh=true`
);
return new Response("<html>bad gateway</html>", {
status: 200,
headers: { "content-type": "text/html" },
});
};
const response = await modelSyncRoute.POST(
new Request(`http://localhost/api/providers/${connection.id}/sync-models`, {
method: "POST",
headers: scheduler.buildModelSyncInternalHeaders(),
}),
{ params: { id: connection.id } }
);
const body = (await response.json()) as any;
const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 });
assert.equal(response.status, 502);
assert.equal(body.error, "Invalid JSON response from /models");
assert.equal(body.upstreamStatus, 200);
assert.equal(logs.length, 1);
assert.equal(logs[0].status, 200);
assert.equal(logs[0].error, "Invalid JSON response from /models");
});
test("model sync route preserves previously synced models when the upstream omits the models list", async () => {
await resetStorage();
@@ -260,11 +300,11 @@ test("model sync route preserves previously synced models when the upstream omit
apiKey: "test-key",
});
await modelsDb.replaceCustomModels("openrouter", [
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [
{
id: "persisted-model",
name: "Persisted Model",
source: "auto-sync",
source: "imported",
},
]);
@@ -290,13 +330,12 @@ test("model sync route preserves previously synced models when the upstream omit
assert.equal(body.syncedModels, 1);
assert.equal(body.logged, false);
assert.deepEqual(body.modelChanges, { added: 0, removed: 0, updated: 0, total: 0 });
assert.deepEqual(body.models, [
assert.deepEqual(body.models, []);
assert.deepEqual(await modelsDb.getSyncedAvailableModels("openrouter"), [
{
id: "persisted-model",
name: "Persisted Model",
source: "auto-sync",
apiFormat: "chat-completions",
supportedEndpoints: ["chat"],
source: "imported",
},
]);
assert.equal(logs.length, 0);
@@ -348,24 +387,12 @@ test("model sync route writes synced available models for Gemini connections", a
assert.equal(body.syncedModels, 1);
assert.equal(body.logged, true);
assert.deepEqual(body.modelChanges, { added: 1, removed: 0, updated: 0, total: 1 });
assert.deepEqual(body.models, [
{
id: "gemini-custom-preview",
name: "Gemini Custom Preview",
source: "api-sync",
apiFormat: "chat-completions",
supportedEndpoints: ["chat", "embeddings"],
inputTokenLimit: 32768,
outputTokenLimit: 8192,
description: "Custom Gemini preview model",
supportsThinking: true,
},
]);
assert.deepEqual(body.models, []);
assert.deepEqual(synced, [
{
id: "gemini-custom-preview",
name: "Gemini Custom Preview",
source: "api-sync",
source: "imported",
supportedEndpoints: ["chat", "embeddings"],
inputTokenLimit: 32768,
outputTokenLimit: 8192,
@@ -421,7 +448,7 @@ test("model sync route writes synced available models for non-Gemini providers t
{
id: "glm-5.1",
name: "GLM 5.1",
source: "api-sync",
source: "imported",
supportedEndpoints: ["chat"],
inputTokenLimit: 262144,
},
@@ -439,6 +466,7 @@ test("model sync route import mode merges discovered models without deleting man
});
await modelsDb.addCustomModel("openrouter", "manual-only", "Manual Only", "manual");
await modelsDb.addCustomModel("openrouter", "router-v4", "Manual Router V4", "manual");
await localDb.setModelAlias("manual-only", "openrouter/manual-only");
globalThis.fetch = async (url) => {
@@ -467,16 +495,21 @@ test("model sync route import mode merges discovered models without deleting man
assert.equal(body.updatedCount, 0);
assert.equal(body.syncedAliases, 1);
assert.deepEqual(body.modelChanges, { added: 1, removed: 0, updated: 0, total: 1 });
assert.deepEqual(body.customModelChanges, { added: 0, removed: 1, updated: 0, total: 1 });
assert.deepEqual(
body.models.map((model) => ({ id: model.id, source: model.source })),
[
{ id: "manual-only", source: "manual" },
{ id: "router-v4", source: "api-sync" },
]
[{ id: "manual-only", source: "manual" }]
);
assert.deepEqual(
body.importedModels.map((model) => ({ id: model.id, source: model.source })),
[{ id: "router-v4", source: "api-sync" }]
[{ id: "router-v4", source: "imported" }]
);
assert.deepEqual(
(await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({
id: model.id,
source: model.source,
})),
[{ id: "router-v4", source: "imported" }]
);
assert.equal(aliases["manual-only"], "openrouter/manual-only");
assert.equal(aliases["router-v4"], "openrouter/router-v4");
@@ -492,12 +525,11 @@ test("model sync route import mode ignores supported endpoint ordering changes",
apiKey: "test-key",
});
await modelsDb.replaceCustomModels("openrouter", [
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [
{
id: "router-v4",
name: "Router V4",
source: "api-sync",
apiFormat: "chat-completions",
source: "imported",
supportedEndpoints: ["chat", "embeddings"],
},
]);
@@ -536,12 +568,13 @@ test("model sync route import mode ignores supported endpoint ordering changes",
assert.equal(body.logged, false);
assert.deepEqual(body.importedModels, []);
assert.deepEqual(
body.models.map((model) => ({
(await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({
id: model.id,
supportedEndpoints: model.supportedEndpoints,
})),
[{ id: "router-v4", supportedEndpoints: ["chat", "embeddings"] }]
);
assert.deepEqual(body.models, []);
assert.equal(logs.length, 0);
});
@@ -555,12 +588,11 @@ test("model sync route import mode reports updates without counting them as new
apiKey: "test-key",
});
await modelsDb.replaceCustomModels("openrouter", [
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [
{
id: "router-v4",
name: "Router V4",
source: "api-sync",
apiFormat: "chat-completions",
source: "imported",
supportedEndpoints: ["chat"],
},
]);
@@ -597,7 +629,7 @@ test("model sync route import mode reports updates without counting them as new
assert.deepEqual(body.importedChanges, { added: 0, updated: 1, unchanged: 0, total: 1 });
assert.deepEqual(body.importedModels, []);
assert.deepEqual(
body.models.map((model) => ({
(await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({
id: model.id,
name: model.name,
supportedEndpoints: model.supportedEndpoints,
@@ -610,6 +642,7 @@ test("model sync route import mode reports updates without counting them as new
},
]
);
assert.deepEqual(body.models, []);
assert.equal(body.logged, true);
assert.equal(logs.length, 1);
});
@@ -624,16 +657,16 @@ test("model sync route records added, removed, and updated model diffs with fall
accessToken: "sync-token",
});
await modelsDb.replaceCustomModels("openrouter", [
await modelsDb.replaceSyncedAvailableModelsForConnection("openrouter", connection.id, [
{
id: "persisted-model",
name: "Persisted Model",
source: "auto-sync",
source: "imported",
},
{
id: "removed-model",
name: "Removed Model",
source: "auto-sync",
source: "imported",
},
]);
@@ -673,7 +706,7 @@ test("model sync route records added, removed, and updated model diffs with fall
assert.equal(body.logged, true);
assert.deepEqual(body.modelChanges, { added: 1, removed: 1, updated: 1, total: 3 });
assert.deepEqual(
body.models.map((model) => ({
(await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({
id: model.id,
name: model.name,
supportedEndpoints: model.supportedEndpoints,
@@ -689,7 +722,7 @@ test("model sync route records added, removed, and updated model diffs with fall
{
id: "fallback-model",
name: "Fallback Model",
supportedEndpoints: ["chat"],
supportedEndpoints: undefined,
description: "Fallback from model field",
},
]
@@ -753,17 +786,12 @@ test("model sync route forwards cookies, filters built-ins, and syncs aliases fo
assert.equal(body.provider, "openrouter");
assert.equal(body.syncedModels, 3);
assert.equal(body.availableModelsCount, 3);
assert.equal(body.syncedAliases, 2);
assert.equal(body.syncedAliases, 3);
assert.equal(body.logged, true);
assert.deepEqual(body.modelChanges, { added: 2, removed: 0, updated: 0, total: 2 });
assert.deepEqual(
body.models.map((model) => ({ id: model.id, name: model.name })),
[
{ id: "router-v2", name: "Router V2" },
{ id: "router-v3", name: "Router V3" },
]
);
assert.deepEqual(body.modelChanges, { added: 3, removed: 0, updated: 0, total: 3 });
assert.deepEqual(body.models, []);
assert.equal(aliases["stale-model"], undefined);
assert.equal(aliases["auto"], "openrouter/auto");
assert.equal(aliases["openrouter-router-v2"], "openrouter/router-v2");
assert.equal(aliases["router-v3"], "openrouter/router-v3");
assert.equal(logs.length, 1);
@@ -782,6 +810,7 @@ test("model sync route reports synced managed models separately from preserved m
});
await modelsDb.addCustomModel("openrouter", "manual-only", "Manual Only", "manual");
await modelsDb.addCustomModel("openrouter", "router-v4", "Manual Router V4", "manual");
globalThis.fetch = async (url) => {
assert.equal(
@@ -807,12 +836,17 @@ test("model sync route reports synced managed models separately from preserved m
assert.equal(body.availableModelsCount, 2);
assert.equal(body.importedCount, 1);
assert.equal(body.updatedCount, 0);
assert.deepEqual(body.customModelChanges, { added: 0, removed: 1, updated: 0, total: 1 });
assert.deepEqual(
body.models.map((model) => ({ id: model.id, source: model.source })),
[
{ id: "manual-only", source: "manual" },
{ id: "router-v4", source: "api-sync" },
]
[{ id: "manual-only", source: "manual" }]
);
assert.deepEqual(
(await modelsDb.getSyncedAvailableModels("openrouter")).map((model) => ({
id: model.id,
source: model.source,
})),
[{ id: "router-v4", source: "imported" }]
);
});
@@ -868,33 +902,71 @@ test("model sync route uses provider-node prefixes when syncing compatible-provi
assert.equal(aliases["cm-sonnet-4-6"], "anthropic-compatible-demo/sonnet-4-6");
});
test("model sync route returns 500 and records a failure when the internal models fetch throws", async () => {
test("model sync route falls back to in-process discovery when internal self-fetch throws", async () => {
await resetStorage();
const connection = await providersDb.createProviderConnection({
provider: "openrouter",
provider: "openai-compatible-aio",
authType: "apikey",
name: "Exploding Sync",
name: "AIO Import",
apiKey: "test-key",
providerSpecificData: {
prefix: "aio",
apiType: "chat",
baseUrl: "https://api.bltcy.ai/v1",
nodeName: "aio",
autoSync: true,
},
});
globalThis.fetch = async () => {
throw new Error("network exploded");
const fetchCalls: string[] = [];
globalThis.fetch = async (url) => {
const urlString = String(url);
fetchCalls.push(urlString);
if (urlString === `http://localhost/api/providers/${connection.id}/models?refresh=true`) {
throw new Error("fetch failed");
}
assert.equal(urlString, "https://api.bltcy.ai/v1/models");
return Response.json({
data: [{ id: "aio-model", name: "AIO Model" }],
});
};
const response = await modelSyncRoute.POST(
new Request(`http://localhost/api/providers/${connection.id}/sync-models`, {
new Request(`http://localhost/api/providers/${connection.id}/sync-models?mode=import`, {
method: "POST",
headers: scheduler.buildModelSyncInternalHeaders(),
}),
{ params: { id: connection.id } }
);
const body = (await response.json()) as any;
const logs = await callLogs.getCallLogs({ model: "model-sync", limit: 10 });
const body = (await response.json()) as {
importedCount: number;
importedModels: Array<{ id: string; source: string }>;
};
const customModels = (await modelsDb.getCustomModels("openai-compatible-aio")) as Array<{
id: string;
source: string;
}>;
const availableModels = await modelsDb.getSyncedAvailableModels("openai-compatible-aio");
assert.equal(response.status, 500);
assert.equal(body.error, "network exploded");
assert.equal(logs.length, 1);
assert.equal(logs[0].status, 500);
assert.equal(logs[0].provider, "openrouter");
assert.equal(response.status, 200);
assert.equal(body.importedCount, 1);
assert.deepEqual(
body.importedModels.map((model) => ({ id: model.id, source: model.source })),
[{ id: "aio-model", source: "imported" }]
);
assert.deepEqual(
customModels.map((model) => ({ id: model.id, source: model.source })),
[]
);
assert.deepEqual(
availableModels.map((model) => ({ id: model.id, source: model.source })),
[{ id: "aio-model", source: "imported" }]
);
assert.deepEqual(fetchCalls, [
`http://localhost/api/providers/${connection.id}/models?refresh=true`,
"https://api.bltcy.ai/v1/models",
]);
});

View File

@@ -354,21 +354,21 @@ test("v1 models catalog includes synced Gemini models and duplicates audio model
{
id: "gemini-audio-live",
name: "Gemini Audio Live",
source: "api-sync",
source: "imported",
supportedEndpoints: ["audio"],
inputTokenLimit: 4096,
},
{
id: "text-embedding-004",
name: "Text Embedding 004",
source: "api-sync",
source: "imported",
supportedEndpoints: ["embeddings"],
inputTokenLimit: 2048,
},
{
id: "gemini-hidden",
name: "Gemini Hidden",
source: "api-sync",
source: "imported",
supportedEndpoints: ["chat"],
},
]
@@ -402,7 +402,7 @@ test("v1 models catalog keeps Gemini chat models untyped when synced endpoints a
{
id: "gemini-2.5-pro-live",
name: "Gemini 2.5 Pro Live",
source: "api-sync",
source: "imported",
inputTokenLimit: 8192,
},
]);
@@ -430,7 +430,7 @@ test("v1 models catalog includes synced non-Gemini provider models from discover
{
id: "glm-5.1",
name: "GLM 5.1",
source: "api-sync",
source: "imported",
supportedEndpoints: ["chat"],
inputTokenLimit: 262144,
},

View File

@@ -0,0 +1,123 @@
import test from "node:test";
import assert from "node:assert/strict";
import { kiro } from "@/lib/oauth/providers/kiro";
test("kiro.requestDeviceCode returns resolved region for IDC token endpoint", async () => {
const originalFetch = global.fetch;
const fetchCalls: Array<{ url: string; body?: string }> = [];
global.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
fetchCalls.push({ url, body: typeof init?.body === "string" ? init.body : undefined });
if (url.endsWith("/client/register")) {
return new Response(JSON.stringify({ clientId: "client-ap", clientSecret: "secret-ap" }), {
status: 200,
});
}
if (url.endsWith("/device_authorization")) {
return new Response(
JSON.stringify({
deviceCode: "dev-code",
userCode: "user-code",
verificationUri: "https://d-tenant.awsapps.com/start/#/device",
verificationUriComplete: "https://d-tenant.awsapps.com/start/#/device?user_code=ABCD",
expiresIn: 600,
interval: 1,
}),
{ status: 200 }
);
}
return new Response("not-found", { status: 404 });
}) as typeof fetch;
try {
const result = await kiro.requestDeviceCode({
registerClientUrl: "https://oidc.ap-southeast-1.amazonaws.com/client/register",
deviceAuthUrl: "https://oidc.ap-southeast-1.amazonaws.com/device_authorization",
tokenUrl: "https://oidc.ap-southeast-1.amazonaws.com/token",
startUrl: "https://d-tenant.awsapps.com/start",
clientName: "kiro-oauth-client",
clientType: "public",
scopes: ["codewhisperer:completions"],
grantTypes: ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
skipIssuerUrlForRegistration: true,
});
assert.equal(result._region, "ap-southeast-1");
assert.equal(result._clientId, "client-ap");
assert.equal(result._clientSecret, "secret-ap");
const registerBody = JSON.parse(fetchCalls[0]?.body || "{}");
assert.equal(registerBody.issuerUrl, undefined);
} finally {
global.fetch = originalFetch;
}
});
test("kiro.pollToken uses region provided by extraData", async () => {
const originalFetch = global.fetch;
let requestedUrl = "";
global.fetch = (async (input: RequestInfo | URL) => {
requestedUrl = String(input);
return new Response(
JSON.stringify({
accessToken: "access",
refreshToken: "refresh",
expiresIn: 3600,
}),
{ status: 200 }
);
}) as typeof fetch;
try {
const result = await kiro.pollToken(
{ tokenUrl: "https://oidc.us-east-1.amazonaws.com/token" },
"device-code",
null,
{ _clientId: "cid", _clientSecret: "csecret", _region: "ap-southeast-1" }
);
assert.equal(requestedUrl, "https://oidc.ap-southeast-1.amazonaws.com/token");
assert.equal(result.ok, true);
assert.equal(result.data.access_token, "access");
assert.equal(result.data._region, "ap-southeast-1");
} finally {
global.fetch = originalFetch;
}
});
test("kiro.mapTokens persists region into providerSpecificData", () => {
const mapped = kiro.mapTokens({
access_token: "at",
refresh_token: "rt",
expires_in: 3600,
_clientId: "cid",
_clientSecret: "csec",
_region: "ap-southeast-1",
});
assert.equal(mapped.accessToken, "at");
assert.equal(mapped.refreshToken, "rt");
assert.equal(mapped.expiresIn, 3600);
assert.equal(mapped.providerSpecificData.clientId, "cid");
assert.equal(mapped.providerSpecificData.clientSecret, "csec");
assert.equal(mapped.providerSpecificData.region, "ap-southeast-1");
});
test("kiro.mapTokens defaults region to undefined when not provided", () => {
const mapped = kiro.mapTokens({
access_token: "at",
refresh_token: "rt",
expires_in: 3600,
_clientId: "cid",
_clientSecret: "csec",
});
assert.equal(mapped.providerSpecificData.region, undefined);
});

View File

@@ -461,7 +461,7 @@ test("provider models route caches discovered opencode-go models per connection"
assert.equal(firstResponse.status, 200);
assert.equal(firstBody.source, "api");
assert.deepEqual(firstBody.models, [{ id: "glm-5.1", name: "GLM 5.1" }]);
assert.deepEqual(cachedModels, [{ id: "glm-5.1", name: "GLM 5.1", source: "api-sync" }]);
assert.deepEqual(cachedModels, [{ id: "glm-5.1", name: "GLM 5.1", source: "imported" }]);
globalThis.fetch = async () => {
throw new Error("cached route should not hit upstream");
@@ -472,7 +472,7 @@ test("provider models route caches discovered opencode-go models per connection"
assert.equal(cachedResponse.status, 200);
assert.equal(cachedBody.source, "cache");
assert.deepEqual(cachedBody.models, [{ id: "glm-5.1", name: "GLM 5.1", source: "api-sync" }]);
assert.deepEqual(cachedBody.models, [{ id: "glm-5.1", name: "GLM 5.1", source: "imported" }]);
assert.equal(fetchCalls, 1);
});
@@ -481,7 +481,7 @@ test("provider models route falls back to cached models when a refresh fails", a
apiKey: "opencode-go-key",
});
await modelsDb.replaceSyncedAvailableModelsForConnection("opencode-go", connection.id, [
{ id: "cached-go", name: "Cached Go", source: "api-sync" },
{ id: "cached-go", name: "Cached Go", source: "imported" },
]);
let fetchCalls = 0;
@@ -496,7 +496,7 @@ test("provider models route falls back to cached models when a refresh fails", a
assert.equal(response.status, 200);
assert.equal(body.source, "cache");
assert.match(body.warning, /cached catalog/i);
assert.deepEqual(body.models, [{ id: "cached-go", name: "Cached Go", source: "api-sync" }]);
assert.deepEqual(body.models, [{ id: "cached-go", name: "Cached Go", source: "imported" }]);
assert.equal(fetchCalls, 1);
});
@@ -505,7 +505,7 @@ test("provider models route clears cached discovery when a refresh returns no re
apiKey: "opencode-go-key",
});
await modelsDb.replaceSyncedAvailableModelsForConnection("opencode-go", connection.id, [
{ id: "cached-go", name: "Cached Go", source: "api-sync" },
{ id: "cached-go", name: "Cached Go", source: "imported" },
]);
globalThis.fetch = async () => {

View File

@@ -6,6 +6,7 @@ import {
createStreamController,
createDisconnectAwareStream,
} from "../../open-sse/utils/streamHandler.ts";
import { createPassthroughStreamWithLogger } from "../../open-sse/utils/stream.ts";
import { wantsProgress, createProgressTransform } from "../../open-sse/utils/progressTracker.ts";
@@ -50,6 +51,88 @@ test("createProgressTransform maps SSE text output to valid byte stream with pro
assert.match(result, /done":true/);
});
test("createPassthroughStreamWithLogger omits [DONE] for Responses clients", async () => {
const transform = createPassthroughStreamWithLogger(
"codex",
null,
null,
"gpt-5.5-low",
null,
null,
null,
null,
null,
"openai-responses"
);
const writer = transform.writable.getWriter();
await writer.write(
new TextEncoder().encode(
[
"event: response.completed",
'data: {"type":"response.completed","response":{"id":"resp_1","model":"gpt-5.5-low","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}',
"",
].join("\n")
)
);
await writer.close();
const reader = transform.readable.getReader();
const decoder = new TextDecoder();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
result += decoder.decode(value);
}
assert.match(result, /event: response\.completed/);
assert.doesNotMatch(result, /data: \[DONE\]/);
});
test("createPassthroughStreamWithLogger synthesizes reasoning summary events from reasoning output items", async () => {
const transform = createPassthroughStreamWithLogger(
"codex",
null,
null,
"gpt-5.5-low",
null,
null,
null,
null,
null,
"openai-responses"
);
const writer = transform.writable.getWriter();
await writer.write(
new TextEncoder().encode(
[
"event: response.output_item.done",
'data: {"type":"response.output_item.done","response_id":"resp_reasoning_1","output_index":0,"item":{"id":"rs_resp_reasoning_1_0","type":"reasoning","summary":[{"type":"summary_text","text":"Reasoning summary text"}]}}',
"",
].join("\n")
)
);
await writer.close();
const reader = transform.readable.getReader();
const decoder = new TextDecoder();
let result = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
result += decoder.decode(value);
}
assert.match(result, /event: response\.reasoning_summary_text\.delta/);
assert.match(result, /"delta":"Reasoning summary text"/);
assert.match(result, /event: response\.reasoning_summary_part\.done/);
assert.match(result, /event: response\.output_item\.done/);
});
test("createStreamController returns valid controller", () => {
let completeLogged = false;
let disconnectLogged = false;
@@ -61,8 +144,8 @@ test("createStreamController returns valid controller", () => {
};
const sc = createStreamController({
connectionId: "conn_1",
onStreamComplete: () => {},
provider: "test",
model: "conn_1",
});
assert.equal(typeof sc.signal, "object");

View File

@@ -464,6 +464,47 @@ test("refreshKiroToken uses the AWS OIDC flow when client credentials are presen
});
});
test("refreshKiroToken uses stored region for AWS OIDC refresh without authMethod", async () => {
const log = createLog();
const calls: any[] = [];
await withMockedFetch(
async (url, options = {}) => {
calls.push({ url, options });
return jsonResponse({
accessToken: "kiro-aws-access",
refreshToken: "kiro-aws-refresh-next",
expiresIn: 900,
});
},
async () => {
const result = await refreshKiroToken(
"kiro-refresh",
{
clientId: "aws-client",
clientSecret: "aws-secret",
region: "ap-southeast-1",
},
log
);
assert.deepEqual(result, {
accessToken: "kiro-aws-access",
refreshToken: "kiro-aws-refresh-next",
expiresIn: 900,
});
}
);
assert.equal(calls[0].url, "https://oidc.ap-southeast-1.amazonaws.com/token");
assert.deepEqual(JSON.parse(calls[0].options.body), {
clientId: "aws-client",
clientSecret: "aws-secret",
refreshToken: "kiro-refresh",
grantType: "refresh_token",
});
});
test("refreshKiroToken falls back to the social-auth refresh endpoint", async () => {
const log = createLog();
const calls: any[] = [];

View File

@@ -76,7 +76,7 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools",
);
assert.deepEqual(result.systemInstruction, {
role: "user",
role: "system",
parts: [{ text: "Rules" }],
});
assert.equal(result.contents[0].role, "model");
@@ -92,6 +92,7 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools",
},
});
assert.equal(result.generationConfig.maxOutputTokens, 256);
assert.match((result as any).tools[0].functionDeclarations[0].name, /^[a-zA-Z0-9_]+$/);
assert.equal(result.generationConfig.temperature, 0.4);
assert.equal(result.generationConfig.topP, 0.8);
assert.deepEqual(result.generationConfig.thinkingConfig, {
@@ -105,6 +106,19 @@ test("Claude -> Gemini maps system, thinking, tool use, tool result and tools",
});
});
test("Claude -> Gemini clamps maxOutputTokens to the model cap", () => {
const result = claudeToGeminiRequest(
"gemini-2.5-flash",
{
messages: [{ role: "user", content: [{ type: "text", text: "Hello" }] }],
max_tokens: 999999,
},
false
);
assert.equal(result.generationConfig.maxOutputTokens, 8192);
});
test("Claude -> Gemini converts text and base64 images to Gemini parts", () => {
const result = claudeToGeminiRequest(
"gemini-2.5-flash",

View File

@@ -224,7 +224,7 @@ test("OpenAI -> Gemini request maps messages, merged system instructions, tools
false
);
assert.equal((result as any).systemInstruction.role, "user");
assert.equal((result as any).systemInstruction.role, "system");
assert.deepEqual((result as any).systemInstruction.parts, [
{ text: "Rule A" },
{ text: "Rule B" },
@@ -558,11 +558,15 @@ test("OpenAI -> Antigravity uses the Claude bridge for Claude-family models", ()
assert.equal(result.project, "proj-claude");
assert.equal(result.userAgent, "antigravity");
assert.equal((result as any).request?.systemInstruction.role, "system");
assert.equal(
(result as any).request?.systemInstruction.parts[0].text,
ANTIGRAVITY_DEFAULT_SYSTEM
);
assert.equal((result as any).request?.systemInstruction.parts[1].text, "Project rules");
assert.equal((result as any).request?.generationConfig.maxOutputTokens, 8192);
assert.equal((result as any).request?.generationConfig.temperature, 1);
assert.equal((result as any).request?.generationConfig.thinkingConfig, undefined);
const modelTurn = result.request.contents.find(
(content) => content.role === "model" && content.parts.some((part) => part.functionCall)
@@ -624,6 +628,7 @@ test("OpenAI -> Antigravity Claude bridge sanitizes long names and preserves res
const sanitizedToolName = (result as any).request?.tools[0].functionDeclarations[0].name;
assert.equal(sanitizedToolName.length, 64);
assert.match(sanitizedToolName, /^[a-zA-Z0-9_]+$/);
assert.equal((result as any)._toolNameMap.get(sanitizedToolName), longToolName);
const modelTurn = result.request.contents.find(
@@ -638,3 +643,22 @@ test("OpenAI -> Antigravity Claude bridge sanitizes long names and preserves res
assert.ok(toolTurn, "expected a tool response turn");
assert.equal(getFunctionResponse(toolTurn.parts[0]).name, sanitizedToolName);
});
test("OpenAI -> Antigravity Claude bridge clamps output tokens and keeps thinking budget separate", () => {
const result = openaiToAntigravityRequest(
"claude-3-7-sonnet",
{
messages: [{ role: "user", content: "Summarize this" }],
max_completion_tokens: 32000,
reasoning_effort: "high",
},
false,
{ projectId: "proj-claude-thinking" } as any
);
assert.equal((result as any).request?.generationConfig.maxOutputTokens, 8192);
assert.deepEqual((result as any).request?.generationConfig.thinkingConfig, {
thinkingBudget: 131072,
includeThoughts: true,
});
});

View File

@@ -0,0 +1,55 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-v1beta-models-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "v1beta-models-test-secret";
const core = await import("../../src/lib/db/core.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const v1betaModelsRoute = await import("../../src/app/api/v1beta/models/route.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("v1beta models route deduplicates custom models against built-in and synced entries", async () => {
await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-main", [
{
id: "gpt-4o",
name: "GPT-4o From Sync",
source: "imported",
},
{
id: "review-sync-only",
name: "Review Sync Only",
source: "imported",
},
]);
await modelsDb.addCustomModel("openai", "gpt-4o", "GPT-4o Manual Duplicate");
await modelsDb.addCustomModel("openai", "review-sync-only", "Review Manual Duplicate");
await modelsDb.addCustomModel("openai", "review-manual-only", "Review Manual Only");
const response = await v1betaModelsRoute.GET();
const body = (await response.json()) as { models: Array<{ name: string }> };
const names = body.models.map((model) => model.name);
assert.equal(response.status, 200);
assert.equal(names.filter((name) => name === "models/openai/gpt-4o").length, 1);
assert.equal(names.filter((name) => name === "models/openai/review-sync-only").length, 1);
assert.equal(names.filter((name) => name === "models/openai/review-manual-only").length, 1);
});

View File

@@ -16,7 +16,7 @@ test("xiaomi-mimo registry uses the current default base URL and MiMo V2 models"
assert.equal(entry.baseUrl, "https://api.xiaomimimo.com/v1");
assert.deepEqual(
entry.models.map((model) => model.id),
["mimo-v2-pro", "mimo-v2-omni", "mimo-v2-tts"]
["mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-omni", "mimo-v2-flash"]
);
});
@@ -24,7 +24,7 @@ test("xiaomi-mimo executor appends /chat/completions for regional base URLs", ()
const executor = new DefaultExecutor("xiaomi-mimo");
assert.equal(
executor.buildUrl("mimo-v2-pro", true, 0, {
executor.buildUrl("mimo-v2.5", true, 0, {
providerSpecificData: {
baseUrl: "https://token-plan-ams.xiaomimimo.com/v1",
},
@@ -33,7 +33,7 @@ test("xiaomi-mimo executor appends /chat/completions for regional base URLs", ()
);
assert.equal(
executor.buildUrl("mimo-v2-pro", true, 0, {
executor.buildUrl("mimo-v2.5", true, 0, {
providerSpecificData: {
baseUrl: "https://token-plan-cn.xiaomimimo.com/v1/chat/completions",
},