93 lines
2.9 KiB
TypeScript
93 lines
2.9 KiB
TypeScript
|
|
export type ProviderId = "xai" | "openai" | "anthropic" | "google" | "opencode-go";
|
|||
|
|
|
|||
|
|
export type ProviderApiKeys = Partial<Record<ProviderId, string>>;
|
|||
|
|
|
|||
|
|
const ENV_MAP: Record<ProviderId, string> = {
|
|||
|
|
xai: "XAI_API_KEY",
|
|||
|
|
openai: "OPENAI_API_KEY",
|
|||
|
|
anthropic: "ANTHROPIC_API_KEY",
|
|||
|
|
google: "GOOGLE_GENERATIVE_AI_API_KEY",
|
|||
|
|
"opencode-go": "OPENCODE_GO_API_KEY",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const PROVIDER_KEY_LABELS: Record<
|
|||
|
|
ProviderId,
|
|||
|
|
{ label: string; hint: string; docsUrl?: string }
|
|||
|
|
> = {
|
|||
|
|
"opencode-go": {
|
|||
|
|
label: "OpenCode Go",
|
|||
|
|
hint: "從 opencode.ai/auth 訂閱 Go 方案後取得",
|
|||
|
|
docsUrl: "https://opencode.ai/docs/go/",
|
|||
|
|
},
|
|||
|
|
xai: { label: "Grok (xAI)", hint: "xAI Console API key" },
|
|||
|
|
openai: { label: "OpenAI", hint: "platform.openai.com API key" },
|
|||
|
|
anthropic: { label: "Anthropic", hint: "console.anthropic.com API key" },
|
|||
|
|
google: { label: "Google Gemini", hint: "Google AI Studio API key" },
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export function parseProviderApiKeys(raw: string | null | undefined): ProviderApiKeys {
|
|||
|
|
if (!raw) return {};
|
|||
|
|
try {
|
|||
|
|
return JSON.parse(raw) as ProviderApiKeys;
|
|||
|
|
} catch {
|
|||
|
|
return {};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function serializeProviderApiKeys(keys: ProviderApiKeys): string {
|
|||
|
|
return JSON.stringify(keys);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function maskApiKey(key: string | undefined | null): string | null {
|
|||
|
|
if (!key) return null;
|
|||
|
|
if (key.length <= 4) return "••••";
|
|||
|
|
return `••••${key.slice(-4)}`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function isMaskedKey(value: string): boolean {
|
|||
|
|
return value.startsWith("••••");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 只讀使用者自己存在 DB 的 key,不 fallback .env,避免新帳號看到舊設定。 */
|
|||
|
|
export function resolveApiKey(provider: ProviderId, keys: ProviderApiKeys): string | undefined {
|
|||
|
|
return keys[provider]?.trim() || undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getApiKeyStatus(keys: ProviderApiKeys): Record<ProviderId, boolean> {
|
|||
|
|
return (Object.keys(ENV_MAP) as ProviderId[]).reduce(
|
|||
|
|
(acc, id) => {
|
|||
|
|
acc[id] = !!keys[id]?.trim();
|
|||
|
|
return acc;
|
|||
|
|
},
|
|||
|
|
{} as Record<ProviderId, boolean>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function mergeProviderApiKeys(
|
|||
|
|
existing: ProviderApiKeys,
|
|||
|
|
incoming: ProviderApiKeys
|
|||
|
|
): ProviderApiKeys {
|
|||
|
|
const merged = { ...existing };
|
|||
|
|
for (const [provider, value] of Object.entries(incoming) as [ProviderId, string][]) {
|
|||
|
|
const trimmed = value?.trim();
|
|||
|
|
if (!trimmed || isMaskedKey(trimmed)) continue;
|
|||
|
|
merged[provider] = trimmed;
|
|||
|
|
}
|
|||
|
|
return merged;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function getMaskedProviderApiKeys(keys: ProviderApiKeys): ProviderApiKeys {
|
|||
|
|
return (Object.keys(ENV_MAP) as ProviderId[]).reduce((acc, id) => {
|
|||
|
|
const masked = maskApiKey(keys[id]);
|
|||
|
|
if (masked) acc[id] = masked;
|
|||
|
|
return acc;
|
|||
|
|
}, {} as ProviderApiKeys);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export function assertProviderApiKey(provider: ProviderId, keys: ProviderApiKeys): void {
|
|||
|
|
const key = resolveApiKey(provider, keys);
|
|||
|
|
if (!key) {
|
|||
|
|
const label = PROVIDER_KEY_LABELS[provider].label;
|
|||
|
|
throw new Error(`尚未設定 ${label} API key,請到 AI 設定頁填入`);
|
|||
|
|
}
|
|||
|
|
}
|