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.
- package/LICENSE +21 -0
- package/README.md +43 -11
- package/package.json +22 -4
- package/src/catalog.ts +98 -20
- package/src/config.ts +75 -0
- package/src/index.ts +11 -4
- package/AGENTS.md +0 -146
- package/docs/superpowers/plans/2026-10-06-inferhub-provider.md +0 -438
- package/docs/superpowers/specs/2026-10-06-inferhub-provider-design.md +0 -207
- package/pi-inferhub-0.2.0.tgz +0 -0
- package/test/auth.test.ts +0 -43
- package/test/catalog.test.ts +0 -123
- package/test/http.test.ts +0 -43
- package/test/index.test.ts +0 -159
- package/tsconfig.json +0 -14
|
@@ -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.
|
package/pi-inferhub-0.2.0.tgz
DELETED
|
Binary file
|