@kindgi/adapter-model-shared 0.0.0-bootstrap.0 → 0.1.6-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +57 -2
- package/dist/ai-sdk/index.d.ts +6 -0
- package/dist/ai-sdk/index.d.ts.map +1 -0
- package/dist/ai-sdk/index.js +8 -0
- package/dist/ai-sdk/index.js.map +1 -0
- package/dist/ai-sdk/provider.d.ts +36 -0
- package/dist/ai-sdk/provider.d.ts.map +1 -0
- package/dist/ai-sdk/provider.js +457 -0
- package/dist/ai-sdk/provider.js.map +1 -0
- package/dist/cost.d.ts +32 -0
- package/dist/cost.d.ts.map +1 -0
- package/dist/cost.js +43 -0
- package/dist/cost.js.map +1 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +57 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/retries.d.ts +35 -0
- package/dist/retries.d.ts.map +1 -0
- package/dist/retries.js +69 -0
- package/dist/retries.js.map +1 -0
- package/package.json +54 -4
- package/src/ai-sdk/index.ts +12 -0
- package/src/ai-sdk/provider.ts +573 -0
- package/src/cost.ts +82 -0
- package/src/errors.ts +90 -0
- package/src/index.ts +23 -0
- package/src/retries.ts +104 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Today's engine: Kindgi's `ModelProvider` over an AI SDK provider model (provider spec V4),
|
|
6
|
+
* called at the spec level (`doGenerate`), never the `ai` package, its agent loop or Vercel's
|
|
7
|
+
* gateway. This module is the only place in the package that
|
|
8
|
+
* knows the AI SDK: an adapter hands in its model (`languageModel`), its provider options and
|
|
9
|
+
* its cost formula, and gets a plain `ModelProvider` back. It does, the same way for each:
|
|
10
|
+
* - our retries (`../retries.ts`), counted per HTTP attempt (`@kindgi/capabilities/attempts`);
|
|
11
|
+
* - typed errors (`ModelProviderError`, `../errors.ts`);
|
|
12
|
+
* - the provider's opaque reasoning state, carried across a pause in the first tool call's
|
|
13
|
+
* `signature` (`aisdk1.`) and sent back on the next call to the same model;
|
|
14
|
+
* - usage onto our counters, sampling a model refuses dropped with a warning, `traceparent`
|
|
15
|
+
* sent only when the call has one.
|
|
16
|
+
*/
|
|
17
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
AISDKError,
|
|
21
|
+
APICallError,
|
|
22
|
+
EmptyResponseBodyError,
|
|
23
|
+
InvalidResponseDataError,
|
|
24
|
+
JSONParseError,
|
|
25
|
+
TypeValidationError,
|
|
26
|
+
} from '@ai-sdk/provider';
|
|
27
|
+
import type {
|
|
28
|
+
LanguageModelV4,
|
|
29
|
+
LanguageModelV4CallOptions,
|
|
30
|
+
LanguageModelV4Content,
|
|
31
|
+
LanguageModelV4Message,
|
|
32
|
+
LanguageModelV4Prompt,
|
|
33
|
+
SharedV4ProviderMetadata,
|
|
34
|
+
SharedV4ProviderOptions,
|
|
35
|
+
} from '@ai-sdk/provider';
|
|
36
|
+
|
|
37
|
+
import { samplingFor } from '@kindgi/capabilities';
|
|
38
|
+
import type {
|
|
39
|
+
ModelCallInput,
|
|
40
|
+
ModelCallResult,
|
|
41
|
+
ModelCallWarning,
|
|
42
|
+
ModelInfo,
|
|
43
|
+
ModelMessage,
|
|
44
|
+
ModelProvider,
|
|
45
|
+
ModelToolCall,
|
|
46
|
+
ProviderMetadata,
|
|
47
|
+
UsageCounters,
|
|
48
|
+
} from '@kindgi/capabilities';
|
|
49
|
+
import { createAttemptCounter } from '@kindgi/capabilities/attempts';
|
|
50
|
+
import { nameToolsAsSent } from '@kindgi/capabilities/tool-names';
|
|
51
|
+
|
|
52
|
+
import { ModelProviderError, modelProviderError } from '../errors.js';
|
|
53
|
+
import { type RetryableFailure, withRetries } from '../retries.js';
|
|
54
|
+
|
|
55
|
+
export interface AiSdkModelProviderOptions {
|
|
56
|
+
readonly metadata: ProviderMetadata;
|
|
57
|
+
/** The provider's model for a name, sending with `fetch` (it counts each HTTP attempt). */
|
|
58
|
+
readonly languageModel: (name: string, fetch: typeof globalThis.fetch) => LanguageModelV4;
|
|
59
|
+
/** Provider options on every call (OpenAI: `store: false`). */
|
|
60
|
+
readonly providerOptions?: (model: ModelInfo) => SharedV4ProviderOptions | undefined;
|
|
61
|
+
/** Our cost formula for this vendor, from its usage. */
|
|
62
|
+
readonly cost: (model: ModelInfo, usage: UsageCounters) => number;
|
|
63
|
+
/** HTTP attempts in all on a retryable failure (408, 409, 429, 5xx, network). Default 3. */
|
|
64
|
+
readonly attempts?: number;
|
|
65
|
+
/**
|
|
66
|
+
* Run before each HTTP attempt, inside the retries, with the call's abort signal: where an
|
|
67
|
+
* adapter signs in (the runtime's identity, a key) itself, so a failure is typed by the adapter
|
|
68
|
+
* rather than by its library. What it returns is this attempt's own (`attemptPrepared()`), for
|
|
69
|
+
* the adapter's code the library calls during the attempt. A `ModelProviderError` thrown here
|
|
70
|
+
* (or anywhere in the adapter's own code) ends the call as it is, never retried.
|
|
71
|
+
*/
|
|
72
|
+
readonly beforeAttempt?: (signal: AbortSignal | undefined) => Promise<unknown>;
|
|
73
|
+
/**
|
|
74
|
+
* A sentence for a failed call's message, where the vendor's own words don't say what to
|
|
75
|
+
* check (Bedrock's 403: the IAM policy, or the account's access to the model). Undefined:
|
|
76
|
+
* the message as it is.
|
|
77
|
+
*/
|
|
78
|
+
readonly explain?: (error: ModelProviderError) => string | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Each attempt's `beforeAttempt` result, for the code the library calls during that attempt. */
|
|
82
|
+
const attemptScope = new AsyncLocalStorage<{ readonly prepared: unknown }>();
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* What this attempt's `beforeAttempt` returned, read from code the library calls during the
|
|
86
|
+
* attempt (a credential provider, a fetch wrapper); undefined outside one. Per attempt, so calls
|
|
87
|
+
* running at once never see each other's.
|
|
88
|
+
*/
|
|
89
|
+
export function attemptPrepared<T>(): T | undefined {
|
|
90
|
+
return attemptScope.getStore()?.prepared as T | undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const wireName = (name: string) => name.replace(/\./g, '__');
|
|
94
|
+
const ourName = (name: string) => name.replace(/__/g, '.');
|
|
95
|
+
|
|
96
|
+
export function createAiSdkModelProvider(options: AiSdkModelProviderOptions): ModelProvider {
|
|
97
|
+
const { metadata } = options;
|
|
98
|
+
const models = new Map(metadata.models.map((m) => [m.name, m] as const));
|
|
99
|
+
const counter = createAttemptCounter();
|
|
100
|
+
const built = new Map<string, LanguageModelV4>();
|
|
101
|
+
const lm = (name: string) => {
|
|
102
|
+
let model = built.get(name);
|
|
103
|
+
if (model === undefined) {
|
|
104
|
+
model = options.languageModel(name, counter.fetch);
|
|
105
|
+
built.set(name, model);
|
|
106
|
+
}
|
|
107
|
+
return model;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
metadata,
|
|
112
|
+
async invoke(input: ModelCallInput): Promise<ModelCallResult> {
|
|
113
|
+
const model = models.get(input.model);
|
|
114
|
+
if (model === undefined) {
|
|
115
|
+
throw new ModelProviderError(
|
|
116
|
+
'invalid-request',
|
|
117
|
+
undefined,
|
|
118
|
+
`provider "${metadata.id}" has no model "${input.model}"`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const startedAt = Date.now();
|
|
122
|
+
const toolIds = input.tools?.map((t) => t.name) ?? [];
|
|
123
|
+
const sampling = samplingFor(model, input);
|
|
124
|
+
const maxOutputTokens = input.maxOutputTokens ?? model.maxOutputTokens;
|
|
125
|
+
const extra = options.providerOptions?.(model);
|
|
126
|
+
const call: LanguageModelV4CallOptions = {
|
|
127
|
+
prompt: toPrompt(input.messages, { provider: metadata.id, model: input.model }, toolIds),
|
|
128
|
+
...(maxOutputTokens !== undefined && { maxOutputTokens }),
|
|
129
|
+
...(sampling.temperature !== undefined && { temperature: sampling.temperature }),
|
|
130
|
+
...(input.tools !== undefined &&
|
|
131
|
+
input.tools.length > 0 && {
|
|
132
|
+
tools: input.tools.map((t) => ({
|
|
133
|
+
type: 'function' as const,
|
|
134
|
+
name: wireName(t.name),
|
|
135
|
+
description: t.description,
|
|
136
|
+
inputSchema: t.inputSchema as never,
|
|
137
|
+
})),
|
|
138
|
+
toolChoice: { type: 'auto' as const },
|
|
139
|
+
}),
|
|
140
|
+
...(input.structuredOutput !== undefined && {
|
|
141
|
+
responseFormat: {
|
|
142
|
+
type: 'json' as const,
|
|
143
|
+
schema: input.structuredOutput.schema as never,
|
|
144
|
+
name: input.structuredOutput.name,
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
...(input.thinking === 'lowest' &&
|
|
148
|
+
model.thinking !== undefined && { reasoning: portableReasoning(model.thinking.lowest) }),
|
|
149
|
+
...(input.abortSignal !== undefined && { abortSignal: input.abortSignal }),
|
|
150
|
+
...(input.traceparent !== undefined && { headers: { traceparent: input.traceparent } }),
|
|
151
|
+
...(extra !== undefined && { providerOptions: extra }),
|
|
152
|
+
};
|
|
153
|
+
const counted = await counter.count(() =>
|
|
154
|
+
withRetries(
|
|
155
|
+
async () => {
|
|
156
|
+
const prepared = await options.beforeAttempt?.(input.abortSignal);
|
|
157
|
+
return attemptScope.run({ prepared }, () => lm(input.model).doGenerate(call));
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
attempts: options.attempts ?? 3,
|
|
161
|
+
...(input.abortSignal !== undefined && { signal: input.abortSignal }),
|
|
162
|
+
describe: describeFailure,
|
|
163
|
+
// An adapter's own typed error (its sign-in failed) is the answer as it is; an error
|
|
164
|
+
// that's neither the vendor's answer nor the network (a bug) propagates as it is.
|
|
165
|
+
toError: (failure, error) => {
|
|
166
|
+
const own = adapterError(error);
|
|
167
|
+
if (own === undefined && failure.unrecognized === true) {
|
|
168
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
169
|
+
}
|
|
170
|
+
return explained(own ?? modelProviderError(failure, error), options.explain);
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
const result = counted.value;
|
|
176
|
+
|
|
177
|
+
const toolCalls: ModelToolCall[] = [];
|
|
178
|
+
for (const part of result.content) {
|
|
179
|
+
if (part.type === 'tool-call' && part.providerExecuted !== true) {
|
|
180
|
+
toolCalls.push({
|
|
181
|
+
id: part.toolCallId,
|
|
182
|
+
name: ourName(part.toolName),
|
|
183
|
+
arguments: parseJson(part.input),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (toolCalls.length > 0) {
|
|
188
|
+
const first = toolCalls[0] as ModelToolCall;
|
|
189
|
+
toolCalls[0] = {
|
|
190
|
+
...first,
|
|
191
|
+
signature: encodeCarry({ provider: metadata.id, model: input.model }, result.content),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const text = result.content.flatMap((p) => (p.type === 'text' ? [p.text] : [])).join('');
|
|
195
|
+
const usage = toUsage(result.usage);
|
|
196
|
+
const warnings: ModelCallWarning[] = [
|
|
197
|
+
...sampling.warnings,
|
|
198
|
+
...result.warnings.map((w) => ({
|
|
199
|
+
code: `sdk-${w.type}`,
|
|
200
|
+
message:
|
|
201
|
+
'feature' in w
|
|
202
|
+
? `${w.feature}${w.details ? `: ${w.details}` : ''}`
|
|
203
|
+
: 'message' in w
|
|
204
|
+
? String(w.message)
|
|
205
|
+
: JSON.stringify(w),
|
|
206
|
+
})),
|
|
207
|
+
];
|
|
208
|
+
const headers = result.response?.headers ?? {};
|
|
209
|
+
const requestId =
|
|
210
|
+
headers['x-request-id'] ??
|
|
211
|
+
headers['request-id'] ??
|
|
212
|
+
headers['x-amzn-requestid'] ??
|
|
213
|
+
headers['apim-request-id'] ??
|
|
214
|
+
result.response?.id;
|
|
215
|
+
return {
|
|
216
|
+
message: { role: 'assistant', content: text, ...(toolCalls.length > 0 && { toolCalls }) },
|
|
217
|
+
finishReason: toolCalls.length > 0 ? 'tool-use' : finishReason(result.finishReason),
|
|
218
|
+
usage,
|
|
219
|
+
costUsd: options.cost(model, usage),
|
|
220
|
+
durationMs: Date.now() - startedAt,
|
|
221
|
+
provider: { id: metadata.id, model: input.model },
|
|
222
|
+
...(result.response?.modelId !== undefined && { servedModel: result.response.modelId }),
|
|
223
|
+
...(requestId !== undefined && { providerRequestId: requestId }),
|
|
224
|
+
...(counted.attempts > 0 && { attempts: counted.attempts }),
|
|
225
|
+
...(result.usage.raw !== undefined && { rawUsage: result.usage.raw }),
|
|
226
|
+
...(warnings.length > 0 && { warnings }),
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Our lowest-thinking values onto the SDK's portable `reasoning` levels. */
|
|
233
|
+
function portableReasoning(lowest: string): NonNullable<LanguageModelV4CallOptions['reasoning']> {
|
|
234
|
+
if (lowest === 'disabled') return 'none';
|
|
235
|
+
if (lowest === 'between_tools') return 'low';
|
|
236
|
+
return lowest as NonNullable<LanguageModelV4CallOptions['reasoning']>;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The model's own reason when the library has no unified one for it: out of context is `length`. */
|
|
240
|
+
const CONTEXT_WINDOW_STOP = /context_window|context_length/i;
|
|
241
|
+
|
|
242
|
+
function finishReason(reason: {
|
|
243
|
+
readonly unified: string;
|
|
244
|
+
readonly raw?: string | undefined;
|
|
245
|
+
}): ModelCallResult['finishReason'] {
|
|
246
|
+
switch (reason.unified) {
|
|
247
|
+
case 'stop':
|
|
248
|
+
return 'stop';
|
|
249
|
+
case 'length':
|
|
250
|
+
return 'length';
|
|
251
|
+
case 'content-filter':
|
|
252
|
+
return 'content-filter';
|
|
253
|
+
case 'tool-calls':
|
|
254
|
+
return 'tool-use';
|
|
255
|
+
default:
|
|
256
|
+
return reason.raw !== undefined && CONTEXT_WINDOW_STOP.test(reason.raw) ? 'length' : 'error';
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function toUsage(u: {
|
|
261
|
+
inputTokens: Record<string, number | undefined>;
|
|
262
|
+
outputTokens: Record<string, number | undefined>;
|
|
263
|
+
}): UsageCounters {
|
|
264
|
+
const read = u.inputTokens.cacheRead;
|
|
265
|
+
const write = u.inputTokens.cacheWrite;
|
|
266
|
+
const reasoning = u.outputTokens.reasoning;
|
|
267
|
+
return {
|
|
268
|
+
promptTokens: u.inputTokens.total ?? (u.inputTokens.noCache ?? 0) + (read ?? 0) + (write ?? 0),
|
|
269
|
+
completionTokens: u.outputTokens.total ?? 0,
|
|
270
|
+
...(read !== undefined && { cacheReadTokens: read }),
|
|
271
|
+
...(write !== undefined && { cacheWriteTokens: write }),
|
|
272
|
+
...(reasoning !== undefined && { reasoningTokens: reasoning }),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseJson(raw: string): Record<string, unknown> {
|
|
277
|
+
try {
|
|
278
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
279
|
+
} catch {
|
|
280
|
+
return {};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The opaque state a provider returns with an answer (reasoning parts with their signatures or
|
|
286
|
+
* encrypted content, text parts' ids, tool calls' thought signatures), in order, carried in the
|
|
287
|
+
* first tool call's `signature` and sent back as `providerOptions` on the next call to the same
|
|
288
|
+
* provider's same model. Only there: two providers can serve a model of the same name.
|
|
289
|
+
*/
|
|
290
|
+
interface Carry {
|
|
291
|
+
readonly provider: string;
|
|
292
|
+
readonly model: string;
|
|
293
|
+
readonly parts: readonly (
|
|
294
|
+
| {
|
|
295
|
+
readonly type: 'reasoning';
|
|
296
|
+
readonly text: string;
|
|
297
|
+
readonly meta?: SharedV4ProviderMetadata;
|
|
298
|
+
}
|
|
299
|
+
| { readonly type: 'text'; readonly text: string; readonly meta?: SharedV4ProviderMetadata }
|
|
300
|
+
| { readonly type: 'tool-call'; readonly id: string; readonly meta?: SharedV4ProviderMetadata }
|
|
301
|
+
)[];
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Whose answer a carry is: the provider's id and the model's name. */
|
|
305
|
+
interface CarryOwner {
|
|
306
|
+
readonly provider: string;
|
|
307
|
+
readonly model: string;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function encodeCarry(owner: CarryOwner, content: readonly LanguageModelV4Content[]): string {
|
|
311
|
+
const parts: Carry['parts'][number][] = [];
|
|
312
|
+
for (const p of content) {
|
|
313
|
+
if (p.type === 'reasoning')
|
|
314
|
+
parts.push({
|
|
315
|
+
type: 'reasoning',
|
|
316
|
+
text: p.text,
|
|
317
|
+
...(p.providerMetadata && { meta: p.providerMetadata }),
|
|
318
|
+
});
|
|
319
|
+
else if (p.type === 'text')
|
|
320
|
+
parts.push({
|
|
321
|
+
type: 'text',
|
|
322
|
+
text: p.text,
|
|
323
|
+
...(p.providerMetadata && { meta: p.providerMetadata }),
|
|
324
|
+
});
|
|
325
|
+
else if (p.type === 'tool-call' && p.providerExecuted !== true)
|
|
326
|
+
parts.push({
|
|
327
|
+
type: 'tool-call',
|
|
328
|
+
id: p.toolCallId,
|
|
329
|
+
...(p.providerMetadata && { meta: p.providerMetadata }),
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
const carry: Carry = { provider: owner.provider, model: owner.model, parts };
|
|
333
|
+
return `aisdk1.${Buffer.from(JSON.stringify(carry)).toString('base64url')}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function decodeCarry(signature: string | undefined, owner: CarryOwner): Carry | undefined {
|
|
337
|
+
if (signature === undefined || !signature.startsWith('aisdk1.')) return undefined;
|
|
338
|
+
try {
|
|
339
|
+
const carry = JSON.parse(
|
|
340
|
+
Buffer.from(signature.slice(7), 'base64url').toString('utf8'),
|
|
341
|
+
) as Carry;
|
|
342
|
+
return carry.provider === owner.provider && carry.model === owner.model ? carry : undefined;
|
|
343
|
+
} catch {
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Our message trail as the spec's prompt; a carried answer is rebuilt part by part. */
|
|
349
|
+
function toPrompt(
|
|
350
|
+
messages: readonly ModelMessage[],
|
|
351
|
+
owner: CarryOwner,
|
|
352
|
+
toolIds: readonly string[],
|
|
353
|
+
): LanguageModelV4Prompt {
|
|
354
|
+
const prompt: LanguageModelV4Message[] = [];
|
|
355
|
+
const toolNames = new Map<string, string>();
|
|
356
|
+
for (const m of messages) {
|
|
357
|
+
if (m.role === 'system') {
|
|
358
|
+
prompt.push({ role: 'system', content: nameToolsAsSent(m.content, toolIds, wireName) });
|
|
359
|
+
} else if (m.role === 'user') {
|
|
360
|
+
prompt.push({ role: 'user', content: [{ type: 'text', text: m.content }] });
|
|
361
|
+
} else if (m.role === 'assistant') {
|
|
362
|
+
const calls = m.toolCalls ?? [];
|
|
363
|
+
for (const c of calls) toolNames.set(c.id, wireName(c.name));
|
|
364
|
+
const carry = decodeCarry(calls[0]?.signature, owner);
|
|
365
|
+
const content: Extract<LanguageModelV4Message, { role: 'assistant' }>['content'] = [];
|
|
366
|
+
if (carry !== undefined) {
|
|
367
|
+
const byId = new Map(calls.map((c) => [c.id, c] as const));
|
|
368
|
+
// The answer's text as the model gave it, part by part with each part's own state, while
|
|
369
|
+
// the trail still says the same; once it doesn't (a caller trimmed or redacted it), the
|
|
370
|
+
// trail's text alone, where the first part was, without state that was about other words.
|
|
371
|
+
const texts = carry.parts.flatMap((p) => (p.type === 'text' ? [p.text] : []));
|
|
372
|
+
const faithful = texts.join('') === m.content;
|
|
373
|
+
let textPlaced = false;
|
|
374
|
+
const placeText = () => {
|
|
375
|
+
if (!textPlaced && m.content.length > 0) content.push({ type: 'text', text: m.content });
|
|
376
|
+
textPlaced = true;
|
|
377
|
+
};
|
|
378
|
+
for (const p of carry.parts) {
|
|
379
|
+
if (p.type === 'reasoning')
|
|
380
|
+
content.push({
|
|
381
|
+
type: 'reasoning',
|
|
382
|
+
text: p.text,
|
|
383
|
+
...(p.meta && { providerOptions: p.meta }),
|
|
384
|
+
});
|
|
385
|
+
else if (p.type === 'text' && faithful) {
|
|
386
|
+
if (p.text.length > 0 || p.meta !== undefined)
|
|
387
|
+
content.push({
|
|
388
|
+
type: 'text',
|
|
389
|
+
text: p.text,
|
|
390
|
+
...(p.meta && { providerOptions: p.meta }),
|
|
391
|
+
});
|
|
392
|
+
} else if (p.type === 'text') placeText();
|
|
393
|
+
else if (p.type === 'tool-call') {
|
|
394
|
+
if (!faithful) placeText();
|
|
395
|
+
const c = byId.get(p.id);
|
|
396
|
+
if (c !== undefined)
|
|
397
|
+
content.push({
|
|
398
|
+
type: 'tool-call',
|
|
399
|
+
toolCallId: c.id,
|
|
400
|
+
toolName: wireName(c.name),
|
|
401
|
+
input: c.arguments,
|
|
402
|
+
...(p.meta && { providerOptions: p.meta }),
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
} else {
|
|
407
|
+
if (m.content.length > 0) content.push({ type: 'text', text: m.content });
|
|
408
|
+
for (const c of calls)
|
|
409
|
+
content.push({
|
|
410
|
+
type: 'tool-call',
|
|
411
|
+
toolCallId: c.id,
|
|
412
|
+
toolName: wireName(c.name),
|
|
413
|
+
input: c.arguments,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (content.length > 0) prompt.push({ role: 'assistant', content });
|
|
417
|
+
} else {
|
|
418
|
+
const id = m.toolCallId ?? '';
|
|
419
|
+
const part = {
|
|
420
|
+
type: 'tool-result' as const,
|
|
421
|
+
toolCallId: id,
|
|
422
|
+
toolName: toolNames.get(id) ?? 'unknown',
|
|
423
|
+
output: { type: 'text' as const, value: m.content },
|
|
424
|
+
};
|
|
425
|
+
const last = prompt[prompt.length - 1];
|
|
426
|
+
if (last?.role === 'tool') last.content.push(part);
|
|
427
|
+
else prompt.push({ role: 'tool', content: [part] });
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return prompt;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** The error with the adapter's sentence after its message, when it has one. */
|
|
434
|
+
function explained(
|
|
435
|
+
error: ModelProviderError,
|
|
436
|
+
explain: AiSdkModelProviderOptions['explain'],
|
|
437
|
+
): ModelProviderError {
|
|
438
|
+
const sentence = explain?.(error);
|
|
439
|
+
if (sentence === undefined || sentence === '') return error;
|
|
440
|
+
return new ModelProviderError(error.kind, error.status, `${error.message} ${sentence}`, {
|
|
441
|
+
cause: error.cause,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* The adapter's own typed error, as thrown, or as the library re-wrapped it: the AI SDK turns an
|
|
447
|
+
* error from a fetch wrapper whose cause carries a network code (`ECONNREFUSED` while a key is
|
|
448
|
+
* read, say) into a retryable "Cannot connect to API", with the adapter's error as its cause.
|
|
449
|
+
*/
|
|
450
|
+
function adapterError(error: unknown): ModelProviderError | undefined {
|
|
451
|
+
if (error instanceof ModelProviderError) return error;
|
|
452
|
+
if (APICallError.isInstance(error) && error.cause instanceof ModelProviderError) {
|
|
453
|
+
return error.cause;
|
|
454
|
+
}
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The codes of a failed connection, as Node (`ECONNRESET`), undici (`UND_ERR_SOCKET`) and Bun
|
|
460
|
+
* (`ConnectionRefused`) set them on an error or its causes: the AI SDK's list, plus DNS.
|
|
461
|
+
*/
|
|
462
|
+
const TRANSPORT_CODES = new Set([
|
|
463
|
+
'ECONNRESET',
|
|
464
|
+
'ECONNREFUSED',
|
|
465
|
+
'ECONNABORTED',
|
|
466
|
+
'ETIMEDOUT',
|
|
467
|
+
'EPIPE',
|
|
468
|
+
'ENOTFOUND',
|
|
469
|
+
'EAI_AGAIN',
|
|
470
|
+
'ENETUNREACH',
|
|
471
|
+
'EHOSTUNREACH',
|
|
472
|
+
'UND_ERR_SOCKET',
|
|
473
|
+
'UND_ERR_HEADERS_TIMEOUT',
|
|
474
|
+
'UND_ERR_BODY_TIMEOUT',
|
|
475
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
476
|
+
'ConnectionRefused',
|
|
477
|
+
'ConnectionClosed',
|
|
478
|
+
'FailedToOpenSocket',
|
|
479
|
+
]);
|
|
480
|
+
|
|
481
|
+
/** A failed connection: `fetch failed`, or a transport code on the error or one of its causes. */
|
|
482
|
+
function isTransport(error: unknown): boolean {
|
|
483
|
+
const seen = new Set<unknown>();
|
|
484
|
+
for (let e = error; e instanceof Error && !seen.has(e); e = e.cause) {
|
|
485
|
+
seen.add(e);
|
|
486
|
+
const code = (e as { code?: unknown }).code;
|
|
487
|
+
if (typeof code === 'string' && TRANSPORT_CODES.has(code)) return true;
|
|
488
|
+
if (e instanceof TypeError && /^(fetch failed|failed to fetch)$/i.test(e.message)) return true;
|
|
489
|
+
}
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* A connection refused by policy, the same every time it's tried: a redirect the request won't
|
|
495
|
+
* follow (its credential goes to the endpoint only), or a host refused before any connection, by
|
|
496
|
+
* a system-style code that isn't a dropped connection's (the runtime's egress rules:
|
|
497
|
+
* `EKINDGIEGRESS`). Its kind and words, when it's one.
|
|
498
|
+
*/
|
|
499
|
+
function refusedConnection(
|
|
500
|
+
error: unknown,
|
|
501
|
+
): { readonly kind: 'unavailable' | 'network'; readonly words: string } | undefined {
|
|
502
|
+
const seen = new Set<unknown>();
|
|
503
|
+
for (let e = error; e instanceof Error && !seen.has(e); e = e.cause) {
|
|
504
|
+
seen.add(e);
|
|
505
|
+
if (/^unexpected redirect$/i.test(e.message)) {
|
|
506
|
+
return {
|
|
507
|
+
kind: 'unavailable',
|
|
508
|
+
words:
|
|
509
|
+
"the endpoint answered with a redirect, which isn't followed: a request's credential goes to its endpoint only. Check the endpoint.",
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
const code = (e as { code?: unknown }).code;
|
|
513
|
+
if (
|
|
514
|
+
typeof code === 'string' &&
|
|
515
|
+
/^E[A-Z0-9_]+$/.test(code) &&
|
|
516
|
+
!code.startsWith('ERR_') &&
|
|
517
|
+
!TRANSPORT_CODES.has(code)
|
|
518
|
+
) {
|
|
519
|
+
return { kind: 'network', words: e.message };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** An answer the library couldn't read: a malformed 200, an empty body, a shape it doesn't know. */
|
|
526
|
+
function isUnreadableAnswer(error: unknown): boolean {
|
|
527
|
+
return (
|
|
528
|
+
InvalidResponseDataError.isInstance(error) ||
|
|
529
|
+
JSONParseError.isInstance(error) ||
|
|
530
|
+
TypeValidationError.isInstance(error) ||
|
|
531
|
+
EmptyResponseBodyError.isInstance(error)
|
|
532
|
+
);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** A failure as the retry policy needs it, and whether it's one we know (a bug isn't). */
|
|
536
|
+
interface DescribedFailure extends RetryableFailure {
|
|
537
|
+
readonly unrecognized?: true;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** An AI SDK failure as the retry policy and the typed error need it. */
|
|
541
|
+
function describeFailure(error: unknown): DescribedFailure {
|
|
542
|
+
const words = error instanceof Error ? error.message : String(error);
|
|
543
|
+
const own = adapterError(error);
|
|
544
|
+
if (own !== undefined) {
|
|
545
|
+
return {
|
|
546
|
+
...(own.status !== undefined && { status: own.status }),
|
|
547
|
+
words: own.message,
|
|
548
|
+
retryable: false,
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
// Refused, not dropped: before the library's own verdict, which retries any failed connection.
|
|
552
|
+
const refused = refusedConnection(error);
|
|
553
|
+
if (refused !== undefined) return { ...refused, retryable: false };
|
|
554
|
+
if (APICallError.isInstance(error)) {
|
|
555
|
+
return {
|
|
556
|
+
...(error.statusCode !== undefined && { status: error.statusCode }),
|
|
557
|
+
words: error.responseBody?.trim() || error.message,
|
|
558
|
+
retryable: error.isRetryable,
|
|
559
|
+
...(error.responseHeaders !== undefined && { headers: error.responseHeaders }),
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
// No response at all: a dropped connection, DNS, TLS.
|
|
563
|
+
if (isTransport(error)) return { words, retryable: true };
|
|
564
|
+
// The vendor answered, and we couldn't read it. Once more, in case it was a bad gateway's
|
|
565
|
+
// page; not again, since each answer may be billed.
|
|
566
|
+
if (isUnreadableAnswer(error)) {
|
|
567
|
+
return { kind: 'unavailable', words, retryable: true, maxAttempts: 2 };
|
|
568
|
+
}
|
|
569
|
+
// The request couldn't be built or sent as asked (a prompt or setting the provider refuses
|
|
570
|
+
// before any HTTP): the caller's request, not retried.
|
|
571
|
+
if (AISDKError.isInstance(error)) return { kind: 'invalid-request', words, retryable: false };
|
|
572
|
+
return { words, retryable: false, unrecognized: true };
|
|
573
|
+
}
|
package/src/cost.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Copyright (C) 2026 Kindgi Inc.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A call's cost from its usage and the model's registered rates, for adapters whose vendor
|
|
6
|
+
* bills by tokens: prompt and completion rates per 1,000 tokens, cache reads and writes as
|
|
7
|
+
* multiples of the prompt rate, a long-context tier, and a data-residency uplift. The same
|
|
8
|
+
* arithmetic as the OpenAI-compatible and Anthropic adapters' own formulas (a test holds the
|
|
9
|
+
* three equal on the bundled presets); what differs between vendors is only what a missing
|
|
10
|
+
* multiplier means, which each adapter says (`TokenCostOptions`).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ModelInfo, UsageCounters } from '@kindgi/capabilities';
|
|
14
|
+
|
|
15
|
+
export interface TokenCostOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Cache reads' multiple of the prompt rate when the model's cost names none
|
|
18
|
+
* (`cachedPromptMultiplier`, or `promptCacheReadMultiplier`). Default 1: the prompt rate,
|
|
19
|
+
* as OpenAI bills; Anthropic's adapters pass 0.1.
|
|
20
|
+
*/
|
|
21
|
+
readonly cacheReadMultiplier?: number;
|
|
22
|
+
/**
|
|
23
|
+
* Cache writes' multiple of the prompt rate when the model's cost names none
|
|
24
|
+
* (`promptCacheCreationMultiplier`). Default 1; Anthropic's adapters pass 1.25 (the
|
|
25
|
+
* 5-minute cache).
|
|
26
|
+
*/
|
|
27
|
+
readonly cacheWriteMultiplier?: number;
|
|
28
|
+
/** The call went to a data-residency endpoint: the model's `dataResidencyMultiplier` applies. */
|
|
29
|
+
readonly dataResidency?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* USD for one call. `usage.promptTokens` includes the cache reads and writes; a prompt past
|
|
34
|
+
* the model's `longContext.thresholdTokens` bills the whole call at the long rates, the
|
|
35
|
+
* cache multipliers applying to the long prompt rate.
|
|
36
|
+
*/
|
|
37
|
+
export function tokenCostUsd(
|
|
38
|
+
model: ModelInfo,
|
|
39
|
+
usage: UsageCounters,
|
|
40
|
+
options: TokenCostOptions = {},
|
|
41
|
+
): number {
|
|
42
|
+
const cost = model.cost as Partial<Record<string, unknown>>;
|
|
43
|
+
const long = longContextOf(cost.longContext);
|
|
44
|
+
const tier = long !== undefined && usage.promptTokens > long.thresholdTokens ? long : undefined;
|
|
45
|
+
const promptRate = tier?.promptUsdPer1kTokens ?? model.cost.promptUsdPer1kTokens;
|
|
46
|
+
const completionRate = tier?.completionUsdPer1kTokens ?? model.cost.completionUsdPer1kTokens;
|
|
47
|
+
const readMultiplier =
|
|
48
|
+
rateOf(cost.cachedPromptMultiplier) ??
|
|
49
|
+
rateOf(cost.promptCacheReadMultiplier) ??
|
|
50
|
+
options.cacheReadMultiplier ??
|
|
51
|
+
1;
|
|
52
|
+
const writeMultiplier =
|
|
53
|
+
rateOf(cost.promptCacheCreationMultiplier) ?? options.cacheWriteMultiplier ?? 1;
|
|
54
|
+
const read = usage.cacheReadTokens ?? 0;
|
|
55
|
+
const written = usage.cacheWriteTokens ?? 0;
|
|
56
|
+
const fresh = Math.max(0, usage.promptTokens - read - written);
|
|
57
|
+
const promptUnits = fresh + read * readMultiplier + written * writeMultiplier;
|
|
58
|
+
const total = (promptUnits * promptRate + usage.completionTokens * completionRate) / 1000;
|
|
59
|
+
const uplift = options.dataResidency === true ? rateOf(cost.dataResidencyMultiplier) : undefined;
|
|
60
|
+
return uplift !== undefined ? total * uplift : total;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function rateOf(value: unknown): number | undefined {
|
|
64
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function longContextOf(
|
|
68
|
+
value: unknown,
|
|
69
|
+
):
|
|
70
|
+
| { thresholdTokens: number; promptUsdPer1kTokens: number; completionUsdPer1kTokens: number }
|
|
71
|
+
| undefined {
|
|
72
|
+
if (typeof value !== 'object' || value === null) return undefined;
|
|
73
|
+
const tier = value as Record<string, unknown>;
|
|
74
|
+
const thresholdTokens = rateOf(tier.thresholdTokens);
|
|
75
|
+
const promptUsdPer1kTokens = rateOf(tier.promptUsdPer1kTokens);
|
|
76
|
+
const completionUsdPer1kTokens = rateOf(tier.completionUsdPer1kTokens);
|
|
77
|
+
return thresholdTokens !== undefined &&
|
|
78
|
+
promptUsdPer1kTokens !== undefined &&
|
|
79
|
+
completionUsdPer1kTokens !== undefined
|
|
80
|
+
? { thresholdTokens, promptUsdPer1kTokens, completionUsdPer1kTokens }
|
|
81
|
+
: undefined;
|
|
82
|
+
}
|