@ultimat3/ai 1.2.0 → 3.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/CLAUDE.md +521 -0
- package/README.md +367 -6
- package/package.json +11 -9
- package/src/agent-facts.ts +70 -0
- package/src/agent-job.ts +97 -0
- package/src/agent-transcript.ts +94 -0
- package/src/agent.ts +396 -0
- package/src/budget.ts +98 -11
- package/src/error-body.ts +44 -0
- package/src/errors.ts +144 -96
- package/src/eval-baseline.ts +1 -1
- package/src/eval-errors.ts +98 -0
- package/src/evals.ts +1 -1
- package/src/fix-line.evals.ts +35 -0
- package/src/fix-line.ts +27 -0
- package/src/fix-line.v1.baseline.json +12 -0
- package/src/gateway.ts +57 -19
- package/src/hive-errors.ts +30 -0
- package/src/hive-pool.ts +90 -0
- package/src/hive-result.ts +96 -0
- package/src/hive.ts +177 -0
- package/src/index.ts +55 -9
- package/src/llm-stream.ts +171 -0
- package/src/llm.ts +203 -26
- package/src/models.ts +186 -49
- package/src/openai-body.ts +96 -0
- package/src/openai-messages.ts +174 -0
- package/src/openai-models.ts +84 -0
- package/src/openai-provider.ts +274 -0
- package/src/openai-wire.ts +339 -0
- package/src/pg-vector-sql.ts +5 -1
- package/src/pg-vector.ts +2 -1
- package/src/prompt.ts +1 -1
- package/src/provider.ts +112 -38
- package/src/rag.ts +27 -3
- package/src/redaction.ts +22 -0
- package/src/remote-embedder.ts +53 -6
- package/src/runtime.ts +29 -0
- package/src/tools.ts +107 -11
- package/src/vector.ts +0 -0
- package/src/wire.ts +41 -11
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// Single responsibility: `openAiProvider()` — a `Provider` speaking the OpenAI chat-completions
|
|
2
|
+
// wire FORMAT, against whatever endpoint is configured.
|
|
3
|
+
//
|
|
4
|
+
// The format matters more than the vendor: Azure OpenAI, vLLM, Ollama, LiteLLM, OpenRouter,
|
|
5
|
+
// Together and most self-hosted company gateways serve it, so one provider plus a `baseUrl` is the
|
|
6
|
+
// difference between "Ultimate talks to models" and "Ultimate talks to OUR models". The request
|
|
7
|
+
// half is ./openai-body, the response half is ./openai-wire; this file owns the socket, the
|
|
8
|
+
// credential and the errors.
|
|
9
|
+
|
|
10
|
+
import type { Secret } from '@ultimat3/core';
|
|
11
|
+
import { isSecret, revealSecret } from '@ultimat3/core';
|
|
12
|
+
import { detailOf, withoutKey } from './error-body';
|
|
13
|
+
import { AiKeyMissingError, AiRequestInvalidError, AiTransportError } from './errors';
|
|
14
|
+
import type { ModelId } from './models';
|
|
15
|
+
import { chatCompletionBody } from './openai-body';
|
|
16
|
+
// Imported for its registration side effect: a provider that cannot price what it serves throws
|
|
17
|
+
// X_AI_MODEL_UNKNOWN at the first call, and the specs belong with the format that names them.
|
|
18
|
+
import './openai-models';
|
|
19
|
+
import type { ChatAnswer } from './openai-wire';
|
|
20
|
+
import { ChatCompletionStream, parseChatCompletion } from './openai-wire';
|
|
21
|
+
import type {
|
|
22
|
+
GenerateRequest,
|
|
23
|
+
GenerateResult,
|
|
24
|
+
Provider,
|
|
25
|
+
StreamChunk,
|
|
26
|
+
TokenUsage,
|
|
27
|
+
} from './provider';
|
|
28
|
+
import { costOf, estimateInputTokens, estimateTextTokens, requiresStreaming } from './provider';
|
|
29
|
+
import { readSse } from './sse';
|
|
30
|
+
|
|
31
|
+
const API_KEY_ENV = 'OPENAI_API_KEY';
|
|
32
|
+
const DEFAULT_BASE_URL = 'https://api.openai.com/v1';
|
|
33
|
+
|
|
34
|
+
export interface OpenAiProviderInput {
|
|
35
|
+
/**
|
|
36
|
+
* A `Secret` for preference — it redacts by value, so the same key is safe in a log line, an
|
|
37
|
+
* error `meta` and a snapshot. A plain string is accepted because an env var already is one.
|
|
38
|
+
* Reads `OPENAI_API_KEY` when omitted; absent at call time is a labelled throw.
|
|
39
|
+
*/
|
|
40
|
+
readonly apiKey?: Secret | string;
|
|
41
|
+
/**
|
|
42
|
+
* The endpoint, minus `/chat/completions`. THIS is what makes the provider vendor-neutral —
|
|
43
|
+
* `http://localhost:11434/v1` is Ollama, `http://vllm:8000/v1` is vLLM, and an Azure deployment
|
|
44
|
+
* URL with its `?api-version=` query works as written: a query on the base is carried onto the
|
|
45
|
+
* request rather than swallowed by the path.
|
|
46
|
+
*/
|
|
47
|
+
readonly baseUrl?: string;
|
|
48
|
+
/**
|
|
49
|
+
* The ids this endpoint serves. Required, and the provider's OWN list rather than the registry's,
|
|
50
|
+
* for the reason `AnthropicProvider.models` is: an app's internal model must not be routed
|
|
51
|
+
* somewhere that has never heard of it. On Azure these are DEPLOYMENT names, not model names.
|
|
52
|
+
*/
|
|
53
|
+
readonly models: readonly ModelId[];
|
|
54
|
+
/** Extra headers — OpenRouter's attribution pair, a gateway's tenant header. Merged last. */
|
|
55
|
+
readonly headers?: Readonly<Record<string, string>>;
|
|
56
|
+
/**
|
|
57
|
+
* Which header carries the key. `bearer` is `Authorization: Bearer …`, the format's default;
|
|
58
|
+
* `api-key` is Azure's. One knob rather than two ways to pass a credential, so the key stays
|
|
59
|
+
* boxed in a `Secret` until the header is built.
|
|
60
|
+
*/
|
|
61
|
+
readonly auth?: 'bearer' | 'api-key';
|
|
62
|
+
/**
|
|
63
|
+
* What this provider is called on a span and in a failure list. Default `openai`. Worth setting
|
|
64
|
+
* when two endpoints speaking this format serve one model — `provider` on the result is the only
|
|
65
|
+
* thing that says which of them answered.
|
|
66
|
+
*/
|
|
67
|
+
readonly name?: string;
|
|
68
|
+
/** Injectable so a test can assert the request body without a network. Defaults to `fetch`. */
|
|
69
|
+
readonly fetch?: typeof fetch;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A provider for any endpoint speaking the OpenAI chat-completions format.
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* // OpenAI
|
|
77
|
+
* openAiProvider({ apiKey: openAiKey, models: [...OPENAI_MODEL_IDS] })
|
|
78
|
+
* // a company gateway, vLLM, Ollama — same class, one different field
|
|
79
|
+
* openAiProvider({ apiKey: gatewayKey, baseUrl: 'https://llm.acme.internal/v1', models: ['acme-70b'] })
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
export function openAiProvider(input: OpenAiProviderInput): Provider {
|
|
83
|
+
return new OpenAiProvider(input);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
class OpenAiProvider implements Provider {
|
|
87
|
+
readonly name: string;
|
|
88
|
+
readonly models: readonly ModelId[];
|
|
89
|
+
private readonly defaultModel: ModelId;
|
|
90
|
+
private readonly config: OpenAiProviderInput;
|
|
91
|
+
|
|
92
|
+
constructor(config: OpenAiProviderInput) {
|
|
93
|
+
const [first] = config.models;
|
|
94
|
+
if (first === undefined) {
|
|
95
|
+
// A provider serving nothing can never be routed to, so it is a boot mistake that would
|
|
96
|
+
// otherwise surface as "no provider serves <model>" — a true statement about the wrong thing.
|
|
97
|
+
throw new AiRequestInvalidError({
|
|
98
|
+
detail:
|
|
99
|
+
'openAiProvider was given an empty models list, so the gateway can never route to it',
|
|
100
|
+
fix: 'pass models: [...OPENAI_MODEL_IDS] to openAiProvider, or the ids your endpoint serves',
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
this.name = config.name ?? 'openai';
|
|
104
|
+
this.models = config.models;
|
|
105
|
+
this.defaultModel = first;
|
|
106
|
+
this.config = config;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Above `STREAM_ONLY_MAX_TOKENS` this runs the streaming transport and assembles the result,
|
|
111
|
+
* for the reason the Anthropic provider does: a non-streaming request that large sits on an
|
|
112
|
+
* open socket past the HTTP timeout and fails after the completion was generated and billed.
|
|
113
|
+
*/
|
|
114
|
+
async generate(request: GenerateRequest): Promise<GenerateResult> {
|
|
115
|
+
if (requiresStreaming(request)) return this.assemble(request);
|
|
116
|
+
const model = this.modelOf(request);
|
|
117
|
+
const response = await this.send(
|
|
118
|
+
chatCompletionBody({ request, model, stream: false }),
|
|
119
|
+
false,
|
|
120
|
+
request.signal,
|
|
121
|
+
);
|
|
122
|
+
const answer = parseChatCompletion((await response.json()) as unknown, this.name);
|
|
123
|
+
return this.result(request, model, answer);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
127
|
+
const model = this.modelOf(request);
|
|
128
|
+
const response = await this.send(
|
|
129
|
+
chatCompletionBody({ request, model, stream: true }),
|
|
130
|
+
true,
|
|
131
|
+
request.signal,
|
|
132
|
+
);
|
|
133
|
+
if (response.body === null) {
|
|
134
|
+
throw new AiTransportError({
|
|
135
|
+
provider: this.name,
|
|
136
|
+
status: response.status,
|
|
137
|
+
detail: 'a streaming response arrived with no body',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
const completion = new ChatCompletionStream(this.name);
|
|
141
|
+
for await (const frame of readSse(response.body)) {
|
|
142
|
+
for (const chunk of completion.push(frame)) yield chunk;
|
|
143
|
+
}
|
|
144
|
+
// A connection cut mid-answer must fail, not resolve: partial text reads as a complete answer,
|
|
145
|
+
// and `end_turn` would be a lie the caller has no way to detect.
|
|
146
|
+
if (!completion.isComplete()) {
|
|
147
|
+
throw new AiTransportError({
|
|
148
|
+
provider: this.name,
|
|
149
|
+
detail: 'the stream ended before a finish reason — the answer is truncated',
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
yield { type: 'done', result: this.result(request, model, completion.state()) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Drive `stream()` to its `done` chunk. It throws on a cut stream, so a partial never lands. */
|
|
156
|
+
private async assemble(request: GenerateRequest): Promise<GenerateResult> {
|
|
157
|
+
for await (const chunk of this.stream(request)) {
|
|
158
|
+
if (chunk.type === 'done') return chunk.result;
|
|
159
|
+
}
|
|
160
|
+
throw new AiTransportError({
|
|
161
|
+
provider: this.name,
|
|
162
|
+
detail: 'the stream completed without a result',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One parsed answer, priced. The only place `cost` is applied — the provider owns prices. */
|
|
167
|
+
private result(request: GenerateRequest, model: ModelId, answer: ChatAnswer): GenerateResult {
|
|
168
|
+
const usage = answer.usage ?? estimatedUsage(request, answer.text);
|
|
169
|
+
return {
|
|
170
|
+
model,
|
|
171
|
+
text: answer.text,
|
|
172
|
+
toolCalls: answer.toolCalls,
|
|
173
|
+
stopReason: answer.stopReason,
|
|
174
|
+
stopDetails: answer.stopDetails,
|
|
175
|
+
usage,
|
|
176
|
+
cost: costOf(model, usage),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The model this request is for. The gateway resolves one before it routes, so the fallback is
|
|
182
|
+
* only ever reached by a direct call — and it is this provider's first model, never the
|
|
183
|
+
* framework's `DEFAULT_MODEL`, which names a Claude id no OpenAI-format endpoint serves.
|
|
184
|
+
*/
|
|
185
|
+
private modelOf(request: GenerateRequest): ModelId {
|
|
186
|
+
return request.model ?? this.defaultModel;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The one place a request leaves the process. A non-2xx becomes an `AiTransportError` carrying
|
|
191
|
+
* its status, because the gateway decides whether to retry from that status and a body parsed as
|
|
192
|
+
* if it were a message would read as an empty, successful answer.
|
|
193
|
+
*/
|
|
194
|
+
private async send(
|
|
195
|
+
body: Record<string, unknown>,
|
|
196
|
+
streaming: boolean,
|
|
197
|
+
signal: AbortSignal | undefined,
|
|
198
|
+
): Promise<Response> {
|
|
199
|
+
const apiKey = this.apiKey();
|
|
200
|
+
const doFetch = this.config.fetch ?? fetch;
|
|
201
|
+
const response = await doFetch(this.url(), {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
headers: {
|
|
204
|
+
...(this.config.auth === 'api-key'
|
|
205
|
+
? { 'api-key': apiKey }
|
|
206
|
+
: { authorization: `Bearer ${apiKey}` }),
|
|
207
|
+
'content-type': 'application/json',
|
|
208
|
+
accept: streaming ? 'text/event-stream' : 'application/json',
|
|
209
|
+
// Last, so a caller can replace the credential header outright for a gateway that wants
|
|
210
|
+
// its own scheme. Nothing here is ever logged.
|
|
211
|
+
...this.config.headers,
|
|
212
|
+
},
|
|
213
|
+
body: JSON.stringify(body),
|
|
214
|
+
// Attached only when the caller has one — same rule, same reason, as the Anthropic half.
|
|
215
|
+
...(signal === undefined ? {} : { signal }),
|
|
216
|
+
});
|
|
217
|
+
if (!response.ok) {
|
|
218
|
+
throw new AiTransportError({
|
|
219
|
+
provider: this.name,
|
|
220
|
+
status: response.status,
|
|
221
|
+
// The endpoint's own message, with the credential scrubbed out of it — `error-body.ts`
|
|
222
|
+
// says why, and both providers call the same pair.
|
|
223
|
+
detail: withoutKey(await detailOf(response), apiKey),
|
|
224
|
+
envVar: API_KEY_ENV,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
return response;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The credential, revealed as late as possible and never stored on the instance. Returned rather
|
|
232
|
+
* than kept, so the only string that exists is the local one `send` puts in a header.
|
|
233
|
+
*/
|
|
234
|
+
private apiKey(): string {
|
|
235
|
+
const configured = this.config.apiKey;
|
|
236
|
+
const value = isSecret(configured)
|
|
237
|
+
? revealSecret(configured)
|
|
238
|
+
: (configured ?? Bun.env[API_KEY_ENV]);
|
|
239
|
+
if (value === undefined || value === '') {
|
|
240
|
+
throw new AiKeyMissingError({ provider: this.name, envVar: API_KEY_ENV });
|
|
241
|
+
}
|
|
242
|
+
return value;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* `<baseUrl>/chat/completions`, with any query on the base preserved. Azure's deployment URL
|
|
247
|
+
* carries `?api-version=…`, and appending the path after it would send the version as part of a
|
|
248
|
+
* path segment — a 404 whose cause reads like a wrong deployment name.
|
|
249
|
+
*/
|
|
250
|
+
private url(): string {
|
|
251
|
+
const base = this.config.baseUrl ?? DEFAULT_BASE_URL;
|
|
252
|
+
const cut = base.indexOf('?');
|
|
253
|
+
const path = (cut === -1 ? base : base.slice(0, cut)).replace(/\/+$/, '');
|
|
254
|
+
return `${path}/chat/completions${cut === -1 ? '' : base.slice(cut)}`;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* What a call cost when the endpoint reported nothing.
|
|
260
|
+
*
|
|
261
|
+
* Every streamed request asks for usage (`stream_options.include_usage`), and every non-streamed
|
|
262
|
+
* response carries it — but a compatible server that ignores the field leaves the budget
|
|
263
|
+
* reconciling a real call against zero, which refunds the reservation in full and turns the ledger
|
|
264
|
+
* into a decoration. An estimate is wrong by a few percent in the safe direction; zero is wrong by
|
|
265
|
+
* all of it.
|
|
266
|
+
*/
|
|
267
|
+
export function estimatedUsage(request: GenerateRequest, text: string): TokenUsage {
|
|
268
|
+
return {
|
|
269
|
+
inputTokens: estimateInputTokens(request),
|
|
270
|
+
outputTokens: estimateTextTokens(text),
|
|
271
|
+
cacheReadTokens: 0,
|
|
272
|
+
cacheWriteTokens: 0,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
// Single responsibility: reading what an OpenAI-format endpoint sends back — one chat completion,
|
|
2
|
+
// and the SSE stream of the same answer arriving in pieces.
|
|
3
|
+
//
|
|
4
|
+
// Split from the provider for the reason wire.ts is: the assembler can then be driven frame by
|
|
5
|
+
// frame with no socket, which is the only way to cover a stream that arrives fragmented, out of
|
|
6
|
+
// order, or stops half way. An LLM response is untrusted input, so every field is parsed and
|
|
7
|
+
// nothing is cast.
|
|
8
|
+
|
|
9
|
+
import { AiTransportError } from './errors';
|
|
10
|
+
import type { StopDetails, StopReason, StreamChunk, TokenUsage } from './provider';
|
|
11
|
+
import type { SseFrame } from './sse';
|
|
12
|
+
import type { LlmToolCall } from './tools';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* What one answer amounts to. `usage` is optional and that is the format's fault, not a laxity:
|
|
16
|
+
* a streamed answer reports usage only in a final chunk, and only when the request asked for it.
|
|
17
|
+
* The provider decides what to do with an absent one — never this file, which reports what arrived.
|
|
18
|
+
*/
|
|
19
|
+
export interface ChatAnswer {
|
|
20
|
+
readonly text: string;
|
|
21
|
+
readonly toolCalls: readonly LlmToolCall[];
|
|
22
|
+
readonly stopReason: StopReason;
|
|
23
|
+
readonly stopDetails: StopDetails | undefined;
|
|
24
|
+
readonly usage: TokenUsage | undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const FINISH_REASONS: Readonly<Record<string, StopReason>> = {
|
|
28
|
+
stop: 'end_turn',
|
|
29
|
+
length: 'max_tokens',
|
|
30
|
+
tool_calls: 'tool_use',
|
|
31
|
+
// The legacy name for the same event; LiteLLM and older self-hosted servers still send it.
|
|
32
|
+
function_call: 'tool_use',
|
|
33
|
+
content_filter: 'refusal',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* In-band error frames carry a type, not a status, and the gateway's retry rule reads a status.
|
|
38
|
+
* Same mapping job as wire.ts's, over this format's own vocabulary.
|
|
39
|
+
*/
|
|
40
|
+
const ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
41
|
+
invalid_request_error: 400,
|
|
42
|
+
authentication_error: 401,
|
|
43
|
+
permission_error: 403,
|
|
44
|
+
not_found_error: 404,
|
|
45
|
+
rate_limit_exceeded: 429,
|
|
46
|
+
insufficient_quota: 429,
|
|
47
|
+
server_error: 500,
|
|
48
|
+
api_error: 500,
|
|
49
|
+
overloaded_error: 503,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** A finish reason this format knows, or `undefined` for `null` — which means "still going". */
|
|
53
|
+
export function parseFinishReason(raw: unknown): StopReason | undefined {
|
|
54
|
+
return typeof raw === 'string' ? FINISH_REASONS[raw] : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* `usage`, or `undefined` when the payload carried none.
|
|
59
|
+
*
|
|
60
|
+
* `prompt_tokens` INCLUDES the cached prefix on this wire, where Anthropic's `input_tokens`
|
|
61
|
+
* excludes it — so the cached half is subtracted out before it is reported as `cacheReadTokens`.
|
|
62
|
+
* Left in place it would be billed twice: once at the full input rate and again at the cache rate.
|
|
63
|
+
*/
|
|
64
|
+
export function parseOpenAiUsage(raw: unknown): TokenUsage | undefined {
|
|
65
|
+
const record = asRecord(raw);
|
|
66
|
+
if (record === undefined) return undefined;
|
|
67
|
+
const prompt = numberOf(record['prompt_tokens']);
|
|
68
|
+
const completion = numberOf(record['completion_tokens']);
|
|
69
|
+
if (prompt === undefined && completion === undefined) return undefined;
|
|
70
|
+
const cached = numberOf(asRecord(record['prompt_tokens_details'])?.['cached_tokens']) ?? 0;
|
|
71
|
+
return {
|
|
72
|
+
inputTokens: Math.max((prompt ?? 0) - cached, 0),
|
|
73
|
+
// `completion_tokens` already contains `reasoning_tokens`; adding them is a double count.
|
|
74
|
+
outputTokens: completion ?? 0,
|
|
75
|
+
cacheReadTokens: cached,
|
|
76
|
+
// Caching is automatic here and carries no write surcharge, so there is nothing to report.
|
|
77
|
+
cacheWriteTokens: 0,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One non-streamed chat completion. Refusal is read BEFORE anything else trusts the content. */
|
|
82
|
+
export function parseChatCompletion(raw: unknown, provider: string): ChatAnswer {
|
|
83
|
+
const record = asRecord(raw);
|
|
84
|
+
if (record === undefined) throw malformed(provider, 'the response body is not a JSON object');
|
|
85
|
+
throwInBandError(record, provider);
|
|
86
|
+
const choice = asRecord(Array.isArray(record['choices']) ? record['choices'][0] : undefined);
|
|
87
|
+
if (choice === undefined) throw malformed(provider, 'the response carried no choices');
|
|
88
|
+
const message = asRecord(choice['message']) ?? {};
|
|
89
|
+
const refusal = typeof message['refusal'] === 'string' ? message['refusal'] : undefined;
|
|
90
|
+
const finish = parseFinishReason(choice['finish_reason']);
|
|
91
|
+
return {
|
|
92
|
+
text: typeof message['content'] === 'string' ? message['content'] : '',
|
|
93
|
+
toolCalls: parseToolCalls(message['tool_calls'], provider),
|
|
94
|
+
// A refusal string is a refusal whatever the finish reason says: the field only ever appears
|
|
95
|
+
// when the model declined, and `stop` beside it would read downstream as an empty answer.
|
|
96
|
+
stopReason: refusal === undefined ? (finish ?? 'end_turn') : 'refusal',
|
|
97
|
+
stopDetails: refusalDetails(finish, refusal),
|
|
98
|
+
usage: parseOpenAiUsage(record['usage']),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function refusalDetails(
|
|
103
|
+
finish: StopReason | undefined,
|
|
104
|
+
refusal: string | undefined,
|
|
105
|
+
): StopDetails | undefined {
|
|
106
|
+
if (refusal !== undefined) return { type: 'refusal', category: 'refusal', explanation: refusal };
|
|
107
|
+
if (finish !== 'refusal') return undefined;
|
|
108
|
+
// `content_filter` is all the endpoint says. Carried as the category rather than dropped: it is
|
|
109
|
+
// the only thing that distinguishes a policy stop from a model that chose not to answer.
|
|
110
|
+
return { type: 'refusal', category: 'content_filter', explanation: undefined };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseToolCalls(raw: unknown, provider: string): readonly LlmToolCall[] {
|
|
114
|
+
if (!Array.isArray(raw)) return [];
|
|
115
|
+
const calls: LlmToolCall[] = [];
|
|
116
|
+
for (const entry of raw) {
|
|
117
|
+
const record = asRecord(entry);
|
|
118
|
+
const fn = asRecord(record?.['function']);
|
|
119
|
+
if (record === undefined || fn === undefined) continue;
|
|
120
|
+
const name = typeof fn['name'] === 'string' ? fn['name'] : '';
|
|
121
|
+
if (name === '') continue;
|
|
122
|
+
calls.push({
|
|
123
|
+
id: typeof record['id'] === 'string' ? record['id'] : '',
|
|
124
|
+
name,
|
|
125
|
+
input: parseArguments(fn['arguments'], name, provider),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return calls;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Arguments are a JSON string. A tool that takes none sends `''` or `'{}'`; neither is a fault. */
|
|
132
|
+
function parseArguments(raw: unknown, name: string, provider: string): Record<string, unknown> {
|
|
133
|
+
if (typeof raw !== 'string' || raw.trim() === '') return {};
|
|
134
|
+
try {
|
|
135
|
+
return asRecord(JSON.parse(raw)) ?? {};
|
|
136
|
+
} catch (error) {
|
|
137
|
+
throw malformed(
|
|
138
|
+
provider,
|
|
139
|
+
`tool "${name}" returned arguments that are not JSON: ${
|
|
140
|
+
error instanceof Error ? error.message : 'unreadable'
|
|
141
|
+
}`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
interface PendingCall {
|
|
147
|
+
id: string;
|
|
148
|
+
name: string;
|
|
149
|
+
args: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One streamed chat completion, assembled frame by frame.
|
|
154
|
+
*
|
|
155
|
+
* Two things this format does that the Anthropic one does not, and both are easy to get subtly
|
|
156
|
+
* wrong: a tool call arrives FRAGMENTED and INDEXED — its id, its name and successive slices of its
|
|
157
|
+
* arguments spread across chunks, keyed only by `tool_calls[].index` — and `usage` arrives once, in
|
|
158
|
+
* a trailing chunk whose `choices` array is empty, long after the finish reason.
|
|
159
|
+
*/
|
|
160
|
+
export class ChatCompletionStream {
|
|
161
|
+
private text = '';
|
|
162
|
+
private refusal = '';
|
|
163
|
+
private stopReason: StopReason = 'end_turn';
|
|
164
|
+
private finished = false;
|
|
165
|
+
private done = false;
|
|
166
|
+
private usage: TokenUsage | undefined;
|
|
167
|
+
private readonly pending = new Map<number, PendingCall>();
|
|
168
|
+
private readonly toolCalls: LlmToolCall[] = [];
|
|
169
|
+
private readonly provider: string;
|
|
170
|
+
|
|
171
|
+
constructor(provider: string) {
|
|
172
|
+
this.provider = provider;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Chunks this frame produced, in order. An unknown field yields nothing — the format grows. */
|
|
176
|
+
push(frame: SseFrame): readonly StreamChunk[] {
|
|
177
|
+
// The sentinel is not JSON, and parsing it is how a stream reader ends in a syntax error.
|
|
178
|
+
if (frame.data.trim() === '[DONE]') {
|
|
179
|
+
this.done = true;
|
|
180
|
+
return [];
|
|
181
|
+
}
|
|
182
|
+
const payload = this.payloadOf(frame);
|
|
183
|
+
throwInBandError(payload, this.provider);
|
|
184
|
+
const usage = parseOpenAiUsage(payload['usage']);
|
|
185
|
+
if (usage !== undefined) this.usage = usage;
|
|
186
|
+
const choice = asRecord(Array.isArray(payload['choices']) ? payload['choices'][0] : undefined);
|
|
187
|
+
if (choice === undefined) return [];
|
|
188
|
+
const chunks = this.onDelta(asRecord(choice['delta']) ?? {});
|
|
189
|
+
return [...chunks, ...this.onFinish(choice['finish_reason'])];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** True once the answer is accounted for. False means the connection died mid-answer. */
|
|
193
|
+
isComplete(): boolean {
|
|
194
|
+
// Either sentinel counts. `[DONE]` is the format's own end marker, but plenty of servers in
|
|
195
|
+
// the family close the socket straight after the finish-reason chunk — and a finish reason IS
|
|
196
|
+
// the model saying why it stopped, which is the fact a truncated stream cannot produce.
|
|
197
|
+
return this.done || this.finished;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** What the stream accumulated. `cost` is applied by the provider, which owns prices. */
|
|
201
|
+
state(): ChatAnswer {
|
|
202
|
+
const details = this.refusalDetails();
|
|
203
|
+
return {
|
|
204
|
+
text: this.text,
|
|
205
|
+
toolCalls: [...this.toolCalls],
|
|
206
|
+
// A refusal that arrived as a `refusal` delta finishes with `stop`, so the reason alone would
|
|
207
|
+
// read as a complete answer that happens to be empty.
|
|
208
|
+
stopReason: details === undefined ? this.stopReason : 'refusal',
|
|
209
|
+
stopDetails: details,
|
|
210
|
+
usage: this.usage,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private refusalDetails(): StopDetails | undefined {
|
|
215
|
+
if (this.refusal !== '') {
|
|
216
|
+
return { type: 'refusal', category: 'refusal', explanation: this.refusal };
|
|
217
|
+
}
|
|
218
|
+
return this.stopReason === 'refusal'
|
|
219
|
+
? { type: 'refusal', category: 'content_filter', explanation: undefined }
|
|
220
|
+
: undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private onDelta(delta: Record<string, unknown>): readonly StreamChunk[] {
|
|
224
|
+
const chunks: StreamChunk[] = [];
|
|
225
|
+
const content = delta['content'];
|
|
226
|
+
if (typeof content === 'string' && content !== '') {
|
|
227
|
+
this.text += content;
|
|
228
|
+
chunks.push({ type: 'text', text: content });
|
|
229
|
+
}
|
|
230
|
+
// `reasoning_content` is vLLM's and DeepSeek's; `reasoning` is the OpenRouter spelling. Neither
|
|
231
|
+
// is ever appended to `text`, for the reason thinking deltas are not: a consumer concatenating
|
|
232
|
+
// every chunk must not end up shipping the reasoning to the user.
|
|
233
|
+
const thinking = delta['reasoning_content'] ?? delta['reasoning'];
|
|
234
|
+
if (typeof thinking === 'string' && thinking !== '') {
|
|
235
|
+
chunks.push({ type: 'thinking', text: thinking });
|
|
236
|
+
}
|
|
237
|
+
const refusal = delta['refusal'];
|
|
238
|
+
if (typeof refusal === 'string') this.refusal += refusal;
|
|
239
|
+
this.accumulate(delta['tool_calls']);
|
|
240
|
+
return chunks;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Merge one chunk's tool-call fragments into the calls they belong to. `index` is the only key —
|
|
245
|
+
* `id` and `name` arrive on the first fragment and are absent from every later one, so appending
|
|
246
|
+
* by array position instead would build one call per chunk and lose every argument but the last.
|
|
247
|
+
*/
|
|
248
|
+
private accumulate(raw: unknown): void {
|
|
249
|
+
if (!Array.isArray(raw)) return;
|
|
250
|
+
for (const entry of raw) {
|
|
251
|
+
const record = asRecord(entry);
|
|
252
|
+
if (record === undefined) continue;
|
|
253
|
+
const index = numberOf(record['index']) ?? 0;
|
|
254
|
+
const call = this.pending.get(index) ?? { id: '', name: '', args: '' };
|
|
255
|
+
const id = record['id'];
|
|
256
|
+
if (typeof id === 'string' && id !== '') call.id = id;
|
|
257
|
+
const fn = asRecord(record['function']);
|
|
258
|
+
const name = fn?.['name'];
|
|
259
|
+
// Concatenated, not assigned: a server that splits the name across two frames is rare and
|
|
260
|
+
// legal, and assigning would keep only the tail.
|
|
261
|
+
if (typeof name === 'string') call.name += name;
|
|
262
|
+
const args = fn?.['arguments'];
|
|
263
|
+
if (typeof args === 'string') call.args += args;
|
|
264
|
+
this.pending.set(index, call);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The finish reason closes the turn, and it is the only close this format has: there is no
|
|
270
|
+
* per-block stop event, so pending tool calls are emitted here — whole, in index order, exactly
|
|
271
|
+
* as the Anthropic path emits them at `content_block_stop`. A fragment is never a call.
|
|
272
|
+
*/
|
|
273
|
+
private onFinish(raw: unknown): readonly StreamChunk[] {
|
|
274
|
+
const reason = parseFinishReason(raw);
|
|
275
|
+
if (reason === undefined) return [];
|
|
276
|
+
this.stopReason = reason;
|
|
277
|
+
this.finished = true;
|
|
278
|
+
const chunks: StreamChunk[] = [];
|
|
279
|
+
for (const index of [...this.pending.keys()].sort((a, b) => a - b)) {
|
|
280
|
+
const pending = this.pending.get(index);
|
|
281
|
+
if (pending === undefined || pending.name === '') continue;
|
|
282
|
+
const call: LlmToolCall = {
|
|
283
|
+
id: pending.id,
|
|
284
|
+
name: pending.name,
|
|
285
|
+
input: parseArguments(pending.args, pending.name, this.provider),
|
|
286
|
+
};
|
|
287
|
+
this.toolCalls.push(call);
|
|
288
|
+
chunks.push({ type: 'tool-call', call });
|
|
289
|
+
}
|
|
290
|
+
this.pending.clear();
|
|
291
|
+
return chunks;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
private payloadOf(frame: SseFrame): Record<string, unknown> {
|
|
295
|
+
try {
|
|
296
|
+
const record = asRecord(JSON.parse(frame.data));
|
|
297
|
+
if (record === undefined) throw new SyntaxError('frame data is not an object');
|
|
298
|
+
return record;
|
|
299
|
+
} catch (error) {
|
|
300
|
+
throw malformed(
|
|
301
|
+
this.provider,
|
|
302
|
+
`unreadable "${frame.event}" frame: ${
|
|
303
|
+
error instanceof Error ? error.message : 'unreadable'
|
|
304
|
+
}`,
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* A 200 that carries an `error` object instead of an answer — how a gateway in front of a model
|
|
312
|
+
* reports a fault it noticed after the headers were sent. Parsed as a message it would read as an
|
|
313
|
+
* empty, successful answer, which is the one outcome nothing downstream can detect.
|
|
314
|
+
*/
|
|
315
|
+
function throwInBandError(payload: Record<string, unknown>, provider: string): void {
|
|
316
|
+
const error = asRecord(payload['error']);
|
|
317
|
+
if (error === undefined) return;
|
|
318
|
+
const type = typeof error['type'] === 'string' ? error['type'] : 'api_error';
|
|
319
|
+
const code = typeof error['code'] === 'string' ? error['code'] : undefined;
|
|
320
|
+
const message = typeof error['message'] === 'string' ? error['message'] : type;
|
|
321
|
+
throw new AiTransportError({
|
|
322
|
+
provider,
|
|
323
|
+
status: ERROR_STATUS[type] ?? (code === undefined ? undefined : ERROR_STATUS[code]) ?? 500,
|
|
324
|
+
detail: message,
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function malformed(provider: string, detail: string): AiTransportError {
|
|
329
|
+
return new AiTransportError({ provider, detail });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
333
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
|
|
334
|
+
return value as Record<string, unknown>;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function numberOf(value: unknown): number | undefined {
|
|
338
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
339
|
+
}
|
package/src/pg-vector-sql.ts
CHANGED
|
@@ -45,7 +45,11 @@ export function conditionsSql(scope: VectorScope, filter?: MetadataFilter): SqlF
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
/**
|
|
48
|
-
*
|
|
48
|
+
* The store's whole schema as one string, for an app to split across migration files. **No
|
|
49
|
+
* command emits it** — `x db gen` diffs `describeEntities()` and a vector store is not an
|
|
50
|
+
* `entity()`, so no CLI file references this at all.
|
|
51
|
+
*
|
|
52
|
+
* The primary key is `(tenant, id)`, not `id`: it makes a cross-tenant
|
|
49
53
|
* overwrite impossible at the storage layer instead of relying on every upsert remembering to
|
|
50
54
|
* check. An unscoped store writes the empty tenant, which is a tenant like any other.
|
|
51
55
|
*/
|
package/src/pg-vector.ts
CHANGED
|
@@ -63,7 +63,8 @@ export class PgVectorStore implements VectorStore {
|
|
|
63
63
|
};
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/**
|
|
66
|
+
/** An app pastes this into migrations — no command emits it. Beside the queries so the index
|
|
67
|
+
* choice is reviewable against the reads that depend on it. */
|
|
67
68
|
ddl(): string {
|
|
68
69
|
return ddlSql(this.target);
|
|
69
70
|
}
|
package/src/prompt.ts
CHANGED
|
@@ -24,7 +24,7 @@ export interface DefinePromptInput<V extends PromptVars> {
|
|
|
24
24
|
readonly template: string;
|
|
25
25
|
/** Optional system prompt. Part of the hash — it changes behaviour. */
|
|
26
26
|
readonly system?: string;
|
|
27
|
-
/** Schema of the variables, for the manifest
|
|
27
|
+
/** Schema of the variables, for the manifest. */
|
|
28
28
|
readonly input?: JsonSchema;
|
|
29
29
|
/** Expected output shape, fed to `output_config.format` when the caller opts in. */
|
|
30
30
|
readonly output?: JsonSchema;
|