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 && (
) : title ? ({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() { ))} +