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(); function hasObjectChanges>( existing: T, patch: Partial | undefined, ): boolean { if (!patch) return false; const keys = Object.keys(patch) as Array; 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 { 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; 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(); 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; 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 | 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 { 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 { 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, existing: ProviderAccount | null, nextApiKey: string, ): Promise { 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 { 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; 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, 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; 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, 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; }