pi-coralbricks-provider 1.0.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/index.ts ADDED
@@ -0,0 +1,599 @@
1
+ /**
2
+ * CoralBricks Provider Extension
3
+ *
4
+ * Registers CoralBricks as a custom provider using the openai-completions API.
5
+ * Base URL: https://inference.coralbricks.ai/v1
6
+ *
7
+ * Model resolution strategy: Stale-While-Revalidate
8
+ * 1. Serve stale immediately: disk cache → embedded models.json (zero-latency)
9
+ * 2. Revalidate in background: authenticated /v1/models (authoritative enabled
10
+ * set + per-million USD pricing) with the unauthenticated public catalog
11
+ * (https://www.coralbricks.ai/api/public/models) as fallback
12
+ * → merge with embedded → cache → hot-swap
13
+ * 3. patch.json + custom-models.json applied on top of whichever source won
14
+ *
15
+ * Merge order: [live|cache|embedded] → apply patch.json → merge custom-models.json
16
+ *
17
+ * Usage:
18
+ * # Option 1: Store in auth.json (recommended)
19
+ * # Add to ~/.pi/agent/auth.json:
20
+ * # "coralbricks": { "type": "api_key", "key": "cb_your-key" }
21
+ *
22
+ * # Option 2: Set as environment variable
23
+ * # (localterm: `localterm secret set coralbricks_api_key` exposes CORALBRICKS_API_KEY)
24
+ * export CORALBRICKS_API_KEY=cb_your-key
25
+ *
26
+ * # Run pi with the extension
27
+ * pi -e /path/to/pi-coralbricks-provider
28
+ *
29
+ * Then use /model to select from available models
30
+ */
31
+
32
+ import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-works/pi-coding-agent";
33
+ import type { AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
34
+ import { clampThinkingLevel, streamOpenAICompletions } from "@earendil-works/pi-ai/compat";
35
+ import modelsData from "./models.json" with { type: "json" };
36
+ import customModelsData from "./custom-models.json" with { type: "json" };
37
+ import patchData from "./patch.json" with { type: "json" };
38
+ import deprecatedData from "./deprecated-models.json" with { type: "json" };
39
+ import fs from "fs";
40
+ import path from "path";
41
+
42
+ // Types
43
+
44
+ interface JsonModel {
45
+ id: string;
46
+ name: string;
47
+ reasoning: boolean;
48
+ input: ("text" | "image")[];
49
+ cost: {
50
+ input: number;
51
+ output: number;
52
+ cacheRead: number;
53
+ cacheWrite: number;
54
+ };
55
+ contextWindow: number;
56
+ maxTokens: number;
57
+ thinkingLevelMap?: {
58
+ off?: string | null;
59
+ minimal?: string | null;
60
+ low?: string | null;
61
+ medium?: string | null;
62
+ high?: string | null;
63
+ xhigh?: string | null;
64
+ max?: string | null;
65
+ };
66
+ compat?: {
67
+ supportsDeveloperRole?: boolean;
68
+ supportsStore?: boolean;
69
+ supportsReasoningEffort?: boolean;
70
+ maxTokensField?: "max_completion_tokens" | "max_tokens";
71
+ thinkingFormat?: "openai" | "zai";
72
+ supportsStrictMode?: boolean;
73
+ requiresReasoningContentOnAssistantMessages?: boolean;
74
+ };
75
+ }
76
+
77
+ interface PatchEntry {
78
+ name?: string;
79
+ reasoning?: boolean;
80
+ input?: ("text" | "image")[];
81
+ cost?: {
82
+ input?: number;
83
+ output?: number;
84
+ cacheRead?: number;
85
+ cacheWrite?: number;
86
+ };
87
+ contextWindow?: number;
88
+ maxTokens?: number;
89
+ thinkingLevelMap?: Record<string, string | null>;
90
+ compat?: Record<string, unknown>;
91
+ }
92
+
93
+ type PatchData = Record<string, PatchEntry>;
94
+
95
+ // Patch Application
96
+
97
+ function applyPatch(model: JsonModel, patch: PatchEntry): JsonModel {
98
+ // Deep-copy nested objects: the delete branches below must not mutate the
99
+ // caller's (embedded or cached) source data through shared references.
100
+ const result: JsonModel = {
101
+ ...model,
102
+ cost: { ...model.cost },
103
+ compat: model.compat ? { ...model.compat } : undefined,
104
+ thinkingLevelMap: model.thinkingLevelMap ? { ...model.thinkingLevelMap } : undefined,
105
+ };
106
+
107
+ if (patch.name !== undefined) result.name = patch.name;
108
+ if (patch.reasoning !== undefined) result.reasoning = patch.reasoning;
109
+ if (patch.input !== undefined) result.input = patch.input;
110
+ if (patch.contextWindow !== undefined) result.contextWindow = patch.contextWindow;
111
+ if (patch.maxTokens !== undefined) result.maxTokens = patch.maxTokens;
112
+ if (patch.thinkingLevelMap !== undefined) result.thinkingLevelMap = patch.thinkingLevelMap as JsonModel["thinkingLevelMap"];
113
+
114
+ if (patch.cost) {
115
+ result.cost = {
116
+ input: patch.cost.input ?? result.cost.input,
117
+ output: patch.cost.output ?? result.cost.output,
118
+ cacheRead: patch.cost.cacheRead ?? result.cost.cacheRead,
119
+ cacheWrite: patch.cost.cacheWrite ?? result.cost.cacheWrite,
120
+ };
121
+ }
122
+ if (patch.compat) {
123
+ result.compat = { ...(result.compat || {}), ...patch.compat };
124
+ }
125
+
126
+ if (!result.reasoning && result.compat?.thinkingFormat) {
127
+ delete result.compat.thinkingFormat;
128
+ }
129
+ if (!result.reasoning && result.thinkingLevelMap) {
130
+ delete result.thinkingLevelMap;
131
+ }
132
+ if (result.compat && Object.keys(result.compat).length === 0) {
133
+ delete result.compat;
134
+ }
135
+
136
+ return result;
137
+ }
138
+
139
+ /** Full pipeline: base models → patch → custom → result */
140
+ function buildModels(base: JsonModel[], custom: JsonModel[], patch: PatchData): JsonModel[] {
141
+ const modelMap = new Map<string, JsonModel>();
142
+
143
+ for (const model of withDeprecated(base)) {
144
+ modelMap.set(model.id, model);
145
+ }
146
+
147
+ for (const [id, patchEntry] of Object.entries(patch)) {
148
+ const existing = modelMap.get(id);
149
+ if (existing) {
150
+ modelMap.set(id, applyPatch(existing, patchEntry));
151
+ }
152
+ }
153
+
154
+ for (const model of custom) {
155
+ const existing = modelMap.get(model.id);
156
+ const patchEntry = patch[model.id];
157
+ if (existing && patchEntry) {
158
+ modelMap.set(model.id, applyPatch(model, patchEntry));
159
+ } else if (existing) {
160
+ modelMap.set(model.id, model);
161
+ } else if (patchEntry) {
162
+ modelMap.set(model.id, applyPatch(model, patchEntry));
163
+ } else {
164
+ modelMap.set(model.id, model);
165
+ }
166
+ }
167
+
168
+ return Array.from(modelMap.values());
169
+ }
170
+
171
+ // Stale-While-Revalidate Model Sync
172
+
173
+ const PROVIDER_ID = "coralbricks";
174
+ const BASE_URL = "https://inference.coralbricks.ai/v1";
175
+ const MODELS_URL = `${BASE_URL}/models`;
176
+ const PUBLIC_CATALOG_URL = "https://www.coralbricks.ai/api/public/models";
177
+ const CACHE_DIR = path.join(getAgentDir(), "cache");
178
+ const CACHE_PATH = path.join(CACHE_DIR, `${PROVIDER_ID}-models.json`);
179
+ const LIVE_FETCH_TIMEOUT_MS = 8000;
180
+
181
+ // Output-token fallbacks for models whose API rows do not report a limit.
182
+ const DEFAULT_MAX_TOKENS: Record<string, number> = {
183
+ "gpt-oss-120b": 40960,
184
+ };
185
+ const FALLBACK_MAX_TOKENS = 32768;
186
+
187
+ function toNumber(value: unknown): number {
188
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
189
+ }
190
+
191
+ function displayName(id: string): string {
192
+ return id
193
+ .split(/[-_/]/)
194
+ .filter(Boolean)
195
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
196
+ .join(" ");
197
+ }
198
+
199
+ /** Parse "1M" / "128K" / "1048576" / 1048576 into a token count. */
200
+ function parseContextWindow(value: unknown, fallback = 131072): number {
201
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
202
+ if (typeof value === "string") {
203
+ const match = value.trim().match(/^([\d.]+)\s*([KkMm])$/);
204
+ if (match) {
205
+ const n = parseFloat(match[1]);
206
+ if (Number.isFinite(n) && n > 0) {
207
+ return Math.round(n * (match[2].toUpperCase() === "M" ? 1024 * 1024 : 1024));
208
+ }
209
+ }
210
+ const n = parseInt(value, 10);
211
+ if (Number.isFinite(n) && n > 0) return n;
212
+ }
213
+ return fallback;
214
+ }
215
+
216
+ function baseCompat() {
217
+ return {
218
+ supportsStore: false,
219
+ supportsDeveloperRole: false,
220
+ maxTokensField: "max_tokens" as const,
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Transform a Coral /v1/models row. Coral reports per-million USD pricing
226
+ * (input_per_m / output_per_m / cached_input_per_m — cached reads are $0 on
227
+ * every model), context_length, and supports_image_input.
228
+ */
229
+ function transformApiModel(apiModel: any): JsonModel | null {
230
+ if (!apiModel?.id) return null;
231
+ const pricing = apiModel.pricing || {};
232
+ const model: JsonModel = {
233
+ id: apiModel.id,
234
+ name: typeof apiModel.name === "string" && apiModel.name ? apiModel.name : displayName(apiModel.id),
235
+ reasoning: false,
236
+ input: apiModel.supports_image_input ? ["text", "image"] : ["text"],
237
+ cost: {
238
+ input: toNumber(pricing.input_per_m),
239
+ output: toNumber(pricing.output_per_m),
240
+ cacheRead: toNumber(pricing.cached_input_per_m),
241
+ cacheWrite: 0,
242
+ },
243
+ contextWindow: parseContextWindow(apiModel.context_length),
244
+ maxTokens: DEFAULT_MAX_TOKENS[apiModel.id] ?? FALLBACK_MAX_TOKENS,
245
+ };
246
+ model.compat = baseCompat();
247
+ return model;
248
+ }
249
+
250
+ /**
251
+ * Transform a row from the public model catalog
252
+ * (https://www.coralbricks.ai/api/public/models) — the no-auth mirror of the
253
+ * gateway control plane. Fields: slug, name, contextWindow ("1M"),
254
+ * inputPerM / outputPerM. Coral's own prices are authoritative here; the
255
+ * parity/field vendor comparison fields are ignored.
256
+ */
257
+ function transformCatalogModel(entry: any): JsonModel | null {
258
+ if (!entry?.slug) return null;
259
+ const model: JsonModel = {
260
+ id: entry.slug,
261
+ name: typeof entry.name === "string" && entry.name ? entry.name : displayName(entry.slug),
262
+ reasoning: false,
263
+ input: ["text"],
264
+ cost: {
265
+ input: toNumber(entry.inputPerM),
266
+ output: toNumber(entry.outputPerM),
267
+ cacheRead: 0,
268
+ cacheWrite: 0,
269
+ },
270
+ contextWindow: parseContextWindow(entry.contextWindow),
271
+ maxTokens: DEFAULT_MAX_TOKENS[entry.slug] ?? FALLBACK_MAX_TOKENS,
272
+ };
273
+ model.compat = baseCompat();
274
+ return model;
275
+ }
276
+
277
+ async function fetchV1Models(apiKey: string, signal?: AbortSignal): Promise<JsonModel[] | null> {
278
+ try {
279
+ const response = await fetch(MODELS_URL, {
280
+ headers: { Authorization: `Bearer ${apiKey}` },
281
+ signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
282
+ });
283
+ if (!response.ok) return null;
284
+ const data = await response.json();
285
+ const apiModels = Array.isArray(data) ? data : (data.data || []);
286
+ if (!Array.isArray(apiModels) || apiModels.length === 0) return null;
287
+ return apiModels.map(transformApiModel).filter((m): m is JsonModel => m !== null);
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
293
+ async function fetchPublicCatalog(signal?: AbortSignal): Promise<JsonModel[] | null> {
294
+ try {
295
+ const response = await fetch(PUBLIC_CATALOG_URL, {
296
+ signal: signal ? AbortSignal.any([AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS), signal]) : AbortSignal.timeout(LIVE_FETCH_TIMEOUT_MS),
297
+ });
298
+ if (!response.ok) return null;
299
+ const data = await response.json();
300
+ const entries = Array.isArray(data) ? data : (data.models || []);
301
+ if (!Array.isArray(entries) || entries.length === 0) return null;
302
+ return entries.map(transformCatalogModel).filter((m): m is JsonModel => m !== null);
303
+ } catch {
304
+ return null;
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Live model fetch. The authenticated /v1/models is authoritative (it is the
310
+ * exact per-key enabled set); the public catalog is the unauthenticated
311
+ * fallback so model discovery works before a key is configured.
312
+ */
313
+ async function fetchLiveModels(apiKey: string | undefined, signal?: AbortSignal): Promise<JsonModel[] | null> {
314
+ if (apiKey) {
315
+ const fromApi = await fetchV1Models(apiKey, signal);
316
+ if (fromApi && fromApi.length > 0) return fromApi;
317
+ }
318
+ return fetchPublicCatalog(signal);
319
+ }
320
+
321
+ function loadCachedModels(): JsonModel[] | null {
322
+ try {
323
+ const data = JSON.parse(fs.readFileSync(CACHE_PATH, "utf8"));
324
+ return Array.isArray(data) ? data : null;
325
+ } catch {
326
+ return null;
327
+ }
328
+ }
329
+
330
+ function cacheModels(models: JsonModel[]): void {
331
+ try {
332
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
333
+ fs.writeFileSync(CACHE_PATH, JSON.stringify(models, null, 2) + "\n");
334
+ } catch {
335
+ // Cache write failure is non-fatal
336
+ }
337
+ }
338
+
339
+ function mergeWithEmbedded(liveModels: JsonModel[], embeddedModels: JsonModel[]): JsonModel[] {
340
+ const embeddedMap = new Map(embeddedModels.map((m) => [m.id, m]));
341
+ const seen = new Set<string>();
342
+ const result: JsonModel[] = [];
343
+ for (const liveModel of liveModels) {
344
+ const embedded = embeddedMap.get(liveModel.id);
345
+ seen.add(liveModel.id);
346
+ if (embedded) {
347
+ // Self-heal: live API pricing is authoritative field-by-field. Prefer the
348
+ // live cost when the API reports it (non-zero); fall back to embedded when
349
+ // the API is silent (0). Coral reports cached_input_per_m as 0 because
350
+ // cached reads are free, which matches the curated cacheRead: 0.
351
+ // Curation (reasoning/input/compat/name/thinkingLevelMap/maxTokens) still wins via ...embedded.
352
+ result.push({
353
+ ...liveModel,
354
+ ...embedded,
355
+ cost: {
356
+ input: liveModel.cost.input || embedded.cost.input,
357
+ output: liveModel.cost.output || embedded.cost.output,
358
+ cacheRead: liveModel.cost.cacheRead || embedded.cost.cacheRead,
359
+ cacheWrite: liveModel.cost.cacheWrite || embedded.cost.cacheWrite,
360
+ },
361
+ contextWindow: liveModel.contextWindow || embedded.contextWindow,
362
+ });
363
+ } else {
364
+ result.push(liveModel);
365
+ }
366
+ }
367
+ // Append any embedded models that the live API didn't return
368
+ for (const em of embeddedModels) {
369
+ if (!seen.has(em.id)) {
370
+ result.push(em);
371
+ }
372
+ }
373
+ return result;
374
+ }
375
+
376
+ // Grace period for delisted models. When the provider API stops listing a
377
+ // model, update-models.js moves its last-known definition into
378
+ // deprecated-models.json (stamped with deprecatedAt) instead of dropping it.
379
+ // For 14 days the model keeps working here so in-flight sessions and saved
380
+ // model settings do not break; afterwards it is evicted permanently.
381
+ const DEPRECATED_MODEL_TTL_MS = 14 * 24 * 60 * 60 * 1000;
382
+
383
+ // Grace-period deprecated models with deprecation metadata stripped.
384
+ function activeDeprecatedModels(entries: Record<string, JsonModel & { deprecatedAt?: string }> = deprecatedData as Record<string, JsonModel & { deprecatedAt?: string }>): JsonModel[] {
385
+ const now = Date.now();
386
+ const result: JsonModel[] = [];
387
+ for (const entry of Object.values(entries)) {
388
+ if (!entry?.id) continue;
389
+ const removedAt = Date.parse(entry.deprecatedAt ?? "");
390
+ if (Number.isNaN(removedAt) || now - removedAt > DEPRECATED_MODEL_TTL_MS) continue;
391
+ const model = { ...entry } as JsonModel & { deprecatedAt?: string };
392
+ delete model.deprecatedAt;
393
+ result.push(model);
394
+ }
395
+ return result;
396
+ }
397
+
398
+ // Append grace-period deprecated models the list does not already have (live data wins).
399
+ function withDeprecated(models: JsonModel[]): JsonModel[] {
400
+ const seen = new Set(models.map((m) => m.id));
401
+ const extras = activeDeprecatedModels().filter((m) => !seen.has(m.id));
402
+ return extras.length > 0 ? [...models, ...extras] : models;
403
+ }
404
+
405
+ function loadStaleModels(embeddedModels: JsonModel[]): JsonModel[] {
406
+ const cached = loadCachedModels();
407
+ if (!cached || cached.length === 0) return embeddedModels;
408
+
409
+ // Merge embedded models that are missing from cache (newly added models)
410
+ const cachedMap = new Map(cached.map((m) => [m.id, m]));
411
+ for (const em of embeddedModels) {
412
+ if (!cachedMap.has(em.id)) {
413
+ cached.push(em);
414
+ }
415
+ }
416
+ return cached;
417
+ }
418
+
419
+ async function revalidateModels(apiKey: string | undefined, embeddedModels: JsonModel[], signal?: AbortSignal): Promise<JsonModel[] | null> {
420
+ const liveModels = await fetchLiveModels(apiKey, signal);
421
+ if (!liveModels || liveModels.length === 0) return null;
422
+ const merged = mergeWithEmbedded(liveModels, embeddedModels);
423
+ cacheModels(merged);
424
+ return merged;
425
+ }
426
+
427
+ // Streaming: Coral gateway tool-call index repair
428
+ //
429
+ // Some Coral models (observed on gpt-oss-120b) emit a streamed tool call's
430
+ // final arguments fragment on a NEW delta index instead of continuing the
431
+ // existing one:
432
+ //
433
+ // {"tool_calls":[{"id":"call_1","index":0,"function":{"name":"get_weather","arguments":"{\"location\": \"Par"}}]}
434
+ // {"tool_calls":[{"index":1,"function":{"arguments":"is\"}"}}]} ← wrong index
435
+ //
436
+ // OpenAI semantics say a delta can only START a tool call when it carries an
437
+ // id (and a function name); continuations omit them. pi-ai's accumulator
438
+ // merges by index, so a mis-indexed fragment materializes a second, truncated
439
+ // tool call. Per choice, we track the index of the last fragment that carried
440
+ // id/name and rewrite id-less, name-less deltas that claim a different index
441
+ // onto it. Deltas that DO carry id/name (new calls, parallel calls) pass
442
+ // through untouched, as do Coral streams that are already correct (GLM/Kimi).
443
+
444
+ function repairToolCallIndices(chunk: any, lastToolCallIndex: Map<number, number>): boolean {
445
+ const choices = chunk?.choices;
446
+ if (!Array.isArray(choices)) return false;
447
+ let changed = false;
448
+ for (const choice of choices) {
449
+ const toolCalls = choice?.delta?.tool_calls;
450
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) continue;
451
+ const choiceIndex = typeof choice.index === "number" ? choice.index : 0;
452
+ for (const tc of toolCalls) {
453
+ const hasId = typeof tc?.id === "string" && tc.id.length > 0;
454
+ const hasName = typeof tc?.function?.name === "string" && tc.function.name.length > 0;
455
+ if (hasId || hasName) {
456
+ lastToolCallIndex.set(choiceIndex, tc.index);
457
+ continue;
458
+ }
459
+ const previous = lastToolCallIndex.get(choiceIndex);
460
+ if (previous !== undefined && typeof tc?.index === "number" && tc.index !== previous) {
461
+ tc.index = previous;
462
+ changed = true;
463
+ }
464
+ }
465
+ }
466
+ return changed;
467
+ }
468
+
469
+ function createToolCallIndexFixer(): TransformStream<Uint8Array, Uint8Array> {
470
+ const decoder = new TextDecoder();
471
+ const encoder = new TextEncoder();
472
+ const lastToolCallIndex = new Map<number, number>();
473
+ let buffer = "";
474
+
475
+ const fixLine = (line: string): string => {
476
+ if (!line.startsWith("data:") || !line.includes("\"tool_calls\"")) return line;
477
+ const payload = line.slice(5).trim();
478
+ if (!payload || payload === "[DONE]") return line;
479
+ let chunk: any;
480
+ try {
481
+ chunk = JSON.parse(payload);
482
+ } catch {
483
+ return line;
484
+ }
485
+ const changed = repairToolCallIndices(chunk, lastToolCallIndex);
486
+ return changed ? "data: " + JSON.stringify(chunk) : line;
487
+ };
488
+
489
+ return new TransformStream<Uint8Array, Uint8Array>({
490
+ transform(chunk, controller) {
491
+ buffer += decoder.decode(chunk, { stream: true });
492
+ let newline: number;
493
+ while ((newline = buffer.indexOf("\n")) >= 0) {
494
+ const line = buffer.slice(0, newline);
495
+ buffer = buffer.slice(newline + 1);
496
+ controller.enqueue(encoder.encode(fixLine(line) + "\n"));
497
+ }
498
+ },
499
+ flush(controller) {
500
+ if (buffer.length > 0) controller.enqueue(encoder.encode(fixLine(buffer)));
501
+ },
502
+ });
503
+ }
504
+
505
+ export function streamCoral(
506
+ model: any,
507
+ context: any,
508
+ options?: SimpleStreamOptions,
509
+ ): AssistantMessageEventStream {
510
+ const apiKey = options?.apiKey || cachedApiKey || "";
511
+ if (!apiKey) {
512
+ throw new Error(
513
+ `No API key for CoralBricks. Add it to ~/.pi/agent/auth.json ` +
514
+ `("coralbricks"), set CORALBRICKS_API_KEY, or use --api-key.`,
515
+ );
516
+ }
517
+
518
+ // pi hands the user's thinking selection to streamSimple providers as
519
+ // options.reasoning (a raw ThinkingLevel); streamOpenAICompletions reads
520
+ // options.reasoningEffort, so replicate pi-ai's clamp+convert here.
521
+ const clampedReasoning = options?.reasoning ? clampThinkingLevel(model, options.reasoning) : undefined;
522
+ const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
523
+ const { reasoning: _reasoning, ...streamOptions } = options ?? {};
524
+
525
+ // Per-request fetch interceptor (never a global patch — concurrent agent and
526
+ // helper-model streams must not see each other's wrappers).
527
+ const upstreamFetch = streamOptions.fetch ?? globalThis.fetch;
528
+ const coralFetch: typeof globalThis.fetch = async (input, init) => {
529
+ const response = await upstreamFetch(input as RequestInfo | URL, init);
530
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : (input as Request).url;
531
+ const isEventStream = (response.headers.get("content-type") ?? "").includes("text/event-stream");
532
+ if (!response.body || !isEventStream || !url.includes("/chat/completions")) return response;
533
+ return new Response(response.body.pipeThrough(createToolCallIndexFixer()), {
534
+ headers: response.headers,
535
+ status: response.status,
536
+ statusText: response.statusText,
537
+ });
538
+ };
539
+
540
+ return streamOpenAICompletions(model, context, {
541
+ ...streamOptions,
542
+ fetch: coralFetch,
543
+ reasoningEffort,
544
+ apiKey,
545
+ } as any);
546
+ }
547
+
548
+ // API Key Resolution (via ModelRegistry)
549
+
550
+ let cachedApiKey: string | undefined;
551
+ let revalidateAbort: AbortController | null = null;
552
+
553
+ async function resolveApiKey(modelRegistry: ModelRegistry): Promise<void> {
554
+ cachedApiKey = (await modelRegistry.getApiKeyForProvider(PROVIDER_ID)) ?? undefined;
555
+ }
556
+
557
+ // Extension Entry Point
558
+
559
+ export default function (pi: ExtensionAPI) {
560
+ const embeddedModels = modelsData as JsonModel[];
561
+ const customModels = customModelsData as JsonModel[];
562
+ const patches = patchData as PatchData;
563
+
564
+ const staleBase = loadStaleModels(embeddedModels);
565
+ const staleModels = buildModels(staleBase, customModels, patches);
566
+
567
+ pi.registerProvider(PROVIDER_ID, {
568
+ baseUrl: BASE_URL,
569
+ apiKey: "$CORALBRICKS_API_KEY",
570
+ api: "openai-completions",
571
+ models: staleModels,
572
+ streamSimple: streamCoral,
573
+ });
574
+
575
+ pi.on("session_start", async (_event, ctx) => {
576
+ revalidateAbort?.abort();
577
+ revalidateAbort = new AbortController();
578
+ const signal = revalidateAbort.signal;
579
+ resolveApiKey(ctx.modelRegistry).then(() => {
580
+ revalidateModels(cachedApiKey, embeddedModels, signal).then((freshBase) => {
581
+ if (freshBase && !signal.aborted) {
582
+ pi.registerProvider(PROVIDER_ID, {
583
+ baseUrl: BASE_URL,
584
+ apiKey: "$CORALBRICKS_API_KEY",
585
+ api: "openai-completions",
586
+ models: buildModels(freshBase, customModels, patches),
587
+ streamSimple: streamCoral,
588
+ });
589
+ }
590
+ });
591
+ });
592
+ });
593
+
594
+ pi.on("session_shutdown", () => {
595
+ revalidateAbort?.abort();
596
+ });
597
+ }
598
+
599
+ export { applyPatch, buildModels, mergeWithEmbedded, transformApiModel, transformCatalogModel, activeDeprecatedModels, withDeprecated, parseContextWindow, repairToolCallIndices, PROVIDER_ID, BASE_URL };