@billjr99/pi-openai-compat 1.1.31 → 1.1.33

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/README.md CHANGED
@@ -68,6 +68,10 @@ If pi is already running when you install, type `/reload` first.
68
68
  | **Token Harbor** | `https://tokenharbor.ai/v1` | `thk_live_...` Universal Key from tokenharbor.ai/dashboard/api-keys |
69
69
  | **Atria ASI** | `https://api.atria-asi.ai/v1` | `atr_...` key from api.atria-asi.ai/console/keys (Google sign-in) |
70
70
  | **Unbiased AI** | `https://api.unbiased.ai/v1` | `sk_...` key from platform.unbiased.ai (signup reviewed by hand) |
71
+ | **Kilo AI** | `https://api.kilo.ai/api/gateway` | API key from app.kilo.ai → Your Profile (personal account), at the bottom of the page; needs account credits |
72
+ | **ModelScope** | `https://api-inference.modelscope.cn/v1` | `ms-...` SDK token from modelscope.cn/my/myaccesstoken |
73
+ | **Aion Labs** | `https://api.aionlabs.ai/v1` | API key from aionlabs.ai/app/api-keys (aionlabs.ai/docs) |
74
+ | **Agnes AI** | `https://apihub.agnes-ai.com/v1` | `sk-...` key from the Agnes console at platform.agnes-ai.com (wiki.agnes-ai.com) |
71
75
  | **Ollama (local)** | `http://localhost:11434/v1` (editable) | Optional bearer token; leave blank for a default local install |
72
76
  | **Ollama Cloud** | `https://ollama.com/v1` | Ollama Cloud API key from ollama.com |
73
77
  | **llmproxy** | `http://localhost:8080/v1` (editable) | Keyless by default; bearer token if your instance requires one |
@@ -84,7 +88,12 @@ If pi is already running when you install, type `/reload` first.
84
88
  > 2. **Built-in fallback list** when no working catalog endpoint exists. Used
85
89
  > when discovery fails or when the upstream simply has no `/models` at all
86
90
  > (e.g. **Hugging Face** returns HTML, **Cloudflare AI Gateway** has no
87
- > catalog endpoint).
91
+ > catalog endpoint, **Unbiased AI** has no such route).
92
+ >
93
+ > A fallback list stands in for a missing endpoint, never for a rejected
94
+ > credential: a catalog fetch that fails with 401 or 403 aborts the login
95
+ > instead, so a mistyped key cannot be saved as a provider that looks healthy
96
+ > in `/model` and then fails on every completion.
88
97
  >
89
98
  > | Provider | Default `/models` symptom | Handling |
90
99
  > |---|---|---|
@@ -92,6 +101,7 @@ If pi is already running when you install, type `/reload` first.
92
101
  > | **Cloudflare Workers AI** | HTTP 405 (no `GET /v1/models`; real catalog at `/ai/models/search`, id field is `name`, mixed task types) | Live discovery — `modelsUrl: …/ai/models/search`, `modelsIdField: name`, `modelsKeepTask: "Text Generation"` |
93
102
  > | **Cloudflare AI Gateway** | HTTP 401 (token missing `AI Gateway: Run`) or HTTP 400 *"Please configure AI Gateway"* (gateway slug doesn't exist, or the upstream isn't configured on it) | Built-in fallback list |
94
103
  > | **Hugging Face** | Returns HTML rather than JSON | Built-in fallback list |
104
+ > | **Unbiased AI** | HTTP 404 `unknown_url` with a valid key (no `/v1/models` at all; an *invalid* key returns 401 on every path, including ones that do not exist, so a 401 here proves nothing) | Built-in fallback list |
95
105
  >
96
106
  > **Auto-heal for older configs:** if you logged in before live discovery
97
107
  > existed, your saved provider is missing these `modelsUrl`/`modelsIdField`/
package/add-provider.sh CHANGED
@@ -72,6 +72,30 @@ const BUILTIN = {
72
72
  keyless: false,
73
73
  keyHint: "platform.unbiased.ai (signup is reviewed by hand; keys look like sk_...)",
74
74
  },
