Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ jobs:
- batch: graphile-unit
packages: 'graphile/graphile-plugin-utils graphile/graphile-realtime-subscriptions graphile/graphile-sql-expression-validator graphile/graphile-upload-plugin'
- batch: agentic
packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/pi agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama'
packages: 'agentic/protocol agentic/agentic-kit agentic/agent agentic/harness agentic/chat agentic/cli agentic/pi agentic/react agentic/agentic-server agentic/anthropic agentic/openai agentic/ollama agentic/run-log agentic/pi-ext-run-log agentic/pi-ext-metered-model agentic/pi-ext-usage-report'
- batch: pgpm-unit
packages: 'pgpm/types pgpm/naming-spec pgpm/diff pgpm/import pgpm/slice pgpm/transform'
- batch: pglite
Expand Down
58 changes: 58 additions & 0 deletions agentic/pi-ext-metered-model/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<p align="center" width="100%">
<img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
</p>

# @agentic-kit/pi-ext-metered-model

A pi extension that makes the [Constructive metered gateway](../agentic-server) the session's model provider, so a coding-agent run is metered exactly like every other platform inference call — same `inference_log`, same identity, same billing.

## Why the gateway and not self-reporting

Two lanes exist for getting a pi run's usage into billing:

| lane | package | authority |
| --- | --- | --- |
| model calls leave through `agentic-server` | **this package** | the gateway — the agent cannot under-report |
| the host owns the provider keys and reports usage afterwards | `@agentic-kit/pi-ext-usage-report` | self-reported; good for reconciliation, not for billing |

Use this one for cloud runs. `agentic-server` already speaks OpenAI's `/v1/chat/completions`, which is one of pi's built-in api types, so no custom streaming code is involved: the extension registers a provider whose `baseUrl` is the gateway and whose headers carry the run's identity.

## Usage

```ts
import { createMeteredModelExtension } from '@agentic-kit/pi-ext-metered-model';

const metered = createMeteredModelExtension({
gatewayUrl: 'https://agentic.example.com', // the gateway root, NOT the /v1 path
identity: {
databaseId: process.env.CONSTRUCTIVE_DATABASE_ID!,
entityId: process.env.CONSTRUCTIVE_OWNER_ID,
actorId: runActorId,
runToken // run-scoped, never an account token
},
models: [
{ id: 'anthropic/claude-sonnet-4', contextWindow: 200000, maxTokens: 8192, input: ['text', 'image'] }
]
});

// hand `metered.extension` to pi alongside your other extensions
```

The first declared model is selected on `session_start`; pass `selectModel: '<id>'` to pick another, or `selectModel: false` to leave the host's choice alone.

## Behavior

- **Identity** travels as `X-Database-Id` / `X-Entity-Id` / `X-Actor-Id`, the headers `agentic-server` already reads, plus `Authorization: Bearer <runToken>` when one is given. Extra `headers` (e.g. `X-LLM-Provider`) are merged, but can never shadow identity.
- **Headers are only as trustworthy as the network.** In-cluster, or behind an ingress that pins identity from the bearer, they are authoritative; a host outside that boundary must send `runToken` and let the ingress do the pinning.
- **Misconfiguration fails at construction**, not at the first turn: a blank `databaseId`, a relative or non-http `gatewayUrl`, a `gatewayUrl` ending in `/v1` (pi appends its own, so the request would 404), an empty model list, or a `selectModel` that is not one of the registered models — the last of which would otherwise leave pi quietly running an *unmetered* model.
- **Model selection fails loudly** if the model is absent from the registry after registration or pi refuses it for lack of credentials.
- **Cost fields default to zero.** The gateway is the pricing authority; zeros mean "not priced client-side", not free. Pass `cost` when you want pi's UI to show numbers.

## Testing

`pnpm test` — the provider/identity builders are asserted directly, and the extension is driven through a fake `ExtensionAPI`, so no gateway or model credentials are needed.

## Related

- `@agentic-kit/run-log` — append-only run log
- `@agentic-kit/pi-ext-run-log` — mirrors pi session entries into it
125 changes: 125 additions & 0 deletions agentic/pi-ext-metered-model/__tests__/extension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import type { ExtensionAPI, ProviderConfig } from '@earendil-works/pi-coding-agent';

import { createMeteredModelExtension } from '../src';

const models = [
{ id: 'anthropic/claude-sonnet-4', contextWindow: 200000, maxTokens: 8192 },
{ id: 'openai/gpt-5', contextWindow: 400000, maxTokens: 16384 }
];

const options = {
gatewayUrl: 'https://agentic.example.com',
identity: { databaseId: 'db-1', runToken: 'tok-1' },
models
};

interface FakePi {
api: ExtensionAPI;
providers: { name: string; config: ProviderConfig }[];
handlers: Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>;
setModel: jest.Mock;
}

