Migration — Phase 70: Multi-platform AI provider + Platform-Admin-managed key store
Migration — Phase 70: Multi-platform AI provider + Platform-Admin-managed key store
Shape: Breaking SDK contract change to IAIProviderFactory + AIPlatformProvider + AISettingsApi. Additive new substrate IPlatformAIKeyStore + PlatformAIKeysApi. Single-platform-provider deployments preserve byte-identical runtime behaviour after the composition-root edit.
What changes
AIPlatformProviderrecord reshape. The bundle no longer carries a pre-builtProvider: IAIProvider; instead it carries aBuild: ApiKey -> Model -> IAIProvidercurried closure and an optionalBootstrapKeyFromEnv: string optionfallback. The factory resolves the API key per-request (team-scope → platform-scope → bootstrap) and invokesBuildwith the resolved (key, model). This unlocks runtime key rotation and per-team key overrides without redeploy.IAIProviderFactory.PlatformDescriptors(new — plural).PlatformDescriptor: AIProviderDescriptor optionremains as a derivedList.tryHeadaccessor for backward compatibility, but new callers should prefer the list. NewIAIProviderFactory.BuildPlatform(providerId, apiKey, model)returns anIAIProvider optionso the Platform Admin keys module can build a provider for the test-key path.DefaultAIProviderFactory.createsignature. Last two parameters change:- Was:
… (platformProvider: AIPlatformProvider option) - Now:
… (platformProviders: AIPlatformProvider list) (platformKeyStore: IPlatformAIKeyStore option)
- Was:
IPlatformAIKeyStore(new). 8-method substrate overISecretStorefor runtime-managed AI keys. Default implBlobPlatformAIKeyStore.createships inToolUp.AI.Server. Two scopes:- Platform-scope:
_platform-ai-keyscontainer, key name = providerId. - Team-scope:
team-ai-keys-{teamId}container, key name = providerId.
- Platform-scope:
composeWithAIgainswithPlatformAIKeyStorefor custom backing stores. When omitted,composeAIregistersIPlatformAIKeyStorein DI via a Func-resolver that lazily buildsBlobPlatformAIKeyStore.create secretStorefrom whicheverISecretStoreis in DI.AISettingsApiwire-surface changes (Stream B):GetPlatformDescriptor: unit -> Async<AIProviderDescriptor option>→GetPlatformDescriptors: unit -> Async<AIProviderDescriptor list>.- New
SetPlatformProviderOverride: string option -> Async<Result<unit, string>>alongsideSetPlatformModelOverride. AIUserConfigViewgainsPlatformProviderOverride: string option.SetPlatformModelOverridevalidation widens to accept any wired descriptor's models (was: only the single descriptor's models).
New
PlatformAIKeysApiFable.Remoting contract (10 methods) +PlatformAIKeysHandlerserver-side handler. Gated onAccessContext.canModifyPlatformConfig. The current key value is never returned by any read path.New
PlatformAIKeysAdminUIclient module appended automatically byAIClientConfig.appendAssistantModule. Sits under the "Platform Admin" sidebar group with id_ai.PlatformAIKeys. Hidden from non-admin sidebars via the shell's role gate.
Diff to apply
Composition root — required for every consumer that wires a platform provider
A consumer who previously wired one provider as the Some bundle arg to DefaultAIProviderFactory.create:
// Before — single platform provider, key baked in at startup
let claudeProvider = ClaudeAIProvider.createWithApiKey claudeKey
let platformBundle = {
Descriptor = ClaudeAIProvider.descriptor
Provider = claudeProvider
Rebuild = Some (fun model -> ClaudeAIProvider.createWithApiKeyAndModel claudeKey model)
}
let factory =
DefaultAIProviderFactory.create
[ ClaudeAIProvider.builder; OpenAIProvider.builder ]
providerProfile
secretStore
PlatformOnly
(Some platformBundle)
becomes:
// After — request-time key resolution; bootstrap env-var key is the back-compat shim
let claudeBundle : AIPlatformProvider = {
Descriptor = ClaudeAIProvider.descriptor
Build = ClaudeAIProvider.createWithApiKeyAndModel // (apiKey -> model -> IAIProvider)
BootstrapKeyFromEnv = Some claudeKey // optional — fall-through when the key store has nothing
}
let factory =
DefaultAIProviderFactory.create
[ ClaudeAIProvider.builder; OpenAIProvider.builder ]
providerProfile
secretStore
PlatformOnly
[ claudeBundle ] // list, not Some _
None // IPlatformAIKeyStore option — None lets composeAI auto-promote
The BootstrapKeyFromEnv field is the migration shim — when the Platform-Admin-managed key store has no key recorded for a provider, the factory falls back to this value. Existing deployments that set ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY at boot continue to work without any new admin action.
Multi-provider opt-in
To surface Anthropic + OpenAI + Gemini together (the v1 multi-platform shape), wire one bundle per vendor:
let factory =
DefaultAIProviderFactory.create
[ ClaudeAIProvider.builder; OpenAIProvider.builder; GeminiAIProvider.builder ]
providerProfile
secretStore
PlatformOnly
[
{ Descriptor = ClaudeAIProvider.descriptor
Build = ClaudeAIProvider.createWithApiKeyAndModel
BootstrapKeyFromEnv = Environment.GetEnvironmentVariable "ANTHROPIC_API_KEY" |> Option.ofObj }
{ Descriptor = OpenAIProvider.descriptor
Build = OpenAIProvider.createWithApiKeyAndModel
BootstrapKeyFromEnv = Environment.GetEnvironmentVariable "OPENAI_API_KEY" |> Option.ofObj }
{ Descriptor = GeminiAIProvider.descriptor
Build = GeminiAIProvider.createWithApiKeyAndModel
BootstrapKeyFromEnv = Environment.GetEnvironmentVariable "GEMINI_API_KEY" |> Option.ofObj }
]
None
The settings UI automatically surfaces a provider+model picker when PlatformDescriptors.Length ≥ 2; with exactly one descriptor the provider dropdown is hidden and only the model picker renders.
Custom key store (advanced)
For a custom IPlatformAIKeyStore (Azure Key Vault wrapper, in-memory test double, etc.), pass it through AIServerApp.withPlatformAIKeyStore AND DefaultAIProviderFactory.create's last argument:
let myKeyStore : IPlatformAIKeyStore = …
let factory =
DefaultAIProviderFactory.create builders providerProfile secretStore PlatformOnly bundles (Some myKeyStore)
aiServerApp
|> AIServerApp.withPlatformAIKeyStore myKeyStore
Test fakes / custom IAIProviderFactory implementations
Any code that implements IAIProviderFactory directly (test doubles, custom wrappers) gains two new abstract members:
member _.PlatformDescriptors = [] // or [single]
member _.BuildPlatform(_providerId, _apiKey, _model) = None // None for tests that don't exercise key-test
The forge codebase's three test fakes (AIProviderEnvValidatorTests, AIProviderProbeValidatorTests, SampleClientToolDispatchTests) and two factory decorators (MeteringProviderFactory, QuotaEnforcingProviderFactory) were updated in the same commit; external test code needs the equivalent two-line addition.
Verification steps
- Build clean.
dotnet build src/ToolUp.AI.Server/ToolUp.AI.Server.fsprojsucceeds with 0 errors after the composition-root edit. - Byte-identical single-provider behaviour. A pre-Phase-70 deployment migrated to the new shape with
BootstrapKeyFromEnv = Some <prior env-var value>resolves the sameIAIProviderinstance on every request as before. Specifically: with no Platform-Admin-managed keys configured, the request-time chain falls through toBootstrapKeyFromEnv, which equals the prior startup-baked key. - Multi-provider picker. With ≥2 bundles wired, the AI Settings module renders a provider dropdown above the model dropdown. Switching provider resets the model selection to the new provider's default.
- Platform Admin keys module. Navigate to "Platform Admin" → "AI Keys" (visible only to Platform Admins). Two sections render: platform-wide keys + per-team keys. Each row shows status badge "Key stored" / "No key", a password input, and Save / Test / Remove buttons. The current key value is never displayed.
- Team-scope precedence. Configure a platform-scope key for Anthropic + a team-scope override for Team A. A user in Team A's requests use Team A's key; users in other teams use the platform-scope key.
- Resolution-chain audit. Tail server logs while exercising AI calls. The four-step chain (team → platform → bootstrap → MissingApiKey) should be exercisable by selectively clearing keys at each scope.
Rollback
Revert to a single AIPlatformProvider bundle and pass [bundle] + None for the key store. Existing env-var-supplied keys continue to work via BootstrapKeyFromEnv. The IPlatformAIKeyStore substrate stays registered in DI (no harm; nothing reads it without bundles wired) and the Platform Admin keys module surfaces "no platform AI providers wired; nothing to manage" when PlatformDescriptors = [].
Risks
- Wire-contract change on
AISettingsApi. TheGetPlatformDescriptor→GetPlatformDescriptorsrename is a breaking Fable.Remoting wire change. Any external client that pinned the old method name needs to update. The built-inAISettingsUIwas migrated in the same commit; no other forge consumer references the method. - Tests that construct
AIPlatformProviderrecords by hand need updating to the new field shape. The forge codebase has no such test today; downstream test suites may. IPlatformAIKeyStoreis a new portability-rule-bearing substrate. External implementations (Azure Key Vault, AWS Secrets Manager) need to satisfy the six rules (identity-by-value, async-at-every-boundary, retry-as-data, stateless, per-scope sharding, no implicit timing). The defaultBlobPlatformAIKeyStoreoverISecretStoreis the conformance reference.