75
+ kilo: {
76
+ displayName: "Kilo AI",
77
+ baseUrl: "https://api.kilo.ai/api/gateway",
78
+ keyless: false,
79
+ keyHint: "app.kilo.ai → Your Profile on your personal account (not an organization), at the bottom of the page; needs account credits (docs at kilo.ai/docs/gateway)",
80
+ },
81
+ modelscope: {
82
+ displayName: "ModelScope",
83
+ baseUrl: "https://api-inference.modelscope.cn/v1",
84
+ keyless: false,
85
+ keyHint: "modelscope.cn/my/myaccesstoken (SDK token, format ms-...; docs at modelscope.cn/docs/model-service/API-Inference/intro)",
86
+ },
87
+ aion_labs: {
88
+ displayName: "Aion Labs",
89
+ baseUrl: "https://api.aionlabs.ai/v1",
90
+ keyless: false,
91
+ keyHint: "aionlabs.ai/app/api-keys (docs at aionlabs.ai/docs/api-reference)",
92
+ },
93
+ agnes_ai: {
94
+ displayName: "Agnes AI",
95
+ baseUrl: "https://apihub.agnes-ai.com/v1",
96
+ keyless: false,
97
+ keyHint: "platform.agnes-ai.com → API Key management (keys look like sk-...; docs at wiki.agnes-ai.com)",
98
+ },
75
99
  };
76
100
 
77
101
  // README "Auth" column text for the built-in providers, used only by patchreadme.
@@ -82,6 +106,10 @@ const BUILTIN_AUTH = {
82
106
  tokenharbor: "`thk_live_...` Universal Key from tokenharbor.ai/dashboard/api-keys",
83
107
  atria_asi: "`atr_...` key from api.atria-asi.ai/console/keys (Google sign-in)",
84
108
  unbiased_ai: "`sk_...` key from platform.unbiased.ai (signup reviewed by hand)",
109
+ kilo: "API key from app.kilo.ai → Your Profile (personal account), at the bottom of the page; needs account credits",
110
+ modelscope: "`ms-...` SDK token from modelscope.cn/my/myaccesstoken",
111
+ aion_labs: "API key from aionlabs.ai/app/api-keys (aionlabs.ai/docs)",
112
+ agnes_ai: "`sk-...` key from the Agnes console at platform.agnes-ai.com (wiki.agnes-ai.com)",
85
113
  };
86
114
 