function fakePi(modelAccepted = true): FakePi {
const providers: FakePi['providers'] = [];
const handlers = new Map<string, (event: unknown, ctx: unknown) => Promise<void> | void>();
const setModel = jest.fn().mockResolvedValue(modelAccepted);
const api = {
registerProvider: (name: string, config: ProviderConfig) => providers.push({ name, config }),
on: (event: string, handler: (event: unknown, ctx: unknown) => Promise<void> | void) => handlers.set(event, handler),
setModel
} as unknown as ExtensionAPI;
return { api, providers, handlers, setModel };
}

function fakeCtx(found: unknown) {
const find = jest.fn().mockReturnValue(found);
return { ctx: { modelRegistry: { find } } as never, find };
}

describe('createMeteredModelExtension', () => {
it('registers the gateway provider under the default name', () => {
const pi = fakePi();
createMeteredModelExtension(options).extension(pi.api);

expect(pi.providers).toHaveLength(1);
expect(pi.providers[0].name).toBe('constructive-gateway');
expect(pi.providers[0].config.baseUrl).toBe('https://agentic.example.com');
});

it('honours a custom provider name', () => {
const pi = fakePi();
const ext = createMeteredModelExtension({ ...options, providerName: 'tenant-gateway' });
ext.extension(pi.api);

expect(pi.providers[0].name).toBe('tenant-gateway');
expect(ext.providerName).toBe('tenant-gateway');
});

it('selects the first model on session start', async () => {
const pi = fakePi();
const ext = createMeteredModelExtension(options);
ext.extension(pi.api);

expect(ext.selectedModel).toBe('anthropic/claude-sonnet-4');

const model = { id: 'anthropic/claude-sonnet-4' };
const { ctx, find } = fakeCtx(model);
await pi.handlers.get('session_start')!({ type: 'session_start' }, ctx);

expect(find).toHaveBeenCalledWith('constructive-gateway', 'anthropic/claude-sonnet-4');
expect(pi.setModel).toHaveBeenCalledWith(model);
});

it('selects an explicitly requested model', async () => {
const pi = fakePi();
const ext = createMeteredModelExtension({ ...options, selectModel: 'openai/gpt-5' });
ext.extension(pi.api);

const { ctx, find } = fakeCtx({ id: 'openai/gpt-5' });
await pi.handlers.get('session_start')!({ type: 'session_start' }, ctx);

expect(find).toHaveBeenCalledWith('constructive-gateway', 'openai/gpt-5');
expect(ext.selectedModel).toBe('openai/gpt-5');
});

it('rejects selecting a model it does not register, which would silently stay unmetered', () => {
expect(() => createMeteredModelExtension({ ...options, selectModel: 'anthropic/other' })).toThrow(
/not one of the registered models/
);
});

it('leaves the host model choice alone when selection is disabled', () => {
const pi = fakePi();
const ext = createMeteredModelExtension({ ...options, selectModel: false });
ext.extension(pi.api);

expect(ext.selectedModel).toBeUndefined();
expect(pi.handlers.size).toBe(0);
expect(pi.providers).toHaveLength(1);
});

it('fails loudly when the model is missing after registration', async () => {
const pi = fakePi();
createMeteredModelExtension(options).extension(pi.api);

const { ctx } = fakeCtx(undefined);
await expect(pi.handlers.get('session_start')!({ type: 'session_start' }, ctx)).rejects.toThrow(/has no model/);
});

it('fails loudly when pi refuses the model for lack of credentials', async () => {
const pi = fakePi(false);
createMeteredModelExtension(options).extension(pi.api);

const { ctx } = fakeCtx({ id: 'anthropic/claude-sonnet-4' });
await expect(pi.handlers.get('session_start')!({ type: 'session_start' }, ctx)).rejects.toThrow(
/refused model .*no usable credentials/
);
});

it('surfaces identity misconfiguration before any turn runs', () => {
expect(() => createMeteredModelExtension({ ...options, identity: { databaseId: '' } })).toThrow(
/databaseId is required/
);
});
});
156 changes: 156 additions & 0 deletions agentic/pi-ext-metered-model/__tests__/provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {
ACTOR_ID_HEADER,
buildIdentityHeaders,
DATABASE_ID_HEADER,
ENTITY_ID_HEADER,
GATEWAY_API,
meteredModelConfig,
meteredProviderConfig,
normalizeGatewayUrl
} from '../src';

const models = [{ id: 'anthropic/claude-sonnet-4', contextWindow: 200000, maxTokens: 8192 }];

