pi-inferhub 0.2.0 → 0.3.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.
@@ -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.
@@ -1,207 +0,0 @@
1
- # InferHub provider extension for pi — design
2
-
3
- Date: 2026-10-06
4
- Status: approved design, pre-plan
5
- Repo: `~/repos/pi-inferhub` (fresh, main has no commits)
6
-
7
- ## Purpose
8
-
9
- A pi provider extension exposing the user's [InferHub](https://inferhub.dev)
10
- **combos** as selectable chat models. InferHub pools AI subscriptions into one
11
- OpenAI-compatible endpoint; combos are the user's primary consumption surface:
12
- each `combo/<slug>` is a server-side routed, failover, cache-sticky bundle of
13
- upstream models billed in USDC.
14
-
15
- Scope is deliberately narrow: **basic inference + catalog only.** No MCP, no
16
- publisher/marketplace, no billing or budget management, no image/video
17
- generation, no status widgets.
18
-
19
- ## API surface facts (verified against https://inferhub.dev/api/openapi.json)
20
-
21
- - **Inference**: `https://api.inferhub.dev` — OpenAI (`/v1/chat/completions`)
22
- and Anthropic (`/v1/messages`) compatible, both streamable, Go proxy. Auth:
23
- `Authorization: Bearer sk-airo-…` (Anthropic-style `x-api-key` also
24
- accepted). Rate limit: 500 req/min per account, `X-RateLimit-*` headers,
25
- OpenAI/Anthropic-shaped 429s with `Retry-After`.
26
- - **Management API**: `https://inferhub.dev/api` — same `sk-airo-` key, Bearer
27
- header. Rate limit: 30 req/min per account (shared with MCP). Errors:
28
- `{"error":{"code","message"}}`; suspended/banned accounts return 403
29
- `account_blocked` on every endpoint.
30
- - **`GET /v1/models`** (inference surface, auth required): returns
31
- `prefix/model` ids, bare aliases, `free/<slug>/<model>`, and image models —
32
- but **combos do not appear**. Model items carry: `id`, `supports_cache`,
33
- `modality` (e.g. `"text"` or `"text,image"`), `output_modality`,
34
- `input_token_limit`, `max_output_tokens`, `reasoning_levels` (e.g.
35
- `["low","medium","high"]`), `upstream_label`, and a `pricing` object
36
- (`official_in`/`official_out` required; `min_ask_in`/`min_ask_out` when
37
- supplied by the marketplace). Pricing omitted for aliases.
38
- - **`GET /combos`** (management surface): the account's combos. Each has:
39
- `id` (uuid), `name`, `slug`, `maxInputPerMtok`/`maxOutputPerMtok` (decimal
40
- string $/Mtok caps, or `null` = unlimited), `selection`
41
- (`order|cheapest|fastest|balanced|reliable`), performance filters
42
- (`maxTtftMs`, `minTps`, `minSuccessPct`), `sticky` (bool), `members[]`.
43
- Member kinds:
44
- - `{"kind":"model", modelId: uuid, model: "<prefix>/<upstream_model_id>",
45
- label}` — resolvable against the catalog.
46
- - `{"kind":"alias", alias: "<bare-name>"}` — auto-routed alias, **no**
47
- catalog linkage, no pricing.
48
- - **`GET /catalog`** (management surface): upstreams (`prefix`, `slug`,
49
- `label`, enabled flags, `systemPromptNote`) each with `models[]` keyed by
50
- `upstreamModelId` and carrying `officialIn`/`officialOut` (decimal strings,
51
- USD per Mtok), `asksIn`/`asksOut`, `supportsCache`, `cacheHitRate`, and
52
- enabled/disabled flags. **Not used by the extension**: it lacks the
53
- capability fields (`reasoning_levels`, input/output limits, modality) the
54
- mapping needs — those come from `GET /v1/models`, which carries the same
55
- official rates in numeric form.
56
- - **Money**: USDC decimal strings on the management surface; inference reports
57
- per-request consumer cost as `usage.cost` in USD (OpenRouter convention),
58
- with a $0.00001 minimum charge on billed requests.
59
-
60
- ## Approach
61
-
62
- Register one provider via pi-ai's `createProvider` with
63
- `api: openAICompletionsApi()`. Every combo streams through
64
- `https://api.inferhub.dev/v1/chat/completions` — InferHub's proxy translates
65
- to every upstream family server-side, so pi's built-in OpenAI Chat Completions
66
- implementation owns message conversion, tool handling, streaming, usage,
67
- cancellation, and retry semantics. **No custom `streamSimple`.**
68
-
69
- The Anthropic-native surface (`/v1/messages`) is intentionally unused in v1:
70
- the OpenAI surface is InferHub's primary advertised interface and combos are
71
- upstream-agnostic from the client's perspective.
72
-
73
- ### Auth
74
-
75
- - `envApiKeyAuth("InferHub API key", ["INFERHUB_API_KEY"])`.
76
- - A `/login inferhub` OAuth-less flow: prompt for the `sk-airo-…` key,
77
- validate it against `GET https://inferhub.dev/api/me` (401 → re-prompt),
78
- then hand it to pi's credential store (`auth.json`). Env remains the
79
- no-stored-credential fallback.
80
-
81
- ### Catalog (`refreshModels`)
82
-
83
- `fetchComboCatalog({ signal, token })` makes two authenticated GETs (well
84
- under every rate budget):
85
-
86
- 1. `GET /combos` (management surface) — the account's combos, order preserved.
87
- 2. `GET /v1/models` (inference surface) — per-model metadata used for member
88
- joins: `reasoning_levels`, `input_token_limit`, `max_output_tokens`,
89
- `modality`, and `pricing.official_in/out` (same official rates as the
90
- management `/catalog` endpoint, in numeric form; `/catalog` itself is not
91
- called — it lacks the capability fields the mapping needs).
92
-
93
- Each combo becomes exactly one `Model<"openai-completions">`:
94
-
95
- - `id = "combo/" + slug` (the routable inference name; bare-name aliases are
96
- not exposed).
97
- - `name` = combo `name` (fallback: slug when name is empty).
98
- - `reasoning` = any resolvable member advertises non-empty
99
- `reasoning_levels`; `thinkingLevelMap` maps pi's thinking levels onto the
100
- advertised levels.
101
- - `contextWindow` = min of resolvable members' `input_token_limit`;
102
- `maxTokens` = min of their `max_output_tokens`. pi-ai's `Model` type
103
- requires both as numbers, so when no resolvable member supplies a field,
104
- conservative fallback constants apply (`CONTEXT_WINDOW_FALLBACK = 128_000`,
105
- `MAX_TOKENS_FALLBACK = 8_192`) rather than a guess at the model's ceiling.
106
- - `input` = `["text", "image"]` only when **every** resolvable member's
107
- `modality` includes image; otherwise `["text"]`. A combo is only as
108
- multimodal as its weakest member.
109
- - `cost` = per-direction min of resolvable members' `pricing.official_in`/
110
- `official_out`. This is a conservative estimate: InferHub bills consumer
111
- requests at a discount below official list, so real `usage.cost` will
112
- typically come in at or under this number. Never fabricate a cache-write
113
- price: `cacheRead`/`cacheWrite` = 0 unless the catalog supplies them.
114
- - Combo-level metadata (`maxInputPerMtok`, `maxOutputPerMtok`, `selection`,
115
- filters, `sticky`, members) is **extension-owned state only** — it is not
116
- placed on the pi-ai `Model` object (reserved-key play-safe ruling).
117
- - `supports_cache`: not forwarded as a Model field; pi's cache-warming knobs
118
- (`promptCache.short/long`) stay unset in v1.
119
-
120
- **Alias-member fallback:** alias members cannot be joined to `/v1/models`.
121
- They are skipped for pricing/feature derivation. If a combo's resolvable
122
- (model-kind) member set is empty, expose it with safe defaults: `input:
123
- ["text"]`, `reasoning: true` (aliases typically route to reasoning-capable
124
- models; a false negative would hide thinking levels, a false positive merely
125
- shows an inert knob), no cost, and the conservative fallback limits.
126
-
127
- **Disabled/upstream-absent members:** catalog entries whose upstream is
128
- `upstreamDisabled` or missing still count for joins (the combo may still
129
- route); their pricing/feature data is used like any other member. The combo
130
- either works or 4xx/5xxs at request time — pi shows the provider error.
131
-
132
- ### Refresh semantics
133
-
134
- - `fetchModels` fetches and returns the combo `Model[]` (complete-`Provider`
135
- contract, per the pi-hyper-provider pattern: pi composes the returned list
136
- over the registered provider and handles persistence requests).
137
- - No persistence of the catalog across runs: pi starts, the user picks a
138
- combo, refresh happens on demand. Combos change on the server; a stale
139
- snapshot is worse than an explicit refresh. (YAGNI: no offline snapshot.)
140
- - Refresh errors surface as provider error strings via pi's normal
141
- model-picker refresh path. Do not swallow 401/403: surface
142
- `account_blocked` verbatim — its message carries the ban reason.
143
-
144
- ### Layout
145
-
146
- ```
147
- ~/repos/pi-inferhub/
148
- package.json # name pi-inferhub, type module, pi.extensions: ["./src/index.ts"]
149
- tsconfig.json
150
- src/
151
- index.ts # extension factory: registerProvider + /login inferhub
152
- auth.ts # env auth + login flow (prompt → validate via GET /me → store)
153
- inferhub.ts # constants: INFERENCE_BASE_URL, MGMT_BASE_URL, PROVIDER_NAME
154
- catalog.ts # fetchComboCatalog: /combos + /catalog → Model[]
155
- http.ts # fetchJson helper: Bearer, AbortSignal, UA string
156
- test/
157
- catalog.test.ts # mapping fixtures (see Testing)
158
- auth.test.ts
159
- ```
160
-
161
- Streaming delegation imports `openAICompletionsApi` from
162
- `@earendil-works/pi-ai/compat` (per the pi-hyper-provider pattern — pi's
163
- extension loader does not virtualize lazy API implementation subpaths).
164
- Dependencies stay at `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`
165
- (types), `typebox` + `typebox/compile` for response validation, vitest for
166
- tests. Distribution: npm package; loaded via `npm:pi-inferhub` in
167
- `~/.pi/agent/settings.json` packages.
168
-
169
- ## Error handling
170
-
171
- - Inference errors: pi-native via the built-in OpenAI implementation (429 +
172
- `Retry-After`, transient retries, context overflow recognition).
173
- - Catalog refresh: any non-2xx surfaces an error message; HTTP 403 with
174
- `account_blocked` passes the upstream message through; network failure is
175
- reported, never silently cached.
176
- - Empty catalog (zero combos): valid state — provider registers with zero
177
- models; pi renders an empty model list rather than erroring.
178
-
179
- ## Testing
180
-
181
- Vitest unit tests over fixture JSON shaped exactly like the documented
182
- endpoint responses:
183
-
184
- - combo → model mapping: `id`/`name`, order preservation, slug fallback for
185
- empty names.
186
- - feature derivation: min context/output across members; image-input rule
187
- (all-members-image vs one-text-member); reasoning derivation.
188
- - cost derivation: min official across members; zero-cost when no resolvable
189
- members; alias-only combo fallback defaults.
190
- - member kinds: model-kind join success; alias members skipped; empty
191
- resolvable set.
192
- - malformed/edge payloads: missing `pricing`, `null` caps, missing optional
193
- fields (via typebox validation).
194
- - auth: validation flow logic (401 → reprompt, success → store); env fallback.
195
- - HTTP helper: abort signal honored, UA header present.
196
-
197
- Live smoke (manual, end of implementation): register provider, refresh
198
- catalog against the real account, one cheap echo request on a cheap combo,
199
- confirm `usage.cost` lands in pi's usage accounting.
200
-
201
- ## Explicitly out of scope (v1)
202
-
203
- - Anthropic `/v1/messages` surface, image/video generation endpoints, free
204
- tier, bare aliases, prefix models as pi models.
205
- - Combo management (create/update/delete via management API), budgets, usage
206
- dashboards, status widgets, credit/balance notifications.
207
- - Catalog persistence, cache-warming configuration, background refresh.
Binary file