diff --git a/memory-bank/aiConfig.md b/memory-bank/aiConfig.md index 69cdec8..ea29c4f 100644 --- a/memory-bank/aiConfig.md +++ b/memory-bank/aiConfig.md @@ -126,7 +126,8 @@ Config flows through three stages: **assemble sources → resolve → watch**. P so the sealed `enforced` overlay can never be overwritten, apply the `PlatformBaseline` beneath, and build `ResolvedProvider[]` via `build-catalog.ts`. Objects deep-merge per leaf-key (`mergeConfigFragments`), `allow`/`deny` arrays - wholesale-replace, and a non-secret env-var overlay applies on top. + wholesale-replace. Connection env vars are a resolver-owned source ranked + below `enforced` but above `user`/`host`/`default` — not a post-resolution overlay. `loadResolvedProviderCatalog()` (`src/node/load-catalog.ts`) is the public read seam that composes assembly + resolve and returns `readonly ResolvedProvider[]`. (`mergeEnforced` — the two-layer merge — remains exported as a low-level @@ -149,7 +150,7 @@ reusable independent of the catalog builder. - **Enablement** (`resolveEnabled`): enforced per-provider > enforced default > user per-provider > user default > platform-baseline per-provider > baseline default. -- **Connection**: non-secret env-var overlay (highest) > enforced > user file > +- **Connection**: enforced > connection env vars > user file > injected `host` sources (e.g. Positron `authentication.*` via `additionalSources`) > built-in defaults. Object keys deep-merge across layers. - **Model routing**: user config (override/custom) > provider config > discovered @@ -298,7 +299,7 @@ the bridge's `ModelInfo` — compatible by contract, not by import. | `src/node/paths.ts` | `AI_CONFIG_DIR`, `PROVIDERS_CONFIG_PATH`, enforced env-var name, lockfile path | | `src/node/types.ts` | Node seam option/result types (`LoadCatalogOptions`, `ProviderCatalogChange`, `Disposable`, …) | | `src/resolve-catalog.ts` | `resolveProviderCatalog()` — pure deep resolver seam; owns the precedence stack + sealed-enforced invariant | -| `src/build-catalog.ts` | `buildCatalog()` — assemble `ResolvedProvider[]` from the resolved config + baseline + env overlay (pure entry) | +| `src/build-catalog.ts` | `buildCatalog()` — assemble `ResolvedProvider[]` from merged config + enablement layers + baseline (pure entry) | | `src/node/load-config.ts` | `loadConfigSources()` / `readFileConfig()` / `readEnvFragment()` — assemble the ordered `ProviderConfigSource` list | | `src/node/load-catalog.ts` | `loadResolvedProviderCatalog()` — public read seam (assemble sources → `resolveProviderCatalog`) | | `src/node/mutate-config.ts` | `mutateProvidersConfig()` — locked, atomic, serialized mutation | diff --git a/packages/ai-config/README.md b/packages/ai-config/README.md index 78c35e9..c806aa2 100644 --- a/packages/ai-config/README.md +++ b/packages/ai-config/README.md @@ -101,11 +101,11 @@ The **deep resolver seam** — the one place that owns the entire precedence sta function resolveProviderCatalog(opts: { sources: readonly ProviderConfigSource[]; // any order — ranked by `kind` baseline: PlatformBaseline; - envVars?: Record; // non-secret connection overlay (default {}) + envVars?: Record; // non-secret connection source ranked below enforced (default {}) }): readonly ResolvedProvider[]; ``` -A `ProviderConfigSource` declares _what it is_ via `kind`, not _where it sits_. The resolver maps each kind to a fixed rank, highest → lowest: **`enforced` > `user` > `host` > `default`**, with the `PlatformBaseline` beneath all sources. The `enforced` layer is a **sealed overlay** — no lower source can overwrite an enforced key (a correctness invariant). Object fields deep-merge per leaf-key; `allow`/`deny` arrays wholesale-replace. +A `ProviderConfigSource` declares _what it is_ via `kind`, not _where it sits_. The resolver maps each kind to a fixed rank, highest → lowest: **`enforced` > connection env > `user` > `host` > `default`**, with the `PlatformBaseline` beneath all sources. The `enforced` layer is a **sealed overlay** — no lower source can overwrite an enforced key (a correctness invariant). Connection env vars (from `envVars`) are converted into a resolver-owned source ranked below `enforced` but above `user`, so admin pins always win while env vars still override file-based config. Object fields deep-merge per leaf-key; `allow`/`deny` arrays wholesale-replace. `mergeEnforced` (two-layer) and `mergeConfigFragments` (layered) remain exported as the low-level merge primitives, but consumers should assemble sources and call `resolveProviderCatalog` rather than merging by hand. @@ -186,15 +186,15 @@ const catalog = await loadResolvedProviderCatalog({ `LoadCatalogOptions`: -| Field | Description | -| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `baseline` (required) | `PlatformBaseline` — enablement defaults for this platform. | -| `configPath?` | Override the file path (testing). | -| `enforcedEnvVar?` | Override the enforced env-var name (defaults to `POSIT_AI_PROVIDERS_ENFORCED`; testing). | -| `defaultEnvVar?` | Override the defaults env-var name (defaults to `POSIT_AI_PROVIDERS_DEFAULT`; testing). | -| `envVars?` | Source for the non-secret connection overlay **and** the enforced/default fragment env vars (defaults to `process.env`). | -| `additionalSources?` | Extra watchable `ProviderConfigSourceProvider`s (e.g. a Positron `authentication.*` `host` source). Folded into both the load and watch paths. | -| `logger?` | `LoggerLike` for diagnostics/validation warnings. | +| Field | Description | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `baseline` (required) | `PlatformBaseline` — enablement defaults for this platform. | +| `configPath?` | Override the file path (testing). | +| `enforcedEnvVar?` | Override the enforced env-var name (defaults to `POSIT_AI_PROVIDERS_ENFORCED`; testing). | +| `defaultEnvVar?` | Override the defaults env-var name (defaults to `POSIT_AI_PROVIDERS_DEFAULT`; testing). | +| `envVars?` | Source for non-secret connection env vars (converted into a resolver-owned source ranked below `enforced`) **and** for reading the enforced/default fragment env vars (defaults to `process.env`). | +| `additionalSources?` | Extra watchable `ProviderConfigSourceProvider`s (e.g. a Positron `authentication.*` `host` source). Folded into both the load and watch paths. | +| `logger?` | `LoggerLike` for diagnostics/validation warnings. | #### `mutateProvidersConfig(mutator, opts?): Promise` @@ -272,7 +272,7 @@ sub.dispose(); Config flows through **assemble sources → resolve → watch**: 1. **Assemble sources**: the node seam reads the user file (missing → `{}`, validated against `providersConfigSchema`), the enforced fragment from `POSIT_AI_PROVIDERS_ENFORCED`, and the defaults fragment from `POSIT_AI_PROVIDERS_DEFAULT` (both validated against the relaxed `enforcedProvidersConfigSchema`), plus any `additionalSources` (e.g. a Positron `authentication.*` `host` source). Each becomes a `ProviderConfigSource` tagged with its `kind`. -2. **Resolve** (`resolveProviderCatalog`): rank the sources by kind (`enforced` > `user` > `host` > `default`), fold them low → high so the sealed `enforced` overlay can never be overwritten, apply the `PlatformBaseline` beneath, and build `ResolvedProvider[]` — objects deep-merge per leaf-key, `allow`/`deny` arrays wholesale-replace, and a non-secret env-var overlay applies on top. +2. **Resolve** (`resolveProviderCatalog`): rank the sources by kind (`enforced` > connection env > `user` > `host` > `default`), fold them low → high so the sealed `enforced` overlay can never be overwritten, apply the `PlatformBaseline` beneath, and build `ResolvedProvider[]` — objects deep-merge per leaf-key, `allow`/`deny` arrays wholesale-replace. Connection env vars are a resolver-owned source below `enforced`, not a post-resolution overlay. 3. **Watch** (`watchResolvedProviderCatalog`): debounced (~300ms), ancestor-aware watch over **every** source (file via `fs.watch`; host sources emit their own change signals; env sources are static) that re-resolves, diffs against the previous catalog, and emits a typed change only when something actually changed. The read path **degrades gracefully**: malformed or missing files log a warning and fall back rather than throwing. diff --git a/packages/ai-config/src/__tests__/build-catalog-custom.test.ts b/packages/ai-config/src/__tests__/build-catalog-custom.test.ts index 360e7af..2e5a2ca 100644 --- a/packages/ai-config/src/__tests__/build-catalog-custom.test.ts +++ b/packages/ai-config/src/__tests__/build-catalog-custom.test.ts @@ -32,15 +32,9 @@ function configWithCustom(): ProvidersConfig { describe("buildCatalog custom providers", () => { it("includes custom providers", () => { - const catalog = buildCatalog(configWithCustom(), layersOf(configWithCustom()), BASELINE, {}); - const customEntry = catalog.find((p) => p.id === "my-gateway"); - expect(customEntry).toBeDefined(); - expect(customEntry!.clientKind).toBe("openai-compatible"); - }); - - it("includes custom providers when options is undefined (default)", () => { const catalog = buildCatalog(configWithCustom(), layersOf(configWithCustom()), BASELINE); const customEntry = catalog.find((p) => p.id === "my-gateway"); expect(customEntry).toBeDefined(); + expect(customEntry!.clientKind).toBe("openai-compatible"); }); }); diff --git a/packages/ai-config/src/__tests__/resolve-catalog.test.ts b/packages/ai-config/src/__tests__/resolve-catalog.test.ts index 1425f05..1f1e5eb 100644 --- a/packages/ai-config/src/__tests__/resolve-catalog.test.ts +++ b/packages/ai-config/src/__tests__/resolve-catalog.test.ts @@ -389,13 +389,66 @@ describe("resolveProviderCatalog — host layer merge semantics (Phase 6)", () = }); }); +describe("resolveProviderCatalog — enforced beats connection env", () => { + it("enforced baseUrl wins over connection env var", () => { + const catalog = resolveProviderCatalog({ + sources: [ + source("enforced", { + providers: { anthropic: { baseUrl: "https://enforced.example.com" } }, + }), + source("user", { providers: {} }), + ], + baseline: STANDALONE, + envVars: { ANTHROPIC_BASE_URL: "https://env.example.com" }, + }); + expect(find(catalog, "anthropic")?.connection.baseUrl).toBe("https://enforced.example.com"); + }); + + it("env beats user/default when no enforced source pins the field", () => { + const catalog = resolveProviderCatalog({ + sources: [ + source("user", { + providers: { anthropic: { baseUrl: "https://user.example.com" } }, + }), + source("default", { + providers: { anthropic: { baseUrl: "https://default.example.com" } }, + }), + ], + baseline: STANDALONE, + envVars: { ANTHROPIC_BASE_URL: "https://env.example.com" }, + }); + expect(find(catalog, "anthropic")?.connection.baseUrl).toBe("https://env.example.com"); + }); + + it("enforced positaiLogin.host wins over POSITAI_AUTH_HOST env, env-only sub-key still lands", () => { + const catalog = resolveProviderCatalog({ + sources: [ + source("enforced", { + providers: { positai: { positaiLogin: { host: "enforced.login.com" } } }, + }), + source("user", { providers: {} }), + ], + baseline: STANDALONE, + envVars: { + POSITAI_AUTH_HOST: "env.login.com", + POSITAI_CLIENT_ID: "env-client-id", + }, + }); + const login = find(catalog, "positai")?.connection.positaiLogin; + // Enforced host wins over env + expect(login?.host).toBe("enforced.login.com"); + // Env-only sub-key (clientId not in enforced) still lands + expect(login?.clientId).toBe("env-client-id"); + }); +}); + describe("recoverValidStack — choose dropped source", () => { /** Custom entry with no `type` — uncompletable unless another source supplies it. */ const badCustom = (name: string): ProviderConfigSource["config"] => ({ providers: { custom: { [name]: { enabled: false } } }, }); - function keptKinds(sources: readonly ProviderConfigSource[]): string[] { + function keptKinds(sources: readonly { readonly kind: string }[]): string[] { return sources.map((s) => s.kind); } diff --git a/packages/ai-config/src/build-catalog.ts b/packages/ai-config/src/build-catalog.ts index 3365769..caecff8 100644 --- a/packages/ai-config/src/build-catalog.ts +++ b/packages/ai-config/src/build-catalog.ts @@ -27,55 +27,6 @@ import { mintCustomProviderId } from "./types.js"; import { BUILTIN_PROVIDER_IDS } from "./vocabulary.js"; import type { BuiltinProviderId, ClientKind } from "./vocabulary.js"; -// --------------------------------------------------------------------------- -// Non-secret connection env var mappings -// --------------------------------------------------------------------------- - -/** - * Maps environment variable names to non-secret connection fields for - * built-in providers. Env vars have the highest precedence in the - * connection resolution chain: env > file (providers.json) > defaults. - * - * Only non-secret connection config goes here. Secret env vars (API keys, - * AWS secret keys) are handled by the separate `envCredentialResolver` in - * `@assistant/node`. - */ -interface ConnectionEnvMapping { - baseUrl?: string; - endpoint?: string; - positaiLogin?: { host?: string; clientId?: string; scope?: string }; - aws?: { region?: string; profile?: string }; - googleCloud?: { project?: string; location?: string }; -} - -const CONNECTION_ENV_MAPPINGS: Readonly> = { - anthropic: { baseUrl: "ANTHROPIC_BASE_URL" }, - openai: { baseUrl: "OPENAI_BASE_URL" }, - gemini: { baseUrl: "GEMINI_BASE_URL" }, - positai: { - baseUrl: "POSITAI_BASE_URL", - positaiLogin: { - host: "POSITAI_AUTH_HOST", - clientId: "POSITAI_CLIENT_ID", - scope: "POSITAI_SCOPE", - }, - }, - openrouter: { baseUrl: "OPENROUTER_BASE_URL" }, - ollama: { endpoint: "OLLAMA_ENDPOINT" }, - lmstudio: { endpoint: "LMSTUDIO_ENDPOINT" }, - bedrock: { aws: { region: "AWS_REGION", profile: "AWS_PROFILE" } }, - "google-vertex": { - googleCloud: { - project: "GOOGLE_CLOUD_PROJECT", - location: "GOOGLE_CLOUD_LOCATION", - }, - }, - "openai-compatible": { baseUrl: "OPENAI_COMPATIBLE_BASE_URL" }, - "ms-foundry": { baseUrl: "MS_FOUNDRY_BASE_URL" }, - "snowflake-cortex": { baseUrl: "SNOWFLAKE_BASE_URL" }, - deepseek: { baseUrl: "DEEPSEEK_BASE_URL" }, -}; - // --------------------------------------------------------------------------- // Built-in provider id → client kind mapping // --------------------------------------------------------------------------- @@ -113,7 +64,8 @@ const BUILTIN_CLIENT_KIND = { * This is the **catalog builder** behind `resolveProviderCatalog` — consumers * iterate the result instead of the static registry. Connection, model * policy, and client kind are read from `mergedConfig` (the deep-merged - * result where higher-precedence sources win per key). Enablement is resolved + * result where higher-precedence sources win per key, including connection + * env vars already folded by the resolver). Enablement is resolved * separately from `enabledLayers` (highest precedence first) so the sealed * enforced overlay can never be overridden and per-layer "id beats default" * semantics are preserved. @@ -122,27 +74,15 @@ export function buildCatalog( mergedConfig: ProvidersConfig, enabledLayers: readonly EnablementLayer[], baseline: PlatformBaseline, - options?: { - /** - * Environment variables for the non-secret connection overlay. Pure — - * defaults to `{}` (no overlay) when omitted, never `process.env`. Node - * callers inject `process.env` explicitly. - */ - envVars?: Record; - }, ): readonly ResolvedProvider[] { const providers = mergedConfig.providers; const catalog: ResolvedProvider[] = []; - // This builder is part of the PURE entry — never reach for `process.env` - // here (a browser/renderer/notebooks caller may have no `process`). Node - // callers inject `process.env` via the ai-config/node seams. - const envVars = options?.envVars ?? {}; // 1. Built-in providers for (const id of BUILTIN_PROVIDER_IDS) { const block = getBuiltinBlock(providers, id); const enabled = resolveEnabled(id, enabledLayers, baseline); - const connection = applyEnvOverlay(id, resolveConnection(id, block), envVars); + const connection = resolveConnection(id, block); catalog.push({ id, @@ -275,92 +215,3 @@ function mergeOptionalSection>( } return { ...defaults, ...block }; } - -// --------------------------------------------------------------------------- -// Environment variable overlay -// --------------------------------------------------------------------------- - -/** - * Apply non-secret connection env vars on top of the resolved connection. - * Env vars have the highest precedence: env > file (providers.json) > defaults. - * - * Only overrides fields where the corresponding env var is set (non-empty). - */ -function applyEnvOverlay( - providerId: string, - connection: ResolvedConnection, - envVars: Record, -): ResolvedConnection { - const mapping = CONNECTION_ENV_MAPPINGS[providerId]; - if (!mapping) return connection; - - let result = connection; - let changed = false; - - // Top-level scalar fields - if (mapping.baseUrl) { - const val = envVars[mapping.baseUrl]; - if (val) { - result = changed ? result : { ...result }; - result.baseUrl = val; - changed = true; - } - } - if (mapping.endpoint) { - const val = envVars[mapping.endpoint]; - if (val) { - result = changed ? result : { ...result }; - result.endpoint = val; - changed = true; - } - } - - // Nested sections — only override fields where the env var is set - if (mapping.positaiLogin) { - const overlay = readEnvSection(mapping.positaiLogin, envVars); - if (overlay) { - result = changed ? result : { ...result }; - result.positaiLogin = result.positaiLogin ? { ...result.positaiLogin, ...overlay } : overlay; - changed = true; - } - } - if (mapping.aws) { - const overlay = readEnvSection(mapping.aws, envVars); - if (overlay) { - result = changed ? result : { ...result }; - result.aws = result.aws ? { ...result.aws, ...overlay } : overlay; - changed = true; - } - } - if (mapping.googleCloud) { - const overlay = readEnvSection(mapping.googleCloud, envVars); - if (overlay) { - result = changed ? result : { ...result }; - result.googleCloud = result.googleCloud ? { ...result.googleCloud, ...overlay } : overlay; - changed = true; - } - } - - return result; -} - -/** - * Read a nested env-mapping section (e.g. `{ host: "ENV_VAR_NAME", ... }`) - * and return an object with only the fields whose env vars are set. - * Returns `undefined` if no env vars in the section are set. - */ -function readEnvSection>( - mapping: T, - envVars: Record, -): Record | undefined { - let result: Record | undefined; - for (const [field, envVarName] of Object.entries(mapping)) { - if (!envVarName) continue; - const val = envVars[envVarName]; - if (val) { - result ??= {}; - result[field] = val; - } - } - return result; -} diff --git a/packages/ai-config/src/connection-env.ts b/packages/ai-config/src/connection-env.ts new file mode 100644 index 0000000..8906df7 --- /dev/null +++ b/packages/ai-config/src/connection-env.ts @@ -0,0 +1,135 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (C) 2026 Posit Software, PBC. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +/** + * Non-secret connection env-var mappings and the reader that converts them + * into a config source fragment. + * + * Internal to ai-config — the env fragment is synthesized inside + * `resolveProviderCatalog` and ranked below `enforced` so admin-pinned + * values can never be overridden by a user's shell variables. + */ + +import type { BuiltinProviderBlock, EnforcedProvidersConfig } from "./types.js"; +import { BUILTIN_PROVIDER_IDS } from "./vocabulary.js"; +import type { BuiltinProviderId } from "./vocabulary.js"; + +// --------------------------------------------------------------------------- +// Non-secret connection env var mappings +// --------------------------------------------------------------------------- + +/** + * Maps environment variable names to non-secret connection fields for a + * built-in provider. + * + * Only non-secret connection config goes here. Secret env vars (API keys, + * AWS secret keys) are handled by the separate `envCredentialResolver` in + * `@assistant/node`. + */ +interface ConnectionEnvMapping { + baseUrl?: string; + endpoint?: string; + positaiLogin?: { host?: string; clientId?: string; scope?: string }; + aws?: { region?: string; profile?: string }; + googleCloud?: { project?: string; location?: string }; +} + +/** + * Annotated as `Partial>` so the reader can + * iterate `BUILTIN_PROVIDER_IDS` and `continue` on an undefined mapping — + * no assertion needed, and the provider vocabulary stays the single source + * of truth. + */ +const CONNECTION_ENV_MAPPINGS: Partial> = { + anthropic: { baseUrl: "ANTHROPIC_BASE_URL" }, + openai: { baseUrl: "OPENAI_BASE_URL" }, + gemini: { baseUrl: "GEMINI_BASE_URL" }, + positai: { + baseUrl: "POSITAI_BASE_URL", + positaiLogin: { + host: "POSITAI_AUTH_HOST", + clientId: "POSITAI_CLIENT_ID", + scope: "POSITAI_SCOPE", + }, + }, + openrouter: { baseUrl: "OPENROUTER_BASE_URL" }, + ollama: { endpoint: "OLLAMA_ENDPOINT" }, + lmstudio: { endpoint: "LMSTUDIO_ENDPOINT" }, + bedrock: { aws: { region: "AWS_REGION", profile: "AWS_PROFILE" } }, + "google-vertex": { + googleCloud: { + project: "GOOGLE_CLOUD_PROJECT", + location: "GOOGLE_CLOUD_LOCATION", + }, + }, + "openai-compatible": { baseUrl: "OPENAI_COMPATIBLE_BASE_URL" }, + "ms-foundry": { baseUrl: "MS_FOUNDRY_BASE_URL" }, + "snowflake-cortex": { baseUrl: "SNOWFLAKE_BASE_URL" }, + deepseek: { baseUrl: "DEEPSEEK_BASE_URL" }, +}; + +// --------------------------------------------------------------------------- +// Env fragment reader +// --------------------------------------------------------------------------- + +/** + * Read non-secret connection env vars and return them as an + * `EnforcedProvidersConfig` fragment suitable for insertion into the + * resolver's precedence stack. + * + * The returned fragment carries only connection fields — never enablement. + * When no env vars are set, the `providers` map is empty (`{}`). + */ +export function readEnvConnectionConfig( + envVars: Record, +): EnforcedProvidersConfig { + const providers: Partial> = {}; + for (const id of BUILTIN_PROVIDER_IDS) { + const mapping = CONNECTION_ENV_MAPPINGS[id]; + if (!mapping) continue; + + const block: BuiltinProviderBlock = {}; + if (mapping.baseUrl) { + const val = envVars[mapping.baseUrl]; + if (val) block.baseUrl = val; + } + if (mapping.endpoint) { + const val = envVars[mapping.endpoint]; + if (val) block.endpoint = val; + } + + const positaiLogin = mapping.positaiLogin && readEnvSection(mapping.positaiLogin, envVars); + if (positaiLogin) block.positaiLogin = positaiLogin; + + const aws = mapping.aws && readEnvSection(mapping.aws, envVars); + if (aws) block.aws = aws; + + const googleCloud = mapping.googleCloud && readEnvSection(mapping.googleCloud, envVars); + if (googleCloud) block.googleCloud = googleCloud; + + if (Object.keys(block).length > 0) providers[id] = block; + } + return { providers }; +} + +/** + * Read a nested env-mapping section (e.g. `{ host: "ENV_VAR_NAME", ... }`) + * and return an object with only the fields whose env vars are set. + * Returns `undefined` if no env vars in the section are set. + */ +function readEnvSection>( + mapping: T, + envVars: Record, +): Record | undefined { + let result: Record | undefined; + for (const [field, envVarName] of Object.entries(mapping)) { + if (!envVarName) continue; + const val = envVars[envVarName]; + if (val) { + result ??= {}; + result[field] = val; + } + } + return result; +} diff --git a/packages/ai-config/src/node/types.ts b/packages/ai-config/src/node/types.ts index 5ca756e..775b8f8 100644 --- a/packages/ai-config/src/node/types.ts +++ b/packages/ai-config/src/node/types.ts @@ -51,10 +51,10 @@ export interface LoadCatalogOptions { readonly logger?: LoggerLike; /** - * Environment variables for non-secret connection overlay AND for reading - * the enforced/default fragment env vars. - * Env vars have highest precedence: env > file > defaults. - * Defaults to `process.env` when omitted. Useful for testing. + * Environment variables for non-secret connection fields (converted into a + * resolver-owned source ranked below `enforced` but above + * `user`/`host`/`default`) AND for reading the enforced/default fragment + * env vars. Defaults to `process.env` when omitted. Useful for testing. */ readonly envVars?: Record; diff --git a/packages/ai-config/src/resolve-catalog.ts b/packages/ai-config/src/resolve-catalog.ts index 32d96ff..b54639e 100644 --- a/packages/ai-config/src/resolve-catalog.ts +++ b/packages/ai-config/src/resolve-catalog.ts @@ -18,6 +18,7 @@ */ import { buildCatalog } from "./build-catalog.js"; +import { readEnvConnectionConfig } from "./connection-env.js"; import { mergeConfigFragments } from "./enforce.js"; import type { EnablementLayer } from "./resolve-enabled.js"; import { providersConfigSchema } from "./schema.js"; @@ -48,12 +49,35 @@ import type { */ export type ProviderConfigSourceKind = "enforced" | "user" | "host" | "default"; -/** Fixed precedence rank per source kind — lower number = higher precedence. */ -const KIND_RANK: Readonly> = { +// --------------------------------------------------------------------------- +// Private ranked-source union (env stays internal to the resolver) +// --------------------------------------------------------------------------- + +/** + * A connection-only source synthesized from `envVars` by the resolver. + * Ranked below `enforced` so admin pins are never overridden, but above + * `user`/`host`/`default` so env vars still beat file-based config. + * + * Private — never appears in `ProviderConfigSourceKind` or the public API. + */ +type ConnectionEnvSource = { kind: "env"; label: string; config: EnforcedProvidersConfig }; + +/** Union of all source types the resolver handles internally. */ +type RankedConfigSource = ProviderConfigSource | ConnectionEnvSource; + +/** + * Fixed precedence rank — lower number = higher precedence. + * + * `env` is internal: connection env vars rank below `enforced` (so admin + * pins are never overridden) but above `user`/`host`/`default` (preserving + * env's documented purpose of overriding file-based config). + */ +const RANK: Readonly> = { enforced: 0, - user: 1, - host: 2, - default: 3, + env: 1, + user: 2, + host: 3, + default: 4, }; /** @@ -90,11 +114,13 @@ export interface ResolveProviderCatalogOptions { readonly baseline: PlatformBaseline; /** - * Environment variables for the non-secret connection overlay. - * Env vars have highest precedence: env > file > defaults. + * Environment variables for non-secret connection fields. The resolver + * converts these into an internal connection source ranked **below + * `enforced`** but above `user`/`host`/`default`, so admin-pinned values + * always win while env vars still override file-based config. * * This is a **pure** function: when omitted it defaults to `{}` (no env - * overlay), never `process.env`. Node callers that want the process + * source), never `process.env`. Node callers that want the process * environment inject it explicitly (the `ai-config/node` seams do). */ readonly envVars?: Record; @@ -130,14 +156,26 @@ export function resolveProviderCatalog( ): readonly ResolvedProvider[] { const { sources, baseline, envVars, logger } = opts; + // Synthesize the private env source from envVars. Skipped when the + // fragment is empty so an inert source never enters the stack. + const envConfig = readEnvConnectionConfig(envVars ?? {}); + const hasEnvConfig = envConfig.providers && Object.keys(envConfig.providers).length > 0; + const ranked: RankedConfigSource[] = hasEnvConfig + ? [...sources, { kind: "env", label: "connection env vars", config: envConfig }] + : [...sources]; + // Order sources by precedence rank (highest precedence first). Array.sort // is stable, so same-kind sources keep their array order (earlier wins). - const highestFirst = [...sources].sort((a, b) => KIND_RANK[a.kind] - KIND_RANK[b.kind]); + const highestFirst = ranked.sort((a, b) => RANK[a.kind] - RANK[b.kind]); const { kept, config } = recoverValidStack(highestFirst, logger); - const enabledLayers = kept.map((s) => s.config.providers); - return buildCatalog(config, enabledLayers, baseline, { envVars }); + // Derive enablement layers excluding the env source — it carries only + // connection fields, so it must never influence enablement resolution. + const enabledLayers = kept + .filter((s): s is ProviderConfigSource => s.kind !== "env") + .map((s) => s.config.providers); + return buildCatalog(config, enabledLayers, baseline); } // --------------------------------------------------------------------------- @@ -147,7 +185,7 @@ export function resolveProviderCatalog( /** The largest valid sub-stack plus its validated merged config. */ export interface RecoveredStack { /** Kept sources, highest precedence first. */ - readonly kept: readonly ProviderConfigSource[]; + readonly kept: readonly RankedConfigSource[]; /** The validated merged config for the kept sources. */ readonly config: ProvidersConfig; } @@ -169,7 +207,7 @@ export interface RecoveredStack { * which overlay to drop, then re-validates, until the stack is valid. */ export function recoverValidStack( - highestFirst: readonly ProviderConfigSource[], + highestFirst: readonly RankedConfigSource[], logger?: LoggerLike, ): RecoveredStack { let kept = highestFirst; @@ -203,8 +241,8 @@ export function recoverValidStack( * `undefined` when there are no relaxed overlays left to drop. */ function chooseDroppedSource( - highestFirst: readonly ProviderConfigSource[], -): ProviderConfigSource | undefined { + highestFirst: readonly RankedConfigSource[], +): RankedConfigSource | undefined { // Lowest precedence first, so a preferred single-removal fix and the // last-resort drop both bias toward keeping higher-precedence sources. const relaxedLowestFirst = highestFirst.filter((s) => s.kind !== "user").reverse(); @@ -226,7 +264,7 @@ function chooseDroppedSource( * single config and validate it with the full schema. */ function mergeAndValidate( - highestFirst: readonly ProviderConfigSource[], + highestFirst: readonly RankedConfigSource[], ): ReturnType { let merged: EnforcedProvidersConfig = {}; for (let i = highestFirst.length - 1; i >= 0; i--) { @@ -236,13 +274,13 @@ function mergeAndValidate( } function without( - sources: readonly ProviderConfigSource[], - victim: ProviderConfigSource, -): ProviderConfigSource[] { + sources: readonly RankedConfigSource[], + victim: RankedConfigSource, +): RankedConfigSource[] { return sources.filter((s) => s !== victim); } -function describeSource(source: ProviderConfigSource): string { +function describeSource(source: RankedConfigSource): string { return source.label ? `"${source.label}" (${source.kind})` : source.kind; }