@tejasa97/pi-orcarouter-provider 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 tejasa97
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @tejasa97/pi-orcarouter-provider
2
+
3
+ Pi provider package for [OrcaRouter](https://orcarouter.ai) — an OpenAI-compatible API gateway with 200+ models, including the free **GLM 5.3 Flash (Free)** tier (`z-ai/glm-5.3-flash-free`).
4
+
5
+ Auth is an API key stored by `/login` in `~/.pi/agent/auth.json`, with `ORCAROUTER_API_KEY` as a fallback. Model discovery uses `GET /v1/models` when a key is available, falling back to a small static catalog.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ pi install git:github.com/tejasa97/pi-orcarouter-provider
11
+ ```
12
+
13
+ For local development:
14
+
15
+ ```sh
16
+ pi install ./pi-orcarouter-provider
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ 1. Create an API key in the [OrcaRouter console](https://www.orcarouter.ai/console)
22
+ 2. In Pi: `/login` → **OrcaRouter** → paste your API key
23
+ 3. `/model` → pick a model such as `orcarouter/z-ai/glm-5.3-flash-free`
24
+
25
+ Or skip `/login` by exporting the key in the shell that starts Pi:
26
+
27
+ ```sh
28
+ export ORCAROUTER_API_KEY="sk-orca-..."
29
+ pi
30
+ ```
31
+
32
+ Optional base URL override:
33
+
34
+ ```sh
35
+ export ORCAROUTER_BASE_URL="https://api.orcarouter.ai/v1"
36
+ ```
37
+
38
+ ## Models
39
+
40
+ Models are fetched dynamically from `GET https://api.orcarouter.ai/v1/models` when a key is configured. Chat-completions models are selected from the catalog:
41
+
42
+ - entries listing `"openai"` in `supported_endpoint_types`, plus
43
+ - free-tier stubs with a null endpoint list (e.g. `z-ai/glm-5.3-flash-free`, `deepseek/deepseek-v4-flash-free`), excluding obvious non-chat offerings (image/video/tts/embeddings by id).
44
+
45
+ The static fallback includes:
46
+
47
+ | Model | Reasoning | Input | Context | Max output |
48
+ | ----- | --------- | ----- | ------- | ---------- |
49
+ | `z-ai/glm-5.3-flash-free` | always-on | text + image | 1M | 128K |
50
+ | `z-ai/glm-5.3-flash` | always-on | text + image | 1M | 128K |
51
+ | `z-ai/glm-5.3` | always-on | text | 1M | 128K |
52
+ | `deepseek/deepseek-v4-flash-free` | yes | text | 164K | 32K |
53
+ | `tencent/hy3-free` | yes | text | 128K | 32K |
54
+
55
+ ## GLM-5 notes
56
+
57
+ Verified against the live endpoint:
58
+
59
+ - Thinking is always on; `reasoning_effort` accepts `low` / `medium` / `high` / `max`. Pi's `off` maps to `low`, `max` maps to `max`.
60
+ - The `developer` role is rejected upstream, so the provider uses `system`.
61
+ - Token limits are sent as `max_completion_tokens`.
62
+ - Costs come from the catalog's per-million price fields; the free tiers are $0 (flat per-request fee).
63
+
64
+ ## Tests
65
+
66
+ ```sh
67
+ npm test
68
+ ```
@@ -0,0 +1,53 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { ENV_API_KEY, resolveBaseUrl, resolveModels } from "./models.ts";
5
+ import { createOrcaRouterProvider } from "./provider.ts";
6
+
7
+ /**
8
+ * Read a stored OrcaRouter API key from <agentDir>/auth.json, as written by
9
+ * /login. The provider's fetchModels() receives the credential from pi, but
10
+ * the extension factory runs before any credential is handed to us, so we
11
+ * read the store directly to enable dynamic discovery at startup.
12
+ */
13
+ function readStoredApiKey(agentDir = getAgentDir()): string | undefined {
14
+ try {
15
+ const raw: unknown = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf-8"));
16
+ if (typeof raw !== "object" || raw === null) return undefined;
17
+ const entry = (raw as Record<string, unknown>).orcarouter;
18
+ if (
19
+ typeof entry === "object" &&
20
+ entry !== null &&
21
+ (entry as { type?: unknown }).type === "api_key"
22
+ ) {
23
+ const key = (entry as { key?: unknown }).key;
24
+ if (typeof key === "string" && key.trim()) return key.trim();
25
+ }
26
+ return undefined;
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ export default async function orcaRouterProvider(pi: ExtensionAPI): Promise<void> {
33
+ const baseUrl = resolveBaseUrl();
34
+ const apiKey = process.env[ENV_API_KEY]?.trim() ?? readStoredApiKey();
35
+ const models = await resolveModels(apiKey, { baseUrl });
36
+ pi.registerProvider(createOrcaRouterProvider(process.env, models));
37
+ }
38
+
39
+ export {
40
+ PROVIDER_ID,
41
+ PROVIDER_NAME,
42
+ ENV_API_KEY,
43
+ ENV_BASE_URL,
44
+ DEFAULT_BASE_URL,
45
+ STATIC_MODEL_IDS,
46
+ buildStaticModels,
47
+ buildModelsFromRemote,
48
+ fetchRemoteModels,
49
+ isChatModel,
50
+ resolveModels,
51
+ resolveBaseUrl,
52
+ } from "./models.ts";
53
+ export { createOrcaRouterProvider } from "./provider.ts";
@@ -0,0 +1,398 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+
3
+ export const PROVIDER_ID = "orcarouter";
4
+ export const PROVIDER_NAME = "OrcaRouter";
5
+ export const ENV_API_KEY = "ORCAROUTER_API_KEY";
6
+ export const ENV_BASE_URL = "ORCAROUTER_BASE_URL";
7
+ export const DEFAULT_BASE_URL = "https://api.orcarouter.ai/v1";
8
+ export const MODELS_ENDPOINT = "/models";
9
+ export const MODELS_FETCH_TIMEOUT_MS = 5_000;
10
+
11
+ export type OrcaRouterApi = "openai-completions";
12
+ export type OrcaRouterModel = Model<OrcaRouterApi>;
13
+
14
+ /**
15
+ * Raw entry shape from GET /v1/models (OpenRouter-style catalog).
16
+ *
17
+ * Chat-capable entries list "openai" in supported_endpoint_types. OrcaRouter
18
+ * also ships stub entries with supported_endpoint_types: null — notably the
19
+ * free tiers (z-ai/glm-5.3-flash-free, deepseek/deepseek-v4-flash-free, …)
20
+ * — which are still served through /v1/chat/completions. Those are included
21
+ * when their id looks like a chat model and they carry token or flat $0
22
+ * pricing; non-chat offerings (image, video, dubbing, embeddings) are
23
+ * excluded by id.
24
+ */
25
+ export interface RemoteModelEntry {
26
+ id?: unknown;
27
+ object?: unknown;
28
+ owned_by?: unknown;
29
+ supported_endpoint_types?: unknown;
30
+ name?: unknown;
31
+ context_length?: unknown;
32
+ max_completion_tokens?: unknown;
33
+ architecture?: {
34
+ input_modalities?: unknown;
35
+ output_modalities?: unknown;
36
+ } | null;
37
+ top_provider?: {
38
+ context_length?: unknown;
39
+ max_completion_tokens?: unknown;
40
+ } | null;
41
+ pricing?: {
42
+ prompt?: unknown;
43
+ completion?: unknown;
44
+ prompt_per_million?: unknown;
45
+ completion_per_million?: unknown;
46
+ input_cache_read?: unknown;
47
+ request?: unknown;
48
+ } | null;
49
+ }
50
+
51
+ const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
52
+
53
+ /** Model ids that are clearly not chat-completions models. */
54
+ const NON_CHAT_ID = /(image|video|kling|dub|tts|speech|sora|whisper|embed|dall|flux|veo|seedance|stable|diffusion|audio|voice|music|rerank)/i;
55
+
56
+ /**
57
+ * GLM-5.x always thinks: thinking.type only accepts enabled upstream and
58
+ * reasoning_effort "none" is rejected, so "off" maps to the lowest effort.
59
+ * Effort values are low / medium / high / max.
60
+ */
61
+ const GLM5_THINKING_LEVEL_MAP = {
62
+ off: "low",
63
+ minimal: "low",
64
+ low: "low",
65
+ medium: "medium",
66
+ high: "high",
67
+ xhigh: "max",
68
+ max: "max",
69
+ } as const satisfies NonNullable<Model<"openai-completions">["thinkingLevelMap"]>;
70
+
71
+ /** Default map for other reasoning families: thinking cannot be turned off. */
72
+ const DEFAULT_REASONING_LEVEL_MAP = {
73
+ off: null,
74
+ minimal: null,
75
+ low: "low",
76
+ medium: "medium",
77
+ high: "high",
78
+ xhigh: "high",
79
+ max: null,
80
+ } as const satisfies NonNullable<Model<"openai-completions">["thinkingLevelMap"]>;
81
+
82
+ /**
83
+ * Shared compat for OrcaRouter models: standard OpenAI chat-completions
84
+ * semantics, "system" role (the "developer" role is rejected upstream), and
85
+ * max_completion_tokens (max_tokens also works, but this is the documented
86
+ * field). reasoning_effort is forwarded only for reasoning models.
87
+ */
88
+ function buildCompat(reasoning: boolean): NonNullable<Model<"openai-completions">["compat"]> {
89
+ return {
90
+ supportsStore: false,
91
+ supportsDeveloperRole: false,
92
+ supportsReasoningEffort: reasoning,
93
+ maxTokensField: "max_completion_tokens",
94
+ };
95
+ }
96
+
97
+ function isGlm5(id: string): boolean {
98
+ return /glm-5(\.|$)/.test(id);
99
+ }
100
+
101
+ function isReasoningModel(id: string): boolean {
102
+ const lower = id.toLowerCase();
103
+ return (
104
+ lower.includes("glm-5") ||
105
+ lower.includes("glm-4.5") ||
106
+ lower.includes("glm-4.6") ||
107
+ lower.includes("glm-4.7") ||
108
+ lower.includes("kimi") ||
109
+ lower.includes("deepseek") ||
110
+ lower.includes("qwq") ||
111
+ lower.includes("minimax") ||
112
+ lower.includes("hy3") ||
113
+ lower.includes("hunyuan") ||
114
+ lower.includes("thinking") ||
115
+ lower.includes("reasoner") ||
116
+ lower.includes("reasoning") ||
117
+ lower.includes("fusion") ||
118
+ lower.includes("gpt-5") ||
119
+ lower.includes("gpt-6") ||
120
+ lower.includes("gpt-oss") ||
121
+ /(^|[^0-9])o[1-9]([^0-9]|$)/.test(lower) ||
122
+ lower.includes("gemini-2.5") ||
123
+ lower.includes("gemini-3") ||
124
+ lower.includes("grok-3") ||
125
+ lower.includes("grok-4")
126
+ );
127
+ }
128
+
129
+ function defaultContextWindow(id: string): number {
130
+ if (id.includes("glm-5")) return 1_000_000;
131
+ if (id.includes("glm-4")) return 200_000;
132
+ if (id.includes("gpt-5") || id.includes("gpt-6")) return 272_000;
133
+ if (id.includes("claude")) return 200_000;
134
+ if (id.includes("gemini-3") || id.includes("gemini-2.5-pro")) return 1_000_000;
135
+ if (id.includes("gemini")) return 1_000_000;
136
+ if (id.includes("kimi")) return 256_000;
137
+ if (id.includes("deepseek")) return 164_000;
138
+ if (id.includes("gpt-oss")) return 131_072;
139
+ return 128_000;
140
+ }
141
+
142
+ function defaultMaxTokens(id: string): number {
143
+ if (id.includes("glm-5")) return 128_000;
144
+ if (id.includes("glm-4")) return 131_072;
145
+ if (id.includes("gpt-5") || id.includes("gpt-6")) return 128_000;
146
+ return 32_768;
147
+ }
148
+
149
+ /** "Z.ai: GLM 5.3 Flash (Free)" -> "GLM 5.3 Flash (Free)"; fallback to prettified id. */
150
+ function formatModelName(entryName: string | undefined, id: string): string {
151
+ if (entryName && entryName.trim()) return entryName.trim();
152
+ return id
153
+ .split(/[-_/]+/)
154
+ .filter(Boolean)
155
+ .map((part) => (/^\d/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1)))
156
+ .join(" ");
157
+ }
158
+
159
+ /** Parse a per-million price string into a number; returns 0 on absent/invalid. */
160
+ function parsePerMillion(value: unknown): number {
161
+ if (typeof value !== "string") return 0;
162
+ const n = Number.parseFloat(value);
163
+ return Number.isFinite(n) && n >= 0 ? n : 0;
164
+ }
165
+
166
+ /** Shift a decimal string right by `places` digits without floating-point error. */
167
+ function shiftDecimal(value: string, places: number): number {
168
+ const match = /^(\d*)(?:\.(\d*))?$/.exec(value.trim());
169
+ if (!match) return Number.parseFloat(value) * 10 ** places;
170
+ const int = match[1] || "0";
171
+ const frac = match[2] ?? "";
172
+ const digits = int + frac;
173
+ const point = int.length + places;
174
+ const scaled =
175
+ point >= digits.length
176
+ ? digits + "0".repeat(point - digits.length)
177
+ : digits.slice(0, point) + "." + digits.slice(point);
178
+ return Number(scaled);
179
+ }
180
+
181
+ /** Parse a per-token price string and convert to per-million. */
182
+ function parsePerTokenAsMillion(value: unknown): number {
183
+ if (typeof value !== "string") return 0;
184
+ const n = shiftDecimal(value, 6);
185
+ return Number.isFinite(n) && n >= 0 ? n : 0;
186
+ }
187
+
188
+ /**
189
+ * Pi expects cost in USD per million tokens. OrcaRouter exposes both
190
+ * per-token fields and *_per_million strings; prefer the per-million form,
191
+ * fall back to the per-token fields. Flat per-request pricing (free tiers
192
+ * charge $0 per request) maps to zero token cost.
193
+ */
194
+ function buildCost(pricing: RemoteModelEntry["pricing"]): NonNullable<Model<"openai-completions">["cost"]> {
195
+ if (!pricing) return { ...ZERO_COST };
196
+ const input =
197
+ parsePerMillion(pricing.prompt_per_million) || parsePerTokenAsMillion(pricing.prompt);
198
+ const output =
199
+ parsePerMillion(pricing.completion_per_million) || parsePerTokenAsMillion(pricing.completion);
200
+ const cacheRead = parsePerTokenAsMillion(pricing.input_cache_read);
201
+ return { input, output, cacheRead, cacheWrite: 0 };
202
+ }
203
+
204
+ function stringValue(value: unknown): string | undefined {
205
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
206
+ }
207
+
208
+ function numberValue(value: unknown): number | undefined {
209
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
210
+ }
211
+
212
+ function stringArray(value: unknown): string[] {
213
+ return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
214
+ }
215
+
216
+ /**
217
+ * Decide whether a raw catalog entry is a chat-completions model.
218
+ *
219
+ * - Endpoints list including "openai" is the reliable signal.
220
+ * - Null endpoints (free-tier stubs like z-ai/glm-5.3-flash-free) are kept
221
+ * when the id looks like a chat model and pricing exists.
222
+ * - Non-null endpoint lists without "openai" (pure embeddings, image
223
+ * generation, …) are dropped.
224
+ */
225
+ export function isChatModel(entry: RemoteModelEntry): boolean {
226
+ const id = stringValue(entry.id);
227
+ if (!id) return false;
228
+
229
+ const endpoints = entry.supported_endpoint_types;
230
+ if (Array.isArray(endpoints)) return endpoints.includes("openai");
231
+ if (endpoints !== null && endpoints !== undefined) return false;
232
+
233
+ if (NON_CHAT_ID.test(id)) return false;
234
+ return entry.pricing !== null && entry.pricing !== undefined;
235
+ }
236
+
237
+ function buildModel(id: string, entry: RemoteModelEntry | undefined, baseUrl: string): OrcaRouterModel {
238
+ const reasoning = isReasoningModel(id);
239
+ const input: ("text" | "image")[] = ["text"];
240
+ const modalities = stringArray(entry?.architecture?.input_modalities);
241
+ if (modalities.includes("image") || id.includes("glm-5.3-flash")) input.push("image");
242
+
243
+ return {
244
+ id,
245
+ name: formatModelName(stringValue(entry?.name), id),
246
+ api: "openai-completions",
247
+ provider: PROVIDER_ID,
248
+ baseUrl,
249
+ reasoning,
250
+ thinkingLevelMap: {
251
+ ...(isGlm5(id) ? GLM5_THINKING_LEVEL_MAP : DEFAULT_REASONING_LEVEL_MAP),
252
+ },
253
+ input,
254
+ cost: buildCost(entry?.pricing),
255
+ compat: buildCompat(reasoning),
256
+ contextWindow:
257
+ numberValue(entry?.context_length) ??
258
+ numberValue(entry?.top_provider?.context_length) ??
259
+ defaultContextWindow(id),
260
+ maxTokens:
261
+ numberValue(entry?.max_completion_tokens) ??
262
+ numberValue(entry?.top_provider?.max_completion_tokens) ??
263
+ defaultMaxTokens(id),
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Static fallback catalog, used when no API key is configured or the
269
+ * /v1/models fetch fails. The free GLM 5.3 Flash tier is the headline model.
270
+ */
271
+ export const STATIC_MODEL_IDS = [
272
+ "z-ai/glm-5.3-flash-free",
273
+ "z-ai/glm-5.3-flash",
274
+ "z-ai/glm-5.3",
275
+ "deepseek/deepseek-v4-flash-free",
276
+ "tencent/hy3-free",
277
+ ] as const;
278
+
279
+ export function buildStaticModels(baseUrl: string): readonly OrcaRouterModel[] {
280
+ return STATIC_MODEL_IDS.map((id) =>
281
+ buildModel(
282
+ id,
283
+ id === "z-ai/glm-5.3-flash-free"
284
+ ? {
285
+ id,
286
+ supported_endpoint_types: null,
287
+ name: "Z.ai: GLM 5.3 Flash (Free)",
288
+ context_length: 1_000_000,
289
+ max_completion_tokens: 128_000,
290
+ architecture: { input_modalities: ["text", "image", "video"] },
291
+ pricing: { request: "0.000000" },
292
+ }
293
+ : id === "z-ai/glm-5.3-flash"
294
+ ? {
295
+ id,
296
+ supported_endpoint_types: ["openai"],
297
+ name: "Z.ai: GLM 5.3 Flash",
298
+ context_length: 1_000_000,
299
+ max_completion_tokens: 128_000,
300
+ architecture: { input_modalities: ["text", "image", "video"] },
301
+ pricing: {
302
+ prompt_per_million: "0.075000",
303
+ completion_per_million: "0.250000",
304
+ input_cache_read: "0.00000001725",
305
+ },
306
+ }
307
+ : undefined,
308
+ baseUrl,
309
+ ),
310
+ );
311
+ }
312
+
313
+ export function buildModelsFromRemote(
314
+ entries: readonly RemoteModelEntry[],
315
+ baseUrl: string,
316
+ ): readonly OrcaRouterModel[] {
317
+ const seen = new Set<string>();
318
+ const models: OrcaRouterModel[] = [];
319
+ for (const entry of entries) {
320
+ const id = stringValue(entry.id);
321
+ if (!id || seen.has(id) || !isChatModel(entry)) continue;
322
+ seen.add(id);
323
+ models.push(buildModel(id, entry, baseUrl));
324
+ }
325
+ return models.sort((a, b) => a.id.localeCompare(b.id));
326
+ }
327
+
328
+ export interface FetchRemoteModelsOptions {
329
+ baseUrl?: string;
330
+ apiKey?: string;
331
+ fetch?: typeof globalThis.fetch;
332
+ timeoutMs?: number;
333
+ signal?: AbortSignal;
334
+ }
335
+
336
+ export async function fetchRemoteModels(
337
+ options: FetchRemoteModelsOptions = {},
338
+ ): Promise<readonly RemoteModelEntry[] | undefined> {
339
+ const baseUrl = options.baseUrl ?? resolveBaseUrl();
340
+ const apiKey = options.apiKey?.trim();
341
+ const fetchFn = options.fetch ?? globalThis.fetch;
342
+ const timeoutMs = options.timeoutMs ?? MODELS_FETCH_TIMEOUT_MS;
343
+
344
+ if (!apiKey || !fetchFn) return undefined;
345
+
346
+ const controller = new AbortController();
347
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
348
+ options.signal?.addEventListener("abort", () => controller.abort(), { once: true });
349
+
350
+ try {
351
+ const response = await fetchFn(`${baseUrl}${MODELS_ENDPOINT}`, {
352
+ headers: { Authorization: `Bearer ${apiKey}` },
353
+ signal: controller.signal,
354
+ });
355
+
356
+ if (!response.ok) return undefined;
357
+
358
+ const json: unknown = await response.json();
359
+ const rawList: RemoteModelEntry[] = Array.isArray(json)
360
+ ? json
361
+ : typeof json === "object" && json !== null && Array.isArray((json as { data?: unknown }).data)
362
+ ? ((json as { data: RemoteModelEntry[] }).data ?? [])
363
+ : [];
364
+
365
+ return rawList.filter((entry) => stringValue(entry?.id));
366
+ } catch {
367
+ return undefined;
368
+ } finally {
369
+ clearTimeout(timer);
370
+ }
371
+ }
372
+
373
+ export interface ResolveModelsOptions extends FetchRemoteModelsOptions {}
374
+
375
+ /**
376
+ * Full catalog from /v1/models when a key is available; static fallback
377
+ * otherwise. Discovery needs the catalog because free-tier stubs (null
378
+ * endpoints) and pricing metadata only appear there.
379
+ */
380
+ export async function resolveModels(
381
+ apiKey: string | undefined,
382
+ options: ResolveModelsOptions = {},
383
+ ): Promise<readonly OrcaRouterModel[]> {
384
+ const baseUrl = options.baseUrl ?? resolveBaseUrl();
385
+ const entries = await fetchRemoteModels({ ...options, apiKey, baseUrl });
386
+ if (entries && entries.length > 0) return buildModelsFromRemote(entries, baseUrl);
387
+ return buildStaticModels(baseUrl);
388
+ }
389
+
390
+ function stripTrailingSlash(url: string): string {
391
+ return url.replace(/\/+$/, "");
392
+ }
393
+
394
+ export function resolveBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
395
+ const override = env[ENV_BASE_URL]?.trim();
396
+ if (override) return stripTrailingSlash(override);
397
+ return DEFAULT_BASE_URL;
398
+ }
@@ -0,0 +1,51 @@
1
+ import {
2
+ createProvider,
3
+ envApiKeyAuth,
4
+ openAICompletionsApi,
5
+ type ApiKeyCredential,
6
+ type Credential,
7
+ type Provider,
8
+ } from "@earendil-works/pi-ai/compat";
9
+ import {
10
+ ENV_API_KEY,
11
+ PROVIDER_ID,
12
+ PROVIDER_NAME,
13
+ buildStaticModels,
14
+ resolveBaseUrl,
15
+ resolveModels,
16
+ type OrcaRouterApi,
17
+ type OrcaRouterModel,
18
+ } from "./models.ts";
19
+
20
+ function resolveApiKeyFromCredential(credential: Credential | undefined): string | undefined {
21
+ if (!credential) return undefined;
22
+ if (credential.type === "api_key") {
23
+ const key = (credential as ApiKeyCredential).key?.trim();
24
+ return key || undefined;
25
+ }
26
+ return undefined;
27
+ }
28
+
29
+ export function createOrcaRouterProvider(
30
+ env: NodeJS.ProcessEnv = process.env,
31
+ initialModels?: readonly OrcaRouterModel[],
32
+ ): Provider<OrcaRouterApi> {
33
+ const baseUrl = resolveBaseUrl(env);
34
+
35
+ return createProvider({
36
+ id: PROVIDER_ID,
37
+ name: PROVIDER_NAME,
38
+ baseUrl,
39
+ auth: {
40
+ apiKey: envApiKeyAuth("OrcaRouter API key", [ENV_API_KEY]),
41
+ },
42
+ models: initialModels ?? buildStaticModels(baseUrl),
43
+ async fetchModels({ credential, signal }) {
44
+ signal.throwIfAborted();
45
+ const apiKey =
46
+ resolveApiKeyFromCredential(credential) ?? env[ENV_API_KEY]?.trim();
47
+ return resolveModels(apiKey, { baseUrl, signal });
48
+ },
49
+ api: openAICompletionsApi(),
50
+ });
51
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@tejasa97/pi-orcarouter-provider",
3
+ "version": "0.1.0",
4
+ "description": "Pi provider package for OrcaRouter (GLM 5.3 Flash Free, DeepSeek, Kimi, and more) — OpenAI-compatible gateway with dynamic model discovery.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test tests/*.test.mjs"
11
+ },
12
+ "keywords": [
13
+ "pi-package",
14
+ "pi-extension",
15
+ "pi-provider",
16
+ "pi-coding-agent",
17
+ "orcarouter",
18
+ "glm",
19
+ "z-ai",
20
+ "free",
21
+ "llm",
22
+ "pi"
23
+ ],
24
+ "license": "MIT",
25
+ "homepage": "https://github.com/tejasa97/pi-orcarouter-provider#readme",
26
+ "bugs": {
27
+ "url": "https://github.com/tejasa97/pi-orcarouter-provider/issues"
28
+ },
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/tejasa97/pi-orcarouter-provider.git"
32
+ },
33
+ "files": [
34
+ "extensions",
35
+ "package.json",
36
+ "README.md",
37
+ "LICENSE"
38
+ ],
39
+ "pi": {
40
+ "extensions": [
41
+ "./extensions/orcarouter/index.ts"
42
+ ]
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-ai": "*",
46
+ "@earendil-works/pi-coding-agent": "*"
47
+ },
48
+ "devDependencies": {
49
+ "@earendil-works/pi-ai": "^0.84.0",
50
+ "@earendil-works/pi-coding-agent": "^0.84.0"
51
+ }
52
+ }