diff --git a/packages/agent/docs/models.md b/packages/agent/docs/models.md index 0be60235b..6a5cd8f70 100644 --- a/packages/agent/docs/models.md +++ b/packages/agent/docs/models.md @@ -737,9 +737,10 @@ Rules: 3. Lazy API wrappers dynamically import real API implementations. 4. Real API implementations import SDK dependencies. 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. +6. `providers/all` imports every built-in provider factory and all catalogs. It is the explicit heavy entrypoint. 7. Provider modules are side-effect-free; importing a provider does not register anything globally. -8. `package.json` sets `sideEffects: false`. +8. `package.json` lists only effectful compat/image registration files in `sideEffects`; root and provider modules stay tree-shakeable. +9. With code splitting, provider SDKs stay in lazy chunks. Without code splitting, bundlers fold statically reachable lazy API implementations into the single bundle; `providers/all` then pulls all statically visible SDKs. Bedrock is the exception because its AWS SDK implementation is behind a bundler-opaque Node-only import and needs `setBedrockProviderModule()` for standalone single-file bundles. Exports map sketch: @@ -786,11 +787,12 @@ for (const provider of layeredProviders) sessionModels.setProvider(provider); coding-agent owns: `FileCredentialStore` + decorators replacing AuthStorage (see "Replacing AuthStorage"), models.json auth sidecar (`$ENV`, `!command`), command execution policy, provider status labels (from `AuthResult.source`), login/logout UI (driving `auth.{apiKey,oauth}.login()` with `prompt()/notify()`), extension lifecycle, provider-management slash commands. -Until then, the only coding-agent changes in this pass are: +Current interim state: -- construct a `Models` instance for `AgentHarness` (builtins + legacy api-dispatch fallback bridging `ModelRegistry` custom providers) -- switch old-global imports to `@earendil-works/pi-ai/compat` -- adapt the login dialog to `prompt()/notify()` callbacks (thin UI adapter; replaces the `usesCallbackServer` special-casing) +- `AgentHarness` already accepts a `Models` instance and uses it for turn streaming, compaction, and branch summaries. +- coding-agent does not use `AgentHarness` yet; `AgentSession` still drives the low-level `Agent` with a `streamFn`. +- coding-agent still uses legacy `AuthStorage` + `ModelRegistry` and imports old global pi-ai APIs through `@earendil-works/pi-ai/compat`. +- The extension loader still aliases the pi-ai root to `/compat` as the runtime grace period for old extensions. ## Implementation TODOs @@ -877,16 +879,59 @@ Decisions: - Copilot: stored-credential baseUrl applied in the wrapped `getModels()` (extension-visible models stay correct) plus per-request `toAuth().baseUrl`. - Cloudflare: provider-auth substitution (key + `CLOUDFLARE_ACCOUNT_ID`/`CLOUDFLARE_GATEWAY_ID` from credential metadata/env -> `ModelAuth.baseUrl`). Built-in compat calls route through `Models`, so they use the same provider auth path. -Ordering: +Ordering for new sessions: -- [x] pi-ai rework first: `Provider.getModels()` sync + optional `refreshModels()`; `Models.getModels`/`getModel` sync, `Models.refresh(provider?)` async; `createProvider` takes `models` array + optional `refreshModels` fetcher (in-flight dedupe). Reverses Phase 1's async-listing decision — see "Provider model listing" for rationale (sync-or-async unions breed latent sync assumptions; async-only breaks sync consumer surfaces like extension `find`/`getAll`). -- [ ] `FileCredentialStore` (ports the auth.json lock backend, reads legacy `type: "api_key"` tags) + `--api-key` overlay + `$ENV`/`!command` resolution; tests. -- [x] Cloudflare provider auth in pi-ai factories. -- [ ] Copilot `getModels` baseUrl wrap. -- [ ] Extension-OAuth adapter (old `OAuthProviderInterface` config -> `OAuthAuth`). -- [ ] ModelRegistry rebuild: owns `MutableModels`, async reads, models.json decoration, provider-keyed extension streams, facade auth ops; AuthStorage deleted. -- [ ] Consumer rewiring: agent-session, sdk (`credentials?: CredentialStore` option replaces `authStorage`; sdk.md updated), model-resolver, interactive login/status UI on `prompt()/notify()`, cli `--api-key`. -- [ ] Test migration; tmux validation of login flows against real providers. +1. [x] pi-ai rework first: `Provider.getModels()` sync + optional `refreshModels()`; `Models.getModels`/`getModel` sync, `Models.refresh(provider?)` async; `createProvider` takes `models` array + optional `refreshModels` fetcher (in-flight dedupe). Reverses Phase 1's async-listing decision — see "Provider model listing" for rationale (sync-or-async unions breed latent sync assumptions; async-only breaks sync consumer surfaces like extension `find`/`getAll`). +2. [x] Cloudflare provider auth in pi-ai factories: Workers AI and AI Gateway validate their required account/gateway metadata and return resolved `baseUrl`, provider-scoped env, and header suppression/override metadata from provider auth. +3. [ ] Add `FileCredentialStore` in coding-agent. + - Implement the pi-ai `CredentialStore` interface over the existing `auth.json` lock backend (`FileAuthStorageBackend` / `InMemoryAuthStorageBackend` can be reused or renamed). + - Preserve the existing file format where possible, but normalize legacy `{ type: "api_key", key, env? }` to pi-ai `{ type: "api-key", key, metadata? }` on read. `env` becomes provider metadata/env sidecar data; do not lose unknown keys. + - `read(provider)` returns the current credential snapshot and records parse/storage errors for status UI parity. + - `modify(provider, fn)` must lock, re-read, run `fn`, merge-write the provider entry, chmod `0600`, and return the post-write credential. + - `delete(provider)` must lock and remove only that provider's entry. + - Add file-backed and in-memory tests covering lock/RMW behavior, legacy `api_key` reads, OAuth reads, metadata/env preservation, delete, parse errors, and concurrent refresh-style modifications. +4. [ ] Add store decorators for coding-agent policy. + - `withConfigValues(store, policy)` resolves stored API-key credentials whose `key` or metadata values use `$ENV` or `!command`, using existing `resolve-config-value.ts` semantics. Command execution stays in coding-agent, not pi-ai. + - `withRuntimeOverrides(store, overrides)` implements CLI `--api-key`: read returns an ephemeral `{ type: "api-key", key }` for each overridden provider, masking stored OAuth/API credentials without persisting. + - Runtime overrides must apply even to OAuth-capable providers; every provider registered in coding-agent must retain or gain an `apiKey` auth slot so the overlay is meaningful. + - Tests cover precedence: runtime override > stored credential > models.json config auth > ambient provider env, with stored credential blocking ambient fallback. +5. [ ] Build provider decoration helpers for `models.json`. + - Start from built-in provider factories, not generated model arrays. + - Wrap provider `getModels()` so provider-level `baseUrl`/`headers`/`compat`, per-model `modelOverrides`, and custom model merges apply on every sync read. + - Preserve `refreshModels()` passthrough so dynamic providers compose with decorations. + - Convert provider `apiKey`/`headers`/`authHeader` models.json config into a wrapped `ApiKeyAuth` that resolves config values first and falls back to the base provider auth. + - Custom providers with `models` use `createProvider()` with the appropriate lazy API wrapper or extension-provided stream implementation. + - Parse errors must keep current `ModelRegistry.getError()` behavior: built-ins remain available, and the error is visible. +6. [ ] Copilot `getModels()` baseUrl wrap. + - GitHub Copilot OAuth `toAuth()` already returns per-credential request `baseUrl` for streaming. + - Wrap Copilot's provider `getModels()` when an OAuth credential is present so extension/UI-visible model metadata also carries the authenticated account base URL. + - Keep API-key/env-token Copilot behavior unchanged. + - Add tests for model metadata before login, after OAuth credential, after refresh/baseUrl change, and logout. +7. [ ] Extension OAuth adapter. + - Adapt old extension `OAuthProviderInterface` configs to pi-ai `OAuthAuth`. + - `login` maps old callbacks/events to `prompt()/notify()`. + - `refreshToken` maps to `refresh`. + - `getApiKey` maps to `toAuth`. + - `modifyModels` becomes a provider `getModels()` wrapper plus `toAuth().baseUrl` where applicable. + - Preserve existing extension runtime compatibility through the `/compat` alias until Phase 10. +8. [ ] Rebuild coding-agent `ModelRegistry` over `MutableModels`. + - It owns a `MutableModels` instance built from decorated built-ins + models.json custom providers + extension providers. + - `getAll()`, `find()`, and `getAvailable()` become async and delegate to the collection/status checks. Update extension-facing types and changelog this breaking change. + - `refresh()` rebuilds provider layers and calls `models.refresh()` where needed; no global api-registry reset should be part of the new path except compat-only grace behavior. + - `registerProvider()`/`unregisterProvider()` mutate provider layers and rebuild the collection. + - Facade auth ops (`login`, `logout`, provider status, available OAuth providers) drive `provider.auth.{apiKey,oauth}` and the `CredentialStore`; no `AuthStorage` type remains. + - Legacy `registerApiProvider` writes stay only for `/compat` callers and are removed in Phase 10. +9. [ ] Rewire consumers. + - `AgentSession` stream function resolves through `ModelRegistry`/`Models`, not `getApiKeyAndHeaders()` + compat globals. + - SDK options replace `authStorage` with `credentials?: CredentialStore` or an agent-dir-backed default; update `sdk.md` and examples. + - `model-resolver`, `--list-models`, model selector, login/logout/status UI, and provider attribution await async model/auth APIs. + - CLI `--api-key` populates the runtime override decorator instead of mutating `AuthStorage`. + - Keep extension loader root-to-compat alias until Phase 10, but expose the new collection/facade as the forward API. +10. [ ] Test migration and real-provider validation. + - Unit tests for `FileCredentialStore`, config-value decorators, provider decoration, extension OAuth adapter, ModelRegistry async facade, and consumer rewiring. + - Regression tests for Cloudflare account/gateway metadata, Copilot OAuth baseUrl wrapping, runtime `--api-key` precedence, `$ENV`/`!command` resolution, and stored credential blocking ambient fallback. + - Update existing tests that assume sync `ModelRegistry.getAll/find/getAvailable`. + - Run targeted non-e2e suites plus tmux validation of login flows against real providers (Anthropic OAuth/API key, OpenAI Codex OAuth, GitHub Copilot OAuth, Cloudflare AI Gateway, Bedrock if credentials are available). ### Phase 10 — compat deletion (pi 2.0 era, separate) diff --git a/packages/agent/src/harness/types.ts b/packages/agent/src/harness/types.ts index f7bdf6dd5..29048e617 100644 --- a/packages/agent/src/harness/types.ts +++ b/packages/agent/src/harness/types.ts @@ -240,22 +240,6 @@ export interface FileInfo { mtimeMs: number; } -/** Options for {@link Shell.exec}. */ -export interface ExecutionEnvExecOptions { - /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */ - cwd?: string; - /** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */ - env?: Record; - /** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */ - timeout?: number; - /** Abort signal used to terminate the command. Defaults to no abort signal. */ - abortSignal?: AbortSignal; - /** Called with stdout chunks as they are produced. */ - onStdout?: (chunk: string) => void; - /** Called with stderr chunks as they are produced. */ - onStderr?: (chunk: string) => void; -} - /** * Filesystem capability used by the harness. * @@ -317,12 +301,28 @@ export interface FileSystem { cleanup(): Promise; } +/** Options for {@link Shell.exec}. */ +export interface ShellExecOptions { + /** Working directory for the command. Relative paths are resolved against {@link ExecutionEnv.cwd}. Defaults to {@link ExecutionEnv.cwd}. */ + cwd?: string; + /** Additional environment variables for the command. Values override the environment defaults. Defaults to no overrides. */ + env?: Record; + /** Timeout in seconds. Implementations should return a timeout error when the command exceeds this duration. Defaults to no timeout. */ + timeout?: number; + /** Abort signal used to terminate the command. Defaults to no abort signal. */ + abortSignal?: AbortSignal; + /** Called with stdout chunks as they are produced. */ + onStdout?: (chunk: string) => void; + /** Called with stderr chunks as they are produced. */ + onStderr?: (chunk: string) => void; +} + /** Shell execution capability used by the harness. */ export interface Shell { /** Execute a shell command in {@link FileSystem.cwd} unless `options.cwd` is provided. */ exec( command: string, - options?: ExecutionEnvExecOptions, + options?: ShellExecOptions, ): Promise>; /** Release shell resources. Must be best-effort and must not throw or reject. */ cleanup(): Promise; diff --git a/packages/agent/src/harness/utils/shell-output.ts b/packages/agent/src/harness/utils/shell-output.ts index fea46d95a..a98b51a5f 100644 --- a/packages/agent/src/harness/utils/shell-output.ts +++ b/packages/agent/src/harness/utils/shell-output.ts @@ -1,15 +1,7 @@ -import { - type ExecutionEnv, - type ExecutionEnvExecOptions, - ExecutionError, - err, - ok, - type Result, - toError, -} from "../types.ts"; +import { type ExecutionEnv, ExecutionError, err, ok, type Result, type ShellExecOptions, toError } from "../types.ts"; import { DEFAULT_MAX_BYTES, truncateTail } from "./truncate.ts"; -export interface ShellCaptureOptions extends Omit { +export interface ShellCaptureOptions extends Omit { onChunk?: (chunk: string) => void; } diff --git a/packages/agent/test/scratch/simple.ts b/packages/agent/test/scratch/simple.ts index 86eae721b..b4feb665c 100644 --- a/packages/agent/test/scratch/simple.ts +++ b/packages/agent/test/scratch/simple.ts @@ -1,7 +1,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { createModels } from "@earendil-works/pi-ai"; -import { getBuiltinModel } from "@earendil-works/pi-ai/providers/all"; +import { cloudflareAIGatewayProvider } from "@earendil-works/pi-ai/providers/cloudflare-ai-gateway"; import { openaiProvider } from "@earendil-works/pi-ai/providers/openai"; import { NodeExecutionEnv } from "../../src/harness/env/nodejs.ts"; import { InMemorySessionStorage } from "../../src/harness/session/memory-storage.ts"; @@ -39,13 +39,20 @@ const { promptTemplates: sourcedPromptTemplates } = await loadSourcedPromptTempl const models = createModels(); models.setProvider(openaiProvider()); +models.setProvider(cloudflareAIGatewayProvider()); +const model = models.getModel("openai", "gpt-5.5"); +// const model = models.getModel("cloudflare-ai-gateway", "claude-haiku-4-5"); +if (!model) { + console.log("Model not found"); + process.exit(-1); +} const session = new Session(new InMemorySessionStorage()); const agent = new AgentHarness({ env, session, models, - model: getBuiltinModel("openai", "gpt-5.5"), + model, thinkingLevel: "low", systemPrompt: ({ env, resources }) => [