diff --git a/app/[lang]/admin/page.tsx b/app/[lang]/admin/page.tsx index 58c97ce..79c6e65 100644 --- a/app/[lang]/admin/page.tsx +++ b/app/[lang]/admin/page.tsx @@ -45,6 +45,7 @@ import { } from "@/lib/admin/settings-registry" import { getApiEndpoint } from "@/lib/base-path" import { + FIXED_CRED_PROVIDERS, PROVIDER_INFO, type ProviderName, SUGGESTED_MODELS, @@ -854,14 +855,33 @@ function ModelsSection({ {(Object.keys(PROVIDER_INFO) as ProviderName[]).map( - (p) => ( - -
- - {PROVIDER_INFO[p].label} -
-
- ), + (p) => { + // Global-credential providers already in + // the env config can't be added here — + // panel credentials would override theirs + const envBlocked = + FIXED_CRED_PROVIDERS.includes(p) && + envProviders.some( + (e) => e.provider === p, + ) + return ( + +
+ + {PROVIDER_INFO[p].label} + {envBlocked && ( + + (managed via env) + + )} +
+
+ ) + }, )}
diff --git a/app/api/admin/providers/route.ts b/app/api/admin/providers/route.ts index e2ec7f0..1db05cb 100644 --- a/app/api/admin/providers/route.ts +++ b/app/api/admin/providers/route.ts @@ -72,8 +72,7 @@ export async function PUT(req: Request) { const merged = mergeSecrets(parsed.data, stored) const envConfig = await loadEnvServerModelsConfig() - const envNames = envConfig?.providers.map((p) => p.name) ?? [] - const validationError = validateAdminProviders(merged, envNames) + const validationError = validateAdminProviders(merged, envConfig) if (validationError) { return Response.json({ error: validationError }, { status: 400 }) } diff --git a/lib/admin/providers.ts b/lib/admin/providers.ts index ca0f564..471770d 100644 --- a/lib/admin/providers.ts +++ b/lib/admin/providers.ts @@ -3,7 +3,11 @@ import { ProviderNameSchema, type ServerModelsConfig, } from "@/lib/server-model-config" -import { PROVIDER_INFO, type ProviderName } from "@/lib/types/model-config" +import { + FIXED_CRED_PROVIDERS, + PROVIDER_INFO, + type ProviderName, +} from "@/lib/types/model-config" import { type MaskedSecret, maskSecret } from "./auth" import { loadSettings } from "./settings" @@ -65,11 +69,6 @@ const SECRET_FIELDS = [ "vertexApiKey", ] as const -// Providers whose credentials the runtime reads from fixed env vars -// (no apiKeyEnv support), so the panel writes those vars directly and -// only one instance of each is allowed. edgeone needs no credentials. -const FIXED_CRED_PROVIDERS: ProviderName[] = ["bedrock", "vertexai", "ollama"] - // ADMIN_-prefixed env var names for instance `index` (0-based) of a provider function credEnvNames( provider: ProviderName, @@ -151,18 +150,27 @@ function displayName(p: StoredAdminProvider): string { export function validateAdminProviders( list: StoredAdminProvider[], - envProviderNames: string[] = [], + envConfig: ServerModelsConfig | null = null, ): string | null { + const envProviders = envConfig?.providers ?? [] for (const single of FIXED_CRED_PROVIDERS) { if (list.filter((p) => p.provider === single).length > 1) { return `Only one ${PROVIDER_INFO[single].label} provider is supported (its credentials use fixed environment variables).` } + // Its credentials are global; a panel instance would silently + // override the credentials env-configured models rely on + if ( + list.some((p) => p.provider === single) && + envProviders.some((p) => p.provider === single) + ) { + return `${PROVIDER_INFO[single].label} is already configured in AI_MODELS_CONFIG / ai-models.json and shares global credentials. Manage it via the environment configuration instead.` + } } const names = list.map((p) => displayName(p)) if (new Set(names).size !== names.length) { return "Provider display names must be unique." } - const envNames = new Set(envProviderNames) + const envNames = new Set(envProviders.map((p) => p.name)) const clash = names.find((n) => envNames.has(n)) if (clash) { return `"${clash}" is already defined in AI_MODELS_CONFIG / ai-models.json. Use a different display name.` diff --git a/lib/types/model-config.ts b/lib/types/model-config.ts index fb24557..4b089eb 100644 --- a/lib/types/model-config.ts +++ b/lib/types/model-config.ts @@ -85,6 +85,15 @@ export interface FlattenedModel { baseUrlEnv?: string } +// Providers whose server credentials live in fixed env vars +// (AWS_ACCESS_KEY_ID, GOOGLE_VERTEX_API_KEY, OLLAMA_API_KEY) with no +// apiKeyEnv redirection support — their credentials are global +export const FIXED_CRED_PROVIDERS: ProviderName[] = [ + "bedrock", + "vertexai", + "ollama", +] + // Map provider names to models.dev logo names export const PROVIDER_LOGO_MAP: Record = { openai: "openai", diff --git a/tests/unit/admin-providers.test.ts b/tests/unit/admin-providers.test.ts index 7b2cb01..f96ed4a 100644 --- a/tests/unit/admin-providers.test.ts +++ b/tests/unit/admin-providers.test.ts @@ -244,13 +244,58 @@ describe("loadRawServerModelsConfig merge", () => { describe("validateAdminProviders", () => { it("rejects names clashing with env-configured providers", () => { expect( - validateAdminProviders( - [provider({ name: "Env OpenAI" })], - ["Env OpenAI"], - ), + validateAdminProviders([provider({ name: "Env OpenAI" })], { + providers: [ + { + name: "Env OpenAI", + provider: "openai", + models: ["gpt-x"], + }, + ], + }), ).toMatch(/already defined/) }) + it("rejects a global-credential provider already in the env config", () => { + expect( + validateAdminProviders( + [ + provider({ + provider: "bedrock", + apiKey: undefined, + awsAccessKeyId: "AKIA-panel", + awsSecretAccessKey: "panel-secret", + awsRegion: "us-east-1", + models: ["claude-x"], + }), + ], + { + providers: [ + { + name: "Env Bedrock", + provider: "bedrock", + models: ["claude-env"], + }, + ], + }, + ), + ).toMatch(/shares global credentials/) + }) + + it("allows a normal provider type alongside the same env type", () => { + expect( + validateAdminProviders([provider({ name: "Panel OpenAI" })], { + providers: [ + { + name: "Env OpenAI", + provider: "openai", + models: ["gpt-x"], + }, + ], + }), + ).toBeNull() + }) + it("rejects two bedrock instances", () => { const list = [ provider({ id: "p1", provider: "bedrock" }),