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
9 changes: 9 additions & 0 deletions agentic/cli/__tests__/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ const writeSkill = (root: string, name: string, body: string) => {
);
};

/**
* The base layer is fetched over HTTP, and these cases are about what assembly
* does when it cannot be had — so the fetch is refused here rather than left to
* reach api.github.com, where a slow lookup read as a test timeout.
*/
const offline = () => Promise.reject(new Error('offline: no network in this test'));

describe('agent skills assembly', () => {
let home: string;

Expand All @@ -29,6 +36,7 @@ describe('agent skills assembly', () => {
const config = loadConfig(home);
config.skillsRepo = 'example/does-not-exist';
config.skillsPin = 'v0.0.0';
config.skillsFetch = offline;
const resolved = await assembleSkills(config);
expect(fs.existsSync(config.overlayDir)).toBe(true);
expect(resolved).toEqual([]);
Expand All @@ -38,6 +46,7 @@ describe('agent skills assembly', () => {
const config = loadConfig(home);
config.skillsRepo = 'example/does-not-exist';
config.skillsPin = 'v9.9.9';
config.skillsFetch = offline;
// Seed a fake cached release so the offline fallback picks it up.
const releaseDir = path.join(config.dirs.skillsRoot, '9.9.9', '.agents', 'skills');
writeSkill(releaseDir, 'alpha', 'base alpha');
Expand Down
8 changes: 7 additions & 1 deletion agentic/cli/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HarnessDirs, harnessDirs, SkillsManifest } from '@agentic-kit/harness';
import { FetchLike, HarnessDirs, harnessDirs, SkillsManifest } from '@agentic-kit/harness';
import { ConfigStore, createConfigStore } from 'appstash';
import * as fs from 'fs';
import * as path from 'path';
Expand Down Expand Up @@ -34,6 +34,12 @@ export interface AgentCliConfig {
manifest: SkillsManifest;
skillsRepo: string;
skillsPin: string;
/**
* HTTP the skills fetch uses, so a caller that must not reach the network —
* a test, an air-gapped host — decides that rather than discovering it as a
* hang against api.github.com.
*/
skillsFetch?: FetchLike;
}

interface ManifestFile {
Expand Down
3 changes: 2 additions & 1 deletion agentic/cli/src/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ async function baseSource(config: AgentCliConfig, log: (msg: string) => void): P
repo: config.skillsRepo,
pin: config.skillsPin,
skillsRoot: config.dirs.skillsRoot,
token: process.env.GITHUB_TOKEN
token: process.env.GITHUB_TOKEN,
...(config.skillsFetch ? { fetchImpl: config.skillsFetch } : {})
});
log(`skills base: ${config.skillsRepo}@${release.version}${release.fromCache ? ' (cached)' : ''}`);
return new DirectorySkillSource(BASE_LAYER, release.skillsDir);
Expand Down
18 changes: 15 additions & 3 deletions agentic/metering/__tests__/gateway.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
ACTOR_ID_HEADER,
buildIdentityHeaders,
completionsBaseUrl,
DATABASE_ID_HEADER,
ENTITY_ID_HEADER,
GATEWAY_API,
Expand Down Expand Up @@ -48,8 +49,9 @@ describe('normalizeGatewayUrl', () => {
expect(normalizeGatewayUrl('https://example.com/gateway/')).toBe('https://example.com/gateway');
});

it('rejects a /v1 suffix, which an openai-compatible client would double up', () => {
expect(() => normalizeGatewayUrl('https://example.com/v1')).toThrow(/drop the \/v1/);
it('answers the root for a caller who named the api root instead', () => {
expect(normalizeGatewayUrl('https://example.com/v1')).toBe('https://example.com');
expect(normalizeGatewayUrl('https://example.com/gateway/v1/')).toBe('https://example.com/gateway');
});

it('rejects relative and non-http urls', () => {
Expand Down Expand Up @@ -91,6 +93,16 @@ describe('resolveMeteredModel', () => {
});
});

describe('completionsBaseUrl', () => {
// An openai-completions client appends `/chat/completions` and nothing else, so
// a baseUrl at the gateway root 404s on the first model turn.
it('is the api root under the gateway root, however the root was spelled', () => {
expect(completionsBaseUrl('https://agentic.example.com')).toBe('https://agentic.example.com/v1');
expect(completionsBaseUrl('https://agentic.example.com/v1')).toBe('https://agentic.example.com/v1');
expect(completionsBaseUrl('https://example.com/gateway/')).toBe('https://example.com/gateway/v1');
});
});

describe('resolveMeteredGateway', () => {
it('resolves the gateway endpoint over the openai-completions api', () => {
const config = resolveMeteredGateway({
Expand All @@ -99,7 +111,7 @@ describe('resolveMeteredGateway', () => {
models
});

expect(config.baseUrl).toBe('https://agentic.example.com');
expect(config.baseUrl).toBe('https://agentic.example.com/v1');
expect(config.api).toBe(GATEWAY_API);
expect(config.models).toHaveLength(1);
expect(config.headers?.[DATABASE_ID_HEADER]).toBe('db-1');
Expand Down
11 changes: 7 additions & 4 deletions agentic/metering/__tests__/reporter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,13 @@ describe('httpUsageSink', () => {
expect(JSON.parse(init.body)).toEqual(report);
});

it('rejects a gateway URL that already includes /v1', () => {
expect(() =>
httpUsageSink({ gatewayUrl: 'https://gw.example.com/v1', identity, fetch: jest.fn() as never })
).toThrow(/drop the \/v1/);
it('posts to the one usage route whether or not the caller named the api root', async () => {
const fetchMock = jest.fn().mockResolvedValue({ ok: true, status: 202 });
const sink = httpUsageSink({ gatewayUrl: 'https://gw.example.com/v1', identity, fetch: fetchMock as never });

await sink(report);

expect(fetchMock.mock.calls[0][0]).toBe('https://gw.example.com/v1/usage');
});

it('requires a databaseId', () => {
Expand Down
31 changes: 22 additions & 9 deletions agentic/metering/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* `agentic-server` speaks OpenAI's `/v1/chat/completions`, so routing a harness
* through it needs no custom streaming code — only an endpoint whose `baseUrl` is
* the gateway and whose headers carry the run's identity. Every harness worth
* the gateway's OpenAI api root and whose headers carry the run's identity. Every harness worth
* adapting can already talk to an OpenAI-compatible endpoint, which is why this
* resolution is neutral and only the registration is vendor-specific: an adapter
* turns `MeteredGateway` into whatever its harness calls a provider.
Expand Down Expand Up @@ -41,7 +41,7 @@ export interface MeteredModelSpec {
}

export interface MeteredGatewayOptions {
/** Gateway root, e.g. `https://agentic.example.com` — not a `/v1` path. */
/** Gateway root, e.g. `https://agentic.example.com`; a trailing `/v1` is tolerated. */
gatewayUrl: string;
identity: MeteredIdentity;
models: readonly MeteredModelSpec[];
Expand Down Expand Up @@ -83,7 +83,13 @@ export interface MeteredModel {

const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } as const;

/** Normalize the gateway root: absolute http(s), no trailing slash, no `/v1`. */
/**
* Normalize the gateway root: absolute http(s), no trailing slash, no `/v1`.
*
* A caller who names the api root instead is answered the root it hangs under,
* so a deployment that spells its gateway either way reaches the same routes
* rather than one of them 404ing on the first model turn.
*/
export function normalizeGatewayUrl(gatewayUrl: string): string {
const raw = gatewayUrl?.trim();
if (!raw) throw new Error('metered model: gatewayUrl is required');
Expand All @@ -98,14 +104,21 @@ export function normalizeGatewayUrl(gatewayUrl: string): string {
throw new Error(`metered model: gatewayUrl must be http(s), got ${parsed.protocol}`);
}

const path = parsed.pathname.replace(/\/+$/, '');
// An OpenAI-compatible client appends `/v1/chat/completions`, so a baseUrl that
// already ends in `/v1` would request `/v1/v1/chat/completions` and 404 at the
// first model turn.
if (/\/v1$/.test(path)) throw new Error(`metered model: gatewayUrl must be the gateway root, not ${raw} (drop the /v1)`);
const path = parsed.pathname.replace(/\/+$/, '').replace(/\/v1$/, '');
return `${parsed.origin}${path}`;
}

/**
* The OpenAI api root under a gateway root.
*
* An `openai-completions` client (pi-ai, the OpenAI SDKs, any harness built on
* either) appends `/chat/completions` to its `baseUrl` and nothing else, so the
* `/v1` belongs here rather than in the caller's environment.
*/
export function completionsBaseUrl(gatewayUrl: string): string {
return `${normalizeGatewayUrl(gatewayUrl)}/v1`;
}

export function resolveMeteredModel(spec: MeteredModelSpec): MeteredModel {
if (!spec.id?.trim()) throw new Error('metered model: model id is required');
return {
Expand Down Expand Up @@ -133,7 +146,7 @@ export function resolveMeteredGateway(options: MeteredGatewayOptions): MeteredGa
return {
providerName: options.providerName ?? DEFAULT_PROVIDER_NAME,
displayName: options.displayName ?? 'Constructive (metered)',
baseUrl: normalizeGatewayUrl(options.gatewayUrl),
baseUrl: completionsBaseUrl(options.gatewayUrl),
api: GATEWAY_API,
headers,
// Harnesses typically require an apiKey once models are declared; the gateway
Expand Down
1 change: 1 addition & 0 deletions agentic/metering/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

export {
completionsBaseUrl,
DEFAULT_PROVIDER_NAME,
GATEWAY_API,
type MeteredGateway,
Expand Down
4 changes: 2 additions & 2 deletions agentic/pi/__tests__/embed/lanes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,9 @@ describe('composeRun', () => {
expect(() =>
composeRun({
runId: 'run-1',
metering: { mode: 'gateway', gatewayUrl: `${gatewayUrl}/v1`, identity, models }
metering: { mode: 'gateway', gatewayUrl: 'ws://gateway.constructive.io', identity, models }
})
).toThrow();
).toThrow(/http\(s\)/);

// A policy that can ask needs somewhere to ask.
expect(() => composeRun({ runId: 'run-1', gate: { policy: {} } })).toThrow(/approvals channel is required/);
Expand Down
3 changes: 2 additions & 1 deletion agentic/pi/__tests__/extensions/metered-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ describe('createMeteredModelExtension', () => {

expect(pi.providers).toHaveLength(1);
expect(pi.providers[0].name).toBe('constructive-gateway');
expect(pi.providers[0].config.baseUrl).toBe('https://agentic.example.com');
// pi-ai appends `/chat/completions`, so the registration must carry the /v1.
expect(pi.providers[0].config.baseUrl).toBe('https://agentic.example.com/v1');
});

it('honours a custom provider name', () => {
Expand Down
Loading