diff --git a/packages/coding-agent/src/cli/__tests__/auth-gateway-catalog.test.ts b/packages/coding-agent/src/cli/__tests__/auth-gateway-catalog.test.ts index b0fb8d3a8..f780fac5e 100644 --- a/packages/coding-agent/src/cli/__tests__/auth-gateway-catalog.test.ts +++ b/packages/coding-agent/src/cli/__tests__/auth-gateway-catalog.test.ts @@ -5,11 +5,11 @@ import { TempDir } from "@oh-my-pi/pi-utils"; import { ModelRegistry } from "../../config/model-registry"; import { indexModelsByRequestId } from "../auth-gateway-cli"; -function stubAuthStorage(): AuthStorage { +function stubAuthStorage(configKeys?: string[]): AuthStorage { const stub = { setFallbackResolver: () => {}, clearConfigApiKeys: () => {}, - setConfigApiKey: () => {}, + setConfigApiKey: (provider: string) => configKeys?.push(provider), removeConfigApiKey: () => {}, hasAuth: () => true, getAll: () => ({ anthropic: {} }), @@ -49,9 +49,11 @@ describe("indexModelsByRequestId (auth-gateway catalog)", () => { expect(index.get("claude-opus-5-repro")?.id).toBe("claude-opus-5-repro"); }); - test("ignores client-side pi-native routing in the gateway registry", async () => { + test("gateway registry ignores local models.yml credential and routing overrides", async () => { using tempDir = TempDir.createSync("@omp-auth-gateway-catalog-"); const modelsPath = tempDir.join("models.yml"); + // anthropic: a plain credential/baseUrl override (no transport) — the + // reviewer's leak. openai: a pi-native gateway route — the self-routing loop. await Bun.write( modelsPath, [ @@ -59,25 +61,36 @@ describe("indexModelsByRequestId (auth-gateway catalog)", () => { " anthropic:", " baseUrl: http://127.0.0.1:18899", " apiKey: gateway-token", + " openai:", + " baseUrl: http://127.0.0.1:18899", + " apiKey: gateway-token", " transport: pi-native", "", ].join("\n"), ); - const clientRegistry = new ModelRegistry(stubAuthStorage(), modelsPath); - const routedModel = clientRegistry.find("anthropic", "claude-sonnet-4-5"); - expect(routedModel?.transport).toBe("pi-native"); - expect(routedModel?.baseUrl).toBe("http://127.0.0.1:18899"); + // A normal client registry applies the local overrides and installs the + // config API keys into AuthStorage. + const clientKeys: string[] = []; + const clientRegistry = new ModelRegistry(stubAuthStorage(clientKeys), modelsPath); + expect(clientRegistry.find("anthropic", "claude-sonnet-4-5")?.baseUrl).toBe("http://127.0.0.1:18899"); + expect(clientRegistry.getAll().find(model => model.provider === "openai")?.transport).toBe("pi-native"); + expect(clientKeys).toContain("anthropic"); - const gatewayRegistry = new ModelRegistry(stubAuthStorage(), modelsPath, { - ignorePiNativeProviderConfig: true, + // The gateway registry ignores models.yml entirely: bundled routing wins, + // no config key reaches AuthStorage, and no pi-native self-route survives. + const gatewayKeys: string[] = []; + const gatewayRegistry = new ModelRegistry(stubAuthStorage(gatewayKeys), modelsPath, { + ignoreLocalModelConfig: true, }); const gatewayModel = gatewayRegistry.find("anthropic", "claude-sonnet-4-5"); const bundledModel = getBundledModels("anthropic").find(model => model.id === "claude-sonnet-4-5"); if (!gatewayModel || !bundledModel) throw new Error("expected bundled Anthropic model"); - expect(gatewayModel.transport).toBeUndefined(); expect(gatewayModel.baseUrl).toBe(bundledModel.baseUrl); + expect(gatewayModel.transport).toBeUndefined(); + expect(gatewayKeys).toHaveLength(0); + expect(gatewayRegistry.getAll().find(model => model.provider === "openai")?.transport).toBeUndefined(); expect(indexModelsByRequestId(gatewayRegistry.getAll(), new Set(["anthropic"])).get(gatewayModel.id)).toBe( gatewayModel, ); diff --git a/packages/coding-agent/src/cli/auth-gateway-cli.ts b/packages/coding-agent/src/cli/auth-gateway-cli.ts index 66a832d96..3e66506a4 100644 --- a/packages/coding-agent/src/cli/auth-gateway-cli.ts +++ b/packages/coding-agent/src/cli/auth-gateway-cli.ts @@ -200,15 +200,18 @@ async function runServe(flags: AuthGatewayCommandArgs["flags"]): Promise { // Build the model resolver + catalog from the ModelRegistry — the same // component the TUI/CLI use — scoped to providers we hold credentials for. // `getAll()` is a superset of the bundled catalog (bundled first, then - // cached + discovered), so the discovery-only models omp itself reaches - // become routable through the gateway instead of freezing on the compiled - // snapshot. Format handlers ask `resolveModel` to translate a - // client-requested `model` field into a pi-ai `Model` before dispatch; + // cached + broker-discovered), so the discovery-only models omp itself + // reaches become routable through the gateway instead of freezing on the + // compiled snapshot. `ignoreLocalModelConfig` keeps the host's `models.yml` + // out of the picture: client-side provider overrides (baseUrl/apiKey/headers/ + // transport) and custom models must never route a broker-backed gateway or + // shadow broker credentials. Format handlers ask `resolveModel` to translate + // a client-requested `model` field into a pi-ai `Model` before dispatch; // `listModels` powers `/v1/models`. const snapshot = storage.exportSnapshot(); const providersWithCreds = new Set(); for (const entry of snapshot.credentials) providersWithCreds.add(entry.provider); - const registry = new ModelRegistry(storage, undefined, { ignorePiNativeProviderConfig: true }); + const registry = new ModelRegistry(storage, undefined, { ignoreLocalModelConfig: true }); await registry.refresh(); let modelById = indexModelsByRequestId(registry.getAll(), providersWithCreds); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index 67d81169c..031688361 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -793,7 +793,7 @@ export class ModelRegistry { // Runtime model managers registered by extensions via fetchDynamicModels. // Keyed by provider name; use the same SQLite cache path as builtins. #runtimeModelManagers: Map; sourceId: string }> = new Map(); - #ignorePiNativeProviderConfig: boolean; + #ignoreLocalModelConfig: boolean; #fetch: FetchImpl; #resolveCommandBackedApiKey(provider: string): CommandApiKeyResolution { @@ -831,12 +831,17 @@ export class ModelRegistry { readonly authStorage: AuthStorage, modelsPath?: string, options?: { - /** Skip models config providers routed through a pi-native gateway, preventing a gateway server from routing back into itself. */ - ignorePiNativeProviderConfig?: boolean; + /** + * Gateway mode: ignore local `models.yml` entirely (provider overrides, + * config API keys, custom models, custom discovery). A broker-backed + * gateway serves only bundled + broker-discovered catalog metadata and + * must never apply client-side credential or routing overrides. + */ + ignoreLocalModelConfig?: boolean; fetch?: FetchImpl; }, ) { - this.#ignorePiNativeProviderConfig = options?.ignorePiNativeProviderConfig ?? false; + this.#ignoreLocalModelConfig = options?.ignoreLocalModelConfig ?? false; this.#fetch = options?.fetch ?? (isBunTestRuntime() @@ -1373,6 +1378,24 @@ export class ModelRegistry { } #loadCustomModels(): CustomModelsResult { + // Gateway mode: serve bundled + broker-discovered catalog metadata only. + // Local models.yml provider overrides (baseUrl/apiKey/headers/transport), + // custom models, custom discovery, and config API keys are all client-side + // routing that MUST NOT reach a broker-backed gateway — applying them would + // send broker bearers to a configured endpoint, install config keys that + // shadow broker credentials (bypassing account pooling/refresh/accounting), + // or route a pi-native gateway back into itself. + if (this.#ignoreLocalModelConfig) { + return { + models: [], + overrides: new Map(), + modelOverrides: new Map(), + keylessProviders: new Set(), + discoverableProviders: [], + configuredProviders: new Set(), + found: false, + }; + } const { value, error, status } = this.#modelsConfigFile.tryLoad(); if (status === "error") { @@ -1402,13 +1425,8 @@ export class ModelRegistry { const allModelOverrides = new Map>(); const keylessProviders = new Set(); const discoverableProviders: DiscoveryProviderConfig[] = []; - const providerEntries = Object.entries(value.providers ?? {}).filter( - ([, providerConfig]) => !this.#ignorePiNativeProviderConfig || providerConfig.transport !== "pi-native", - ); - const configuredProviders = new Set(providerEntries.map(([providerName]) => providerName)); - const effectiveConfig = this.#ignorePiNativeProviderConfig - ? { ...value, providers: Object.fromEntries(providerEntries) } - : value; + const providerEntries = Object.entries(value.providers ?? {}); + const configuredProviders = new Set(Object.keys(value.providers ?? {})); for (const [providerName, providerConfig] of providerEntries) { const resolvedProviderHeaders = resolveConfigHeaders(providerConfig.headers); // Always set overrides when baseUrl/headers/apiKey/authHeader/compat/disableStrictTools/transport are present @@ -1481,7 +1499,7 @@ export class ModelRegistry { } return { - models: this.#parseModels(effectiveConfig), + models: this.#parseModels(value), overrides, modelOverrides: allModelOverrides, keylessProviders,