diff --git a/.gitignore b/.gitignore
index acb837cc..3d18f5cc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -65,3 +65,6 @@ package-lock.json
#Ignore vscode AI rules
.github/instructions/codacy.instructions.md
README1.md
+deploy.sh
+ecosystem.config.*
+start.sh
diff --git a/next.config.mjs b/next.config.mjs
index 971446e0..36505ea0 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -4,9 +4,7 @@ const nextConfig = {
images: {
unoptimized: true
},
- env: {
- NEXT_PUBLIC_CLOUD_URL: "https://9router.com",
- },
+ env: {},
webpack: (config, { isServer }) => {
// Ignore fs/path modules in browser bundle
if (!isServer) {
diff --git a/open-sse/config/constants.js b/open-sse/config/constants.js
index efb42349..39b2e326 100644
--- a/open-sse/config/constants.js
+++ b/open-sse/config/constants.js
@@ -1,5 +1,13 @@
import { platform, arch } from "os";
+// === GitHub Copilot Version Constants ===
+export const GITHUB_COPILOT = {
+ VSCODE_VERSION: "1.110.0",
+ COPILOT_CHAT_VERSION: "0.38.0",
+ USER_AGENT: "GitHubCopilotChat/0.38.0",
+ API_VERSION: "2025-04-01",
+};
+
// === Antigravity Binary Alignment: Numeric Enums ===
// Reference: Antigravity binary analysis - google.internal.cloud.code.v1internal.ClientMetadata
@@ -53,7 +61,7 @@ export function getPlatformEnum() {
export function getPlatformUserAgent() {
const os = platform();
const architecture = arch();
- return `antigravity/1.16.5 ${os}/${architecture}`;
+ return `antigravity/1.104.0 ${os}/${architecture}`;
}
// Centralized client metadata (used in request bodies for loadCodeAssist, onboardUser, etc.)
@@ -71,7 +79,8 @@ export const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local
export const ANTIGRAVITY_HEADERS = {
"X-Client-Name": "antigravity",
"X-Client-Version": "1.107.0",
- "x-goog-api-client": "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x"
+ "x-goog-api-client": "gl-node/18.18.2 fire/0.8.6 grpc/1.10.x",
+ "User-Agent": "antigravity/1.107.0 darwin/arm64"
};
// Cloud Code Assist API endpoints (for Project ID discovery)
@@ -220,11 +229,11 @@ export const PROVIDERS = {
format: "openai", // GitHub Copilot uses OpenAI-compatible format
headers: {
"copilot-integration-id": "vscode-chat",
- "editor-version": "vscode/1.107.1",
- "editor-plugin-version": "copilot-chat/0.26.7",
- "user-agent": "GitHubCopilotChat/0.26.7",
+ "editor-version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`,
+ "editor-plugin-version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`,
+ "user-agent": GITHUB_COPILOT.USER_AGENT,
"openai-intent": "conversation-panel",
- "x-github-api-version": "2025-04-01",
+ "x-github-api-version": GITHUB_COPILOT.API_VERSION,
"x-vscode-user-agent-library-version": "electron-fetch",
"X-Initiator": "user",
"Accept": "application/json",
@@ -283,6 +292,82 @@ export const PROVIDERS = {
},
tokenUrl: "https://api.cline.bot/api/v1/auth/token",
refreshUrl: "https://api.cline.bot/api/v1/auth/refresh"
+ },
+ nvidia: {
+ baseUrl: "https://integrate.api.nvidia.com/v1/chat/completions",
+ format: "openai"
+ },
+ anthropic: {
+ baseUrl: "https://api.anthropic.com/v1/messages",
+ format: "claude",
+ headers: {
+ "Anthropic-Version": "2023-06-01",
+ "Anthropic-Beta": "claude-code-20250219,interleaved-thinking-2025-05-14"
+ }
+ },
+ deepseek: {
+ baseUrl: "https://api.deepseek.com/chat/completions",
+ format: "openai"
+ },
+ groq: {
+ baseUrl: "https://api.groq.com/openai/v1/chat/completions",
+ format: "openai"
+ },
+ xai: {
+ baseUrl: "https://api.x.ai/v1/chat/completions",
+ format: "openai"
+ },
+ mistral: {
+ baseUrl: "https://api.mistral.ai/v1/chat/completions",
+ format: "openai"
+ },
+ perplexity: {
+ baseUrl: "https://api.perplexity.ai/chat/completions",
+ format: "openai"
+ },
+ together: {
+ baseUrl: "https://api.together.xyz/v1/chat/completions",
+ format: "openai"
+ },
+ fireworks: {
+ baseUrl: "https://api.fireworks.ai/inference/v1/chat/completions",
+ format: "openai"
+ },
+ cerebras: {
+ baseUrl: "https://api.cerebras.ai/v1/chat/completions",
+ format: "openai"
+ },
+ cohere: {
+ baseUrl: "https://api.cohere.ai/v1/chat/completions",
+ format: "openai"
+ },
+ nebius: {
+ baseUrl: "https://api.studio.nebius.ai/v1/chat/completions",
+ format: "openai"
+ },
+ siliconflow: {
+ baseUrl: "https://api.siliconflow.cn/v1/chat/completions",
+ format: "openai"
+ },
+ hyperbolic: {
+ baseUrl: "https://api.hyperbolic.xyz/v1/chat/completions",
+ format: "openai"
+ },
+ deepgram: {
+ baseUrl: "https://api.deepgram.com/v1/listen",
+ format: "openai"
+ },
+ assemblyai: {
+ baseUrl: "https://api.assemblyai.com/v1/audio/transcriptions",
+ format: "openai"
+ },
+ nanobanana: {
+ baseUrl: "https://api.nanobananaapi.ai/v1/chat/completions",
+ format: "openai"
+ },
+ chutes: {
+ baseUrl: "https://llm.chutes.ai/v1/chat/completions",
+ format: "openai"
}
};
diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js
index 6905f60f..1c175539 100644
--- a/open-sse/config/providerModels.js
+++ b/open-sse/config/providerModels.js
@@ -34,9 +34,6 @@ export const PROVIDER_MODELS = {
gc: [ // Gemini CLI
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
{ id: "gemini-3-pro-preview", name: "Gemini 3 Pro Preview" },
- { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
- { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
- { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash Lite" },
],
qw: [ // Qwen Code
// { id: "qwen3-coder-next", name: "Qwen3 Coder Next" },
diff --git a/open-sse/executors/antigravity.js b/open-sse/executors/antigravity.js
index fe8842fb..b4ce74a6 100644
--- a/open-sse/executors/antigravity.js
+++ b/open-sse/executors/antigravity.js
@@ -21,11 +21,11 @@ export class AntigravityExecutor extends BaseExecutor {
return {
"Content-Type": "application/json",
"Authorization": `Bearer ${credentials.accessToken}`,
- "User-Agent": this.config.headers?.["User-Agent"] || "antigravity/1.104.0 darwin/arm64",
+ "User-Agent": this.config.headers?.["User-Agent"] || ANTIGRAVITY_HEADERS['User-Agent'],
[INTERNAL_REQUEST_HEADER.name]: INTERNAL_REQUEST_HEADER.value,
...ANTIGRAVITY_HEADERS,
...(sessionId && { "X-Machine-Session-Id": sessionId }),
- ...(stream && { "Accept": "text/event-stream" })
+ "Accept": stream ? "text/event-stream" : "application/json"
};
}
diff --git a/open-sse/executors/github.js b/open-sse/executors/github.js
index 08172c1d..5beb3917 100644
--- a/open-sse/executors/github.js
+++ b/open-sse/executors/github.js
@@ -1,5 +1,5 @@
import { BaseExecutor } from "./base.js";
-import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.js";
+import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS, GITHUB_COPILOT } from "../config/constants.js";
import { openaiToOpenAIResponsesRequest } from "../translator/request/openai-responses.js";
import { openaiResponsesToOpenAIResponse } from "../translator/response/openai-responses.js";
import { initState } from "../translator/index.js";
@@ -22,11 +22,11 @@ export class GithubExecutor extends BaseExecutor {
"Authorization": `Bearer ${token}`,
"Content-Type": "application/json",
"copilot-integration-id": "vscode-chat",
- "editor-version": "vscode/1.107.1",
- "editor-plugin-version": "copilot-chat/0.26.7",
- "user-agent": "GitHubCopilotChat/0.26.7",
+ "editor-version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`,
+ "editor-plugin-version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`,
+ "user-agent": GITHUB_COPILOT.USER_AGENT,
"openai-intent": "conversation-panel",
- "x-github-api-version": "2025-04-01",
+ "x-github-api-version": GITHUB_COPILOT.API_VERSION,
"x-request-id": crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
"x-vscode-user-agent-library-version": "electron-fetch",
"X-Initiator": "user",
@@ -46,7 +46,7 @@ export class GithubExecutor extends BaseExecutor {
if (result.response.status === HTTP_STATUS.BAD_REQUEST) {
const errorBody = await result.response.clone().text();
-
+
if (errorBody.includes("not accessible via the /chat/completions endpoint")) {
log?.warn("GITHUB", `Model ${model} requires /responses. Switching...`);
this.knownCodexModels.add(model);
@@ -60,7 +60,7 @@ export class GithubExecutor extends BaseExecutor {
async executeWithResponsesEndpoint({ model, body, stream, credentials, signal, log }) {
const url = this.config.responsesUrl;
const headers = this.buildHeaders(credentials, stream);
-
+
const transformedBody = openaiToOpenAIResponsesRequest(model, body, stream, credentials);
log?.debug("GITHUB", "Sending translated request to /responses");
@@ -86,7 +86,7 @@ export class GithubExecutor extends BaseExecutor {
async transform(chunk, controller) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split("\n");
-
+
buffer = lines.pop() || "";
for (const line of lines) {
@@ -110,13 +110,13 @@ export class GithubExecutor extends BaseExecutor {
},
flush(controller) {
if (buffer.trim()) {
- const parsed = parseSSELine(buffer.trim());
- if (parsed && !parsed.done) {
- const converted = openaiResponsesToOpenAIResponse(parsed, state);
- if (converted) {
- controller.enqueue(new TextEncoder().encode(formatSSE(converted, "openai")));
- }
- }
+ const parsed = parseSSELine(buffer.trim());
+ if (parsed && !parsed.done) {
+ const converted = openaiResponsesToOpenAIResponse(parsed, state);
+ if (converted) {
+ controller.enqueue(new TextEncoder().encode(formatSSE(converted, "openai")));
+ }
+ }
}
}
});
@@ -140,13 +140,18 @@ export class GithubExecutor extends BaseExecutor {
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
headers: {
"Authorization": `token ${githubAccessToken}`,
- "User-Agent": "GithubCopilot/1.0",
- "Editor-Version": "vscode/1.100.0",
- "Editor-Plugin-Version": "copilot/1.300.0",
- "Accept": "application/json"
+ "User-Agent": GITHUB_COPILOT.USER_AGENT,
+ "Editor-Version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`,
+ "Editor-Plugin-Version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`,
+ "Accept": "application/json",
+ "x-github-api-version": GITHUB_COPILOT.API_VERSION
}
});
- if (!response.ok) return null;
+ if (!response.ok) {
+ const errorText = await response.text();
+ log?.error?.("TOKEN", `Copilot token refresh failed: ${response.status} ${errorText}`);
+ return null;
+ }
const data = await response.json();
log?.info?.("TOKEN", "Copilot token refreshed");
return { token: data.token, expiresAt: data.expires_at };
@@ -180,7 +185,7 @@ export class GithubExecutor extends BaseExecutor {
async refreshCredentials(credentials, log) {
let copilotResult = await this.refreshCopilotToken(credentials.accessToken, log);
-
+
if (!copilotResult && credentials.refreshToken) {
const githubTokens = await this.refreshGitHubToken(credentials.refreshToken, log);
if (githubTokens?.accessToken) {
@@ -191,18 +196,18 @@ export class GithubExecutor extends BaseExecutor {
return githubTokens;
}
}
-
+
if (copilotResult) {
return { accessToken: credentials.accessToken, refreshToken: credentials.refreshToken, copilotToken: copilotResult.token, copilotTokenExpiresAt: copilotResult.expiresAt };
}
-
+
return null;
}
needsRefresh(credentials) {
// Always refresh if no copilotToken
if (!credentials.copilotToken) return true;
-
+
if (credentials.copilotTokenExpiresAt) {
// Handle both Unix timestamp (seconds) and ISO string
let expiresAtMs = credentials.copilotTokenExpiresAt;
diff --git a/open-sse/services/model.js b/open-sse/services/model.js
index dcb03ba6..a712f36c 100644
--- a/open-sse/services/model.js
+++ b/open-sse/services/model.js
@@ -9,11 +9,39 @@ const ALIAS_TO_PROVIDER_ID = {
gh: "github",
kr: "kiro",
cu: "cursor",
- // API Key providers (alias = id)
+ // API Key providers
openai: "openai",
anthropic: "anthropic",
gemini: "gemini",
openrouter: "openrouter",
+ glm: "glm",
+ kimi: "kimi",
+ minimax: "minimax",
+ "minimax-cn": "minimax-cn",
+ ds: "deepseek",
+ deepseek: "deepseek",
+ groq: "groq",
+ xai: "xai",
+ mistral: "mistral",
+ pplx: "perplexity",
+ perplexity: "perplexity",
+ together: "together",
+ fireworks: "fireworks",
+ cerebras: "cerebras",
+ cohere: "cohere",
+ nvidia: "nvidia",
+ nebius: "nebius",
+ siliconflow: "siliconflow",
+ hyp: "hyperbolic",
+ hyperbolic: "hyperbolic",
+ dg: "deepgram",
+ deepgram: "deepgram",
+ aai: "assemblyai",
+ assemblyai: "assemblyai",
+ nb: "nanobanana",
+ nanobanana: "nanobanana",
+ ch: "chutes",
+ chutes: "chutes",
cursor: "cursor",
};
@@ -42,7 +70,12 @@ export function parseModel(modelStr) {
}
// Alias format (model alias, not provider alias)
- return { provider: null, model: modelStr, isAlias: true, providerAlias: null };
+ return {
+ provider: null,
+ model: modelStr,
+ isAlias: true,
+ providerAlias: null,
+ };
}
/**
@@ -51,29 +84,29 @@ export function parseModel(modelStr) {
*/
export function resolveModelAliasFromMap(alias, aliases) {
if (!aliases) return null;
-
+
// Check if alias exists
const resolved = aliases[alias];
if (!resolved) return null;
-
+
// Resolved value is "provider/model" format
if (typeof resolved === "string" && resolved.includes("/")) {
const firstSlash = resolved.indexOf("/");
const providerOrAlias = resolved.slice(0, firstSlash);
return {
provider: resolveProviderAlias(providerOrAlias),
- model: resolved.slice(firstSlash + 1)
+ model: resolved.slice(firstSlash + 1),
};
}
-
+
// Or object { provider, model }
if (typeof resolved === "object" && resolved.provider && resolved.model) {
return {
provider: resolveProviderAlias(resolved.provider),
- model: resolved.model
+ model: resolved.model,
};
}
-
+
return null;
}
@@ -93,9 +126,10 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
}
// Get aliases (from object or function)
- const aliases = typeof aliasesOrGetter === "function"
- ? await aliasesOrGetter()
- : aliasesOrGetter;
+ const aliases =
+ typeof aliasesOrGetter === "function"
+ ? await aliasesOrGetter()
+ : aliasesOrGetter;
// Resolve alias
const resolved = resolveModelAliasFromMap(parsed.model, aliases);
@@ -103,10 +137,26 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) {
return resolved;
}
- // Fallback: treat as openai model
+ // Fallback: infer provider from model name prefix
return {
- provider: "openai",
- model: parsed.model
+ provider: inferProviderFromModelName(parsed.model),
+ model: parsed.model,
};
}
+/**
+ * Infer provider from model name prefix
+ * Used as fallback when no provider prefix or alias is given
+ */
+function inferProviderFromModelName(modelName) {
+ if (!modelName) return "openai";
+ const m = modelName.toLowerCase();
+ if (m.startsWith("claude-")) return "anthropic";
+ if (m.startsWith("gemini-")) return "gemini";
+ if (m.startsWith("gpt-")) return "openai";
+ if (m.startsWith("o1") || m.startsWith("o3") || m.startsWith("o4"))
+ return "openai";
+ if (m.startsWith("deepseek-")) return "openrouter";
+ // Default fallback
+ return "openai";
+}
diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js
index 0222ecb1..805779e2 100644
--- a/open-sse/services/tokenRefresh.js
+++ b/open-sse/services/tokenRefresh.js
@@ -1,4 +1,4 @@
-import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.js";
+import { PROVIDERS, OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../config/constants.js";
// Token expiry buffer (refresh if expires within 5 minutes)
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
@@ -301,6 +301,7 @@ export async function refreshKiroToken(refreshToken, providerSpecificData, log)
headers: {
"Content-Type": "application/json",
Accept: "application/json",
+ "User-Agent": "kiro-cli/1.0.0",
},
body: JSON.stringify({
refreshToken: refreshToken,
@@ -425,10 +426,11 @@ export async function refreshCopilotToken(githubAccessToken, log) {
const response = await fetch("https://api.github.com/copilot_internal/v2/token", {
headers: {
"Authorization": `token ${githubAccessToken}`,
- "User-Agent": "GithubCopilot/1.0",
- "Editor-Version": "vscode/1.100.0",
- "Editor-Plugin-Version": "copilot/1.300.0",
- "Accept": "application/json"
+ "User-Agent": GITHUB_COPILOT.USER_AGENT,
+ "Editor-Version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`,
+ "Editor-Plugin-Version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`,
+ "Accept": "application/json",
+ "x-github-api-version": GITHUB_COPILOT.API_VERSION
}
});
diff --git a/open-sse/services/usage.js b/open-sse/services/usage.js
index 9ee774ee..ff14c9c0 100644
--- a/open-sse/services/usage.js
+++ b/open-sse/services/usage.js
@@ -221,16 +221,26 @@ async function getAntigravityUsage(accessToken, providerSpecificData) {
"Authorization": `Bearer ${accessToken}`,
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
"Content-Type": "application/json",
+ "X-Client-Name": "antigravity",
+ "X-Client-Version": "1.107.0",
},
body: JSON.stringify({
- ...(projectId ? { project: projectId } : {}),
- metadata: CLIENT_METADATA,
- mode: 1
+ ...(projectId ? { project: projectId } : {})
}),
});
if (response.status === 403) {
- return { message: "Antigravity access forbidden. Check subscription." };
+ return {
+ message: "Antigravity quota API access forbidden. Chat may still work.",
+ quotas: {}
+ };
+ }
+
+ if (response.status === 401) {
+ return {
+ message: "Antigravity quota API authentication expired. Chat may still work.",
+ quotas: {}
+ };
}
if (!response.ok) {
@@ -470,6 +480,15 @@ async function getKiroUsage(accessToken, providerSpecificData) {
if (!response.ok) {
const errorText = await response.text();
+
+ // Handle authentication errors gracefully
+ if (response.status === 403 || response.status === 401) {
+ return {
+ message: "Kiro quota API authentication expired. Chat may still work.",
+ quotas: {}
+ };
+ }
+
throw new Error(`Kiro API error (${response.status}): ${errorText}`);
}
diff --git a/package.json b/package.json
index 295534f8..b8ecf590 100644
--- a/package.json
+++ b/package.json
@@ -5,8 +5,11 @@
"private": true,
"scripts": {
"dev": "next dev --webpack --port 20128",
- "build": "next build --webpack",
- "start": "next start"
+ "build": "NODE_ENV=production next build --webpack",
+ "start": "NODE_ENV=production next start",
+ "dev:bun": "bun --bun next dev --webpack --port 20128",
+ "build:bun": "NODE_ENV=production bun --bun next build --webpack",
+ "start:bun": "NODE_ENV=production bun ./.next/standalone/server.js"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
@@ -37,6 +40,7 @@
"@tailwindcss/postcss": "^4.1.18",
"eslint": "^9",
"eslint-config-next": "16.1.6",
+ "postcss": "^8.5.6",
"tailwindcss": "^4"
}
-}
+}
\ No newline at end of file
diff --git a/public/icons/icon-192.svg b/public/icons/icon-192.svg
new file mode 100644
index 00000000..e7978910
--- /dev/null
+++ b/public/icons/icon-192.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/icons/icon-512.svg b/public/icons/icon-512.svg
new file mode 100644
index 00000000..6fef45af
--- /dev/null
+++ b/public/icons/icon-512.svg
@@ -0,0 +1,4 @@
+
diff --git a/public/providers/chutes.png b/public/providers/chutes.png
new file mode 100644
index 00000000..07e4d1db
Binary files /dev/null and b/public/providers/chutes.png differ
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 00000000..4c4de800
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,22 @@
+self.addEventListener('push', function (event) {
+ if (event.data) {
+ const data = event.data.json()
+ const options = {
+ body: data.body,
+ icon: data.icon || '/icons/icon-192.svg',
+ badge: '/icons/icon-192.svg',
+ vibrate: [100, 50, 100],
+ data: {
+ dateOfArrival: Date.now(),
+ primaryKey: '2',
+ },
+ }
+ event.waitUntil(self.registration.showNotification(data.title, options))
+ }
+})
+
+self.addEventListener('notificationclick', function (event) {
+ console.log('Notification click received.')
+ event.notification.close()
+ event.waitUntil(clients.openWindow('/'))
+})
diff --git a/src/app/(dashboard)/dashboard/usage/page.js b/src/app/(dashboard)/dashboard/usage/page.js
index 8b857523..e1505713 100644
--- a/src/app/(dashboard)/dashboard/usage/page.js
+++ b/src/app/(dashboard)/dashboard/usage/page.js
@@ -1,6 +1,6 @@
"use client";
-import { useState, Suspense, useEffect } from "react";
+import { Suspense, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { UsageStats, RequestLogger, CardSkeleton, SegmentedControl } from "@/shared/components";
import ProviderLimits from "./components/ProviderLimits";
@@ -9,28 +9,25 @@ import RequestDetailsTab from "./components/RequestDetailsTab";
export default function UsagePage() {
return (
}>
-
+
);
}
-function UsagePageContent() {
+function UsageContent() {
const searchParams = useSearchParams();
const router = useRouter();
- const [activeTab, setActiveTab] = useState(searchParams.get("tab") || "overview");
+
const [tabLoading, setTabLoading] = useState(false);
- useEffect(() => {
- const tabFromUrl = searchParams.get("tab");
- if (tabFromUrl && ["overview", "logs", "limits", "details"].includes(tabFromUrl)) {
- setActiveTab(tabFromUrl);
- }
- }, [searchParams]);
+ const tabFromUrl = searchParams.get("tab");
+ const activeTab = tabFromUrl && ["overview", "logs", "limits", "details"].includes(tabFromUrl)
+ ? tabFromUrl
+ : "overview";
const handleTabChange = (value) => {
if (value === activeTab) return;
setTabLoading(true);
- setActiveTab(value);
const params = new URLSearchParams(searchParams);
params.set("tab", value);
router.push(`/dashboard/usage?${params.toString()}`, { scroll: false });
@@ -71,3 +68,4 @@ function UsagePageContent() {
);
}
+
diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js
index b1dff57c..3b92d7a1 100644
--- a/src/app/api/providers/[id]/models/route.js
+++ b/src/app/api/providers/[id]/models/route.js
@@ -1,6 +1,48 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
+import { KiroService } from "@/lib/oauth/services/kiro";
+import { GEMINI_CONFIG } from "@/lib/oauth/constants/oauth";
+import { refreshGoogleToken, updateProviderCredentials } from "@/sse/services/tokenRefresh";
+
+const GEMINI_CLI_MODELS_URL = "https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels";
+
+const parseOpenAIStyleModels = (data) => {
+ if (Array.isArray(data)) return data;
+ return data?.data || data?.models || data?.results || [];
+};
+
+const parseGeminiCliModels = (data) => {
+ if (Array.isArray(data?.models)) {
+ return data.models
+ .map((item) => {
+ const id = item?.id || item?.model || item?.name;
+ if (!id) return null;
+ return { id, name: item?.displayName || item?.name || id };
+ })
+ .filter(Boolean);
+ }
+
+ if (data?.models && typeof data.models === "object") {
+ return Object.entries(data.models)
+ .filter(([, info]) => !info?.isInternal)
+ .map(([id, info]) => ({
+ id,
+ name: info?.displayName || info?.name || id,
+ }));
+ }
+
+ return [];
+};
+
+const createOpenAIModelsConfig = (url) => ({
+ url,
+ method: "GET",
+ headers: { "Content-Type": "application/json" },
+ authHeader: "Authorization",
+ authPrefix: "Bearer ",
+ parseResponse: parseOpenAIStyleModels
+});
// Provider models endpoints configuration
const PROVIDER_MODELS_CONFIG = {
@@ -21,14 +63,6 @@ const PROVIDER_MODELS_CONFIG = {
authQuery: "key", // Use query param for API key
parseResponse: (data) => data.models || []
},
- "gemini-cli": {
- url: "https://generativelanguage.googleapis.com/v1beta/models",
- method: "GET",
- headers: { "Content-Type": "application/json" },
- authHeader: "Authorization",
- authPrefix: "Bearer ",
- parseResponse: (data) => data.models || []
- },
qwen: {
url: "https://portal.qwen.ai/v1/models",
method: "GET",
@@ -46,22 +80,35 @@ const PROVIDER_MODELS_CONFIG = {
body: {},
parseResponse: (data) => data.models || []
},
- openai: {
- url: "https://api.openai.com/v1/models",
+ github: {
+ url: "https://api.githubcopilot.com/models",
method: "GET",
- headers: { "Content-Type": "application/json" },
+ headers: {
+ "Content-Type": "application/json",
+ "Copilot-Integration-Id": "vscode-chat",
+ "editor-version": "vscode/1.107.1",
+ "editor-plugin-version": "copilot-chat/0.26.7",
+ "user-agent": "GitHubCopilotChat/0.26.7"
+ },
authHeader: "Authorization",
authPrefix: "Bearer ",
- parseResponse: (data) => data.data || []
- },
- openrouter: {
- url: "https://openrouter.ai/api/v1/models",
- method: "GET",
- headers: { "Content-Type": "application/json" },
- authHeader: "Authorization",
- authPrefix: "Bearer ",
- parseResponse: (data) => data.data || []
+ parseResponse: (data) => {
+ if (!data?.data) return [];
+ // Filter out embeddings, non-chat models, and disabled models
+ return data.data
+ .filter(m => m.capabilities?.type === "chat")
+ .filter(m => m.policy?.state !== "disabled") // Only return explicitly enabled models
+ .map(m => ({
+ id: m.id,
+ name: m.name || m.id,
+ version: m.version,
+ capabilities: m.capabilities,
+ isDefault: m.model_picker_enabled === true
+ }));
+ }
},
+ openai: createOpenAIModelsConfig("https://api.openai.com/v1/models"),
+ openrouter: createOpenAIModelsConfig("https://openrouter.ai/api/v1/models"),
anthropic: {
url: "https://api.anthropic.com/v1/models",
method: "GET",
@@ -71,7 +118,25 @@ const PROVIDER_MODELS_CONFIG = {
},
authHeader: "x-api-key",
parseResponse: (data) => data.data || []
- }
+ },
+
+ // OpenAI-compatible API key providers
+ deepseek: createOpenAIModelsConfig("https://api.deepseek.com/models"),
+ groq: createOpenAIModelsConfig("https://api.groq.com/openai/v1/models"),
+ xai: createOpenAIModelsConfig("https://api.x.ai/v1/models"),
+ mistral: createOpenAIModelsConfig("https://api.mistral.ai/v1/models"),
+ perplexity: createOpenAIModelsConfig("https://api.perplexity.ai/models"),
+ together: createOpenAIModelsConfig("https://api.together.xyz/v1/models"),
+ fireworks: createOpenAIModelsConfig("https://api.fireworks.ai/inference/v1/models"),
+ cerebras: createOpenAIModelsConfig("https://api.cerebras.ai/v1/models"),
+ cohere: createOpenAIModelsConfig("https://api.cohere.ai/v1/models"),
+ nebius: createOpenAIModelsConfig("https://api.studio.nebius.ai/v1/models"),
+ siliconflow: createOpenAIModelsConfig("https://api.siliconflow.cn/v1/models"),
+ hyperbolic: createOpenAIModelsConfig("https://api.hyperbolic.xyz/v1/models"),
+ nanobanana: createOpenAIModelsConfig("https://api.nanobananaapi.ai/v1/models"),
+ chutes: createOpenAIModelsConfig("https://llm.chutes.ai/v1/models"),
+ nvidia: createOpenAIModelsConfig("https://integrate.api.nvidia.com/v1/models"),
+ assemblyai: createOpenAIModelsConfig("https://api.assemblyai.com/v1/models")
};
/**
@@ -124,12 +189,12 @@ export async function GET(request, { params }) {
if (!baseUrl) {
return NextResponse.json({ error: "No base URL configured for Anthropic compatible provider" }, { status: 400 });
}
-
+
baseUrl = baseUrl.replace(/\/$/, "");
if (baseUrl.endsWith("/messages")) {
baseUrl = baseUrl.slice(0, -9);
}
-
+
const url = `${baseUrl}/models`;
const response = await fetch(url, {
method: "GET",
@@ -160,6 +225,96 @@ export async function GET(request, { params }) {
});
}
+ // Kiro: Try dynamic model fetching first
+ if (connection.provider === "kiro") {
+ try {
+ const kiroService = new KiroService();
+ const profileArn = connection.providerSpecificData?.profileArn;
+ const accessToken = connection.accessToken;
+
+ if (accessToken && profileArn) {
+ const models = await kiroService.listAvailableModels(accessToken, profileArn);
+ return NextResponse.json({
+ provider: connection.provider,
+ connectionId: connection.id,
+ models
+ });
+ }
+ } catch (error) {
+ console.log("Failed to fetch Kiro models dynamically, falling back to static:", error.message);
+ }
+ }
+
+ if (connection.provider === "gemini-cli") {
+ const { accessToken, refreshToken } = connection;
+ if (!accessToken) {
+ return NextResponse.json({ error: "No valid token found" }, { status: 401 });
+ }
+
+ const projectId = connection.projectId || connection.providerSpecificData?.projectId;
+ const body = projectId ? { project: projectId } : {};
+
+ const fetchModels = async (token) => {
+ const response = await fetch(GEMINI_CLI_MODELS_URL, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`,
+ "User-Agent": "google-api-nodejs-client/9.15.1",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1"
+ },
+ body: JSON.stringify(body)
+ });
+ return response;
+ };
+
+ let warning;
+
+ try {
+ let response = await fetchModels(accessToken);
+
+ // Attempt refresh on 401/403 when refresh token exists
+ if (!response.ok && (response.status === 401 || response.status === 403) && refreshToken) {
+ const refreshed = await refreshGoogleToken(refreshToken, GEMINI_CONFIG.clientId, GEMINI_CONFIG.clientSecret);
+ if (refreshed?.accessToken) {
+ await updateProviderCredentials(connection.id, {
+ accessToken: refreshed.accessToken,
+ refreshToken: refreshed.refreshToken,
+ expiresIn: refreshed.expiresIn,
+ });
+ response = await fetchModels(refreshed.accessToken);
+ }
+ }
+
+ if (response.ok) {
+ const data = await response.json();
+ const models = parseGeminiCliModels(data);
+ if (models.length > 0) {
+ return NextResponse.json({
+ provider: connection.provider,
+ connectionId: connection.id,
+ models
+ });
+ }
+ } else {
+ const errorText = await response.text();
+ warning = `Failed to fetch Gemini CLI models: ${response.status} ${errorText}`;
+ console.log("Failed to fetch Gemini CLI models dynamically, falling back to static:", errorText);
+ }
+ } catch (error) {
+ warning = `Failed to fetch Gemini CLI models: ${error.message}`;
+ console.log("Failed to fetch Gemini CLI models dynamically, falling back to static:", error.message);
+ }
+
+ // Return empty dynamic list so UI falls back to static provider models.
+ return NextResponse.json({
+ provider: connection.provider,
+ connectionId: connection.id,
+ models: [],
+ warning,
+ });
+ }
+
const config = PROVIDER_MODELS_CONFIG[connection.provider];
if (!config) {
return NextResponse.json(
@@ -169,7 +324,7 @@ export async function GET(request, { params }) {
}
// Get auth token
- const token = connection.accessToken || connection.apiKey;
+ const token = connection.providerSpecificData?.copilotToken || connection.accessToken || connection.apiKey;
if (!token) {
return NextResponse.json({ error: "No valid token found" }, { status: 401 });
}
diff --git a/src/app/api/providers/[id]/route.js b/src/app/api/providers/[id]/route.js
index aec181ae..1b58aecc 100644
--- a/src/app/api/providers/[id]/route.js
+++ b/src/app/api/providers/[id]/route.js
@@ -30,7 +30,18 @@ export async function PUT(request, { params }) {
try {
const { id } = await params;
const body = await request.json();
- const { name, priority, globalPriority, defaultModel, isActive, apiKey, testStatus, lastError, lastErrorAt } = body;
+ const {
+ name,
+ priority,
+ globalPriority,
+ defaultModel,
+ isActive,
+ apiKey,
+ testStatus,
+ lastError,
+ lastErrorAt,
+ providerSpecificData
+ } = body;
const existing = await getProviderConnectionById(id);
if (!existing) {
@@ -47,6 +58,12 @@ export async function PUT(request, { params }) {
if (testStatus !== undefined) updateData.testStatus = testStatus;
if (lastError !== undefined) updateData.lastError = lastError;
if (lastErrorAt !== undefined) updateData.lastErrorAt = lastErrorAt;
+ if (providerSpecificData !== undefined) {
+ updateData.providerSpecificData = {
+ ...(existing.providerSpecificData || {}),
+ ...providerSpecificData,
+ };
+ }
const updated = await updateProviderConnection(id, updateData);
diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js
index 727054c9..d84f2dd4 100644
--- a/src/app/api/providers/[id]/test/testUtils.js
+++ b/src/app/api/providers/[id]/test/testUtils.js
@@ -281,6 +281,58 @@ async function testApiKeyConnection(connection) {
const res = await fetch("https://api.x.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
}
+ case "nvidia": {
+ const res = await fetch("https://integrate.api.nvidia.com/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "perplexity": {
+ const res = await fetch("https://api.perplexity.ai/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "together": {
+ const res = await fetch("https://api.together.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "fireworks": {
+ const res = await fetch("https://api.fireworks.ai/inference/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "cerebras": {
+ const res = await fetch("https://api.cerebras.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "cohere": {
+ const res = await fetch("https://api.cohere.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "nebius": {
+ const res = await fetch("https://api.studio.nebius.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "siliconflow": {
+ const res = await fetch("https://api.siliconflow.cn/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "hyperbolic": {
+ const res = await fetch("https://api.hyperbolic.xyz/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "deepgram": {
+ const res = await fetch("https://api.deepgram.com/v1/projects", { headers: { Authorization: `Token ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "assemblyai": {
+ const res = await fetch("https://api.assemblyai.com/v1/account", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "nanobanana": {
+ const res = await fetch("https://api.nanobananaapi.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
+ case "chutes": {
+ const res = await fetch("https://llm.chutes.ai/v1/models", { headers: { Authorization: `Bearer ${connection.apiKey}` } });
+ return { valid: res.ok, error: res.ok ? null : "Invalid API key" };
+ }
default:
return { valid: false, error: "Provider test not supported" };
}
diff --git a/src/app/api/providers/validate/route.js b/src/app/api/providers/validate/route.js
index c06844a6..26c083d9 100644
--- a/src/app/api/providers/validate/route.js
+++ b/src/app/api/providers/validate/route.js
@@ -38,22 +38,22 @@ export async function POST(request) {
if (!node) {
return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 });
}
-
+
let normalizedBase = node.baseUrl?.trim().replace(/\/$/, "") || "";
if (normalizedBase.endsWith("/messages")) {
normalizedBase = normalizedBase.slice(0, -9); // remove /messages
}
-
+
const modelsUrl = `${normalizedBase}/models`;
-
+
const res = await fetch(modelsUrl, {
- headers: {
+ headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
- "Authorization": `Bearer ${apiKey}`
+ "Authorization": `Bearer ${apiKey}`
},
});
-
+
isValid = res.ok;
return NextResponse.json({
valid: isValid,
@@ -145,8 +145,57 @@ export async function POST(request) {
break;
}
- default:
- return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
+ case "deepseek":
+ case "groq":
+ case "xai":
+ case "mistral":
+ case "perplexity":
+ case "together":
+ case "fireworks":
+ case "cerebras":
+ case "cohere":
+ case "nebius":
+ case "siliconflow":
+ case "hyperbolic":
+ case "assemblyai":
+ case "nanobanana":
+ case "chutes":
+ case "nvidia": {
+ const endpoints = {
+ deepseek: "https://api.deepseek.com/models",
+ groq: "https://api.groq.com/openai/v1/models",
+ xai: "https://api.x.ai/v1/models",
+ mistral: "https://api.mistral.ai/v1/models",
+ perplexity: "https://api.perplexity.ai/models",
+ together: "https://api.together.xyz/v1/models",
+ fireworks: "https://api.fireworks.ai/inference/v1/models",
+ cerebras: "https://api.cerebras.ai/v1/models",
+ cohere: "https://api.cohere.ai/v1/models",
+ nebius: "https://api.studio.nebius.ai/v1/models",
+ siliconflow: "https://api.siliconflow.cn/v1/models",
+ hyperbolic: "https://api.hyperbolic.xyz/v1/models",
+ assemblyai: "https://api.assemblyai.com/v1/account",
+ nanobanana: "https://api.nanobananaapi.ai/v1/models",
+ chutes: "https://llm.chutes.ai/v1/models",
+ nvidia: "https://integrate.api.nvidia.com/v1/models"
+ };
+ const res = await fetch(endpoints[provider], {
+ headers: { "Authorization": `Bearer ${apiKey}` },
+ });
+ isValid = res.ok;
+ break;
+ }
+
+ case "deepgram": {
+ const res = await fetch("https://api.deepgram.com/v1/projects", {
+ headers: { "Authorization": `Token ${apiKey}` },
+ });
+ isValid = res.ok;
+ break;
+ }
+
+ default:
+ return NextResponse.json({ error: "Provider validation not supported" }, { status: 400 });
}
} catch (err) {
error = err.message;
diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js
index 9439db0e..4794d791 100644
--- a/src/app/api/usage/[connectionId]/route.js
+++ b/src/app/api/usage/[connectionId]/route.js
@@ -7,12 +7,12 @@ import { getExecutor } from "open-sse/executors/index.js";
*/
async function refreshAndUpdateCredentials(connection) {
const executor = getExecutor(connection.provider);
-
+
// Build credentials object from connection
const credentials = {
accessToken: connection.accessToken,
refreshToken: connection.refreshToken,
- expiresAt: connection.tokenExpiresAt,
+ expiresAt: connection.expiresAt || connection.tokenExpiresAt,
providerSpecificData: connection.providerSpecificData,
// For GitHub
copilotToken: connection.providerSpecificData?.copilotToken,
@@ -21,7 +21,7 @@ async function refreshAndUpdateCredentials(connection) {
// Check if refresh is needed
const needsRefresh = executor.needsRefresh(credentials);
-
+
if (!needsRefresh) {
return { connection, refreshed: false };
}
@@ -55,9 +55,9 @@ async function refreshAndUpdateCredentials(connection) {
// Update token expiry
if (refreshResult.expiresIn) {
- updateData.tokenExpiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
+ updateData.expiresAt = new Date(Date.now() + refreshResult.expiresIn * 1000).toISOString();
} else if (refreshResult.expiresAt) {
- updateData.tokenExpiresAt = refreshResult.expiresAt;
+ updateData.expiresAt = refreshResult.expiresAt;
}
// Handle provider-specific data (copilotToken for GitHub, etc.)
@@ -77,7 +77,7 @@ async function refreshAndUpdateCredentials(connection) {
...connection,
...updateData,
};
-
+
return {
connection: updatedConnection,
refreshed: true,
@@ -90,7 +90,7 @@ async function refreshAndUpdateCredentials(connection) {
export async function GET(request, { params }) {
try {
const { connectionId } = await params;
-
+
// Get connection from database
let connection = await getProviderConnectionById(connectionId);
if (!connection) {
@@ -108,8 +108,8 @@ export async function GET(request, { params }) {
connection = result.connection;
} catch (refreshError) {
console.error("[Usage API] Credential refresh failed:", refreshError);
- return Response.json({
- error: `Credential refresh failed: ${refreshError.message}`
+ return Response.json({
+ error: `Credential refresh failed: ${refreshError.message}`
}, { status: 401 });
}
diff --git a/src/app/api/v1/models/route.js b/src/app/api/v1/models/route.js
index f29038ae..04dd33a9 100644
--- a/src/app/api/v1/models/route.js
+++ b/src/app/api/v1/models/route.js
@@ -1,4 +1,5 @@
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
+import { getProviderAlias } from "@/shared/constants/providers";
import { getProviderConnections, getCombos } from "@/lib/localDb";
/**
@@ -39,11 +40,12 @@ export async function GET() {
console.log("Could not fetch combos");
}
- // Build set of active provider aliases
- const activeAliases = new Set();
+ // Build first active connection per provider (connections already sorted by priority)
+ const activeConnectionByProvider = new Map();
for (const conn of connections) {
- const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider;
- activeAliases.add(alias);
+ if (!activeConnectionByProvider.has(conn.provider)) {
+ activeConnectionByProvider.set(conn.provider, conn);
+ }
}
// Collect models from active providers (or all if none active)
@@ -64,22 +66,68 @@ export async function GET() {
}
// Add provider models
- for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
- // If we have active providers, only include those; otherwise include all
- if (connections.length > 0 && !activeAliases.has(alias)) {
- continue;
+ if (connections.length === 0) {
+ // DB unavailable or no active providers -> return all static models
+ for (const [alias, providerModels] of Object.entries(PROVIDER_MODELS)) {
+ for (const model of providerModels) {
+ models.push({
+ id: `${alias}/${model.id}`,
+ object: "model",
+ created: timestamp,
+ owned_by: alias,
+ permission: [],
+ root: model.id,
+ parent: null,
+ });
+ }
}
+ } else {
+ for (const [providerId, conn] of activeConnectionByProvider.entries()) {
+ const staticAlias = PROVIDER_ID_TO_ALIAS[providerId] || providerId;
+ const outputAlias = getProviderAlias(providerId) || staticAlias;
+ const providerModels = PROVIDER_MODELS[staticAlias] || [];
+ const enabledModels = conn?.providerSpecificData?.enabledModels;
+ const hasExplicitEnabledModels =
+ Array.isArray(enabledModels) && enabledModels.length > 0;
- for (const model of providerModels) {
- models.push({
- id: `${alias}/${model.id}`,
- object: "model",
- created: timestamp,
- owned_by: alias,
- permission: [],
- root: model.id,
- parent: null,
- });
+ // Default: if no explicit selection, all static models are active.
+ // If explicit selection exists, expose exactly those model IDs (including non-static IDs).
+ const rawModelIds = hasExplicitEnabledModels
+ ? Array.from(
+ new Set(
+ enabledModels.filter(
+ (modelId) => typeof modelId === "string" && modelId.trim() !== "",
+ ),
+ ),
+ )
+ : providerModels.map((model) => model.id);
+
+ const modelIds = rawModelIds
+ .map((modelId) => {
+ if (modelId.startsWith(`${outputAlias}/`)) {
+ return modelId.slice(outputAlias.length + 1);
+ }
+ if (modelId.startsWith(`${staticAlias}/`)) {
+ return modelId.slice(staticAlias.length + 1);
+ }
+ if (modelId.startsWith(`${providerId}/`)) {
+ return modelId.slice(providerId.length + 1);
+ }
+ return modelId;
+ })
+ .filter((modelId) => typeof modelId === "string" && modelId.trim() !== "");
+
+ for (const modelId of modelIds) {
+ models.push({
+ id: `${outputAlias}/${modelId}`,
+ object: "model",
+ created: timestamp,
+ owned_by: outputAlias,
+ permission: [],
+ root: modelId,
+ parent: null,
+ });
+ }
}
}
diff --git a/src/app/layout.js b/src/app/layout.js
index 370a2b85..6639fa80 100644
--- a/src/app/layout.js
+++ b/src/app/layout.js
@@ -16,6 +16,10 @@ export const metadata = {
},
};
+export const viewport = {
+ themeColor: "#0a0a0a",
+};
+
export default function RootLayout({ children }) {
return (
diff --git a/src/app/manifest.js b/src/app/manifest.js
new file mode 100644
index 00000000..6ad46da7
--- /dev/null
+++ b/src/app/manifest.js
@@ -0,0 +1,30 @@
+export default function manifest() {
+ return {
+ name: '9Router - AI Infrastructure Management',
+ short_name: '9Router',
+ description: 'One endpoint for all your AI providers. Manage keys, monitor usage, and scale effortlessly.',
+ start_url: '/',
+ display: 'standalone',
+ background_color: '#0a0a0a',
+ theme_color: '#0a0a0a',
+ orientation: 'portrait-primary',
+ icons: [
+ {
+ src: '/icons/icon-192.svg',
+ sizes: '192x192',
+ type: 'image/svg+xml',
+ },
+ {
+ src: '/icons/icon-512.svg',
+ sizes: '512x512',
+ type: 'image/svg+xml',
+ },
+ {
+ src: '/icons/icon-512.svg',
+ sizes: '512x512',
+ type: 'image/svg+xml',
+ purpose: 'maskable',
+ },
+ ],
+ }
+}
diff --git a/src/lib/oauth/services/kiro.js b/src/lib/oauth/services/kiro.js
index e65b0abb..06231304 100644
--- a/src/lib/oauth/services/kiro.js
+++ b/src/lib/oauth/services/kiro.js
@@ -18,7 +18,7 @@ export class KiroService {
*/
async registerClient(region = "us-east-1") {
const endpoint = `https://oidc.${region}.amazonaws.com/client/register`;
-
+
const response = await fetch(endpoint, {
method: "POST",
headers: {
@@ -51,7 +51,7 @@ export class KiroService {
*/
async startDeviceAuthorization(clientId, clientSecret, startUrl, region = "us-east-1") {
const endpoint = `https://oidc.${region}.amazonaws.com/device_authorization`;
-
+
const response = await fetch(endpoint, {
method: "POST",
headers: {
@@ -85,7 +85,7 @@ export class KiroService {
*/
async pollDeviceToken(clientId, clientSecret, deviceCode, region = "us-east-1") {
const endpoint = `https://oidc.${region}.amazonaws.com/token`;
-
+
const response = await fetch(endpoint, {
method: "POST",
headers: {
@@ -141,7 +141,7 @@ export class KiroService {
async exchangeSocialCode(code, codeVerifier) {
// Must match the redirect_uri used in buildSocialLoginUrl
const redirectUri = "kiro://kiro.kiroAgent/authenticate-success";
-
+
const response = await fetch(`${KIRO_AUTH_SERVICE}/oauth/token`, {
method: "POST",
headers: {
@@ -177,7 +177,7 @@ export class KiroService {
// AWS SSO OIDC refresh (Builder ID or IDC)
if (clientId && clientSecret) {
const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`;
-
+
const response = await fetch(endpoint, {
method: "POST",
headers: {
@@ -253,6 +253,43 @@ export class KiroService {
}
}
+ /**
+ * List available models from CodeWhisperer API
+ */
+ async listAvailableModels(accessToken, profileArn) {
+ const endpoint = "https://codewhisperer.us-east-1.amazonaws.com";
+ const target = "AmazonCodeWhispererService.ListAvailableModels";
+
+ const response = await fetch(endpoint, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/x-amz-json-1.0",
+ "x-amz-target": target,
+ "Authorization": `Bearer ${accessToken}`,
+ "Accept": "application/json",
+ },
+ body: JSON.stringify({
+ origin: "AI_EDITOR",
+ profileArn,
+ }),
+ });
+
+ if (!response.ok) {
+ const error = await response.text();
+ throw new Error(`Failed to list models: ${error}`);
+ }
+
+ const data = await response.json();
+ return (data.models || []).map(m => ({
+ id: m.modelId,
+ name: m.modelName || m.modelId,
+ description: m.description,
+ rateMultiplier: m.rateMultiplier,
+ rateUnit: m.rateUnit,
+ maxInputTokens: m.tokenLimits?.maxInputTokens || 0,
+ }));
+ }
+
/**
* Fetch user email from access token (optional, for display)
*/
diff --git a/src/mitm/manager.js b/src/mitm/manager.js
index e1a9f6d9..d19d7a3a 100644
--- a/src/mitm/manager.js
+++ b/src/mitm/manager.js
@@ -1,4 +1,5 @@
-const { spawn, exec } = require("child_process");
+const cp = require("child_process");
+const { exec } = cp;
const path = require("path");
const fs = require("fs");
const os = require("os");
@@ -65,11 +66,11 @@ function isProcessAlive(pid) {
function killProcess(pid, force = false) {
if (IS_WIN) {
const flag = force ? "/F " : "";
- exec(`taskkill ${flag}/PID ${pid}`, () => {});
+ exec(`taskkill ${flag}/PID ${pid}`, () => { });
} else {
// Use pkill to kill entire process group (catches sudo + child node process)
const sig = force ? "SIGKILL" : "SIGTERM";
- exec(`pkill -${sig} -P ${pid} 2>/dev/null; kill -${sig} ${pid} 2>/dev/null`, () => {});
+ exec(`pkill -${sig} -P ${pid} 2>/dev/null; kill -${sig} ${pid} 2>/dev/null`, () => { });
}
}
@@ -231,9 +232,9 @@ async function killLeftoverMitm(sudoPassword) {
const escaped = SERVER_PATH.replace(/'/g, "'\\''");
if (sudoPassword) {
const { execWithPassword } = require("./dns/dnsConfig");
- await execWithPassword(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, sudoPassword).catch(() => {});
+ await execWithPassword(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, sudoPassword).catch(() => { });
} else {
- exec(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, () => {});
+ exec(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, () => { });
}
await new Promise(r => setTimeout(r, 500));
} catch { /* ignore */ }
@@ -378,7 +379,7 @@ async function startMitm(apiKey, sudoPassword) {
const certAlreadyInstalled = settings.mitmCertInstalled && fs.existsSync(certPath);
if (!certAlreadyInstalled) {
await installCert(sudoPassword, certPath);
- if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => {});
+ if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { });
}
// 3. Add DNS entry
diff --git a/src/shared/components/KiroAuthModal.js b/src/shared/components/KiroAuthModal.js
index ac499d1c..0a9133df 100644
--- a/src/shared/components/KiroAuthModal.js
+++ b/src/shared/components/KiroAuthModal.js
@@ -74,13 +74,13 @@ export default function KiroAuthModal({ isOpen, onMethodSelect, onClose }) {
});
const data = await res.json();
-
+
if (!res.ok) {
throw new Error(data.error || "Import failed");
}
- // Success - close modal
- onClose();
+ // Success - notify parent to refresh connections
+ onMethodSelect("import");
} catch (err) {
setError(err.message);
} finally {
diff --git a/src/shared/constants/providers.js b/src/shared/constants/providers.js
index 9fe0a5d3..4e780f69 100644
--- a/src/shared/constants/providers.js
+++ b/src/shared/constants/providers.js
@@ -46,6 +46,7 @@ export const APIKEY_PROVIDERS = {
deepgram: { id: "deepgram", alias: "dg", name: "Deepgram", icon: "mic", color: "#13EF93", textIcon: "DG", website: "https://deepgram.com" },
assemblyai: { id: "assemblyai", alias: "aai", name: "AssemblyAI", icon: "record_voice_over", color: "#0062FF", textIcon: "AA", website: "https://assemblyai.com" },
nanobanana: { id: "nanobanana", alias: "nb", name: "NanoBanana", icon: "image", color: "#FFD700", textIcon: "NB", website: "https://nanobananaapi.ai" },
+ chutes: { id: "chutes", alias: "ch", name: "Chutes AI", icon: "water_drop", color: "#5B6EF5", textIcon: "CH", website: "https://chutes.ai" },
};
export const OPENAI_COMPATIBLE_PREFIX = "openai-compatible-";
diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js
index 62c55cfc..230ad640 100644
--- a/src/sse/handlers/chat.js
+++ b/src/sse/handlers/chat.js
@@ -28,7 +28,7 @@ export async function handleChat(request, clientRawRequest = null) {
log.warn("CHAT", "Invalid JSON body");
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid JSON body");
}
-
+
// Build clientRawRequest for logging (if not provided)
if (!clientRawRequest) {
const url = new URL(request.url);
@@ -42,7 +42,7 @@ export async function handleChat(request, clientRawRequest = null) {
// Log request endpoint and model
const url = new URL(request.url);
const modelStr = body.model;
-
+
// Count messages (support both messages[] and input[] formats)
const msgCount = body.messages?.length || body.input?.length || 0;
const toolCount = body.tools?.length || 0;
@@ -99,7 +99,19 @@ export async function handleChat(request, clientRawRequest = null) {
*/
async function handleSingleModelChat(body, modelStr, clientRawRequest = null, request = null, apiKey = null) {
const modelInfo = await getModelInfo(modelStr);
+
+ // If provider is null, this might be a combo name - check and handle
if (!modelInfo.provider) {
+ const comboModels = await getComboModels(modelStr);
+ if (comboModels) {
+ log.info("CHAT", `Combo "${modelStr}" with ${comboModels.length} models`);
+ return handleComboChat({
+ body,
+ models: comboModels,
+ handleSingleModel: (b, m) => handleSingleModelChat(b, m, clientRawRequest, request, apiKey, forceSourceFormat),
+ log
+ });
+ }
log.warn("CHAT", "Invalid model format", { model: modelStr });
return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
}
@@ -178,12 +190,12 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
await clearAccountError(credentials.connectionId, credentials);
}
});
-
+
if (result.success) return result.response;
// Mark account unavailable (auto-calculates cooldown with exponential backoff)
const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model);
-
+
if (shouldFallback) {
log.warn("AUTH", `Account ${accountId}... unavailable (${result.status}), trying fallback`);
excludeConnectionId = credentials.connectionId;
diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js
index 275ac9b2..e12049eb 100644
--- a/src/sse/services/auth.js
+++ b/src/sse/services/auth.js
@@ -268,11 +268,11 @@ export async function markAccountUnavailable(connectionId, status, errorText, pr
export async function clearAccountError(connectionId, currentConnection) {
// Only update if currently has error status
const hasError = currentConnection.testStatus === "unavailable" ||
- currentConnection.lastError ||
- currentConnection.rateLimitedUntil;
-
+ currentConnection.lastError ||
+ currentConnection.rateLimitedUntil;
+
if (!hasError) return; // Skip if already clean
-
+
await updateProviderConnection(connectionId, {
testStatus: "active",
lastError: null,
@@ -280,17 +280,25 @@ export async function clearAccountError(connectionId, currentConnection) {
rateLimitedUntil: null,
backoffLevel: 0
});
- log.info("AUTH", `Account ${connectionId.slice(0,8)} error cleared`);
+ log.info("AUTH", `Account ${connectionId.slice(0, 8)} error cleared`);
}
/**
* Extract API key from request headers
*/
export function extractApiKey(request) {
+ // Check Authorization header first
const authHeader = request.headers.get("Authorization");
if (authHeader?.startsWith("Bearer ")) {
return authHeader.slice(7);
}
+
+ // Check Anthropic x-api-key header
+ const xApiKey = request.headers.get("x-api-key");
+ if (xApiKey) {
+ return xApiKey;
+ }
+
return null;
}
diff --git a/src/sse/services/model.js b/src/sse/services/model.js
index 4cec31da..838966bb 100644
--- a/src/sse/services/model.js
+++ b/src/sse/services/model.js
@@ -40,6 +40,15 @@ export async function getModelInfo(modelStr) {
};
}
+ // Check if this is a combo name before resolving as alias
+ // This prevents combo names from being incorrectly routed to providers
+ const combo = await getComboByName(parsed.model);
+ if (combo) {
+ // Return null provider to signal this should be handled as combo
+ // The caller (handleChat) will detect this and handle it as combo
+ return { provider: null, model: parsed.model };
+ }
+
return getModelInfoCore(modelStr, getModelAliases);
}
@@ -50,7 +59,7 @@ export async function getModelInfo(modelStr) {
export async function getComboModels(modelStr) {
// Only check if it's not in provider/model format
if (modelStr.includes("/")) return null;
-
+
const combo = await getComboByName(modelStr);
if (combo && combo.models && combo.models.length > 0) {
return combo.models;