toolup-forgetoolup-forge

0.4.x — Phase 70: multi-platform AI provider substrate + `IPlatformAIKeyStore`

0.4.x — Phase 70: multi-platform AI provider substrate + IPlatformAIKeyStore

Phase 70 (forge commit 662cd24, Stream A) replaces the previous single-baked-key shape — where each platform provider was constructed at startup with its API key already snapped in — with a request-time key-resolution chain backed by IPlatformAIKeyStore. Operators upgrading from 0.3.x or pre-Phase 70 0.4.x deployments need to understand the new chain to avoid the MissingApiKey failure mode that surfaces silently when the key store is wired but empty.

What changed

Before (0.3.x) After (Phase 70)
AIPlatformProvider = { Descriptor; Provider; Rebuild }Provider was a pre-built IAIProvider carrying the API key from startup. AIPlatformProvider = { Descriptor; Build; BootstrapKeyFromEnv }Build : apiKey -> model -> IAIProvider is invoked per resolution with a freshly-fetched key.
API key sourced once at composition from an env var. API key sourced per request via the four-step resolution chain (below).
Rotating a key required a process restart. Rotating via Platform Admin > AI Keys takes effect on the next request without restart.

Resolution chain (per request)

When DefaultAIProviderFactory needs to mint a provider for a request, it walks four steps in order. The first step that yields Some key wins:

  1. Team-scope key storeIPlatformAIKeyStore.GetTeamKey(teamId, providerId). Lets a team override the platform-wide key with their own (used by enterprise deployments where each team holds its own vendor relationship).
  2. Platform-scope key storeIPlatformAIKeyStore.GetPlatformKey(providerId). The default for most deployments — one key per provider across the whole platform.
  3. BootstrapKeyFromEnv — the value wired into AIPlatformProvider.BootstrapKeyFromEnv at composition. This is the 0.3.x migration shim: a deployment that reads the key from an env var at composition can pass it here and Phase 70 will honour it as a fallback when no key has been set via the keys store.
  4. NoneMissingApiKey(providerId, hint) is returned. The 0.4.3 hint distinguishes "BootstrapKeyFromEnv was wired but is currently unset for this scope" from "no fallback wired, set a key in Platform Admin or wire BootstrapKeyFromEnv".

The chain runs every request so a key set via Platform Admin propagates immediately. A team-scope key set after platform-scope was in use immediately shadows the platform key for that team.

Migrating from 0.3.x

The minimal change: keep your env-var read at composition and pass the value into BootstrapKeyFromEnv. Existing deployments continue to work byte-for-byte.

Before (0.3.x composition)

let anthropicKey = System.Environment.GetEnvironmentVariable "TOOLUP_AI_ANTHROPIC_KEY"
let anthropic = AnthropicProvider.createWithApiKey anthropicKey "claude-sonnet-4-6"
let platformProviders = [
    { Descriptor = AnthropicProvider.descriptor
      Provider = anthropic
      Rebuild = fun key model -> AnthropicProvider.createWithApiKeyAndModel key model }
]

After (Phase 70 composition)

let anthropicKey = System.Environment.GetEnvironmentVariable "TOOLUP_AI_ANTHROPIC_KEY"
let platformProviders = [
    { Descriptor = AnthropicProvider.descriptor
      Build = AnthropicProvider.createWithApiKeyAndModel
      BootstrapKeyFromEnv =
        if System.String.IsNullOrEmpty anthropicKey then None else Some anthropicKey }
]

Two things changed at the consumer:

  • Provider is gone — the factory builds on demand.
  • Rebuild is gone — Build (curried apiKey -> model -> IAIProvider) covers both first-build and rebuild.
  • BootstrapKeyFromEnv is string option. Pass None if you want the deployment to refuse to start until a key is set via Platform Admin; pass Some <env value> to preserve the 0.3.x behaviour during the transition.

Wiring IPlatformAIKeyStore

Pass the store as the new last parameter of DefaultAIProviderFactory.create:

let factory =
    DefaultAIProviderFactory.create
        builders
        providerProfile
        secretStore
        fallbackPolicy
        platformProviders
        (Some keyStore)   // <-- new

Pass None to opt out of the keys-store layer entirely — the factory then runs the chain skipping straight to BootstrapKeyFromEnv. This is the right shape for tests and for deployments that intentionally manage keys outside the platform.

IPlatformAIKeyStore is in ToolUp.Platform.AI. The default in-process implementation is suitable for single-instance deployments; multi-instance deployments should provide a distributed-store implementation per the six portability rules (see CLAUDE.md).

After the migration

The recommended end state for a multi-instance production deployment:

  1. Visit Platform Admin > AI Keys.
  2. Set the key for each provider you offer.
  3. Remove the TOOLUP_AI_ANTHROPIC_KEY-shaped env vars from your deployment manifest.
  4. Pass BootstrapKeyFromEnv = None at composition to make accidental env-var leakage a startup error rather than a silent fallback.

A team-scope key override is set via the same module's team-context switch.

Diagnosing MissingApiKey

The 0.4.3 error shape is MissingApiKey(providerId, hint) where hint is one of:

  • "Platform Admin > AI Keys (the wired BootstrapKeyFromEnv was non-empty at startup but is currently unset for this scope)" — the factory walked the chain, didn't find a team or platform key for the scope, and BootstrapKeyFromEnv was Some _ at composition but returned None for the resolution (typically a scope mismatch). Set the key via Platform Admin or audit the scope resolution.
  • "Platform Admin > AI Keys, or wire BootstrapKeyFromEnv at composition time" — no key found anywhere in the chain and BootstrapKeyFromEnv was None. Either set the key in Platform Admin or pass the env-var value through BootstrapKeyFromEnv at composition.

Rollback

Revert the <PackageVersion> of ToolUp.AI.Server to the pre-0.4.3 version. Composition code that uses the Phase 70 shape (Build + BootstrapKeyFromEnv) does not compile against the prior version — revert it to the { Descriptor; Provider; Rebuild } shape. Consumers using singleProvider or empty factory shims are unaffected.