87
115
  /**
@@ -158,8 +186,9 @@ function normalizeModels(body, idField, keepTask) {
158
186
  else if (json && typeof json === "object") {
159
187
  if (Array.isArray(json.data)) raw = json.data;
160
188
  else if (Array.isArray(json.result)) raw = json.result;
189
+ else if (Array.isArray(json.models)) raw = json.models;
161
190
  }
162
- if (!raw) throw new Error('expected an array or an object with a "data" or "result" array');
191
+ if (!raw) throw new Error('expected an array or an object with a "data", "result" or "models" array');
163
192
  const field = idField || "id";
164
193
  return raw
165
194
  .filter((m) => {
@@ -436,6 +465,17 @@ fi
436
465
  MODEL_COUNT=0
437
466
  [ -n "$MODELS_JSON" ] && MODEL_COUNT="$(printf '%s' "$MODELS_JSON" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(String(JSON.parse(s).length)))')"
438
467
 
468
+ # Mirrors isAuthFailure() in index.ts: a fallback list stands in for an endpoint
469
+ # that is not there, never for a credential that was refused. Falling back on a
470
+ # 401/403 would write a provider that looks healthy in /model and fails on every
471
+ # completion, which is worse than saying the key was rejected.
472
+ if [ "$MODEL_COUNT" = "0" ] && { [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "403" ]; }; then
473
+ echo " The provider rejected the API key (HTTP $HTTP_CODE)."
474
+ echo " Not using the built-in model list: that would save a provider whose"
475
+ echo " every request fails. Re-run with a valid key."
476
+ exit 1
477
+ fi
478
+
439
479
  if [ "$MODEL_COUNT" = "0" ]; then
440
480
  if [ -n "$FALLBACK_MODELS" ]; then
441
481
  echo " Using this template's built-in model list instead."
package/index.ts CHANGED
@@ -413,11 +413,69 @@ export const TEMPLATES: Record<string, {
413
413
  // answers both /v1/chat/completions and Anthropic's /v1/messages and takes
414
414
  // either Authorization: Bearer or x-api-key; pi is registered with the
415
415
  // OpenAI pair, which is what registerProvider and fetchModels already send.
416
- // GET /v1/models is registered and key-gated (a well-formed sk_ key gets
417
- // "Invalid API key" rather than a format complaint), so no modelsUrl or
418
- // fallbackModels override is needed.
416
+ //
417
+ // GET /v1/models does NOT exist: with a valid key it returns 404
418
+ // "unknown_url". An earlier note here claimed the endpoint was registered
419
+ // and key-gated, reasoning from a well-formed but invalid key getting
420
+ // "Invalid API key" rather than a 404. That inference was wrong. The
421
+ // gateway authenticates before it routes, so a path that certainly does
422
+ // not exist returns the same 401: GET /v1/definitely-not-a-real-endpoint
423
+ // answers "Invalid API key" with a bad key and "Missing API key" without
424
+ // one, exactly as /v1/models does. A 401 here says nothing about routing.
425
+ //
426
+ // Discovery therefore finds nothing, which is what fallbackModels is for.
427
+ // It is safe here because the catch that consumes it now refuses to fall
428
+ // back on a 401/403 (see isAuthFailure), so a mistyped key still fails the
429
+ // login instead of being saved as a provider that fails every completion.
430
+ fallbackModels: ["pareto"],
419
431
  keyHint: "platform.unbiased.ai (signup is reviewed by hand; keys look like sk_...)",
420
432
  },
433
+ kilo: {
434
+ displayName: "Kilo AI",
435
+ baseUrl: "https://api.kilo.ai/api/gateway",
436
+ keyless: false,
437
+ // Multi-vendor gateway; the base path is /api/gateway, not /v1. GET
438
+ // /api/gateway/models answers 200 without a key and returns an
439
+ // OpenRouter-shaped {"data": [...]} catalog (~380 models, ids like
440
+ // anthropic/claude-sonnet-5), so no modelsUrl or fallbackModels override
441
+ // is needed. Inference itself needs a key and account credits.
442
+ keyHint: "app.kilo.ai → Your Profile on your personal account (not an organization), at the bottom of the page; needs account credits (docs at kilo.ai/docs/gateway)",
443
+ },
444
+ modelscope: {
445
+ displayName: "ModelScope",
446
+ baseUrl: "https://api-inference.modelscope.cn/v1",
447
+ keyless: false,
448
+ // Alibaba's API-Inference service. GET /v1/models answers 200 without a
449
+ // key in the plain OpenAI {"data": [...]} shape, so the default discovery
450
+ // path works as is. Free tier is an account-wide 2,000 requests/day.
451
+ keyHint: "modelscope.cn/my/myaccesstoken (SDK token, format ms-...; docs at modelscope.cn/docs/model-service/API-Inference/intro)",
452
+ },
453
+ aion_labs: {
454
+ displayName: "Aion Labs",
455
+ baseUrl: "https://api.aionlabs.ai/v1",
456
+ keyless: false,
457
+ // Aion's own API reference documents GET /v1/models as needing no
458
+ // authentication and returning its array under a top-level "models" key
459
+ // rather than "data". fetchModels normalizes that shape, so no modelsUrl
460
+ // or fallbackModels override is needed. Four models as of
461
+ // September 2026 (aion-2.0, aion-3.0, aion-3.0-mini, aion-rp-llama-3.1-8b).
462
+ keyHint: "aionlabs.ai/app/api-keys (docs at aionlabs.ai/docs/api-reference)",
463
+ },
464
+ agnes_ai: {
465
+ displayName: "Agnes AI",
466
+ baseUrl: "https://apihub.agnes-ai.com/v1",
467
+ keyless: false,
468
+ // Multimodal gateway. GET /v1/models is key-gated (it answers 401 "Token
469
+ // not provided", where an unknown /v1 path answers 404), which is exactly
470
+ // what fetchModels already sends, so no override is needed. The catalog
471
+ // also carries image and video model ids (agnes-image-*, agnes-video-*);
472
+ // they are registered too and will show up in /model, but only the text
473
+ // models serve chat/completions: the free flash tier (agnes-3.0-flash,
474
+ // agnes-2.5-flash, agnes-2.0-flash) and the paid agnes-2.5-pro pair. No
475
+ // modelFilter is set because the live id list could not be confirmed
476
+ // without a key, and a filter that misses would register nothing.
477
+ keyHint: "platform.agnes-ai.com → API Key management (keys look like sk-...; docs at wiki.agnes-ai.com)",
478
+ },
421
479
  custom: {
422
480
  displayName: "Custom Endpoint",
423
481
  baseUrl: "",
@@ -610,6 +668,24 @@ export function isLocalUrl(url: string): boolean {
610
668
  /** Cap on how much of an upstream error body is surfaced to the user. */
