pi-coralbricks-provider 1.0.28 → 1.0.30

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
@@ -108,7 +108,8 @@ Thinking levels attach to the model id with `:<level>` — e.g. `:low`, `:high`,
108
108
 
109
109
  ## Provider Settings
110
110
 
111
- Run `/coralbricks-settings` to choose the **API surface**. The searchable,
111
+ Run `/coralbricks-settings` to choose the **API surface** and, for Responses,
112
+ whether to **park turns**. The searchable,
112
113
  bordered settings list matches pi's settings UI; GUI/RPC clients get a selection
113
114
  dialog. Changes are saved immediately and apply to the next request without a
114
115
  restart, including when a CoralBricks model is already selected.
@@ -117,7 +118,7 @@ Settings live at `~/.pi/agent/extensions/coralbricks.json` (or under
117
118
  `PI_CODING_AGENT_DIR` when set):
118
119
 
119
120
  ```json
120
- { "api": "responses" }
121
+ { "api": "responses", "park": false }
121
122
  ```
122
123
 
123
124
  - **`chat-completions`** (default): uses `/v1/chat/completions`; existing behavior
@@ -126,10 +127,36 @@ Settings live at `~/.pi/agent/extensions/coralbricks.json` (or under
126
127
  tool-call, image, usage, and cancellation handling. The same model IDs and API
127
128
  key work on both surfaces. Thinking levels map to `reasoning.effort` here.
128
129
 
129
- Responses requests replay the full local conversation and send `store: false`.
130
- This integration does **not** use `previous_response_id`, server-side conversation
131
- storage, background jobs, or resumable streams. Coral's broader Responses API is
132
- documented at [coralbricks.ai/docs](https://www.coralbricks.ai/docs).
130
+ By default, Responses requests replay the full local conversation and send
131
+ `store: false`. This integration does not use background jobs or resumable
132
+ streams. Coral's broader Responses API is documented at
133
+ [coralbricks.ai/docs](https://www.coralbricks.ai/docs).
134
+
135
+ ### Parking (`"park": true`)
136
+
137
+ With parking on, every Responses request is sent with `store: true` and the
138
+ next request continues it with `previous_response_id`. pi still builds the full
139
+ transcript, but only the items after the parked response (tool results, the
140
+ next user message) go over the wire. Tools are re-sent every time because Coral
141
+ does not carry them across a chain.
142
+
143
+ A turn is chained only when the full input starts with exactly what was sent
144
+ for the parked response, followed by that response's own output. Compaction, a
145
+ model switch, an edited or branched history, or a pi restart falls back to a
146
+ full replay, and that request is parked again. If Coral no longer has the parent
147
+ (`previous_response_not_found`), the same request is re-sent once in full.
148
+ Parked ids live in memory only.
149
+
150
+ Things to know before turning it on:
151
+
152
+ - Coral keeps every parked response on its side. Deleting a parent breaks the
153
+ chain, so the extension does not delete them.
154
+ - Coral bills a chained request as the tool definitions plus the new items. The
155
+ first chained request in a session writes the tool block to cache once, and
156
+ later ones read it back. With a warm cache, a 7-request tool loop cost about
157
+ the same as a full replay on DeepSeek V4.1 Flash and about a third more on
158
+ GLM 5.3 Flash. The parked history itself is not billed again, which matters
159
+ when Coral evicts a long session's cache.
133
160
 
134
161
  Live probes verified text, automatic tool calls, tool-result replay, and switching
135
162
  back to Chat Completions on all current models. The gateway currently rejects
package/index.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * CoralBricks Provider Extension
3
3
  *
4
- * Registers CoralBricks with Chat Completions (default) or opt-in Responses.
4
+ * Registers CoralBricks with Chat Completions (default) or opt-in Responses,
5
+ * optionally parking Responses turns with previous_response_id (park.ts).
5
6
  * Base URL: https://inference.coralbricks.ai/v1
6
7
  *
7
8
  * Model resolution strategy: Stale-While-Revalidate
@@ -33,6 +34,7 @@ import { getAgentDir, type ExtensionAPI, type ModelRegistry } from "@earendil-wo
33
34
  import type { AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai/compat";
34
35
  import { clampThinkingLevel, streamOpenAICompletions, streamOpenAIResponses } from "@earendil-works/pi-ai/compat";
35
36
  import { loadConfig, registerSettingsCommand } from "./settings";
37
+ import { park, planPark, withParkFallback, type ParkPlan } from "./park";
36
38
  import modelsData from "./models.json" with { type: "json" };
37
39
  import customModelsData from "./custom-models.json" with { type: "json" };
38
40
  import patchData from "./patch.json" with { type: "json" };
@@ -449,10 +451,18 @@ async function revalidateModels(apiKey: string | undefined, embeddedModels: Json
449
451
  return merged;
450
452
  }
451
453
 
454
+ /** pi-ai's clampOpenAIPromptCacheKey, inlined: Coral accepts the same field. */
455
+ function clampPromptCacheKey(key: string | undefined): string | undefined {
456
+ if (key === undefined) return undefined;
457
+ const chars = Array.from(key);
458
+ return chars.length <= 64 ? key : chars.slice(0, 64).join("");
459
+ }
460
+
452
461
  export function streamCoral(
453
462
  model: any,
454
463
  context: any,
455
464
  options?: SimpleStreamOptions,
465
+ settings: { park?: boolean } = {},
456
466
  ): AssistantMessageEventStream {
457
467
  const apiKey = options?.apiKey || cachedApiKey || "";
458
468
  if (!apiKey) {
@@ -469,12 +479,58 @@ export function streamCoral(
469
479
  const reasoningEffort = clampedReasoning === "off" ? undefined : clampedReasoning;
470
480
  const { reasoning: _reasoning, ...streamOptions } = options ?? {};
471
481
 
472
- const stream = model.api === "openai-responses" ? streamOpenAIResponses : streamOpenAICompletions;
473
- return stream(model, context, {
482
+ // Coral groups a conversation's turns by the first session id it finds: a
483
+ // session header, else the body's prompt_cache_key. pi-ai only sends the id as
484
+ // x-session-affinity on Chat Completions when a model opts in (forced below),
485
+ // and omits prompt_cache_key there for every base URL but api.openai.com, so
486
+ // send Coral's own header on both surfaces and the body key on Completions.
487
+ const sessionId = (streamOptions as any).sessionId as string | undefined;
488
+ const sessionHeaders = sessionId
489
+ ? { "x-coral-session": sessionId, ...((streamOptions as any).headers ?? {}) }
490
+ : (streamOptions as any).headers;
491
+ const promptCacheKey = (streamOptions as any).cacheRetention === "none" ? undefined : clampPromptCacheKey(sessionId);
492
+ const withPromptCacheKey = (params: any) => {
493
+ if (!promptCacheKey || params?.prompt_cache_key !== undefined) return params;
494
+ return { ...params, prompt_cache_key: promptCacheKey };
495
+ };
496
+
497
+ const compat = { ...model.compat, sendSessionAffinityHeaders: true };
498
+ const streamModel = { ...model, compat };
499
+
500
+ if (streamModel.api !== "openai-responses") {
501
+ const onPayload = (streamOptions as any).onPayload;
502
+ return streamOpenAICompletions(streamModel, context, {
503
+ ...streamOptions,
504
+ headers: sessionHeaders,
505
+ reasoningEffort,
506
+ apiKey,
507
+ onPayload: async (params: any, payloadModel: any) => withPromptCacheKey((await onPayload?.(params, payloadModel)) ?? params),
508
+ } as any);
509
+ }
510
+ if (!settings.park) {
511
+ return streamOpenAIResponses(streamModel, context, { ...streamOptions, headers: sessionHeaders, reasoningEffort, apiKey } as any);
512
+ }
513
+
514
+ let plan: ParkPlan | undefined;
515
+ const baseFetch = (streamOptions as any).fetch ?? ((input: any, init?: any) => globalThis.fetch(input, init));
516
+ const stream = streamOpenAIResponses(streamModel, context, {
474
517
  ...streamOptions,
518
+ headers: sessionHeaders,
475
519
  reasoningEffort,
476
520
  apiKey,
521
+ fetch: withParkFallback(baseFetch, () => plan),
522
+ onPayload: async (params: any, payloadModel: any) => {
523
+ const replaced = await options?.onPayload?.(params, payloadModel);
524
+ plan = planPark(withPromptCacheKey(replaced ?? params), context?.messages, model);
525
+ return plan.params;
526
+ },
477
527
  } as any);
528
+ void stream.result().then((message: any) => {
529
+ if (plan && message?.responseId && message.stopReason !== "error" && message.stopReason !== "aborted") {
530
+ park(message.responseId, model.id, plan.input);
531
+ }
532
+ }, () => {});
533
+ return stream;
478
534
  }
479
535
 
480
536
  // API Key Resolution (via ModelRegistry)
@@ -504,7 +560,7 @@ export default function (pi: ExtensionAPI) {
504
560
  models: currentModels,
505
561
  // An already-selected model may still carry the previous API. Snapshot
506
562
  // the current selection per request, without mutating that model/history.
507
- streamSimple: (model, context, options) => streamCoral({ ...model, api: selectedApi() }, context, options),
563
+ streamSimple: (model, context, options) => streamCoral({ ...model, api: selectedApi() }, context, options, { park: config.park }),
508
564
  });
509
565
  }
510
566
  registerProvider();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-coralbricks-provider",
3
- "version": "1.0.28",
3
+ "version": "1.0.30",
4
4
  "description": "CoralBricks provider extension for pi - Access GLM 5.3 and DeepSeek V4.1 Flash (FP4 variants included) through the Coral Inference API",
5
5
  "author": "monotykamary",
6
6
  "homepage": "https://github.com/monotykamary/pi-coralbricks-provider#readme",
@@ -38,6 +38,7 @@
38
38
  "files": [
39
39
  "index.ts",
40
40
  "settings.ts",
41
+ "park.ts",
41
42
  "models.json",
42
43
  "deprecated-models.json",
43
44
  "custom-models.json",
package/park.ts ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Parking: continue Coral Responses turns with `previous_response_id`.
3
+ *
4
+ * pi replays the whole transcript on every request. With parking on, each
5
+ * response is stored on Coral (`store: true`) and the next request sends only
6
+ * the items that came after it (tool results, the next user message). Coral
7
+ * keeps the parked state, sticky-routes the chain to the same replica and
8
+ * bills only the new input.
9
+ *
10
+ * A chain is used only when the full input pi would have sent starts with
11
+ * exactly the input of the parked request, followed by that response's own
12
+ * output items. Anything else (compaction, a model switch, an edited or
13
+ * branched history, a restart) sends the full transcript, which parks again.
14
+ * If Coral no longer has the parent (`previous_response_not_found`), the same
15
+ * request is re-sent once in full.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+
19
+ type Item = Record<string, unknown>;
20
+
21
+ interface ParkedTurn {
22
+ model: string;
23
+ /** Digests of the full input sent with the request that produced this response. */
24
+ input: string[];
25
+ }
26
+
27
+ export interface ParkPlan {
28
+ /** Request body to send. */
29
+ params: Record<string, any>;
30
+ /** Full-transcript body to re-send if the parent is gone; undefined when not chained. */
31
+ fallback?: Record<string, any>;
32
+ /** Digests of the full input, recorded against the response id on success. */
33
+ input: string[];
34
+ }
35
+
36
+ const MAX_PARKED = 256;
37
+ const parked = new Map<string, ParkedTurn>();
38
+
39
+ function digest(item: unknown): string {
40
+ return createHash("sha256").update(JSON.stringify(item)).digest("base64url");
41
+ }
42
+
43
+ function isOutputItem(item: Item): boolean {
44
+ return item.type === "reasoning"
45
+ || item.type === "function_call"
46
+ || item.type === "custom_tool_call"
47
+ || (item.type === "message" && item.role === "assistant");
48
+ }
49
+
50
+ /** The last assistant message, if it is a completed Coral Responses turn from this model. */
51
+ function parentId(messages: any[] | undefined, model: { id: string; provider: string }): string | undefined {
52
+ for (let i = (messages?.length ?? 0) - 1; i >= 0; i--) {
53
+ const msg = messages![i];
54
+ if (msg?.role !== "assistant") continue;
55
+ const ok = typeof msg.responseId === "string"
56
+ && msg.provider === model.provider
57
+ && msg.api === "openai-responses"
58
+ && msg.model === model.id
59
+ && msg.stopReason !== "error"
60
+ && msg.stopReason !== "aborted";
61
+ return ok ? msg.responseId : undefined;
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ export function planPark(params: Record<string, any>, messages: any[] | undefined, model: { id: string; provider: string }): ParkPlan {
67
+ const full: Item[] = Array.isArray(params.input) ? params.input : [];
68
+ const input = full.map(digest);
69
+ const stored: Record<string, any> = { ...params, store: true };
70
+ delete stored.previous_response_id;
71
+
72
+ const id = parentId(messages, model);
73
+ const turn = id ? parked.get(id) : undefined;
74
+ if (!id || !turn || turn.model !== model.id || turn.input.length > input.length) return { params: stored, input };
75
+ for (let i = 0; i < turn.input.length; i++) {
76
+ if (turn.input[i] !== input[i]) return { params: stored, input };
77
+ }
78
+
79
+ // Skip the parent's own output; Coral already holds it.
80
+ let start = turn.input.length;
81
+ while (start < full.length && isOutputItem(full[start])) start++;
82
+ const delta = full.slice(start);
83
+ if (delta.length === 0 || delta.some(isOutputItem)) return { params: stored, input };
84
+
85
+ return { params: { ...stored, previous_response_id: id, input: delta }, fallback: stored, input };
86
+ }
87
+
88
+ export function park(responseId: string, model: string, input: string[]): void {
89
+ parked.delete(responseId);
90
+ parked.set(responseId, { model, input });
91
+ while (parked.size > MAX_PARKED) parked.delete(parked.keys().next().value!);
92
+ }
93
+
94
+ export function forget(responseId: string): void {
95
+ parked.delete(responseId);
96
+ }
97
+
98
+ export function clearParked(): void {
99
+ parked.clear();
100
+ }
101
+
102
+ export function isParentMissing(status: number, body: string): boolean {
103
+ return (status === 404 || status === 400) && body.includes("previous_response_not_found");
104
+ }
105
+
106
+ /**
107
+ * Wrap fetch so a chained request whose parent Coral no longer has is re-sent
108
+ * once with the full transcript. Every other response passes through untouched.
109
+ */
110
+ export function withParkFallback(base: typeof fetch, plan: () => ParkPlan | undefined): typeof fetch {
111
+ return async (input: any, init?: any) => {
112
+ const response = await base(input, init);
113
+ const current = plan();
114
+ if (response.ok || !current?.fallback) return response;
115
+ const body = await response.clone().text().catch(() => "");
116
+ if (!isParentMissing(response.status, body)) return response;
117
+ forget(current.params.previous_response_id);
118
+ const headers = new Headers(init?.headers);
119
+ headers.delete("content-length");
120
+ return base(input, { ...init, headers, body: JSON.stringify(current.fallback) });
121
+ };
122
+ }
package/settings.ts CHANGED
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  export type CoralApi = "chat-completions" | "responses";
7
- export interface CoralConfig { api: CoralApi }
7
+ export interface CoralConfig { api: CoralApi; park: boolean }
8
8
 
9
9
  export function configPath(): string {
10
10
  return path.join(getAgentDir(), "extensions", "coralbricks.json");
@@ -23,16 +23,16 @@ function readRawConfig(): Record<string, unknown> {
23
23
 
24
24
  export function loadConfig(): CoralConfig {
25
25
  try {
26
- return { api: readRawConfig().api === "responses" ? "responses" : "chat-completions" };
26
+ const raw = readRawConfig();
27
+ return { api: raw.api === "responses" ? "responses" : "chat-completions", park: raw.park === true };
27
28
  } catch {
28
- return { api: "chat-completions" };
29
+ return { api: "chat-completions", park: false };
29
30
  }
30
31
  }
31
32
 
32
33
  /** Preserve unrelated fields; never overwrite malformed settings. */
33
- export function saveApi(api: CoralApi): void {
34
- const raw = readRawConfig();
35
- raw.api = api;
34
+ export function saveConfig(patch: Partial<CoralConfig>): void {
35
+ const raw = { ...readRawConfig(), ...patch };
36
36
  const file = configPath();
37
37
  fs.mkdirSync(path.dirname(file), { recursive: true });
38
38
  const temporary = `${file}.${randomUUID()}.tmp`;
@@ -44,35 +44,49 @@ export function saveApi(api: CoralApi): void {
44
44
  }
45
45
  }
46
46
 
47
+ export function saveApi(api: CoralApi): void {
48
+ saveConfig({ api });
49
+ }
50
+
51
+ const PARK_DESCRIPTION = "Responses only. Stores each response on Coral (store:true) and sends only new items with previous_response_id. Falls back to a full replay when the chain does not match.";
52
+
47
53
  export function registerSettingsCommand(
48
54
  pi: ExtensionAPI,
49
55
  getConfig: () => CoralConfig,
50
56
  apply: (config: CoralConfig) => void,
51
57
  ): void {
52
58
  pi.registerCommand("coralbricks-settings", {
53
- description: "Configure CoralBricks API: Chat Completions or opt-in Responses",
59
+ description: "Configure CoralBricks API (Chat Completions or opt-in Responses) and Responses parking",
54
60
  async handler(_args, ctx) {
55
61
  if (!ctx.hasUI) {
56
62
  ctx.ui.notify("/coralbricks-settings requires a UI (TUI or GUI).", "error");
57
63
  return;
58
64
  }
59
65
  const values: CoralApi[] = ["chat-completions", "responses"];
60
- const change = (value: string): boolean => {
61
- if (!values.includes(value as CoralApi)) return false;
66
+ const parkValues = ["off", "on"];
67
+ const change = (id: string, value: string): boolean => {
68
+ let patch: Partial<CoralConfig>;
69
+ if (id === "api" && values.includes(value as CoralApi)) patch = { api: value as CoralApi };
70
+ else if (id === "park" && parkValues.includes(value)) patch = { park: value === "on" };
71
+ else return false;
62
72
  try {
63
- saveApi(value as CoralApi);
73
+ saveConfig(patch);
64
74
  } catch {
65
75
  ctx.ui.notify(`Could not save ${configPath()}. Check its JSON and permissions; API unchanged.`, "error");
66
76
  return false;
67
77
  }
68
- apply({ api: value as CoralApi });
69
- ctx.ui.notify(`CoralBricks API: ${value} — applies to the next request.`, "info");
78
+ const next = { ...getConfig(), ...patch };
79
+ apply(next);
80
+ const label = id === "api" ? `CoralBricks API: ${value}` : `CoralBricks parking: ${value}`;
81
+ ctx.ui.notify(`${label} — applies to the next request.`, "info");
70
82
  return true;
71
83
  };
72
84
 
73
85
  if (ctx.mode !== "tui") {
74
86
  const selected = await ctx.ui.select(`CoralBricks API (current: ${getConfig().api})`, values);
75
- if (selected !== undefined) change(selected);
87
+ if (selected === undefined || !change("api", selected) || selected !== "responses") return;
88
+ const park = await ctx.ui.select(`Park Responses turns with previous_response_id? (current: ${getConfig().park ? "on" : "off"})`, parkValues);
89
+ if (park !== undefined) change("park", park);
76
90
  return;
77
91
  }
78
92
 
@@ -87,12 +101,19 @@ export function registerSettingsCommand(
87
101
  {
88
102
  id: "api",
89
103
  label: "API surface",
90
- description: "Chat Completions is the default. Responses is opt-in, replays full history with store:false; no server-side threading or background jobs.",
104
+ description: "Chat Completions is the default. Responses is opt-in; it replays full history with store:false unless parking is on.",
91
105
  currentValue: getConfig().api,
92
106
  values,
93
107
  },
94
- ], 3, getSettingsListTheme(), (_id, value) => {
95
- if (!change(value)) list.updateValue("api", getConfig().api);
108
+ {
109
+ id: "park",
110
+ label: "Park Responses turns",
111
+ description: PARK_DESCRIPTION,
112
+ currentValue: getConfig().park ? "on" : "off",
113
+ values: parkValues,
114
+ },
115
+ ], 3, getSettingsListTheme(), (id, value) => {
116
+ if (!change(id, value)) list.updateValue(id, id === "api" ? getConfig().api : getConfig().park ? "on" : "off");
96
117
  }, () => done(), { enableSearch: true });
97
118
  container.addChild(list);
98
119
  container.addChild(border());