Skip to content

Commit 245bceb

Browse files
committed
fix(providers,engine,agent-adapter): per-leg gateway routing and account model ids for pi
1 parent e1da303 commit 245bceb

6 files changed

Lines changed: 91 additions & 15 deletions

File tree

‎packages/foundation/providers/src/catalog.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export interface ServiceVariant {
2121
/** Ids for this endpoint in an agent's own provider catalog. Present means the agent already
2222
* carries the wire adapter and model metadata, so it needs only the key injected. */
2323
knownProvider?: Partial<Record<AgentKind, string>>;
24+
/** `endpointParams` key → the env name that agent's own provider entry reads it under. Present
25+
* means the agent templates a per-model URL, so injecting one base URL would flatten routes that
26+
* differ per model — declare this instead of a base URL for such a provider. */
27+
endpointEnv?: Partial<Record<AgentKind, Record<string, string>>>;
2428
}
2529

2630
/**
@@ -211,6 +215,12 @@ export const SERVICE_CATALOG: ServiceDescriptor[] = [
211215
'openai-chat': {
212216
baseUrl: 'https://gateway.ai.cloudflare.com/v1/{account_id}/{gateway_id}/compat',
213217
knownProvider: { opencode: 'cloudflare-ai-gateway', pi: 'cloudflare-ai-gateway' },
218+
// Pi's own entry routes each model to the leg its wire needs — Claude to `/anthropic`, GPT
219+
// to `/openai`, Workers AI to `/compat` — so pinning every one of them to `/compat` answers
220+
// `400 Compatibility endpoint: v1/messages is not supported` on all but the last group.
221+
endpointEnv: {
222+
pi: { account_id: 'CLOUDFLARE_ACCOUNT_ID', gateway_id: 'CLOUDFLARE_GATEWAY_ID' },
223+
},
214224
},
215225
},
216226
},

‎packages/foundation/providers/src/resolve.ts‎

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Account, AccountEndpoint, AccountProtocol, AgentKind } from '@linkcode/schema';
22
import { AccountProtocolSchema } from '@linkcode/schema';
33
import { never } from 'foxts/guard';
4+
import { isObjectEmpty } from 'foxts/is-object-empty';
45
import type { EndpointService, ServiceVariant } from './catalog';
56
import { endpointServiceById } from './catalog';
67
import { fillTemplate, isTemplateFilled } from './template';
@@ -37,6 +38,9 @@ export type ResolvedBinding =
3738
baseUrl?: string;
3839
/** The endpoint's id in this agent's own provider catalog, when it has one. */
3940
knownProvider?: string;
41+
/** Endpoint params under the env names this agent's provider entry reads them by. Present
42+
* means the agent owns its per-model URL, so `baseUrl` must not be injected. */
43+
providerEnv?: Record<string, string>;
4044
}
4145
| { tier: 'unavailable'; reason: BindingUnavailableReason };
4246

