mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
refactor(app): DRY pass — split large files, extract shared utils
S1: delete page.new.js (1724L abandoned) + remove dead getAntigravityProjectId
S2: split large files by natural seams
- usage.js → usage/{github,google,claude,codex,kiro,minimax,misc,shared}.js
- media-providers page → components/{Embedding,Tts,Generic,Stt}ExampleCard.js
- EndpointPageClient → endpointConstants.js + endpointPing.js + components/
- tokenRefresh.js → tokenRefresh/{dedup,providers}.js
- ProviderLimits/index.js: 16 pure fn + 9 constants → utils.js
- oauth/providers.js: 7 pure helpers → providerHelpers.js
S3: shared utils
- getModelKind(m, fallback) → shared/constants/models.js (replaces 20× m.kind||m.type)
- getStatusVariant → shared/utils/connectionStatus.js (dedup ConnectionRow/ConnectionsCard)
- sseChunk → open-sse/utils/sse.js (dedup grok-web/perplexity-web)
- fetchWithTimeout → usage/shared.js (replace 4× AbortController pattern in google.js)
fix: enableObservability2 field name in requestDetailsRepo
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { SSE_DONE, SSE_HEADERS_NO_BUFFER } from "../utils/sseConstants.js";
|
||||
import { sseChunk } from "../utils/sse.js";
|
||||
|
||||
const GROK_CHAT_API = PROVIDERS["grok-web"].baseUrl;
|
||||
const GROK_USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
|
||||
@@ -131,10 +132,6 @@ async function* extractContent(eventStream, isThinkingModel, signal) {
|
||||
yield { done: true, fingerprint, responseId };
|
||||
}
|
||||
|
||||
function sseChunk(data) {
|
||||
return `data: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function buildStreamingResponse(eventStream, model, cid, created, isThinkingModel, signal) {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BaseExecutor } from "./base.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { SSE_DONE, SSE_HEADERS_NO_BUFFER } from "../utils/sseConstants.js";
|
||||
import { sseChunk } from "../utils/sse.js";
|
||||
|
||||
const PPLX_SSE_ENDPOINT = PROVIDERS["perplexity-web"].baseUrl;
|
||||
const PPLX_API_VERSION = "2.18";
|
||||
@@ -290,10 +291,6 @@ async function* extractContent(eventStream, signal) {
|
||||
yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true };
|
||||
}
|
||||
|
||||
function sseChunk(data) {
|
||||
return `data: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
|
||||
function buildStreamingResponse(eventStream, model, cid, created, history, currentMsg, signal) {
|
||||
const encoder = new TextEncoder();
|
||||
return new ReadableStream({
|
||||
|
||||
@@ -1,73 +1,35 @@
|
||||
import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js";
|
||||
import { OAUTH_ENDPOINTS, GITHUB_COPILOT, REFRESH_LEAD_MS } from "../config/appConstants.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import { PROVIDERS } from "../config/providers.js";
|
||||
import { OAUTH_ENDPOINTS, REFRESH_LEAD_MS } from "../config/appConstants.js";
|
||||
import {
|
||||
refreshXaiToken,
|
||||
refreshAccessToken,
|
||||
refreshClaudeOAuthToken,
|
||||
refreshGoogleToken,
|
||||
refreshQwenToken,
|
||||
refreshCodexToken,
|
||||
refreshKiroToken,
|
||||
refreshIflowToken,
|
||||
refreshGitHubToken,
|
||||
refreshCopilotToken,
|
||||
classifyOAuthRefreshError,
|
||||
} from "./tokenRefresh/providers.js";
|
||||
|
||||
// xAI refresh — wraps the class method from src/lib/oauth/services/xai.js so
|
||||
// the token-refresh switches below can stay flat (one function per provider).
|
||||
let _xaiServiceSingleton = null;
|
||||
async function refreshXaiToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("xai", refreshToken, async () => {
|
||||
try {
|
||||
if (!_xaiServiceSingleton) {
|
||||
const mod = await import("../../src/lib/oauth/services/xai.js");
|
||||
_xaiServiceSingleton = new mod.XaiService();
|
||||
}
|
||||
const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken);
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
idToken: tokens.id_token,
|
||||
};
|
||||
} catch (e) {
|
||||
log?.warn?.("TOKEN_REFRESH", `xai refresh failed: ${e?.message || e}`);
|
||||
const msg = String(e?.message || "");
|
||||
if (msg.includes("invalid_grant") || msg.includes("invalid_request")) {
|
||||
return { error: "invalid_grant" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
// Re-export all provider refresh functions (preserves public API for all consumers)
|
||||
export {
|
||||
refreshAccessToken,
|
||||
refreshClaudeOAuthToken,
|
||||
refreshGoogleToken,
|
||||
refreshQwenToken,
|
||||
refreshCodexToken,
|
||||
refreshKiroToken,
|
||||
refreshIflowToken,
|
||||
refreshGitHubToken,
|
||||
refreshCopilotToken,
|
||||
classifyOAuthRefreshError,
|
||||
};
|
||||
|
||||
// Default token expiry buffer (refresh if expires within 5 minutes)
|
||||
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
// Dedup: cache in-flight promise + recent result to prevent refresh_token_reused (Auth0 family revoke)
|
||||
const REFRESH_RESULT_TTL_MS = 10_000;
|
||||
const refreshDedupCache = new Map();
|
||||
|
||||
async function dedupRefresh(provider, oldToken, fn, log) {
|
||||
if (!oldToken) return fn();
|
||||
const key = `${provider}:${oldToken}`;
|
||||
const hit = refreshDedupCache.get(key);
|
||||
if (hit) {
|
||||
if (hit.promise) {
|
||||
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
|
||||
return hit.promise;
|
||||
}
|
||||
if (hit.expiresAt > Date.now()) {
|
||||
log?.info?.("TOKEN_REFRESH", `Reusing recent refresh result for ${provider}`);
|
||||
return hit.result;
|
||||
}
|
||||
refreshDedupCache.delete(key);
|
||||
}
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await fn();
|
||||
refreshDedupCache.set(key, { result, expiresAt: Date.now() + REFRESH_RESULT_TTL_MS });
|
||||
return result;
|
||||
} catch (err) {
|
||||
refreshDedupCache.delete(key);
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
refreshDedupCache.set(key, { promise });
|
||||
return promise;
|
||||
}
|
||||
|
||||
// Check if refresh result indicates unrecoverable error (caller should stop retry, force re-auth)
|
||||
export function isUnrecoverableRefreshError(result) {
|
||||
return (
|
||||
result &&
|
||||
@@ -79,543 +41,82 @@ export function isUnrecoverableRefreshError(result) {
|
||||
);
|
||||
}
|
||||
|
||||
// Get provider-specific refresh lead time, falls back to default buffer
|
||||
export function getRefreshLeadMs(provider) {
|
||||
return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh OAuth access token using refresh token
|
||||
*/
|
||||
export async function refreshAccessToken(provider, refreshToken, credentials, log) {
|
||||
const config = PROVIDERS[provider];
|
||||
|
||||
if (!config || !config.refreshUrl) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return dedupRefresh(provider, refreshToken, async () => {
|
||||
export function parseVertexSaJson(apiKey) {
|
||||
if (typeof apiKey !== "string") return null;
|
||||
try {
|
||||
const response = await fetch(config.refreshUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", `Failed to refresh token for ${provider}`, {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
const parsed = JSON.parse(apiKey);
|
||||
if (parsed.type === "service_account" && parsed.client_email && parsed.private_key && parsed.project_id) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", `Successfully refreshed token for ${provider}`, {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
|
||||
error: error.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Claude OAuth tokens
|
||||
*/
|
||||
export async function refreshClaudeOAuthToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("claude", refreshToken, async () => {
|
||||
try {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.claude.clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText });
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in });
|
||||
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Google providers (Gemini, Antigravity)
|
||||
*/
|
||||
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh(`google:${clientId}`, refreshToken, async () => {
|
||||
try {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", { status: response.status, error: errorText });
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in });
|
||||
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Google token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Qwen OAuth tokens
|
||||
*/
|
||||
export async function refreshQwenToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("qwen", refreshToken, async () => {
|
||||
const endpoint = OAUTH_ENDPOINTS.qwen.token;
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.qwen.clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: tokens.resource_url
|
||||
? { resourceUrl: tokens.resource_url }
|
||||
: undefined,
|
||||
};
|
||||
} else {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
|
||||
return null;
|
||||
}, log);
|
||||
}
|
||||
|
||||
export function classifyOAuthRefreshError(errorText = "", status = 0) {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = errorText ? JSON.parse(errorText) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
const code = parsed?.error?.code || parsed?.error || parsed?.error_code || "";
|
||||
const description = parsed?.error_description || parsed?.message || errorText || "";
|
||||
const combined = `${code} ${description}`.toLowerCase();
|
||||
const permanent = [
|
||||
"refresh_token_expired",
|
||||
"refresh_token_reused",
|
||||
"refresh_token_invalidated",
|
||||
"invalid_grant",
|
||||
].some((marker) => combined.includes(marker));
|
||||
|
||||
return { status, code, description, permanent };
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Codex (OpenAI) OAuth tokens.
|
||||
* OpenAI uses rotating (one-time-use) refresh tokens.
|
||||
* Returns { error: 'unrecoverable_refresh_error' } when token already consumed/invalid,
|
||||
* so callers stop retrying and request re-authentication.
|
||||
*/
|
||||
export async function refreshCodexToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("codex", refreshToken, async () => {
|
||||
try {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: PROVIDERS.codex.clientId,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
const failure = classifyOAuthRefreshError(errorText, response.status);
|
||||
if (failure.permanent) {
|
||||
log?.error?.("TOKEN_REFRESH", "Codex refresh token already used or invalid. Re-auth required.", {
|
||||
status: response.status,
|
||||
code: failure.code,
|
||||
});
|
||||
return { error: "unrecoverable_refresh_error", code: failure.code };
|
||||
}
|
||||
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
code: failure.code,
|
||||
permanent: failure.permanent,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
hasIdToken: !!tokens.id_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
idToken: tokens.id_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for Kiro (AWS CodeWhisperer) tokens
|
||||
* Supports both AWS SSO OIDC (Builder ID/IDC) and Social Auth (Google/GitHub)
|
||||
*/
|
||||
// Backfill missing Kiro profileArn on refresh so existing IDC connections self-heal
|
||||
async function resolveKiroProfileArnPatch(providerSpecificData, accessToken, refreshedArn) {
|
||||
if (providerSpecificData?.profileArn) return {};
|
||||
let profileArn = refreshedArn?.trim?.() || null;
|
||||
if (!profileArn) {
|
||||
const { fetchKiroProfileArn } = await import("../../src/lib/oauth/providers.js");
|
||||
profileArn = await fetchKiroProfileArn(accessToken);
|
||||
}
|
||||
return profileArn ? { providerSpecificData: { profileArn } } : {};
|
||||
}
|
||||
|
||||
export async function refreshKiroToken(refreshToken, providerSpecificData, log, proxyOptions = null) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("kiro", refreshToken, async () => {
|
||||
const authMethod = providerSpecificData?.authMethod;
|
||||
const clientId = providerSpecificData?.clientId;
|
||||
const clientSecret = providerSpecificData?.clientSecret;
|
||||
const region = providerSpecificData?.region;
|
||||
|
||||
// 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 response = await proxyAwareFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
refreshToken: refreshToken,
|
||||
grantType: "refresh_token",
|
||||
}),
|
||||
}, proxyOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", {
|
||||
hasNewAccessToken: !!tokens.accessToken,
|
||||
expiresIn: tokens.expiresIn,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken || refreshToken,
|
||||
expiresIn: tokens.expiresIn,
|
||||
...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)),
|
||||
};
|
||||
}
|
||||
|
||||
// Social Auth (Google/GitHub) - use Kiro's refresh endpoint
|
||||
const response = await proxyAwareFetch(PROVIDERS.kiro.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "kiro-cli/1.0.0",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refreshToken: refreshToken,
|
||||
}),
|
||||
}, proxyOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", {
|
||||
hasNewAccessToken: !!tokens.accessToken,
|
||||
expiresIn: tokens.expiresIn,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken || refreshToken,
|
||||
expiresIn: tokens.expiresIn,
|
||||
...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)),
|
||||
};
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for iFlow OAuth tokens
|
||||
*/
|
||||
export async function refreshIflowToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("iflow", refreshToken, async () => {
|
||||
const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`);
|
||||
// Cache Vertex tokens keyed by service account email { token, expiresAt }
|
||||
const vertexTokenCache = new Map();
|
||||
|
||||
const response = await fetch(OAUTH_ENDPOINTS.iflow.token, {
|
||||
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: PROVIDERS.iflow.clientId,
|
||||
client_secret: PROVIDERS.iflow.clientSecret,
|
||||
}),
|
||||
});
|
||||
export async function refreshVertexToken(saJson, log) {
|
||||
const cacheKey = saJson.client_email;
|
||||
const cached = vertexTokenCache.get(cacheKey);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
if (cached && cached.expiresAt - Date.now() > 5 * 60 * 1000) {
|
||||
return { accessToken: cached.token, expiresAt: cached.expiresAt };
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specialized refresh for GitHub Copilot OAuth tokens
|
||||
*/
|
||||
export async function refreshGitHubToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("github", refreshToken, async () => {
|
||||
const params = {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.github.clientId,
|
||||
};
|
||||
if (PROVIDERS.github.clientSecret) {
|
||||
params.client_secret = PROVIDERS.github.clientSecret;
|
||||
}
|
||||
|
||||
const response = await fetch(OAUTH_ENDPOINTS.github.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
}, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh GitHub Copilot token using GitHub access token
|
||||
*/
|
||||
export async function refreshCopilotToken(githubAccessToken, log) {
|
||||
if (!githubAccessToken) return null;
|
||||
return dedupRefresh("copilot", githubAccessToken, async () => {
|
||||
try {
|
||||
const response = await fetch(PROVIDER_OAUTH["github"]?.copilotTokenUrl, {
|
||||
headers: {
|
||||
"Authorization": `token ${githubAccessToken}`,
|
||||
"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
|
||||
}
|
||||
const { SignJWT, importPKCS8 } = await import("jose");
|
||||
log?.debug?.("TOKEN_REFRESH", `Vertex minting token for ${saJson.client_email}`);
|
||||
const privateKey = await importPKCS8(saJson.private_key.replace(/\\n/g, "\n"), "RS256");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const jwt = await new SignJWT({ scope: "https://www.googleapis.com/auth/cloud-platform" })
|
||||
.setProtectedHeader({ alg: "RS256" })
|
||||
.setIssuer(saJson.client_email)
|
||||
.setAudience(OAUTH_ENDPOINTS.google.token)
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 3600)
|
||||
.sign(privateKey);
|
||||
|
||||
const res = await fetch(OAUTH_ENDPOINTS.google.token, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion: jwt,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
|
||||
status: response.status,
|
||||
error: errorText
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log?.error?.("TOKEN_REFRESH", `Vertex token mint failed: ${err}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const { access_token, expires_in } = await res.json();
|
||||
const expiresAt = Date.now() + (expires_in ?? 3600) * 1000;
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", {
|
||||
hasToken: !!data.token,
|
||||
expiresAt: data.expires_at
|
||||
});
|
||||
vertexTokenCache.set(cacheKey, { token: access_token, expiresAt });
|
||||
log?.info?.("TOKEN_REFRESH", `Vertex token minted for ${saJson.client_email}`);
|
||||
|
||||
return {
|
||||
token: data.token,
|
||||
expiresAt: data.expires_at
|
||||
};
|
||||
return { accessToken: access_token, expiresAt };
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
|
||||
error: error.message
|
||||
});
|
||||
log?.error?.("TOKEN_REFRESH", `Vertex token error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
// Single source of per-provider refresh dispatch (logic stays in each refreshXxx fn).
|
||||
// Each handler: (credentials, log) => Promise<tokens|null>
|
||||
function vertexRefreshHandler(c, log) {
|
||||
const saJson = parseVertexSaJson(c.apiKey);
|
||||
if (!saJson) return null;
|
||||
return refreshVertexToken(saJson, log);
|
||||
}
|
||||
|
||||
const REFRESH_HANDLERS = {
|
||||
"gemini-cli": (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS["gemini-cli"].clientId, PROVIDERS["gemini-cli"].clientSecret, log),
|
||||
antigravity: (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS.antigravity.clientId, PROVIDERS.antigravity.clientSecret, log),
|
||||
@@ -630,28 +131,15 @@ const REFRESH_HANDLERS = {
|
||||
"vertex-partner": vertexRefreshHandler
|
||||
};
|
||||
|
||||
function vertexRefreshHandler(c, log) {
|
||||
const saJson = parseVertexSaJson(c.apiKey);
|
||||
if (!saJson) return null;
|
||||
return refreshVertexToken(saJson, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token for a specific provider (with in-flight dedup).
|
||||
* If a refresh is already in-flight for same provider+token, share the promise
|
||||
* to prevent parallel OAuth requests → Auth0 'refresh_token_reused' family revoke.
|
||||
*/
|
||||
export async function getAccessToken(provider, credentials, log) {
|
||||
if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") {
|
||||
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
// Dedup is handled inside each refreshXxxToken function
|
||||
return _getAccessTokenInternal(provider, credentials, log);
|
||||
}
|
||||
|
||||
async function _getAccessTokenInternal(provider, credentials, log) {
|
||||
// "gemini" shares Google refresh here (unlike refreshTokenByProvider)
|
||||
if (provider === "gemini") {
|
||||
return refreshGoogleToken(credentials.refreshToken, PROVIDERS.gemini.clientId, PROVIDERS.gemini.clientSecret, log);
|
||||
}
|
||||
@@ -663,19 +151,12 @@ async function _getAccessTokenInternal(provider, credentials, log) {
|
||||
return handler(credentials, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token by provider type (helper for handlers)
|
||||
*/
|
||||
export async function refreshTokenByProvider(provider, credentials, log) {
|
||||
if (!credentials.refreshToken) return null;
|
||||
const handler = REFRESH_HANDLERS[provider];
|
||||
// default: generic refresh (note: "gemini" is NOT special-cased here, by design)
|
||||
return handler ? handler(credentials, log) : refreshAccessToken(provider, credentials.refreshToken, credentials, log);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format credentials for provider
|
||||
*/
|
||||
export function formatProviderCredentials(provider, credentials, log) {
|
||||
const config = PROVIDERS[provider];
|
||||
if (!config) {
|
||||
@@ -725,9 +206,6 @@ export function formatProviderCredentials(provider, credentials, log) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all access tokens for a user
|
||||
*/
|
||||
export async function getAllAccessTokens(userInfo, log) {
|
||||
const results = {};
|
||||
|
||||
@@ -748,89 +226,6 @@ export async function getAllAccessTokens(userInfo, log) {
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Vertex AI Service Account JSON from apiKey string
|
||||
*/
|
||||
export function parseVertexSaJson(apiKey) {
|
||||
if (typeof apiKey !== "string") return null;
|
||||
try {
|
||||
const parsed = JSON.parse(apiKey);
|
||||
if (parsed.type === "service_account" && parsed.client_email && parsed.private_key && parsed.project_id) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Cache Vertex tokens keyed by service account email { token, expiresAt }
|
||||
const vertexTokenCache = new Map();
|
||||
|
||||
/**
|
||||
* Mint a short-lived OAuth2 Bearer token for Google Cloud Vertex AI
|
||||
* using Service Account JSON + jose (RS256 JWT assertion flow).
|
||||
* Token is cached until 5 minutes before expiry.
|
||||
*/
|
||||
export async function refreshVertexToken(saJson, log) {
|
||||
const cacheKey = saJson.client_email;
|
||||
const cached = vertexTokenCache.get(cacheKey);
|
||||
|
||||
// Return cached token if still valid (5-min buffer)
|
||||
if (cached && cached.expiresAt - Date.now() > 5 * 60 * 1000) {
|
||||
return { accessToken: cached.token, expiresAt: cached.expiresAt };
|
||||
}
|
||||
|
||||
try {
|
||||
const { SignJWT, importPKCS8 } = await import("jose");
|
||||
log?.debug?.("TOKEN_REFRESH", `Vertex minting token for ${saJson.client_email}`);
|
||||
const privateKey = await importPKCS8(saJson.private_key.replace(/\\n/g, "\n"), "RS256");
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const jwt = await new SignJWT({ scope: "https://www.googleapis.com/auth/cloud-platform" })
|
||||
.setProtectedHeader({ alg: "RS256" })
|
||||
.setIssuer(saJson.client_email)
|
||||
.setAudience(OAUTH_ENDPOINTS.google.token)
|
||||
.setIssuedAt(now)
|
||||
.setExpirationTime(now + 3600)
|
||||
.sign(privateKey);
|
||||
|
||||
const res = await fetch(OAUTH_ENDPOINTS.google.token, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion: jwt,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text();
|
||||
log?.error?.("TOKEN_REFRESH", `Vertex token mint failed: ${err}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const { access_token, expires_in } = await res.json();
|
||||
const expiresAt = Date.now() + (expires_in ?? 3600) * 1000;
|
||||
|
||||
vertexTokenCache.set(cacheKey, { token: access_token, expiresAt });
|
||||
log?.info?.("TOKEN_REFRESH", `Vertex token minted for ${saJson.client_email}`);
|
||||
|
||||
return { accessToken: access_token, expiresAt };
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Vertex token error: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh token with retry and exponential backoff
|
||||
* Retries on failure with increasing delay: 1s, 2s, 3s...
|
||||
* @param {function} refreshFn - Async function that returns token or null
|
||||
* @param {number} maxRetries - Max retry attempts (default 3)
|
||||
* @param {object} log - Logger instance (optional)
|
||||
* @returns {Promise<object|null>} Token result or null if all retries fail
|
||||
*/
|
||||
export async function refreshWithRetry(refreshFn, maxRetries = 3, log = null) {
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
if (attempt > 0) {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const REFRESH_RESULT_TTL_MS = 10_000;
|
||||
const refreshDedupCache = new Map();
|
||||
|
||||
export async function dedupRefresh(provider, oldToken, fn, log) {
|
||||
if (!oldToken) return fn();
|
||||
const key = `${provider}:${oldToken}`;
|
||||
const hit = refreshDedupCache.get(key);
|
||||
if (hit) {
|
||||
if (hit.promise) {
|
||||
log?.info?.("TOKEN_REFRESH", `Reusing in-flight refresh for ${provider}`);
|
||||
return hit.promise;
|
||||
}
|
||||
if (hit.expiresAt > Date.now()) {
|
||||
log?.info?.("TOKEN_REFRESH", `Reusing recent refresh result for ${provider}`);
|
||||
return hit.result;
|
||||
}
|
||||
refreshDedupCache.delete(key);
|
||||
}
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const result = await fn();
|
||||
refreshDedupCache.set(key, { result, expiresAt: Date.now() + REFRESH_RESULT_TTL_MS });
|
||||
return result;
|
||||
} catch (err) {
|
||||
refreshDedupCache.delete(key);
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
refreshDedupCache.set(key, { promise });
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
import { PROVIDERS, PROVIDER_OAUTH } from "../../config/providers.js";
|
||||
import { OAUTH_ENDPOINTS, GITHUB_COPILOT } from "../../config/appConstants.js";
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { dedupRefresh } from "./dedup.js";
|
||||
|
||||
let _xaiServiceSingleton = null;
|
||||
export async function refreshXaiToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("xai", refreshToken, async () => {
|
||||
try {
|
||||
if (!_xaiServiceSingleton) {
|
||||
const mod = await import("../../../src/lib/oauth/services/xai.js");
|
||||
_xaiServiceSingleton = new mod.XaiService();
|
||||
}
|
||||
const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken);
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
idToken: tokens.id_token,
|
||||
};
|
||||
} catch (e) {
|
||||
log?.warn?.("TOKEN_REFRESH", `xai refresh failed: ${e?.message || e}`);
|
||||
const msg = String(e?.message || "");
|
||||
if (msg.includes("invalid_grant") || msg.includes("invalid_request")) {
|
||||
return { error: "invalid_grant" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(provider, refreshToken, credentials, log) {
|
||||
const config = PROVIDERS[provider];
|
||||
|
||||
if (!config || !config.refreshUrl) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No refresh URL configured for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
log?.warn?.("TOKEN_REFRESH", `No refresh token available for provider: ${provider}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return dedupRefresh(provider, refreshToken, async () => {
|
||||
try {
|
||||
const response = await fetch(config.refreshUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: config.clientId,
|
||||
client_secret: config.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", `Failed to refresh token for ${provider}`, {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", `Successfully refreshed token for ${provider}`, {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Error refreshing token for ${provider}`, {
|
||||
error: error.message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
export async function refreshClaudeOAuthToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("claude", refreshToken, async () => {
|
||||
try {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.anthropic.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.claude.clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Claude OAuth token", { status: response.status, error: errorText });
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Claude OAuth token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in });
|
||||
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Claude token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
export async function refreshGoogleToken(refreshToken, clientId, clientSecret, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh(`google:${clientId}`, refreshToken, async () => {
|
||||
try {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.google.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Google token", { status: response.status, error: errorText });
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Google token", { hasNewAccessToken: !!tokens.access_token, expiresIn: tokens.expires_in });
|
||||
return { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in };
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Google token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
export async function refreshQwenToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("qwen", refreshToken, async () => {
|
||||
const endpoint = OAUTH_ENDPOINTS.qwen.token;
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.qwen.clientId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Qwen token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
providerSpecificData: tokens.resource_url
|
||||
? { resourceUrl: tokens.resource_url }
|
||||
: undefined,
|
||||
};
|
||||
} else {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
log?.warn?.("TOKEN_REFRESH", `Error with Qwen endpoint`, {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log?.warn?.("TOKEN_REFRESH", `Network error trying Qwen endpoint`, {
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Qwen token");
|
||||
return null;
|
||||
}, log);
|
||||
}
|
||||
|
||||
export function classifyOAuthRefreshError(errorText = "", status = 0) {
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = errorText ? JSON.parse(errorText) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
|
||||
const code = parsed?.error?.code || parsed?.error || parsed?.error_code || "";
|
||||
const description = parsed?.error_description || parsed?.message || errorText || "";
|
||||
const combined = `${code} ${description}`.toLowerCase();
|
||||
const permanent = [
|
||||
"refresh_token_expired",
|
||||
"refresh_token_reused",
|
||||
"refresh_token_invalidated",
|
||||
"invalid_grant",
|
||||
].some((marker) => combined.includes(marker));
|
||||
|
||||
return { status, code, description, permanent };
|
||||
}
|
||||
|
||||
export async function refreshCodexToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("codex", refreshToken, async () => {
|
||||
try {
|
||||
const response = await fetch(OAUTH_ENDPOINTS.openai.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: PROVIDERS.codex.clientId,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
const failure = classifyOAuthRefreshError(errorText, response.status);
|
||||
if (failure.permanent) {
|
||||
log?.error?.("TOKEN_REFRESH", "Codex refresh token already used or invalid. Re-auth required.", {
|
||||
status: response.status,
|
||||
code: failure.code,
|
||||
});
|
||||
return { error: "unrecoverable_refresh_error", code: failure.code };
|
||||
}
|
||||
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Codex token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
code: failure.code,
|
||||
permanent: failure.permanent,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Codex token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
hasIdToken: !!tokens.id_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
idToken: tokens.id_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", `Network error refreshing Codex token: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
|
||||
async function resolveKiroProfileArnPatch(providerSpecificData, accessToken, refreshedArn) {
|
||||
if (providerSpecificData?.profileArn) return {};
|
||||
let profileArn = refreshedArn?.trim?.() || null;
|
||||
if (!profileArn) {
|
||||
const { fetchKiroProfileArn } = await import("../../../src/lib/oauth/providers.js");
|
||||
profileArn = await fetchKiroProfileArn(accessToken);
|
||||
}
|
||||
return profileArn ? { providerSpecificData: { profileArn } } : {};
|
||||
}
|
||||
|
||||
export async function refreshKiroToken(refreshToken, providerSpecificData, log, proxyOptions = null) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("kiro", refreshToken, async () => {
|
||||
const authMethod = providerSpecificData?.authMethod;
|
||||
const clientId = providerSpecificData?.clientId;
|
||||
const clientSecret = providerSpecificData?.clientSecret;
|
||||
const region = providerSpecificData?.region;
|
||||
|
||||
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 response = await proxyAwareFetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
clientId: clientId,
|
||||
clientSecret: clientSecret,
|
||||
refreshToken: refreshToken,
|
||||
grantType: "refresh_token",
|
||||
}),
|
||||
}, proxyOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro AWS token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro AWS token", {
|
||||
hasNewAccessToken: !!tokens.accessToken,
|
||||
expiresIn: tokens.expiresIn,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken || refreshToken,
|
||||
expiresIn: tokens.expiresIn,
|
||||
...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await proxyAwareFetch(PROVIDERS.kiro.tokenUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
"User-Agent": "kiro-cli/1.0.0",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refreshToken: refreshToken,
|
||||
}),
|
||||
}, proxyOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Kiro social token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Kiro social token", {
|
||||
hasNewAccessToken: !!tokens.accessToken,
|
||||
expiresIn: tokens.expiresIn,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.accessToken,
|
||||
refreshToken: tokens.refreshToken || refreshToken,
|
||||
expiresIn: tokens.expiresIn,
|
||||
...(await resolveKiroProfileArnPatch(providerSpecificData, tokens.accessToken, tokens.profileArn)),
|
||||
};
|
||||
}, log);
|
||||
}
|
||||
|
||||
export async function refreshIflowToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("iflow", refreshToken, async () => {
|
||||
const basicAuth = btoa(`${PROVIDERS.iflow.clientId}:${PROVIDERS.iflow.clientSecret}`);
|
||||
|
||||
const response = await fetch(OAUTH_ENDPOINTS.iflow.token, {
|
||||
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: PROVIDERS.iflow.clientId,
|
||||
client_secret: PROVIDERS.iflow.clientSecret,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh iFlow token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed iFlow token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
}, log);
|
||||
}
|
||||
|
||||
export async function refreshGitHubToken(refreshToken, log) {
|
||||
if (!refreshToken) return null;
|
||||
return dedupRefresh("github", refreshToken, async () => {
|
||||
const params = {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: PROVIDERS.github.clientId,
|
||||
};
|
||||
if (PROVIDERS.github.clientSecret) {
|
||||
params.client_secret = PROVIDERS.github.clientSecret;
|
||||
}
|
||||
|
||||
const response = await fetch(OAUTH_ENDPOINTS.github.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: new URLSearchParams(params),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh GitHub token", {
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokens = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed GitHub token", {
|
||||
hasNewAccessToken: !!tokens.access_token,
|
||||
hasNewRefreshToken: !!tokens.refresh_token,
|
||||
expiresIn: tokens.expires_in,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken: tokens.access_token,
|
||||
refreshToken: tokens.refresh_token || refreshToken,
|
||||
expiresIn: tokens.expires_in,
|
||||
};
|
||||
}, log);
|
||||
}
|
||||
|
||||
export async function refreshCopilotToken(githubAccessToken, log) {
|
||||
if (!githubAccessToken) return null;
|
||||
return dedupRefresh("copilot", githubAccessToken, async () => {
|
||||
try {
|
||||
const response = await fetch(PROVIDER_OAUTH["github"]?.copilotTokenUrl, {
|
||||
headers: {
|
||||
"Authorization": `token ${githubAccessToken}`,
|
||||
"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) {
|
||||
const errorText = await response.text();
|
||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
|
||||
status: response.status,
|
||||
error: errorText
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
log?.info?.("TOKEN_REFRESH", "Successfully refreshed Copilot token", {
|
||||
hasToken: !!data.token,
|
||||
expiresAt: data.expires_at
|
||||
});
|
||||
|
||||
return {
|
||||
token: data.token,
|
||||
expiresAt: data.expires_at
|
||||
};
|
||||
} catch (error) {
|
||||
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
|
||||
error: error.message
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}, log);
|
||||
}
|
||||
+14
-1301
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Claude usage handler
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { ANTHROPIC_API_VERSION } from "../../providers/shared.js";
|
||||
import { U, parseResetTime } from "./shared.js";
|
||||
|
||||
// Claude API config (urls from registry, apiVersion is header logic kept here)
|
||||
const CLAUDE_CONFIG = {
|
||||
oauthUsageUrl: U("claude").oauthUrl,
|
||||
usageUrl: U("claude").orgUrl,
|
||||
settingsUrl: U("claude").settingsUrl,
|
||||
apiVersion: ANTHROPIC_API_VERSION,
|
||||
};
|
||||
|
||||
/**
|
||||
* Claude Usage - Primary: OAuth endpoint, Fallback: legacy settings/org endpoint
|
||||
*/
|
||||
export async function getClaudeUsage(accessToken, proxyOptions = null) {
|
||||
try {
|
||||
// Primary: OAuth usage endpoint (Claude Code consumer OAuth tokens)
|
||||
const oauthResponse = await proxyAwareFetch(CLAUDE_CONFIG.oauthUsageUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
}, proxyOptions);
|
||||
|
||||
if (oauthResponse.ok) {
|
||||
const data = await oauthResponse.json();
|
||||
const quotas = {};
|
||||
|
||||
// utilization = % USED (e.g. 87 means 87% used, 13% remaining)
|
||||
const hasUtilization = (window) =>
|
||||
window && typeof window === "object" && typeof window.utilization === "number";
|
||||
|
||||
const createQuotaObject = (window) => {
|
||||
const used = window.utilization;
|
||||
const remaining = Math.max(0, 100 - used);
|
||||
return {
|
||||
used,
|
||||
total: 100,
|
||||
remaining,
|
||||
remainingPercentage: remaining,
|
||||
resetAt: parseResetTime(window.resets_at),
|
||||
unlimited: false,
|
||||
};
|
||||
};
|
||||
|
||||
if (hasUtilization(data.five_hour)) {
|
||||
quotas["session (5h)"] = createQuotaObject(data.five_hour);
|
||||
}
|
||||
|
||||
if (hasUtilization(data.seven_day)) {
|
||||
quotas["weekly (7d)"] = createQuotaObject(data.seven_day);
|
||||
}
|
||||
|
||||
// Parse model-specific weekly windows (e.g. seven_day_sonnet, seven_day_opus)
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key.startsWith("seven_day_") && key !== "seven_day" && hasUtilization(value)) {
|
||||
const modelName = key.replace("seven_day_", "");
|
||||
quotas[`weekly ${modelName} (7d)`] = createQuotaObject(value);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plan: "Claude Code",
|
||||
extraUsage: data.extra_usage ?? null,
|
||||
quotas,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: legacy settings + org usage endpoint
|
||||
console.warn(`[Claude Usage] OAuth endpoint returned ${oauthResponse.status}, falling back to legacy`);
|
||||
return await getClaudeUsageLegacy(accessToken, proxyOptions);
|
||||
} catch (error) {
|
||||
return { message: `Claude connected. Unable to fetch usage: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy Claude usage for API key / org admin users
|
||||
*/
|
||||
async function getClaudeUsageLegacy(accessToken, proxyOptions = null) {
|
||||
try {
|
||||
const settingsResponse = await proxyAwareFetch(CLAUDE_CONFIG.settingsUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
}, proxyOptions);
|
||||
|
||||
if (settingsResponse.ok) {
|
||||
const settings = await settingsResponse.json();
|
||||
|
||||
if (settings.organization_id) {
|
||||
const usageResponse = await proxyAwareFetch(
|
||||
CLAUDE_CONFIG.usageUrl.replace("{org_id}", settings.organization_id),
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"anthropic-version": CLAUDE_CONFIG.apiVersion,
|
||||
},
|
||||
},
|
||||
proxyOptions
|
||||
);
|
||||
|
||||
if (usageResponse.ok) {
|
||||
const usage = await usageResponse.json();
|
||||
return {
|
||||
plan: settings.plan || "Unknown",
|
||||
organization: settings.organization_name,
|
||||
quotas: usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plan: settings.plan || "Unknown",
|
||||
organization: settings.organization_name,
|
||||
message: "Claude connected. Usage details require admin access.",
|
||||
};
|
||||
}
|
||||
|
||||
return { message: "Claude connected. Usage API requires admin permissions." };
|
||||
} catch (error) {
|
||||
return { message: `Claude connected. Unable to fetch usage: ${error.message}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* Codex (OpenAI) usage handler
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { U, parseResetTime, toFiniteNumber } from "./shared.js";
|
||||
|
||||
// Codex (OpenAI) API config
|
||||
const CODEX_CONFIG = {
|
||||
usageUrl: U("codex").url,
|
||||
};
|
||||
|
||||
function getCodexRateLimitBody(snapshot) {
|
||||
if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null;
|
||||
return snapshot.rate_limit && typeof snapshot.rate_limit === "object"
|
||||
? snapshot.rate_limit
|
||||
: snapshot;
|
||||
}
|
||||
|
||||
function formatCodexWindow(window) {
|
||||
const used = Math.max(0, Math.min(100, toFiniteNumber(window?.used_percent ?? window?.percent_used, 0)));
|
||||
return {
|
||||
used,
|
||||
total: 100,
|
||||
remaining: Math.max(0, 100 - used),
|
||||
resetAt: parseResetTime(window?.reset_at ?? window?.resets_at ?? window?.resetAt ?? null),
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
function appendCodexQuotaWindows(quotas, prefix, snapshot) {
|
||||
const rateLimit = getCodexRateLimitBody(snapshot);
|
||||
if (!rateLimit) return false;
|
||||
|
||||
const primary = rateLimit.primary_window || rateLimit.primary || snapshot.primary_window || snapshot.primary;
|
||||
const secondary = rateLimit.secondary_window || rateLimit.secondary || snapshot.secondary_window || snapshot.secondary;
|
||||
let added = false;
|
||||
|
||||
if (primary) {
|
||||
quotas[prefix ? `${prefix}_session` : "session"] = formatCodexWindow(primary);
|
||||
added = true;
|
||||
}
|
||||
if (secondary) {
|
||||
quotas[prefix ? `${prefix}_weekly` : "weekly"] = formatCodexWindow(secondary);
|
||||
added = true;
|
||||
}
|
||||
|
||||
return added;
|
||||
}
|
||||
|
||||
function getCodexReviewRateLimit(data) {
|
||||
if (data.code_review_rate_limit || data.review_rate_limit) {
|
||||
return data.code_review_rate_limit || data.review_rate_limit;
|
||||
}
|
||||
|
||||
const byLimitId = data.rate_limits_by_limit_id;
|
||||
if (byLimitId && typeof byLimitId === "object" && !Array.isArray(byLimitId)) {
|
||||
return byLimitId.code_review || byLimitId.codex_review || byLimitId.review || null;
|
||||
}
|
||||
|
||||
const additional = Array.isArray(data.additional_rate_limits) ? data.additional_rate_limits : [];
|
||||
return additional.find((entry) => {
|
||||
const id = String(entry?.limit_name || entry?.metered_feature || entry?.id || "").toLowerCase();
|
||||
return id === "code_review" || id === "codex_review" || id === "review" || id.includes("review");
|
||||
}) || null;
|
||||
}
|
||||
|
||||
export async function getCodexUsage(accessToken, proxyOptions = null) {
|
||||
try {
|
||||
const response = await proxyAwareFetch(CODEX_CONFIG.usageUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
}, proxyOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
return { message: `Codex connected. Usage API temporarily unavailable (${response.status}).` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const normalRateLimit = data.rate_limit || data.rate_limits || data.rate_limits_by_limit_id?.codex || {};
|
||||
const reviewRateLimit = getCodexReviewRateLimit(data);
|
||||
const quotas = {};
|
||||
|
||||
appendCodexQuotaWindows(quotas, "", normalRateLimit);
|
||||
appendCodexQuotaWindows(quotas, "review", reviewRateLimit);
|
||||
|
||||
return {
|
||||
plan: data.plan_type || data.summary?.plan || "unknown",
|
||||
limitReached: getCodexRateLimitBody(normalRateLimit)?.limit_reached || false,
|
||||
reviewLimitReached: getCodexRateLimitBody(reviewRateLimit)?.limit_reached || false,
|
||||
quotas,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch Codex usage: ${error.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* GitHub Copilot usage handler
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { PROVIDER_OAUTH } from "../../providers/index.js";
|
||||
import { U, parseResetTime } from "./shared.js";
|
||||
|
||||
// GitHub API config — single source from registry oauth block
|
||||
const GITHUB_CONFIG = {
|
||||
apiVersion: PROVIDER_OAUTH.github?.apiVersion,
|
||||
userAgent: PROVIDER_OAUTH.github?.userAgent,
|
||||
};
|
||||
|
||||
/**
|
||||
* GitHub Copilot Usage
|
||||
* Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API
|
||||
*/
|
||||
export async function getGitHubUsage(accessToken, providerSpecificData, proxyOptions = null) {
|
||||
try {
|
||||
if (!accessToken) {
|
||||
throw new Error("No GitHub access token available. Please re-authorize the connection.");
|
||||
}
|
||||
|
||||
// copilot_internal/user API requires GitHub OAuth token, not copilotToken
|
||||
const response = await proxyAwareFetch(U("github").url, {
|
||||
headers: {
|
||||
"Authorization": `token ${accessToken}`,
|
||||
"Accept": "application/json",
|
||||
"X-GitHub-Api-Version": GITHUB_CONFIG.apiVersion,
|
||||
"User-Agent": GITHUB_CONFIG.userAgent,
|
||||
"Editor-Version": "vscode/1.100.0",
|
||||
"Editor-Plugin-Version": "copilot-chat/0.26.7",
|
||||
},
|
||||
}, proxyOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(`GitHub API error: ${error}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Handle different response formats (paid vs free)
|
||||
if (data.quota_snapshots) {
|
||||
// Paid plan format
|
||||
const snapshots = data.quota_snapshots;
|
||||
const resetAt = parseResetTime(data.quota_reset_date);
|
||||
|
||||
return {
|
||||
plan: data.copilot_plan,
|
||||
resetDate: data.quota_reset_date,
|
||||
quotas: {
|
||||
chat: { ...formatGitHubQuotaSnapshot(snapshots.chat), resetAt },
|
||||
completions: { ...formatGitHubQuotaSnapshot(snapshots.completions), resetAt },
|
||||
premium_interactions: { ...formatGitHubQuotaSnapshot(snapshots.premium_interactions), resetAt },
|
||||
},
|
||||
};
|
||||
} else if (data.monthly_quotas || data.limited_user_quotas) {
|
||||
// Free/limited plan format
|
||||
const monthlyQuotas = data.monthly_quotas || {};
|
||||
const usedQuotas = data.limited_user_quotas || {};
|
||||
const resetAt = parseResetTime(data.limited_user_reset_date);
|
||||
|
||||
return {
|
||||
plan: data.copilot_plan || data.access_type_sku,
|
||||
resetDate: data.limited_user_reset_date,
|
||||
quotas: {
|
||||
chat: {
|
||||
used: usedQuotas.chat || 0,
|
||||
total: monthlyQuotas.chat || 0,
|
||||
unlimited: false,
|
||||
resetAt,
|
||||
},
|
||||
completions: {
|
||||
used: usedQuotas.completions || 0,
|
||||
total: monthlyQuotas.completions || 0,
|
||||
unlimited: false,
|
||||
resetAt,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { message: "GitHub Copilot connected. Unable to parse quota data." };
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch GitHub usage: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function formatGitHubQuotaSnapshot(quota) {
|
||||
if (!quota) return { used: 0, total: 0, unlimited: true };
|
||||
|
||||
return {
|
||||
used: quota.entitlement - quota.remaining,
|
||||
total: quota.entitlement,
|
||||
remaining: quota.remaining,
|
||||
unlimited: quota.unlimited || false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* Google usage handlers (Gemini CLI + Antigravity)
|
||||
*/
|
||||
|
||||
import { CLIENT_METADATA, getPlatformUserAgent } from "../../config/appConstants.js";
|
||||
import { ANTIGRAVITY_OAUTH_CLIENT } from "../../providers/shared.js";
|
||||
import { U, parseResetTime, normalizeCloudCodeProjectId, fetchWithTimeout } from "./shared.js";
|
||||
|
||||
// Antigravity API config (from Quotio) — urls from registry, oauth client + dynamic UA kept here
|
||||
const ANTIGRAVITY_CONFIG = {
|
||||
...U("antigravity"),
|
||||
...ANTIGRAVITY_OAUTH_CLIENT,
|
||||
userAgent: getPlatformUserAgent(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Gemini CLI Usage — fetch per-model quota via Cloud Code Assist API.
|
||||
* Uses retrieveUserQuota (same endpoint as `gemini /stats`) returning
|
||||
* per-model buckets with remainingFraction + resetTime.
|
||||
*/
|
||||
export async function getGeminiUsage(accessToken, providerSpecificData, proxyOptions = null) {
|
||||
if (!accessToken) {
|
||||
return { plan: "Free", message: "Gemini CLI access token not available." };
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve project id: prefer connection-stored id, else loadCodeAssist lookup.
|
||||
// #1271: OAuth save stores projectId on the connection, not providerSpecificData.
|
||||
let projectId = normalizeCloudCodeProjectId(providerSpecificData?.projectId);
|
||||
let plan = "Free";
|
||||
|
||||
if (!projectId) {
|
||||
const subInfo = await getGeminiSubscriptionInfo(accessToken, proxyOptions);
|
||||
projectId = normalizeCloudCodeProjectId(subInfo?.cloudaicompanionProject);
|
||||
plan = subInfo?.currentTier?.name || plan;
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return {
|
||||
plan,
|
||||
message: "Gemini CLI project ID not available. Reconnect Gemini CLI, or configure a Google Cloud project with Gemini Code Assist access before checking quota.",
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetchWithTimeout(
|
||||
U("gemini-cli").quotaUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ project: projectId }),
|
||||
},
|
||||
10000,
|
||||
proxyOptions
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
return { plan, message: `Gemini CLI quota error (${response.status}).` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const quotas = {};
|
||||
|
||||
if (Array.isArray(data.buckets)) {
|
||||
for (const bucket of data.buckets) {
|
||||
if (!bucket.modelId || bucket.remainingFraction == null) continue;
|
||||
|
||||
const remainingFraction = Number(bucket.remainingFraction) || 0;
|
||||
const total = 1000; // Normalized base, matches antigravity convention
|
||||
const remaining = Math.round(total * remainingFraction);
|
||||
const used = Math.max(0, total - remaining);
|
||||
|
||||
quotas[bucket.modelId] = {
|
||||
used,
|
||||
total,
|
||||
resetAt: parseResetTime(bucket.resetTime),
|
||||
remainingPercentage: remainingFraction * 100,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { plan, quotas };
|
||||
} catch (error) {
|
||||
return { message: `Gemini CLI error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Gemini CLI subscription info via loadCodeAssist
|
||||
*/
|
||||
async function getGeminiSubscriptionInfo(accessToken, proxyOptions = null) {
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
U("gemini-cli").loadCodeAssistUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ metadata: CLIENT_METADATA }),
|
||||
},
|
||||
10000,
|
||||
proxyOptions
|
||||
);
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Antigravity Usage - Fetch quota from Google Cloud Code API
|
||||
*/
|
||||
export async function getAntigravityUsage(accessToken, providerSpecificData, proxyOptions = null) {
|
||||
try {
|
||||
// Fetch subscription info once — reuse for both projectId and plan
|
||||
const subscriptionInfo = await getAntigravitySubscriptionInfo(accessToken, proxyOptions);
|
||||
const projectId = subscriptionInfo?.cloudaicompanionProject || null;
|
||||
|
||||
const response = await fetchWithTimeout(ANTIGRAVITY_CONFIG.quotaApiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
|
||||
"Content-Type": "application/json",
|
||||
"X-Client-Name": "antigravity",
|
||||
"X-Client-Version": "1.107.0",
|
||||
"x-request-source": "local", // MITM bypass
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(projectId ? { project: projectId } : {})
|
||||
}),
|
||||
}, 10000, proxyOptions);
|
||||
|
||||
if (response.status === 403) {
|
||||
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) {
|
||||
throw new Error(`Antigravity API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const quotas = {};
|
||||
|
||||
// Parse model quotas (inspired by vscode-antigravity-cockpit)
|
||||
if (data.models) {
|
||||
// Filter only recommended/important models (must match PROVIDER_MODELS ag ids)
|
||||
const importantModels = [
|
||||
'gemini-3-flash-agent',
|
||||
'gemini-3.5-flash-low',
|
||||
'gemini-3.5-flash-extra-low',
|
||||
'gemini-pro-agent',
|
||||
'gemini-3.1-pro-low',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-opus-4-6-thinking',
|
||||
'gpt-oss-120b-medium',
|
||||
'gemini-3-flash',
|
||||
];
|
||||
|
||||
for (const [modelKey, info] of Object.entries(data.models)) {
|
||||
// Skip models without quota info
|
||||
if (!info.quotaInfo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip internal models and non-important models
|
||||
if (info.isInternal || !importantModels.includes(modelKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const remainingFraction = info.quotaInfo.remainingFraction || 0;
|
||||
const remainingPercentage = remainingFraction * 100;
|
||||
|
||||
// Convert percentage to used/total for UI compatibility
|
||||
const total = 1000; // Normalized base
|
||||
const remaining = Math.round(total * remainingFraction);
|
||||
const used = total - remaining;
|
||||
|
||||
// Use modelKey as key (matches PROVIDER_MODELS id)
|
||||
quotas[modelKey] = {
|
||||
used,
|
||||
total,
|
||||
resetAt: parseResetTime(info.quotaInfo.resetTime),
|
||||
remainingPercentage,
|
||||
unlimited: false,
|
||||
displayName: info.displayName || modelKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
plan: subscriptionInfo?.currentTier?.name || "Unknown",
|
||||
quotas,
|
||||
subscriptionInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("[Antigravity Usage] Error:", error.message, error.cause);
|
||||
return { message: `Antigravity error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Antigravity subscription info
|
||||
*/
|
||||
async function getAntigravitySubscriptionInfo(accessToken, proxyOptions = null) {
|
||||
try {
|
||||
const response = await fetchWithTimeout(ANTIGRAVITY_CONFIG.loadProjectApiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"User-Agent": ANTIGRAVITY_CONFIG.userAgent,
|
||||
"Content-Type": "application/json",
|
||||
"x-request-source": "local", // MITM bypass
|
||||
},
|
||||
body: JSON.stringify({ metadata: CLIENT_METADATA, mode: 1 }),
|
||||
}, 10000, proxyOptions);
|
||||
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("[Antigravity Subscription] Error:", error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Kiro (AWS CodeWhisperer) usage handler
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { resolveDefaultProfileArn } from "../../config/kiroConstants.js";
|
||||
import { U, parseResetTime } from "./shared.js";
|
||||
|
||||
/**
|
||||
* Kiro (AWS CodeWhisperer) Usage
|
||||
*/
|
||||
function parseKiroQuotaData(data) {
|
||||
const usageList = data.usageBreakdownList || [];
|
||||
const quotaInfo = {};
|
||||
const resetAt = parseResetTime(data.nextDateReset || data.resetDate);
|
||||
|
||||
usageList.forEach((breakdown) => {
|
||||
const resourceType = breakdown.resourceType?.toLowerCase() || "unknown";
|
||||
const used = breakdown.currentUsageWithPrecision || 0;
|
||||
const total = breakdown.usageLimitWithPrecision || 0;
|
||||
|
||||
quotaInfo[resourceType] = {
|
||||
used,
|
||||
total,
|
||||
remaining: total - used,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
};
|
||||
|
||||
// Add free trial if available
|
||||
if (breakdown.freeTrialInfo) {
|
||||
const freeUsed = breakdown.freeTrialInfo.currentUsageWithPrecision || 0;
|
||||
const freeTotal = breakdown.freeTrialInfo.usageLimitWithPrecision || 0;
|
||||
|
||||
quotaInfo[`${resourceType}_freetrial`] = {
|
||||
used: freeUsed,
|
||||
total: freeTotal,
|
||||
remaining: freeTotal - freeUsed,
|
||||
resetAt: parseResetTime(breakdown.freeTrialInfo.freeTrialExpiry || resetAt),
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
plan: data.subscriptionInfo?.subscriptionTitle || "Kiro",
|
||||
quotas: quotaInfo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getKiroUsage(accessToken, providerSpecificData, proxyOptions = null) {
|
||||
const authMethod = providerSpecificData?.authMethod || "builder-id";
|
||||
const profileArn = providerSpecificData?.profileArn || resolveDefaultProfileArn(authMethod);
|
||||
|
||||
const getUsageParams = new URLSearchParams({
|
||||
isEmailRequired: "true",
|
||||
origin: "AI_EDITOR",
|
||||
resourceType: "AGENTIC_REQUEST",
|
||||
});
|
||||
|
||||
// For compatibility, try multiple known Kiro usage endpoints
|
||||
const attempts = [
|
||||
{
|
||||
name: "codewhisperer-get",
|
||||
run: async () => proxyAwareFetch(
|
||||
`${U("kiro").cwHost}${U("kiro").limitsPath}?${getUsageParams.toString()}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Accept": "application/json",
|
||||
"x-amz-user-agent": "aws-sdk-js/1.0.0 KiroIDE",
|
||||
"user-agent": "aws-sdk-js/1.0.0 KiroIDE",
|
||||
},
|
||||
},
|
||||
proxyOptions
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "codewhisperer-post",
|
||||
run: async () => proxyAwareFetch(U("kiro").cwHost, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/x-amz-json-1.0",
|
||||
"x-amz-target": "AmazonCodeWhispererService.GetUsageLimits",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
origin: "AI_EDITOR",
|
||||
profileArn,
|
||||
resourceType: "AGENTIC_REQUEST",
|
||||
}),
|
||||
}, proxyOptions),
|
||||
},
|
||||
{
|
||||
name: "q-get",
|
||||
run: async () => {
|
||||
const params = new URLSearchParams({
|
||||
origin: "AI_EDITOR",
|
||||
profileArn,
|
||||
resourceType: "AGENTIC_REQUEST",
|
||||
});
|
||||
return proxyAwareFetch(`${U("kiro").qHost}${U("kiro").limitsPath}?${params}`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Accept": "application/json",
|
||||
},
|
||||
}, proxyOptions);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let sawAuthError = false;
|
||||
const errors = [];
|
||||
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
const response = await attempt.run();
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
sawAuthError = true;
|
||||
}
|
||||
errors.push(`${attempt.name}:${response.status}${errorText ? `:${errorText}` : ""}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return parseKiroQuotaData(data);
|
||||
} catch (error) {
|
||||
errors.push(`${attempt.name}:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (sawAuthError && authMethod === "idc") {
|
||||
return {
|
||||
message: "Kiro quota API is unavailable for the current AWS IAM Identity Center session. Chat may still work. If this persists after renewing your session, reconnect Kiro.",
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
|
||||
// Social auth (Google/GitHub) - these use a different token format that may not work with AWS CodeWhisperer quota APIs
|
||||
if (sawAuthError && (authMethod === "google" || authMethod === "github")) {
|
||||
return {
|
||||
message: "Kiro quota API authentication expired. Chat may still work.",
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
|
||||
if (sawAuthError) {
|
||||
return {
|
||||
message: "Kiro quota API rejected the current token. Chat may still work.",
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackMessage =
|
||||
errors.length > 0
|
||||
? `Unable to fetch Kiro usage right now. (${errors[errors.length - 1]})`
|
||||
: "Unable to fetch Kiro usage right now.";
|
||||
|
||||
return {
|
||||
message: fallbackMessage,
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* MiniMax usage handler
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { U, parseResetTime } from "./shared.js";
|
||||
|
||||
// MiniMax usage endpoints (try in order, fallback on transient errors)
|
||||
const MINIMAX_USAGE_URLS = {
|
||||
minimax: U("minimax").urls,
|
||||
"minimax-cn": U("minimax-cn").urls,
|
||||
};
|
||||
|
||||
// ── MiniMax helpers ──────────────────────────────────────────────────────
|
||||
function getMiniMaxField(model, snakeKey, camelKey) {
|
||||
if (!model || typeof model !== "object") return null;
|
||||
return model[snakeKey] ?? model[camelKey] ?? null;
|
||||
}
|
||||
|
||||
function getMiniMaxModelName(model) {
|
||||
return String(getMiniMaxField(model, "model_name", "modelName") || "").trim();
|
||||
}
|
||||
|
||||
function formatMiniMaxQuotaName(model) {
|
||||
const rawName = getMiniMaxModelName(model);
|
||||
if (!rawName) return "MiniMax";
|
||||
|
||||
// M3+ shared quota pool: MiniMax reports M-series as a single wildcard
|
||||
// bucket ("MiniMax-M*"). Newer responses rename it to plain "general".
|
||||
// Render both as a friendly series label rather than leaking the
|
||||
// asterisk or the vague "general" word to the UI.
|
||||
if (rawName === "MiniMax-M*" || rawName === "general") return "M-series";
|
||||
|
||||
return rawName
|
||||
.replace(/[_-]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.replace(/\b\w/g, (ch) => ch.toUpperCase())
|
||||
.replace(/\bTo\b/g, "to")
|
||||
.replace(/\bTts\b/g, "TTS")
|
||||
.replace(/\bHd\b/g, "HD");
|
||||
}
|
||||
|
||||
function getMiniMaxProvidedPercent(model, snakeKey, camelKey) {
|
||||
if (!model || typeof model !== "object") return null;
|
||||
const raw = model[snakeKey] ?? model[camelKey];
|
||||
if (raw === null || raw === undefined) return null;
|
||||
const num = Number(raw);
|
||||
if (!Number.isFinite(num)) return null;
|
||||
return Math.max(0, Math.min(100, num));
|
||||
}
|
||||
|
||||
function getMiniMaxSessionTotal(model) {
|
||||
return Math.max(0, Number(getMiniMaxField(model, "current_interval_total_count", "currentIntervalTotalCount")) || 0);
|
||||
}
|
||||
|
||||
function getMiniMaxWeeklyTotal(model) {
|
||||
return Math.max(0, Number(getMiniMaxField(model, "current_weekly_total_count", "currentWeeklyTotalCount")) || 0);
|
||||
}
|
||||
|
||||
function hasMiniMaxQuota(model) {
|
||||
// Old format has real count totals; M3-era M-series buckets ship percent-only
|
||||
// (count fields are 0) so accept those too.
|
||||
if (getMiniMaxSessionTotal(model) > 0 || getMiniMaxWeeklyTotal(model) > 0) return true;
|
||||
if (getMiniMaxProvidedPercent(model, "current_interval_remaining_percent", "currentIntervalRemainingPercent") !== null) return true;
|
||||
if (getMiniMaxProvidedPercent(model, "current_weekly_remaining_percent", "currentWeeklyRemainingPercent") !== null) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function getMiniMaxResetAt(model, capturedAtMs, remainsSnake, remainsCamel, endSnake, endCamel) {
|
||||
const remainsMs = Number(getMiniMaxField(model, remainsSnake, remainsCamel)) || 0;
|
||||
if (remainsMs > 0) return new Date(capturedAtMs + remainsMs).toISOString();
|
||||
return parseResetTime(getMiniMaxField(model, endSnake, endCamel));
|
||||
}
|
||||
|
||||
function buildMiniMaxQuota(total, count, resetAt, countMeansRemaining, providedPercent = null) {
|
||||
const safeTotal = Math.max(0, total);
|
||||
const used = countMeansRemaining ? Math.max(safeTotal - count, 0) : Math.min(Math.max(0, count), safeTotal);
|
||||
const remaining = Math.max(safeTotal - used, 0);
|
||||
// M-series buckets ship percent-only (count = 0). Prefer the upstream value
|
||||
// when present, otherwise fall back to the computed percentage. When the
|
||||
// quota is unbounded (no count) and no upstream percent is available, surface
|
||||
// the percent anyway as long as it is defined.
|
||||
const remainingPercentage = providedPercentage(providedPercent, remaining, safeTotal);
|
||||
return {
|
||||
used,
|
||||
total: safeTotal,
|
||||
remaining,
|
||||
remainingPercentage,
|
||||
resetAt,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
function providedPercentage(provided, remaining, total) {
|
||||
if (provided !== null && provided !== undefined && Number.isFinite(provided)) {
|
||||
return Math.max(0, Math.min(100, provided));
|
||||
}
|
||||
return total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0;
|
||||
}
|
||||
|
||||
function addMiniMaxQuota(quotas, key, model, getTotal, countSnake, countCamel, percentSnake, percentCamel, resetArgs, countMeansRemaining) {
|
||||
const total = getTotal(model);
|
||||
const providedPercent = getMiniMaxProvidedPercent(model, percentSnake, percentCamel);
|
||||
if (total <= 0 && providedPercent === null) return;
|
||||
|
||||
const count = Math.max(0, Number(getMiniMaxField(model, countSnake, countCamel)) || 0);
|
||||
let effectiveTotal = total;
|
||||
let effectiveCount = count;
|
||||
if (total <= 0) {
|
||||
// M-series bucket: API only ships *_remaining_percent (count = 0). Normalize
|
||||
// to total=100. The downstream buildMiniMaxQuota treats the count as
|
||||
// "used" or "remaining" depending on countMeansRemaining, so the synthetic
|
||||
// count has to match that semantic — otherwise the UI flips the percentage.
|
||||
effectiveTotal = 100;
|
||||
const pct = providedPercent;
|
||||
effectiveCount = countMeansRemaining
|
||||
? Math.round(effectiveTotal * (pct / 100))
|
||||
: Math.round(effectiveTotal * (1 - pct / 100));
|
||||
}
|
||||
quotas[key] = buildMiniMaxQuota(
|
||||
effectiveTotal,
|
||||
effectiveCount,
|
||||
getMiniMaxResetAt(model, ...resetArgs),
|
||||
countMeansRemaining,
|
||||
providedPercent
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* MiniMax Token Plan / Coding Plan usage
|
||||
*/
|
||||
export async function getMiniMaxUsage(apiKey, provider, proxyOptions = null) {
|
||||
if (!apiKey) {
|
||||
return { message: "MiniMax API key not available." };
|
||||
}
|
||||
|
||||
const usageUrls = MINIMAX_USAGE_URLS[provider] || [];
|
||||
let lastErrorMessage = "";
|
||||
|
||||
for (let index = 0; index < usageUrls.length; index += 1) {
|
||||
const usageUrl = usageUrls[index];
|
||||
const canFallback = index < usageUrls.length - 1;
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(usageUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}, proxyOptions);
|
||||
|
||||
const rawText = await response.text();
|
||||
let payload = {};
|
||||
if (rawText) {
|
||||
try { payload = JSON.parse(rawText); } catch { payload = {}; }
|
||||
}
|
||||
|
||||
const baseResp = (payload?.base_resp ?? payload?.baseResp) || {};
|
||||
const apiStatusCode = Number(baseResp.status_code ?? baseResp.statusCode) || 0;
|
||||
const apiStatusMessage = String(baseResp.status_msg ?? baseResp.statusMsg ?? "").trim();
|
||||
const combined = `${apiStatusMessage} ${rawText}`.trim();
|
||||
const authLike = /token plan|coding plan|invalid api key|invalid key|unauthorized|inactive/i;
|
||||
|
||||
if (response.status === 401 || response.status === 403 || apiStatusCode === 1004 || authLike.test(combined)) {
|
||||
return { message: "MiniMax API key invalid or inactive. Use an active Token/Coding Plan key." };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
lastErrorMessage = `MiniMax usage endpoint error (${response.status})`;
|
||||
if ((response.status === 404 || response.status === 405 || response.status >= 500) && canFallback) continue;
|
||||
return { message: `MiniMax connected. ${lastErrorMessage}` };
|
||||
}
|
||||
|
||||
if (apiStatusCode !== 0) {
|
||||
return { message: `MiniMax connected. ${apiStatusMessage || "Upstream quota API error"}` };
|
||||
}
|
||||
|
||||
const modelRemains = payload?.model_remains ?? payload?.modelRemains;
|
||||
const allModels = Array.isArray(modelRemains) ? modelRemains : [];
|
||||
const quotaModels = allModels.filter(hasMiniMaxQuota);
|
||||
|
||||
if (quotaModels.length === 0) {
|
||||
return { message: "MiniMax connected. No quota data was returned." };
|
||||
}
|
||||
|
||||
const capturedAtMs = Date.now();
|
||||
const countMeansRemaining = usageUrl.includes("/coding_plan/remains");
|
||||
const quotas = {};
|
||||
|
||||
for (const model of quotaModels) {
|
||||
const displayName = formatMiniMaxQuotaName(model);
|
||||
addMiniMaxQuota(
|
||||
quotas,
|
||||
`${displayName} (5h)`,
|
||||
model,
|
||||
getMiniMaxSessionTotal,
|
||||
"current_interval_usage_count",
|
||||
"currentIntervalUsageCount",
|
||||
"current_interval_remaining_percent",
|
||||
"currentIntervalRemainingPercent",
|
||||
[capturedAtMs, "remains_time", "remainsTime", "end_time", "endTime"],
|
||||
countMeansRemaining
|
||||
);
|
||||
|
||||
addMiniMaxQuota(
|
||||
quotas,
|
||||
`${displayName} (7d)`,
|
||||
model,
|
||||
getMiniMaxWeeklyTotal,
|
||||
"current_weekly_usage_count",
|
||||
"currentWeeklyUsageCount",
|
||||
"current_weekly_remaining_percent",
|
||||
"currentWeeklyRemainingPercent",
|
||||
[capturedAtMs, "weekly_remains_time", "weeklyRemainsTime", "weekly_end_time", "weeklyEndTime"],
|
||||
countMeansRemaining
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(quotas).length === 0) {
|
||||
return { message: "MiniMax connected. Unable to extract quota usage." };
|
||||
}
|
||||
|
||||
return { quotas };
|
||||
} catch (error) {
|
||||
lastErrorMessage = error.message;
|
||||
if (!canFallback) break;
|
||||
}
|
||||
}
|
||||
|
||||
return { message: lastErrorMessage ? `MiniMax connected. Unable to fetch usage: ${lastErrorMessage}` : "MiniMax connected. Unable to fetch usage." };
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Misc usage handlers (Qwen, iFlow, Ollama, GLM, Vercel AI Gateway, Qoder)
|
||||
*/
|
||||
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
import { U } from "./shared.js";
|
||||
|
||||
// GLM quota endpoints (region-aware) — url from registry transport.usage
|
||||
const GLM_QUOTA_URLS = {
|
||||
international: U("glm").url,
|
||||
china: U("glm-cn").url,
|
||||
};
|
||||
|
||||
// Vercel AI Gateway credits endpoint
|
||||
// Returns { balance: "95.50", total_used: "4.50" } (USD as decimal strings).
|
||||
const VERCEL_AI_GATEWAY_CREDITS_URL = U("vercel-ai-gateway").url;
|
||||
|
||||
/**
|
||||
* Qwen Usage
|
||||
*/
|
||||
export async function getQwenUsage(accessToken, providerSpecificData) {
|
||||
try {
|
||||
const resourceUrl = providerSpecificData?.resourceUrl;
|
||||
if (!resourceUrl) {
|
||||
return { message: "Qwen connected. No resource URL available." };
|
||||
}
|
||||
|
||||
// Qwen may have usage endpoint at resource URL
|
||||
return { message: "Qwen connected. Usage tracked per request." };
|
||||
} catch (error) {
|
||||
return { message: "Unable to fetch Qwen usage." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* iFlow Usage
|
||||
*/
|
||||
export async function getIflowUsage(accessToken) {
|
||||
try {
|
||||
// iFlow may have usage endpoint
|
||||
return { message: "iFlow connected. Usage tracked per request." };
|
||||
} catch (error) {
|
||||
return { message: "Unable to fetch iFlow usage." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ollama Cloud Usage
|
||||
* Ollama Cloud uses an API key from ollama.com/settings/keys
|
||||
* and has no public usage API — free tier has light usage limits (resets every 5h & 7d).
|
||||
* This returns an informational message with the plan details.
|
||||
*/
|
||||
export async function getOllamaUsage(accessToken, providerSpecificData) {
|
||||
try {
|
||||
// Ollama Cloud does not expose a public quota/usage API.
|
||||
// The provider is configured as noAuth with a notice explaining limits.
|
||||
// We return a graceful message so the UI shows a friendly state instead of an error.
|
||||
const plan = providerSpecificData?.plan || "Free";
|
||||
return {
|
||||
plan,
|
||||
message: "Ollama Cloud uses a free tier with light usage limits (resets every 5h & 7d). For detailed usage tracking, visit ollama.com/settings/keys.",
|
||||
quotas: [],
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: "Unable to fetch Ollama Cloud usage." };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GLM Coding Plan usage (international + China regions)
|
||||
*/
|
||||
export async function getGlmUsage(apiKey, provider, proxyOptions = null) {
|
||||
if (!apiKey) {
|
||||
return { message: "GLM API key not available." };
|
||||
}
|
||||
|
||||
const region = provider === "glm-cn" ? "china" : "international";
|
||||
const quotaUrl = GLM_QUOTA_URLS[region];
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(quotaUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
}, proxyOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
return { message: "GLM API key invalid or expired." };
|
||||
}
|
||||
return { message: `GLM quota API error (${response.status}).` };
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
const data = json?.data && typeof json.data === "object" ? json.data : {};
|
||||
const limits = Array.isArray(data.limits) ? data.limits : [];
|
||||
const quotas = {};
|
||||
|
||||
for (const limit of limits) {
|
||||
if (!limit || limit.type !== "TOKENS_LIMIT") continue;
|
||||
const usedPercent = Number(limit.percentage) || 0;
|
||||
const resetMs = Number(limit.nextResetTime) || 0;
|
||||
const remaining = Math.max(0, 100 - usedPercent);
|
||||
|
||||
quotas["session"] = {
|
||||
used: usedPercent,
|
||||
total: 100,
|
||||
remaining,
|
||||
remainingPercentage: remaining,
|
||||
resetAt: resetMs > 0 ? new Date(resetMs).toISOString() : null,
|
||||
unlimited: false,
|
||||
};
|
||||
}
|
||||
|
||||
const levelRaw = typeof data.level === "string" ? data.level : "";
|
||||
const plan = levelRaw
|
||||
? levelRaw.charAt(0).toUpperCase() + levelRaw.slice(1).toLowerCase()
|
||||
: "Unknown";
|
||||
|
||||
return { plan, quotas };
|
||||
} catch (error) {
|
||||
return { message: `GLM error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vercel AI Gateway usage — credit balance for the API key
|
||||
*
|
||||
* Calls GET /v1/credits which returns:
|
||||
* { "balance": "95.50", "total_used": "4.50" } (USD as decimal strings)
|
||||
*
|
||||
* We surface this as a single "Balance ($)" quota row so the existing
|
||||
* QuotaTable / progress-bar UI can render it. used = total_used,
|
||||
* total = balance + total_used (the original credit allotment), so the
|
||||
* remaining percentage equals balance / total.
|
||||
*
|
||||
* Docs: https://vercel.com/docs/ai-gateway/usage
|
||||
*/
|
||||
export async function getVercelAiGatewayUsage(apiKey, proxyOptions = null) {
|
||||
if (!apiKey) {
|
||||
return { message: "Vercel AI Gateway API key not available." };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await proxyAwareFetch(VERCEL_AI_GATEWAY_CREDITS_URL, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
}, proxyOptions);
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { message: "Vercel AI Gateway API key invalid or expired." };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "");
|
||||
const trimmed = errorText ? `: ${errorText.slice(0, 200)}` : "";
|
||||
return { message: `Vercel AI Gateway credits API error (${response.status})${trimmed}` };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Vercel returns numeric strings; coerce safely.
|
||||
const balance = Number(data?.balance) || 0;
|
||||
const totalUsed = Number(data?.total_used) || 0;
|
||||
|
||||
// Vercel gives $5/month free credit. The API doesn't return the
|
||||
// monthly allocation so we use the known constant as the denominator.
|
||||
const MONTHLY_CREDIT = 5;
|
||||
const remainingPercentage = (balance / MONTHLY_CREDIT) * 100;
|
||||
|
||||
if (balance <= 0 && totalUsed <= 0) {
|
||||
return {
|
||||
plan: "Pay-as-you-go",
|
||||
message: "Vercel AI Gateway connected. No credit allocation found (BYOK or unfunded account).",
|
||||
quotas: {},
|
||||
};
|
||||
}
|
||||
|
||||
// "Used (USD)": how much has been spent this month (no fixed cap → unlimited).
|
||||
// "Remaining (USD)": balance remaining out of the $5 monthly allocation.
|
||||
return {
|
||||
plan: "Pay-as-you-go",
|
||||
quotas: {
|
||||
"Used (USD)": {
|
||||
used: totalUsed,
|
||||
total: 0,
|
||||
remaining: 0,
|
||||
remainingPercentage: 100,
|
||||
unlimited: true,
|
||||
},
|
||||
"Remaining (USD)": {
|
||||
used: balance,
|
||||
total: MONTHLY_CREDIT,
|
||||
remaining: balance,
|
||||
remainingPercentage,
|
||||
unlimited: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `Vercel AI Gateway error: ${error.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getQoderUsage(accessToken, proxyOptions = null) {
|
||||
if (!accessToken) {
|
||||
return { message: "Qoder usage unavailable: no access token" };
|
||||
}
|
||||
try {
|
||||
const response = await proxyAwareFetch(
|
||||
U("qoder").url,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
proxyOptions,
|
||||
);
|
||||
if (!response.ok) {
|
||||
return { message: `Qoder connected. Usage fetch returned ${response.status}.` };
|
||||
}
|
||||
const body = await response.json().catch(() => null);
|
||||
if (!body) {
|
||||
return { message: "Qoder connected. Usage response was not JSON." };
|
||||
}
|
||||
// Quota records live under `quotas`; scalar metadata
|
||||
// (totalUsagePercentage, isQuotaExceeded, expiresAt) are surfaced as
|
||||
// siblings so the dashboard parser doesn't try to render them as rows.
|
||||
const userQuota = body.userQuota || {};
|
||||
const orgQuota = body.orgResourcePackage || {};
|
||||
// Qoder publishes a single absolute reset timestamp (`expiresAt` in ms);
|
||||
// surface it on every quota record as ISO so the table can render
|
||||
// "resets at" alongside used/total.
|
||||
const expiresAtMs = Number.isFinite(Number(body.expiresAt)) && Number(body.expiresAt) > 0
|
||||
? Number(body.expiresAt)
|
||||
: null;
|
||||
const resetAt = expiresAtMs ? new Date(expiresAtMs).toISOString() : null;
|
||||
const quotas = {
|
||||
user: {
|
||||
total: Number(userQuota.total) || 0,
|
||||
used: Number(userQuota.used) || 0,
|
||||
remaining: Number(userQuota.remaining) || 0,
|
||||
unit: userQuota.unit || "credits",
|
||||
resetAt,
|
||||
},
|
||||
organization: {
|
||||
total: Number(orgQuota.total) || 0,
|
||||
used: Number(orgQuota.used) || 0,
|
||||
remaining: Number(orgQuota.remaining) || 0,
|
||||
unit: orgQuota.unit || "credits",
|
||||
resetAt,
|
||||
},
|
||||
};
|
||||
return {
|
||||
quotas,
|
||||
totalUsagePercentage: Number(body.totalUsagePercentage) || 0,
|
||||
isQuotaExceeded: !!body.isQuotaExceeded,
|
||||
expiresAt: expiresAtMs,
|
||||
};
|
||||
} catch (error) {
|
||||
return { message: `Qoder connected. Unable to fetch usage: ${error.message}` };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Shared usage helpers (cross-provider)
|
||||
*/
|
||||
|
||||
import { PROVIDERS } from "../../providers/index.js";
|
||||
import { proxyAwareFetch } from "../../utils/proxyFetch.js";
|
||||
|
||||
// usage endpoints: single source from registry transport.usage
|
||||
export const U = (id) => PROVIDERS[id]?.usage || {};
|
||||
|
||||
/**
|
||||
* Parse reset date/time to ISO string
|
||||
* Handles multiple formats: Unix timestamp (ms), ISO date string, etc.
|
||||
*/
|
||||
export function parseResetTime(resetValue) {
|
||||
if (!resetValue) return null;
|
||||
|
||||
try {
|
||||
// If it's already a Date object
|
||||
if (resetValue instanceof Date) {
|
||||
return resetValue.toISOString();
|
||||
}
|
||||
|
||||
// Unix timestamps from provider APIs may be seconds or milliseconds.
|
||||
if (typeof resetValue === 'number') {
|
||||
return new Date(resetValue < 1e12 ? resetValue * 1000 : resetValue).toISOString();
|
||||
}
|
||||
|
||||
// If it's a numeric string, treat it like a Unix timestamp too.
|
||||
if (typeof resetValue === 'string') {
|
||||
if (/^\d+$/.test(resetValue)) {
|
||||
const timestamp = Number(resetValue);
|
||||
return new Date(timestamp < 1e12 ? timestamp * 1000 : timestamp).toISOString();
|
||||
}
|
||||
return new Date(resetValue).toISOString();
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse reset time: ${resetValue}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function toFiniteNumber(value, fallback = 0) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function normalizeCloudCodeProjectId(project) {
|
||||
if (typeof project === "string") return project.trim() || null;
|
||||
if (project && typeof project === "object" && typeof project.id === "string") {
|
||||
return project.id.trim() || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchWithTimeout(url, opts, ms = 10000, proxyOptions = null) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), ms);
|
||||
try {
|
||||
return await proxyAwareFetch(url, { ...opts, signal: controller.signal }, proxyOptions);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function sseChunk(data) {
|
||||
return `data: ${JSON.stringify(data)}\n\n`;
|
||||
}
|
||||
@@ -5,62 +5,21 @@ import PropTypes from "prop-types";
|
||||
import { Card, Button, Input, Modal, CardSkeleton, Toggle, ConfirmModal } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { getCurrentLocale, onLocaleChange } from "@/i18n/runtime";
|
||||
|
||||
// Locales that unlock wenyan (classical Chinese) caveman levels
|
||||
const WENYAN_LOCALES = ["zh-CN", "zh-TW"];
|
||||
|
||||
const TUNNEL_BENEFITS = [
|
||||
{ icon: "public", title: "Access Anywhere", desc: "Use your API from any network" },
|
||||
{ icon: "group", title: "Share Endpoint", desc: "Share URL with team members" },
|
||||
{ icon: "code", title: "Use in Cursor/Cline", desc: "Connect AI tools remotely" },
|
||||
{ icon: "lock", title: "Encrypted", desc: "End-to-end TLS via Cloudflare" },
|
||||
];
|
||||
|
||||
const TUNNEL_PING_INTERVAL_MS = 2000;
|
||||
const TUNNEL_PING_MAX_MS = 300000;
|
||||
const STATUS_POLL_FAST_MS = 5000;
|
||||
const STATUS_POLL_SLOW_MS = 30000;
|
||||
const REACHABLE_MISS_THRESHOLD = 5;
|
||||
const CLIENT_PING_FAST_MS = 10000;
|
||||
const CLIENT_PING_SLOW_MS = 60000;
|
||||
const CLIENT_PING_TIMEOUT_MS = 5000;
|
||||
|
||||
// Browser-side health probe: must reach origin (not just CF/TS edge).
|
||||
// cors mode → res.ok=false for 5xx (e.g. Cloudflare 530 when origin dead).
|
||||
// /api/health route sets Access-Control-Allow-Origin: * → CORS works through tunnel.
|
||||
async function clientPingUrl(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const res = await fetch(`${url}/api/health`, {
|
||||
mode: "cors",
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(CLIENT_PING_TIMEOUT_MS),
|
||||
});
|
||||
return res.ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Race multiple URLs: resolve true as soon as any one passes ping.
|
||||
async function clientPingAny(...urls) {
|
||||
const checks = urls.filter(Boolean).map(clientPingUrl);
|
||||
if (!checks.length) return false;
|
||||
return new Promise((resolve) => {
|
||||
let pending = checks.length;
|
||||
checks.forEach((p) => p.then((ok) => {
|
||||
if (ok) resolve(true);
|
||||
else if (--pending === 0) resolve(false);
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
const CAVEMAN_LEVELS = [
|
||||
{ id: "lite", label: "Lite", desc: "Drop filler, keep grammar" },
|
||||
{ id: "full", label: "Full", desc: "Drop articles, fragments OK" },
|
||||
{ id: "ultra", label: "Ultra", desc: "Telegraphic, max compression" },
|
||||
{ id: "wenyan-lite", label: "文 Lite", desc: "Classical Chinese, light compression", wenyan: true },
|
||||
{ id: "wenyan", label: "文 Full", desc: "Maximum 文言文, 80-90% reduction", wenyan: true },
|
||||
{ id: "wenyan-ultra", label: "文 Ultra", desc: "Extreme classical compression", wenyan: true },
|
||||
];
|
||||
import {
|
||||
WENYAN_LOCALES,
|
||||
TUNNEL_BENEFITS,
|
||||
TUNNEL_PING_INTERVAL_MS,
|
||||
TUNNEL_PING_MAX_MS,
|
||||
STATUS_POLL_FAST_MS,
|
||||
REACHABLE_MISS_THRESHOLD,
|
||||
CLIENT_PING_FAST_MS,
|
||||
CAVEMAN_LEVELS,
|
||||
} from "./endpointConstants";
|
||||
import { clientPingUrl, clientPingAny } from "./endpointPing";
|
||||
import EndpointRow from "./components/EndpointRow";
|
||||
import StatusAlert from "./components/StatusAlert";
|
||||
import Tooltip from "./components/Tooltip";
|
||||
import SecurityWarning from "./components/SecurityWarning";
|
||||
export default function APIPageClient({ machineId }) {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -1474,81 +1433,6 @@ export default function APIPageClient({ machineId }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Reusable endpoint row component */
|
||||
function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
|
||||
(badge === "CF" || badge === "TS") ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
|
||||
}`}>{label}</span>
|
||||
<Input value={url} readOnly className="flex-1 font-mono text-sm" />
|
||||
<button
|
||||
onClick={() => onCopy(url, copyId)}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">{copied === copyId ? "check" : "content_copy"}</span>
|
||||
</button>
|
||||
{actions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Reusable status alert */
|
||||
function StatusAlert({ status, className = "" }) {
|
||||
// Render URLs in message as clickable links
|
||||
const renderMessage = (msg) => {
|
||||
const parts = msg.split(/(https?:\/\/[^\s]+)/g);
|
||||
return parts.map((part, i) =>
|
||||
/^https?:\/\//.test(part)
|
||||
? <a key={i} href={part} target="_blank" rel="noreferrer" className="underline font-medium">{part}</a>
|
||||
: part
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-2 rounded text-sm ${className} ${status.type === "success" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
status.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
|
||||
status.type === "info" ? "bg-blue-500/10 text-blue-600 dark:text-blue-400" :
|
||||
"bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}>
|
||||
{renderMessage(status.message)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline tooltip, Claude Code CLI style */
|
||||
function Tooltip({ text }) {
|
||||
return (
|
||||
<span className="relative group inline-flex items-center">
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted cursor-help">help</span>
|
||||
<span className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 z-50 w-64 rounded bg-gray-900 dark:bg-gray-800 text-white text-xs px-2.5 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Security warning banner with optional action link */
|
||||
function SecurityWarning({ message, action }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-400">
|
||||
<span className="material-symbols-outlined text-[16px] shrink-0 mt-0.5">warning</span>
|
||||
<p className="text-xs flex-1">{message}</p>
|
||||
{action && (
|
||||
<a
|
||||
href={action.href}
|
||||
className="text-xs font-medium underline shrink-0 hover:opacity-80"
|
||||
onClick={action.href.startsWith("#") ? (e) => {
|
||||
e.preventDefault();
|
||||
document.getElementById(action.href.slice(1))?.scrollIntoView({ behavior: "smooth" });
|
||||
} : undefined}
|
||||
>
|
||||
{action.label}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
APIPageClient.propTypes = {
|
||||
machineId: PropTypes.string.isRequired,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/shared/components";
|
||||
|
||||
/** Reusable endpoint row component */
|
||||
export default function EndpointRow({ label, url, copyId, copied, onCopy, badge, actions }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-mono px-1.5 py-0.5 rounded shrink-0 min-w-[88px] text-center ${
|
||||
(badge === "CF" || badge === "TS") ? "bg-primary/10 text-primary" : "bg-surface-2 text-text-muted"
|
||||
}`}>{label}</span>
|
||||
<Input value={url} readOnly className="flex-1 font-mono text-sm" />
|
||||
<button
|
||||
onClick={() => onCopy(url, copyId)}
|
||||
className="p-2 hover:bg-black/5 dark:hover:bg-white/5 rounded text-text-muted hover:text-primary transition-colors shrink-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">{copied === copyId ? "check" : "content_copy"}</span>
|
||||
</button>
|
||||
{actions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/** Security warning banner with optional action link */
|
||||
export default function SecurityWarning({ message, action }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-400">
|
||||
<span className="material-symbols-outlined text-[16px] shrink-0 mt-0.5">warning</span>
|
||||
<p className="text-xs flex-1">{message}</p>
|
||||
{action && (
|
||||
<a
|
||||
href={action.href}
|
||||
className="text-xs font-medium underline shrink-0 hover:opacity-80"
|
||||
onClick={action.href.startsWith("#") ? (e) => {
|
||||
e.preventDefault();
|
||||
document.getElementById(action.href.slice(1))?.scrollIntoView({ behavior: "smooth" });
|
||||
} : undefined}
|
||||
>
|
||||
{action.label}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
/** Reusable status alert */
|
||||
export default function StatusAlert({ status, className = "" }) {
|
||||
const renderMessage = (msg) => {
|
||||
const parts = msg.split(/(https?:\/\/[^\s]+)/g);
|
||||
return parts.map((part, i) =>
|
||||
/^https?:\/\//.test(part)
|
||||
? <a key={i} href={part} target="_blank" rel="noreferrer" className="underline font-medium">{part}</a>
|
||||
: part
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-2 rounded text-sm ${className} ${status.type === "success" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
status.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
|
||||
status.type === "info" ? "bg-blue-500/10 text-blue-600 dark:text-blue-400" :
|
||||
"bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}>
|
||||
{renderMessage(status.message)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
/** Inline tooltip, Claude Code CLI style */
|
||||
export default function Tooltip({ text }) {
|
||||
return (
|
||||
<span className="relative group inline-flex items-center">
|
||||
<span className="material-symbols-outlined text-[14px] text-text-muted cursor-help">help</span>
|
||||
<span className="pointer-events-none absolute left-5 top-1/2 -translate-y-1/2 z-50 w-64 rounded bg-gray-900 dark:bg-gray-800 text-white text-xs px-2.5 py-1.5 opacity-0 group-hover:opacity-100 transition-opacity shadow-lg">
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const WENYAN_LOCALES = ["zh-CN", "zh-TW"];
|
||||
|
||||
export const TUNNEL_BENEFITS = [
|
||||
{ icon: "public", title: "Access Anywhere", desc: "Use your API from any network" },
|
||||
{ icon: "group", title: "Share Endpoint", desc: "Share URL with team members" },
|
||||
{ icon: "code", title: "Use in Cursor/Cline", desc: "Connect AI tools remotely" },
|
||||
{ icon: "lock", title: "Encrypted", desc: "End-to-end TLS via Cloudflare" },
|
||||
];
|
||||
|
||||
export const TUNNEL_PING_INTERVAL_MS = 2000;
|
||||
export const TUNNEL_PING_MAX_MS = 300000;
|
||||
export const STATUS_POLL_FAST_MS = 5000;
|
||||
export const STATUS_POLL_SLOW_MS = 30000;
|
||||
export const REACHABLE_MISS_THRESHOLD = 5;
|
||||
export const CLIENT_PING_FAST_MS = 10000;
|
||||
export const CLIENT_PING_SLOW_MS = 60000;
|
||||
export const CLIENT_PING_TIMEOUT_MS = 5000;
|
||||
|
||||
export const CAVEMAN_LEVELS = [
|
||||
{ id: "lite", label: "Lite", desc: "Drop filler, keep grammar" },
|
||||
{ id: "full", label: "Full", desc: "Drop articles, fragments OK" },
|
||||
{ id: "ultra", label: "Ultra", desc: "Telegraphic, max compression" },
|
||||
{ id: "wenyan-lite", label: "文 Lite", desc: "Classical Chinese, light compression", wenyan: true },
|
||||
{ id: "wenyan", label: "文 Full", desc: "Maximum 文言文, 80-90% reduction", wenyan: true },
|
||||
{ id: "wenyan-ultra", label: "文 Ultra", desc: "Extreme classical compression", wenyan: true },
|
||||
];
|
||||
@@ -0,0 +1,29 @@
|
||||
import { CLIENT_PING_TIMEOUT_MS } from "./endpointConstants";
|
||||
|
||||
// Browser-side health probe: must reach origin (not just CF/TS edge).
|
||||
// cors mode → res.ok=false for 5xx (e.g. Cloudflare 530 when origin dead).
|
||||
// /api/health route sets Access-Control-Allow-Origin: * → CORS works through tunnel.
|
||||
export async function clientPingUrl(url) {
|
||||
if (!url) return false;
|
||||
try {
|
||||
const res = await fetch(`${url}/api/health`, {
|
||||
mode: "cors",
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(CLIENT_PING_TIMEOUT_MS),
|
||||
});
|
||||
return res.ok;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
// Race multiple URLs: resolve true as soon as any one passes ping.
|
||||
export async function clientPingAny(...urls) {
|
||||
const checks = urls.filter(Boolean).map(clientPingUrl);
|
||||
if (!checks.length) return false;
|
||||
return new Promise((resolve) => {
|
||||
let pending = checks.length;
|
||||
checks.forEach((p) => p.then((ok) => {
|
||||
if (ok) resolve(true);
|
||||
else if (--pending === 0) resolve(false);
|
||||
}));
|
||||
});
|
||||
}
|
||||
+254
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { getProviderAlias, isCustomEmbeddingProvider } from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { Row } from "./exampleShared";
|
||||
|
||||
const DEFAULT_RESPONSE_EXAMPLE = `{
|
||||
"object": "list",
|
||||
"data": [{
|
||||
"object": "embedding",
|
||||
"index": 0,
|
||||
"embedding": [0.002301, -0.019212, 0.004815, -0.031249, ...]
|
||||
}],
|
||||
"model": "...",
|
||||
"usage": { "prompt_tokens": 9, "total_tokens": 9 }
|
||||
}`;
|
||||
|
||||
export function EmbeddingExampleCard({ providerId, customAlias }) {
|
||||
const isCustom = isCustomEmbeddingProvider(providerId);
|
||||
const providerAlias = isCustom ? (customAlias || providerId) : getProviderAlias(providerId);
|
||||
const embeddingModels = isCustom ? [] : getModelsByProviderId(providerId).filter((m) => getModelKind(m) === "embedding");
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState(embeddingModels[0]?.id ?? "");
|
||||
const [input, setInput] = useState("The quick brown fox jumps over the lazy dog");
|
||||
const [dimensions, setDimensions] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [useTunnel, setUseTunnel] = useState(false);
|
||||
const [localEndpoint, setLocalEndpoint] = useState("");
|
||||
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
|
||||
const [result, setResult] = useState(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
|
||||
const { copied: copiedRes, copy: copyRes } = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEndpoint(window.location.origin);
|
||||
fetch("/api/keys")
|
||||
.then((r) => r.json())
|
||||
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
||||
.catch(() => {});
|
||||
fetch("/api/tunnel/status")
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
|
||||
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
|
||||
|
||||
// Build request body — include dimensions only if user provided a positive number
|
||||
const buildBody = () => {
|
||||
const body = { model: modelFull, input: input.trim() };
|
||||
const dim = Number(dimensions);
|
||||
if (dimensions && Number.isFinite(dim) && dim > 0) body.dimensions = dim;
|
||||
return body;
|
||||
};
|
||||
|
||||
const curlSnippet = `curl -X POST ${endpoint}/v1/embeddings \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
|
||||
-d '${JSON.stringify(buildBody())}'`;
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!input.trim() || !modelFull) return;
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch("/api/v1/embeddings", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(buildBody()),
|
||||
});
|
||||
const latencyMs = Date.now() - start;
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data?.error?.message || data?.error || `HTTP ${res.status}`); return; }
|
||||
setResult({ data, latencyMs });
|
||||
} catch (e) {
|
||||
setError(e.message || "Network error");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Compact embedding array: first 4 values + count
|
||||
const formatResultJson = (data) => {
|
||||
if (!data) return DEFAULT_RESPONSE_EXAMPLE;
|
||||
const clone = JSON.parse(JSON.stringify(data));
|
||||
(clone.data || []).forEach((item) => {
|
||||
if (Array.isArray(item.embedding) && item.embedding.length > 4) {
|
||||
item.embedding = [...item.embedding.slice(0, 4).map((v) => parseFloat(v.toFixed(6))), `... (${item.embedding.length} dims)`];
|
||||
}
|
||||
});
|
||||
return JSON.stringify(clone, null, 2);
|
||||
};
|
||||
|
||||
const resultJson = result ? JSON.stringify(result.data, null, 2) : "";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Model — text input for custom node, dropdown otherwise */}
|
||||
<Row label="Model">
|
||||
{isCustom ? (
|
||||
<input
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{embeddingModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{/* Endpoint */}
|
||||
<Row label="Endpoint">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<input
|
||||
value={endpoint}
|
||||
onChange={(e) => useTunnel ? setTunnelEndpoint(e.target.value) : setLocalEndpoint(e.target.value)}
|
||||
className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
||||
placeholder="http://localhost:3000"
|
||||
/>
|
||||
{/* Tunnel toggle — only show if tunnel URL is available */}
|
||||
{tunnelEndpoint && (
|
||||
<button
|
||||
onClick={() => setUseTunnel((v) => !v)}
|
||||
title={useTunnel ? "Using tunnel" : "Using local"}
|
||||
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
||||
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
||||
Tunnel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* API Key */}
|
||||
<Row label="API Key">
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="sk-..."
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{/* Input */}
|
||||
<Row label="Input">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
{input && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInput("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Dimensions (optional) — truncate embedding vector length */}
|
||||
<Row label="Dimensions">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={dimensions}
|
||||
onChange={(e) => setDimensions(e.target.value)}
|
||||
placeholder="optional, e.g. 512, 1024 (leave empty for default)"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</Row>
|
||||
|
||||
{/* Curl + Run */}
|
||||
<div className="mt-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={() => copyCurl(curlSnippet)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
||||
{copiedCurl ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={running || !input.trim() || !modelFull}
|
||||
className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
play_arrow
|
||||
</span>
|
||||
{running ? "Running..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
||||
|
||||
{/* Response — default example or real result */}
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
Response {result && <span className="font-normal normal-case">⚡ {result.latencyMs}ms</span>}
|
||||
</span>
|
||||
{result && (
|
||||
<button
|
||||
onClick={() => copyRes(resultJson)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
|
||||
{copiedRes ? "Copied" : "Copy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-70">
|
||||
{formatResultJson(result?.data)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { MEDIA_PROVIDER_KINDS, getProviderAlias, resolveProviderId } from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { Row, KIND_EXAMPLE_CONFIG } from "./exampleShared";
|
||||
|
||||
const CLOUDFLARE_TEST_IMAGE_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog.png";
|
||||
const CLOUDFLARE_TEST_MASK_URL = "https://pub-1fb693cb11cc46b2b2f656f51e015a2c.r2.dev/dog-mask.png";
|
||||
|
||||
function getImageEditDefaults(providerId, modelId) {
|
||||
if (providerId !== "cloudflare-ai") return {};
|
||||
if (modelId === "@cf/runwayml/stable-diffusion-v1-5-img2img") {
|
||||
return { image: CLOUDFLARE_TEST_IMAGE_URL };
|
||||
}
|
||||
if (modelId === "@cf/runwayml/stable-diffusion-v1-5-inpainting") {
|
||||
return { image: CLOUDFLARE_TEST_IMAGE_URL, mask_image: CLOUDFLARE_TEST_MASK_URL };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function toImagePreviewSrc(value) {
|
||||
const trimmed = typeof value === "string" ? value.trim() : "";
|
||||
if (!trimmed) return "";
|
||||
if (/^(data:image\/|https?:\/\/)/i.test(trimmed)) return trimmed;
|
||||
return `data:image/png;base64,${trimmed}`;
|
||||
}
|
||||
|
||||
export function GenericExampleCard({ providerId, kind }) {
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
const resolvedId = resolveProviderId(providerAlias);
|
||||
const safeProviderAlias = resolvedId === providerId ? providerAlias : providerId;
|
||||
const kindConfig = MEDIA_PROVIDER_KINDS.find((k) => k.id === kind);
|
||||
const exConfig = KIND_EXAMPLE_CONFIG[kind];
|
||||
const safeExConfig = exConfig || {};
|
||||
|
||||
// Get models for this kind (e.g., type="image")
|
||||
const kindModels = getModelsByProviderId(providerId).filter((m) => getModelKind(m) === kind);
|
||||
// Kinds that need a model identifier in the request (image/video/music)
|
||||
const KIND_NEEDS_MODEL = new Set(["image", "video", "music", "imageToText"]);
|
||||
const needsModel = KIND_NEEDS_MODEL.has(kind);
|
||||
const allowManualModel = needsModel && kindModels.length === 0;
|
||||
const [selectedModel, setSelectedModel] = useState(kindModels[0]?.id ?? "");
|
||||
const selectedModelObj = kindModels.find((m) => m.id === selectedModel);
|
||||
const supportsEdit = !!selectedModelObj?.capabilities?.includes("edit");
|
||||
const supportsMask = !!selectedModelObj?.capabilities?.includes("mask");
|
||||
|
||||
const [input, setInput] = useState(safeExConfig.defaultInput || "");
|
||||
const [refImage, setRefImage] = useState("");
|
||||
const [maskImage, setMaskImage] = useState("");
|
||||
const [extraValues, setExtraValues] = useState(() =>
|
||||
(safeExConfig.extraFields || []).reduce((acc, f) => { acc[f.key] = f.default ?? ""; return acc; }, {})
|
||||
);
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [useTunnel, setUseTunnel] = useState(false);
|
||||
const [localEndpoint, setLocalEndpoint] = useState("");
|
||||
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
|
||||
const [result, setResult] = useState(null);
|
||||
const [progress, setProgress] = useState(null); // { stage, bytesReceived }
|
||||
const [partialImage, setPartialImage] = useState(null);
|
||||
const [imageOutputFormat, setImageOutputFormat] = useState("json"); // json | binary
|
||||
const [binaryImageUrl, setBinaryImageUrl] = useState("");
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [pinnedConnectionId, setPinnedConnectionId] = useState("");
|
||||
const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
|
||||
const { copied: copiedRes, copy: copyRes } = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEndpoint(window.location.origin);
|
||||
fetch("/api/keys")
|
||||
.then((r) => r.json())
|
||||
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
||||
.catch(() => {});
|
||||
fetch("/api/tunnel/status")
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
|
||||
.catch(() => {});
|
||||
// Load active connections of this provider for pinning
|
||||
fetch("/api/providers/client")
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
const conns = (d.connections || []).filter((c) => c.provider === providerId && c.isActive !== false);
|
||||
setConnections(conns);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [providerId]);
|
||||
|
||||
// Safe to early-return now that all hooks are declared
|
||||
if (!kindConfig || !exConfig) return null;
|
||||
|
||||
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
|
||||
const apiPath = kindConfig.endpoint.path;
|
||||
// webSearch/webFetch: use safeProviderAlias only. Other kinds: append model when present.
|
||||
const modelFull = !needsModel
|
||||
? safeProviderAlias
|
||||
: (selectedModel ? `${safeProviderAlias}/${selectedModel}` : (allowManualModel ? "" : safeProviderAlias));
|
||||
const imageEditDefaults = getImageEditDefaults(providerId, selectedModel);
|
||||
const effectiveRefImage = refImage.trim() || imageEditDefaults.image || "";
|
||||
const effectiveMaskImage = maskImage.trim() || imageEditDefaults.mask_image || "";
|
||||
const refImagePreviewSrc = toImagePreviewSrc(effectiveRefImage);
|
||||
const maskImagePreviewSrc = toImagePreviewSrc(effectiveMaskImage);
|
||||
|
||||
// Build request body with optional extra fields (only non-empty values)
|
||||
const extraBodyFromFields = Object.entries(extraValues).reduce((acc, [k, v]) => {
|
||||
if (v === "" || v === null || v === undefined) return acc;
|
||||
if (typeof v === "number" && Number.isNaN(v)) return acc;
|
||||
acc[k] = v;
|
||||
return acc;
|
||||
}, {});
|
||||
const requestBody = {
|
||||
model: modelFull,
|
||||
[exConfig.bodyKey]: input,
|
||||
...exConfig.extraBody,
|
||||
...extraBodyFromFields,
|
||||
...(supportsEdit && effectiveRefImage ? { image: effectiveRefImage } : {}),
|
||||
...(supportsMask && effectiveMaskImage ? { mask_image: effectiveMaskImage } : {}),
|
||||
};
|
||||
|
||||
// Streaming supported for codex image (Plus/Pro accounts) — disabled when binary output requested
|
||||
const wantBinary = kind === "image" && imageOutputFormat === "binary";
|
||||
const useStreaming = kind === "image" && providerId === "codex" && !wantBinary;
|
||||
const apiPathWithQuery = `${apiPath}${wantBinary ? "?response_format=binary" : ""}`;
|
||||
const headersPreview = `-H "Content-Type: application/json" \\\n -H "Authorization: Bearer ${apiKey || "YOUR_KEY"}"${pinnedConnectionId ? ` \\\n -H "x-connection-id: ${pinnedConnectionId}"` : ""}${useStreaming ? ` \\\n -H "Accept: text/event-stream"` : ""}`;
|
||||
const curlSnippet = `curl -X ${kindConfig.endpoint.method} ${endpoint}${apiPathWithQuery} \\
|
||||
${headersPreview.replace(/\\\n /g, "\\\n ")} \\
|
||||
-d '${JSON.stringify(requestBody)}'${wantBinary ? " \\\n --output image.png" : ""}`;
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!input.trim() || !modelFull) return;
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
setProgress(null);
|
||||
setPartialImage(null);
|
||||
if (binaryImageUrl) { try { URL.revokeObjectURL(binaryImageUrl); } catch {} setBinaryImageUrl(""); }
|
||||
const start = Date.now();
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
if (pinnedConnectionId) headers["x-connection-id"] = pinnedConnectionId;
|
||||
if (useStreaming) headers["Accept"] = "text/event-stream";
|
||||
const body = { ...requestBody, model: modelFull };
|
||||
const res = await fetch(`/api${apiPathWithQuery}`, {
|
||||
method: kindConfig.endpoint.method,
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError(data?.error?.message || data?.error || `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const ctype = res.headers.get("content-type") || "";
|
||||
// Binary image response — convert to blob URL
|
||||
if (ctype.startsWith("image/")) {
|
||||
const blob = await res.blob();
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
setBinaryImageUrl(objUrl);
|
||||
setResult({ data: { binary: true, mime: ctype, size: blob.size }, latencyMs: Date.now() - start });
|
||||
return;
|
||||
}
|
||||
const isSse = ctype.includes("text/event-stream");
|
||||
if (isSse && res.body) {
|
||||
// Parse SSE: progress / partial_image / done / error
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
let finalData = null;
|
||||
let streamErr = null;
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
let sep;
|
||||
while ((sep = buf.indexOf("\n\n")) !== -1) {
|
||||
const block = buf.slice(0, sep);
|
||||
buf = buf.slice(sep + 2);
|
||||
let evt = null, dataStr = "";
|
||||
for (const line of block.split("\n")) {
|
||||
if (line.startsWith("event:")) evt = line.slice(6).trim();
|
||||
else if (line.startsWith("data:")) dataStr += line.slice(5).trim();
|
||||
}
|
||||
if (!evt) continue;
|
||||
try {
|
||||
const payload = dataStr ? JSON.parse(dataStr) : {};
|
||||
if (evt === "progress") setProgress(payload);
|
||||
else if (evt === "partial_image") setPartialImage(payload);
|
||||
else if (evt === "done") finalData = payload;
|
||||
else if (evt === "error") streamErr = payload?.message || "Stream error";
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
const latencyMs = Date.now() - start;
|
||||
if (streamErr) { setError(streamErr); return; }
|
||||
if (finalData) setResult({ data: finalData, latencyMs });
|
||||
} else {
|
||||
const data = await res.json();
|
||||
const latencyMs = Date.now() - start;
|
||||
setResult({ data, latencyMs });
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message || "Network error");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Mask large b64_json strings in JSON view to keep it readable
|
||||
const maskB64 = (obj) => {
|
||||
if (!obj || typeof obj !== "object") return obj;
|
||||
if (Array.isArray(obj)) return obj.map(maskB64);
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
out[k] = (k === "b64_json" && typeof v === "string" && v.length > 100)
|
||||
? `<${v.length} chars base64>`
|
||||
: maskB64(v);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
const resultJson = result ? JSON.stringify(maskB64(result.data), null, 2) : "";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Model selector — dropdown if presets exist, else manual input for media kinds */}
|
||||
{kindModels.length > 0 ? (
|
||||
<Row label="Model">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{kindModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
) : allowManualModel ? (
|
||||
<Row label="Model">
|
||||
<input
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
placeholder="Enter model id (provider-specific)"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
</Row>
|
||||
) : null}
|
||||
|
||||
{/* Endpoint */}
|
||||
<Row label="Endpoint">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
||||
{endpoint}{apiPath}
|
||||
</span>
|
||||
{tunnelEndpoint && (
|
||||
<button
|
||||
onClick={() => setUseTunnel((v) => !v)}
|
||||
title={useTunnel ? "Using tunnel" : "Using local"}
|
||||
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
||||
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
||||
Tunnel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* API Key */}
|
||||
<Row label="API Key">
|
||||
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
|
||||
{apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
{/* Connection picker - only show when 2+ connections (or any with email) */}
|
||||
{connections.length > 0 && (
|
||||
<Row label="Connection">
|
||||
<select
|
||||
value={pinnedConnectionId}
|
||||
onChange={(e) => setPinnedConnectionId(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Auto (by priority)</option>
|
||||
{connections.map((c) => {
|
||||
const plan = c.providerSpecificData?.chatgptPlanType;
|
||||
const label = c.email || c.name || c.id.slice(0, 8);
|
||||
return (
|
||||
<option key={c.id} value={c.id}>
|
||||
{label}{plan ? ` [${plan}]` : ""}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<Row label={exConfig.inputLabel}>
|
||||
<div className="relative">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={exConfig.inputPlaceholder}
|
||||
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
{input && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInput("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Reference image (only for edit-capable image models) */}
|
||||
{supportsEdit && (
|
||||
<Row label="Ref Image (URL)">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={refImage}
|
||||
onChange={(e) => setRefImage(e.target.value)}
|
||||
placeholder={imageEditDefaults.image || "https://example.com/source.png"}
|
||||
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
{refImage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRefImage("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{refImagePreviewSrc && (
|
||||
<img
|
||||
src={refImagePreviewSrc}
|
||||
alt="Reference"
|
||||
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
|
||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||
onLoad={(e) => { e.currentTarget.style.display = "block"; }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{supportsMask && (
|
||||
<Row label="Mask (URL)">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={maskImage}
|
||||
onChange={(e) => setMaskImage(e.target.value)}
|
||||
placeholder={imageEditDefaults.mask_image || "https://example.com/mask.png"}
|
||||
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
{maskImage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMaskImage("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{maskImagePreviewSrc && (
|
||||
<img
|
||||
src={maskImagePreviewSrc}
|
||||
alt="Mask"
|
||||
className="max-h-40 rounded-lg border border-border object-contain bg-sidebar"
|
||||
onError={(e) => { e.currentTarget.style.display = "none"; }}
|
||||
onLoad={(e) => { e.currentTarget.style.display = "block"; }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Extra fields — for kinds without model concept (webSearch/webFetch), show all; otherwise filter by model.params */}
|
||||
{(exConfig.extraFields || [])
|
||||
.filter((f) => kindModels.length === 0 || (Array.isArray(selectedModelObj?.params) && selectedModelObj.params.includes(f.key)))
|
||||
.map((f) => (
|
||||
<Row key={f.key} label={f.label}>
|
||||
{f.type === "select" ? (
|
||||
<select
|
||||
value={extraValues[f.key] ?? ""}
|
||||
onChange={(e) => setExtraValues((s) => ({ ...s, [f.key]: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{(f.options || []).map((opt) => (
|
||||
<option key={opt} value={opt}>{opt === "" ? "(default)" : opt}</option>
|
||||
))}
|
||||
</select>
|
||||
) : f.type === "text" ? (
|
||||
<input
|
||||
type="text"
|
||||
value={extraValues[f.key] ?? ""}
|
||||
placeholder={f.placeholder}
|
||||
onChange={(e) => setExtraValues((s) => ({ ...s, [f.key]: e.target.value }))}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="number"
|
||||
value={extraValues[f.key] ?? ""}
|
||||
min={f.min}
|
||||
max={f.max}
|
||||
onChange={(e) => setExtraValues((s) => ({ ...s, [f.key]: e.target.value === "" ? "" : Number(e.target.value) }))}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
)}
|
||||
</Row>
|
||||
))}
|
||||
|
||||
{/* Output Format toggle (image only) — last */}
|
||||
{kind === "image" && (
|
||||
<Row label="Output Format">
|
||||
<select
|
||||
value={imageOutputFormat}
|
||||
onChange={(e) => setImageOutputFormat(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="json">JSON (Base64)</option>
|
||||
<option value="binary">Binary File</option>
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Curl + Run */}
|
||||
<div className="mt-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={() => copyCurl(curlSnippet)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
||||
{copiedCurl ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={running || !input.trim() || !modelFull}
|
||||
className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
play_arrow
|
||||
</span>
|
||||
{running ? "Running..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
|
||||
</div>
|
||||
|
||||
{/* Streaming progress */}
|
||||
{(running || progress) && useStreaming && (
|
||||
<div className="flex flex-col gap-2 px-3 py-2 rounded-lg bg-sidebar border border-border sm:flex-row sm:items-center sm:gap-3">
|
||||
<span className="material-symbols-outlined text-[16px] text-primary" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
{running ? "progress_activity" : "check_circle"}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{progress?.stage || "starting"}
|
||||
{!running && progress?.bytesReceived ? ` · ${(progress.bytesReceived / 1024).toFixed(1)} KB` : ""}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Partial image preview (codex stream) */}
|
||||
{partialImage?.b64_json && !result && (
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Partial preview</span>
|
||||
<img
|
||||
src={`data:image/png;base64,${partialImage.b64_json}`}
|
||||
alt="Partial"
|
||||
className="max-w-full rounded-lg border border-border mt-1.5 opacity-80"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
||||
|
||||
{/* Response */}
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
Response {result && <span className="font-normal normal-case">⚡ {result.latencyMs}ms</span>}
|
||||
</span>
|
||||
{result && (
|
||||
<button
|
||||
onClick={() => copyRes(resultJson)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
|
||||
{copiedRes ? "Copied" : "Copy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-70">
|
||||
{result ? resultJson : exConfig.defaultResponse}
|
||||
</pre>
|
||||
{kind === "image" && (binaryImageUrl || result?.data?.data?.[0]) && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-end mb-1.5">
|
||||
<a
|
||||
href={binaryImageUrl || (result?.data?.data?.[0]?.b64_json ? `data:image/png;base64,${result.data.data[0].b64_json}` : result?.data?.data?.[0]?.url || "")}
|
||||
download="image.png"
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">download</span>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<img
|
||||
src={binaryImageUrl || (result?.data?.data?.[0]?.b64_json ? `data:image/png;base64,${result.data.data[0].b64_json}` : result?.data?.data?.[0]?.url)}
|
||||
alt="Generated"
|
||||
className="max-w-full rounded-lg border border-border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+290
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { getProviderAlias } from "@/shared/constants/providers";
|
||||
import { getModelKind } from "@/shared/constants/models";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { Row } from "./exampleShared";
|
||||
|
||||
export function SttExampleCard({ providerId }) {
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
const builtinSttModels = getModelsByProviderId(providerId).filter((m) => getModelKind(m) === "stt");
|
||||
const [customSttModels, setCustomSttModels] = useState([]);
|
||||
const sttModels = [...builtinSttModels, ...customSttModels];
|
||||
|
||||
const [selectedModel, setSelectedModel] = useState(builtinSttModels[0]?.id ?? "");
|
||||
const selectedModelObj = sttModels.find((m) => m.id === selectedModel);
|
||||
const allowedParams = Array.isArray(selectedModelObj?.params) ? selectedModelObj.params : [];
|
||||
|
||||
const [audioFile, setAudioFile] = useState(null);
|
||||
const [language, setLanguage] = useState("");
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [responseFormat, setResponseFormat] = useState("json");
|
||||
const [temperature, setTemperature] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [useTunnel, setUseTunnel] = useState(false);
|
||||
const [localEndpoint, setLocalEndpoint] = useState("");
|
||||
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
|
||||
const [result, setResult] = useState(null);
|
||||
const [latency, setLatency] = useState(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
|
||||
const { copied: copiedRes, copy: copyRes } = useCopyToClipboard();
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEndpoint(window.location.origin);
|
||||
fetch("/api/keys")
|
||||
.then((r) => r.json())
|
||||
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
||||
.catch(() => {});
|
||||
fetch("/api/tunnel/status")
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
|
||||
.catch(() => {});
|
||||
const loadCustom = () => {
|
||||
fetch("/api/models/custom", { cache: "no-store" })
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
const list = (d.models || []).filter((m) => getModelKind(m) === "stt" && m.providerAlias === providerAlias);
|
||||
setCustomSttModels(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
loadCustom();
|
||||
window.addEventListener("focus", loadCustom);
|
||||
window.addEventListener("customModelChanged", loadCustom);
|
||||
return () => {
|
||||
window.removeEventListener("focus", loadCustom);
|
||||
window.removeEventListener("customModelChanged", loadCustom);
|
||||
};
|
||||
}, [providerAlias]);
|
||||
|
||||
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
|
||||
const modelFull = selectedModel ? `${providerAlias}/${selectedModel}` : "";
|
||||
|
||||
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/transcriptions \\
|
||||
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
|
||||
-F "file=@${audioFile?.name || "audio.mp3"}" \\
|
||||
-F "model=${modelFull}"${allowedParams.includes("language") && language ? ` \\\n -F "language=${language}"` : ""}${allowedParams.includes("response_format") ? ` \\\n -F "response_format=${responseFormat}"` : ""}${allowedParams.includes("temperature") && temperature ? ` \\\n -F "temperature=${temperature}"` : ""}${allowedParams.includes("prompt") && prompt ? ` \\\n -F "prompt=${prompt}"` : ""}`;
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!audioFile || !modelFull) return;
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setResult(null);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", audioFile);
|
||||
fd.append("model", modelFull);
|
||||
if (allowedParams.includes("language") && language) fd.append("language", language);
|
||||
if (allowedParams.includes("response_format")) fd.append("response_format", responseFormat);
|
||||
if (allowedParams.includes("temperature") && temperature) fd.append("temperature", temperature);
|
||||
if (allowedParams.includes("prompt") && prompt) fd.append("prompt", prompt);
|
||||
|
||||
const headers = {};
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch("/api/v1/audio/transcriptions", { method: "POST", headers, body: fd });
|
||||
setLatency(Date.now() - start);
|
||||
const ct = res.headers.get("content-type") || "";
|
||||
const data = ct.includes("application/json") ? await res.json() : await res.text();
|
||||
if (!res.ok) {
|
||||
setError(data?.error?.message || data?.error || data || `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
setResult(data);
|
||||
} catch (e) {
|
||||
setError(e.message || "Network error");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resultStr = typeof result === "string" ? result : (result ? JSON.stringify(result, null, 2) : `{\n "text": "Hello world..."\n}`);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Model */}
|
||||
{sttModels.length > 0 ? (
|
||||
<Row label="Model">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{sttModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
) : (
|
||||
<Row label="Model">
|
||||
<input
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
placeholder="Enter model id"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Endpoint */}
|
||||
<Row label="Endpoint">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
||||
{endpoint}/v1/audio/transcriptions
|
||||
</span>
|
||||
{tunnelEndpoint && (
|
||||
<button
|
||||
onClick={() => setUseTunnel((v) => !v)}
|
||||
title={useTunnel ? "Using tunnel" : "Using local"}
|
||||
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
||||
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
||||
Tunnel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* API Key */}
|
||||
<Row label="API Key">
|
||||
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
|
||||
{apiKey ? `${apiKey.slice(0, 8)}${"\u2022".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
{/* Audio file */}
|
||||
<Row label="Audio File">
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="file"
|
||||
accept="audio/*,video/mp4,.m4a,.mp3,.wav,.ogg,.flac,.webm,.opus"
|
||||
onChange={(e) => setAudioFile(e.target.files?.[0] || null)}
|
||||
className="w-full text-xs text-text-muted file:mr-2 file:py-1 file:px-2.5 file:rounded-lg file:border file:border-border file:bg-background file:text-text-main hover:file:bg-sidebar file:cursor-pointer"
|
||||
/>
|
||||
{audioFile && (
|
||||
<span className="text-xs text-text-muted font-mono">
|
||||
{audioFile.name} · {(audioFile.size / 1024).toFixed(1)} KB
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Language (if model supports) */}
|
||||
{allowedParams.includes("language") && (
|
||||
<Row label="Language">
|
||||
<input
|
||||
value={language}
|
||||
onChange={(e) => setLanguage(e.target.value)}
|
||||
placeholder="e.g. en, vi, ja (auto-detect if empty)"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Prompt (if model supports) */}
|
||||
{allowedParams.includes("prompt") && (
|
||||
<Row label="Prompt">
|
||||
<input
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="optional context to improve accuracy"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Temperature (if model supports) */}
|
||||
{allowedParams.includes("temperature") && (
|
||||
<Row label="Temperature">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
value={temperature}
|
||||
onChange={(e) => setTemperature(e.target.value)}
|
||||
placeholder="0 - 1 (default 0)"
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Response format (if model supports) */}
|
||||
{allowedParams.includes("response_format") && (
|
||||
<Row label="Response Format">
|
||||
<select
|
||||
value={responseFormat}
|
||||
onChange={(e) => setResponseFormat(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="json">json</option>
|
||||
<option value="text">text</option>
|
||||
<option value="srt">srt</option>
|
||||
<option value="verbose_json">verbose_json</option>
|
||||
<option value="vtt">vtt</option>
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Curl + Run */}
|
||||
<div className="mt-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={() => copyCurl(curlSnippet)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
||||
{copiedCurl ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={running || !audioFile || !modelFull}
|
||||
className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
play_arrow
|
||||
</span>
|
||||
{running ? "Transcribing..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
||||
|
||||
{/* Response */}
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
Response {result && latency && <span className="font-normal normal-case">⚡ {latency}ms</span>}
|
||||
</span>
|
||||
{result && (
|
||||
<button
|
||||
onClick={() => copyRes(resultStr)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedRes ? "check" : "content_copy"}</span>
|
||||
{copiedRes ? "Copied" : "Copy"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-70">
|
||||
{resultStr}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
+566
@@ -0,0 +1,566 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
import { AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders";
|
||||
import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js";
|
||||
import { GOOGLE_TTS_LANGUAGES } from "open-sse/config/googleTtsLanguages.js";
|
||||
import { Row } from "./exampleShared";
|
||||
|
||||
const DEFAULT_TTS_RESPONSE_EXAMPLE = `// Audio will appear here after running.
|
||||
// Example JSON response (response_format=json):
|
||||
{
|
||||
"format": "mp3",
|
||||
"audio": "//NExAANaAIIAUAAANNNNNNNN..." // base64 encoded MP3
|
||||
}`;
|
||||
|
||||
export function TtsExampleCard({ providerId }) {
|
||||
const providerAlias = getProviderAlias(providerId);
|
||||
const config = TTS_PROVIDER_CONFIG[providerId] || TTS_PROVIDER_CONFIG["edge-tts"];
|
||||
|
||||
// Voice state
|
||||
const [selectedVoice, setSelectedVoice] = useState(config.defaultVoiceId || "");
|
||||
const [selectedVoiceName, setSelectedVoiceName] = useState("");
|
||||
const [voiceId, setVoiceId] = useState(config.defaultVoiceId || ""); // editable voice id (elevenlabs/config providers)
|
||||
// Voices shown below Voice row after language selected
|
||||
const [countryVoices, setCountryVoices] = useState([]);
|
||||
const [selectedLang, setSelectedLang] = useState("");
|
||||
const [selectedModel, setSelectedModel] = useState(() => {
|
||||
const cfgModels = AI_PROVIDERS[providerId]?.ttsConfig?.models;
|
||||
if (cfgModels?.length) return cfgModels[0].id;
|
||||
if (config.hasModelSelector && config.modelKey) {
|
||||
const models = getModelsByProviderId(config.modelKey);
|
||||
return models?.[0]?.id || "";
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
// Form state
|
||||
const [input, setInput] = useState("Hello, this is a text to speech test.");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [useTunnel, setUseTunnel] = useState(false);
|
||||
const [localEndpoint, setLocalEndpoint] = useState("");
|
||||
const [tunnelEndpoint, setTunnelEndpoint] = useState("");
|
||||
const [responseFormat, setResponseFormat] = useState("mp3"); // mp3 | json
|
||||
const [audioUrl, setAudioUrl] = useState("");
|
||||
const [jsonResponse, setJsonResponse] = useState(null); // Store JSON response
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [latency, setLatency] = useState(null);
|
||||
const { copied: copiedCurl, copy: copyCurl } = useCopyToClipboard();
|
||||
|
||||
// Country picker modal state
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [languages, setLanguages] = useState([]);
|
||||
const [modalLoading, setModalLoading] = useState(false);
|
||||
const [modalSearch, setModalSearch] = useState("");
|
||||
const [modalError, setModalError] = useState("");
|
||||
const [byLang, setByLang] = useState({});
|
||||
// Language hint (e.g. Gemini): controls the spoken language without affecting voice selection
|
||||
const [languageHint, setLanguageHint] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLocalEndpoint(window.location.origin);
|
||||
fetch("/api/keys")
|
||||
.then((r) => r.json())
|
||||
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
|
||||
.catch(() => {});
|
||||
fetch("/api/tunnel/status")
|
||||
.then((r) => r.json())
|
||||
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
|
||||
.catch(() => {});
|
||||
|
||||
// Pre-select default voice based on provider config
|
||||
if (config.voiceSource === "hardcoded") {
|
||||
const defaultModel = config.hasModelSelector && config.modelKey
|
||||
? (getModelsByProviderId(config.modelKey)?.[0]?.id || "")
|
||||
: "";
|
||||
// Use per-model voices if available, else flat list
|
||||
const voices = (config.voicesPerModel && defaultModel)
|
||||
? (getTtsVoicesForModel(providerId, defaultModel) || [])
|
||||
: getModelsByProviderId(config.voiceKey || providerId).filter((m) => getModelKind(m) === "tts");
|
||||
if (voices.length) {
|
||||
if (config.hasBrowseButton) {
|
||||
// Google TTS: pre-select "en" (English) as default, show as single voice chip
|
||||
const defaultVoice = voices.find((v) => v.id === "en") || voices[0];
|
||||
setSelectedLang(defaultVoice.id);
|
||||
setSelectedVoice(defaultVoice.id);
|
||||
setSelectedVoiceName(defaultVoice.name);
|
||||
setCountryVoices([{ id: defaultVoice.id, name: defaultVoice.name }]);
|
||||
} else {
|
||||
// OpenAI/OpenRouter: set voice chips directly (no language picker)
|
||||
setCountryVoices(voices);
|
||||
setSelectedVoice(voices[0].id);
|
||||
setSelectedVoiceName(voices[0].name || voices[0].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// api-language (edge-tts, local-device, elevenlabs): NO default load, wait for user to pick language
|
||||
// config (nvidia, hyperbolic, deepgram, huggingface, cartesia, playht, coqui, tortoise, inworld, qwen):
|
||||
// use ttsConfig.models for model selector; voice is empty by default (backend uses provider default)
|
||||
}, [providerId]);
|
||||
|
||||
// Update voices when model changes (voicesPerModel providers)
|
||||
useEffect(() => {
|
||||
if (!config.voicesPerModel || !selectedModel) return;
|
||||
const voices = getTtsVoicesForModel(providerId, selectedModel) || [];
|
||||
setCountryVoices(voices);
|
||||
if (voices.length) {
|
||||
setSelectedVoice(voices[0].id);
|
||||
setSelectedVoiceName(voices[0].name || voices[0].id);
|
||||
}
|
||||
}, [selectedModel]);
|
||||
|
||||
// Open modal — load language list
|
||||
const openModal = async () => {
|
||||
setModalOpen(true);
|
||||
setModalSearch("");
|
||||
setModalError("");
|
||||
if (languages.length) return; // already loaded
|
||||
setModalLoading(true);
|
||||
try {
|
||||
if (config.voiceSource === "hardcoded") {
|
||||
// Build languages/byLang from static providerModels data
|
||||
const voiceKey = config.voiceKey || providerId;
|
||||
const voices = getModelsByProviderId(voiceKey).filter((m) => getModelKind(m) === "tts");
|
||||
const byLangMap = {};
|
||||
for (const v of voices) {
|
||||
if (!byLangMap[v.id]) byLangMap[v.id] = { code: v.id, name: v.name, voices: [{ id: v.id, name: v.name }] };
|
||||
}
|
||||
setByLang(byLangMap);
|
||||
setLanguages(Object.values(byLangMap).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
} else {
|
||||
// Use provider-specific apiEndpoint if available, else default to edge-tts voices API
|
||||
const url = config.apiEndpoint
|
||||
? config.apiEndpoint
|
||||
: `/api/media-providers/tts/voices?provider=${providerId === "local-device" ? "local-device" : "edge-tts"}`;
|
||||
const r = await fetch(url);
|
||||
const d = await r.json();
|
||||
if (d.error) { setModalError(d.error); return; }
|
||||
setLanguages(d.languages || []);
|
||||
setByLang(d.byLang || {});
|
||||
}
|
||||
} catch (e) {
|
||||
setModalError(e.message);
|
||||
} finally {
|
||||
setModalLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Click language → close modal → show voices below
|
||||
const handlePickLanguage = (lang) => {
|
||||
setModalOpen(false);
|
||||
setSelectedLang(lang.code);
|
||||
const voices = byLang[lang.code]?.voices || [];
|
||||
setCountryVoices(voices);
|
||||
// Auto-select first voice
|
||||
if (voices.length) {
|
||||
setSelectedVoice(voices[0].id);
|
||||
setSelectedVoiceName(voices[0].name);
|
||||
if (config.hasVoiceIdInput) setVoiceId(voices[0].id);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLanguages = modalSearch
|
||||
? languages.filter((c) =>
|
||||
c.name.toLowerCase().includes(modalSearch.toLowerCase()) ||
|
||||
c.code.toLowerCase().includes(modalSearch.toLowerCase())
|
||||
)
|
||||
: languages;
|
||||
|
||||
const endpoint = useTunnel ? tunnelEndpoint : localEndpoint;
|
||||
// For ElevenLabs/config-driven: prefer manual voiceId (if any), else fall back to selectedVoice
|
||||
const activeVoiceId = config.hasVoiceIdInput ? (voiceId || selectedVoice) : selectedVoice;
|
||||
const modelFull = (() => {
|
||||
if (config.hasModelSelector && selectedModel && activeVoiceId) return `${providerAlias}/${selectedModel}/${activeVoiceId}`;
|
||||
if (config.hasModelSelector && selectedModel) return `${providerAlias}/${selectedModel}`;
|
||||
if (activeVoiceId) return `${providerAlias}/${activeVoiceId}`;
|
||||
return "";
|
||||
})();
|
||||
|
||||
const ttsBody = (() => {
|
||||
const b = { model: modelFull, input };
|
||||
if (config.hasLanguageHint && languageHint) b.language = languageHint;
|
||||
return b;
|
||||
})();
|
||||
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer ${apiKey || "YOUR_KEY"}" \\
|
||||
-d '${JSON.stringify(ttsBody)}' \\
|
||||
${responseFormat === "json" ? "" : "--output speech.mp3"}`;
|
||||
|
||||
const handleRun = async () => {
|
||||
if (!input.trim() || !modelFull) return;
|
||||
setRunning(true);
|
||||
setError("");
|
||||
setAudioUrl("");
|
||||
setJsonResponse(null);
|
||||
const start = Date.now();
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const url = `/api/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""}`;
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ ...ttsBody, input: input.trim() }),
|
||||
});
|
||||
setLatency(Date.now() - start);
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
setError(d?.error?.message || d?.error || `HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (responseFormat === "json") {
|
||||
const data = await res.json();
|
||||
setJsonResponse(data); // Store full JSON response
|
||||
const audioBlob = await fetch(`data:audio/mp3;base64,${data.audio}`).then(r => r.blob());
|
||||
setAudioUrl(URL.createObjectURL(audioBlob));
|
||||
} else {
|
||||
const blob = await res.blob();
|
||||
setAudioUrl(URL.createObjectURL(blob));
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e.message || "Network error");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<h2 className="text-lg font-semibold mb-4">Example</h2>
|
||||
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{/* Endpoint + API Key as read-only text */}
|
||||
<Row label="Endpoint">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<span className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate">
|
||||
{endpoint}/v1/audio/speech
|
||||
</span>
|
||||
{tunnelEndpoint && (
|
||||
<button
|
||||
onClick={() => setUseTunnel((v) => !v)}
|
||||
title={useTunnel ? "Using tunnel" : "Using local"}
|
||||
className={`flex items-center gap-1 text-xs px-2 py-1.5 rounded-lg border shrink-0 transition-colors ${
|
||||
useTunnel ? "border-primary/40 bg-primary/10 text-primary" : "border-border text-text-muted hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">wifi_tethering</span>
|
||||
Tunnel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
<Row label="API Key">
|
||||
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
|
||||
{apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
|
||||
</span>
|
||||
</Row>
|
||||
|
||||
{/* Model selector — prefer PROVIDER_MODELS[kind=tts], else providerModels via modelKey */}
|
||||
{config.hasModelSelector && (config.modelKey || getModelsByProviderId(providerId).some(m => getModelKind(m) === "tts")) && (
|
||||
<Row label="Model">
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{(() => {
|
||||
const ttsModels = getModelsByProviderId(providerId).filter(m => getModelKind(m) === "tts");
|
||||
return (ttsModels.length ? ttsModels : getModelsByProviderId(config.modelKey) || []).map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
));
|
||||
})()}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */}
|
||||
{config.hasLanguageHint && (
|
||||
<Row label="Language">
|
||||
<select
|
||||
value={languageHint}
|
||||
onChange={(e) => setLanguageHint(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Auto-detect</option>
|
||||
{GOOGLE_TTS_LANGUAGES.map((l) => (
|
||||
<option key={l.id} value={l.name}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Language row + Browse button (edge-tts, local-device, elevenlabs) */}
|
||||
{config.hasBrowseButton && (
|
||||
<Row label="Language">
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="w-full min-w-0 flex-1 px-3 py-1.5 text-sm border border-border rounded-lg bg-background font-mono truncate text-left hover:border-primary/40 transition-colors"
|
||||
>
|
||||
{selectedLang
|
||||
? <span className="text-text-main">{languages.find((l) => l.code === selectedLang)?.name || selectedLang}</span>
|
||||
: <span className="text-text-muted">No language selected</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="flex w-full items-center justify-center gap-1 text-xs px-2.5 py-1.5 rounded-lg border border-border text-text-muted hover:text-primary hover:border-primary/40 transition-colors sm:w-auto sm:shrink-0"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">language</span>
|
||||
Select language
|
||||
</button>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */}
|
||||
{countryVoices.length > 0 && (
|
||||
<Row label="Voice">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{countryVoices.map((v) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => {
|
||||
setSelectedVoice(v.id);
|
||||
setSelectedVoiceName(v.name);
|
||||
if (config.hasVoiceIdInput) setVoiceId(v.id);
|
||||
}}
|
||||
className={`px-2.5 py-1 rounded-full text-xs border transition-colors ${
|
||||
selectedVoice === v.id
|
||||
? "bg-primary/15 border-primary/40 text-primary font-medium"
|
||||
: "border-border text-text-muted hover:text-primary hover:border-primary/40"
|
||||
}`}
|
||||
>
|
||||
{v.name}{v.gender ? ` · ${v.gender[0].toUpperCase()}` : ""}
|
||||
{v.free_users_allowed === true && (
|
||||
<span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-green-500/15 text-green-600 border border-green-500/20">Free</span>
|
||||
)}
|
||||
{v.free_users_allowed === false && (
|
||||
<span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-amber-500/15 text-amber-600 border border-amber-500/20">Paid</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Voice ID input (ElevenLabs) — manual entry or auto-fill from chip */}
|
||||
{config.hasVoiceIdInput && (
|
||||
<Row label="Voice ID">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={voiceId}
|
||||
onChange={(e) => {
|
||||
setVoiceId(e.target.value);
|
||||
setSelectedVoice(e.target.value);
|
||||
}}
|
||||
placeholder="e.g. CwhRBWXzGAHq8TQ4Fs17"
|
||||
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary font-mono"
|
||||
/>
|
||||
{voiceId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setVoiceId(""); setSelectedVoice(""); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Google TTS: Language dropdown */}
|
||||
{config.hasLanguageDropdown && (
|
||||
<Row label="Language">
|
||||
<select
|
||||
value={selectedVoice}
|
||||
onChange={(e) => {
|
||||
const m = getModelsByProviderId(providerId).filter((m) => getModelKind(m) === "tts").find((m) => m.id === e.target.value);
|
||||
setSelectedVoice(e.target.value);
|
||||
setSelectedVoiceName(m?.name || e.target.value);
|
||||
}}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
{getModelsByProviderId(providerId).filter((m) => getModelKind(m) === "tts").map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.name || m.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<Row label="Input">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
{input && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInput("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Output Format */}
|
||||
<Row label="Output Format">
|
||||
<select
|
||||
value={responseFormat}
|
||||
onChange={(e) => setResponseFormat(e.target.value)}
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
>
|
||||
<option value="mp3">MP3 (Binary)</option>
|
||||
<option value="json">JSON (Base64)</option>
|
||||
</select>
|
||||
</Row>
|
||||
|
||||
{/* Curl + Run */}
|
||||
<div className="mt-1">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Request</span>
|
||||
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={() => copyCurl(curlSnippet)}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">{copiedCurl ? "check" : "content_copy"}</span>
|
||||
{copiedCurl ? "Copied" : "Copy"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={running || !input.trim() || !modelFull}
|
||||
className="flex w-full sm:w-auto items-center justify-center gap-1.5 px-3 py-1 rounded-lg bg-primary text-white text-xs font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" style={running ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
play_arrow
|
||||
</span>
|
||||
{running ? "Generating..." : "Run"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">{curlSnippet}</pre>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500 break-words">{error}</p>}
|
||||
|
||||
{/* Audio player */}
|
||||
{audioUrl ? (
|
||||
<div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">
|
||||
Response {latency && <span className="font-normal normal-case">⚡ {latency}ms</span>}
|
||||
</span>
|
||||
<a href={audioUrl} download="speech.mp3" className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined text-[14px]">download</span>
|
||||
Download
|
||||
</a>
|
||||
</div>
|
||||
<audio controls src={audioUrl} className="w-full" />
|
||||
|
||||
{/* JSON Response (if format is json) */}
|
||||
{jsonResponse && (
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">JSON Response</span>
|
||||
</div>
|
||||
<pre className="bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify({
|
||||
format: jsonResponse.format,
|
||||
audio: jsonResponse.audio ? `${jsonResponse.audio.substring(0, 100)}...` : ""
|
||||
}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wider">Response</span>
|
||||
<pre className="mt-1.5 bg-sidebar rounded-lg px-3 py-2.5 text-xs font-mono text-text-main overflow-x-auto whitespace-pre-wrap break-all opacity-50">{DEFAULT_TTS_RESPONSE_EXAMPLE}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Country Picker Modal */}
|
||||
{modalOpen && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center sm:items-center"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.6)", backdropFilter: "blur(2px)" }}
|
||||
onClick={() => setModalOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="border border-border rounded-xl shadow-2xl w-full max-w-md mx-4 flex flex-col max-h-[80vh]"
|
||||
style={{ backgroundColor: "var(--color-bg)", isolation: "isolate" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0 rounded-t-xl">
|
||||
<h3 className="text-sm font-semibold">Select Language</h3>
|
||||
<button onClick={() => setModalOpen(false)} className="text-text-muted hover:text-primary transition-colors">
|
||||
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-4 py-2.5 border-b border-border shrink-0">
|
||||
<input
|
||||
autoFocus
|
||||
value={modalSearch}
|
||||
onChange={(e) => setModalSearch(e.target.value)}
|
||||
placeholder="Search language..."
|
||||
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Language list */}
|
||||
<div className="overflow-y-auto flex-1 p-2">
|
||||
{modalError && <p className="text-xs text-red-500 px-2 py-1">{modalError}</p>}
|
||||
{modalLoading ? (
|
||||
<p className="text-xs text-text-muted px-2 py-3">Loading...</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{filteredLanguages.map((c) => (
|
||||
<button
|
||||
key={c.code}
|
||||
onClick={() => handlePickLanguage(c)}
|
||||
className={`flex items-center justify-between w-full px-3 py-2 rounded-lg text-left hover:bg-sidebar transition-colors ${
|
||||
selectedLang === c.code ? "bg-primary/10 text-primary" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">{c.name}</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-xs text-text-muted">{c.voices.length} voices</span>
|
||||
{selectedLang === c.code && (
|
||||
<span className="material-symbols-outlined text-[16px] text-primary">check</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{filteredLanguages.length === 0 && (
|
||||
<p className="text-xs text-text-muted px-2 py-3">No languages found.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
export function Row({ label, children }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
|
||||
<span className="w-full text-xs font-medium text-text-muted sm:w-20 sm:shrink-0">{label}</span>
|
||||
<div className="w-full min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const KIND_EXAMPLE_CONFIG = {
|
||||
webSearch: {
|
||||
inputLabel: "Query",
|
||||
inputPlaceholder: "What is the latest news about AI?",
|
||||
defaultInput: "What is the latest news about AI?",
|
||||
bodyKey: "query",
|
||||
defaultResponse: `{\n "results": [\n { "title": "...", "url": "...", "snippet": "..." }\n ]\n}`,
|
||||
extraFields: [
|
||||
{ key: "search_type", label: "Type", type: "select", default: "web", options: ["web", "news"] },
|
||||
{ key: "max_results", label: "Max results", type: "number", default: 5, min: 1, max: 100 },
|
||||
{ key: "country", label: "Country", type: "text", default: "" },
|
||||
{ key: "language", label: "Language", type: "text", default: "" },
|
||||
],
|
||||
},
|
||||
webFetch: {
|
||||
inputLabel: "URL",
|
||||
inputPlaceholder: "https://example.com",
|
||||
defaultInput: "https://example.com",
|
||||
bodyKey: "url",
|
||||
defaultResponse: `{\n "content": "...",\n "title": "...",\n "url": "..."\n}`,
|
||||
extraFields: [
|
||||
{ key: "format", label: "Format", type: "select", default: "markdown", options: ["markdown", "text", "html"] },
|
||||
{ key: "max_characters", label: "Max chars", type: "number", default: 0, min: 0 },
|
||||
],
|
||||
},
|
||||
image: {
|
||||
inputLabel: "Prompt",
|
||||
inputPlaceholder: "A cute cat wearing a hat",
|
||||
defaultInput: "A cute cat wearing a hat",
|
||||
bodyKey: "prompt",
|
||||
defaultResponse: `{\n "data": [\n { "url": "...", "b64_json": "..." }\n ]\n}`,
|
||||
extraFields: [
|
||||
{ key: "n", label: "n", type: "number", default: 1, min: 1, max: 4 },
|
||||
{ key: "size", label: "Size", type: "select", default: "auto", options: ["auto", "1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"] },
|
||||
{ key: "quality", label: "Quality", type: "select", default: "auto", options: ["auto", "low", "medium", "high", "standard", "hd"] },
|
||||
{ key: "background", label: "Background", type: "select", default: "auto", options: ["auto", "transparent", "opaque"] },
|
||||
{ key: "style", label: "Style", type: "select", default: "", options: ["", "vivid", "natural"] },
|
||||
{ key: "response_format", label: "Format", type: "select", default: "", options: ["", "url", "b64_json"] },
|
||||
{ key: "image_detail", label: "Image Detail", type: "select", default: "high", options: ["auto", "low", "high", "original"] },
|
||||
{ key: "output_format", label: "Codec", type: "select", default: "png", options: ["png", "jpeg", "webp"] },
|
||||
],
|
||||
},
|
||||
imageToText: {
|
||||
inputLabel: "Image URL",
|
||||
inputPlaceholder: "https://example.com/image.png",
|
||||
defaultInput: "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg",
|
||||
bodyKey: "url",
|
||||
extraBody: { prompt: "Describe this image in detail" },
|
||||
defaultResponse: `{\n "text": "A cat sitting on a windowsill...",\n "model": "..."\n}`,
|
||||
},
|
||||
video: {
|
||||
inputLabel: "Prompt",
|
||||
inputPlaceholder: "A serene lake at sunset",
|
||||
defaultInput: "A serene lake at sunset",
|
||||
bodyKey: "prompt",
|
||||
defaultResponse: `{\n "data": [\n { "url": "..." }\n ]\n}`,
|
||||
},
|
||||
music: {
|
||||
inputLabel: "Prompt",
|
||||
inputPlaceholder: "A calm piano melody",
|
||||
defaultInput: "A calm piano melody",
|
||||
bodyKey: "prompt",
|
||||
defaultResponse: `{\n "data": [\n { "url": "...", "format": "mp3" }\n ]\n}`,
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus";
|
||||
import PropTypes from "prop-types";
|
||||
import { Badge, Toggle } from "@/shared/components";
|
||||
import CooldownTimer from "./CooldownTimer";
|
||||
@@ -107,12 +108,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
? "active" // Cooldown expired u2192 treat as active
|
||||
: connection.testStatus;
|
||||
|
||||
const getStatusVariant = () => {
|
||||
if (connection.isActive === false) return "default";
|
||||
if (effectiveStatus === "active" || effectiveStatus === "success") return "success";
|
||||
if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error";
|
||||
return "default";
|
||||
};
|
||||
const getStatusVariant = () => getConnectionStatusVariant(connection.isActive, effectiveStatus);
|
||||
|
||||
const getOneByOneVariant = () => {
|
||||
if (!oneByOneStatus) return "default";
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS, THINKING_CONFIG } from "@/shared/constants/providers";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import { translate } from "@/i18n/runtime";
|
||||
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
|
||||
@@ -914,7 +914,7 @@ export default function ProviderDetailPage() {
|
||||
const allModels = [
|
||||
...models,
|
||||
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
|
||||
].filter((m) => { const k = m.kind || m.type; return !k || k === "llm"; });
|
||||
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; });
|
||||
const disabledSet = new Set(disabledModelIds);
|
||||
const displayModels = allModels.filter((m) => !disabledSet.has(m.id));
|
||||
const disabledDisplayModels = allModels.filter((m) => disabledSet.has(m.id));
|
||||
@@ -1449,7 +1449,7 @@ export default function ProviderDetailPage() {
|
||||
const allIds = [
|
||||
...models,
|
||||
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
|
||||
].filter((m) => { const k = m.kind || m.type; return !k || k === "llm"; }).map((m) => m.id);
|
||||
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; }).map((m) => m.id);
|
||||
const activeIds = allIds.filter((id) => !disabledModelIds.includes(id));
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus";
|
||||
import PropTypes from "prop-types";
|
||||
import { Card, Badge, Button, Modal, Select, Toggle, EditConnectionModal, ConfirmModal } from "@/shared/components";
|
||||
|
||||
@@ -86,12 +87,7 @@ function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMov
|
||||
|
||||
const effectiveStatus = connection.testStatus === "unavailable" && !isCooldown ? "active" : connection.testStatus;
|
||||
|
||||
const getStatusVariant = () => {
|
||||
if (connection.isActive === false) return "default";
|
||||
if (effectiveStatus === "active" || effectiveStatus === "success") return "success";
|
||||
if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error";
|
||||
return "default";
|
||||
};
|
||||
const getStatusVariant = () => getConnectionStatusVariant(connection.isActive, effectiveStatus);
|
||||
|
||||
const displayName = isOAuth
|
||||
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Card, Button, Modal } from "@/shared/components";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
|
||||
import { getProviderAlias } from "@/shared/constants/providers";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
@@ -206,14 +206,14 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
|
||||
const builtInModels = kindFilter
|
||||
? allBuiltIn.filter((m) => {
|
||||
if (m.kinds) return m.kinds.includes(kindFilter);
|
||||
return (m.kind || m.type || "llm") === kindFilter;
|
||||
return getModelKind(m, "llm") === kindFilter;
|
||||
})
|
||||
: allBuiltIn;
|
||||
|
||||
// Custom models for this provider + kind, dedupe vs built-in
|
||||
const myCustomModels = customModels.filter(
|
||||
(m) => m.providerAlias === providerAlias
|
||||
&& (m.kind || m.type || "llm") === effectiveType
|
||||
&& getModelKind(m, "llm") === effectiveType
|
||||
&& !builtInModels.some((b) => b.id === m.id)
|
||||
);
|
||||
|
||||
|
||||
@@ -4,222 +4,39 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import ProviderIcon from "@/shared/components/ProviderIcon";
|
||||
import QuotaTable from "./QuotaTable";
|
||||
import Toggle from "@/shared/components/Toggle";
|
||||
import { parseQuotaData, calculatePercentage } from "./utils";
|
||||
import {
|
||||
parseQuotaData,
|
||||
calculatePercentage,
|
||||
getConnectionLabel,
|
||||
getConnectionQuotaRemaining,
|
||||
sortVisibleConnections,
|
||||
buildLoadingState,
|
||||
filterQuotaStateByConnections,
|
||||
getConnectionsEmptyMessage,
|
||||
getPageSizeLabel,
|
||||
getConnectionsPaginationSummary,
|
||||
getSafePagination,
|
||||
getSafeTotals,
|
||||
shouldResetPage,
|
||||
getPaginationPageValue,
|
||||
getProviderOptions,
|
||||
reconcileConnectionsPage,
|
||||
getQuotaCache,
|
||||
setQuotaCache,
|
||||
QUOTA_CACHE_KEY,
|
||||
REFRESH_INTERVAL_MS,
|
||||
DEPLETED_QUOTA_THRESHOLD,
|
||||
AUTO_REFRESH_STORAGE_KEY,
|
||||
CONNECTIONS_PAGE_SIZE,
|
||||
ACCOUNT_PAGE_SIZE_OPTIONS,
|
||||
ACCOUNT_PAGE_SIZE_MAX,
|
||||
ACCOUNT_FILTER_OPTIONS,
|
||||
QUOTA_SORT_OPTIONS,
|
||||
} from "./utils";
|
||||
import Card from "@/shared/components/Card";
|
||||
import { EditConnectionModal } from "@/shared/components";
|
||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
||||
|
||||
function getConnectionLabel(connection) {
|
||||
const isEmail = (value) =>
|
||||
typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
if (isEmail(connection.email)) return connection.email;
|
||||
if (isEmail(connection.name)) return connection.name;
|
||||
return connection.name;
|
||||
}
|
||||
|
||||
function getConnectionQuotaRemaining(connection, quotaData) {
|
||||
const quota = quotaData[connection.id]?.quotas?.[0];
|
||||
if (!quota) return Number.POSITIVE_INFINITY;
|
||||
if (typeof quota.remaining === "number") return quota.remaining;
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function sortVisibleConnections(
|
||||
connections,
|
||||
quotaData,
|
||||
expiringFirst,
|
||||
providerFilter,
|
||||
quotaSortMode,
|
||||
) {
|
||||
if (providerFilter === "codex" && quotaSortMode !== "default") {
|
||||
return [...connections].sort((a, b) => {
|
||||
const remainingA = getConnectionQuotaRemaining(a, quotaData);
|
||||
const remainingB = getConnectionQuotaRemaining(b, quotaData);
|
||||
const remainingDiff =
|
||||
quotaSortMode === "remaining-asc"
|
||||
? remainingA - remainingB
|
||||
: remainingB - remainingA;
|
||||
|
||||
if (remainingDiff !== 0) return remainingDiff;
|
||||
return (getConnectionLabel(a) || "").localeCompare(
|
||||
getConnectionLabel(b) || "",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!expiringFirst) return connections;
|
||||
|
||||
const getEarliestResetTime = (connection) => {
|
||||
const resetTimes = (quotaData[connection.id]?.quotas || [])
|
||||
.map((quota) =>
|
||||
quota.resetAt
|
||||
? new Date(quota.resetAt).getTime()
|
||||
: Number.POSITIVE_INFINITY,
|
||||
)
|
||||
.filter((time) => Number.isFinite(time));
|
||||
return resetTimes.length > 0
|
||||
? Math.min(...resetTimes)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
return [...connections].sort((a, b) => {
|
||||
const expiryDiff = getEarliestResetTime(a) - getEarliestResetTime(b);
|
||||
if (expiryDiff !== 0) return expiryDiff;
|
||||
return (
|
||||
(a.provider || "").localeCompare(b.provider || "") ||
|
||||
(getConnectionLabel(a) || "").localeCompare(getConnectionLabel(b) || "")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function buildLoadingState(connections) {
|
||||
const nextLoadingState = {};
|
||||
connections.forEach((connection) => {
|
||||
nextLoadingState[connection.id] = true;
|
||||
});
|
||||
return nextLoadingState;
|
||||
}
|
||||
|
||||
function filterQuotaStateByConnections(state, connections) {
|
||||
const visibleIds = new Set(connections.map((connection) => connection.id));
|
||||
return Object.fromEntries(
|
||||
Object.entries(state).filter(([id]) => visibleIds.has(id)),
|
||||
);
|
||||
}
|
||||
|
||||
function getConnectionsPageRange(pagination) {
|
||||
if (!pagination.total) {
|
||||
return { start: 0, end: 0 };
|
||||
}
|
||||
|
||||
const start = (pagination.page - 1) * pagination.pageSize + 1;
|
||||
const end = Math.min(pagination.page * pagination.pageSize, pagination.total);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function getConnectionsEmptyMessage(totals, providerFilter, accountFilter) {
|
||||
if (!totals.eligibleConnections) {
|
||||
return {
|
||||
icon: "cloud_off",
|
||||
title: "No Providers Connected",
|
||||
description:
|
||||
"Connect to providers with OAuth to track your API quota limits and usage.",
|
||||
};
|
||||
}
|
||||
|
||||
if (!totals.providerFilteredConnections) {
|
||||
return {
|
||||
icon: "filter_alt_off",
|
||||
title: "No Accounts Match Current Filters",
|
||||
description:
|
||||
providerFilter === "all"
|
||||
? "Try changing the account status filter to see more quota trackers."
|
||||
: `No ${accountFilter === "inactive" ? "turned off" : accountFilter === "active" ? "active" : "matching"} accounts found for ${providerFilter}.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
icon: "filter_alt_off",
|
||||
title: "No Accounts On This Page",
|
||||
description:
|
||||
"Try moving to another page or refreshing the current filters.",
|
||||
};
|
||||
}
|
||||
|
||||
function sortRequestFromExpiringFirst(expiringFirst) {
|
||||
return expiringFirst ? "expiring" : "priority";
|
||||
}
|
||||
|
||||
function getPageSizeLabel(pageSize, isCustomPageSize) {
|
||||
return isCustomPageSize ? `Custom: ${pageSize} / page` : `${pageSize} / page`;
|
||||
}
|
||||
|
||||
function getConnectionsPaginationSummary(pagination) {
|
||||
const { start, end } = getConnectionsPageRange(pagination);
|
||||
return `Showing ${start}-${end} of ${pagination.total}`;
|
||||
}
|
||||
|
||||
function getSafePagination(pagination, fallbackPageSize) {
|
||||
return (
|
||||
pagination || {
|
||||
page: 1,
|
||||
pageSize: fallbackPageSize,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getSafeTotals(totals, fallbackTotal = 0) {
|
||||
return (
|
||||
totals || {
|
||||
eligibleConnections: fallbackTotal,
|
||||
providerFilteredConnections: fallbackTotal,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function shouldResetPage(previousValue, nextValue) {
|
||||
return previousValue !== nextValue;
|
||||
}
|
||||
|
||||
function getPaginationPageValue(dataPagination, fallbackPage) {
|
||||
return dataPagination?.page || fallbackPage;
|
||||
}
|
||||
|
||||
function getProviderOptions(dataProviderOptions) {
|
||||
return dataProviderOptions || [];
|
||||
}
|
||||
|
||||
async function reconcileConnectionsPage(fetchConnections, targetPage) {
|
||||
const nextConnections = await fetchConnections(targetPage);
|
||||
return nextConnections;
|
||||
}
|
||||
|
||||
const QUOTA_CACHE_KEY = "quotaCacheData";
|
||||
|
||||
function getQuotaCache() {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const cached = window.localStorage.getItem(QUOTA_CACHE_KEY);
|
||||
return cached ? JSON.parse(cached) : {};
|
||||
} catch (error) {
|
||||
console.error("Error reading quota cache:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function setQuotaCache(connectionId, quotaEntry) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const cache = getQuotaCache();
|
||||
cache[connectionId] = {
|
||||
...quotaEntry,
|
||||
cachedAt: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem(QUOTA_CACHE_KEY, JSON.stringify(cache));
|
||||
} catch (error) {
|
||||
console.error("Error writing quota cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL_MS = 60000; // 60 seconds
|
||||
const DEPLETED_QUOTA_THRESHOLD = 5; // percent
|
||||
const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
|
||||
const ACCOUNT_FILTER_OPTIONS = [
|
||||
{ value: "all", label: "All accounts" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "inactive", label: "Turned off" },
|
||||
];
|
||||
const QUOTA_SORT_OPTIONS = [
|
||||
{ value: "default", label: "Default quota order" },
|
||||
{ value: "remaining-asc", label: "% quota: low to high" },
|
||||
{ value: "remaining-desc", label: "% quota: high to low" },
|
||||
];
|
||||
const CONNECTIONS_PAGE_SIZE = 20;
|
||||
const ACCOUNT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
const ACCOUNT_PAGE_SIZE_MAX = 500;
|
||||
|
||||
export default function ProviderLimits() {
|
||||
const [connections, setConnections] = useState([]);
|
||||
const [quotaData, setQuotaData] = useState({});
|
||||
|
||||
@@ -1,5 +1,212 @@
|
||||
import { getModelsByProviderId } from "open-sse/config/providerModels.js";
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
export const QUOTA_CACHE_KEY = "quotaCacheData";
|
||||
export const REFRESH_INTERVAL_MS = 60000;
|
||||
export const DEPLETED_QUOTA_THRESHOLD = 5;
|
||||
export const AUTO_REFRESH_STORAGE_KEY = "quotaAutoRefresh";
|
||||
export const CONNECTIONS_PAGE_SIZE = 20;
|
||||
export const ACCOUNT_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
export const ACCOUNT_PAGE_SIZE_MAX = 500;
|
||||
export const ACCOUNT_FILTER_OPTIONS = [
|
||||
{ value: "all", label: "All accounts" },
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "inactive", label: "Turned off" },
|
||||
];
|
||||
export const QUOTA_SORT_OPTIONS = [
|
||||
{ value: "default", label: "Default quota order" },
|
||||
{ value: "remaining-asc", label: "% quota: low to high" },
|
||||
{ value: "remaining-desc", label: "% quota: high to low" },
|
||||
];
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
export function getConnectionLabel(connection) {
|
||||
const isEmail = (value) =>
|
||||
typeof value === "string" && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
if (isEmail(connection.email)) return connection.email;
|
||||
if (isEmail(connection.name)) return connection.name;
|
||||
return connection.name;
|
||||
}
|
||||
|
||||
export function getConnectionQuotaRemaining(connection, quotaData) {
|
||||
const quota = quotaData[connection.id]?.quotas?.[0];
|
||||
if (!quota) return Number.POSITIVE_INFINITY;
|
||||
if (typeof quota.remaining === "number") return quota.remaining;
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
export function sortVisibleConnections(
|
||||
connections,
|
||||
quotaData,
|
||||
expiringFirst,
|
||||
providerFilter,
|
||||
quotaSortMode,
|
||||
) {
|
||||
if (providerFilter === "codex" && quotaSortMode !== "default") {
|
||||
return [...connections].sort((a, b) => {
|
||||
const remainingA = getConnectionQuotaRemaining(a, quotaData);
|
||||
const remainingB = getConnectionQuotaRemaining(b, quotaData);
|
||||
const remainingDiff =
|
||||
quotaSortMode === "remaining-asc"
|
||||
? remainingA - remainingB
|
||||
: remainingB - remainingA;
|
||||
if (remainingDiff !== 0) return remainingDiff;
|
||||
return (getConnectionLabel(a) || "").localeCompare(
|
||||
getConnectionLabel(b) || "",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!expiringFirst) return connections;
|
||||
|
||||
const getEarliestResetTime = (connection) => {
|
||||
const resetTimes = (quotaData[connection.id]?.quotas || [])
|
||||
.map((quota) =>
|
||||
quota.resetAt
|
||||
? new Date(quota.resetAt).getTime()
|
||||
: Number.POSITIVE_INFINITY,
|
||||
)
|
||||
.filter((time) => Number.isFinite(time));
|
||||
return resetTimes.length > 0
|
||||
? Math.min(...resetTimes)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
};
|
||||
|
||||
return [...connections].sort((a, b) => {
|
||||
const expiryDiff = getEarliestResetTime(a) - getEarliestResetTime(b);
|
||||
if (expiryDiff !== 0) return expiryDiff;
|
||||
return (
|
||||
(a.provider || "").localeCompare(b.provider || "") ||
|
||||
(getConnectionLabel(a) || "").localeCompare(getConnectionLabel(b) || "")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLoadingState(connections) {
|
||||
const nextLoadingState = {};
|
||||
connections.forEach((connection) => {
|
||||
nextLoadingState[connection.id] = true;
|
||||
});
|
||||
return nextLoadingState;
|
||||
}
|
||||
|
||||
export function filterQuotaStateByConnections(state, connections) {
|
||||
const visibleIds = new Set(connections.map((connection) => connection.id));
|
||||
return Object.fromEntries(
|
||||
Object.entries(state).filter(([id]) => visibleIds.has(id)),
|
||||
);
|
||||
}
|
||||
|
||||
export function getConnectionsPageRange(pagination) {
|
||||
if (!pagination.total) {
|
||||
return { start: 0, end: 0 };
|
||||
}
|
||||
const start = (pagination.page - 1) * pagination.pageSize + 1;
|
||||
const end = Math.min(pagination.page * pagination.pageSize, pagination.total);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function getConnectionsEmptyMessage(totals, providerFilter, accountFilter) {
|
||||
if (!totals.eligibleConnections) {
|
||||
return {
|
||||
icon: "cloud_off",
|
||||
title: "No Providers Connected",
|
||||
description:
|
||||
"Connect to providers with OAuth to track your API quota limits and usage.",
|
||||
};
|
||||
}
|
||||
if (!totals.providerFilteredConnections) {
|
||||
return {
|
||||
icon: "filter_alt_off",
|
||||
title: "No Accounts Match Current Filters",
|
||||
description:
|
||||
providerFilter === "all"
|
||||
? "Try changing the account status filter to see more quota trackers."
|
||||
: `No ${accountFilter === "inactive" ? "turned off" : accountFilter === "active" ? "active" : "matching"} accounts found for ${providerFilter}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: "filter_alt_off",
|
||||
title: "No Accounts On This Page",
|
||||
description:
|
||||
"Try moving to another page or refreshing the current filters.",
|
||||
};
|
||||
}
|
||||
|
||||
export function sortRequestFromExpiringFirst(expiringFirst) {
|
||||
return expiringFirst ? "expiring" : "priority";
|
||||
}
|
||||
|
||||
export function getPageSizeLabel(pageSize, isCustomPageSize) {
|
||||
return isCustomPageSize ? `Custom: ${pageSize} / page` : `${pageSize} / page`;
|
||||
}
|
||||
|
||||
export function getConnectionsPaginationSummary(pagination) {
|
||||
const { start, end } = getConnectionsPageRange(pagination);
|
||||
return `Showing ${start}-${end} of ${pagination.total}`;
|
||||
}
|
||||
|
||||
export function getSafePagination(pagination, fallbackPageSize) {
|
||||
return (
|
||||
pagination || {
|
||||
page: 1,
|
||||
pageSize: fallbackPageSize,
|
||||
total: 0,
|
||||
totalPages: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function getSafeTotals(totals, fallbackTotal = 0) {
|
||||
return (
|
||||
totals || {
|
||||
eligibleConnections: fallbackTotal,
|
||||
providerFilteredConnections: fallbackTotal,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldResetPage(previousValue, nextValue) {
|
||||
return previousValue !== nextValue;
|
||||
}
|
||||
|
||||
export function getPaginationPageValue(dataPagination, fallbackPage) {
|
||||
return dataPagination?.page || fallbackPage;
|
||||
}
|
||||
|
||||
export function getProviderOptions(dataProviderOptions) {
|
||||
return dataProviderOptions || [];
|
||||
}
|
||||
|
||||
export async function reconcileConnectionsPage(fetchConnections, targetPage) {
|
||||
return await fetchConnections(targetPage);
|
||||
}
|
||||
|
||||
export function getQuotaCache() {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const cached = window.localStorage.getItem(QUOTA_CACHE_KEY);
|
||||
return cached ? JSON.parse(cached) : {};
|
||||
} catch (error) {
|
||||
console.error("Error reading quota cache:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function setQuotaCache(connectionId, quotaEntry) {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
const cache = getQuotaCache();
|
||||
cache[connectionId] = {
|
||||
...quotaEntry,
|
||||
cachedAt: new Date().toISOString(),
|
||||
};
|
||||
window.localStorage.setItem(QUOTA_CACHE_KEY, JSON.stringify(cache));
|
||||
} catch (error) {
|
||||
console.error("Error writing quota cache:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit)
|
||||
* @param {string|Date} date - ISO date string or Date object
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PROVIDER_MODELS } from "open-sse/config/providerModels.js";
|
||||
import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers";
|
||||
import { getModelKind } from "@/shared/constants/models";
|
||||
|
||||
const KIND_ENDPOINT = {
|
||||
llm: "/v1/chat/completions",
|
||||
@@ -52,10 +53,10 @@ function lookup(fullId, requestedKind) {
|
||||
// PROVIDER_MODELS lookup (by alias key, fallback to providerId)
|
||||
const list = PROVIDER_MODELS[alias] || PROVIDER_MODELS[providerId] || [];
|
||||
const m = requestedKind
|
||||
? list.find((x) => x.id === modelId && (x.kind || x.type || "llm") === requestedKind)
|
||||
? list.find((x) => x.id === modelId && getModelKind(x, "llm") === requestedKind)
|
||||
: list.find((x) => x.id === modelId);
|
||||
if (m) {
|
||||
const kind = m.kind || m.type || "llm";
|
||||
const kind = getModelKind(m, "llm");
|
||||
return buildInfo({ alias, providerId, model: m, kind, providerInfo });
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models";
|
||||
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS, getModelKind } from "@/shared/constants/models";
|
||||
import {
|
||||
AI_PROVIDERS,
|
||||
getProviderAlias,
|
||||
@@ -316,7 +316,7 @@ export async function buildModelsList(kindFilter) {
|
||||
|
||||
const customModelIds = customModels
|
||||
.filter((m) => {
|
||||
if (!m?.id || ((m.kind || m.type) && (m.kind || m.type) !== "llm")) return false;
|
||||
if (!m?.id || (getModelKind(m) && getModelKind(m) !== "llm")) return false;
|
||||
const alias = m.providerAlias;
|
||||
return alias === staticAlias || alias === outputAlias || alias === providerId;
|
||||
})
|
||||
|
||||
@@ -16,8 +16,8 @@ async function getObservabilityConfig() {
|
||||
const { getSettings } = await import("./settingsRepo.js");
|
||||
const settings = await getSettings();
|
||||
const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false";
|
||||
const enabled = typeof settings.enableObservability === "boolean"
|
||||
? settings.enableObservability
|
||||
const enabled = typeof settings.enableObservability2 === "boolean"
|
||||
? settings.enableObservability2
|
||||
: envEnabled;
|
||||
cachedConfig = {
|
||||
enabled,
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
const BASE64_BLOCK_SIZE = 4;
|
||||
|
||||
function validateXaiOAuthEndpoint(rawUrl, field) {
|
||||
const value = String(rawUrl || "").trim();
|
||||
if (!value) throw new Error(`xai discovery ${field} is empty`);
|
||||
let parsed;
|
||||
try { parsed = new URL(value); } catch (err) {
|
||||
throw new Error(`xai discovery ${field} is invalid: ${err.message}`);
|
||||
}
|
||||
if (parsed.protocol !== "https:") throw new Error(`xai discovery ${field} must use https: ${value}`);
|
||||
const host = parsed.hostname.toLowerCase().trim();
|
||||
if (host !== "x.ai" && !host.endsWith(".x.ai")) {
|
||||
throw new Error(`xai discovery ${field} host ${host} is not on x.ai`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function decodeXaiIdTokenEmail(idToken) {
|
||||
if (!idToken || typeof idToken !== "string") return undefined;
|
||||
const parts = idToken.split(".");
|
||||
if (parts.length !== 3) return undefined;
|
||||
try {
|
||||
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE;
|
||||
const json = Buffer.from(base64 + "=".repeat(padding), "base64").toString("utf8");
|
||||
const payload = JSON.parse(json);
|
||||
return payload.email || payload.preferred_username || payload.sub || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function decodeJwtPayload(jwt) {
|
||||
try {
|
||||
if (!jwt || typeof jwt !== "string") return null;
|
||||
const parts = jwt.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const missingPadding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE;
|
||||
const padded = base64 + "=".repeat(missingPadding);
|
||||
return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractEmailFromAccessToken(accessToken) {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
if (!payload) return undefined;
|
||||
return payload.email || payload.preferred_username || payload.sub || undefined;
|
||||
}
|
||||
|
||||
export async function fetchKiroProfileArn(accessToken) {
|
||||
if (!accessToken) return null;
|
||||
try {
|
||||
const response = await fetch("https://codewhisperer.us-east-1.amazonaws.com/ListAvailableProfiles", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ maxResults: 10 }),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data.profiles?.find((p) => p.arn?.trim())?.arn?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractCodexAccountInfo(idToken) {
|
||||
const payload = decodeJwtPayload(idToken);
|
||||
if (!payload) return {};
|
||||
const chatgpt = payload["https://api.openai.com/auth"] || {};
|
||||
return {
|
||||
email: payload.email,
|
||||
chatgptAccountId: chatgpt.chatgpt_account_id || payload.account_id,
|
||||
chatgptPlanType: chatgpt.chatgpt_plan_type || payload.plan_type,
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
BASE64_BLOCK_SIZE,
|
||||
validateXaiOAuthEndpoint,
|
||||
decodeXaiIdTokenEmail,
|
||||
decodeJwtPayload,
|
||||
extractEmailFromAccessToken,
|
||||
};
|
||||
@@ -27,25 +27,19 @@ import {
|
||||
getOAuthClientMetadata,
|
||||
} from "./constants/oauth";
|
||||
import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai";
|
||||
import {
|
||||
validateXaiOAuthEndpoint,
|
||||
decodeXaiIdTokenEmail,
|
||||
extractEmailFromAccessToken,
|
||||
extractCodexAccountInfo,
|
||||
fetchKiroProfileArn,
|
||||
} from "./providerHelpers";
|
||||
|
||||
export { extractCodexAccountInfo, fetchKiroProfileArn };
|
||||
|
||||
// Inlined from services/xai.js to keep web route bundle free of `open` (CLI-only) package
|
||||
let cachedXaiDiscovery = null;
|
||||
|
||||
function validateXaiOAuthEndpoint(rawUrl, field) {
|
||||
const value = String(rawUrl || "").trim();
|
||||
if (!value) throw new Error(`xai discovery ${field} is empty`);
|
||||
let parsed;
|
||||
try { parsed = new URL(value); } catch (err) {
|
||||
throw new Error(`xai discovery ${field} is invalid: ${err.message}`);
|
||||
}
|
||||
if (parsed.protocol !== "https:") throw new Error(`xai discovery ${field} must use https: ${value}`);
|
||||
const host = parsed.hostname.toLowerCase().trim();
|
||||
if (host !== "x.ai" && !host.endsWith(".x.ai")) {
|
||||
throw new Error(`xai discovery ${field} host ${host} is not on x.ai`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function discoverXaiEndpoints() {
|
||||
if (cachedXaiDiscovery) return cachedXaiDiscovery;
|
||||
try {
|
||||
@@ -63,81 +57,6 @@ async function discoverXaiEndpoints() {
|
||||
return cachedXaiDiscovery;
|
||||
}
|
||||
|
||||
function decodeXaiIdTokenEmail(idToken) {
|
||||
if (!idToken || typeof idToken !== "string") return undefined;
|
||||
const parts = idToken.split(".");
|
||||
if (parts.length !== 3) return undefined;
|
||||
try {
|
||||
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const padding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE;
|
||||
const json = Buffer.from(base64 + "=".repeat(padding), "base64").toString("utf8");
|
||||
const payload = JSON.parse(json);
|
||||
return payload.email || payload.preferred_username || payload.sub || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const BASE64_BLOCK_SIZE = 4;
|
||||
|
||||
/**
|
||||
* Decode JWT access token and extract a stable account identifier for display/upsert.
|
||||
* @param {string} accessToken
|
||||
* @returns {string|undefined}
|
||||
*/
|
||||
function decodeJwtPayload(jwt) {
|
||||
try {
|
||||
if (!jwt || typeof jwt !== "string") return null;
|
||||
const parts = jwt.split(".");
|
||||
if (parts.length !== 3) return null;
|
||||
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
||||
const missingPadding = (BASE64_BLOCK_SIZE - (base64.length % BASE64_BLOCK_SIZE)) % BASE64_BLOCK_SIZE;
|
||||
const padded = base64 + "=".repeat(missingPadding);
|
||||
return JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractEmailFromAccessToken(accessToken) {
|
||||
const payload = decodeJwtPayload(accessToken);
|
||||
if (!payload) return undefined;
|
||||
return payload.email || payload.preferred_username || payload.sub || undefined;
|
||||
}
|
||||
|
||||
// Resolve Kiro profileArn via CodeWhisperer (IDC/Builder-ID tokens omit it, causing 403)
|
||||
export async function fetchKiroProfileArn(accessToken) {
|
||||
if (!accessToken) return null;
|
||||
try {
|
||||
const response = await fetch("https://codewhisperer.us-east-1.amazonaws.com/ListAvailableProfiles", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({ maxResults: 10 }),
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
const data = await response.json();
|
||||
return data.profiles?.find((p) => p.arn?.trim())?.arn?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract codex account info from id_token or access token
|
||||
export function extractCodexAccountInfo(idToken) {
|
||||
const payload = decodeJwtPayload(idToken);
|
||||
if (!payload) return {};
|
||||
const chatgpt = payload["https://api.openai.com/auth"] || {};
|
||||
return {
|
||||
email: payload.email,
|
||||
chatgptAccountId: chatgpt.chatgpt_account_id || payload.account_id,
|
||||
chatgptPlanType: chatgpt.chatgpt_plan_type || payload.plan_type,
|
||||
};
|
||||
}
|
||||
|
||||
// Provider configurations
|
||||
const PROVIDERS = {
|
||||
claude: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useMemo, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import Modal from "./Modal";
|
||||
import ProviderIcon from "./ProviderIcon";
|
||||
import { getModelsByProviderId } from "@/shared/constants/models";
|
||||
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
|
||||
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, AI_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, getProviderAlias } from "@/shared/constants/providers";
|
||||
|
||||
// Provider order: OAuth first, then Free Tier, then API Key (matches dashboard/providers)
|
||||
@@ -123,12 +123,11 @@ export default function ModelSelectModal({
|
||||
// For these kinds, providers without hardcoded models can still be picked (provider-as-model fallback)
|
||||
const ALLOW_PROVIDER_FALLBACK_KINDS = new Set(["tts", "image", "webFetch"]);
|
||||
|
||||
const mKind = (m) => m.kind || m.type;
|
||||
// Filter a models[] array by kindFilter (keep only matching kind)
|
||||
const filterByKind = (models) => {
|
||||
if (!kindFilter) return models.filter((m) => m.isPlaceholder || !mKind(m) || mKind(m) === "llm");
|
||||
if (!kindFilter) return models.filter((m) => m.isPlaceholder || !getModelKind(m) || getModelKind(m) === "llm");
|
||||
if (!TYPED_KINDS.has(kindFilter)) return models;
|
||||
return models.filter((m) => m.isPlaceholder || mKind(m) === kindFilter);
|
||||
return models.filter((m) => m.isPlaceholder || getModelKind(m) === kindFilter);
|
||||
};
|
||||
|
||||
// Get all active provider IDs from connections (filtered by kindFilter if set)
|
||||
|
||||
@@ -36,3 +36,5 @@ export function isValidModel(aliasOrId, modelId) {
|
||||
export const AI_MODELS = Object.entries(MODELS).flatMap(([alias, models]) =>
|
||||
models.map(m => ({ provider: alias, model: m.id, name: m.name }))
|
||||
);
|
||||
|
||||
export const getModelKind = (m, fallback = null) => m?.kind || m?.type || fallback;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export function getStatusVariant(isActive, effectiveStatus) {
|
||||
if (isActive === false) return "default";
|
||||
if (effectiveStatus === "active" || effectiveStatus === "success") return "success";
|
||||
if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error";
|
||||
return "default";
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user