diff --git a/open-sse/config/providers.js b/open-sse/config/providers.js index 1d060ff0..f995601e 100644 --- a/open-sse/config/providers.js +++ b/open-sse/config/providers.js @@ -96,6 +96,15 @@ export const PROVIDERS = { tokenUrl: "https://iflow.cn/oauth/token", authUrl: "https://iflow.cn/oauth" }, + qoder: { + baseUrl: "https://api.qoder.com/v1/chat/completions", + format: "openai", + headers: { "User-Agent": "Qoder-Cli" }, + clientId: process.env.QODER_OAUTH_CLIENT_ID || "10009311001", + clientSecret: process.env.QODER_OAUTH_CLIENT_SECRET || "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW", + tokenUrl: "https://api.qoder.com/oauth/token", + authUrl: "https://qoder.com/oauth/authorize" + }, antigravity: { baseUrls: [ "https://daily-cloudcode-pa.googleapis.com", diff --git a/open-sse/executors/index.js b/open-sse/executors/index.js index ac76eb77..f2be733c 100644 --- a/open-sse/executors/index.js +++ b/open-sse/executors/index.js @@ -2,6 +2,7 @@ import { AntigravityExecutor } from "./antigravity.js"; import { GeminiCLIExecutor } from "./gemini-cli.js"; import { GithubExecutor } from "./github.js"; import { IFlowExecutor } from "./iflow.js"; +import { QoderExecutor } from "./qoder.js"; import { KiroExecutor } from "./kiro.js"; import { CodexExecutor } from "./codex.js"; import { CursorExecutor } from "./cursor.js"; @@ -13,6 +14,7 @@ const executors = { "gemini-cli": new GeminiCLIExecutor(), github: new GithubExecutor(), iflow: new IFlowExecutor(), + qoder: new QoderExecutor(), kiro: new KiroExecutor(), codex: new CodexExecutor(), cursor: new CursorExecutor(), @@ -38,6 +40,7 @@ export { AntigravityExecutor } from "./antigravity.js"; export { GeminiCLIExecutor } from "./gemini-cli.js"; export { GithubExecutor } from "./github.js"; export { IFlowExecutor } from "./iflow.js"; +export { QoderExecutor } from "./qoder.js"; export { KiroExecutor } from "./kiro.js"; export { CodexExecutor } from "./codex.js"; export { CursorExecutor } from "./cursor.js"; diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js new file mode 100644 index 00000000..a64a229f --- /dev/null +++ b/open-sse/executors/qoder.js @@ -0,0 +1,73 @@ +import crypto from "crypto"; +import { BaseExecutor } from "./base.js"; +import { PROVIDERS } from "../config/providers.js"; + +/** + * QoderExecutor - Executor for Qoder API with HMAC-SHA256 signature + * Requires 3 custom headers to avoid 406 error: session-id, x-qoder-timestamp, x-qoder-signature + */ +export class QoderExecutor extends BaseExecutor { + constructor() { + super("qoder", PROVIDERS.qoder); + } + + /** + * Create Qoder signature using HMAC-SHA256 + * Formula: HMAC-SHA256(key=apiKey, message="UserAgent:sessionID:timestamp") + */ + createSignature(userAgent, sessionID, timestamp, apiKey) { + if (!apiKey) return ""; + const payload = `${userAgent}:${sessionID}:${timestamp}`; + const hmac = crypto.createHmac("sha256", apiKey); + hmac.update(payload); + return hmac.digest("hex"); + } + + /** + * Build headers with Qoder-specific signature + */ + buildHeaders(credentials, stream = true) { + const sessionID = `session-${crypto.randomUUID()}`; + const timestamp = Date.now(); + const userAgent = this.config.headers["User-Agent"] || "Qoder-Cli"; + const apiKey = credentials.apiKey || credentials.accessToken || ""; + + const signature = this.createSignature(userAgent, sessionID, timestamp, apiKey); + + const headers = { + "Content-Type": "application/json", + ...this.config.headers, + "session-id": sessionID, + "x-qoder-timestamp": timestamp.toString(), + "x-qoder-signature": signature, + }; + + if (credentials.apiKey) { + headers["Authorization"] = `Bearer ${credentials.apiKey}`; + } else if (credentials.accessToken) { + headers["Authorization"] = `Bearer ${credentials.accessToken}`; + } + + if (stream) { + headers["Accept"] = "text/event-stream"; + } + + return headers; + } + + buildUrl(model, stream, urlIndex = 0, credentials = null) { + return this.config.baseUrl; + } + + /** + * Inject stream_options for usage data on streaming requests + */ + transformRequest(model, body, stream, credentials) { + if (stream && body.messages && !body.stream_options) { + body.stream_options = { include_usage: true }; + } + return body; + } +} + +export default QoderExecutor; diff --git a/package.json b/package.json index 7944a2dc..2dc7e8b0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.66", + "version": "0.3.69", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index f64ccd45..d4d55165 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -737,7 +737,7 @@ export default function ProviderDetailPage() { {providerInfo.notice && !providerInfo.deprecated && (
info -

{providerInfo.notice.text}

+

{providerInfo.notice.text}

{providerInfo.notice.apiKeyUrl && ( { + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + state: state, + }); + return `${config.authorizeUrl}?${params.toString()}`; + }, + exchangeToken: async (config, code, redirectUri) => { + const basicAuth = Buffer.from(`${config.clientId}:${config.clientSecret}`).toString("base64"); + + const response = await fetch(config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: redirectUri, + client_id: config.clientId, + client_secret: config.clientSecret, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + }, + postExchange: async (tokens) => { + // Fetch user info (MUST succeed to get API key) + const userInfoRes = await fetch( + `${QODER_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`, + { headers: { Accept: "application/json" } } + ); + + if (!userInfoRes.ok) { + const errorText = await userInfoRes.text(); + throw new Error(`Failed to fetch user info: ${errorText}`); + } + + const result = await userInfoRes.json(); + if (!result.success) { + throw new Error(`User info request failed: ${result.message || "Unknown error"}`); + } + + const userInfo = result.data || {}; + + if (!userInfo.apiKey || userInfo.apiKey.trim() === "") { + throw new Error("Empty API key returned from Qoder"); + } + + const email = userInfo.email?.trim() || userInfo.phone?.trim(); + if (!email) { + throw new Error("Missing account email/phone in user info"); + } + + return { userInfo }; + }, + mapTokens: (tokens, extra) => ({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + apiKey: extra?.userInfo?.apiKey, + email: extra?.userInfo?.email || extra?.userInfo?.phone, + displayName: extra?.userInfo?.nickname || extra?.userInfo?.name, + }), + }, + qwen: { config: QWEN_CONFIG, flowType: "device_code", diff --git a/src/lib/oauth/services/index.js b/src/lib/oauth/services/index.js index 352762ce..33cea03c 100644 --- a/src/lib/oauth/services/index.js +++ b/src/lib/oauth/services/index.js @@ -8,6 +8,7 @@ export { CodexService } from "./codex.js"; export { GeminiCLIService } from "./gemini.js"; export { QwenService } from "./qwen.js"; export { IFlowService } from "./iflow.js"; +export { QoderService } from "./qoder.js"; export { AntigravityService } from "./antigravity.js"; export { OpenAIService } from "./openai.js"; export { GitHubService } from "./github.js"; diff --git a/src/lib/oauth/services/qoder.js b/src/lib/oauth/services/qoder.js new file mode 100644 index 00000000..39f7caf9 --- /dev/null +++ b/src/lib/oauth/services/qoder.js @@ -0,0 +1,232 @@ +import crypto from "crypto"; +import open from "open"; +import { QODER_CONFIG } from "../constants/oauth.js"; +import { getServerCredentials } from "../config/index.js"; +import { startLocalServer } from "../utils/server.js"; +import { spinner as createSpinner } from "../utils/ui.js"; + +/** + * Qoder OAuth Service + * Uses Authorization Code flow with Basic Auth + */ +export class QoderService { + constructor() { + this.config = QODER_CONFIG; + } + + /** + * Build Qoder authorization URL + */ + buildAuthUrl(redirectUri, state) { + const params = new URLSearchParams({ + client_id: this.config.clientId, + response_type: "code", + redirect_uri: redirectUri, + state: state, + }); + + return `${this.config.authorizeUrl}?${params.toString()}`; + } + + /** + * Exchange authorization code for tokens + */ + async exchangeCode(code, redirectUri) { + const basicAuth = Buffer.from( + `${this.config.clientId}:${this.config.clientSecret}` + ).toString("base64"); + + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code: code, + redirect_uri: redirectUri, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token exchange failed: ${error}`); + } + + return await response.json(); + } + + /** + * Refresh access token using refresh token + */ + async refreshToken(refreshToken) { + const basicAuth = Buffer.from( + `${this.config.clientId}:${this.config.clientSecret}` + ).toString("base64"); + + const response = await fetch(this.config.tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${basicAuth}`, + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: this.config.clientId, + client_secret: this.config.clientSecret, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Token refresh failed: ${error}`); + } + + return await response.json(); + } + + /** + * Get user info from Qoder + */ + async getUserInfo(accessToken) { + const response = await fetch( + `${this.config.userInfoUrl}?accessToken=${encodeURIComponent(accessToken)}`, + { headers: { Accept: "application/json" } } + ); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Failed to get user info: ${error}`); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error("Failed to get user info"); + } + + return result.data; + } + + /** + * Save Qoder tokens to server + */ + async saveTokens(tokens, userInfo) { + const { server, token, userId } = getServerCredentials(); + + const response = await fetch(`${server}/api/cli/providers/qoder`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + "X-User-Id": userId, + }, + body: JSON.stringify({ + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresIn: tokens.expires_in, + apiKey: userInfo.apiKey, + email: userInfo.email || userInfo.phone, + }), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || "Failed to save tokens"); + } + + return await response.json(); + } + + /** + * Refresh and update tokens on server + */ + async refreshAndSave(existingRefreshToken) { + const spinner = createSpinner("Refreshing Qoder token...").start(); + + try { + const tokens = await this.refreshToken(existingRefreshToken); + const userInfo = await this.getUserInfo(tokens.access_token); + await this.saveTokens(tokens, userInfo); + spinner.succeed("Qoder token refreshed successfully"); + return tokens; + } catch (error) { + spinner.fail(`Token refresh failed: ${error.message}`); + throw error; + } + } + + /** + * Complete Qoder OAuth flow + */ + async connect() { + const spinner = createSpinner("Starting Qoder OAuth...").start(); + + try { + spinner.text = "Starting local server..."; + + let callbackParams = null; + const { port, close } = await startLocalServer((params) => { + callbackParams = params; + }); + + const redirectUri = `http://localhost:${port}/callback`; + spinner.succeed(`Local server started on port ${port}`); + + const state = crypto.randomBytes(32).toString("base64url"); + const authUrl = this.buildAuthUrl(redirectUri, state); + + console.log("\nOpening browser for Qoder authentication..."); + console.log(`If browser doesn't open, visit:\n${authUrl}\n`); + + await open(authUrl); + + spinner.start("Waiting for Qoder authorization..."); + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Authentication timeout (5 minutes)")); + }, 300000); + + const checkInterval = setInterval(() => { + if (callbackParams) { + clearInterval(checkInterval); + clearTimeout(timeout); + resolve(); + } + }, 100); + }); + + close(); + + if (callbackParams.error) { + throw new Error(callbackParams.error_description || callbackParams.error); + } + + if (!callbackParams.code) { + throw new Error("No authorization code received"); + } + + spinner.start("Exchanging code for tokens..."); + const tokens = await this.exchangeCode(callbackParams.code, redirectUri); + + spinner.text = "Fetching user info..."; + const userInfo = await this.getUserInfo(tokens.access_token); + + spinner.text = "Saving tokens to server..."; + await this.saveTokens(tokens, userInfo); + + spinner.succeed(`Qoder connected successfully! (${userInfo.email || userInfo.phone})`); + return true; + } catch (error) { + spinner.fail(`Failed: ${error.message}`); + throw error; + } + } +} diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index 380c565d..6576b1c6 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -38,12 +38,14 @@ const getPageInfo = (pathname) => { return { title: "Providers", description: "Manage your AI provider connections", + icon: "dns", breadcrumbs: [], }; if (pathname.includes("/combos")) return { title: "Combos", description: "Model combos with fallback", + icon: "layers", breadcrumbs: [], }; if (pathname.includes("/usage")) @@ -51,48 +53,70 @@ const getPageInfo = (pathname) => { title: "Usage & Analytics", description: "Monitor your API usage, token consumption, and request logs", + icon: "bar_chart", + breadcrumbs: [], + }; + if (pathname.includes("/quota")) + return { + title: "Quota Tracker", + description: "Track and manage your API quota limits", + icon: "data_usage", breadcrumbs: [], }; if (pathname.includes("/mitm")) return { title: "MITM Proxy", description: "Intercept CLI tool traffic and route through 9Router", + icon: "security", breadcrumbs: [], }; if (pathname.includes("/cli-tools")) return { title: "CLI Tools", description: "Configure CLI tools", + icon: "terminal", + breadcrumbs: [], + }; + if (pathname.includes("/proxy-pools")) + return { + title: "Proxy Pools", + description: "Manage your proxy pool configurations", + icon: "lan", breadcrumbs: [], }; if (pathname.includes("/endpoint")) return { title: "Endpoint", description: "API endpoint configuration", + icon: "api", breadcrumbs: [], }; if (pathname.includes("/profile")) return { title: "Settings", description: "Manage your preferences", + icon: "settings", breadcrumbs: [], }; if (pathname.includes("/translator")) return { title: "Translator", description: "Debug translation flow between formats", + icon: "translate", breadcrumbs: [], }; if (pathname.includes("/console-log")) return { title: "Console Log", description: "Live server console output", + icon: "monitor", breadcrumbs: [], }; if (pathname === "/dashboard") return { title: "Endpoint", description: "API endpoint configuration", + icon: "api", breadcrumbs: [], }; return { title: "", description: "", breadcrumbs: [] }; @@ -104,7 +128,7 @@ export default function Header({ onMenuClick, showMenuButton = true }) { // Memoize page info to prevent unnecessary recalculations const pageInfo = useMemo(() => getPageInfo(pathname), [pathname]); - const { title, description, breadcrumbs } = pageInfo; + const { title, description, icon, breadcrumbs } = pageInfo; const handleLogout = async () => { try { @@ -174,9 +198,16 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
) : title ? (
-

- {translate(title)} -

+
+ {icon && ( + + {icon} + + )} +

+ {translate(title)} +

+
{description && (

{translate(description)} diff --git a/src/shared/components/UsageStats.js b/src/shared/components/UsageStats.js index ee92194b..eb65258d 100644 --- a/src/shared/components/UsageStats.js +++ b/src/shared/components/UsageStats.js @@ -191,6 +191,7 @@ export default function UsageStats() { const [loading, setLoading] = useState(true); const [fetching, setFetching] = useState(false); const [tableView, setTableView] = useState("model"); + const [viewMode, setViewMode] = useState("costs"); const [providers, setProviders] = useState([]); const [period, setPeriod] = useState("7d"); @@ -236,14 +237,14 @@ export default function UsageStats() { es.onmessage = (e) => { try { const data = JSON.parse(e.data); - // Only update real-time fields from SSE, keep filtered stats intact - setStats((prev) => prev ? { - ...prev, + // Always merge only real-time fields, never overwrite full stats from REST + setStats((prev) => ({ + ...(prev || {}), activeRequests: data.activeRequests, recentRequests: data.recentRequests, errorProvider: data.errorProvider, pending: data.pending, - } : data); + })); setLoading(false); } catch (err) { console.error("[SSE CLIENT] parse error:", err); @@ -443,6 +444,20 @@ export default function UsageStats() { ))} +

+ + +
{loading ? spinner : activeTableConfig && (