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/LICENSE +21 -0
- package/README.md +227 -0
- package/custom-models.json +1 -0
- package/deprecated-models.json +1 -0
- package/index.ts +599 -0
- package/models.json +133 -0
- package/package.json +59 -0
- package/patch.json +1 -0
- package/scripts/probe-stream-fix.ts +62 -0
- package/scripts/update-models.js +572 -0
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 };
|