782 lines
29 KiB
TypeScript
782 lines
29 KiB
TypeScript
import type { IncomingMessage, ServerResponse } from 'http';
|
|
import {
|
|
type ProviderConfig,
|
|
} from '../../utils/secure-storage';
|
|
import {
|
|
getProviderConfig,
|
|
} from '../../utils/provider-registry';
|
|
import { browserOAuthManager, type BrowserOAuthProviderType } from '../../utils/browser-oauth';
|
|
import type { HostApiContext } from '../context';
|
|
import { parseJsonBody, sendJson } from '../route-utils';
|
|
import { proxyAwareFetch } from '../../utils/proxy-fetch';
|
|
import { WORKS_SQUARE_CONFIG } from '../works-config';
|
|
import { getHostApiToken } from '../server';
|
|
import { validateApiKeyWithProvider } from '../../services/providers/provider-validation';
|
|
import { getProviderService } from '../../services/providers/provider-service';
|
|
import type { ProviderAccount } from '../../shared/providers/types';
|
|
import { logger } from '../../utils/logger';
|
|
import { getPort } from '../../utils/config';
|
|
import { seedWorksSquareAIGatewayCredential } from '../../services/works-square-ai-gateway';
|
|
import {
|
|
NIANCODE_USER_MODEL_ACCOUNT_ID,
|
|
NIANCODE_USER_MODEL_ACCOUNT_LABEL,
|
|
normalizeImportedUserModelId,
|
|
} from '../../../shared/user-model-config';
|
|
|
|
const legacyProviderRoutesWarned = new Set<string>();
|
|
|
|
function hasObjectChanges<T extends Record<string, unknown>>(
|
|
existing: T,
|
|
patch: Partial<T> | undefined,
|
|
): boolean {
|
|
if (!patch) return false;
|
|
const keys = Object.keys(patch) as Array<keyof T>;
|
|
if (keys.length === 0) return false;
|
|
return keys.some((key) => JSON.stringify(existing[key]) !== JSON.stringify(patch[key]));
|
|
}
|
|
|
|
function readRequiredString(value: unknown, field: string): string {
|
|
if (typeof value !== 'string' || !value.trim()) {
|
|
throw new Error(`Missing ${field}`);
|
|
}
|
|
return value.trim();
|
|
}
|
|
|
|
function normalizeWorksBase(value = WORKS_SQUARE_CONFIG.apiBaseUrl): string {
|
|
const apiBase = value.replace(/\/+$/, '');
|
|
if (!/^https?:\/\//i.test(apiBase)) {
|
|
throw new Error('Works Square API base URL must start with http:// or https://');
|
|
}
|
|
return apiBase;
|
|
}
|
|
|
|
function createWorksUrl(pathname: string): URL {
|
|
return new URL(`${normalizeWorksBase()}${pathname}`);
|
|
}
|
|
|
|
async function readResponsePayload(response: Response): Promise<unknown> {
|
|
const text = await response.text();
|
|
if (!text.trim()) return null;
|
|
try {
|
|
return JSON.parse(text) as unknown;
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
|
|
function getErrorMessage(payload: unknown, fallback: string): string {
|
|
if (payload && typeof payload === 'object') {
|
|
const record = payload as Record<string, unknown>;
|
|
for (const field of ['msg', 'message', 'error_description', 'error', 'detail']) {
|
|
const value = record[field];
|
|
if (typeof value === 'string' && value.trim()) {
|
|
return value;
|
|
}
|
|
}
|
|
}
|
|
if (typeof payload === 'string' && payload.trim()) {
|
|
return payload;
|
|
}
|
|
return fallback;
|
|
}
|
|
|
|
function normalizeImportedModels(value: unknown): string[] {
|
|
if (!Array.isArray(value)) return [];
|
|
const seen = new Set<string>();
|
|
const models: string[] = [];
|
|
for (const item of value) {
|
|
const model = typeof item === 'string' ? normalizeImportedUserModelId(item) : '';
|
|
if (!model || seen.has(model)) continue;
|
|
seen.add(model);
|
|
models.push(model);
|
|
}
|
|
return models;
|
|
}
|
|
|
|
function normalizeExistingImportedModelSelection(value: string | undefined): string | null {
|
|
const trimmed = value?.trim();
|
|
if (!trimmed) return null;
|
|
const providerPrefix = `${NIANCODE_USER_MODEL_ACCOUNT_ID}/`;
|
|
const modelId = trimmed.startsWith(providerPrefix)
|
|
? trimmed.slice(providerPrefix.length)
|
|
: trimmed;
|
|
return normalizeImportedUserModelId(modelId);
|
|
}
|
|
|
|
function orderImportedModelsForAccount(
|
|
existing: ProviderAccount | null,
|
|
models: string[],
|
|
): string[] {
|
|
const selectedModel = normalizeExistingImportedModelSelection(existing?.model);
|
|
if (!selectedModel || !models.includes(selectedModel)) {
|
|
return models;
|
|
}
|
|
return [
|
|
selectedModel,
|
|
...models.filter((model) => model !== selectedModel),
|
|
];
|
|
}
|
|
|
|
function normalizeWorksSquareAiGatewayBaseUrl(baseUrl: string, credentialMode: string): string {
|
|
const normalized = baseUrl.trim().replace(/\/+$/, '');
|
|
if (credentialMode !== WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) {
|
|
return normalized;
|
|
}
|
|
try {
|
|
const parsed = new URL(normalized);
|
|
if (parsed.pathname === '' || parsed.pathname === '/') {
|
|
parsed.pathname = '/v1';
|
|
return parsed.toString().replace(/\/+$/, '');
|
|
}
|
|
} catch {
|
|
return normalized;
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
type ImportedUserModelConfig = {
|
|
label: string;
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
credentialMode: string;
|
|
apiKeyExpiresIn: number | null;
|
|
models: string[];
|
|
};
|
|
|
|
const WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE = 'works_square_ai_gateway';
|
|
const WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE = 'works_square_ai_gateway_proxy';
|
|
const WORKS_SQUARE_AI_TOKEN_HEADER = 'X-Works-Square-AI-Token';
|
|
const NIANCODE_USER_MODEL_API_KEY_ENV = 'NIANCODE_OPENCODE_NIANCODE_USER_MODELS_API_KEY';
|
|
|
|
class WorksSquareModelConfigError extends Error {
|
|
constructor(
|
|
public readonly statusCode: number,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
function normalizeImportedUserModelConfig(payload: unknown): ImportedUserModelConfig {
|
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
throw new Error('Works Square model config response is invalid');
|
|
}
|
|
|
|
const record = payload as Record<string, unknown>;
|
|
const rawBaseUrl = readRequiredString(record.base_url ?? record.baseUrl, 'base_url');
|
|
const apiKey = readRequiredString(record.api_key ?? record.apiKey, 'api_key');
|
|
const credentialMode = typeof (record.credential_mode ?? record.credentialMode) === 'string'
|
|
? String(record.credential_mode ?? record.credentialMode).trim()
|
|
: 'api_key';
|
|
const baseUrl = normalizeWorksSquareAiGatewayBaseUrl(rawBaseUrl, credentialMode || 'api_key');
|
|
const apiKeyExpiresInRaw = record.api_key_expires_in ?? record.apiKeyExpiresIn;
|
|
const apiKeyExpiresIn = typeof apiKeyExpiresInRaw === 'number' && Number.isFinite(apiKeyExpiresInRaw)
|
|
? Math.max(0, Math.floor(apiKeyExpiresInRaw))
|
|
: null;
|
|
const models = normalizeImportedModels(record.models);
|
|
if (models.length === 0) {
|
|
throw new Error('Works Square model config response has no models');
|
|
}
|
|
|
|
const label = typeof record.label === 'string' && record.label.trim()
|
|
? record.label.trim()
|
|
: NIANCODE_USER_MODEL_ACCOUNT_LABEL;
|
|
|
|
return {
|
|
label,
|
|
baseUrl,
|
|
apiKey,
|
|
credentialMode: credentialMode || 'api_key',
|
|
apiKeyExpiresIn,
|
|
models,
|
|
};
|
|
}
|
|
|
|
function importedUserModelHeaders(modelConfig: ImportedUserModelConfig): Record<string, string> | undefined {
|
|
if (modelConfig.credentialMode !== WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
Authorization: `Bearer {env:${NIANCODE_USER_MODEL_API_KEY_ENV}}`,
|
|
[WORKS_SQUARE_AI_TOKEN_HEADER]: `{env:${NIANCODE_USER_MODEL_API_KEY_ENV}}`,
|
|
};
|
|
}
|
|
|
|
function importedUserModelMetadata(
|
|
existing: ProviderAccount | null,
|
|
modelConfig: ImportedUserModelConfig,
|
|
nowMs: number,
|
|
useLocalAiProxy: boolean,
|
|
): ProviderAccount['metadata'] {
|
|
if (useLocalAiProxy) {
|
|
const metadata = { ...(existing?.metadata ?? {}) };
|
|
delete metadata.worksSquareCredentialExpiresAt;
|
|
return {
|
|
...metadata,
|
|
customModels: modelConfig.models,
|
|
worksSquareCredentialMode: WORKS_SQUARE_AI_GATEWAY_PROXY_CREDENTIAL_MODE,
|
|
worksSquareOneApiBaseUrl: modelConfig.baseUrl,
|
|
};
|
|
}
|
|
|
|
const metadata = { ...(existing?.metadata ?? {}) };
|
|
delete metadata.worksSquareCredentialExpiresAt;
|
|
delete metadata.worksSquareOneApiBaseUrl;
|
|
const credentialExpiresAt = modelConfig.apiKeyExpiresIn === null
|
|
? undefined
|
|
: new Date(nowMs + modelConfig.apiKeyExpiresIn * 1000).toISOString();
|
|
return {
|
|
...metadata,
|
|
customModels: modelConfig.models,
|
|
worksSquareCredentialMode: modelConfig.credentialMode,
|
|
...(credentialExpiresAt ? { worksSquareCredentialExpiresAt: credentialExpiresAt } : {}),
|
|
};
|
|
}
|
|
|
|
function localAiProxyBaseUrl(): string {
|
|
return `http://127.0.0.1:${getPort('NIANCODE_HOST_API')}/api/ai-proxy/v1`;
|
|
}
|
|
|
|
async function fetchCurrentUserModelConfig(accessToken: string): Promise<ImportedUserModelConfig> {
|
|
const response = await proxyAwareFetch(createWorksUrl('/api/auth/me/model-config').toString(), {
|
|
method: 'GET',
|
|
headers: {
|
|
Authorization: `Bearer ${accessToken}`,
|
|
},
|
|
});
|
|
const payload = await readResponsePayload(response);
|
|
if (!response.ok) {
|
|
throw new WorksSquareModelConfigError(
|
|
response.status >= 400 && response.status < 500 ? response.status : 502,
|
|
getErrorMessage(payload, `Works Square model config failed (${response.status})`),
|
|
);
|
|
}
|
|
return normalizeImportedUserModelConfig(payload);
|
|
}
|
|
|
|
async function refreshRunningRuntimeAfterProviderChange(ctx: HostApiContext): Promise<void> {
|
|
if (ctx.opencodeManager.getStatus().state === 'stopped') return;
|
|
|
|
try {
|
|
const status = await ctx.opencodeManager.restart();
|
|
if (status.state !== 'running' || typeof status.pid !== 'number') {
|
|
throw new Error('opencode runtime did not restart with the new provider configuration');
|
|
}
|
|
} catch (error) {
|
|
logger.warn('[providers] Failed to restart opencode runtime after provider configuration changed', error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function providerAccountRuntimeShape(account: ProviderAccount): unknown {
|
|
return {
|
|
label: account.label,
|
|
authMode: account.authMode,
|
|
baseUrl: account.baseUrl,
|
|
apiProtocol: account.apiProtocol,
|
|
headers: account.headers,
|
|
model: account.model,
|
|
fallbackModels: account.fallbackModels,
|
|
fallbackAccountIds: account.fallbackAccountIds,
|
|
enabled: account.enabled,
|
|
isDefault: account.isDefault,
|
|
metadata: {
|
|
customModels: account.metadata?.customModels,
|
|
worksSquareCredentialMode: account.metadata?.worksSquareCredentialMode,
|
|
worksSquareOneApiBaseUrl: account.metadata?.worksSquareOneApiBaseUrl,
|
|
},
|
|
};
|
|
}
|
|
|
|
function importedProviderRuntimeShapeChanged(
|
|
existing: ProviderAccount | null,
|
|
next: ProviderAccount,
|
|
): boolean {
|
|
if (!existing) return true;
|
|
return JSON.stringify(providerAccountRuntimeShape(existing))
|
|
!== JSON.stringify(providerAccountRuntimeShape(next));
|
|
}
|
|
|
|
async function importedProviderApiKeyChanged(
|
|
providerService: ReturnType<typeof getProviderService>,
|
|
existing: ProviderAccount | null,
|
|
nextApiKey: string,
|
|
): Promise<boolean> {
|
|
if (!existing) return false;
|
|
const existingApiKey = await providerService.getAccountApiKey(NIANCODE_USER_MODEL_ACCOUNT_ID);
|
|
return existingApiKey !== nextApiKey;
|
|
}
|
|
|
|
export async function importCurrentUserModelConfig(
|
|
ctx: HostApiContext,
|
|
accessToken: string,
|
|
): Promise<{ account: ProviderAccount; importedModels: string[] }> {
|
|
const providerService = getProviderService();
|
|
const modelConfig = await fetchCurrentUserModelConfig(accessToken);
|
|
const useLocalAiProxy = modelConfig.credentialMode === WORKS_SQUARE_AI_GATEWAY_CREDENTIAL_MODE;
|
|
if (useLocalAiProxy) {
|
|
seedWorksSquareAIGatewayCredential({
|
|
accessToken: modelConfig.apiKey,
|
|
expiresIn: modelConfig.apiKeyExpiresIn,
|
|
oneApiBaseUrl: modelConfig.baseUrl,
|
|
});
|
|
}
|
|
const nowMs = Date.now();
|
|
const now = new Date(nowMs).toISOString();
|
|
const existing = await providerService.getAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
|
|
const accountBaseUrl = useLocalAiProxy ? localAiProxyBaseUrl() : modelConfig.baseUrl;
|
|
const accountApiKey = useLocalAiProxy ? getHostApiToken() : modelConfig.apiKey;
|
|
const orderedModels = orderImportedModelsForAccount(existing, modelConfig.models);
|
|
const account: ProviderAccount = {
|
|
id: NIANCODE_USER_MODEL_ACCOUNT_ID,
|
|
vendorId: 'custom',
|
|
label: modelConfig.label,
|
|
authMode: 'api_key',
|
|
baseUrl: accountBaseUrl,
|
|
apiProtocol: 'openai-completions',
|
|
headers: useLocalAiProxy ? undefined : importedUserModelHeaders(modelConfig),
|
|
model: orderedModels[0],
|
|
fallbackModels: orderedModels.slice(1),
|
|
fallbackAccountIds: existing?.fallbackAccountIds,
|
|
enabled: true,
|
|
isDefault: true,
|
|
metadata: importedUserModelMetadata(existing, modelConfig, nowMs, useLocalAiProxy),
|
|
createdAt: existing?.createdAt ?? now,
|
|
updatedAt: now,
|
|
};
|
|
const shouldRestartRuntime = importedProviderRuntimeShapeChanged(existing, account)
|
|
|| (useLocalAiProxy && await importedProviderApiKeyChanged(providerService, existing, accountApiKey));
|
|
|
|
const savedAccount = existing
|
|
? await providerService.updateAccount(
|
|
NIANCODE_USER_MODEL_ACCOUNT_ID,
|
|
account,
|
|
accountApiKey,
|
|
)
|
|
: await providerService.createAccount(account, accountApiKey);
|
|
|
|
await providerService.setDefaultAccount(NIANCODE_USER_MODEL_ACCOUNT_ID);
|
|
if (shouldRestartRuntime) {
|
|
await refreshRunningRuntimeAfterProviderChange(ctx);
|
|
}
|
|
|
|
return {
|
|
account: savedAccount,
|
|
importedModels: modelConfig.models,
|
|
};
|
|
}
|
|
|
|
export async function handleProviderRoutes(
|
|
req: IncomingMessage,
|
|
res: ServerResponse,
|
|
url: URL,
|
|
ctx: HostApiContext,
|
|
): Promise<boolean> {
|
|
const providerService = getProviderService();
|
|
const logLegacyProviderRoute = (route: string): void => {
|
|
if (legacyProviderRoutesWarned.has(route)) return;
|
|
legacyProviderRoutesWarned.add(route);
|
|
logger.warn(
|
|
`[provider-migration] Legacy HTTP route "${route}" is deprecated. Prefer /api/provider-accounts endpoints.`,
|
|
);
|
|
};
|
|
|
|
if (url.pathname === '/api/provider-vendors' && req.method === 'GET') {
|
|
sendJson(res, 200, await providerService.listVendors());
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/provider-accounts' && req.method === 'GET') {
|
|
sendJson(res, 200, await providerService.listAccounts());
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/provider-accounts' && req.method === 'POST') {
|
|
try {
|
|
const body = await parseJsonBody<{ account: ProviderAccount; apiKey?: string }>(req);
|
|
const account = await providerService.createAccount(body.account, body.apiKey);
|
|
await refreshRunningRuntimeAfterProviderChange(ctx);
|
|
sendJson(res, 200, { success: true, account });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/provider-accounts/default' && req.method === 'GET') {
|
|
sendJson(res, 200, { accountId: await providerService.getDefaultAccountId() ?? null });
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/provider-accounts/default' && req.method === 'PUT') {
|
|
try {
|
|
const body = await parseJsonBody<{ accountId: string }>(req);
|
|
const currentDefault = await providerService.getDefaultAccountId();
|
|
if (currentDefault === body.accountId) {
|
|
sendJson(res, 200, { success: true, noChange: true });
|
|
return true;
|
|
}
|
|
await providerService.setDefaultAccount(body.accountId);
|
|
await refreshRunningRuntimeAfterProviderChange(ctx);
|
|
sendJson(res, 200, { success: true });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ── New account-based companion endpoints ─────────────────────────
|
|
// Exposed alongside the existing /api/provider-accounts surface so the
|
|
// renderer (and any future external client) can drop the legacy
|
|
// /api/providers paths without losing functionality. Specific paths
|
|
// must be matched BEFORE the generic /api/provider-accounts/:id rule
|
|
// below to avoid being captured as account ids.
|
|
|
|
if (url.pathname === '/api/provider-accounts/key-info' && req.method === 'GET') {
|
|
sendJson(res, 200, await providerService.listAccountsKeyInfo());
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/provider-accounts/validate' && req.method === 'POST') {
|
|
try {
|
|
// Accept legacy `providerId` as a fallback so external clients that
|
|
// migrate by URL alone (without renaming their request body) continue
|
|
// to work. The renderer always sends all three fields; older callers
|
|
// may send only `providerId`.
|
|
const body = await parseJsonBody<{
|
|
accountId?: string;
|
|
vendorId?: string;
|
|
providerId?: string;
|
|
apiKey: string;
|
|
options?: { baseUrl?: string; apiProtocol?: string };
|
|
}>(req);
|
|
const accountId = body.accountId || body.vendorId || body.providerId || '';
|
|
const account = accountId ? await providerService.getAccount(accountId) : null;
|
|
const providerType = account?.vendorId || body.vendorId || body.providerId || accountId;
|
|
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
|
const resolvedBaseUrl = body.options?.baseUrl || account?.baseUrl || registryBaseUrl;
|
|
const resolvedProtocol = body.options?.apiProtocol || account?.apiProtocol;
|
|
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, {
|
|
baseUrl: resolvedBaseUrl,
|
|
apiProtocol: resolvedProtocol,
|
|
}));
|
|
} catch (error) {
|
|
sendJson(res, 500, { valid: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/provider-accounts/import-user-model-config' && req.method === 'POST') {
|
|
try {
|
|
const body = await parseJsonBody<{ accessToken?: unknown }>(req);
|
|
const result = await importCurrentUserModelConfig(
|
|
ctx,
|
|
readRequiredString(body.accessToken, 'accessToken'),
|
|
);
|
|
sendJson(res, 200, { success: true, ...result });
|
|
} catch (error) {
|
|
sendJson(res, error instanceof WorksSquareModelConfigError ? error.statusCode : 500, {
|
|
success: false,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/provider-accounts/oauth/start' && req.method === 'POST') {
|
|
try {
|
|
const body = await parseJsonBody<{
|
|
provider: BrowserOAuthProviderType | string;
|
|
region?: 'global' | 'cn';
|
|
accountId?: string;
|
|
label?: string;
|
|
}>(req);
|
|
if (body.provider === 'google' || body.provider === 'openai') {
|
|
await browserOAuthManager.startFlow(body.provider, {
|
|
accountId: body.accountId,
|
|
label: body.label,
|
|
});
|
|
} else {
|
|
sendJson(res, 400, { success: false, error: 'OAuth provider is not supported by the runtime yet' });
|
|
return true;
|
|
}
|
|
sendJson(res, 200, { success: true });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/provider-accounts/oauth/cancel' && req.method === 'POST') {
|
|
try {
|
|
await browserOAuthManager.stopFlow();
|
|
sendJson(res, 200, { success: true });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/provider-accounts/oauth/submit' && req.method === 'POST') {
|
|
try {
|
|
const body = await parseJsonBody<{ code: string }>(req);
|
|
const accepted = browserOAuthManager.submitManualCode(body.code || '');
|
|
if (!accepted) {
|
|
sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' });
|
|
return true;
|
|
}
|
|
sendJson(res, 200, { success: true });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'GET') {
|
|
const remainder = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
|
if (remainder.endsWith('/api-key')) {
|
|
const accountId = remainder.slice(0, -'/api-key'.length);
|
|
sendJson(res, 200, { apiKey: await providerService.getAccountApiKey(accountId) });
|
|
return true;
|
|
}
|
|
if (remainder.endsWith('/has-api-key')) {
|
|
const accountId = remainder.slice(0, -'/has-api-key'.length);
|
|
sendJson(res, 200, { hasKey: await providerService.hasAccountApiKey(accountId) });
|
|
return true;
|
|
}
|
|
sendJson(res, 200, await providerService.getAccount(remainder));
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'PUT') {
|
|
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
|
try {
|
|
const body = await parseJsonBody<{ updates: Partial<ProviderAccount>; apiKey?: string }>(req);
|
|
const existing = await providerService.getAccount(accountId);
|
|
if (!existing) {
|
|
sendJson(res, 404, { success: false, error: 'Provider account not found' });
|
|
return true;
|
|
}
|
|
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, body.updates);
|
|
if (!hasPatchChanges && body.apiKey === undefined) {
|
|
sendJson(res, 200, { success: true, noChange: true, account: existing });
|
|
return true;
|
|
}
|
|
const nextAccount = await providerService.updateAccount(accountId, body.updates, body.apiKey);
|
|
await refreshRunningRuntimeAfterProviderChange(ctx);
|
|
sendJson(res, 200, { success: true, account: nextAccount });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/provider-accounts/') && req.method === 'DELETE') {
|
|
const accountId = decodeURIComponent(url.pathname.slice('/api/provider-accounts/'.length));
|
|
try {
|
|
if (url.searchParams.get('apiKeyOnly') === '1') {
|
|
await providerService._deleteProviderApiKeyInternal(accountId);
|
|
await refreshRunningRuntimeAfterProviderChange(ctx);
|
|
sendJson(res, 200, { success: true });
|
|
return true;
|
|
}
|
|
await providerService.deleteAccount(accountId);
|
|
await refreshRunningRuntimeAfterProviderChange(ctx);
|
|
sendJson(res, 200, { success: true });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/providers' && req.method === 'GET') {
|
|
logLegacyProviderRoute('GET /api/providers');
|
|
sendJson(res, 200, await providerService._listProvidersWithKeyInfoInternal());
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/providers/default' && req.method === 'GET') {
|
|
logLegacyProviderRoute('GET /api/providers/default');
|
|
sendJson(res, 200, { providerId: await providerService._getDefaultProviderInternal() ?? null });
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/providers/default' && req.method === 'PUT') {
|
|
logLegacyProviderRoute('PUT /api/providers/default');
|
|
try {
|
|
const body = await parseJsonBody<{ providerId: string }>(req);
|
|
const currentDefault = await providerService._getDefaultProviderInternal();
|
|
if (currentDefault === body.providerId) {
|
|
sendJson(res, 200, { success: true, noChange: true });
|
|
return true;
|
|
}
|
|
await providerService._setDefaultProviderInternal(body.providerId);
|
|
await refreshRunningRuntimeAfterProviderChange(ctx);
|
|
sendJson(res, 200, { success: true });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/providers/validate' && req.method === 'POST') {
|
|
logLegacyProviderRoute('POST /api/providers/validate');
|
|
try {
|
|
const body = await parseJsonBody<{ providerId: string; apiKey: string; options?: { baseUrl?: string; apiProtocol?: string } }>(req);
|
|
const provider = await providerService._getProviderInternal(body.providerId);
|
|
const providerType = provider?.type || body.providerId;
|
|
const registryBaseUrl = getProviderConfig(providerType)?.baseUrl;
|
|
const resolvedBaseUrl = body.options?.baseUrl || provider?.baseUrl || registryBaseUrl;
|
|
const resolvedProtocol = body.options?.apiProtocol || provider?.apiProtocol;
|
|
sendJson(res, 200, await validateApiKeyWithProvider(providerType, body.apiKey, { baseUrl: resolvedBaseUrl, apiProtocol: resolvedProtocol }));
|
|
} catch (error) {
|
|
sendJson(res, 500, { valid: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/providers/oauth/start' && req.method === 'POST') {
|
|
logLegacyProviderRoute('POST /api/providers/oauth/start');
|
|
try {
|
|
const body = await parseJsonBody<{
|
|
provider: BrowserOAuthProviderType | string;
|
|
region?: 'global' | 'cn';
|
|
accountId?: string;
|
|
label?: string;
|
|
}>(req);
|
|
if (body.provider === 'google' || body.provider === 'openai') {
|
|
await browserOAuthManager.startFlow(body.provider, {
|
|
accountId: body.accountId,
|
|
label: body.label,
|
|
});
|
|
} else {
|
|
sendJson(res, 400, { success: false, error: 'OAuth provider is not supported by the runtime yet' });
|
|
return true;
|
|
}
|
|
sendJson(res, 200, { success: true });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/providers/oauth/cancel' && req.method === 'POST') {
|
|
logLegacyProviderRoute('POST /api/providers/oauth/cancel');
|
|
try {
|
|
await browserOAuthManager.stopFlow();
|
|
sendJson(res, 200, { success: true });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/providers/oauth/submit' && req.method === 'POST') {
|
|
logLegacyProviderRoute('POST /api/providers/oauth/submit');
|
|
try {
|
|
const body = await parseJsonBody<{ code: string }>(req);
|
|
const accepted = browserOAuthManager.submitManualCode(body.code || '');
|
|
if (!accepted) {
|
|
sendJson(res, 400, { success: false, error: 'No active manual OAuth input pending' });
|
|
return true;
|
|
}
|
|
sendJson(res, 200, { success: true });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname === '/api/providers' && req.method === 'POST') {
|
|
logLegacyProviderRoute('POST /api/providers');
|
|
try {
|
|
const body = await parseJsonBody<{ config: ProviderConfig; apiKey?: string }>(req);
|
|
const config = body.config;
|
|
await providerService._saveProviderInternal(config);
|
|
if (body.apiKey !== undefined) {
|
|
const trimmedKey = body.apiKey.trim();
|
|
if (trimmedKey) {
|
|
await providerService._setProviderApiKeyInternal(config.id, trimmedKey);
|
|
}
|
|
}
|
|
await refreshRunningRuntimeAfterProviderChange(ctx);
|
|
sendJson(res, 200, { success: true });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/providers/') && req.method === 'GET') {
|
|
logLegacyProviderRoute('GET /api/providers/:id');
|
|
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
|
if (providerId.endsWith('/api-key')) {
|
|
const actualId = providerId.slice(0, -('/api-key'.length));
|
|
sendJson(res, 200, { apiKey: await providerService._getProviderApiKeyInternal(actualId) });
|
|
return true;
|
|
}
|
|
if (providerId.endsWith('/has-api-key')) {
|
|
const actualId = providerId.slice(0, -('/has-api-key'.length));
|
|
sendJson(res, 200, { hasKey: await providerService._hasProviderApiKeyInternal(actualId) });
|
|
return true;
|
|
}
|
|
sendJson(res, 200, await providerService._getProviderInternal(providerId));
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/providers/') && req.method === 'PUT') {
|
|
logLegacyProviderRoute('PUT /api/providers/:id');
|
|
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
|
try {
|
|
const body = await parseJsonBody<{ updates: Partial<ProviderConfig>; apiKey?: string }>(req);
|
|
const existing = await providerService._getProviderInternal(providerId);
|
|
if (!existing) {
|
|
sendJson(res, 404, { success: false, error: 'Provider not found' });
|
|
return true;
|
|
}
|
|
const hasPatchChanges = hasObjectChanges(existing as unknown as Record<string, unknown>, body.updates);
|
|
if (!hasPatchChanges && body.apiKey === undefined) {
|
|
sendJson(res, 200, { success: true, noChange: true });
|
|
return true;
|
|
}
|
|
const nextConfig: ProviderConfig = { ...existing, ...body.updates, updatedAt: new Date().toISOString() };
|
|
await providerService._saveProviderInternal(nextConfig);
|
|
if (body.apiKey !== undefined) {
|
|
const trimmedKey = body.apiKey.trim();
|
|
if (trimmedKey) {
|
|
await providerService._setProviderApiKeyInternal(providerId, trimmedKey);
|
|
} else {
|
|
await providerService._deleteProviderApiKeyInternal(providerId);
|
|
}
|
|
}
|
|
await refreshRunningRuntimeAfterProviderChange(ctx);
|
|
sendJson(res, 200, { success: true });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/providers/') && req.method === 'DELETE') {
|
|
logLegacyProviderRoute('DELETE /api/providers/:id');
|
|
const providerId = decodeURIComponent(url.pathname.slice('/api/providers/'.length));
|
|
try {
|
|
await providerService._getProviderInternal(providerId);
|
|
if (url.searchParams.get('apiKeyOnly') === '1') {
|
|
await providerService._deleteProviderApiKeyInternal(providerId);
|
|
await refreshRunningRuntimeAfterProviderChange(ctx);
|
|
sendJson(res, 200, { success: true });
|
|
return true;
|
|
}
|
|
await providerService._deleteProviderInternal(providerId);
|
|
await refreshRunningRuntimeAfterProviderChange(ctx);
|
|
sendJson(res, 200, { success: true });
|
|
} catch (error) {
|
|
sendJson(res, 500, { success: false, error: String(error) });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|