diff --git a/packages/foundation/providers/src/catalog.ts b/packages/foundation/providers/src/catalog.ts index c375ce20..d5d33b7b 100644 --- a/packages/foundation/providers/src/catalog.ts +++ b/packages/foundation/providers/src/catalog.ts @@ -21,6 +21,10 @@ export interface ServiceVariant { /** Ids for this endpoint in an agent's own provider catalog. Present means the agent already * carries the wire adapter and model metadata, so it needs only the key injected. */ knownProvider?: Partial>; + /** `endpointParams` key → the env name that agent's own provider entry reads it under. Present + * means the agent templates a per-model URL, so injecting one base URL would flatten routes that + * differ per model — declare this instead of a base URL for such a provider. */ + endpointEnv?: Partial>>; } /** @@ -211,6 +215,12 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [ 'openai-chat': { baseUrl: 'https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/compat', knownProvider: { opencode: 'cloudflare-ai-gateway', pi: 'cloudflare-ai-gateway' }, + // Pi's own entry routes each model to the leg its wire needs — Claude to `/anthropic`, GPT + // to `/openai`, Workers AI to `/compat` — so pinning every one of them to `/compat` answers + // `400 Compatibility endpoint: v1/messages is not supported` on all but the last group. + endpointEnv: { + pi: { account_id: 'CLOUDFLARE_ACCOUNT_ID', gateway_id: 'CLOUDFLARE_GATEWAY_ID' }, + }, }, }, }, diff --git a/packages/foundation/providers/src/resolve.ts b/packages/foundation/providers/src/resolve.ts index f2a4deb0..945c37ea 100644 --- a/packages/foundation/providers/src/resolve.ts +++ b/packages/foundation/providers/src/resolve.ts @@ -1,6 +1,7 @@ import type { Account, AccountEndpoint, AccountProtocol, AgentKind } from '@linkcode/schema'; import { AccountProtocolSchema } from '@linkcode/schema'; import { never } from 'foxts/guard'; +import { isObjectEmpty } from 'foxts/is-object-empty'; import type { EndpointService, ServiceVariant } from './catalog'; import { endpointServiceById } from './catalog'; import { fillTemplate, isTemplateFilled } from './template'; @@ -37,6 +38,9 @@ export type ResolvedBinding = baseUrl?: string; /** The endpoint's id in this agent's own provider catalog, when it has one. */ knownProvider?: string; + /** Endpoint params under the env names this agent's provider entry reads them by. Present + * means the agent owns its per-model URL, so `baseUrl` must not be injected. */ + providerEnv?: Record; } | { tier: 'unavailable'; reason: BindingUnavailableReason }; @@ -95,9 +99,16 @@ function resolveService( for (const protocol of preferredProtocols(service, kind)) { const variant = service.variants[protocol]; if (!variant) continue; - const baseUrl = fillTemplate(variant.baseUrl, account.endpointParams ?? {}); + const params = account.endpointParams ?? {}; + const baseUrl = fillTemplate(variant.baseUrl, params); if (!isTemplateFilled(baseUrl)) return { tier: 'unavailable', reason: 'endpoint-incomplete' }; - return bind(kind, protocol, baseUrl, knownProviderFor(variant, kind)); + return bind( + kind, + protocol, + baseUrl, + knownProviderFor(variant, kind), + providerEnvFor(variant, kind, params), + ); } return { tier: 'unavailable', reason: 'protocol-unsupported' }; } @@ -133,8 +144,14 @@ function bind( protocol: AccountProtocol, baseUrl: string, knownProvider: string | undefined, + providerEnv?: Record, ): ResolvedBinding { - const resolved = { protocol, baseUrl, ...(knownProvider !== undefined && { knownProvider }) }; + const resolved = { + protocol, + baseUrl, + ...(knownProvider !== undefined && { knownProvider }), + ...(providerEnv !== undefined && { providerEnv }), + }; switch (kind) { case 'claude-code': if (protocol === 'anthropic') return { tier: 'native', ...resolved }; @@ -163,6 +180,24 @@ function knownProviderFor( return variant?.knownProvider?.[kind]; } +/** The variant's declared env names carrying this account's endpoint params, for agents that + * template their own per-model URL. Only the pinned-endpoint path skips it: a URL the user typed + * outranks whatever the agent's own catalog would build. */ +function providerEnvFor( + variant: ServiceVariant, + kind: AgentKind, + params: Record, +): Record | undefined { + const mapping = variant.endpointEnv?.[kind]; + if (!mapping) return undefined; + const env: Record = {}; + for (const [param, name] of Object.entries(mapping)) { + const value = params[param]; + if (value !== undefined) env[name] = value; + } + return isObjectEmpty(env) ? undefined : env; +} + /** * The endpoint the user named, if any. A stored endpoint the catalog itself produces is not one: * the pre-variant add flow wrote one onto every catalog account, back when an account could only diff --git a/packages/host/agent-adapter/AGENTS.md b/packages/host/agent-adapter/AGENTS.md index ea40bb8b..26cd5759 100644 --- a/packages/host/agent-adapter/AGENTS.md +++ b/packages/host/agent-adapter/AGENTS.md @@ -115,7 +115,7 @@ engine caches the emitted effort and replays it when the newly created session a - **Several accounts can serve one agent at once, and a live session can move between them — never in place.** Which accounts an agent *offers* is `providers[kind].enabledAccountIds` (absent = every bindable one), and that is its **only** per-account state: there is no default account and no default model. A session that names neither — automation, schedules, IM threads, mobile — resolves to the head of `enabledAccountModels` (pool order × the account's own model order), the same entry the composer displays for an untouched draft, so the two sides cannot disagree about what "unpicked" means. Sessions started from a picker carry `StartOptions.accountId`; that field is a *request* — resolution consumes it and reports the account that actually backed the run, so an id naming a deleted account falls back to that same head instead of starting a session with no credential. An enabled account the agent cannot speak to is skipped rather than fatal (it never reaches the model menu either); only an account the request *names* fails the start loudly. Credentials and base URL are injected once at spawn, so the engine implements a cross-account `set-model` (one carrying `accountId`) as a relaunch under the same session id that resumes the transcript — `SessionLifecycleService.switchModel`, refused while a turn runs, without a transcript, or when the agent cannot resume. Each run records what the thread is *set to* — account, model, effort, approval tier — and a relaunch replays it, so a thread keeps its own picks even after the head of the agent's list moves. Only accepted picks are recorded (`SessionLifecycleService.applyInput`): a model an adapter resolved for itself is reflected to the client but never pinned, or every thread would be stuck on its first launch and a change to the agent's list could never reach it again. No adapter sees any of this: the old one is destroyed and a new one is constructed from fresh `StartOptions`. `onSetModel` therefore only ever handles a switch *within* the session's own account, which is why opencode's cross-provider rejection there remains correct. - **apiKey injection** (all read `StartOptions.config.apiKey`, five shapes): claude-code → `ANTHROPIC_API_KEY` in spawned env; codex → `CODEX_API_KEY` in the app-server env (the CLI still honors `CODEX_HOME`/config.toml auth); opencode → nested `config.provider[providerID].options.apiKey`; pi → `authStorage.setRuntimeApiKey` + `registerProvider`; grok-build → `XAI_API_KEY` in the headless process env. - - **The two provider-routed agents need a provider id, and the model string is not a reliable source.** Precedence: model-ref (`providerID/modelID`, which decides routing) → for pi, the resumed session's own last-routed provider (`lastPiModelChange`, direct evidence) → `config.knownProvider` (the endpoint's id in the agent's own catalog, from `@linkcode/providers`) → for pi, its first available provider. Before `knownProvider` existed a bare model id left the credential uninjected entirely; putting it ahead of the resumed provider instead strands a resumed session on a provider that never got the key. + - **The two provider-routed agents need a provider id, and the model string is not a reliable source.** An explicit account-bound Pi model resolves as `config.knownProvider` + the complete endpoint-owned model id, even when that id contains `/`; an unbound Pi model uses its `provider/modelId` ref. A same-provider Pi-qualified pin is unwrapped only when the opaque account form has no registry match and the qualified form does. With no explicit model, Pi precedence is the resumed session's last-routed provider (`lastPiModelChange`, direct evidence) → `config.knownProvider` (the endpoint's id in Pi's catalog, from `@linkcode/providers`) → Pi's first available provider. This keeps a credential-only resume on the provider that actually owns the transcript while ensuring an explicit current-account model is not overridden by stale transcript routing. - **pi's credential injection cannot change a provider's wire, and must not pretend to.** `registerProvider` with no `models` takes `applyProviderConfig`'s override-only branch (verified in the installed `dist/core/model-registry.js`), which rewrites `baseUrl` and leaves each model's `api` untouched. `config.api` is read in exactly two places — the `config.streamSimple` branch and the `config.models` branch — so on a baseUrl-only call it is **silently discarded**, despite `ProviderConfigInput` declaring `api?: Api`. Passing it typechecks and does nothing; an earlier revision of this adapter did exactly that, and mocked-`registerProvider` tests asserted the call shape and never noticed. This is why injection is only correct when the target provider's *built-in* wire already matches the endpoint — which is the case that matters, since pi ships correct metadata for every provider it knows. Aiming a provider at a differently-shaped endpoint needs a `models`-carrying call (`@linkcode/providers` AGENTS.md records why that is not built). - **Interactive login** (`login.ts` dispatcher `startAgentCliLogin`, kinds in `AGENT_LOGIN_KINDS`): claude-code drives `claude auth login --claudeai` (remote callback page; the user pastes the code back via stdin); codex drives `account/login/start {type:'chatgpt'}` on a short-lived app-server (`native/codex/login.ts`) whose OWN localhost callback completes the flow — no code hand-back (`submitCode` is a no-op), settle = `account/login/completed {success, error?}`. Auth probing: claude `auth status --json` (stdout, structured); codex `login status` (TEXT only — signed-out rides STDERR + exit 1, parse both streams; `parseCodexLoginStatus` fails open on rewording). - **Fixed bypass visibility**: Pi's in-process tools and Grok Build's headless `--permission-mode bypassPermissions` have no interactive approval path. Both advertise a single `bypassPermissions` policy with an explicit non-switchable description; this is visibility only, never a claim that approval is available. `set-approval-policy` continues to reject. diff --git a/packages/host/agent-adapter/src/__tests__/pi-model-registry.test.ts b/packages/host/agent-adapter/src/__tests__/pi-model-registry.test.ts new file mode 100644 index 00000000..6e08b016 --- /dev/null +++ b/packages/host/agent-adapter/src/__tests__/pi-model-registry.test.ts @@ -0,0 +1,62 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import type { AgentEvent } from '@linkcode/schema'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { PiAdapter } from '../native/pi'; + +const roots: string[] = []; + +afterEach(() => { + vi.unstubAllEnvs(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe('Pi model registry integration', () => { + it.each([ + { + provider: 'anthropic', + modelId: 'claude-sonnet-4-6', + inputModel: 'claude-sonnet-4-6', + baseUrl: 'https://api.anthropic.com', + }, + { + provider: 'openrouter', + modelId: 'anthropic/claude-sonnet-4.6', + inputModel: 'anthropic/claude-sonnet-4.6', + baseUrl: 'https://openrouter.ai/api/v1', + }, + { + provider: 'vercel-ai-gateway', + modelId: 'anthropic/claude-sonnet-4.6', + inputModel: 'anthropic/claude-sonnet-4.6', + baseUrl: 'https://ai-gateway.vercel.sh/v1', + }, + { + provider: 'openrouter', + modelId: 'anthropic/claude-sonnet-4.6', + inputModel: 'openrouter/anthropic/claude-sonnet-4.6', + baseUrl: 'https://openrouter.ai/api/v1', + }, + ])('resolves $provider input $inputModel to $modelId', async (testCase) => { + const { provider, modelId, inputModel, baseUrl } = testCase; + const root = mkdtempSync(join(tmpdir(), 'pi-model-registry-')); + roots.push(root); + vi.stubEnv('PI_CODING_AGENT_DIR', root); + const adapter = new PiAdapter(); + const events: AgentEvent[] = []; + adapter.onEvent((event) => events.push(event)); + + await adapter.start({ + kind: 'pi', + cwd: root, + model: inputModel, + config: { authToken: 'dummy', baseUrl, knownProvider: provider }, + }); + + // An account-bound session reflects the account's own id, unprefixed: the client's picker is + // built from the account's model list and has no other vocabulary to match against. + expect(events).toContainEqual({ type: 'model-update', model: modelId }); + await adapter.stop(); + }); +}); diff --git a/packages/host/agent-adapter/src/__tests__/pi-model.test.ts b/packages/host/agent-adapter/src/__tests__/pi-model.test.ts index 3d7ad332..9d1832bc 100644 --- a/packages/host/agent-adapter/src/__tests__/pi-model.test.ts +++ b/packages/host/agent-adapter/src/__tests__/pi-model.test.ts @@ -196,9 +196,59 @@ describe('Pi dynamic model catalog', () => { expect(sdk.createOptions).toMatchObject({ model: sdk.models[3] }); }); - it('keeps a qualified model authoritative over provider hints', async () => { + it.each([ + ['openrouter', 'anthropic/claude-sonnet-4.6'], + ['vercel-ai-gateway', 'anthropic/claude-sonnet-4.6'], + ['gateway', 'vendor/family/model'], + ])('keeps the complete account model id under known provider %s', async (provider, modelId) => { + const accountModel = { provider, id: modelId, reasoning: false }; + sdk.models.push(accountModel); + + await start({ + model: modelId, + config: { + apiKey: 'account-key', + baseUrl: 'https://gateway.example.test/v1', + knownProvider: provider, + }, + }); + + expect(sdk.setRuntimeApiKey).toHaveBeenCalledWith(provider, 'account-key'); + expect(sdk.registerProvider).toHaveBeenCalledWith(provider, { + baseUrl: 'https://gateway.example.test/v1', + apiKey: 'account-key', + }); + expect(sdk.createOptions).toMatchObject({ model: accountModel }); + }); + + it('keeps a qualified-looking account model under the selected provider', async () => { + const accountModel = { provider: 'openai', id: 'other/nulls', reasoning: false }; + sdk.models.push(accountModel); + await start({ model: 'other/nulls', config: { knownProvider: 'openai' } }); + expect(sdk.createOptions).toMatchObject({ model: accountModel }); + }); + + it('unwraps a same-provider Pi-qualified replay when its opaque account id is absent', async () => { + const accountModel = { + provider: 'openrouter', + id: 'anthropic/claude-sonnet-4.6', + reasoning: false, + }; + sdk.models.push(accountModel); + + await start({ + model: 'openrouter/anthropic/claude-sonnet-4.6', + config: { knownProvider: 'openrouter' }, + }); + + expect(sdk.createOptions).toMatchObject({ model: accountModel }); + }); + + it('keeps a Pi-qualified model authoritative without account provider evidence', async () => { + await start({ model: 'other/nulls' }); + expect(sdk.createOptions).toMatchObject({ model: sdk.models[1] }); }); @@ -294,4 +344,34 @@ describe('Pi native resume', () => { // the key under `openai` would leave the model pi actually resumes without credentials. expect(sdk.setRuntimeApiKey).toHaveBeenCalledWith('other', 'account-key'); }); + + it('uses an explicit account model instead of the resumed provider', async () => { + const root = mkdtempSync(join(tmpdir(), 'pi-resume-')); + vi.stubEnv('PI_CODING_AGENT_DIR', root); + mkdirSync(join(root, 'sessions', 'slug'), { recursive: true }); + writeFileSync(join(root, 'sessions', 'slug', '2026_resume-id.jsonl'), ''); + sdk.open.mockReturnValue({ + getBranch: () => [{ type: 'model_change', provider: 'other', modelId: 'nulls' }], + getCwd: () => '/saved/cwd', + }); + const adapter = new PiAdapter(); + adapter.onEvent(noop); + + await adapter.resumeHistory( + { historyId: asHistoryId('resume-id') }, + { + kind: 'pi', + cwd: '/caller', + model: 'gpt', + config: { + apiKey: 'account-key', + baseUrl: 'https://gateway.example.test/v1', + knownProvider: 'openai', + }, + }, + ); + + expect(sdk.setRuntimeApiKey).toHaveBeenCalledWith('openai', 'account-key'); + expect(sdk.createOptions).toMatchObject({ model: sdk.models[0] }); + }); }); diff --git a/packages/host/agent-adapter/src/credential.ts b/packages/host/agent-adapter/src/credential.ts index b3075fd2..c79d3467 100644 --- a/packages/host/agent-adapter/src/credential.ts +++ b/packages/host/agent-adapter/src/credential.ts @@ -16,6 +16,10 @@ export interface AgentCredential { /** This endpoint's id in the agent's own provider catalog, when it has one. Provider-routed * agents (opencode, pi) inject the credential under it instead of guessing a provider. */ knownProvider?: string; + /** Endpoint params under the env names that provider entry reads them by. Present means the agent + * builds its own per-model URL, so `baseUrl` must not be injected — one URL cannot serve a + * provider whose models sit on different routes. */ + providerEnv?: Record; /** Extra environment for the agent process. */ extraEnv?: Record; } @@ -24,11 +28,13 @@ export interface AgentCredential { export function readAgentCredential(config: StartOptions['config']): AgentCredential { if (!config) return {}; const extraEnv = readStringRecord(config.extraEnv); + const providerEnv = readStringRecord(config.providerEnv); return { apiKey: readString(config.apiKey), authToken: readString(config.authToken), baseUrl: readString(config.baseUrl), knownProvider: readString(config.knownProvider), + ...(providerEnv && { providerEnv }), ...(extraEnv && { extraEnv }), }; } diff --git a/packages/host/agent-adapter/src/native/pi/adapter.ts b/packages/host/agent-adapter/src/native/pi/adapter.ts index 85c99f84..8170a273 100644 --- a/packages/host/agent-adapter/src/native/pi/adapter.ts +++ b/packages/host/agent-adapter/src/native/pi/adapter.ts @@ -31,6 +31,7 @@ import { invariant } from 'foxts/guard'; import type { AgentStartCatalogOptions, BrowserToolsetFactory } from '../../adapter'; import { renderBrowserToolResult } from '../../adapter'; import { BaseAgentAdapter } from '../../base'; +import type { AgentCredential } from '../../credential'; import { readAgentCredential } from '../../credential'; import { decodeHistoryBranchCursor } from '../../history-branch'; import { asHistoryId } from '../../history-util'; @@ -93,9 +94,18 @@ function effortLevels(model: PiModel): PiEffort[] { return level !== 'xhigh' || mapped !== undefined; }); } -function modelOptions(models: PiModel[]) { +/** + * How a session names a model on the wire. An account-bound session speaks the account's own + * vocabulary — endpoint-owned ids, unprefixed — because that is the list the client picks from; + * qualifying them leaves the picker unable to match the id the session reflects back at it. + */ +function advertisedModelId(model: PiModel, accountProvider: string | undefined): string { + return model.provider === accountProvider ? model.id : `${model.provider}/${model.id}`; +} + +function modelOptions(models: PiModel[], accountProvider?: string) { return models.map((model) => ({ - id: `${model.provider}/${model.id}`, + id: advertisedModelId(model, accountProvider), label: model.name, description: `${model.provider}/${model.id}`, effortLevels: effortLevels(model), @@ -130,37 +140,68 @@ function piCommandCatalog( return commands; } -function createConfiguredRegistry( - pi: PiSdk, - opts: Pick, +/** + * Resolve a model string against Pi's registry. Session start and mid-session set-model must share + * this — a divergence makes an id that starts a session unselectable inside it. + */ +function resolveModelRef( + modelRegistry: PiRegistry, + model: string, + cred: Pick, fallbackProvider?: string, ) { - const authStorage = pi.AuthStorage.create(); - const modelRegistry = pi.ModelRegistry.create(authStorage); - const cred = readAgentCredential(opts.config); - let ref = opts.model ? parseModel(opts.model) : null; - if (!ref && opts.model) { - const endpointProviders = new Set(); - if (cred.baseUrl) { - for (const model of modelRegistry.getAll()) { - if (model.id === opts.model && model.baseUrl === cred.baseUrl) { - endpointProviders.add(model.provider); - } + const parsedRef = parseModel(model); + const accountRef = cred.knownProvider ? { provider: cred.knownProvider, modelId: model } : null; + // Account model ids are endpoint-owned. A same-provider Pi-qualified pin is unwrapped only when + // its opaque account form is absent and the qualified registry entry exists. + if ( + accountRef && + parsedRef?.provider === accountRef.provider && + !modelRegistry.find(accountRef.provider, accountRef.modelId) && + modelRegistry.find(parsedRef.provider, parsedRef.modelId) + ) { + return parsedRef; + } + const ref = accountRef ?? parsedRef; + if (ref) return ref; + const endpointProviders = new Set(); + if (cred.baseUrl) { + for (const entry of modelRegistry.getAll()) { + if (entry.id === model && entry.baseUrl === cred.baseUrl) { + endpointProviders.add(entry.provider); } } - const endpointProvider = - endpointProviders.size === 1 ? endpointProviders.values().next().value : undefined; - const provider = fallbackProvider ?? cred.knownProvider ?? endpointProvider; - if (!provider) { - throw new Error(`pi: model must be 'provider/modelId' (got '${opts.model}')`); - } - ref = { provider, modelId: opts.model }; } + const endpointProvider = + endpointProviders.size === 1 ? endpointProviders.values().next().value : undefined; + const provider = fallbackProvider ?? endpointProvider; + if (!provider) { + throw new Error(`pi: model must be 'provider/modelId' (got '${model}')`); + } + return { provider, modelId: model }; +} +function createConfiguredRegistry( + pi: PiSdk, + opts: Pick, + fallbackProvider?: string, +) { + const cred = readAgentCredential(opts.config); const key = cred.apiKey ?? cred.authToken; - // The model ref decides which provider pi routes through, so it wins; a resumed session's own - // last-routed provider comes next, being direct evidence rather than a catalog default; the - // resolved known provider only replaces the "first available provider" guess. + // Pi reads a provider's endpoint params only off a *stored* credential — `setRuntimeApiKey` carries + // no env and `registerProvider` has no env field — so seed the store instead. In-memory, because + // `set()` on the file-backed store would leave the account's secret in ~/.pi/agent/auth.json. + const seeded = + key && cred.knownProvider && cred.providerEnv + ? { [cred.knownProvider]: { type: 'api_key' as const, key, env: cred.providerEnv } } + : undefined; + const authStorage = seeded ? pi.AuthStorage.inMemory(seeded) : pi.AuthStorage.create(); + const modelRegistry = pi.ModelRegistry.create(authStorage); + const ref = opts.model + ? resolveModelRef(modelRegistry, opts.model, cred, fallbackProvider) + : null; + + // An explicit model fixes routing; without one, resume evidence outranks the account default. const provider = ref?.provider ?? fallbackProvider ?? @@ -169,12 +210,13 @@ function createConfiguredRegistry( if (!provider && (key || cred.baseUrl)) { throw new Error('pi: cannot target credential without a provider/model'); } - if (key && provider) authStorage.setRuntimeApiKey(provider, key); - if (cred.baseUrl) { + if (key && provider && !seeded) authStorage.setRuntimeApiKey(provider, key); + if (!seeded && cred.baseUrl) { // baseUrl override only: a models-less registerProvider rewrites the URL and leaves each // model's wire at pi's built-in value, so this works exactly when that provider's built-in // wire already matches the endpoint. Pointing a provider at a differently-shaped endpoint is // not expressible without supplying full model metadata (see @linkcode/providers AGENTS.md). + // A seeded provider is the escape hatch: it templates its own per-model URL from the env above. modelRegistry.registerProvider(provider, { baseUrl: cred.baseUrl, ...(key && { apiKey: key }), @@ -184,6 +226,7 @@ function createConfiguredRegistry( authStorage, modelRegistry, ref, + credential: cred, credentialProviderId: key || cred.baseUrl ? provider : null, }; } @@ -208,6 +251,7 @@ export class PiAdapter extends BaseAgentAdapter { private resumeFrom: AgentHistoryId | null = null; private pendingBranchManager: SessionManager | null = null; private credentialProviderId: string | null = null; + private credential: Pick = {}; private policyId: PiPolicy = 'default'; private readonly sessionAllowedTools = new Set(); private initialEffort: PiEffort | null = null; @@ -227,8 +271,8 @@ export class PiAdapter extends BaseAgentAdapter { override async startCatalog(opts: AgentStartCatalogOptions = {}): Promise { const pi = await this.importSdk(); - const { modelRegistry } = createConfiguredRegistry(pi, opts); - const models = modelOptions(modelRegistry.getAvailable()); + const { modelRegistry, credential } = createConfiguredRegistry(pi, opts); + const models = modelOptions(modelRegistry.getAvailable(), credential.knownProvider); return { models, policies: [...POLICIES], @@ -309,12 +353,10 @@ export class PiAdapter extends BaseAgentAdapter { } // Inject the account's key as a runtime override so it outranks ~/.pi/agent/auth.json and env // vars; a gateway base URL is registered on the model registry, overriding the provider's URL. - const { authStorage, modelRegistry, ref, credentialProviderId } = createConfiguredRegistry( - pi, - opts, - savedProvider, - ); + const { authStorage, modelRegistry, ref, credential, credentialProviderId } = + createConfiguredRegistry(pi, opts, savedProvider); this.modelRegistry = modelRegistry; + this.credential = credential; this.credentialProviderId = credentialProviderId; let model = ref ? modelRegistry.find(ref.provider, ref.modelId) : undefined; if (ref && !model) { @@ -349,7 +391,9 @@ export class PiAdapter extends BaseAgentAdapter { if (this.lifecycle === generation && this.session === session) this.handleEvent(ev); }); const runningModel = session.model ?? model; - if (runningModel) this.emitModel(`${runningModel.provider}/${runningModel.id}`); + if (runningModel) { + this.emitModel(advertisedModelId(runningModel, this.credential.knownProvider)); + } this.emitModels( modelOptions( this.credentialProviderId @@ -357,6 +401,7 @@ export class PiAdapter extends BaseAgentAdapter { .getAvailable() .filter((item) => item.provider === this.credentialProviderId) : modelRegistry.getAvailable(), + this.credential.knownProvider, ), ); this.emitApprovalPolicy({ availablePolicies: [...POLICIES], currentPolicyId: this.policyId }); @@ -444,15 +489,16 @@ export class PiAdapter extends BaseAgentAdapter { const session = this.session; const registry = this.modelRegistry; if (!session || !registry) throw new Error('pi: session not started'); - const ref = parseModel(value); - if (!ref) throw new Error(`pi: model must be 'provider/modelId' (got '${value}')`); + const ref = resolveModelRef(registry, value, this.credential, session.model?.provider); if (this.credentialProviderId && ref.provider !== this.credentialProviderId) { throw new Error(`pi: this session's credential is scoped to '${this.credentialProviderId}'`); } const model = registry.find(ref.provider, ref.modelId); if (!model) throw new Error(`pi: unknown model '${value}'`); await session.setModel(model); - if (session.model) this.emitModel(`${session.model.provider}/${session.model.id}`); + if (session.model) { + this.emitModel(advertisedModelId(session.model, this.credential.knownProvider)); + } if (isEffort(session.thinkingLevel)) this.emitEffort(session.thinkingLevel); } diff --git a/packages/host/engine/src/agent/provider-config.ts b/packages/host/engine/src/agent/provider-config.ts index 768c7d49..10a5b013 100644 --- a/packages/host/engine/src/agent/provider-config.ts +++ b/packages/host/engine/src/agent/provider-config.ts @@ -88,6 +88,7 @@ function accountConfigBundle( if (binding.baseUrl !== undefined) bundle.baseUrl = binding.baseUrl; if (binding.protocol !== undefined) bundle.protocol = binding.protocol; if (binding.knownProvider !== undefined) bundle.knownProvider = binding.knownProvider; + if (binding.providerEnv !== undefined) bundle.providerEnv = binding.providerEnv; if (extraEnv) bundle.extraEnv = extraEnv; return { bundle }; }