@ultimat3/ai 27.8.1 → 27.10.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/CLAUDE.md +137 -137
- package/README.md +95 -4
- package/package.json +11 -11
- package/src/agent-continue.ts +434 -0
- package/src/agent-declared.ts +157 -0
- package/src/agent-def.ts +134 -0
- package/src/agent-errors.ts +69 -0
- package/src/agent-guards.ts +114 -0
- package/src/agent-system.ts +37 -0
- package/src/agent-turn.ts +54 -0
- package/src/agent-usage.ts +63 -0
- package/src/agent.ts +190 -250
- package/src/answer-errors.ts +88 -0
- package/src/billed.ts +87 -0
- package/src/budget.ts +40 -12
- package/src/cost.ts +146 -0
- package/src/echo-provider.ts +3 -1
- package/src/errors-retry.ts +14 -0
- package/src/errors.ts +26 -19
- package/src/failure-disclosure.ts +37 -1
- package/src/gateway.ts +62 -9
- package/src/in-process-tool.ts +77 -0
- package/src/index.ts +47 -3
- package/src/llm-def.ts +97 -0
- package/src/llm.ts +89 -66
- package/src/models.ts +45 -1
- package/src/openai-body.ts +56 -4
- package/src/openai-messages.ts +57 -5
- package/src/openai-provider.ts +26 -5
- package/src/openai-wire.ts +184 -69
- package/src/prompt-cache-blocks.ts +114 -0
- package/src/prompt-cache.ts +189 -0
- package/src/provider.ts +71 -45
- package/src/run-meter.ts +147 -0
- package/src/sampling.ts +86 -0
- package/src/tool-arguments.ts +88 -0
- package/src/tools.ts +51 -6
- package/src/wire.ts +15 -14
package/src/openai-provider.ts
CHANGED
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
|
|
10
10
|
import type { Secret } from '@ultimat3/core';
|
|
11
11
|
import { isSecret, revealSecret } from '@ultimat3/core';
|
|
12
|
+
import { costOf, exactCostOf } from './cost';
|
|
12
13
|
import { detailOf, withoutKey } from './error-body';
|
|
13
14
|
import { AiKeyMissingError, AiRequestInvalidError, AiTransportError } from './errors';
|
|
14
15
|
import type { AiFetch } from './fetch-seam';
|
|
15
16
|
import { resolveModel } from './model-resolve';
|
|
16
17
|
import type { ModelId } from './models';
|
|
17
|
-
import { chatCompletionBody } from './openai-body';
|
|
18
|
+
import { chatCompletionBody, sessionHeaders } from './openai-body';
|
|
18
19
|
import type { ChatAnswer } from './openai-wire';
|
|
19
20
|
import { ChatCompletionStream, parseChatCompletion } from './openai-wire';
|
|
21
|
+
import { promptCacheRule } from './prompt-cache';
|
|
20
22
|
import type {
|
|
21
23
|
GenerateRequest,
|
|
22
24
|
GenerateResult,
|
|
@@ -24,8 +26,9 @@ import type {
|
|
|
24
26
|
StreamChunk,
|
|
25
27
|
TokenUsage,
|
|
26
28
|
} from './provider';
|
|
27
|
-
import {
|
|
29
|
+
import { estimateInputTokens, estimateTextTokens, requiresStreaming } from './provider';
|
|
28
30
|
import { readSse } from './sse';
|
|
31
|
+
import { assertToolArguments } from './tool-arguments';
|
|
29
32
|
import type { LlmToolCall } from './tools';
|
|
30
33
|
|
|
31
34
|
const API_KEY_ENV = 'OPENAI_API_KEY';
|
|
@@ -84,6 +87,7 @@ export function openAiProvider(input: OpenAiProviderInput): Provider {
|
|
|
84
87
|
}
|
|
85
88
|
|
|
86
89
|
class OpenAiProvider implements Provider {
|
|
90
|
+
readonly wire = 'openai' as const;
|
|
87
91
|
readonly name: string;
|
|
88
92
|
readonly models: readonly ModelId[];
|
|
89
93
|
private readonly config: OpenAiProviderInput;
|
|
@@ -105,6 +109,10 @@ class OpenAiProvider implements Provider {
|
|
|
105
109
|
this.config = config;
|
|
106
110
|
}
|
|
107
111
|
|
|
112
|
+
marksPromptCache(model: ModelId): boolean {
|
|
113
|
+
return promptCacheRule('openai', this.config.baseUrl, model).marks === 'parts';
|
|
114
|
+
}
|
|
115
|
+
|
|
108
116
|
/**
|
|
109
117
|
* Above `STREAM_ONLY_MAX_TOKENS` this runs the streaming transport and assembles the result,
|
|
110
118
|
* for the reason the Anthropic provider does: a non-streaming request that large sits on an
|
|
@@ -115,10 +123,12 @@ class OpenAiProvider implements Provider {
|
|
|
115
123
|
// this provider's first listed model — the app's three places are the only ones.
|
|
116
124
|
const model = resolveModel('provider', request.model);
|
|
117
125
|
if (requiresStreaming({ ...request, model })) return this.assemble(request);
|
|
126
|
+
const { baseUrl } = this.config;
|
|
118
127
|
const response = await this.send(
|
|
119
|
-
chatCompletionBody({ request, model, stream: false, provider: this.name }),
|
|
128
|
+
chatCompletionBody({ request, model, stream: false, provider: this.name, baseUrl }),
|
|
120
129
|
false,
|
|
121
130
|
request.signal,
|
|
131
|
+
sessionHeaders(request, model, baseUrl),
|
|
122
132
|
);
|
|
123
133
|
const answer = parseChatCompletion((await response.json()) as unknown, this.name);
|
|
124
134
|
return this.result(request, model, answer);
|
|
@@ -126,10 +136,12 @@ class OpenAiProvider implements Provider {
|
|
|
126
136
|
|
|
127
137
|
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
128
138
|
const model = resolveModel('provider', request.model);
|
|
139
|
+
const { baseUrl } = this.config;
|
|
129
140
|
const response = await this.send(
|
|
130
|
-
chatCompletionBody({ request, model, stream: true, provider: this.name }),
|
|
141
|
+
chatCompletionBody({ request, model, stream: true, provider: this.name, baseUrl }),
|
|
131
142
|
true,
|
|
132
143
|
request.signal,
|
|
144
|
+
sessionHeaders(request, model, baseUrl),
|
|
133
145
|
);
|
|
134
146
|
if (response.body === null) {
|
|
135
147
|
throw new AiTransportError({
|
|
@@ -142,6 +154,8 @@ class OpenAiProvider implements Provider {
|
|
|
142
154
|
for await (const frame of readSse(response.body, this.name)) {
|
|
143
155
|
for (const chunk of completion.push(frame)) yield chunk;
|
|
144
156
|
}
|
|
157
|
+
// The socket closed: the calls of a finished turn, for a server that never sends `[DONE]`.
|
|
158
|
+
for (const chunk of completion.end()) yield chunk;
|
|
145
159
|
// A connection cut mid-answer must fail, not resolve: partial text reads as a complete answer,
|
|
146
160
|
// and `end_turn` would be a lie the caller has no way to detect.
|
|
147
161
|
if (!completion.isComplete()) {
|
|
@@ -167,6 +181,9 @@ class OpenAiProvider implements Provider {
|
|
|
167
181
|
/** One parsed answer, priced. The only place `cost` is applied — the provider owns prices. */
|
|
168
182
|
private result(request: GenerateRequest, model: ModelId, answer: ChatAnswer): GenerateResult {
|
|
169
183
|
const usage = answer.usage ?? estimatedUsage(request, answer.text, answer.toolCalls);
|
|
184
|
+
const cost = costOf(model, usage);
|
|
185
|
+
// After usage is known, so a finished turn with unreadable arguments fails WITH its bill.
|
|
186
|
+
assertToolArguments({ provider: this.name, model, ...answer, usage, cost });
|
|
170
187
|
return {
|
|
171
188
|
model,
|
|
172
189
|
text: answer.text,
|
|
@@ -174,7 +191,8 @@ class OpenAiProvider implements Provider {
|
|
|
174
191
|
stopReason: answer.stopReason,
|
|
175
192
|
stopDetails: answer.stopDetails,
|
|
176
193
|
usage,
|
|
177
|
-
cost
|
|
194
|
+
cost,
|
|
195
|
+
costExact: exactCostOf(model, usage),
|
|
178
196
|
};
|
|
179
197
|
}
|
|
180
198
|
|
|
@@ -187,6 +205,7 @@ class OpenAiProvider implements Provider {
|
|
|
187
205
|
body: Record<string, unknown>,
|
|
188
206
|
streaming: boolean,
|
|
189
207
|
signal: AbortSignal | undefined,
|
|
208
|
+
extra: Readonly<Record<string, string>>,
|
|
190
209
|
): Promise<Response> {
|
|
191
210
|
const apiKey = this.apiKey();
|
|
192
211
|
const doFetch: AiFetch = this.config.fetch ?? fetch;
|
|
@@ -198,6 +217,8 @@ class OpenAiProvider implements Provider {
|
|
|
198
217
|
: { authorization: `Bearer ${apiKey}` }),
|
|
199
218
|
'content-type': 'application/json',
|
|
200
219
|
accept: streaming ? 'text/event-stream' : 'application/json',
|
|
220
|
+
// The prompt-cache session, when the request carries one and this host routes by it.
|
|
221
|
+
...extra,
|
|
201
222
|
// Last, so a caller can replace the credential header outright for a gateway that wants
|
|
202
223
|
// its own scheme. Nothing here is ever logged.
|
|
203
224
|
...this.config.headers,
|
package/src/openai-wire.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { renderThrowable } from '@ultimat3/core';
|
|
|
10
10
|
import { AiTransportError } from './errors';
|
|
11
11
|
import type { StopDetails, StopReason, StreamChunk, TokenUsage } from './provider';
|
|
12
12
|
import type { SseFrame } from './sse';
|
|
13
|
+
import type { MalformedToolCall } from './tool-arguments';
|
|
14
|
+
import { parseToolArguments } from './tool-arguments';
|
|
13
15
|
import type { LlmToolCall } from './tools';
|
|
14
16
|
|
|
15
17
|
/**
|
|
@@ -20,9 +22,16 @@ import type { LlmToolCall } from './tools';
|
|
|
20
22
|
export interface ChatAnswer {
|
|
21
23
|
readonly text: string;
|
|
22
24
|
readonly toolCalls: readonly LlmToolCall[];
|
|
25
|
+
/**
|
|
26
|
+
* Calls whose arguments were not JSON. Never among `toolCalls` — a fragment is never a call —
|
|
27
|
+
* and what they mean is the provider's decision (`assertToolArguments`), once usage is known.
|
|
28
|
+
*/
|
|
29
|
+
readonly malformed: readonly MalformedToolCall[];
|
|
23
30
|
readonly stopReason: StopReason;
|
|
24
31
|
readonly stopDetails: StopDetails | undefined;
|
|
25
32
|
readonly usage: TokenUsage | undefined;
|
|
33
|
+
/** The raw finish reason when this build does not know it — `stopReason` is then read off the content. */
|
|
34
|
+
readonly unknownFinish?: string;
|
|
26
35
|
}
|
|
27
36
|
|
|
28
37
|
// A `Map`, not an object literal: `raw` is the PROVIDER's string on every read, and
|
|
@@ -33,14 +42,46 @@ export interface ChatAnswer {
|
|
|
33
42
|
const FINISH_REASONS: ReadonlyMap<string, StopReason> = new Map(
|
|
34
43
|
Object.entries({
|
|
35
44
|
stop: 'end_turn',
|
|
45
|
+
// Synonyms for "the model finished", as endpoints in the family spell it: the Messages API's
|
|
46
|
+
// word passed through by a gateway, a tokenizer's end-of-sequence, Gemini's `STOP`.
|
|
47
|
+
end_turn: 'end_turn',
|
|
48
|
+
eos: 'end_turn',
|
|
49
|
+
eos_token: 'end_turn',
|
|
50
|
+
stop_sequence: 'stop_sequence',
|
|
36
51
|
length: 'max_tokens',
|
|
52
|
+
max_tokens: 'max_tokens',
|
|
53
|
+
model_context_window_exceeded: 'model_context_window_exceeded',
|
|
37
54
|
tool_calls: 'tool_use',
|
|
38
55
|
// The legacy name for the same event; LiteLLM and older self-hosted servers still send it.
|
|
39
56
|
function_call: 'tool_use',
|
|
57
|
+
tool_use: 'tool_use',
|
|
40
58
|
content_filter: 'refusal',
|
|
41
59
|
} as const),
|
|
42
60
|
);
|
|
43
61
|
|
|
62
|
+
/** What a `finish_reason` field said: nothing yet, a reason this build knows, or one it does not. */
|
|
63
|
+
export type FinishReading =
|
|
64
|
+
| { readonly kind: 'none' }
|
|
65
|
+
| { readonly kind: 'known'; readonly reason: StopReason }
|
|
66
|
+
| { readonly kind: 'unknown'; readonly raw: string };
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read a finish reason, case-insensitively (`STOP`, `MAX_TOKENS`). A non-string (`null` on a
|
|
70
|
+
* delta) is "still going". A string this table does not list is UNKNOWN, and deliberately not
|
|
71
|
+
* truncation: `llm()` and `agent()` refuse a truncated turn before reading it, so guessing
|
|
72
|
+
* "cut off" fails every call of an endpoint that merely spells "done" its own way. What an unknown
|
|
73
|
+
* reason amounts to is decided from the answer itself (`unknownFinishReason`), by the provider.
|
|
74
|
+
*/
|
|
75
|
+
export function readFinishReason(raw: unknown): FinishReading {
|
|
76
|
+
if (typeof raw !== 'string') return { kind: 'none' };
|
|
77
|
+
const reason = FINISH_REASONS.get(raw) ?? FINISH_REASONS.get(raw.toLowerCase());
|
|
78
|
+
return reason === undefined ? { kind: 'unknown', raw } : { kind: 'known', reason };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The stop reason of a turn whose finish reason is unknown: what its content shows it to be. */
|
|
82
|
+
const settledAs = (calls: readonly LlmToolCall[]): StopReason =>
|
|
83
|
+
calls.length > 0 ? 'tool_use' : 'end_turn';
|
|
84
|
+
|
|
44
85
|
/**
|
|
45
86
|
* In-band error frames carry a type, not a status, and the gateway's retry rule reads a status.
|
|
46
87
|
* Same mapping job as wire.ts's, over this format's own vocabulary.
|
|
@@ -63,21 +104,26 @@ const ERROR_STATUS: ReadonlyMap<string, number> = new Map(
|
|
|
63
104
|
);
|
|
64
105
|
|
|
65
106
|
/**
|
|
66
|
-
* The finish reason
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* likelier to be a way of stopping early than of finishing.
|
|
107
|
+
* The finish reason as a `StopReason`, or `undefined` for a non-string. An UNKNOWN string reads
|
|
108
|
+
* `end_turn` here — the bare lookup has no answer to judge it by; the two readers below do, and
|
|
109
|
+
* report the raw reason as `unknownFinish` so the provider can refuse a turn that is not whole.
|
|
70
110
|
*/
|
|
71
111
|
export function parseFinishReason(raw: unknown): StopReason | undefined {
|
|
72
|
-
|
|
112
|
+
const read = readFinishReason(raw);
|
|
113
|
+
return read.kind === 'none' ? undefined : read.kind === 'known' ? read.reason : 'end_turn';
|
|
73
114
|
}
|
|
74
115
|
|
|
75
116
|
/**
|
|
76
117
|
* `usage`, or `undefined` when the payload carried none.
|
|
77
118
|
*
|
|
78
|
-
* `prompt_tokens`
|
|
79
|
-
*
|
|
80
|
-
*
|
|
119
|
+
* `prompt_tokens` is the WHOLE prompt on this wire — the part read from a cache and the part
|
|
120
|
+
* written to one included — where Anthropic's `input_tokens` excludes both. So both are subtracted
|
|
121
|
+
* out before they are reported under their own counters. Left in place a cached token would be
|
|
122
|
+
* billed twice: once at the full input rate and again at the cache rate.
|
|
123
|
+
*
|
|
124
|
+
* `cache_write_tokens` is OpenRouter's (`prompt_tokens_details.cache_write_tokens`, "Number of
|
|
125
|
+
* tokens written to the cache"), reported for a model whose vendor bills a write. An endpoint that
|
|
126
|
+
* never sends it — OpenAI's own, where a write is free — reads as zero.
|
|
81
127
|
*/
|
|
82
128
|
export function parseOpenAiUsage(raw: unknown): TokenUsage | undefined {
|
|
83
129
|
const record = asRecord(raw);
|
|
@@ -85,14 +131,18 @@ export function parseOpenAiUsage(raw: unknown): TokenUsage | undefined {
|
|
|
85
131
|
const prompt = countOf(record['prompt_tokens']);
|
|
86
132
|
const completion = countOf(record['completion_tokens']);
|
|
87
133
|
if (prompt === undefined && completion === undefined) return undefined;
|
|
88
|
-
const
|
|
134
|
+
const details = asRecord(record['prompt_tokens_details']);
|
|
135
|
+
const total = prompt ?? 0;
|
|
136
|
+
// Each clamped to what is left of the prompt: a counter larger than the prompt it is part of is
|
|
137
|
+
// the endpoint's mistake, and an unclamped one bills tokens that were never sent.
|
|
138
|
+
const cached = Math.min(countOf(details?.['cached_tokens']) ?? 0, total);
|
|
139
|
+
const written = Math.min(countOf(details?.['cache_write_tokens']) ?? 0, total - cached);
|
|
89
140
|
return {
|
|
90
|
-
inputTokens:
|
|
141
|
+
inputTokens: total - cached - written,
|
|
91
142
|
// `completion_tokens` already contains `reasoning_tokens`; adding them is a double count.
|
|
92
143
|
outputTokens: completion ?? 0,
|
|
93
144
|
cacheReadTokens: cached,
|
|
94
|
-
|
|
95
|
-
cacheWriteTokens: 0,
|
|
145
|
+
cacheWriteTokens: written,
|
|
96
146
|
};
|
|
97
147
|
}
|
|
98
148
|
|
|
@@ -108,15 +158,24 @@ export function parseChatCompletion(raw: unknown, provider: string): ChatAnswer
|
|
|
108
158
|
// `"refusal": ""` beside a real answer, and that body must not refuse here and answer streamed.
|
|
109
159
|
const declined = message['refusal'];
|
|
110
160
|
const refusal = typeof declined === 'string' && declined !== '' ? declined : undefined;
|
|
111
|
-
const
|
|
161
|
+
const read = parseToolCalls(message['tool_calls']);
|
|
162
|
+
const reading = readFinishReason(choice['finish_reason']);
|
|
163
|
+
const finish =
|
|
164
|
+
reading.kind === 'none'
|
|
165
|
+
? undefined
|
|
166
|
+
: reading.kind === 'known'
|
|
167
|
+
? reading.reason
|
|
168
|
+
: settledAs(read.calls);
|
|
112
169
|
return {
|
|
113
170
|
text: typeof message['content'] === 'string' ? message['content'] : '',
|
|
114
|
-
toolCalls:
|
|
171
|
+
toolCalls: read.calls,
|
|
172
|
+
malformed: read.malformed,
|
|
115
173
|
// A refusal string is a refusal whatever the finish reason says: the field only ever appears
|
|
116
174
|
// when the model declined, and `stop` beside it would read downstream as an empty answer.
|
|
117
175
|
stopReason: refusal === undefined ? (finish ?? 'end_turn') : 'refusal',
|
|
118
176
|
stopDetails: refusalDetails(finish, refusal),
|
|
119
177
|
usage: parseOpenAiUsage(record['usage']),
|
|
178
|
+
...(reading.kind === 'unknown' ? { unknownFinish: reading.raw } : {}),
|
|
120
179
|
};
|
|
121
180
|
}
|
|
122
181
|
|
|
@@ -131,41 +190,52 @@ function refusalDetails(
|
|
|
131
190
|
return { type: 'refusal', category: 'content_filter', explanation: undefined };
|
|
132
191
|
}
|
|
133
192
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
193
|
+
interface ReadCalls {
|
|
194
|
+
readonly calls: LlmToolCall[];
|
|
195
|
+
readonly malformed: MalformedToolCall[];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseToolCalls(raw: unknown): ReadCalls {
|
|
199
|
+
const read: ReadCalls = { calls: [], malformed: [] };
|
|
200
|
+
if (!Array.isArray(raw)) return read;
|
|
137
201
|
for (const entry of raw) {
|
|
138
202
|
const record = asRecord(entry);
|
|
139
203
|
const fn = asRecord(record?.['function']);
|
|
140
204
|
if (record === undefined || fn === undefined) continue;
|
|
141
205
|
const name = typeof fn['name'] === 'string' ? fn['name'] : '';
|
|
142
206
|
if (name === '') continue;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
name,
|
|
146
|
-
input: parseArguments(fn['arguments'], name, provider),
|
|
147
|
-
});
|
|
207
|
+
const args = fn['arguments'];
|
|
208
|
+
collect(read, typeof record['id'] === 'string' ? record['id'] : '', name, args);
|
|
148
209
|
}
|
|
149
|
-
return
|
|
210
|
+
return read;
|
|
150
211
|
}
|
|
151
212
|
|
|
152
|
-
/**
|
|
153
|
-
function
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
213
|
+
/** One call, read: among the calls when its arguments parse, among the malformed when not. */
|
|
214
|
+
function collect(
|
|
215
|
+
read: ReadCalls,
|
|
216
|
+
id: string,
|
|
217
|
+
name: string,
|
|
218
|
+
args: unknown,
|
|
219
|
+
): LlmToolCall | undefined {
|
|
220
|
+
const raw = typeof args === 'string' ? args : '';
|
|
221
|
+
const input = parseToolArguments(raw);
|
|
222
|
+
if (input === undefined) {
|
|
223
|
+
read.malformed.push({ name, chars: raw.length });
|
|
224
|
+
return undefined;
|
|
162
225
|
}
|
|
226
|
+
const call: LlmToolCall = { id, name, input };
|
|
227
|
+
read.calls.push(call);
|
|
228
|
+
return call;
|
|
163
229
|
}
|
|
164
230
|
|
|
165
231
|
interface PendingCall {
|
|
232
|
+
/** Where the call sorts among its turn's: the wire's `index`, else its order of arrival. */
|
|
233
|
+
readonly index: number;
|
|
166
234
|
id: string;
|
|
167
235
|
name: string;
|
|
168
236
|
args: string;
|
|
237
|
+
/** A second NAMED fragment arrived with no index and no id: two calls, indistinguishable. */
|
|
238
|
+
unkeyed?: boolean;
|
|
169
239
|
}
|
|
170
240
|
|
|
171
241
|
/**
|
|
@@ -180,11 +250,14 @@ export class ChatCompletionStream {
|
|
|
180
250
|
private text = '';
|
|
181
251
|
private refusal = '';
|
|
182
252
|
private stopReason: StopReason = 'end_turn';
|
|
253
|
+
private unknownFinish: string | undefined;
|
|
183
254
|
private finished = false;
|
|
184
255
|
private done = false;
|
|
185
256
|
private usage: TokenUsage | undefined;
|
|
186
|
-
|
|
187
|
-
private readonly
|
|
257
|
+
/** Every open call, in order of arrival; `byIndex` is which one a wire index currently means. */
|
|
258
|
+
private readonly pending: PendingCall[] = [];
|
|
259
|
+
private readonly byIndex = new Map<number, PendingCall>();
|
|
260
|
+
private readonly read: ReadCalls = { calls: [], malformed: [] };
|
|
188
261
|
private readonly provider: string;
|
|
189
262
|
|
|
190
263
|
constructor(provider: string) {
|
|
@@ -196,7 +269,7 @@ export class ChatCompletionStream {
|
|
|
196
269
|
// The sentinel is not JSON, and parsing it is how a stream reader ends in a syntax error.
|
|
197
270
|
if (frame.data.trim() === '[DONE]') {
|
|
198
271
|
this.done = true;
|
|
199
|
-
return
|
|
272
|
+
return this.end();
|
|
200
273
|
}
|
|
201
274
|
const payload = this.payloadOf(frame);
|
|
202
275
|
throwInBandError(payload, this.provider);
|
|
@@ -205,7 +278,34 @@ export class ChatCompletionStream {
|
|
|
205
278
|
const choice = asRecord(Array.isArray(payload['choices']) ? payload['choices'][0] : undefined);
|
|
206
279
|
if (choice === undefined) return [];
|
|
207
280
|
const chunks = this.onDelta(asRecord(choice['delta']) ?? {});
|
|
208
|
-
|
|
281
|
+
this.onFinish(choice['finish_reason']);
|
|
282
|
+
return chunks;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The stream is over: the calls a finished turn made, whole, in index order. Called by the
|
|
287
|
+
* provider when the socket closes, and by `[DONE]` — whichever comes first; the second is empty.
|
|
288
|
+
*
|
|
289
|
+
* Here, and not when the finish reason arrives: this format has no per-call stop event, and
|
|
290
|
+
* nothing in it orders the finish reason against the last argument fragment — a call read at the
|
|
291
|
+
* reason is read half way whenever a gateway sends the reason first. A turn with no finish
|
|
292
|
+
* reason drains nothing: its fragments stay open, and `isComplete()` refuses the stream.
|
|
293
|
+
*/
|
|
294
|
+
end(): readonly StreamChunk[] {
|
|
295
|
+
if (!this.finished) return [];
|
|
296
|
+
const chunks: StreamChunk[] = [];
|
|
297
|
+
for (const pending of this.pending.splice(0).sort((a, b) => a.index - b.index)) {
|
|
298
|
+
if (pending.name === '') continue;
|
|
299
|
+
if (pending.unkeyed === true) {
|
|
300
|
+
// Deliberate: merged arguments are never parsed, even when they happen to be JSON.
|
|
301
|
+
this.read.malformed.push({ name: pending.name, chars: pending.args.length, unkeyed: true });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
const call = collect(this.read, pending.id, pending.name, pending.args);
|
|
305
|
+
if (call !== undefined) chunks.push({ type: 'tool-call', call });
|
|
306
|
+
}
|
|
307
|
+
this.byIndex.clear();
|
|
308
|
+
return chunks;
|
|
209
309
|
}
|
|
210
310
|
|
|
211
311
|
/** True once the answer is accounted for. False means the connection died mid-answer. */
|
|
@@ -223,20 +323,29 @@ export class ChatCompletionStream {
|
|
|
223
323
|
// half a JSON object, and would report `end_turn` for a turn that stopped to call one. So the
|
|
224
324
|
// stream is refused exactly as the Anthropic half refuses a missing `message_stop`
|
|
225
325
|
// (`provider.ts`), and `openai-provider.ts`'s existing truncation guard is what raises it.
|
|
226
|
-
return this.finished || (this.done && this.pending.
|
|
326
|
+
return this.finished || (this.done && this.pending.length === 0);
|
|
227
327
|
}
|
|
228
328
|
|
|
229
329
|
/** What the stream accumulated. `cost` is applied by the provider, which owns prices. */
|
|
230
330
|
state(): ChatAnswer {
|
|
331
|
+
// Idempotent, so a reader that never called `end()` still sees a finished turn's calls.
|
|
332
|
+
this.end();
|
|
231
333
|
const details = this.refusalDetails();
|
|
232
334
|
return {
|
|
233
335
|
text: this.text,
|
|
234
|
-
toolCalls: [...this.
|
|
336
|
+
toolCalls: [...this.read.calls],
|
|
337
|
+
malformed: [...this.read.malformed],
|
|
235
338
|
// A refusal that arrived as a `refusal` delta finishes with `stop`, so the reason alone would
|
|
236
339
|
// read as a complete answer that happens to be empty.
|
|
237
|
-
stopReason:
|
|
340
|
+
stopReason:
|
|
341
|
+
details !== undefined
|
|
342
|
+
? 'refusal'
|
|
343
|
+
: this.unknownFinish === undefined
|
|
344
|
+
? this.stopReason
|
|
345
|
+
: settledAs(this.read.calls),
|
|
238
346
|
stopDetails: details,
|
|
239
347
|
usage: this.usage,
|
|
348
|
+
...(this.unknownFinish === undefined ? {} : { unknownFinish: this.unknownFinish }),
|
|
240
349
|
};
|
|
241
350
|
}
|
|
242
351
|
|
|
@@ -270,54 +379,60 @@ export class ChatCompletionStream {
|
|
|
270
379
|
}
|
|
271
380
|
|
|
272
381
|
/**
|
|
273
|
-
* Merge one chunk's tool-call fragments into the calls they belong to. `index` is the
|
|
382
|
+
* Merge one chunk's tool-call fragments into the calls they belong to. `index` is the key —
|
|
274
383
|
* `id` and `name` arrive on the first fragment and are absent from every later one, so appending
|
|
275
384
|
* by array position instead would build one call per chunk and lose every argument but the last.
|
|
385
|
+
*
|
|
386
|
+
* It is not the WHOLE key. A gateway translating another vendor's stream may number every call
|
|
387
|
+
* of a turn `0` (or send no index) and tell them apart by id alone; merged by index, two
|
|
388
|
+
* well-formed calls became one string — `{…}{…}` — that is not JSON. So a fragment carrying an id
|
|
389
|
+
* that is not the open call's starts a new call. An id repeated on every fragment, or absent
|
|
390
|
+
* from all but the first, is still one call.
|
|
276
391
|
*/
|
|
277
392
|
private accumulate(raw: unknown): void {
|
|
278
393
|
if (!Array.isArray(raw)) return;
|
|
279
394
|
for (const entry of raw) {
|
|
280
395
|
const record = asRecord(entry);
|
|
281
396
|
if (record === undefined) continue;
|
|
282
|
-
const index = numberOf(record['index'])
|
|
283
|
-
const
|
|
284
|
-
const id =
|
|
285
|
-
|
|
397
|
+
const index = numberOf(record['index']);
|
|
398
|
+
const rawId = record['id'];
|
|
399
|
+
const id = typeof rawId === 'string' && rawId !== '' ? rawId : undefined;
|
|
400
|
+
let call = index === undefined ? this.pending.at(-1) : this.byIndex.get(index);
|
|
401
|
+
if (call === undefined || (id !== undefined && call.id !== '' && call.id !== id)) {
|
|
402
|
+
call = { index: index ?? this.pending.length, id: '', name: '', args: '' };
|
|
403
|
+
this.pending.push(call);
|
|
404
|
+
}
|
|
405
|
+
if (index !== undefined) this.byIndex.set(index, call);
|
|
406
|
+
if (id !== undefined) call.id = id;
|
|
286
407
|
const fn = asRecord(record['function']);
|
|
287
408
|
const name = fn?.['name'];
|
|
409
|
+
// No index, no id, and a tool NAMED again after arguments already arrived: a second call
|
|
410
|
+
// this stream gives no way to tell from the first. A name split across frames arrives
|
|
411
|
+
// before any argument, so that legal case is not this one.
|
|
412
|
+
if (
|
|
413
|
+
index === undefined &&
|
|
414
|
+
id === undefined &&
|
|
415
|
+
call.args !== '' &&
|
|
416
|
+
name !== undefined &&
|
|
417
|
+
name !== ''
|
|
418
|
+
) {
|
|
419
|
+
call.unkeyed = true;
|
|
420
|
+
}
|
|
288
421
|
// Concatenated, not assigned: a server that splits the name across two frames is rare and
|
|
289
422
|
// legal, and assigning would keep only the tail.
|
|
290
423
|
if (typeof name === 'string') call.name += name;
|
|
291
424
|
const args = fn?.['arguments'];
|
|
292
425
|
if (typeof args === 'string') call.args += args;
|
|
293
|
-
this.pending.set(index, call);
|
|
294
426
|
}
|
|
295
427
|
}
|
|
296
428
|
|
|
297
|
-
/**
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const reason = parseFinishReason(raw);
|
|
304
|
-
if (reason === undefined) return [];
|
|
305
|
-
this.stopReason = reason;
|
|
429
|
+
/** The finish reason closes the TURN. The calls are read by `end()`, when the stream is over. */
|
|
430
|
+
private onFinish(raw: unknown): void {
|
|
431
|
+
const reading = readFinishReason(raw);
|
|
432
|
+
if (reading.kind === 'none') return;
|
|
433
|
+
if (reading.kind === 'known') this.stopReason = reading.reason;
|
|
434
|
+
else this.unknownFinish = reading.raw;
|
|
306
435
|
this.finished = true;
|
|
307
|
-
const chunks: StreamChunk[] = [];
|
|
308
|
-
for (const index of [...this.pending.keys()].sort((a, b) => a - b)) {
|
|
309
|
-
const pending = this.pending.get(index);
|
|
310
|
-
if (pending === undefined || pending.name === '') continue;
|
|
311
|
-
const call: LlmToolCall = {
|
|
312
|
-
id: pending.id,
|
|
313
|
-
name: pending.name,
|
|
314
|
-
input: parseArguments(pending.args, pending.name, this.provider),
|
|
315
|
-
};
|
|
316
|
-
this.toolCalls.push(call);
|
|
317
|
-
chunks.push({ type: 'tool-call', call });
|
|
318
|
-
}
|
|
319
|
-
this.pending.clear();
|
|
320
|
-
return chunks;
|
|
321
436
|
}
|
|
322
437
|
|
|
323
438
|
private payloadOf(frame: SseFrame): Record<string, unknown> {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Single responsibility: the Anthropic Messages wire's half of prompt caching — `system` as text
|
|
2
|
+
// blocks, and `cache_control` on the blocks ./prompt-cache's `blocks` rule names. Pure, so the body
|
|
3
|
+
// a test asserts is the body the socket gets.
|
|
4
|
+
|
|
5
|
+
import type { SystemPrompt } from './prompt-cache';
|
|
6
|
+
import { CACHE_MARK, systemParts } from './prompt-cache';
|
|
7
|
+
import type { AiMessage, GenerateRequest } from './provider';
|
|
8
|
+
|
|
9
|
+
type Marked<T> = T & { readonly cache_control?: typeof CACHE_MARK };
|
|
10
|
+
type TextBlock = Marked<{ readonly type: 'text'; readonly text: string }>;
|
|
11
|
+
type WireMessage = { readonly role: AiMessage['role']; readonly content: unknown };
|
|
12
|
+
|
|
13
|
+
/** The vendor's ceiling on breakpoints in one request; a fifth is a 400. */
|
|
14
|
+
const MAX_BREAKPOINTS = 4;
|
|
15
|
+
|
|
16
|
+
/** An empty or whitespace-only text block cannot carry a mark (and is a 400 with one). */
|
|
17
|
+
const blank = (text: string): boolean => text.trim() === '';
|
|
18
|
+
|
|
19
|
+
export interface AnthropicPrompt {
|
|
20
|
+
readonly system: string | readonly TextBlock[] | undefined;
|
|
21
|
+
readonly messages: readonly WireMessage[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `system` and `messages` as the wire takes them. Unmarked, a string system stays the string every
|
|
26
|
+
* existing request sent and the messages pass through untouched.
|
|
27
|
+
*
|
|
28
|
+
* Marked, at most three breakpoints, spent in this order: the transcript's TAIL (what the next
|
|
29
|
+
* request reads back), the stable system block (what other runs share), then the message before
|
|
30
|
+
* the tail (a second start for the 20-block lookback). Marks the caller already placed — a raw
|
|
31
|
+
* block or tool carrying its own `cache_control` — count against the four first, so this never
|
|
32
|
+
* turns a legal request into a 400; ours are the ones dropped.
|
|
33
|
+
*/
|
|
34
|
+
export function anthropicPrompt(request: GenerateRequest, marked: boolean): AnthropicPrompt {
|
|
35
|
+
const { messages } = request;
|
|
36
|
+
if (!marked) {
|
|
37
|
+
return { system: systemOf(request.system, false), messages: messages.map(plain) };
|
|
38
|
+
}
|
|
39
|
+
let left = MAX_BREAKPOINTS - placedBy(request);
|
|
40
|
+
const take = (wanted: boolean): boolean => {
|
|
41
|
+
if (!wanted || left <= 0) return false;
|
|
42
|
+
left -= 1;
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
45
|
+
const tail = messages.length - 1;
|
|
46
|
+
const markTail = take(tail >= 0 && markableAt(messages[tail]) !== undefined);
|
|
47
|
+
const markSystem = take(!blank(systemParts(request.system).stable));
|
|
48
|
+
const markPrevious = take(tail >= 1 && markableAt(messages[tail - 1]) !== undefined);
|
|
49
|
+
return {
|
|
50
|
+
system: systemOf(request.system, markSystem),
|
|
51
|
+
messages: messages.map((message, index) =>
|
|
52
|
+
(index === tail && markTail) || (index === tail - 1 && markPrevious)
|
|
53
|
+
? { role: message.role, content: markedContent(message.content) }
|
|
54
|
+
: plain(message),
|
|
55
|
+
),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const plain = (message: AiMessage): WireMessage => ({
|
|
60
|
+
role: message.role,
|
|
61
|
+
content: message.content,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** The stable block first and the only one marked; an empty part is dropped, never sent. */
|
|
65
|
+
function systemOf(
|
|
66
|
+
system: SystemPrompt | undefined,
|
|
67
|
+
marked: boolean,
|
|
68
|
+
): string | readonly TextBlock[] | undefined {
|
|
69
|
+
if (system === undefined) return undefined;
|
|
70
|
+
if (typeof system === 'string' && !marked) return system;
|
|
71
|
+
const { stable, run } = systemParts(system);
|
|
72
|
+
const blocks: TextBlock[] = [];
|
|
73
|
+
if (stable !== '') {
|
|
74
|
+
blocks.push({ type: 'text', text: stable, ...(marked ? { cache_control: CACHE_MARK } : {}) });
|
|
75
|
+
}
|
|
76
|
+
if (run !== '') blocks.push({ type: 'text', text: run });
|
|
77
|
+
return blocks.length === 0 ? undefined : blocks;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Where one message's mark goes: its LAST block that is not blank text, so the mark moves back
|
|
82
|
+
* past a trailing empty block rather than landing on it. `undefined` when no block can carry one.
|
|
83
|
+
*/
|
|
84
|
+
function markableAt(message: AiMessage | undefined): number | undefined {
|
|
85
|
+
if (message === undefined) return undefined;
|
|
86
|
+
const { content } = message;
|
|
87
|
+
if (typeof content === 'string') return blank(content) ? undefined : 0;
|
|
88
|
+
for (let index = content.length - 1; index >= 0; index -= 1) {
|
|
89
|
+
const block = content[index];
|
|
90
|
+
if (block !== undefined && !(block.type === 'text' && blank(block.text))) return index;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function markedContent(content: AiMessage['content']): unknown {
|
|
96
|
+
if (typeof content === 'string') {
|
|
97
|
+
return [{ type: 'text', text: content, cache_control: CACHE_MARK }];
|
|
98
|
+
}
|
|
99
|
+
const at = markableAt({ role: 'user', content });
|
|
100
|
+
return content.map((block, index) =>
|
|
101
|
+
index === at ? { ...block, cache_control: CACHE_MARK } : block,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Breakpoints the caller placed itself: the types do not offer the field, a cast still sends it. */
|
|
106
|
+
function placedBy(request: GenerateRequest): number {
|
|
107
|
+
const carries = (value: unknown): boolean =>
|
|
108
|
+
typeof value === 'object' && value !== null && Object.hasOwn(value, 'cache_control');
|
|
109
|
+
let count = (request.tools ?? []).filter(carries).length;
|
|
110
|
+
for (const { content } of request.messages) {
|
|
111
|
+
if (typeof content !== 'string') count += content.filter(carries).length;
|
|
112
|
+
}
|
|
113
|
+
return count;
|
|
114
|
+
}
|