611
669
  export const MAX_ERROR_BODY = 500;
612
670
 
671
+ /** An Error from fetchModels carrying the upstream HTTP status, when it had one. */
672
+ export type CatalogError = Error & { status?: number };
673
+
674
+ /**
675
+ * True when a catalog fetch failed because the credential was rejected.
676
+ *
677
+ * This is the distinction that makes fallbackModels safe to use. A fallback
678
+ * list exists for endpoints that publish no catalog, so it should stand in for
679
+ * a missing endpoint and never for a bad key: falling back on a 401 would save
680
+ * a provider that looks healthy in /model and fails on every completion, which
681
+ * is worse than refusing the login. A network error (no status) is treated as
682
+ * non-auth, so an offline or unreachable host still gets the fallback.
683
+ */
684
+ export function isAuthFailure(err: unknown): boolean {
685
+ const status = (err as CatalogError | null)?.status;
686
+ return status === 401 || status === 403;
687
+ }
688
+
613
689
  /** Optional per-provider overrides controlling how /models is fetched. */
614
690
  interface FetchOverrides {
615
691
  /** Full URL to fetch instead of `<baseUrl>/models`. */
@@ -646,28 +722,35 @@ export async function fetchModels(
646
722
  const snippet = body.length > MAX_ERROR_BODY
647
723
  ? `${body.slice(0, MAX_ERROR_BODY)}… (truncated)`
648
724
  : body;
649
- throw new Error(`HTTP ${resp.status} from ${url}: ${snippet}`);
725
+ // Carry the status on the error. Callers need to tell "this endpoint is
726
+ // not there" from "your key is wrong", and parsing it back out of the
727
+ // message would break the moment the wording changes.
728
+ const err = new Error(`HTTP ${resp.status} from ${url}: ${snippet}`) as CatalogError;
729
+ err.status = resp.status;
730
+ throw err;
650
731
  }
651
732
 
652
733
  // Normalize the various shapes /models can return:
653
734
  // - OpenAI style: {"data": [...]}
654
735
  // - Cloudflare / some gateways: {"result": [...]}
736
+ // - Aion Labs: {"models": [...]}
655
737
  // - Together (and a few others): [...] (bare JSON array)
656
738
  const json = (await resp.json()) as unknown;
657
739
  let raw: RawModel[] | undefined;
658
740
  if (Array.isArray(json)) {
659
741
  raw = json as RawModel[];
660
742
  } else if (json && typeof json === "object") {
661
- const obj = json as { data?: unknown; result?: unknown };
743
+ const obj = json as { data?: unknown; result?: unknown; models?: unknown };
662
744
  if (Array.isArray(obj.data)) raw = obj.data as RawModel[];
663
745
  else if (Array.isArray(obj.result)) raw = obj.result as RawModel[];
746
+ else if (Array.isArray(obj.models)) raw = obj.models as RawModel[];
664
747
  }
665
748
  if (!raw) {
666
749
  // `url` may be an override (e.g. /catalog/models, /ai/models/search), so
667
750
  // keep the wording generic rather than referring specifically to /models.
668
751
  throw new Error(
669
752
  `Unexpected model catalog payload shape from ${url} ` +
670
- `(expected an array or an object with a "data" or "result" array).`
753
+ `(expected an array or an object with a "data", "result" or "models" array).`
671
754
  );
672
755
  }
673
756
 
@@ -984,7 +1067,7 @@ export default async function (pi: ExtensionAPI) {
984
1067
  keepTask: tpl.modelsKeepTask,
985
1068
  });
986
1069
  } catch (err) {
987
- if (tpl.fallbackModels && tpl.fallbackModels.length > 0) {
1070
+ if (tpl.fallbackModels && tpl.fallbackModels.length > 0 && !isAuthFailure(err)) {
988
1071
  ctx.ui.notify(
989
1072
  `Could not fetch model list from ${tpl.displayName} (${err}).\nUsing built-in model list instead.`,
990
1073
  "warning"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@billjr99/pi-openai-compat",
3
- "version": "1.1.31",
3
+ "version": "1.1.33",
4
4
  "description": "pi-coding-agent extension: OpenAI-compatible endpoint support (OpenRouter, NVIDIA NIM, Nous Portal, Ollama, custom)",
5
5
  "author": "Bill Mongan <https://github.com/BillJr99>",
6
6
  "license": "MIT",