mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +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 { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS } from "../config/providers.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
import { SSE_DONE, SSE_HEADERS_NO_BUFFER } from "../utils/sseConstants.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_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";
|
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 };
|
yield { done: true, fingerprint, responseId };
|
||||||
}
|
}
|
||||||
|
|
||||||
function sseChunk(data) {
|
|
||||||
return `data: ${JSON.stringify(data)}\n\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildStreamingResponse(eventStream, model, cid, created, isThinkingModel, signal) {
|
function buildStreamingResponse(eventStream, model, cid, created, isThinkingModel, signal) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
return new ReadableStream({
|
return new ReadableStream({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BaseExecutor } from "./base.js";
|
import { BaseExecutor } from "./base.js";
|
||||||
import { PROVIDERS } from "../config/providers.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
import { SSE_DONE, SSE_HEADERS_NO_BUFFER } from "../utils/sseConstants.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_SSE_ENDPOINT = PROVIDERS["perplexity-web"].baseUrl;
|
||||||
const PPLX_API_VERSION = "2.18";
|
const PPLX_API_VERSION = "2.18";
|
||||||
@@ -290,10 +291,6 @@ async function* extractContent(eventStream, signal) {
|
|||||||
yield { delta: "", answer: fullAnswer, backendUuid: backendUuid ?? undefined, done: true };
|
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) {
|
function buildStreamingResponse(eventStream, model, cid, created, history, currentMsg, signal) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
return new ReadableStream({
|
return new ReadableStream({
|
||||||
|
|||||||
@@ -1,73 +1,35 @@
|
|||||||
import { PROVIDERS, PROVIDER_OAUTH } from "../config/providers.js";
|
import { PROVIDERS } from "../config/providers.js";
|
||||||
import { OAUTH_ENDPOINTS, GITHUB_COPILOT, REFRESH_LEAD_MS } from "../config/appConstants.js";
|
import { OAUTH_ENDPOINTS, REFRESH_LEAD_MS } from "../config/appConstants.js";
|
||||||
import { proxyAwareFetch } from "../utils/proxyFetch.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
|
// Re-export all provider refresh functions (preserves public API for all consumers)
|
||||||
// the token-refresh switches below can stay flat (one function per provider).
|
export {
|
||||||
let _xaiServiceSingleton = null;
|
refreshAccessToken,
|
||||||
async function refreshXaiToken(refreshToken, log) {
|
refreshClaudeOAuthToken,
|
||||||
if (!refreshToken) return null;
|
refreshGoogleToken,
|
||||||
return dedupRefresh("xai", refreshToken, async () => {
|
refreshQwenToken,
|
||||||
try {
|
refreshCodexToken,
|
||||||
if (!_xaiServiceSingleton) {
|
refreshKiroToken,
|
||||||
const mod = await import("../../src/lib/oauth/services/xai.js");
|
refreshIflowToken,
|
||||||
_xaiServiceSingleton = new mod.XaiService();
|
refreshGitHubToken,
|
||||||
}
|
refreshCopilotToken,
|
||||||
const tokens = await _xaiServiceSingleton.refreshAccessToken(refreshToken);
|
classifyOAuthRefreshError,
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default token expiry buffer (refresh if expires within 5 minutes)
|
|
||||||
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
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) {
|
export function isUnrecoverableRefreshError(result) {
|
||||||
return (
|
return (
|
||||||
result &&
|
result &&
|
||||||
@@ -79,543 +41,82 @@ export function isUnrecoverableRefreshError(result) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get provider-specific refresh lead time, falls back to default buffer
|
|
||||||
export function getRefreshLeadMs(provider) {
|
export function getRefreshLeadMs(provider) {
|
||||||
return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS;
|
return REFRESH_LEAD_MS[provider] || TOKEN_EXPIRY_BUFFER_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function parseVertexSaJson(apiKey) {
|
||||||
* Refresh OAuth access token using refresh token
|
if (typeof apiKey !== "string") return null;
|
||||||
*/
|
|
||||||
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 {
|
try {
|
||||||
const response = await fetch(config.refreshUrl, {
|
const parsed = JSON.parse(apiKey);
|
||||||
method: "POST",
|
if (parsed.type === "service_account" && parsed.client_email && parsed.private_key && parsed.project_id) {
|
||||||
headers: {
|
return parsed;
|
||||||
"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;
|
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 {
|
} 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;
|
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Cache Vertex tokens keyed by service account email { token, expiresAt }
|
||||||
* Specialized refresh for iFlow OAuth tokens
|
const vertexTokenCache = new Map();
|
||||||
*/
|
|
||||||
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, {
|
export async function refreshVertexToken(saJson, log) {
|
||||||
method: "POST",
|
const cacheKey = saJson.client_email;
|
||||||
headers: {
|
const cached = vertexTokenCache.get(cacheKey);
|
||||||
"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) {
|
if (cached && cached.expiresAt - Date.now() > 5 * 60 * 1000) {
|
||||||
const errorText = await response.text();
|
return { accessToken: cached.token, expiresAt: cached.expiresAt };
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 {
|
try {
|
||||||
const response = await fetch(PROVIDER_OAUTH["github"]?.copilotTokenUrl, {
|
const { SignJWT, importPKCS8 } = await import("jose");
|
||||||
headers: {
|
log?.debug?.("TOKEN_REFRESH", `Vertex minting token for ${saJson.client_email}`);
|
||||||
"Authorization": `token ${githubAccessToken}`,
|
const privateKey = await importPKCS8(saJson.private_key.replace(/\\n/g, "\n"), "RS256");
|
||||||
"User-Agent": GITHUB_COPILOT.USER_AGENT,
|
const now = Math.floor(Date.now() / 1000);
|
||||||
"Editor-Version": `vscode/${GITHUB_COPILOT.VSCODE_VERSION}`,
|
|
||||||
"Editor-Plugin-Version": `copilot-chat/${GITHUB_COPILOT.COPILOT_CHAT_VERSION}`,
|
const jwt = await new SignJWT({ scope: "https://www.googleapis.com/auth/cloud-platform" })
|
||||||
"Accept": "application/json",
|
.setProtectedHeader({ alg: "RS256" })
|
||||||
"x-github-api-version": GITHUB_COPILOT.API_VERSION
|
.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) {
|
if (!res.ok) {
|
||||||
const errorText = await response.text();
|
const err = await res.text();
|
||||||
log?.error?.("TOKEN_REFRESH", "Failed to refresh Copilot token", {
|
log?.error?.("TOKEN_REFRESH", `Vertex token mint failed: ${err}`);
|
||||||
status: response.status,
|
|
||||||
error: errorText
|
|
||||||
});
|
|
||||||
return null;
|
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", {
|
vertexTokenCache.set(cacheKey, { token: access_token, expiresAt });
|
||||||
hasToken: !!data.token,
|
log?.info?.("TOKEN_REFRESH", `Vertex token minted for ${saJson.client_email}`);
|
||||||
expiresAt: data.expires_at
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return { accessToken: access_token, expiresAt };
|
||||||
token: data.token,
|
|
||||||
expiresAt: data.expires_at
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log?.error?.("TOKEN_REFRESH", "Error refreshing Copilot token", {
|
log?.error?.("TOKEN_REFRESH", `Vertex token error: ${error.message}`);
|
||||||
error: error.message
|
|
||||||
});
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, log);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Single source of per-provider refresh dispatch (logic stays in each refreshXxx fn).
|
function vertexRefreshHandler(c, log) {
|
||||||
// Each handler: (credentials, log) => Promise<tokens|null>
|
const saJson = parseVertexSaJson(c.apiKey);
|
||||||
|
if (!saJson) return null;
|
||||||
|
return refreshVertexToken(saJson, log);
|
||||||
|
}
|
||||||
|
|
||||||
const REFRESH_HANDLERS = {
|
const REFRESH_HANDLERS = {
|
||||||
"gemini-cli": (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS["gemini-cli"].clientId, PROVIDERS["gemini-cli"].clientSecret, log),
|
"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),
|
antigravity: (c, log) => refreshGoogleToken(c.refreshToken, PROVIDERS.antigravity.clientId, PROVIDERS.antigravity.clientSecret, log),
|
||||||
@@ -630,28 +131,15 @@ const REFRESH_HANDLERS = {
|
|||||||
"vertex-partner": vertexRefreshHandler
|
"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) {
|
export async function getAccessToken(provider, credentials, log) {
|
||||||
if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") {
|
if (!credentials || !credentials.refreshToken || typeof credentials.refreshToken !== "string") {
|
||||||
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
|
log?.warn?.("TOKEN_REFRESH", `No valid refresh token available for provider: ${provider}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Dedup is handled inside each refreshXxxToken function
|
|
||||||
return _getAccessTokenInternal(provider, credentials, log);
|
return _getAccessTokenInternal(provider, credentials, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _getAccessTokenInternal(provider, credentials, log) {
|
async function _getAccessTokenInternal(provider, credentials, log) {
|
||||||
// "gemini" shares Google refresh here (unlike refreshTokenByProvider)
|
|
||||||
if (provider === "gemini") {
|
if (provider === "gemini") {
|
||||||
return refreshGoogleToken(credentials.refreshToken, PROVIDERS.gemini.clientId, PROVIDERS.gemini.clientSecret, log);
|
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);
|
return handler(credentials, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh token by provider type (helper for handlers)
|
|
||||||
*/
|
|
||||||
export async function refreshTokenByProvider(provider, credentials, log) {
|
export async function refreshTokenByProvider(provider, credentials, log) {
|
||||||
if (!credentials.refreshToken) return null;
|
if (!credentials.refreshToken) return null;
|
||||||
const handler = REFRESH_HANDLERS[provider];
|
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);
|
return handler ? handler(credentials, log) : refreshAccessToken(provider, credentials.refreshToken, credentials, log);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Format credentials for provider
|
|
||||||
*/
|
|
||||||
export function formatProviderCredentials(provider, credentials, log) {
|
export function formatProviderCredentials(provider, credentials, log) {
|
||||||
const config = PROVIDERS[provider];
|
const config = PROVIDERS[provider];
|
||||||
if (!config) {
|
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) {
|
export async function getAllAccessTokens(userInfo, log) {
|
||||||
const results = {};
|
const results = {};
|
||||||
|
|
||||||
@@ -748,89 +226,6 @@ export async function getAllAccessTokens(userInfo, log) {
|
|||||||
return results;
|
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) {
|
export async function refreshWithRetry(refreshFn, maxRetries = 3, log = null) {
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||||
if (attempt > 0) {
|
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 { Card, Button, Input, Modal, CardSkeleton, Toggle, ConfirmModal } from "@/shared/components";
|
||||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||||
import { getCurrentLocale, onLocaleChange } from "@/i18n/runtime";
|
import { getCurrentLocale, onLocaleChange } from "@/i18n/runtime";
|
||||||
|
import {
|
||||||
// Locales that unlock wenyan (classical Chinese) caveman levels
|
WENYAN_LOCALES,
|
||||||
const WENYAN_LOCALES = ["zh-CN", "zh-TW"];
|
TUNNEL_BENEFITS,
|
||||||
|
TUNNEL_PING_INTERVAL_MS,
|
||||||
const TUNNEL_BENEFITS = [
|
TUNNEL_PING_MAX_MS,
|
||||||
{ icon: "public", title: "Access Anywhere", desc: "Use your API from any network" },
|
STATUS_POLL_FAST_MS,
|
||||||
{ icon: "group", title: "Share Endpoint", desc: "Share URL with team members" },
|
REACHABLE_MISS_THRESHOLD,
|
||||||
{ icon: "code", title: "Use in Cursor/Cline", desc: "Connect AI tools remotely" },
|
CLIENT_PING_FAST_MS,
|
||||||
{ icon: "lock", title: "Encrypted", desc: "End-to-end TLS via Cloudflare" },
|
CAVEMAN_LEVELS,
|
||||||
];
|
} from "./endpointConstants";
|
||||||
|
import { clientPingUrl, clientPingAny } from "./endpointPing";
|
||||||
const TUNNEL_PING_INTERVAL_MS = 2000;
|
import EndpointRow from "./components/EndpointRow";
|
||||||
const TUNNEL_PING_MAX_MS = 300000;
|
import StatusAlert from "./components/StatusAlert";
|
||||||
const STATUS_POLL_FAST_MS = 5000;
|
import Tooltip from "./components/Tooltip";
|
||||||
const STATUS_POLL_SLOW_MS = 30000;
|
import SecurityWarning from "./components/SecurityWarning";
|
||||||
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 },
|
|
||||||
];
|
|
||||||
export default function APIPageClient({ machineId }) {
|
export default function APIPageClient({ machineId }) {
|
||||||
const [keys, setKeys] = useState([]);
|
const [keys, setKeys] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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 = {
|
APIPageClient.propTypes = {
|
||||||
machineId: PropTypes.string.isRequired,
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Badge, Toggle } from "@/shared/components";
|
import { Badge, Toggle } from "@/shared/components";
|
||||||
import CooldownTimer from "./CooldownTimer";
|
import CooldownTimer from "./CooldownTimer";
|
||||||
@@ -107,12 +108,7 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
|||||||
? "active" // Cooldown expired u2192 treat as active
|
? "active" // Cooldown expired u2192 treat as active
|
||||||
: connection.testStatus;
|
: connection.testStatus;
|
||||||
|
|
||||||
const getStatusVariant = () => {
|
const getStatusVariant = () => getConnectionStatusVariant(connection.isActive, effectiveStatus);
|
||||||
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 getOneByOneVariant = () => {
|
const getOneByOneVariant = () => {
|
||||||
if (!oneByOneStatus) return "default";
|
if (!oneByOneStatus) return "default";
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import Link from "next/link";
|
|||||||
import Image from "next/image";
|
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 { 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 { 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 { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||||
import { translate } from "@/i18n/runtime";
|
import { translate } from "@/i18n/runtime";
|
||||||
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
|
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
|
||||||
@@ -914,7 +914,7 @@ export default function ProviderDetailPage() {
|
|||||||
const allModels = [
|
const allModels = [
|
||||||
...models,
|
...models,
|
||||||
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
|
...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 disabledSet = new Set(disabledModelIds);
|
||||||
const displayModels = allModels.filter((m) => !disabledSet.has(m.id));
|
const displayModels = allModels.filter((m) => !disabledSet.has(m.id));
|
||||||
const disabledDisplayModels = 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 = [
|
const allIds = [
|
||||||
...models,
|
...models,
|
||||||
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
|
...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));
|
const activeIds = allIds.filter((id) => !disabledModelIds.includes(id));
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Card, Badge, Button, Modal, Select, Toggle, EditConnectionModal, ConfirmModal } from "@/shared/components";
|
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 effectiveStatus = connection.testStatus === "unavailable" && !isCooldown ? "active" : connection.testStatus;
|
||||||
|
|
||||||
const getStatusVariant = () => {
|
const getStatusVariant = () => getConnectionStatusVariant(connection.isActive, effectiveStatus);
|
||||||
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 displayName = isOAuth
|
const displayName = isOAuth
|
||||||
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
? connection.name || connection.email || connection.displayName || "OAuth Account"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useState, useCallback, useEffect } from "react";
|
import { useState, useCallback, useEffect } from "react";
|
||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import { Card, Button, Modal } from "@/shared/components";
|
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 { getProviderAlias } from "@/shared/constants/providers";
|
||||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||||
|
|
||||||
@@ -206,14 +206,14 @@ export default function ModelsCard({ providerId, kindFilter, providerAliasOverri
|
|||||||
const builtInModels = kindFilter
|
const builtInModels = kindFilter
|
||||||
? allBuiltIn.filter((m) => {
|
? allBuiltIn.filter((m) => {
|
||||||
if (m.kinds) return m.kinds.includes(kindFilter);
|
if (m.kinds) return m.kinds.includes(kindFilter);
|
||||||
return (m.kind || m.type || "llm") === kindFilter;
|
return getModelKind(m, "llm") === kindFilter;
|
||||||
})
|
})
|
||||||
: allBuiltIn;
|
: allBuiltIn;
|
||||||
|
|
||||||
// Custom models for this provider + kind, dedupe vs built-in
|
// Custom models for this provider + kind, dedupe vs built-in
|
||||||
const myCustomModels = customModels.filter(
|
const myCustomModels = customModels.filter(
|
||||||
(m) => m.providerAlias === providerAlias
|
(m) => m.providerAlias === providerAlias
|
||||||
&& (m.kind || m.type || "llm") === effectiveType
|
&& getModelKind(m, "llm") === effectiveType
|
||||||
&& !builtInModels.some((b) => b.id === m.id)
|
&& !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 ProviderIcon from "@/shared/components/ProviderIcon";
|
||||||
import QuotaTable from "./QuotaTable";
|
import QuotaTable from "./QuotaTable";
|
||||||
import Toggle from "@/shared/components/Toggle";
|
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 Card from "@/shared/components/Card";
|
||||||
import { EditConnectionModal } from "@/shared/components";
|
import { EditConnectionModal } from "@/shared/components";
|
||||||
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
|
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() {
|
export default function ProviderLimits() {
|
||||||
const [connections, setConnections] = useState([]);
|
const [connections, setConnections] = useState([]);
|
||||||
const [quotaData, setQuotaData] = useState({});
|
const [quotaData, setQuotaData] = useState({});
|
||||||
|
|||||||
@@ -1,5 +1,212 @@
|
|||||||
import { getModelsByProviderId } from "open-sse/config/providerModels.js";
|
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)
|
* Format ISO date string to countdown format (inspired by vscode-antigravity-cockpit)
|
||||||
* @param {string|Date} date - ISO date string or Date object
|
* @param {string|Date} date - ISO date string or Date object
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { PROVIDER_MODELS } from "open-sse/config/providerModels.js";
|
import { PROVIDER_MODELS } from "open-sse/config/providerModels.js";
|
||||||
import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers";
|
import { AI_PROVIDERS, ALIAS_TO_ID } from "@/shared/constants/providers";
|
||||||
|
import { getModelKind } from "@/shared/constants/models";
|
||||||
|
|
||||||
const KIND_ENDPOINT = {
|
const KIND_ENDPOINT = {
|
||||||
llm: "/v1/chat/completions",
|
llm: "/v1/chat/completions",
|
||||||
@@ -52,10 +53,10 @@ function lookup(fullId, requestedKind) {
|
|||||||
// PROVIDER_MODELS lookup (by alias key, fallback to providerId)
|
// PROVIDER_MODELS lookup (by alias key, fallback to providerId)
|
||||||
const list = PROVIDER_MODELS[alias] || PROVIDER_MODELS[providerId] || [];
|
const list = PROVIDER_MODELS[alias] || PROVIDER_MODELS[providerId] || [];
|
||||||
const m = requestedKind
|
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);
|
: list.find((x) => x.id === modelId);
|
||||||
if (m) {
|
if (m) {
|
||||||
const kind = m.kind || m.type || "llm";
|
const kind = getModelKind(m, "llm");
|
||||||
return buildInfo({ alias, providerId, model: m, kind, providerInfo });
|
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 {
|
import {
|
||||||
AI_PROVIDERS,
|
AI_PROVIDERS,
|
||||||
getProviderAlias,
|
getProviderAlias,
|
||||||
@@ -316,7 +316,7 @@ export async function buildModelsList(kindFilter) {
|
|||||||
|
|
||||||
const customModelIds = customModels
|
const customModelIds = customModels
|
||||||
.filter((m) => {
|
.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;
|
const alias = m.providerAlias;
|
||||||
return alias === staticAlias || alias === outputAlias || alias === providerId;
|
return alias === staticAlias || alias === outputAlias || alias === providerId;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ async function getObservabilityConfig() {
|
|||||||
const { getSettings } = await import("./settingsRepo.js");
|
const { getSettings } = await import("./settingsRepo.js");
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false";
|
const envEnabled = process.env.OBSERVABILITY_ENABLED !== "false";
|
||||||
const enabled = typeof settings.enableObservability === "boolean"
|
const enabled = typeof settings.enableObservability2 === "boolean"
|
||||||
? settings.enableObservability
|
? settings.enableObservability2
|
||||||
: envEnabled;
|
: envEnabled;
|
||||||
cachedConfig = {
|
cachedConfig = {
|
||||||
enabled,
|
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,
|
getOAuthClientMetadata,
|
||||||
} from "./constants/oauth";
|
} from "./constants/oauth";
|
||||||
import { XAI_CONFIG, XAI_PKCE_VERIFIER_BYTES } from "./constants/xai";
|
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
|
// Inlined from services/xai.js to keep web route bundle free of `open` (CLI-only) package
|
||||||
let cachedXaiDiscovery = null;
|
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() {
|
async function discoverXaiEndpoints() {
|
||||||
if (cachedXaiDiscovery) return cachedXaiDiscovery;
|
if (cachedXaiDiscovery) return cachedXaiDiscovery;
|
||||||
try {
|
try {
|
||||||
@@ -63,81 +57,6 @@ async function discoverXaiEndpoints() {
|
|||||||
return cachedXaiDiscovery;
|
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
|
// Provider configurations
|
||||||
const PROVIDERS = {
|
const PROVIDERS = {
|
||||||
claude: {
|
claude: {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, useMemo, useEffect } from "react";
|
|||||||
import PropTypes from "prop-types";
|
import PropTypes from "prop-types";
|
||||||
import Modal from "./Modal";
|
import Modal from "./Modal";
|
||||||
import ProviderIcon from "./ProviderIcon";
|
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";
|
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)
|
// 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)
|
// 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 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)
|
// Filter a models[] array by kindFilter (keep only matching kind)
|
||||||
const filterByKind = (models) => {
|
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;
|
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)
|
// 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]) =>
|
export const AI_MODELS = Object.entries(MODELS).flatMap(([alias, models]) =>
|
||||||
models.map(m => ({ provider: alias, model: m.id, name: m.name }))
|
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