mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Resolve conflicts
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.7.4",
|
||||
"version": "3.7.5",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
||||
4
llm.txt
4
llm.txt
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
|
||||
108
open-sse/services/responsesToolCallState.ts
Normal file
108
open-sse/services/responsesToolCallState.ts
Normal 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();
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 }],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
236
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, () =>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
74
src/app/api/tunnels/ngrok/route.ts
Normal file
74
src/app/api/tunnels/ngrok/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "技能",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
142
src/lib/ngrokTunnel.ts
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`
|
||||
|
||||
@@ -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" },
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
123
tests/unit/oauth-kiro-idc.test.ts
Normal file
123
tests/unit/oauth-kiro-idc.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
55
tests/unit/v1beta-models-route.test.ts
Normal file
55
tests/unit/v1beta-models-route.test.ts
Normal 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);
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user