feat(ai): provider factories, per-provider catalogs, createProvider (phase 3)

Auth helpers in src/auth/helpers.ts: envApiKeyAuth() (stored key wins,
then env vars in order, with secret-prompt login) and lazyOAuth()
(flow loads on first use through bundler-opaque dynamic imports in
utils/oauth/load.ts; the OAuthAuth flow exports land in phase 4).
There is no OAuth factory toggle: providers that support OAuth always
attach it, advertising costs nothing until login/refresh runs.

createProvider() in models.ts builds providers from parts: single API
implementation or a map dispatched on model.api (mixed-API providers
like opencode and github-copilot); unknown api yields a stream error.

generate-models.ts now emits one providers/<id>.models.ts catalog per
provider (35 files, biome-excluded like models.generated.ts) and
models.generated.ts becomes a generated aggregator, so importing one
provider factory pulls one catalog. Typed getModel globals unchanged.

One factory per built-in provider under src/providers/: envApiKeyAuth
for standard providers, OAuth for anthropic/openai-codex/github-copilot,
ambient ApiKeyAuth for amazon-bedrock (AWS env/profile/IAM) and
google-vertex (explicit key or ADC+project+location).

providers/all.ts: builtinProviders(), builtinModels(), getBuiltin*
re-exports. fauxProvider() factory returns a real Provider for tests;
legacy registerFauxProvider() unchanged.
This commit is contained in:
Mario Zechner
2026-06-10 20:33:20 +02:00
parent afc2bd370e
commit fec0c3d12f
83 changed files with 18409 additions and 17094 deletions
+18 -30
View File
@@ -108,7 +108,7 @@ All built-ins, explicitly heavy metadata entrypoint:
```ts
import { builtinModels } from "@earendil-works/pi-ai/providers/all";
const models = builtinModels({ oauth: "node" });
const models = builtinModels();
```
`providers/all` may import all provider metadata/catalogs. It still must not eagerly import SDK implementations; provider streams use lazy wrappers.
@@ -581,31 +581,22 @@ export type AuthEvent =
`prompt()` returns the entered/selected string (`select` returns the option id). Flows race a `manual_code` prompt against a callback server by setting `AuthPrompt.signal` and aborting the prompt when the callback wins.
### OAuth implementation target
### OAuth attachment
OAuth must not force Node-only code (`node:http`, `node:crypto`) into browser bundles. Keep OAuth lazy; the provider factory decides which implementation to attach:
Providers that support OAuth always attach it. There is no factory toggle: the flow is lazy-loaded, so advertising OAuth costs nothing until `login()`/`refresh()` actually runs, and a host that never logs in never loads it.
```ts
export type OAuthTarget = "node" | "web" | false;
export interface AnthropicProviderOptions {
oauth?: OAuthTarget; // default false
}
export function anthropicProvider(options: AnthropicProviderOptions = {}): Provider {
export function anthropicProvider(): Provider {
return createProvider({
id: "anthropic",
name: "Anthropic",
baseUrl: "https://api.anthropic.com/v1",
auth: {
apiKey: envApiKeyAuth("Anthropic API key", ["ANTHROPIC_API_KEY"]),
oauth:
options.oauth === "node"
? lazyOAuth({
name: "Anthropic (Claude Pro/Max)",
load: () => import("../utils/oauth/anthropic.ts").then((m) => m.anthropicOAuth),
})
: undefined,
oauth: lazyOAuth({
name: "Anthropic (Claude Pro/Max)",
load: () => import("../utils/oauth/anthropic.ts").then((m) => m.anthropicOAuth),
}),
},
models: ANTHROPIC_MODELS,
api: anthropicMessagesApi(),
@@ -613,10 +604,6 @@ export function anthropicProvider(options: AnthropicProviderOptions = {}): Provi
}
```
- Individual factories default to `oauth: false`.
- `builtinModels({ oauth: "node" })` for pi CLI/coding-agent.
- `"web"` is reserved; web flows (sitegeist-style: Web Crypto PKCE, auth tab, extension tab APIs watching the localhost redirect, fetch token exchange, device-code polling for Copilot) are a follow-up. Until implemented, passing `"web"` throws at login time with a clear message.
`lazyOAuth()` wraps a dynamically imported `OAuthAuth` so provider definitions can advertise OAuth without importing the implementation (`toAuth` is async for exactly this reason):
```ts
@@ -626,6 +613,8 @@ export function lazyOAuth(input: {
}): OAuthAuth;
```
OAuth must not force Node-only code (`node:http`, `node:crypto`) into browser bundles: the dynamic import inside `lazyOAuth()` uses the same bundler-opaque variable-specifier trick as the bedrock lazy wrapper. Browser hosts never trigger the load (no stored node OAuth credentials, no login flow). If web OAuth lands later (sitegeist proved feasibility: Web Crypto PKCE, auth tab, fetch token exchange, device-code polling), it is just a different `OAuthAuth` implementation — no reserved option values.
The existing flows in `src/utils/oauth/` (anthropic, openai-codex, github-copilot) are adapted to `OAuthAuth` (`login`/`refresh`/`toAuth`, replacing `login`/`refreshToken`/`getApiKey`/`modifyModels`) with the new callbacks, staying Node-targeted and lazy-loaded. Copilot's `modifyModels` baseUrl rewriting becomes `toAuth` returning `ModelAuth.baseUrl`.
## Provider wrappers and models.json
@@ -699,7 +688,7 @@ Built-in provider factories use `createProvider()` internally. models.json custo
Old semantics being preserved: global `stream()` dispatched purely on `model.api` via the api-registry, with env API key injection. The compat module reproduces this:
- Lazily creates a default `Models` singleton from `builtinModels({ oauth: "node" })` on first use.
- Lazily creates a default `Models` singleton from `builtinModels()` on first use.
- `stream/complete/streamSimple/completeSimple(model, ctx, opts)`: look up `getProvider(model.provider)`; if found, route through the singleton (auth resolution included). If not found (custom models.json/extension models), fall back to api-dispatch through a hidden `createProvider()` map containing all builtin API implementations plus anything registered via compat `registerApiProvider()`.
- `registerApiProvider()/unregisterApiProviders()` feed that fallback dispatch map. `api-registry.ts` dies as a real mechanism.
- Sync `getModel/getModels/getProviders` become deprecated aliases of `getBuiltinModel/getBuiltinModels/getBuiltinProviders` (they were always pure generated-catalog reads — verified: nothing ever mutated the old `modelRegistry`).
@@ -730,7 +719,7 @@ Rules:
2. Provider modules import their catalog, auth helpers, and lazy API wrappers only.
3. Lazy API wrappers dynamically import real API implementations.
4. Real API implementations import SDK dependencies.
5. OAuth implementations are selected by factory option (`oauth: "node" | "web" | false`) and lazy-loaded; provider metadata never eagerly imports Node-only OAuth code.
5. OAuth implementations are always attached via `lazyOAuth()` and lazy-loaded behind a bundler-opaque dynamic import; provider metadata never eagerly imports Node-only OAuth code.
6. `providers/all` may import all provider metadata, but no eager SDK imports.
7. Provider modules are side-effect-free; importing a provider does not register anything globally.
8. `package.json` sets `sideEffects: false`.
@@ -809,18 +798,17 @@ Check items off as they land. Keep this list current; it is the working state fo
### Phase 3 — provider factories + catalogs
- [ ] Auth helpers in `src/auth/`: `envApiKeyAuth()`, `lazyOAuth()`, `OAuthTarget`.
- [ ] `createProvider()` (single + mixed `api` map, dispatch on `model.api`).
- [ ] Per-provider factories under `src/providers/` for all built-in catalog providers, `oauth` factory options where applicable.
- [ ] `providers/all.ts`: `builtinModels({ oauth? })`, `getBuiltinModel/getBuiltinModels/getBuiltinProviders`.
- [ ] Faux provider factory (`providers/faux.ts`) for tests.
- [ ] Split generated catalogs per provider via `scripts/generate-models.ts` (`providers/<id>.models.ts`) — or record explicitly that this is deferred.
- [x] Auth helpers in `src/auth/helpers.ts`: `envApiKeyAuth()` (with secret-prompt `login`), `lazyOAuth()`. OAuth flow loads go through `utils/oauth/load.ts` (bundler-opaque dynamic import); the `OAuthAuth` exports it references land in Phase 4.
- [x] `createProvider()` in `models.ts` (single + mixed `api` map, dispatch on `model.api`, unknown api -> stream error).
- [x] Per-provider factories under `src/providers/` for all built-in catalog providers; OAuth attached via `lazyOAuth()` (anthropic, openai-codex, github-copilot); ambient `ApiKeyAuth` for amazon-bedrock (AWS env/profile) and google-vertex (key or ADC+project+location).
- [x] `providers/all.ts`: `builtinProviders()`, `builtinModels()`, `getBuiltinModel/getBuiltinModels/getBuiltinProviders` re-exports.
- [x] Faux provider factory (`fauxProvider()` in `providers/faux.ts`) for tests; legacy `registerFauxProvider()` kept until compat dies.
- [x] Split generated catalogs per provider via `scripts/generate-models.ts` (`providers/<id>.models.ts`); `models.generated.ts` becomes a generated aggregator.
### Phase 4 — OAuth adaptation
- [ ] Adapt `utils/oauth/anthropic.ts`, `openai-codex.ts`, `github-copilot.ts` to `OAuthAuth` (`login`/`refresh`/`toAuth`) + `prompt()/notify()`; `modifyModels` baseUrl rewriting becomes `toAuth().baseUrl`.
- [ ] Remove `usesCallbackServer`; callback-server flows race a `manual_code` prompt instead.
- [ ] `oauth: "web"` reserved: throws at login with clear message.
### Phase 5 — packaging