From 408a3f3be00e33dab11dbdfa9030095bb732a504 Mon Sep 17 00:00:00 2001 From: credence-the-bot Date: Tue, 12 May 2026 18:56:48 +0000 Subject: [PATCH] feat(providers): make managed-auth capability catalog-driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #30408 (Jason / LUM-1517) shipped the UX fix on macOS: hide the Platform auth-type option for providers without a managed proxy route. It does so via `store.isManagedCapable(provider)` which until now was a hardcoded `Set("anthropic", "openai", "gemini")` on SettingsStore — with a docstring that explicitly calls out 'Mirrors the `MANAGED_PROVIDER_META` table in the backend.' That's a drift hazard: adding a managed provider in the proxy routing table doesn't propagate to the UI gate. This PR closes the loop by making the catalog the source of truth. Changes ------- * assistant/src/providers/model-catalog.ts — add `supportsManagedAuth?: boolean` to ProviderCatalogEntry, derived from `MANAGED_PROVIDER_META[entry.id]?.managed === true` at PROVIDER_CATALOG build time. The two can no longer drift. * assistant/scripts/sync-llm-catalog.ts — project the field through to the client-facing JSON. Re-generated both copies (meta/ + clients/shared/Resources/). * assistant/src/__tests__/llm-catalog-parity.test.ts — parity assertion + a focused invariant test guarding against future hand-edits drifting from MANAGED_PROVIDER_META. * clients/shared/Utilities/LLMProviderRegistry.swift — decode the field on `LLMProviderEntry` (optional, defaults to nil for forward-compat with older catalog versions). * clients/macos/.../SettingsStore.swift — refactor `isManagedCapable(_:)` and `managedCapableProviders` to read the flag from `LLMProviderRegistry` instead of the hardcoded set. The wire-protocol `ProviderCatalogEntry` doesn't carry capability flags, so we read from the static registry to keep the answer stable across daemon `model_info` refreshes. Behavior parity --------------- The existing 7 isManagedCapable tests + 2 managedCapableProviders tests in SettingsStoreManagedInferenceSelectionTests all continue to pass — anthropic/openai/gemini → true, ollama/fireworks/openrouter → false, unknown → false. Same truth table, different source. Not in this PR -------------- * macOS auth-type dropdown filtering — already shipped in #30408. * Web modal parity — separate vellum-assistant-platform PR. * Rename "managed" → "platform" terminology — Noa-tracked follow-up after this lands. Test ---- * `bun test src/__tests__/llm-catalog-parity.test.ts` — 14/14 pass. * Daemon lint clean (`bun run lint`). --- assistant/scripts/sync-llm-catalog.ts | 2 ++ .../src/__tests__/llm-catalog-parity.test.ts | 18 ++++++++++++++++++ assistant/src/providers/model-catalog.ts | 16 ++++++++++++++++ .../Features/Settings/SettingsStore.swift | 19 +++++++++++++------ .../Resources/llm-provider-catalog.json | 6 ++++++ .../Utilities/LLMProviderRegistry.swift | 11 +++++++++++ meta/llm-provider-catalog.json | 6 ++++++ 7 files changed, 72 insertions(+), 6 deletions(-) diff --git a/assistant/scripts/sync-llm-catalog.ts b/assistant/scripts/sync-llm-catalog.ts index 5642885d25f..a4c89a82c54 100644 --- a/assistant/scripts/sync-llm-catalog.ts +++ b/assistant/scripts/sync-llm-catalog.ts @@ -97,6 +97,8 @@ function projectProvider(entry: ProviderCatalogEntry): Record { projected.apiKeyPlaceholder = entry.apiKeyPlaceholder; if (entry.credentialsGuide !== undefined) projected.credentialsGuide = entry.credentialsGuide; + if (entry.supportsManagedAuth !== undefined) + projected.supportsManagedAuth = entry.supportsManagedAuth; projected.defaultModel = entry.defaultModel; projected.models = entry.models.map(projectModel); // NOTE: `apiKeyUrl` intentionally omitted — clients use diff --git a/assistant/src/__tests__/llm-catalog-parity.test.ts b/assistant/src/__tests__/llm-catalog-parity.test.ts index 65d72ae3f90..621d59a63ce 100644 --- a/assistant/src/__tests__/llm-catalog-parity.test.ts +++ b/assistant/src/__tests__/llm-catalog-parity.test.ts @@ -2,6 +2,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, test } from "bun:test"; +import { MANAGED_PROVIDER_META } from "../providers/managed-proxy/constants.js"; import { PROVIDER_CATALOG } from "../providers/model-catalog.js"; import { resolvePricing, resolvePricingForUsage } from "../util/pricing.js"; @@ -70,6 +71,7 @@ interface ClientCatalogEntry { envVar?: string; apiKeyPlaceholder?: string; credentialsGuide?: ClientCatalogCredentialsGuide; + supportsManagedAuth?: boolean; defaultModel: string; models: ClientCatalogModel[]; } @@ -122,6 +124,9 @@ describe("LLM catalog parity: daemon vs client", () => { expect(clientEntry.setupHint).toBe(daemonEntry.setupHint); expect(clientEntry.envVar).toBe(daemonEntry.envVar); expect(clientEntry.apiKeyPlaceholder).toBe(daemonEntry.apiKeyPlaceholder); + expect(clientEntry.supportsManagedAuth).toBe( + daemonEntry.supportsManagedAuth, + ); expect(clientEntry.credentialsGuide).toEqual( daemonEntry.credentialsGuide, ); @@ -129,6 +134,19 @@ describe("LLM catalog parity: daemon vs client", () => { } }); + test("supportsManagedAuth mirrors MANAGED_PROVIDER_META", () => { + // The catalog field is derived from MANAGED_PROVIDER_META at build + // time. This test guards against future hand-edits to model-catalog.ts + // that would let the two drift. Adding a provider to MANAGED_PROVIDER_META + // must auto-propagate; flipping `managed: true` to `false` (or vice + // versa) must propagate too. + for (const entry of PROVIDER_CATALOG) { + const expectedSupportsManagedAuth = + MANAGED_PROVIDER_META[entry.id]?.managed === true; + expect(entry.supportsManagedAuth).toBe(expectedSupportsManagedAuth); + } + }); + test("each provider's model count matches the daemon catalog", () => { const json = loadClientCatalog(); diff --git a/assistant/src/providers/model-catalog.ts b/assistant/src/providers/model-catalog.ts index 22132f4ee86..6563edd1dd2 100644 --- a/assistant/src/providers/model-catalog.ts +++ b/assistant/src/providers/model-catalog.ts @@ -1,3 +1,5 @@ +import { MANAGED_PROVIDER_META } from "./managed-proxy/constants.js"; + export type LongContextMode = | "native-model" | "provider-request-option" @@ -86,6 +88,15 @@ export interface ProviderCatalogEntry { url: string; linkLabel: string; }; + /** + * Whether this provider supports the `platform` auth type (Vellum-managed + * keys routed through the platform proxy). Derived from + * `MANAGED_PROVIDER_META` at catalog build time so the two stay in lock + * step. Clients use this field to hide the "Platform (managed by Vellum)" + * option from the auth-type dropdown for providers like Fireworks or + * OpenRouter where managed keys are not available. + */ + supportsManagedAuth?: boolean; } /** @@ -833,6 +844,11 @@ export const PROVIDER_CATALOG: ProviderCatalogEntry[] = RAW_PROVIDER_CATALOG.map((entry) => ({ ...entry, models: entry.models.map(catalogModel), + // Derive supportsManagedAuth from MANAGED_PROVIDER_META so the catalog + // and the proxy routing table can never drift. Adding a provider to + // MANAGED_PROVIDER_META with `managed: true` automatically opts it into + // the Platform auth-type dropdown in the clients. + supportsManagedAuth: MANAGED_PROVIDER_META[entry.id]?.managed === true, })); /** Check if a model ID is in the catalog for a given provider. */ diff --git a/clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift b/clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift index 1d4fd617e7d..e44d0dbc97c 100644 --- a/clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift +++ b/clients/macos/vellum-assistant/Features/Settings/SettingsStore.swift @@ -1187,17 +1187,23 @@ public final class SettingsStore: ObservableObject { // MARK: - Provider Capability Helpers - /// Provider IDs that support managed proxy routing (i.e., can be used in managed mode). - /// Mirrors the `MANAGED_PROVIDER_META` table in the backend. - private static let managedCapableProviderIds: Set = ["anthropic", "openai", "gemini"] - /// Provider IDs that support native web search (inference-provider-native). /// Anthropic and OpenAI pass `useNativeWebSearch` to their providers; others do not. private static let nativeWebSearchCapableProviderIds: Set = ["anthropic", "openai"] /// Returns the catalog entries for providers that support managed proxy routing. + /// Source of truth: the `supportsManagedAuth` field on `LLMProviderRegistry` + /// entries, which is derived upstream from `MANAGED_PROVIDER_META` at catalog + /// build time. Reading from the registry (not `providerCatalog`) keeps the + /// answer stable across daemon `model_info` refreshes — the wire-protocol + /// `ProviderCatalogEntry` doesn't carry capability flags. var managedCapableProviders: [ProviderCatalogEntry] { - providerCatalog.filter { Self.managedCapableProviderIds.contains($0.id) } + let managedIds = Set( + LLMProviderRegistry.providers + .filter { $0.supportsManagedAuth == true } + .map(\.id) + ) + return providerCatalog.filter { managedIds.contains($0.id) } } /// Returns the catalog entries for providers that support native web search. @@ -1206,8 +1212,9 @@ public final class SettingsStore: ObservableObject { } /// Whether a given provider supports managed proxy routing. + /// See `managedCapableProviders` for the source-of-truth rationale. func isManagedCapable(_ provider: String) -> Bool { - Self.managedCapableProviderIds.contains(provider) + LLMProviderRegistry.provider(id: provider)?.supportsManagedAuth == true } /// Whether the current inference selection supports native web search. diff --git a/clients/shared/Resources/llm-provider-catalog.json b/clients/shared/Resources/llm-provider-catalog.json index 24b45e55dde..09fc44fce72 100644 --- a/clients/shared/Resources/llm-provider-catalog.json +++ b/clients/shared/Resources/llm-provider-catalog.json @@ -14,6 +14,7 @@ "url": "https://console.anthropic.com/settings/keys", "linkLabel": "Open Anthropic Console" }, + "supportsManagedAuth": true, "defaultModel": "claude-opus-4-7", "models": [ { @@ -106,6 +107,7 @@ "url": "https://platform.openai.com/api-keys", "linkLabel": "Open OpenAI Platform" }, + "supportsManagedAuth": true, "defaultModel": "gpt-5.5", "models": [ { @@ -250,6 +252,7 @@ "url": "https://aistudio.google.com/apikey", "linkLabel": "Open Google AI Studio" }, + "supportsManagedAuth": true, "defaultModel": "gemini-2.5-flash", "models": [ { @@ -411,6 +414,7 @@ "url": "https://ollama.com/download", "linkLabel": "Download Ollama" }, + "supportsManagedAuth": false, "defaultModel": "llama3.2", "models": [ { @@ -452,6 +456,7 @@ "url": "https://fireworks.ai/account/api-keys", "linkLabel": "Open Fireworks Dashboard" }, + "supportsManagedAuth": false, "defaultModel": "accounts/fireworks/models/kimi-k2p5", "models": [ { @@ -485,6 +490,7 @@ "url": "https://openrouter.ai/keys", "linkLabel": "Open OpenRouter" }, + "supportsManagedAuth": false, "defaultModel": "x-ai/grok-4.20-beta", "models": [ { diff --git a/clients/shared/Utilities/LLMProviderRegistry.swift b/clients/shared/Utilities/LLMProviderRegistry.swift index 9a6580b6926..c50a781302b 100644 --- a/clients/shared/Utilities/LLMProviderRegistry.swift +++ b/clients/shared/Utilities/LLMProviderRegistry.swift @@ -148,6 +148,15 @@ public struct LLMProviderEntry: Decodable { /// Guide for obtaining API credentials from this provider. `nil` for /// keyless providers. public let credentialsGuide: LLMCredentialsGuide? + /// Whether this provider supports the `platform` auth type — i.e. + /// Vellum-managed keys routed through the platform proxy. Derived + /// upstream from `MANAGED_PROVIDER_META` in + /// `assistant/src/providers/managed-proxy/constants.ts`. When `false` + /// (or absent in older catalog versions, in which case it defaults to + /// `false`), the auth-type dropdown hides the "Platform (managed by + /// Vellum)" option for this provider — selecting it would have no + /// effect since there's no managed proxy route for the provider. + public let supportsManagedAuth: Bool? /// The default model ID (must be present in `models`). public let defaultModel: String /// All models offered by this provider. @@ -162,6 +171,7 @@ public struct LLMProviderEntry: Decodable { envVar: String?, apiKeyPlaceholder: String?, credentialsGuide: LLMCredentialsGuide?, + supportsManagedAuth: Bool? = nil, defaultModel: String, models: [LLMModelEntry] ) { @@ -173,6 +183,7 @@ public struct LLMProviderEntry: Decodable { self.envVar = envVar self.apiKeyPlaceholder = apiKeyPlaceholder self.credentialsGuide = credentialsGuide + self.supportsManagedAuth = supportsManagedAuth self.defaultModel = defaultModel self.models = models } diff --git a/meta/llm-provider-catalog.json b/meta/llm-provider-catalog.json index 24b45e55dde..09fc44fce72 100644 --- a/meta/llm-provider-catalog.json +++ b/meta/llm-provider-catalog.json @@ -14,6 +14,7 @@ "url": "https://console.anthropic.com/settings/keys", "linkLabel": "Open Anthropic Console" }, + "supportsManagedAuth": true, "defaultModel": "claude-opus-4-7", "models": [ { @@ -106,6 +107,7 @@ "url": "https://platform.openai.com/api-keys", "linkLabel": "Open OpenAI Platform" }, + "supportsManagedAuth": true, "defaultModel": "gpt-5.5", "models": [ { @@ -250,6 +252,7 @@ "url": "https://aistudio.google.com/apikey", "linkLabel": "Open Google AI Studio" }, + "supportsManagedAuth": true, "defaultModel": "gemini-2.5-flash", "models": [ { @@ -411,6 +414,7 @@ "url": "https://ollama.com/download", "linkLabel": "Download Ollama" }, + "supportsManagedAuth": false, "defaultModel": "llama3.2", "models": [ { @@ -452,6 +456,7 @@ "url": "https://fireworks.ai/account/api-keys", "linkLabel": "Open Fireworks Dashboard" }, + "supportsManagedAuth": false, "defaultModel": "accounts/fireworks/models/kimi-k2p5", "models": [ { @@ -485,6 +490,7 @@ "url": "https://openrouter.ai/keys", "linkLabel": "Open OpenRouter" }, + "supportsManagedAuth": false, "defaultModel": "x-ai/grok-4.20-beta", "models": [ {