@@ -95,9 +99,16 @@ function resolveService(
9599
for (const protocol of preferredProtocols(service, kind)) {
96100
const variant = service.variants[protocol];
97101
if (!variant) continue;
98-
const baseUrl = fillTemplate(variant.baseUrl, account.endpointParams ?? {});
102+
const params = account.endpointParams ?? {};
103+
const baseUrl = fillTemplate(variant.baseUrl, params);
99104
if (!isTemplateFilled(baseUrl)) return { tier: 'unavailable', reason: 'endpoint-incomplete' };
100-
return bind(kind, protocol, baseUrl, knownProviderFor(variant, kind));
105+
return bind(
106+
kind,
107+
protocol,
108+
baseUrl,
109+
knownProviderFor(variant, kind),
110+
providerEnvFor(variant, kind, params),
111+
);
101112
}
102113
return { tier: 'unavailable', reason: 'protocol-unsupported' };
103114
}
@@ -133,8 +144,14 @@ function bind(
133144
protocol: AccountProtocol,
134145
baseUrl: string,
135146
knownProvider: string | undefined,
147+
providerEnv?: Record<string, string>,
136148
): ResolvedBinding {
137-
const resolved = { protocol, baseUrl, ...(knownProvider !== undefined && { knownProvider }) };
149+
const resolved = {
150+
protocol,
151+
baseUrl,
152+
...(knownProvider !== undefined && { knownProvider }),
153+
...(providerEnv !== undefined && { providerEnv }),
154+
};
138155
switch (kind) {
139156
case 'claude-code':
140157
if (protocol === 'anthropic') return { tier: 'native', ...resolved };
@@ -163,6 +180,24 @@ function knownProviderFor(
163180
return variant?.knownProvider?.[kind];
164181
}
165182

183+
/** The variant's declared env names carrying this account's endpoint params, for agents that
184+
* template their own per-model URL. Only the pinned-endpoint path skips it: a URL the user typed
185+
* outranks whatever the agent's own catalog would build. */
186+
function providerEnvFor(
187+
variant: ServiceVariant,
188+
kind: AgentKind,
189+
params: Record<string, string>,
190+
): Record<string, string> | undefined {
191+
const mapping = variant.endpointEnv?.[kind];
192+
if (!mapping) return undefined;
193+
const env: Record<string, string> = {};
194+
for (const [param, name] of Object.entries(mapping)) {
195+
const value = params[param];
196+
if (value !== undefined) env[name] = value;
197+
}
198+
return isObjectEmpty(env) ? undefined : env;
199+
}
200+
166201
/**
167202
* The endpoint the user named, if any. A stored endpoint the catalog itself produces is not one:
168203
* the pre-variant add flow wrote one onto every catalog account, back when an account could only

‎packages/host/agent-adapter/src/__tests__/pi-model-registry.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,9 @@ describe('Pi model registry integration', () => {
5454
config: { authToken: 'dummy', baseUrl, knownProvider: provider },
5555
});
5656

57-
expect(events).toContainEqual({ type: 'model-update', model: `${provider}/${modelId}` });
57+
// An account-bound session reflects the account's own id, unprefixed: the client's picker is
58+
// built from the account's model list and has no other vocabulary to match against.
59+
expect(events).toContainEqual({ type: 'model-update', model: modelId });
5860
await adapter.stop();
5961
});
6062
});

‎packages/host/agent-adapter/src/credential.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ export interface AgentCredential {
1616
/** This endpoint's id in the agent's own provider catalog, when it has one. Provider-routed
1717
* agents (opencode, pi) inject the credential under it instead of guessing a provider. */
1818
knownProvider?: string;
19+
/** Endpoint params under the env names that provider entry reads them by. Present means the agent
20+
* builds its own per-model URL, so `baseUrl` must not be injected — one URL cannot serve a
21+
* provider whose models sit on different routes. */
22+
providerEnv?: Record<string, string>;
1923
/** Extra environment for the agent process. */
2024
extraEnv?: Record<string, string>;
2125
}
@@ -24,11 +28,13 @@ export interface AgentCredential {
2428
export function readAgentCredential(config: StartOptions['config']): AgentCredential {
2529
if (!config) return {};
2630
const extraEnv = readStringRecord(config.extraEnv);
31+
const providerEnv = readStringRecord(config.providerEnv);
2732
return {
2833
apiKey: readString(config.apiKey),
2934
authToken: readString(config.authToken),
3035
baseUrl: readString(config.baseUrl),
3136
knownProvider: readString(config.knownProvider),
37+
...(providerEnv && { providerEnv }),
3238
...(extraEnv && { extraEnv }),
3339
};
3440
}

‎packages/host/agent-adapter/src/native/pi/adapter.ts‎

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,18 @@ function effortLevels(model: PiModel): PiEffort[] {
9494
return level !== 'xhigh' || mapped !== undefined;
9595
});
9696
}
97-
function modelOptions(models: PiModel[]) {
97+
/**
98+
* How a session names a model on the wire. An account-bound session speaks the account's own
99+
* vocabulary — endpoint-owned ids, unprefixed — because that is the list the client picks from;
100+
* qualifying them leaves the picker unable to match the id the session reflects back at it.
101+
*/
102+
function advertisedModelId(model: PiModel, accountProvider: string | undefined): string {
103+
return model.provider === accountProvider ? model.id : `${model.provider}/${model.id}`;
104+
}
105+
106+
function modelOptions(models: PiModel[], accountProvider?: string) {
98107
return models.map((model) => ({
99-
id: `${model.provider}/${model.id}`,
108+
id: advertisedModelId(model, accountProvider),
100109
label: model.name,
101110
description: `${model.provider}/${model.id}`,
102111
effortLevels: effortLevels(model),
@@ -177,14 +186,21 @@ function createConfiguredRegistry(
177186
opts: Pick<AgentStartCatalogOptions, 'model' | 'config'>,
178187
fallbackProvider?: string,
179188
) {
180-
const authStorage = pi.AuthStorage.create();
181-
const modelRegistry = pi.ModelRegistry.create(authStorage);
182189
const cred = readAgentCredential(opts.config);
190+
const key = cred.apiKey ?? cred.authToken;
191+
// Pi reads a provider's endpoint params only off a *stored* credential — `setRuntimeApiKey` carries
192+
// no env and `registerProvider` has no env field — so seed the store instead. In-memory, because
193+
// `set()` on the file-backed store would leave the account's secret in ~/.pi/agent/auth.json.
194+
const seeded =
195+
key && cred.knownProvider && cred.providerEnv
196+
? { [cred.knownProvider]: { type: 'api_key' as const, key, env: cred.providerEnv } }
197+
: undefined;
198+
const authStorage = seeded ? pi.AuthStorage.inMemory(seeded) : pi.AuthStorage.create();
199+
const modelRegistry = pi.ModelRegistry.create(authStorage);
183200
const ref = opts.model
184201
? resolveModelRef(modelRegistry, opts.model, cred, fallbackProvider)
185202
: null;
186203

187-
const key = cred.apiKey ?? cred.authToken;
188204
// An explicit model fixes routing; without one, resume evidence outranks the account default.
189205
const provider =
190206
ref?.provider ??
@@ -194,12 +210,13 @@ function createConfiguredRegistry(
194210
if (!provider && (key || cred.baseUrl)) {
195211
throw new Error('pi: cannot target credential without a provider/model');
196212
}
197-
if (key && provider) authStorage.setRuntimeApiKey(provider, key);
198-
if (cred.baseUrl) {
213+
if (key && provider && !seeded) authStorage.setRuntimeApiKey(provider, key);
214+
if (!seeded && cred.baseUrl) {
199215
// baseUrl override only: a models-less registerProvider rewrites the URL and leaves each
200216
// model's wire at pi's built-in value, so this works exactly when that provider's built-in
201217
// wire already matches the endpoint. Pointing a provider at a differently-shaped endpoint is
202218
// not expressible without supplying full model metadata (see @linkcode/providers AGENTS.md).
219+
// A seeded provider is the escape hatch: it templates its own per-model URL from the env above.
203220
modelRegistry.registerProvider(provider, {
204221
baseUrl: cred.baseUrl,
205222
...(key && { apiKey: key }),
@@ -254,8 +271,8 @@ export class PiAdapter extends BaseAgentAdapter {
254271

255272
override async startCatalog(opts: AgentStartCatalogOptions = {}): Promise<AgentStartCatalog> {
256273
const pi = await this.importSdk();
257-
const { modelRegistry } = createConfiguredRegistry(pi, opts);
258-
const models = modelOptions(modelRegistry.getAvailable());
274+
const { modelRegistry, credential } = createConfiguredRegistry(pi, opts);
275+
const models = modelOptions(modelRegistry.getAvailable(), credential.knownProvider);
259276
return {
260277
models,
261278
policies: [...POLICIES],
@@ -374,14 +391,17 @@ export class PiAdapter extends BaseAgentAdapter {
374391
if (this.lifecycle === generation && this.session === session) this.handleEvent(ev);
375392
});
376393
const runningModel = session.model ?? model;
377-
if (runningModel) this.emitModel(`${runningModel.provider}/${runningModel.id}`);
394+
if (runningModel) {
395+
this.emitModel(advertisedModelId(runningModel, this.credential.knownProvider));
396+
}
378397
this.emitModels(
379398
modelOptions(
380399
this.credentialProviderId
381400
? modelRegistry
382401
.getAvailable()
383402
.filter((item) => item.provider === this.credentialProviderId)
384403
: modelRegistry.getAvailable(),
404+
this.credential.knownProvider,
385405
),
386406
);
387407
this.emitApprovalPolicy({ availablePolicies: [...POLICIES], currentPolicyId: this.policyId });
@@ -476,7 +496,9 @@ export class PiAdapter extends BaseAgentAdapter {
476496
const model = registry.find(ref.provider, ref.modelId);
477497
if (!model) throw new Error(`pi: unknown model '${value}'`);
478498
await session.setModel(model);
479-
if (session.model) this.emitModel(`${session.model.provider}/${session.model.id}`);
499+
if (session.model) {
500+
this.emitModel(advertisedModelId(session.model, this.credential.knownProvider));
501+
}
480502
if (isEffort(session.thinkingLevel)) this.emitEffort(session.thinkingLevel);
481503
}
482504

‎packages/host/engine/src/agent/provider-config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ function accountConfigBundle(
8888
if (binding.baseUrl !== undefined) bundle.baseUrl = binding.baseUrl;
8989
if (binding.protocol !== undefined) bundle.protocol = binding.protocol;
9090
if (binding.knownProvider !== undefined) bundle.knownProvider = binding.knownProvider;
91+
if (binding.providerEnv !== undefined) bundle.providerEnv = binding.providerEnv;
9192
if (extraEnv) bundle.extraEnv = extraEnv;
9293
return { bundle };
9394
}

0 commit comments

Comments
 (0)