pi-inferhub 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md DELETED
@@ -1,146 +0,0 @@
1
- # pi-inferhub
2
-
3
- pi provider extension exposing the user's InferHub (https://inferhub.dev)
4
- **combos** as selectable chat models. Combos are the product: the extension
5
- never surfaces `prefix/model` upstream ids, bare aliases, `free/` tier
6
- models, or images/video generation. Combos are `combo/<slug>` models routed
7
- through InferHub's OpenAI-compatible inference surface; all failover,
8
- serialization, and selection happens server-side.
9
-
10
- Spec (binding design authority): `docs/superpowers/specs/2026-10-06-inferhub-provider-design.md`
11
- Plan: `docs/superpowers/plans/2026-10-06-inferhub-provider.md`
12
-
13
- ## Architecture
14
-
15
- Four modules, acyclic, one-directional:
16
-
17
- ```
18
- src/inferhub.ts constants only — PROVIDER_NAME="inferhub", PROVIDER_DISPLAY_NAME="InferHub",
19
- INFERENCE_BASE_URL="https://api.inferhub.dev/v1", MGMT_BASE_URL="https://inferhub.dev/api"
20
- src/http.ts ← transport only — fetchJson (Bearer GET + typebox validation), InferhubHttpError{status,code,message}
21
- src/auth.ts ← loginInferhub (prompt → validate GET /me → store), inferhubApiKeyAuth (envApiKeyAuth)
22
- src/catalog.ts ← fetchComboCatalog (Promise.all of the two GETs), pure combosToModels for tests
23
- src/index.ts ← default extension factory: createProvider registration + inferhub-login signpost command
24
- ```
25
-
26
- No custom `streamSimple`. Everything streams through pi-ai's
27
- `openAICompletionsApi()` (imported from `@earendil-works/pi-ai/compat` —
28
- pi's extension loader does not virtualize lazy API subpaths). pi-ai owns
29
- message conversion, tool handling, usage, cancellation, retries. Adding a
30
- custom stream is a design-level decision, never a drive-by fix for an
31
- upstream quirk.
32
-
33
- ## Wire contract (live-pinned 2026-10-07, verify before trusting)
34
-
35
- - Auth: `Authorization: Bearer sk-airo-…` on BOTH surfaces.
36
- - `GET MGMT_BASE_URL/combos` → **bare JSON array** of combos (id/name/slug/
37
- members[]; member kinds `model` with `model: "prefix/upstream_id"` and
38
- `alias` with bare name, no catalog linkage).
39
- - `GET INFERENCE_BASE_URL/models` → `{object:"list", data:[...]}`; items
40
- carry `id, modality ("text" | "text,image"), input_token_limit,
41
- max_output_tokens, reasoning_levels, pricing.official_in/official_out`.
42
- Combos do NOT appear here — that is why refresh fetches /combos
43
- separately. `/v1/models` requires auth (401 unauthenticated).
44
- - `GET MGMT_BASE_URL/me` → any JSON object; login validation endpoint.
45
- - Management API rate limit 30 req/min/account; inference 500 req/min/account.
46
- - Errors: `{"error":{"code","message"}}`; suspended/banned → 403
47
- `account_blocked` with the ban reason in `message`.
48
- - Cost: inference responses report `usage.cost` USD (OpenRouter convention),
49
- $0.00001 minimum on billed requests. All management-surface money is USDC
50
- decimal strings.
51
-
52
- ## Invariants (breaking any of these is a reverted commit)
53
-
54
- 1. **Reserved-key play-safe**: combo-level metadata (maxInputPerMtok/
55
- maxOutputPerMtok caps, selection mode, filters, sticky, members) never
56
- rides on the pi-ai `Model` object. Mapped models carry exactly pi-ai
57
- `Model<"openai-completions">` fields.
58
- 2. **Cost honesty**: `cost.input/output` = per-direction MIN of resolvable
59
- members' `official` rates (conservative upper bound — InferHub bills
60
- discounts below official). `cost.cacheRead/cacheWrite` = 0 always —
61
- InferHub's /v1/models pricing carries no cache fields; never fabricate.
62
- 3. **Fallback constants**: `CONTEXT_WINDOW_FALLBACK = 262_144`,
63
- `MAX_TOKENS_FALLBACK = 131_072` — used when no resolvable member supplies
64
- the field (last resort after the alias sibling join; see 5). pi-ai's
65
- `Model` type requires both as numbers; never guess a model's ceiling
66
- other than via these constants.
67
- 4. **Image rule**: `input: ["text","image"]` only when ≥1 resolvable member
68
- AND every resolvable member's `modality` includes `image`. Understating
69
- input is acceptable; overstating sends requests the upstream rejects.
70
- 5. **Alias-member fallback**: bare alias rows (`owned_by: "alias"`, no
71
- data — observed live 2026-10-15) and alias-kind members first resolve
72
- through prefixed sibling servings of the same model name (`<prefix>/<name>`
73
- rows in the /v1/models catalog, min-of applies). Only when no sibling
74
- carries data either → `input:["text"]`, `reasoning:true`, zero cost,
75
- fallback limits. `reasoning:true` is deliberate: a false negative hides
76
- thinking levels (the worse error); a false positive shows an inert knob.
77
- 6. **thinkingLevelMap**: built from the union of resolvable members'
78
- `reasoning_levels`; each pi level (`minimal|low|medium|high|xhigh|max`)
79
- maps to itself when advertised, else `null`; `off:"off"`. Set only when
80
- `reasoning` is true. Alias-only/level-less combos OMIT the map entirely —
81
- an all-null map would hide every selectable level.
82
- 7. **No key leakage**: the API key appears ONLY in the Authorization header
83
- and pi's credential store (`auth.json` via pi's own store). Never log,
84
- print, or persist it anywhere else. Tests inject fetch mocks; a test
85
- asserting the header is fine, echoing the key in fixtures is not.
86
- 8. **Unconfigured = zero models, no network**: `fetchModels` returns `[]`
87
- and fetches nothing when auth resolves no key. pi-ai passes BOTH
88
- stored-credential and env-resolved keys as `context.credential`
89
- (verified in pi-ai 1.0.4 `dist/models.js` `resolveRefreshCredential`) —
90
- do not add a second key-resolution path.
91
- 9. **No cross-run catalog persistence**: refresh is always live. A stale
92
- combo snapshot silently serving the wrong routing is worse than an
93
- explicit refresh.
94
-
95
- ## Commands
96
-
97
- ```
98
- npx vitest run # full suite (18 tests, <2s)
99
- npx tsc --noEmit # typecheck
100
- npm pack --dry-run # inspect package contents before publishing
101
- ```
102
-
103
- pi packs the extension from `package.json` `pi.extensions:
104
- ["./src/index.ts"]`. Local dev: `pi -e <repo path>`. Published install:
105
- `npm:pi-inferhub` in `~/.pi/agent/settings.json` packages (NOT yet
106
- published npm:pi-inferhub (v0.1.1).
107
-
108
- ## Environment gotchas (learned the hard way)
109
-
110
- - **Pi packages declare host modules as peerDependencies, never
111
- dependencies**: `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`,
112
- `typebox` (and pi-tui if used) go in `peerDependencies` with `"*"` ranges;
113
- keep `dependencies` empty. Regular dependencies make pi's package installer
114
- warn about duplicate host-module copies — extensions must bind to the
115
- copies the pi host already loads. Mirror the same modules in
116
- `devDependencies` (with real version pins) for the repo's own
117
- tests/typecheck.
118
-
119
-
120
- - **TypeScript 6.0.3**: tsconfig MUST carry `types: ["node"]` — TS 6 dropped
121
- automatic `@types/*` inclusion; without it `node:module`/`fetch`/`AbortSignal`
122
- don't resolve and `tsc --noEmit` fails.
123
- - **typebox 1.3.36**: compiled validators are typed `Validator<TProperties,
124
- TSchema>` — pass the validator, not the schema, to `fetchJson`. Validation
125
- error detail lives on `instancePath` (there is no `path` field). Extra
126
- payload keys pass through by default; do NOT add
127
- `additionalProperties: false` to wire schemas.
128
- - **pi-ai `AuthEvent`** is a union; non-info variants carry no `message`.
129
- Anything typing an interaction callback as `{type: string; message:
130
- string}` breaks under contravariance — use `{type: string; message?:
131
- string}`.
132
- - **Live pinning of wire shapes is allowed and encouraged** when a shape is
133
- undocumented: run a free listing GET, pin the parsed envelope in a typebox
134
- validator, and leave a dated comment. Drift surfaces as
135
- `code: "schema_mismatch"` with `instancePath` — fail-loud by design.
136
-
137
- ## Testing conventions
138
-
139
- - Typebox schemas live at module scope (`Compile(Type.Object({...}))`).
140
- - Tabs, ESM with `.js` import specifiers, single-responsibility modules.
141
- - Tests verify real behavior through public seams (`combosToModels` pure
142
- function; real `createProvider` refresh path in test/index.test.ts with
143
- injected `fetchImpl`). No seams into internals.
144
- - Deleting a brief-verbatim test line, or "fixing" a failing test by editing
145
- the test to fit the code rather than the contract, is not allowed without
146
- naming the contract being violated.
@@ -1,438 +0,0 @@
1
- # InferHub pi provider extension Implementation Plan
2
-
3
- > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
-
5
- **Goal:** A pi provider extension that exposes the user's InferHub combos as selectable chat models over InferHub's OpenAI-compatible inference surface.
6
-
7
- **Architecture:** One provider registered via pi-ai's `createProvider` with `api: openAICompletionsApi()` — no custom streaming. `fetchModels` merges `GET inferhub.dev/api/combos` (account combos) with `GET api.inferhub.dev/v1/models` (member capability/pricing metadata) into `Model<"openai-completions">` entries. Auth is `envApiKeyAuth(["INFERHUB_API_KEY"])` plus a `/login inferhub` prompt-validate-store flow.
8
-
9
- **Tech Stack:** TypeScript (ESM), `@earendil-works/pi-ai` + `@earendil-works/pi-coding-agent` (types), `typebox/compile` for payload validation, vitest.
10
-
11
- **Spec:** `docs/superpowers/specs/2026-10-06-inferhub-provider-design.md`
12
-
13
- ## Global Constraints
14
-
15
- - Provider name: `inferhub`. Constants: `INFERENCE_BASE_URL = "https://api.inferhub.dev/v1"`, `MGMT_BASE_URL = "https://inferhub.dev/api"`, `PROVIDER_NAME = "inferhub"`, `PROVIDER_DISPLAY_NAME = "InferHub"`.
16
- - Fallback limits: `CONTEXT_WINDOW_FALLBACK = 128_000`, `MAX_TOKENS_FALLBACK = 8_192` (exact values from the spec).
17
- - Model ids on the wire are `combo/<slug>`; bare-name aliases and `prefix/model` ids are never exposed as pi models.
18
- - Combo-level metadata (caps, selection, filters, sticky, members) is never placed on the pi-ai `Model` object.
19
- - `thinkingLevelMap` derives from each combo's resolvable members' union of `reasoning_levels` (see Task 3 for the exact rule).
20
- - `cost`: `cacheRead`/`cacheWrite` are `0` unless a member supplies them (InferHub's `/v1/models` pricing has no cache fields — they stay 0).
21
- - Empty catalog (zero combos) is a valid registration state: provider registers with zero models, no error.
22
- - package.json: `name: "pi-inferhub"`, `"type": "module"`, `pi.extensions: ["./src/index.ts"]`; devDeps `vitest`, `typescript`, `@types/node`; deps `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, `typebox`. Test command: `npx vitest run`. Typecheck: `npx tsc --noEmit`.
23
- - HTTP helper contract: JSON-validated GET with Bearer auth, `AbortSignal` honored, `User-Agent: pi-inferhub/<version>`.
24
- - Payload validation philosophy: typebox schemas validate shape and required fields; unknown extra fields pass through (no `additionalProperties: false`).
25
- - Never log or persist the API key; `auth.ts` returns credentials to pi, never writes them anywhere else.
26
-
27
- ## Review Focus
28
-
29
- 1. **Member-join mismatch**: a combo member whose `model` string is absent from `/v1/models` (upstream vanished between the two GETs). Expected: that member is skipped silently for derivation; the combo still registers on remaining members; empty resolvable set → safe defaults (spec's alias-member fallback rule applies verbatim). Pinned by Task 3 tests.
30
- 2. **Reasoning-capable combo mapped to non-reasoning**: `reasoning: false` when every member lacks `reasoning_levels` — pi then hides thinking controls, and `reasoning_effort` is not sent. If wrong (true when unsupported), requests may 400 upstream. Pinned by Task 3.
31
- 3. **Image-capability overstatement**: `input: ["text","image"]` on a combo with a text-only member sends images the upstream rejects (worse than understating). Pinned by Task 3 (all-members rule).
32
- 4. **Alias-only combos**: member `{"kind":"alias"}` carries no `model`/`modelId` — indexing members by `model` string must not crash. Pinned by Task 3.
33
- 5. **403 `account_blocked` swallowing**: refresh must surface the ban reason verbatim, not a generic "refresh failed". Pinned by Task 1 (fetchJson error extraction preserves `error.code`/`error.message`); Task 5 step 4 exercises the no-credential path end to end.
34
-
35
- ---
36
-
37
- ### Task 1: Project scaffold + HTTP helper
38
-
39
- **Files:**
40
- - Create: `package.json`, `tsconfig.json`, `.gitignore`
41
- - Create: `src/inferhub.ts`
42
- - Create: `src/http.ts`
43
- - Test: `test/http.test.ts`
44
-
45
- **Interfaces:**
46
- - Consumes: nothing (first task).
47
- - Produces: `PROVIDER_NAME`, `PROVIDER_DISPLAY_NAME`, `INFERENCE_BASE_URL`, `MGMT_BASE_URL` from `src/inferhub.ts`; `fetchJson<T>(url: string, token: string, schema: TSchema, signal?: AbortSignal): Promise<T>` from `src/http.ts` (typebox-validated; throws `InferhubHttpError` with `status`, `code`, `message` on non-2xx).
48
-
49
- - [ ] **Step 1: Write package.json, tsconfig, .gitignore**
50
-
51
- package.json fields pinned by Global Constraints; tsconfig: `module: "NodeNext"`, `moduleResolution: "NodeNext"`, `target: ES2022`, `strict: true`, `noEmit: true`, `skipLibCheck: true`. `.gitignore`: `node_modules/`.
52
-
53
- - [ ] **Step 2: Write `src/inferhub.ts` with the four constants**
54
-
55
- ```ts
56
- export const PROVIDER_NAME = "inferhub";
57
- export const PROVIDER_DISPLAY_NAME = "InferHub";
58
- export const INFERENCE_BASE_URL = "https://api.inferhub.dev/v1";
59
- export const MGMT_BASE_URL = "https://inferhub.dev/api";
60
- ```
61
-
62
- - [ ] **Step 3: Write the failing tests for `fetchJson`**
63
-
64
- ```ts
65
- import { describe, expect, it, vi } from "vitest";
66
- import { Type } from "typebox";
67
- import { Compile } from "typebox/compile";
68
- import { fetchJson, InferhubHttpError } from "../src/http.js";
69
-
70
- const Payload = Type.Object({ object: Type.String() });
71
- const Validator = Compile(Payload);
72
-
73
- function respond(status: number, body: string, headers: Record<string, string> = {}): Response {
74
- const h = new Headers({ "content-type": "application/json", ...headers });
75
- return new Response(body, { status, headers: h });
76
- }
77
-
78
- describe("fetchJson", () => {
79
- it("sends bearer auth and user agent, parses and validates JSON", async () => {
80
- const fetchMock = vi.fn(async () => respond(200, JSON.stringify({ object: "list" })));
81
- const result = await fetchJson("https://x/y", "sk-test", Validator, undefined, { fetchImpl: fetchMock as typeof fetch });
82
- expect(result).toEqual({ object: "list" });
83
- const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
84
- expect(url).toBe("https://x/y");
85
- expect((init.headers as Record<string, string>)["Authorization"]).toBe("Bearer sk-test");
86
- expect((init.headers as Record<string, string>)["User-Agent"]).toMatch(/^pi-inferhub\//);
87
- });
88
-
89
- it("throws InferhubHttpError with parsed code/message on error JSON", async () => {
90
- const fetchMock = vi.fn(async () => respond(403, JSON.stringify({ error: { code: "account_blocked", message: "banned: reason" } })));
91
- await expect(fetchJson("https://x/y", "k", Validator, undefined, { fetchImpl: fetchMock as typeof fetch }))
92
- .rejects.toMatchObject({ status: 403, code: "account_blocked", message: "banned: reason" });
93
- });
94
-
95
- it("aborts via signal and surfaces non-JSON error bodies as raw text", async () => {
96
- const controller = new AbortController();
97
- const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
98
- controller.abort();
99
- throw new DOMException("aborted", "AbortError");
100
- });
101
- await expect(fetchJson("https://x/y", "k", Validator, controller.signal, { fetchImpl: fetchMock as typeof fetch }))
102
- .rejects.toMatchObject({ name: "AbortError" });
103
- const fetchMock2 = vi.fn(async () => respond(500, "<html>oops</html>"));
104
- await expect(fetchJson("https://x/y", "k", Validator, undefined, { fetchImpl: fetchMock2 as typeof fetch }))
105
- .rejects.toMatchObject({ status: 500, code: "http_500" });
106
- });
107
- });
108
- ```
109
-
110
- - [ ] **Step 4: Run tests to verify they fail**
111
-
112
- Run: `npx vitest run test/http.test.ts`
113
- Expected: FAIL — `src/http.js` does not exist.
114
-
115
- - [ ] **Step 5: Implement `src/http.ts`**
116
-
117
- `fetchJson<T>(url, token, schema, signal?, opts?)` where `opts: { fetchImpl?: typeof fetch }` is an injectable fetch for tests (default `globalThis.fetch`). Sends `Authorization: Bearer <token>`, `User-Agent: pi-inferhub/<version from package.json via createRequire>`, `Accept: application/json`. Non-2xx: parse body as JSON; extract `error.code`/`error.message` when present (OpenAI-style `{error:{message}}` without `code` → `code: "unknown"`), else raw text with `code: "http_<status>"`; throw `InferhubHttpError extends Error` with `status`, `code`, `message`. On 2xx: `schema.Decode`/validate, rethrow typebox validation failures as `InferhubHttpError` with `status: response.status`, `code: "schema_mismatch"`, and the validator's error text as `message`. Abort errors propagate untouched.
118
-
119
- - [ ] **Step 6: Run tests to verify they pass**
120
-
121
- Run: `npx vitest run test/http.test.ts`
122
- Expected: PASS
123
-
124
- - [ ] **Step 7: Typecheck and commit**
125
-
126
- ```bash
127
- npx tsc --noEmit
128
- git add package.json tsconfig.json .gitignore src/inferhub.ts src/http.ts test/http.test.ts
129
- git commit -m "feat: scaffold + bearer fetchJson helper"
130
- ```
131
-
132
- ### Task 2: Auth — env resolution + `/login inferhub`
133
-
134
- **Files:**
135
- - Create: `src/auth.ts`
136
- - Test: `test/auth.test.ts`
137
-
138
- **Interfaces:**
139
- - Consumes: `MGMT_BASE_URL` (Task 1), `fetchJson` (Task 1), pi-ai's `envApiKeyAuth` from `@earendil-works/pi-ai/compat`.
140
- - Produces: `loginInferhub(interaction: ProviderAuthInteraction): Promise<ApiKeyCredential>` — prompt for the key (`secret` prompt, placeholder `sk-airo-…`), validate via `GET MGMT_BASE_URL + "/me"` (401 → notify + re-prompt, max 3 attempts then throw), return `{ type: "api_key", key }`. Validation uses an injectable fetch (`opts` param, same pattern as `fetchJson`). Also re-exports `envApiKeyAuth("InferHub API key", ["INFERHUB_API_KEY"])` as `inferhubApiKeyAuth`.
141
-
142
- - [ ] **Step 1: Write failing tests**
143
-
144
- ```ts
145
- import { describe, expect, it, vi } from "vitest";
146
- import { loginInferhub } from "../src/auth.js";
147
-
148
- function interaction(responses: string[], notified: string[] = []) {
149
- let i = 0;
150
- return {
151
- signal: new AbortController().signal,
152
- prompt: vi.fn(async () => {
153
- const v = responses[i++];
154
- if (v === undefined) throw new Error("prompt exhausted");
155
- return v;
156
- }),
157
- notify: (e: { type: string; message: string }) => notified.push(`${e.type}: ${e.message}`),
158
- };
159
- }
160
-
161
- describe("loginInferhub", () => {
162
- it("validates the key against /me and returns an api_key credential", async () => {
163
- const fetchMock = vi.fn(async (_url: string, init?: RequestInit) =>
164
- new Response(JSON.stringify({}), { status: 200, headers: { "content-type": "application/json" } }));
165
- const result = await loginInferhub(interaction(["sk-airo-abc"]), { fetchImpl: fetchMock as typeof fetch });
166
- expect(result).toEqual({ type: "api_key", key: "sk-airo-abc" });
167
- expect((fetchMock.mock.calls[0][1]?.headers as Record<string, string>)["Authorization"]).toBe("Bearer sk-airo-abc");
168
- });
169
-
170
- it("re-prompts after a 401 and notifies, up to 3 attempts", async () => {
171
- const fetchMock = vi.fn(async () => new Response(JSON.stringify({ error: { code: "invalid_api_key", message: "bad key" } }), { status: 401 }));
172
- const notified: string[] = [];
173
- const result = await loginInferhub(interaction(["sk-bad", "sk-bad", "sk-airo-good"], notified), { fetchImpl: fetchMock as typeof fetch });
174
- expect(result.key).toBe("sk-airo-good");
175
- expect(notified.some((n) => n.startsWith("info:"))).toBe(true);
176
- });
177
-
178
- it("throws after 3 failed attempts", async () => {
179
- const fetchMock = vi.fn(async () => new Response("{}", { status: 401 }));
180
- await expect(loginInferhub(interaction(["a", "b", "c"]), { fetchImpl: fetchMock as typeof fetch })).rejects.toThrow(/three/);
181
- });
182
- });
183
- ```
184
-
185
- - [ ] **Step 2: Run tests to verify they fail**
186
-
187
- Run: `npx vitest run test/auth.test.ts`
188
- Expected: FAIL — `src/auth.js` does not exist.
189
-
190
- - [ ] **Step 3: Implement `src/auth.ts`**
191
-
192
- `loginInferhub(interaction, opts?)`: loop up to 3 attempts — `interaction.prompt({ type: "secret", message: "InferHub API key", placeholder: "sk-airo-…" })`; trim; `fetchJson(MGMT_BASE_URL + "/me", key, MeSchema, interaction.signal, opts)` where `MeSchema` accepts any object; on success return `{ type: "api_key", key }`; on `InferhubHttpError` with `status: 401`: `interaction.notify({ type: "info", message: "Key rejected (401). Check the key and try again." })`, continue; on 3rd failure throw `new Error("InferHub login failed after three attempts")`. Re-export `inferhubApiKeyAuth = envApiKeyAuth("InferHub API key", ["INFERHUB_API_KEY"])`.
193
-
194
- - [ ] **Step 4: Run tests to verify they pass**
195
-
196
- Run: `npx vitest run test/auth.test.ts`
197
- Expected: PASS
198
-
199
- - [ ] **Step 5: Typecheck and commit**
200
-
201
- ```bash
202
- npx tsc --noEmit
203
- git add src/auth.ts test/auth.test.ts
204
- git commit -m "feat: /login inferhub prompt-validate flow"
205
- ```
206
-
207
- ### Task 3: Catalog — combo → Model mapping
208
-
209
- **Files:**
210
- - Create: `src/catalog.ts`
211
- - Test: `test/catalog.test.ts`
212
-
213
- **Interfaces:**
214
- - Consumes: `INFERENCE_BASE_URL`, `MGMT_BASE_URL` (Task 1), `fetchJson` (Task 1).
215
- - Produces:
216
- - `fetchComboCatalog(token: string, signal?: AbortSignal, opts?: { fetchImpl?: typeof fetch }): Promise<Model<"openai-completions">[]>` — GETs `MGMT_BASE_URL + "/combos"` and `INFERENCE_BASE_URL + "/models"`, maps combos to models (see mapping rules below).
217
- - `combosToModels(combos: Combo[], catalogModels: CatalogModel[]): Model<"openai-completions">[]` — pure mapping function, exported for direct unit testing.
218
- - Typebox schemas + exported TS types: `ComboSchema`/`Combo` (id, name, slug, members[]), `MemberSchema`/`Member` (`kind: "model"` with `model: string` | `kind: "alias"` with `alias: string`), `CatalogModelSchema`/`CatalogModel` (id, `modality?`, `input_token_limit?`, `max_output_tokens?`, `reasoning_levels?: string[]`, `pricing?: { official_in, official_out }`).
219
-
220
- Mapping rules (verbatim from the spec):
221
- - `id = "combo/" + slug`; `name` = combo `name`, falling back to `slug` when empty/whitespace.
222
- - Resolvable member = member with `kind === "model"` whose `model` string matches a `CatalogModel.id`.
223
- - `reasoning` = any resolvable member has non-empty `reasoning_levels`; `thinkingLevelMap` = built from the union of advertised levels across resolvable members.
224
- - `contextWindow` = min of resolvable members' `input_token_limit` (where present), else `CONTEXT_WINDOW_FALLBACK = 128_000`; `maxTokens` = min of `max_output_tokens` (where present), else `MAX_TOKENS_FALLBACK = 8_192`.
225
- - `input` = `["text", "image"]` only if every resolvable member's `modality` includes `image` (and there is ≥1 resolvable member); else `["text"]`.
226
- - `cost` = per-direction min of resolvable members' `pricing.official_in`/`official_out`; `cacheRead`/`cacheWrite` = 0. If no resolvable member has pricing, `cost` = all-zeros.
227
- - Empty resolvable set (alias-only or all-missed-joins): `input: ["text"]`, `reasoning: true`, `cost` zeros, fallback limits.
228
- - Output order = input combo order; unknown extra payload fields pass validation.
229
- - Every model also carries `api: "openai-completions"`, `provider: PROVIDER_NAME`, `baseUrl: INFERENCE_BASE_URL`, `reasoning` flag per above.
230
-
231
- - [ ] **Step 1: Write failing tests**
232
-
233
- ```ts
234
- import { describe, expect, it } from "vitest";
235
- import type { CatalogModel, Combo } from "../src/catalog.js";
236
- import { combosToModels } from "../src/catalog.js";
237
- import { INFERENCE_BASE_URL, PROVIDER_NAME } from "../src/inferhub.js";
238
-
239
- const base = {
240
- input_token_limit: 200_000,
241
- max_output_tokens: 32_000,
242
- modality: "text,image",
243
- reasoning_levels: ["low", "high"],
244
- pricing: { official_in: 3.0, official_out: 15.0 },
245
- };
246
-
247
- const catalog: CatalogModel[] = [
248
- { id: "croc/glm-5.3", ...base },
249
- { id: "ag/claude-opus", ...base, modality: "text", input_token_limit: 100_000, max_output_tokens: 16_000, pricing: { official_in: 1.0, official_out: 5.0 } },
250
- ];
251
-
252
- const combo = (members: Combo["members"], overrides: Partial<Combo> = {}): Combo => ({
253
- id: "c1", name: "My Combo", slug: "my-combo", members, ...overrides,
254
- });
255
-
256
- describe("combosToModels", () => {
257
- it("maps id, name, provider, baseUrl, api", () => {
258
- const [m] = combosToModels([combo([{ kind: "model", model: "croc/glm-5.3" }])], catalog);
259
- expect(m.id).toBe("combo/my-combo");
260
- expect(m.name).toBe("My Combo");
261
- expect(m.provider).toBe(PROVIDER_NAME);
262
- expect(m.baseUrl).toBe(INFERENCE_BASE_URL);
263
- expect(m.api).toBe("openai-completions");
264
- });
265
-
266
- it("derives min limits, all-members image rule, and cheapest-member cost", () => {
267
- const [m] = combosToModels([combo([{ kind: "model", model: "croc/glm-5.3" }, { kind: "model", model: "ag/claude-opus" }])], catalog);
268
- expect(m.contextWindow).toBe(100_000);
269
- expect(m.maxTokens).toBe(16_000);
270
- expect(m.input).toEqual(["text"]); // one text-only member demotes the combo
271
- expect(m.reasoning).toBe(true);
272
- expect(m.cost).toEqual({ input: 1.0, output: 5.0, cacheRead: 0, cacheWrite: 0 });
273
- });
274
-
275
- it("falls back to slug when name is blank; empty name whitespace counts", () => {
276
- const [m] = combosToModels([combo([{ kind: "model", model: "croc/glm-5.3" }], { name: " " })], catalog);
277
- expect(m.name).toBe("my-combo");
278
- });
279
-
280
- it("skips unresolvable members and alias members; alias-only combo gets safe defaults", () => {
281
- const aliasOnly = combosToModels([combo([{ kind: "alias", alias: "glm-5" }])], catalog);
282
- expect(aliasOnly[0].input).toEqual(["text"]);
283
- expect(aliasOnly[0].reasoning).toBe(true);
284
- expect(aliasOnly[0].cost).toEqual({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 });
285
- expect(aliasOnly[0].contextWindow).toBe(128_000);
286
- expect(aliasOnly[0].maxTokens).toBe(8_192);
287
-
288
- const partial = combosToModels([combo([{ kind: "model", model: "ghost/gone" }, { kind: "model", model: "croc/glm-5.3" }])], catalog);
289
- expect(partial[0].contextWindow).toBe(200_000);
290
- expect(partial[0].input).toEqual(["text", "image"]);
291
- });
292
-
293
- it("preserves combo order and handles empty combo list", () => {
294
- const models = combosToModels([
295
- combo([{ kind: "model", model: "ag/claude-opus" }], { slug: "b-combo", name: "B" }),
296
- combo([{ kind: "model", model: "croc/glm-5.3" }], { slug: "a-combo", name: "A" }),
297
- ], catalog);
298
- expect(models.map((m) => m.id)).toEqual(["combo/b-combo", "combo/a-combo"]);
299
- expect(combosToModels([], catalog)).toEqual([]);
300
- });
301
-
302
- it("reasoning false when no member advertises levels", () => {
303
- const noLevels: CatalogModel = { id: "x/plain", modality: "text" };
304
- const [m] = combosToModels([combo([{ kind: "model", model: "x/plain" }])], [noLevels]);
305
- expect(m.reasoning).toBe(false);
306
- });
307
- });
308
- ```
309
-
310
- - [ ] **Step 2: Run tests to verify they fail**
311
-
312
- Run: `npx vitest run test/catalog.test.ts`
313
- Expected: FAIL — `src/catalog.js` does not exist.
314
-
315
- - [ ] **Step 3: Implement `src/catalog.ts`**
316
-
317
- Schemas with typebox per Interfaces; `combosToModels` is a pure function implementing the mapping rules; `fetchComboCatalog` runs the two GETs (Promise.all — independent), then `combosToModels(combos, models)`. `thinkingLevelMap`: build from the union of resolvable members' `reasoning_levels` — map each advertised level through `LOW_LEVEL_MAP`-style identity where the level string matches a pi thinking level (`minimal|low|medium|high|xhigh|max`), else `null` for unsupported pi levels. (Concrete: `{ off: "off", minimal: <"minimal"|"null">, low: ..., ... max }` — a level the combo advertises maps to itself, one it doesn't advertise maps to `null`.) Set `thinkingLevelMap` only when `reasoning` is true.
318
-
319
- - [ ] **Step 4: Run tests to verify they pass**
320
-
321
- Run: `npx vitest run test/catalog.test.ts`
322
- Expected: PASS
323
-
324
- - [ ] **Step 5: Typecheck and commit**
325
-
326
- ```bash
327
- npx tsc --noEmit
328
- git add src/catalog.ts test/catalog.test.ts
329
- git commit -m "feat: combo -> pi model catalog mapping"
330
- ```
331
-
332
- ### Task 4: Provider registration
333
-
334
- **Files:**
335
- - Create: `src/index.ts`
336
- - Test: `test/index.test.ts`
337
-
338
- **Interfaces:**
339
- - Consumes: everything above; pi-ai `createProvider`, `envApiKeyAuth` (via `inferhubApiKeyAuth`), `openAICompletionsApi` from `@earendil-works/pi-ai/compat`; `ProviderAuthInteraction` type from pi-ai.
340
- - Produces: default-export extension factory `export default function (pi: ExtensionAPI): void` that registers the provider and the `/login inferhub` command. `fetchModels(context: RefreshModelsContext): Promise<readonly ProviderModel<"openai-completions">[]>` = `fetchComboCatalog(token, context.signal)` with the token resolved from `context.credential` (`type === "api_key" ? credential.key : undefined`).
341
-
342
- - [ ] **Step 1: Write failing test**
343
-
344
- ```ts
345
- import { describe, expect, it, vi } from "vitest";
346
- import { createProviderHarness } from "./harness.js";
347
-
348
- describe("extension registration", () => {
349
- it("registers the inferhub provider with openai-completions api and combo fetch", async () => {
350
- const { pi, providers, commands } = createProviderHarness();
351
- const mod = await import("../src/index.js");
352
- mod.default(pi);
353
- expect(providers.has("inferhub")).toBe(true);
354
- expect(commands.has("login inferhub") || commands.has("/login inferhub")).toBe(true);
355
- });
356
- });
357
- ```
358
-
359
- (The harness shape is decided in Step 3 — the test asserts against whatever `createProviderHarness` records; if a mockable `ExtensionAPI` proves impractical, replace this test with a thin unit test over an exported `registerInferhubProvider(pi)` helper that takes a minimal `{ registerProvider, registerCommand }` interface. The assertion contract is: provider registered under `inferhub` with the combo fetch and env+login auth; `/login inferhub` command registered.)
360
-
361
- - [ ] **Step 2: Run test to verify it fails**
362
-
363
- Run: `npx vitest run test/index.test.ts`
364
- Expected: FAIL — `src/index.js` does not exist.
365
-
366
- - [ ] **Step 3: Implement `src/index.ts`**
367
-
368
- Wire per the hyper-provider pattern:
369
-
370
- ```ts
371
- import { createProvider, type ApiKeyCredential } from "@earendil-works/pi-ai";
372
- import { openAICompletionsApi } from "@earendil-works/pi-ai/compat";
373
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
374
- ```
375
-
376
- `registerInferhubProvider(pi)` calls `pi.registerProvider(createProvider({ id: PROVIDER_NAME, name: PROVIDER_DISPLAY_NAME, baseUrl: INFERENCE_BASE_URL, auth: { apiKey: inferhubApiKeyAuth }, models: [], fetchModels: async (context) => { const credential = context.credential; const token = credential?.type === "api_key" ? credential.key : undefined; if (!token) return []; return fetchComboCatalog(token, context.signal); }, api: openAICompletionsApi() }))` — note `models: []` baseline with the combo list arriving via `fetchModels`. Register `pi.registerCommand("inferhub-login", ...)` if pi's command API takes a single name (check `ExtensionAPI.registerCommand`'s signature at implementation time; if it supports a single command name only, the command is `inferhub-login`, not `/login inferhub` — the `/login <provider>` surface is provided by pi's auth system via `envApiKeyAuth`'s built-in `login` + our custom `loginInferhub` wired as `auth.apiKey.login`). Wire `inferhubApiKeyAuth`'s `login` to `loginInferhub` so `/login inferhub` routes through the prompt-validate-store flow.
377
-
378
- - [ ] **Step 4: Run test to verify it passes**
379
-
380
- Run: `npx vitest run test/index.test.ts`
381
- Expected: PASS
382
-
383
- - [ ] **Step 5: Typecheck and commit**
384
-
385
- ```bash
386
- npx tsc --noEmit
387
- git add src/index.ts test/index.test.ts
388
- git commit -m "feat: register inferhub provider"
389
- ```
390
-
391
- ### Task 5: Live smoke test + README
392
-
393
- **Files:**
394
- - Create: `README.md`
395
- - Modify: `~/.pi/agent/settings.json` (add `"pi-inferhub"` to packages — done manually by the user, not in code)
396
-
397
- **Interfaces:**
398
- - Consumes: the installed extension.
399
- - Produces: a verified-live registration; README documenting install, `/login inferhub`, model selection, refresh behavior, and the catalog-derivation rules (link to the spec).
400
-
401
- - [ ] **Step 1: Install the extension locally for pi to discover**
402
-
403
- Run: `cd ~/repos/pi-inferhub && npm install && npm pack --dry-run` (verify pack contents), then either register as a discovered extension for the smoke test: `pi -e ~/repos/pi-inferhub` (pi loads the extension via `pi.extensions` in package.json).
404
-
405
- - [ ] **Step 2: Live catalog refresh against the real account**
406
-
407
- Run: `pi -e ~/repos/pi-inferhub --list-models 2>&1 | head -50` (or the equivalent list-models invocation; check `pi --help` for the exact flag).
408
- Expected: `inferhub` provider appears; models are exactly `combo/<slug>` entries matching the account's combos at https://inferhub.dev/dashboard (cross-check combo count and slugs against the dashboard).
409
-
410
- - [ ] **Step 3: One cheap live inference through a combo**
411
-
412
- Run: start `pi -e ~/repos/pi-inferhub`, pick the cheapest combo, send "Reply with the single word: ok".
413
- Expected: a reply streams back; `usage.cost` reflects in the session (verify pi's cost display shows a nonzero estimate; the authoritative check is that the request completes with text output).
414
-
415
- - [ ] **Step 4: Negative check — no key, clear error**
416
-
417
- Run: `INFERHUB_API_KEY= pi -e ~/repos/pi-inferhub --list-models` (env unset, no stored credential).
418
- Expected: provider appears with zero models or a clear auth-required error — never a crash or a silent empty catalog masquerading as "no combos".
419
-
420
- - [ ] **Step 5: Write README**
421
-
422
- Document: what the provider exposes (combos only), install (`npm:pi-inferhub` in settings.json packages, or `pi -e` for local dev), auth (env var or `/login inferhub`), refresh semantics (on-demand, no persistence), cost estimate semantics (cheapest-member official rates; real `usage.cost` runs at-or-under), combo fallback limits, and a link to the spec.
423
-
424
- - [ ] **Step 6: Commit**
425
-
426
- ```bash
427
- git add README.md
428
- git commit -m "docs: README"
429
- ```
430
-
431
- ---
432
-
433
- ## Execution notes
434
-
435
- - pi subagent dispatch policy: do NOT pin models; `~/.pi/agent/settings.json` `subagents.agentOverrides` is authoritative (worker=gateway/glm-5.3, reviewer=gateway/kimi-k3).
436
- - Task 4's test may collapse to the `registerInferhubProvider` thin-helper variant if mocking `ExtensionAPI` is impractical — the implementer decides and records why in the task report.
437
- - The full-suite runs (vitest) are fast (< 5s); no watchdog concerns.
438
- - Live smoke (Task 5) needs the user's real `INFERHUB_API_KEY` or a stored credential; the executor should hand off to the user for the interactive `/login` step if no credential is stored.