describe('buildIdentityHeaders', () => {
it('sends the gateway identity headers', () => {
expect(
buildIdentityHeaders({
databaseId: 'db-1',
entityId: 'ent-1',
actorId: 'actor-1',
runToken: 'tok-1'
})
).toEqual({
[DATABASE_ID_HEADER]: 'db-1',
[ENTITY_ID_HEADER]: 'ent-1',
[ACTOR_ID_HEADER]: 'actor-1',
Authorization: 'Bearer tok-1'
});
});

it('omits optional identity rather than sending blanks', () => {
expect(buildIdentityHeaders({ databaseId: 'db-1', entityId: ' ', actorId: '' })).toEqual({
[DATABASE_ID_HEADER]: 'db-1'
});
});

it('rejects a missing databaseId up front', () => {
expect(() => buildIdentityHeaders({ databaseId: ' ' })).toThrow(/databaseId is required/);
});
});

describe('normalizeGatewayUrl', () => {
it('keeps the origin and strips trailing slashes', () => {
expect(normalizeGatewayUrl('https://agentic.example.com/')).toBe('https://agentic.example.com');
expect(normalizeGatewayUrl(' http://localhost:3000 ')).toBe('http://localhost:3000');
});

it('preserves a mount path', () => {
expect(normalizeGatewayUrl('https://example.com/gateway/')).toBe('https://example.com/gateway');
});

it('rejects a /v1 suffix, which pi would double up', () => {
expect(() => normalizeGatewayUrl('https://example.com/v1')).toThrow(/drop the \/v1/);
});

it('rejects relative and non-http urls', () => {
expect(() => normalizeGatewayUrl('/v1/chat')).toThrow(/absolute URL/);
expect(() => normalizeGatewayUrl('ws://example.com')).toThrow(/http\(s\)/);
expect(() => normalizeGatewayUrl('')).toThrow(/gatewayUrl is required/);
});
});

describe('meteredModelConfig', () => {
it('fills the model fields pi requires with safe defaults', () => {
expect(meteredModelConfig(models[0])).toEqual({
id: 'anthropic/claude-sonnet-4',
name: 'anthropic/claude-sonnet-4',
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192
});
});

it('passes through declared capabilities and cost', () => {
expect(
meteredModelConfig({
id: 'm',
name: 'M',
contextWindow: 1,
maxTokens: 2,
reasoning: true,
input: ['text', 'image'],
cost: { input: 3, output: 4, cacheRead: 5, cacheWrite: 6 }
})
).toMatchObject({ name: 'M', reasoning: true, input: ['text', 'image'], cost: { input: 3, cacheWrite: 6 } });
});

it('rejects a blank model id', () => {
expect(() => meteredModelConfig({ id: ' ', contextWindow: 1, maxTokens: 1 })).toThrow(/model id is required/);
});
});

describe('meteredProviderConfig', () => {
it('points pi at the gateway over the openai-completions api', () => {
const config = meteredProviderConfig({
gatewayUrl: 'https://agentic.example.com',
identity: { databaseId: 'db-1' },
models
});

expect(config.baseUrl).toBe('https://agentic.example.com');
expect(config.api).toBe(GATEWAY_API);
expect(config.models).toHaveLength(1);
expect(config.headers?.[DATABASE_ID_HEADER]).toBe('db-1');
});

it('merges extra headers but never lets them shadow identity', () => {
const config = meteredProviderConfig({
gatewayUrl: 'https://agentic.example.com',
identity: { databaseId: 'real-db' },
models,
headers: { 'X-LLM-Provider': 'anthropic', [DATABASE_ID_HEADER]: 'spoofed' }
});

expect(config.headers).toMatchObject({ 'X-LLM-Provider': 'anthropic', [DATABASE_ID_HEADER]: 'real-db' });
});

it('uses the run token as the api key so pi passes its auth check', () => {
const config = meteredProviderConfig({
gatewayUrl: 'https://agentic.example.com',
identity: { databaseId: 'db-1', runToken: 'tok-1' },
models
});

expect(config.apiKey).toBe('tok-1');
});

it('falls back to a placeholder api key when identity travels in headers only', () => {
const config = meteredProviderConfig({
gatewayUrl: 'https://agentic.example.com',
identity: { databaseId: 'db-1' },
models
});

expect(config.apiKey).toBe('unused');
});

it('prefers an explicit api key', () => {
const config = meteredProviderConfig({
gatewayUrl: 'https://agentic.example.com',
identity: { databaseId: 'db-1', runToken: 'tok-1' },
models,
apiKey: 'explicit'
});

expect(config.apiKey).toBe('explicit');
});

it('requires at least one model', () => {
expect(() =>
meteredProviderConfig({ gatewayUrl: 'https://agentic.example.com', identity: { databaseId: 'db-1' }, models: [] })
).toThrow(/at least one model/);
});
});
21 changes: 21 additions & 0 deletions agentic/pi-ext-metered-model/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
babelConfig: false,
tsconfig: 'tsconfig.json',
},
],
},
transformIgnorePatterns: [`/node_modules/*`],
testRegex: '(/__tests__/.*\\.(test|spec))\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: ['dist/*'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
};
Loading
Loading