@oh-my-pi/pi-agent-core 16.3.11 → 16.3.13
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/CHANGELOG.md +11 -0
- package/dist/types/agent-loop.d.ts +18 -0
- package/dist/types/compaction/compaction.d.ts +3 -2
- package/dist/types/compaction/openai.d.ts +17 -0
- package/package.json +7 -7
- package/src/agent-loop.ts +65 -1
- package/src/compaction/compaction.ts +49 -28
- package/src/compaction/openai.ts +67 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [16.3.12] - 2026-07-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added per-tool abort metadata so stream-wide aborts can label matching tool-call placeholders separately from unaffected sibling calls ([#2783](https://github.com/can1357/oh-my-pi/issues/2783)).
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed handoff generation retrying with `toolChoice: "auto"` when custom OpenAI-compatible providers reject `toolChoice: "none"` with an auto-only 400. ([#4715](https://github.com/can1357/oh-my-pi/issues/4715))
|
|
14
|
+
- Fixed generic remote compaction against OpenAI-compatible `/chat/completions` endpoints (for example llama.cpp `openai-completions`) by sending chat messages instead of the custom `{ systemPrompt, prompt }` summarizer payload. ([#4630](https://github.com/can1357/oh-my-pi/issues/4630))
|
|
15
|
+
|
|
5
16
|
## [16.3.7] - 2026-07-05
|
|
6
17
|
|
|
7
18
|
### Fixed
|
|
@@ -8,6 +8,24 @@ import { type AgentRunCoverage, type AgentRunSummary } from "./run-collector.js"
|
|
|
8
8
|
import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, StreamFn } from "./types.js";
|
|
9
9
|
/** Stop-details marker for a provider error after assistant content/tool args already streamed. */
|
|
10
10
|
export declare const STREAM_INTERRUPTED_AFTER_CONTENT_STOP_DETAIL = "stream_interrupted_after_content";
|
|
11
|
+
/**
|
|
12
|
+
* Cadence (ms) for polling queued steering while an `interruptible` tool is in
|
|
13
|
+
* flight, so a steer cuts the wait short instead of sitting idle until the
|
|
14
|
+
* tool's own window elapses. A cheap synchronous queue check; latency-bounded
|
|
15
|
+
* at one tick.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Abort reason for a turn-wide interruption where only some tool calls caused
|
|
19
|
+
* the abort and sibling placeholders need neutral messages.
|
|
20
|
+
*/
|
|
21
|
+
export interface ToolScopedAbortReason {
|
|
22
|
+
readonly kind: "tool-scoped-abort";
|
|
23
|
+
readonly message: string;
|
|
24
|
+
readonly toolCallMessages: Record<string, string>;
|
|
25
|
+
readonly defaultToolCallMessage: string;
|
|
26
|
+
}
|
|
27
|
+
/** Creates an abort reason that labels matching tool calls separately from siblings. */
|
|
28
|
+
export declare function createToolScopedAbortReason(message: string, toolCallMessages: Record<string, string>, defaultToolCallMessage: string): ToolScopedAbortReason;
|
|
11
29
|
export declare function resolveOwnedDialectFromEnv(value: string | undefined): Dialect | undefined;
|
|
12
30
|
/**
|
|
13
31
|
* Start an agent loop with a new prompt message.
|
|
@@ -249,8 +249,9 @@ export interface HandoffFromContextOptions {
|
|
|
249
249
|
* `streamOptions` that mirror the live turn's cache routing. That keeps the
|
|
250
250
|
* cache-preserving context construction in the host (which owns the transform
|
|
251
251
|
* pipeline) while this function centralizes the handoff request contract:
|
|
252
|
-
* `toolChoice: "none"`, clamped reasoning effort,
|
|
253
|
-
*
|
|
252
|
+
* cache-first `toolChoice: "none"`, clamped reasoning effort, one retry for
|
|
253
|
+
* auto-only `tool_choice` providers, oneshot telemetry, text-only extraction,
|
|
254
|
+
* and provider-error mapping.
|
|
254
255
|
*/
|
|
255
256
|
export declare function generateHandoffFromContext(context: Context, model: Model, options: HandoffFromContextOptions): Promise<string>;
|
|
256
257
|
export declare function generateHandoff(messages: AgentMessage[], model: Model, apiKey: ApiKey, options: HandoffOptions, signal?: AbortSignal): Promise<string>;
|
|
@@ -71,7 +71,24 @@ export declare function requestOpenAiRemoteCompaction(model: Model, apiKey: stri
|
|
|
71
71
|
fetch?: FetchImpl;
|
|
72
72
|
timeoutMs?: number;
|
|
73
73
|
}): Promise<OpenAiRemoteCompactionResponse>;
|
|
74
|
+
/**
|
|
75
|
+
* Generic remote-compaction POST. Two wire shapes are auto-selected by
|
|
76
|
+
* endpoint suffix so a single `compaction.remoteEndpoint` setting can point at
|
|
77
|
+
* either a purpose-built omp summarizer (`{systemPrompt, prompt}` → `{summary}`)
|
|
78
|
+
* or any OpenAI-compatible chat-completions server (`/chat/completions`,
|
|
79
|
+
* `/v1/chat/completions`, …) as reported for llama.cpp / vLLM / etc. in
|
|
80
|
+
* issue #4630: without this, the omp payload was rejected with
|
|
81
|
+
* HTTP 400 `"'messages' is required"`, compaction silently fell back to
|
|
82
|
+
* local summarization, and context grew unbounded.
|
|
83
|
+
*
|
|
84
|
+
* When `context.model` is provided the chat-completions body is tagged with
|
|
85
|
+
* that model's wire id (llama.cpp requires the field) and `context.apiKey` is
|
|
86
|
+
* forwarded as `Authorization: Bearer`. Callers wrap this in `withAuth` so
|
|
87
|
+
* 401s force-refresh through the standard credential rotation policy.
|
|
88
|
+
*/
|
|
74
89
|
export declare function requestRemoteCompaction(endpoint: string, request: RemoteCompactionRequest, signal?: AbortSignal, opts?: {
|
|
75
90
|
fetch?: FetchImpl;
|
|
76
91
|
timeoutMs?: number;
|
|
92
|
+
model?: Model;
|
|
93
|
+
apiKey?: string;
|
|
77
94
|
}): Promise<RemoteCompactionResponse>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-agent-core",
|
|
4
|
-
"version": "16.3.
|
|
4
|
+
"version": "16.3.13",
|
|
5
5
|
"description": "General-purpose agent with transport abstraction, state management, and attachment support",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -35,12 +35,12 @@
|
|
|
35
35
|
"fmt": "biome format --write ."
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@oh-my-pi/pi-ai": "16.3.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "16.3.
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.3.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.3.
|
|
42
|
-
"@oh-my-pi/pi-wire": "16.3.
|
|
43
|
-
"@oh-my-pi/snapcompact": "16.3.
|
|
38
|
+
"@oh-my-pi/pi-ai": "16.3.13",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "16.3.13",
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.3.13",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.3.13",
|
|
42
|
+
"@oh-my-pi/pi-wire": "16.3.13",
|
|
43
|
+
"@oh-my-pi/snapcompact": "16.3.13",
|
|
44
44
|
"@opentelemetry/api": "^1.9.1"
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
package/src/agent-loop.ts
CHANGED
|
@@ -112,6 +112,26 @@ function hardToolChoiceBlocks(choice: ToolChoice | undefined, requiredTool: stri
|
|
|
112
112
|
* tool's own window elapses. A cheap synchronous queue check; latency-bounded
|
|
113
113
|
* at one tick.
|
|
114
114
|
*/
|
|
115
|
+
/**
|
|
116
|
+
* Abort reason for a turn-wide interruption where only some tool calls caused
|
|
117
|
+
* the abort and sibling placeholders need neutral messages.
|
|
118
|
+
*/
|
|
119
|
+
export interface ToolScopedAbortReason {
|
|
120
|
+
readonly kind: "tool-scoped-abort";
|
|
121
|
+
readonly message: string;
|
|
122
|
+
readonly toolCallMessages: Record<string, string>;
|
|
123
|
+
readonly defaultToolCallMessage: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Creates an abort reason that labels matching tool calls separately from siblings. */
|
|
127
|
+
export function createToolScopedAbortReason(
|
|
128
|
+
message: string,
|
|
129
|
+
toolCallMessages: Record<string, string>,
|
|
130
|
+
defaultToolCallMessage: string,
|
|
131
|
+
): ToolScopedAbortReason {
|
|
132
|
+
return { kind: "tool-scoped-abort", message, toolCallMessages, defaultToolCallMessage };
|
|
133
|
+
}
|
|
134
|
+
|
|
115
135
|
const STEERING_INTERRUPT_POLL_MS = 250;
|
|
116
136
|
|
|
117
137
|
class HarmonyLeakInterruption extends Error {
|
|
@@ -173,6 +193,7 @@ function snapshotAssistantMessage(message: AssistantMessage): AssistantMessage {
|
|
|
173
193
|
cost: { ...message.usage.cost },
|
|
174
194
|
},
|
|
175
195
|
disabledFeatures: message.disabledFeatures ? [...message.disabledFeatures] : undefined,
|
|
196
|
+
toolCallAbortMessages: message.toolCallAbortMessages ? { ...message.toolCallAbortMessages } : undefined,
|
|
176
197
|
};
|
|
177
198
|
}
|
|
178
199
|
|
|
@@ -918,9 +939,17 @@ async function runLoopBody(
|
|
|
918
939
|
(c): c is ToolCallContent =>
|
|
919
940
|
c.type === "toolCall" && (c as CursorExecResolvedCarrier)[kCursorExecResolved] !== true,
|
|
920
941
|
);
|
|
942
|
+
// Provider-built aborted messages (stream error events) carry no
|
|
943
|
+
// per-tool labels; derive them from a tool-scoped abort signal so
|
|
944
|
+
// only the matching call is blamed and siblings stay neutral.
|
|
945
|
+
const scopedAbort = toolScopedAbortReason(signal);
|
|
946
|
+
const toolCallAbortMessages =
|
|
947
|
+
message.toolCallAbortMessages ??
|
|
948
|
+
(scopedAbort ? buildToolCallAbortMessages(message, scopedAbort) : undefined);
|
|
921
949
|
const toolResults: ToolResultMessage[] = [];
|
|
922
950
|
for (const toolCall of toolCalls) {
|
|
923
|
-
const
|
|
951
|
+
const errorMessage = toolCallAbortMessages?.[toolCall.id] ?? message.errorMessage;
|
|
952
|
+
const result = createAbortedToolResult(toolCall, stream, message.stopReason, errorMessage);
|
|
924
953
|
currentContext.messages.push(result);
|
|
925
954
|
newMessages.push(result);
|
|
926
955
|
toolResults.push(result);
|
|
@@ -1610,6 +1639,34 @@ function emitDiscardedHarmonyPartial(
|
|
|
1610
1639
|
});
|
|
1611
1640
|
}
|
|
1612
1641
|
|
|
1642
|
+
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
1643
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1644
|
+
return Object.values(value).every(child => typeof child === "string");
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
function toolScopedAbortReason(signal: AbortSignal | undefined): ToolScopedAbortReason | undefined {
|
|
1648
|
+
const reason = signal?.reason;
|
|
1649
|
+
if (!reason || typeof reason !== "object") return undefined;
|
|
1650
|
+
if (Reflect.get(reason, "kind") !== "tool-scoped-abort") return undefined;
|
|
1651
|
+
if (typeof Reflect.get(reason, "message") !== "string") return undefined;
|
|
1652
|
+
if (typeof Reflect.get(reason, "defaultToolCallMessage") !== "string") return undefined;
|
|
1653
|
+
return isStringRecord(Reflect.get(reason, "toolCallMessages")) ? reason : undefined;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
function buildToolCallAbortMessages(
|
|
1657
|
+
message: AssistantMessage,
|
|
1658
|
+
reason: ToolScopedAbortReason,
|
|
1659
|
+
): Record<string, string> | undefined {
|
|
1660
|
+
let hasToolCall = false;
|
|
1661
|
+
const messages: Record<string, string> = {};
|
|
1662
|
+
for (const block of message.content) {
|
|
1663
|
+
if (block.type !== "toolCall") continue;
|
|
1664
|
+
hasToolCall = true;
|
|
1665
|
+
messages[block.id] = reason.toolCallMessages[block.id] ?? reason.defaultToolCallMessage;
|
|
1666
|
+
}
|
|
1667
|
+
return hasToolCall ? messages : undefined;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1613
1670
|
/** Resolve the human-readable reason an abort carried. A caller that aborts via
|
|
1614
1671
|
* `AbortController.abort(reason)` with a string or a non-`AbortError` `Error`
|
|
1615
1672
|
* (e.g. the coding agent's user-interrupt label) gets that text surfaced on the
|
|
@@ -1617,6 +1674,8 @@ function emitDiscardedHarmonyPartial(
|
|
|
1617
1674
|
* `signal.reason` is the default `AbortError` `DOMException`) falls back to the
|
|
1618
1675
|
* generic sentinel that downstream renderers treat as "no specific reason". */
|
|
1619
1676
|
export function abortReasonText(signal: AbortSignal | undefined): string {
|
|
1677
|
+
const scopedReason = toolScopedAbortReason(signal);
|
|
1678
|
+
if (scopedReason) return scopedReason.message;
|
|
1620
1679
|
const reason = signal?.reason;
|
|
1621
1680
|
if (typeof reason === "string" && reason.trim().length > 0) return reason;
|
|
1622
1681
|
if (reason instanceof Error && reason.name !== "AbortError" && reason.message.trim().length > 0) {
|
|
@@ -1665,6 +1724,11 @@ function emitAbortedAssistantMessage(
|
|
|
1665
1724
|
// labeled user interrupt still surfaces through `errorMessage`, but partial
|
|
1666
1725
|
// tool arguments are unsafe to keep and can carry incomplete provider IDs.
|
|
1667
1726
|
const retained = retainCompletedToolCalls(base, completedToolCallIds);
|
|
1727
|
+
const scopedAbort = toolScopedAbortReason(requestSignal);
|
|
1728
|
+
const toolCallAbortMessages = scopedAbort ? buildToolCallAbortMessages(retained, scopedAbort) : undefined;
|
|
1729
|
+
if (toolCallAbortMessages) {
|
|
1730
|
+
retained.toolCallAbortMessages = toolCallAbortMessages;
|
|
1731
|
+
}
|
|
1668
1732
|
const abortedMessage = snapshotAssistantMessage(retained);
|
|
1669
1733
|
if (addedPartial) {
|
|
1670
1734
|
context.messages[context.messages.length - 1] = abortedMessage;
|
|
@@ -698,6 +698,12 @@ function createSummarizationError(prefix: string, response: AssistantMessage): E
|
|
|
698
698
|
return response.errorStatus === undefined ? new Error(text) : new ProviderHttpError(text, response.errorStatus);
|
|
699
699
|
}
|
|
700
700
|
|
|
701
|
+
function shouldRetryHandoffWithAutoToolChoice(response: AssistantMessage): boolean {
|
|
702
|
+
if (response.errorStatus !== 400) return false;
|
|
703
|
+
const message = response.errorMessage ?? "";
|
|
704
|
+
return /\btool_choice\b/i.test(message) && /\bauto\b/i.test(message) && /\bsupported\b/i.test(message);
|
|
705
|
+
}
|
|
706
|
+
|
|
701
707
|
/**
|
|
702
708
|
* Generate a summary of the conversation using the LLM.
|
|
703
709
|
* If previousSummary is provided, uses the update prompt to merge.
|
|
@@ -813,14 +819,17 @@ export async function generateSummary(
|
|
|
813
819
|
];
|
|
814
820
|
|
|
815
821
|
if (options?.remoteEndpoint) {
|
|
816
|
-
const
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
822
|
+
const endpoint = options.remoteEndpoint;
|
|
823
|
+
const remote = await withAuth(
|
|
824
|
+
apiKey,
|
|
825
|
+
key =>
|
|
826
|
+
requestRemoteCompaction(
|
|
827
|
+
endpoint,
|
|
828
|
+
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText },
|
|
829
|
+
signal,
|
|
830
|
+
{ fetch: options.fetch, model, apiKey: key },
|
|
831
|
+
),
|
|
832
|
+
{ signal, missingKeyMessage: "Remote compaction credentials unavailable" },
|
|
824
833
|
);
|
|
825
834
|
return remote.summary;
|
|
826
835
|
}
|
|
@@ -917,24 +926,33 @@ export interface HandoffFromContextOptions {
|
|
|
917
926
|
* `streamOptions` that mirror the live turn's cache routing. That keeps the
|
|
918
927
|
* cache-preserving context construction in the host (which owns the transform
|
|
919
928
|
* pipeline) while this function centralizes the handoff request contract:
|
|
920
|
-
* `toolChoice: "none"`, clamped reasoning effort,
|
|
921
|
-
*
|
|
929
|
+
* cache-first `toolChoice: "none"`, clamped reasoning effort, one retry for
|
|
930
|
+
* auto-only `tool_choice` providers, oneshot telemetry, text-only extraction,
|
|
931
|
+
* and provider-error mapping.
|
|
922
932
|
*/
|
|
923
933
|
export async function generateHandoffFromContext(
|
|
924
934
|
context: Context,
|
|
925
935
|
model: Model,
|
|
926
936
|
options: HandoffFromContextOptions,
|
|
927
937
|
): Promise<string> {
|
|
928
|
-
const
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
);
|
|
938
|
+
const requestOptions = {
|
|
939
|
+
...options.streamOptions,
|
|
940
|
+
reasoning: resolveCompactionEffort(model, options.thinkingLevel),
|
|
941
|
+
toolChoice: "none" as const,
|
|
942
|
+
};
|
|
943
|
+
let response = await instrumentedCompleteSimple(model, context, requestOptions, {
|
|
944
|
+
telemetry: options.telemetry,
|
|
945
|
+
oneshotKind: "handoff",
|
|
946
|
+
completeImpl: options.completeImpl,
|
|
947
|
+
});
|
|
948
|
+
if (response.stopReason === "error" && shouldRetryHandoffWithAutoToolChoice(response)) {
|
|
949
|
+
response = await instrumentedCompleteSimple(
|
|
950
|
+
model,
|
|
951
|
+
context,
|
|
952
|
+
{ ...requestOptions, toolChoice: "auto" },
|
|
953
|
+
{ telemetry: options.telemetry, oneshotKind: "handoff", completeImpl: options.completeImpl },
|
|
954
|
+
);
|
|
955
|
+
}
|
|
938
956
|
|
|
939
957
|
if (response.stopReason === "error") {
|
|
940
958
|
throw createSummarizationError("Handoff generation failed", response);
|
|
@@ -1001,14 +1019,17 @@ async function generateShortSummary(
|
|
|
1001
1019
|
promptText += SHORT_SUMMARY_PROMPT;
|
|
1002
1020
|
|
|
1003
1021
|
if (options?.remoteEndpoint) {
|
|
1004
|
-
const
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1022
|
+
const endpoint = options.remoteEndpoint;
|
|
1023
|
+
const remote = await withAuth(
|
|
1024
|
+
apiKey,
|
|
1025
|
+
key =>
|
|
1026
|
+
requestRemoteCompaction(
|
|
1027
|
+
endpoint,
|
|
1028
|
+
{ systemPrompt: SUMMARIZATION_SYSTEM_PROMPT, prompt: promptText },
|
|
1029
|
+
signal,
|
|
1030
|
+
{ fetch: options?.fetch, model, apiKey: key },
|
|
1031
|
+
),
|
|
1032
|
+
{ signal, missingKeyMessage: "Remote compaction credentials unavailable" },
|
|
1012
1033
|
);
|
|
1013
1034
|
return remote.summary;
|
|
1014
1035
|
}
|
package/src/compaction/openai.ts
CHANGED
|
@@ -547,16 +547,55 @@ export async function requestOpenAiRemoteCompaction(
|
|
|
547
547
|
return { provider: model.provider, replacementHistory, compactionItem };
|
|
548
548
|
}
|
|
549
549
|
|
|
550
|
+
/**
|
|
551
|
+
* Generic remote-compaction POST. Two wire shapes are auto-selected by
|
|
552
|
+
* endpoint suffix so a single `compaction.remoteEndpoint` setting can point at
|
|
553
|
+
* either a purpose-built omp summarizer (`{systemPrompt, prompt}` → `{summary}`)
|
|
554
|
+
* or any OpenAI-compatible chat-completions server (`/chat/completions`,
|
|
555
|
+
* `/v1/chat/completions`, …) as reported for llama.cpp / vLLM / etc. in
|
|
556
|
+
* issue #4630: without this, the omp payload was rejected with
|
|
557
|
+
* HTTP 400 `"'messages' is required"`, compaction silently fell back to
|
|
558
|
+
* local summarization, and context grew unbounded.
|
|
559
|
+
*
|
|
560
|
+
* When `context.model` is provided the chat-completions body is tagged with
|
|
561
|
+
* that model's wire id (llama.cpp requires the field) and `context.apiKey` is
|
|
562
|
+
* forwarded as `Authorization: Bearer`. Callers wrap this in `withAuth` so
|
|
563
|
+
* 401s force-refresh through the standard credential rotation policy.
|
|
564
|
+
*/
|
|
550
565
|
export async function requestRemoteCompaction(
|
|
551
566
|
endpoint: string,
|
|
552
567
|
request: RemoteCompactionRequest,
|
|
553
568
|
signal?: AbortSignal,
|
|
554
|
-
opts?: { fetch?: FetchImpl; timeoutMs?: number },
|
|
569
|
+
opts?: { fetch?: FetchImpl; timeoutMs?: number; model?: Model; apiKey?: string },
|
|
555
570
|
): Promise<RemoteCompactionResponse> {
|
|
571
|
+
let endpointPath = endpoint;
|
|
572
|
+
try {
|
|
573
|
+
endpointPath = new URL(endpoint).pathname;
|
|
574
|
+
} catch {
|
|
575
|
+
// Keep the raw endpoint for relative/custom fetch implementations.
|
|
576
|
+
}
|
|
577
|
+
const isChatCompletions = /\/chat\/completions\/?$/.test(endpointPath);
|
|
578
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
579
|
+
if (isChatCompletions) {
|
|
580
|
+
if (opts?.apiKey) headers.Authorization = `Bearer ${opts.apiKey}`;
|
|
581
|
+
if (opts?.model?.headers) Object.assign(headers, opts.model.headers);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const body: Record<string, unknown> = isChatCompletions
|
|
585
|
+
? {
|
|
586
|
+
model: opts?.model ? resolveOpenAiCompactModel(opts.model) : undefined,
|
|
587
|
+
messages: [
|
|
588
|
+
{ role: "system", content: request.systemPrompt },
|
|
589
|
+
{ role: "user", content: request.prompt },
|
|
590
|
+
],
|
|
591
|
+
stream: false,
|
|
592
|
+
}
|
|
593
|
+
: { systemPrompt: request.systemPrompt, prompt: request.prompt };
|
|
594
|
+
|
|
556
595
|
const response = await (opts?.fetch ?? fetch)(endpoint, {
|
|
557
596
|
method: "POST",
|
|
558
|
-
headers
|
|
559
|
-
body: JSON.stringify(
|
|
597
|
+
headers,
|
|
598
|
+
body: JSON.stringify(body),
|
|
560
599
|
signal: withRequestTimeout(signal, opts?.timeoutMs ?? REMOTE_COMPACTION_TIMEOUT_MS),
|
|
561
600
|
});
|
|
562
601
|
|
|
@@ -577,6 +616,31 @@ export async function requestRemoteCompaction(
|
|
|
577
616
|
);
|
|
578
617
|
}
|
|
579
618
|
|
|
619
|
+
if (isChatCompletions) {
|
|
620
|
+
type ChatCompletionsResponse = {
|
|
621
|
+
choices?: Array<{
|
|
622
|
+
message?: {
|
|
623
|
+
content?: string | Array<{ type?: string; text?: string }> | null;
|
|
624
|
+
};
|
|
625
|
+
}>;
|
|
626
|
+
};
|
|
627
|
+
const data = (await response.json()) as ChatCompletionsResponse | undefined;
|
|
628
|
+
const choice = data?.choices?.[0]?.message?.content;
|
|
629
|
+
let summary: string | undefined;
|
|
630
|
+
if (typeof choice === "string") {
|
|
631
|
+
summary = choice;
|
|
632
|
+
} else if (Array.isArray(choice)) {
|
|
633
|
+
summary = choice
|
|
634
|
+
.filter((part): part is { type?: string; text: string } => typeof part?.text === "string")
|
|
635
|
+
.map(part => part.text)
|
|
636
|
+
.join("");
|
|
637
|
+
}
|
|
638
|
+
if (typeof summary !== "string" || summary.length === 0) {
|
|
639
|
+
throw new Error("Remote compaction response missing choices[0].message.content");
|
|
640
|
+
}
|
|
641
|
+
return { summary };
|
|
642
|
+
}
|
|
643
|
+
|
|
580
644
|
const data = (await response.json()) as RemoteCompactionResponse | undefined;
|
|
581
645
|
if (!data || typeof data.summary !== "string") {
|
|
582
646
|
throw new Error("Remote compaction response missing summary");
|