Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/foundation/providers/src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<AgentKind, string>>;
/** `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<Record<AgentKind, Record<string, string>>>;
}

/**
Expand Down Expand Up @@ -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' },
},
},
},
},
Expand Down
41 changes: 38 additions & 3 deletions packages/foundation/providers/src/resolve.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<string, string>;
}
| { tier: 'unavailable'; reason: BindingUnavailableReason };

Expand Down Expand Up @@ -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' };
}
Expand Down Expand Up @@ -133,8 +144,14 @@ function bind(
protocol: AccountProtocol,
baseUrl: string,
knownProvider: string | undefined,
providerEnv?: Record<string, string>,
): 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 };
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only the pinned-endpoint path skips it: a URL the user typed outranks whatever the agent's own catalog would build.

The carve-out is reasonable in principle, but pinnedEndpoint() keeps more than URLs a user typed — and the accounts it over-keeps are the ones this PR is trying to fix.

A pre-variant Cloudflare account has a filled endpoint (…/8f3a/prod/compat) written by the old add flow. It never equals the templated variant baseUrl, so pinnedEndpoint returns it, resolveBinding takes the early bind(kind, protocol, baseUrl, knownProvider) branch at line 62 — still four arguments, no providerEnv — and the adapter falls through to the flat registerProvider(baseUrl). That pins every model to /compat, which is precisely the 400 Compatibility endpoint: v1/messages is not supported the reporter filed.

__tests__/resolve.test.ts:287 pins this shape with a cloudflare-gateway account and the comment "the pre-variant behavior, which was never broken for these accounts". That claim was true before this PR; it isn't now — pi bound to such an account still can't reach the anthropic leg.

Worth confirming how many real accounts are in that state. The current add flow doesn't write endpoint (per add-flow.test.tsx), so this is bounded to accounts created before the variant work — but there's no migration, and the only user-facing recovery is delete-and-re-add, which nothing tells them to do. Options: backfill providerEnv when the pinned URL's origin+params match a templated variant, or narrow pinnedEndpoint so a filled-template match against the catalog isn't treated as user-authored.

* outranks whatever the agent's own catalog would build. */
function providerEnvFor(
variant: ServiceVariant,
kind: AgentKind,
params: Record<string, string>,
): Record<string, string> | undefined {
const mapping = variant.endpointEnv?.[kind];
if (!mapping) return undefined;
const env: Record<string, string> = {};
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
Expand Down
2 changes: 1 addition & 1 deletion packages/host/agent-adapter/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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 },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file drives the installed SDK, so it's the one place the seeded path can actually be proven — the four unit-test mocks stub AuthStorage as { create } only and would throw on inMemory.

A fifth case here would cover it end-to-end: knownProvider: 'cloudflare-ai-gateway', providerEnv: { CLOUDFLARE_ACCOUNT_ID: '8f3a', CLOUDFLARE_GATEWAY_ID: 'prod' }, no baseUrl, and an anthropic-family model id from pi's own table. Asserting the resolved model's baseUrl ends in /anthropic rather than /compat is the assertion that would have caught the reported bug, and it also proves the two env names in catalog.ts still match pi's templates if the SDK ever renames them.

A companion case in providers/src/__tests__/resolve.test.ts asserting resolveBinding(cloudflareAccount, 'pi') returns providerEnv and no baseUrl would cover the other half.

});

// 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();
});
});
82 changes: 81 additions & 1 deletion packages/host/agent-adapter/src/__tests__/pi-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Comment thread
lucas77778 marked this conversation as resolved.

expect(sdk.createOptions).toMatchObject({ model: sdk.models[1] });
});

Expand Down Expand Up @@ -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] });
});
});
6 changes: 6 additions & 0 deletions packages/host/agent-adapter/src/credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>;
/** Extra environment for the agent process. */
extraEnv?: Record<string, string>;
}
Expand All @@ -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 }),
};
}
Expand Down
Loading
Loading