@ultimat3/ai 27.8.1 → 27.9.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 +57 -53
- package/README.md +75 -0
- package/package.json +11 -11
- package/src/agent-def.ts +111 -0
- package/src/agent-errors.ts +50 -0
- package/src/agent-guards.ts +114 -0
- package/src/agent-system.ts +37 -0
- package/src/agent-usage.ts +27 -0
- package/src/agent.ts +65 -89
- package/src/budget.ts +3 -2
- package/src/errors-retry.ts +6 -0
- package/src/errors.ts +4 -0
- package/src/gateway.ts +9 -2
- package/src/index.ts +19 -1
- package/src/llm.ts +11 -0
- package/src/openai-body.ts +50 -1
- package/src/openai-messages.ts +57 -5
- package/src/openai-provider.ts +15 -3
- package/src/openai-wire.ts +16 -7
- package/src/prompt-cache-blocks.ts +114 -0
- package/src/prompt-cache.ts +189 -0
- package/src/provider.ts +39 -11
package/src/openai-messages.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import type { AiMediaBlock } from './content-blocks';
|
|
9
9
|
import { isMediaBlock } from './content-blocks';
|
|
10
10
|
import { AiContentUnsupportedError } from './content-errors';
|
|
11
|
+
import type { SystemPrompt } from './prompt-cache';
|
|
12
|
+
import { CACHE_MARK, systemParts, systemText } from './prompt-cache';
|
|
11
13
|
import type { AiContentBlock, AiMessage } from './provider';
|
|
12
14
|
import type { JsonSchema, LlmTool } from './tools';
|
|
13
15
|
|
|
@@ -20,7 +22,12 @@ export interface OpenAiToolCall {
|
|
|
20
22
|
|
|
21
23
|
/** A user message's parts, when it carries more than prose. Field names are the format's own. */
|
|
22
24
|
export type OpenAiContentPart =
|
|
23
|
-
| {
|
|
25
|
+
| {
|
|
26
|
+
readonly type: 'text';
|
|
27
|
+
readonly text: string;
|
|
28
|
+
/** A prompt-cache breakpoint — sent ONLY where `PROMPT_CACHE_RULES` says `parts`. */
|
|
29
|
+
readonly cache_control?: typeof CACHE_MARK;
|
|
30
|
+
}
|
|
24
31
|
| { readonly type: 'image_url'; readonly image_url: { readonly url: string } }
|
|
25
32
|
| {
|
|
26
33
|
readonly type: 'file';
|
|
@@ -28,14 +35,18 @@ export type OpenAiContentPart =
|
|
|
28
35
|
};
|
|
29
36
|
|
|
30
37
|
export type OpenAiMessage =
|
|
31
|
-
| { readonly role: 'system'; readonly content: string }
|
|
38
|
+
| { readonly role: 'system'; readonly content: string | readonly OpenAiContentPart[] }
|
|
32
39
|
| { readonly role: 'user'; readonly content: string | readonly OpenAiContentPart[] }
|
|
33
40
|
| {
|
|
34
41
|
readonly role: 'assistant';
|
|
35
42
|
readonly content?: string | undefined;
|
|
36
43
|
readonly tool_calls?: readonly OpenAiToolCall[] | undefined;
|
|
37
44
|
}
|
|
38
|
-
| {
|
|
45
|
+
| {
|
|
46
|
+
readonly role: 'tool';
|
|
47
|
+
readonly tool_call_id: string;
|
|
48
|
+
readonly content: string | readonly OpenAiContentPart[];
|
|
49
|
+
};
|
|
39
50
|
|
|
40
51
|
export interface OpenAiFunctionTool {
|
|
41
52
|
readonly type: 'function';
|
|
@@ -68,12 +79,17 @@ export interface OpenAiToolChoice {
|
|
|
68
79
|
* speaking this format — vLLM, Ollama, LiteLLM, Together — knows only `system`. OpenAI accepts it.
|
|
69
80
|
*/
|
|
70
81
|
export function toOpenAiMessages(
|
|
71
|
-
system:
|
|
82
|
+
system: SystemPrompt | undefined,
|
|
72
83
|
messages: readonly AiMessage[],
|
|
73
84
|
target: MediaTarget,
|
|
85
|
+
/** Whether this endpoint and model take explicit `cache_control` parts (`./prompt-cache`). */
|
|
86
|
+
marked = false,
|
|
74
87
|
): readonly OpenAiMessage[] {
|
|
75
88
|
const out: OpenAiMessage[] = [];
|
|
76
|
-
|
|
89
|
+
// One string, stable half first, wherever nothing is marked: every server in the family takes
|
|
90
|
+
// it, and an implicit cache is a prefix match.
|
|
91
|
+
const text = systemText(system);
|
|
92
|
+
if (text !== '') out.push({ role: 'system', content: marked ? systemPartsOf(system) : text });
|
|
77
93
|
messages.forEach((message, m) => {
|
|
78
94
|
if (typeof message.content === 'string') {
|
|
79
95
|
out.push({ role: message.role, content: message.content });
|
|
@@ -84,9 +100,45 @@ export function toOpenAiMessages(
|
|
|
84
100
|
out.push(...userTurn(message.content, m, target));
|
|
85
101
|
}
|
|
86
102
|
});
|
|
103
|
+
if (marked) markTail(out);
|
|
87
104
|
return out;
|
|
88
105
|
}
|
|
89
106
|
|
|
107
|
+
/** The system message as parts: the stable one marked, the per-run one after it and unmarked. */
|
|
108
|
+
function systemPartsOf(system: SystemPrompt | undefined): readonly OpenAiContentPart[] {
|
|
109
|
+
const { stable, run } = systemParts(system);
|
|
110
|
+
const parts: OpenAiContentPart[] = [];
|
|
111
|
+
// A whitespace-only part is sent as it stands and never marked.
|
|
112
|
+
if (stable !== '') {
|
|
113
|
+
parts.push({
|
|
114
|
+
type: 'text',
|
|
115
|
+
text: stable,
|
|
116
|
+
...(stable.trim() === '' ? {} : { cache_control: CACHE_MARK }),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (run !== '') parts.push({ type: 'text', text: run });
|
|
120
|
+
return parts;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Mark the LAST message's last part — the breakpoint the next turn reads back. A `user` or `tool`
|
|
125
|
+
* message only: those are the roles whose content this wire takes as parts, and a transcript sent
|
|
126
|
+
* to a model always ends on one of them.
|
|
127
|
+
*/
|
|
128
|
+
function markTail(out: OpenAiMessage[]): void {
|
|
129
|
+
const tail = out.at(-1);
|
|
130
|
+
if (tail === undefined || (tail.role !== 'user' && tail.role !== 'tool')) return;
|
|
131
|
+
const parts: readonly OpenAiContentPart[] =
|
|
132
|
+
typeof tail.content === 'string' ? [{ type: 'text', text: tail.content }] : tail.content;
|
|
133
|
+
const last = parts.at(-1);
|
|
134
|
+
// Only a text part carries the mark here, and never an empty or whitespace-only one.
|
|
135
|
+
if (last === undefined || last.type !== 'text' || last.text.trim() === '') return;
|
|
136
|
+
out[out.length - 1] = {
|
|
137
|
+
...tail,
|
|
138
|
+
content: [...parts.slice(0, -1), { ...last, cache_control: CACHE_MARK }],
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
90
142
|
/** Who a media refusal names: this provider, and the model the request is for. */
|
|
91
143
|
export interface MediaTarget {
|
|
92
144
|
readonly provider: string;
|
package/src/openai-provider.ts
CHANGED
|
@@ -14,9 +14,10 @@ import { AiKeyMissingError, AiRequestInvalidError, AiTransportError } from './er
|
|
|
14
14
|
import type { AiFetch } from './fetch-seam';
|
|
15
15
|
import { resolveModel } from './model-resolve';
|
|
16
16
|
import type { ModelId } from './models';
|
|
17
|
-
import { chatCompletionBody } from './openai-body';
|
|
17
|
+
import { chatCompletionBody, sessionHeaders } from './openai-body';
|
|
18
18
|
import type { ChatAnswer } from './openai-wire';
|
|
19
19
|
import { ChatCompletionStream, parseChatCompletion } from './openai-wire';
|
|
20
|
+
import { promptCacheRule } from './prompt-cache';
|
|
20
21
|
import type {
|
|
21
22
|
GenerateRequest,
|
|
22
23
|
GenerateResult,
|
|
@@ -105,6 +106,10 @@ class OpenAiProvider implements Provider {
|
|
|
105
106
|
this.config = config;
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
marksPromptCache(model: ModelId): boolean {
|
|
110
|
+
return promptCacheRule('openai', this.config.baseUrl, model).marks === 'parts';
|
|
111
|
+
}
|
|
112
|
+
|
|
108
113
|
/**
|
|
109
114
|
* Above `STREAM_ONLY_MAX_TOKENS` this runs the streaming transport and assembles the result,
|
|
110
115
|
* for the reason the Anthropic provider does: a non-streaming request that large sits on an
|
|
@@ -115,10 +120,12 @@ class OpenAiProvider implements Provider {
|
|
|
115
120
|
// this provider's first listed model — the app's three places are the only ones.
|
|
116
121
|
const model = resolveModel('provider', request.model);
|
|
117
122
|
if (requiresStreaming({ ...request, model })) return this.assemble(request);
|
|
123
|
+
const { baseUrl } = this.config;
|
|
118
124
|
const response = await this.send(
|
|
119
|
-
chatCompletionBody({ request, model, stream: false, provider: this.name }),
|
|
125
|
+
chatCompletionBody({ request, model, stream: false, provider: this.name, baseUrl }),
|
|
120
126
|
false,
|
|
121
127
|
request.signal,
|
|
128
|
+
sessionHeaders(request, model, baseUrl),
|
|
122
129
|
);
|
|
123
130
|
const answer = parseChatCompletion((await response.json()) as unknown, this.name);
|
|
124
131
|
return this.result(request, model, answer);
|
|
@@ -126,10 +133,12 @@ class OpenAiProvider implements Provider {
|
|
|
126
133
|
|
|
127
134
|
async *stream(request: GenerateRequest): AsyncIterable<StreamChunk> {
|
|
128
135
|
const model = resolveModel('provider', request.model);
|
|
136
|
+
const { baseUrl } = this.config;
|
|
129
137
|
const response = await this.send(
|
|
130
|
-
chatCompletionBody({ request, model, stream: true, provider: this.name }),
|
|
138
|
+
chatCompletionBody({ request, model, stream: true, provider: this.name, baseUrl }),
|
|
131
139
|
true,
|
|
132
140
|
request.signal,
|
|
141
|
+
sessionHeaders(request, model, baseUrl),
|
|
133
142
|
);
|
|
134
143
|
if (response.body === null) {
|
|
135
144
|
throw new AiTransportError({
|
|
@@ -187,6 +196,7 @@ class OpenAiProvider implements Provider {
|
|
|
187
196
|
body: Record<string, unknown>,
|
|
188
197
|
streaming: boolean,
|
|
189
198
|
signal: AbortSignal | undefined,
|
|
199
|
+
extra: Readonly<Record<string, string>>,
|
|
190
200
|
): Promise<Response> {
|
|
191
201
|
const apiKey = this.apiKey();
|
|
192
202
|
const doFetch: AiFetch = this.config.fetch ?? fetch;
|
|
@@ -198,6 +208,8 @@ class OpenAiProvider implements Provider {
|
|
|
198
208
|
: { authorization: `Bearer ${apiKey}` }),
|
|
199
209
|
'content-type': 'application/json',
|
|
200
210
|
accept: streaming ? 'text/event-stream' : 'application/json',
|
|
211
|
+
// The prompt-cache session, when the request carries one and this host routes by it.
|
|
212
|
+
...extra,
|
|
201
213
|
// Last, so a caller can replace the credential header outright for a gateway that wants
|
|
202
214
|
// its own scheme. Nothing here is ever logged.
|
|
203
215
|
...this.config.headers,
|
package/src/openai-wire.ts
CHANGED
|
@@ -75,9 +75,14 @@ export function parseFinishReason(raw: unknown): StopReason | undefined {
|
|
|
75
75
|
/**
|
|
76
76
|
* `usage`, or `undefined` when the payload carried none.
|
|
77
77
|
*
|
|
78
|
-
* `prompt_tokens`
|
|
79
|
-
*
|
|
80
|
-
*
|
|
78
|
+
* `prompt_tokens` is the WHOLE prompt on this wire — the part read from a cache and the part
|
|
79
|
+
* written to one included — where Anthropic's `input_tokens` excludes both. So both are subtracted
|
|
80
|
+
* out before they are reported under their own counters. Left in place a cached token would be
|
|
81
|
+
* billed twice: once at the full input rate and again at the cache rate.
|
|
82
|
+
*
|
|
83
|
+
* `cache_write_tokens` is OpenRouter's (`prompt_tokens_details.cache_write_tokens`, "Number of
|
|
84
|
+
* tokens written to the cache"), reported for a model whose vendor bills a write. An endpoint that
|
|
85
|
+
* never sends it — OpenAI's own, where a write is free — reads as zero.
|
|
81
86
|
*/
|
|
82
87
|
export function parseOpenAiUsage(raw: unknown): TokenUsage | undefined {
|
|
83
88
|
const record = asRecord(raw);
|
|
@@ -85,14 +90,18 @@ export function parseOpenAiUsage(raw: unknown): TokenUsage | undefined {
|
|
|
85
90
|
const prompt = countOf(record['prompt_tokens']);
|
|
86
91
|
const completion = countOf(record['completion_tokens']);
|
|
87
92
|
if (prompt === undefined && completion === undefined) return undefined;
|
|
88
|
-
const
|
|
93
|
+
const details = asRecord(record['prompt_tokens_details']);
|
|
94
|
+
const total = prompt ?? 0;
|
|
95
|
+
// Each clamped to what is left of the prompt: a counter larger than the prompt it is part of is
|
|
96
|
+
// the endpoint's mistake, and an unclamped one bills tokens that were never sent.
|
|
97
|
+
const cached = Math.min(countOf(details?.['cached_tokens']) ?? 0, total);
|
|
98
|
+
const written = Math.min(countOf(details?.['cache_write_tokens']) ?? 0, total - cached);
|
|
89
99
|
return {
|
|
90
|
-
inputTokens:
|
|
100
|
+
inputTokens: total - cached - written,
|
|
91
101
|
// `completion_tokens` already contains `reasoning_tokens`; adding them is a double count.
|
|
92
102
|
outputTokens: completion ?? 0,
|
|
93
103
|
cacheReadTokens: cached,
|
|
94
|
-
|
|
95
|
-
cacheWriteTokens: 0,
|
|
104
|
+
cacheWriteTokens: written,
|
|
96
105
|
};
|
|
97
106
|
}
|
|
98
107
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Single responsibility: PROVIDER prompt caching — the neutral request shapes (a system prompt in
|
|
2
|
+
// a stable and a per-run part, the hint that asks for the prefix to be cached) and the table that
|
|
3
|
+
// says how each wire and host is told. Not the gateway's response cache, not `llm()`'s semantic one.
|
|
4
|
+
//
|
|
5
|
+
// THE RULE, and where it comes from (both pages read 2026-10-11):
|
|
6
|
+
//
|
|
7
|
+
// Anthropic — https://platform.claude.com/docs/en/docs/build-with-claude/prompt-caching
|
|
8
|
+
// "Place `cache_control` directly on individual content blocks"; the shape is
|
|
9
|
+
// `"cache_control": { "type": "ephemeral" }` (5-minute TTL, the default).
|
|
10
|
+
// "Prompt caching references the entire prompt: `tools`, `system`, and `messages` (in that
|
|
11
|
+
// order), up to and including the block designated with `cache_control`."
|
|
12
|
+
// "You can define up to 4 cache breakpoints" — a fifth is a 400.
|
|
13
|
+
// "The system checks at most 20 positions per breakpoint, counting the breakpoint itself."
|
|
14
|
+
// "Shorter prompts cannot be cached, even if marked with `cache_control`. Any requests to cache
|
|
15
|
+
// fewer than this number of tokens will be processed without caching, and no error is
|
|
16
|
+
// returned." Minimums are per model (512 to 4,096 tokens) — `ModelSpec.cacheMinimumTokens`.
|
|
17
|
+
// "5-minute cache write tokens are 1.25 times the base input tokens price … Cache read tokens
|
|
18
|
+
// are 0.1 times the base input tokens price" (0.05x and 0.025x on named models).
|
|
19
|
+
//
|
|
20
|
+
// OpenRouter — https://openrouter.ai/docs/features/prompt-caching
|
|
21
|
+
// Anthropic models: `cache_control` on a `text` part inside a message's `content` array;
|
|
22
|
+
// "There is a limit of four explicit breakpoints."
|
|
23
|
+
// Gemini: "Gemini 2.5 series models and newer support implicit caching" with no write cost; an
|
|
24
|
+
// explicit breakpoint is billed as a write (input price plus storage). So: never mark.
|
|
25
|
+
// OpenAI: automatic, no configuration. So: never mark.
|
|
26
|
+
// Stickiness: `session_id` in the body or the `x-session-id` header, at most 256 characters;
|
|
27
|
+
// "Sticky routing is skipped when you set `provider.order`" — this package sends no `provider`
|
|
28
|
+
// object at all, and must not start to.
|
|
29
|
+
// Usage: `prompt_tokens_details.cached_tokens` and `.cache_write_tokens`.
|
|
30
|
+
// https://openrouter.ai/docs/use-cases/usage-accounting: "The `usage: { include: true }` …
|
|
31
|
+
// parameters are deprecated and have no effect" — so it is not sent.
|
|
32
|
+
//
|
|
33
|
+
// OpenAI — automatic above 1,024 tokens (the OpenRouter page's summary of it); the body field
|
|
34
|
+
// `prompt_cache_key` groups requests that share a prefix. OpenRouter reads it as a session id
|
|
35
|
+
// when neither `session_id` nor the header is given.
|
|
36
|
+
//
|
|
37
|
+
// Checked against the live service, 2026-10-11, `anthropic/claude-sonnet-5.5` on OpenRouter, a
|
|
38
|
+
// three-turn `agent()` run: a mark on a `role: 'tool'` message's text part IS honoured (turn 2
|
|
39
|
+
// read turn 1's 19,914-token prefix and wrote 72), and `prompt_tokens` INCLUDES both the read and
|
|
40
|
+
// the written tokens — which is why ./openai-wire subtracts both.
|
|
41
|
+
//
|
|
42
|
+
// Two limits of the rows below, stated rather than hidden:
|
|
43
|
+
// - the `anthropic` row matches ANY host an `anthropicProvider({ baseUrl })` is pointed at — a
|
|
44
|
+
// proxy speaking that wire is assumed to speak its `cache_control` too;
|
|
45
|
+
// - the `parts` row marks the tail only, so the next turn finds this turn's entry through the
|
|
46
|
+
// 20-position lookback alone. A turn that appends more blocks than that — more than about 9
|
|
47
|
+
// tool calls in ONE turn, each a `tool_use` and a `tool_result` — misses it and is billed as a
|
|
48
|
+
// fresh write of the whole transcript. The stable system block still reads.
|
|
49
|
+
//
|
|
50
|
+
// The table is DATA: a new host or model family is a row. Unknown endpoints get NOTHING — a field
|
|
51
|
+
// a self-hosted server has never heard of is a 400 on some of them, and a mark is never worth that.
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A system prompt in two parts. `stable` is byte-identical across runs — rules, a knowledge-base
|
|
55
|
+
* index — and is what a cache shares BETWEEN runs; `run` is whatever differs per run and comes
|
|
56
|
+
* after it, so it never invalidates the stable half. A cache is a prefix match: one changed byte in
|
|
57
|
+
* `stable` (a timestamp, an unsorted list) is a full-price write on every call.
|
|
58
|
+
*/
|
|
59
|
+
export interface SystemParts {
|
|
60
|
+
readonly stable: string;
|
|
61
|
+
readonly run?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A plain string is the whole prompt as one stable part. */
|
|
65
|
+
export type SystemPrompt = string | SystemParts;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* "Cache this request's prefix": present on a `GenerateRequest`, each provider marks the stable
|
|
69
|
+
* system part and the transcript's tail the way its wire wants — or not at all, where the host
|
|
70
|
+
* caches implicitly. Absent, nothing is marked and nothing is sent.
|
|
71
|
+
*/
|
|
72
|
+
export interface PromptCacheHint {
|
|
73
|
+
/**
|
|
74
|
+
* A key naming the runs that share a prefix, for hosts that route or shard by one (an
|
|
75
|
+
* `x-session-id` header on OpenRouter, `prompt_cache_key` on OpenAI). Never a secret, never
|
|
76
|
+
* personal data: it leaves the process in a header.
|
|
77
|
+
*/
|
|
78
|
+
readonly session?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** OpenRouter's documented ceiling on a session id ("Maximum length is 256 characters"). */
|
|
82
|
+
export const SESSION_KEY_MAX = 256;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The longest `prompt_cache_key` sent to OpenAI verbatim. OpenAI's prompt-caching guide and its
|
|
86
|
+
* SDK types (read 2026-10-11) state NO maximum, and its API reference could not be read from
|
|
87
|
+
* here; 64 is the conservative figure a reviewer recalled. A longer key is not refused — it is a
|
|
88
|
+
* grouping key, so it is replaced by a deterministic hash of itself: same key, same group.
|
|
89
|
+
*/
|
|
90
|
+
export const OPENAI_CACHE_KEY_MAX = 64;
|
|
91
|
+
|
|
92
|
+
/** The one mark this package sends: the 5-minute TTL, which is the rate `costOf` prices. */
|
|
93
|
+
export const CACHE_MARK = { type: 'ephemeral' } as const;
|
|
94
|
+
|
|
95
|
+
/** Both parts as one text, stable first — for an estimate, and for a wire with one system string. */
|
|
96
|
+
export function systemText(system: SystemPrompt | undefined): string {
|
|
97
|
+
if (system === undefined) return '';
|
|
98
|
+
if (typeof system === 'string') return system;
|
|
99
|
+
return [system.stable, system.run ?? ''].filter((part) => part !== '').join('\n\n');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The parts, normalised: `''` for an absent half, so a wire never sends an empty block. */
|
|
103
|
+
export function systemParts(system: SystemPrompt | undefined): Required<SystemParts> {
|
|
104
|
+
if (system === undefined) return { stable: '', run: '' };
|
|
105
|
+
if (typeof system === 'string') return { stable: system, run: '' };
|
|
106
|
+
return { stable: system.stable, run: system.run ?? '' };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export type CacheWire = 'anthropic' | 'openai';
|
|
110
|
+
|
|
111
|
+
export interface PromptCacheRule {
|
|
112
|
+
readonly wire: CacheWire;
|
|
113
|
+
/** A hostname and every subdomain of it; `'*'` is any endpoint on this wire. */
|
|
114
|
+
readonly host: string;
|
|
115
|
+
/** A model-id prefix; `''` is every model. */
|
|
116
|
+
readonly model: string;
|
|
117
|
+
/**
|
|
118
|
+
* `blocks`: `cache_control` on Anthropic content blocks — the stable system block and the last
|
|
119
|
+
* block of the last two messages (three of the four breakpoints; the fourth is left free).
|
|
120
|
+
* `parts`: the same mark on chat-completions content PARTS — the stable system part and the tail
|
|
121
|
+
* message's last part (two breakpoints).
|
|
122
|
+
* `none`: the host caches by itself, or is unknown.
|
|
123
|
+
*/
|
|
124
|
+
readonly marks: 'blocks' | 'parts' | 'none';
|
|
125
|
+
/** Where a `PromptCacheHint.session` goes. */
|
|
126
|
+
readonly session: 'x-session-id' | 'prompt_cache_key' | 'none';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** First match wins. */
|
|
130
|
+
export const PROMPT_CACHE_RULES: readonly PromptCacheRule[] = [
|
|
131
|
+
{ wire: 'anthropic', host: '*', model: '', marks: 'blocks', session: 'none' },
|
|
132
|
+
{
|
|
133
|
+
wire: 'openai',
|
|
134
|
+
host: 'openrouter.ai',
|
|
135
|
+
model: 'anthropic/',
|
|
136
|
+
marks: 'parts',
|
|
137
|
+
session: 'x-session-id',
|
|
138
|
+
},
|
|
139
|
+
// Gemini, OpenAI, and everything else OpenRouter fronts: implicit or automatic. An explicit mark
|
|
140
|
+
// on Gemini turns a free implicit cache into a paid write.
|
|
141
|
+
{ wire: 'openai', host: 'openrouter.ai', model: '', marks: 'none', session: 'x-session-id' },
|
|
142
|
+
{ wire: 'openai', host: 'api.openai.com', model: '', marks: 'none', session: 'prompt_cache_key' },
|
|
143
|
+
];
|
|
144
|
+
|
|
145
|
+
const NO_RULE: Pick<PromptCacheRule, 'marks' | 'session'> = { marks: 'none', session: 'none' };
|
|
146
|
+
|
|
147
|
+
/** OpenAI's own endpoint, which is what an `openAiProvider()` with no `baseUrl` talks to. */
|
|
148
|
+
const DEFAULT_OPENAI_HOST = 'api.openai.com';
|
|
149
|
+
|
|
150
|
+
/** The rule for one request. Total: a base URL that does not parse matches no host. */
|
|
151
|
+
export function promptCacheRule(
|
|
152
|
+
wire: CacheWire,
|
|
153
|
+
baseUrl: string | undefined,
|
|
154
|
+
model: string,
|
|
155
|
+
): Pick<PromptCacheRule, 'marks' | 'session'> {
|
|
156
|
+
const host = baseUrl === undefined ? DEFAULT_OPENAI_HOST : hostOf(baseUrl);
|
|
157
|
+
const found = PROMPT_CACHE_RULES.find(
|
|
158
|
+
(rule) =>
|
|
159
|
+
rule.wire === wire &&
|
|
160
|
+
(rule.host === '*' || host === rule.host || host.endsWith(`.${rule.host}`)) &&
|
|
161
|
+
model.startsWith(rule.model),
|
|
162
|
+
);
|
|
163
|
+
return found ?? NO_RULE;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function hostOf(baseUrl: string): string {
|
|
167
|
+
return URL.canParse(baseUrl) ? new URL(baseUrl).hostname.toLowerCase() : '';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* What a declaration writes: `'auto'` marks every request of the call, `'off'` marks none, and the
|
|
172
|
+
* object form is `'auto'` plus a session key — a constant, or derived per call (per org, say).
|
|
173
|
+
*/
|
|
174
|
+
export type PromptCacheOption<A> =
|
|
175
|
+
| 'auto'
|
|
176
|
+
| 'off'
|
|
177
|
+
| { readonly session: string | ((args: A) => string) };
|
|
178
|
+
|
|
179
|
+
/** The hint one call sends on every request, or `undefined` for none. */
|
|
180
|
+
export function promptCacheHint<A>(
|
|
181
|
+
option: PromptCacheOption<A> | undefined,
|
|
182
|
+
fallback: 'auto' | 'off',
|
|
183
|
+
args: A,
|
|
184
|
+
): PromptCacheHint | undefined {
|
|
185
|
+
const chosen = option ?? fallback;
|
|
186
|
+
if (chosen === 'off') return undefined;
|
|
187
|
+
if (chosen === 'auto') return {};
|
|
188
|
+
return { session: typeof chosen.session === 'string' ? chosen.session : chosen.session(args) };
|
|
189
|
+
}
|
package/src/provider.ts
CHANGED
|
@@ -12,6 +12,9 @@ import type { AiFetch } from './fetch-seam';
|
|
|
12
12
|
import { resolveModel } from './model-resolve';
|
|
13
13
|
import type { Effort, ModelId, ThinkingMode } from './models';
|
|
14
14
|
import { modelSpec, reasoningBody } from './models';
|
|
15
|
+
import type { PromptCacheHint, SystemPrompt } from './prompt-cache';
|
|
16
|
+
import { promptCacheRule, systemText } from './prompt-cache';
|
|
17
|
+
import { anthropicPrompt } from './prompt-cache-blocks';
|
|
15
18
|
import { readSse } from './sse';
|
|
16
19
|
import type { LlmTool, LlmToolCall } from './tools';
|
|
17
20
|
import {
|
|
@@ -65,7 +68,14 @@ export function messageText(message: AiMessage): string {
|
|
|
65
68
|
|
|
66
69
|
export interface GenerateRequest {
|
|
67
70
|
readonly model?: ModelId;
|
|
68
|
-
|
|
71
|
+
/** One string, or a stable part and a per-run part (`./prompt-cache`) — two blocks on the wire. */
|
|
72
|
+
readonly system?: SystemPrompt;
|
|
73
|
+
/**
|
|
74
|
+
* Ask the provider to cache this request's prefix: the stable system part and the transcript's
|
|
75
|
+
* tail, marked per wire and host by `PROMPT_CACHE_RULES`. Absent sends no mark. Not part of
|
|
76
|
+
* `promptCacheKey` — it changes what a call costs, never what it answers.
|
|
77
|
+
*/
|
|
78
|
+
readonly promptCache?: PromptCacheHint;
|
|
69
79
|
readonly messages: readonly AiMessage[];
|
|
70
80
|
/** Enforced output ceiling. The model never sees it, so it can be cut off mid-answer. */
|
|
71
81
|
readonly maxTokens: number;
|
|
@@ -154,6 +164,12 @@ export interface Provider {
|
|
|
154
164
|
readonly models: readonly ModelId[];
|
|
155
165
|
generate(request: GenerateRequest): Promise<GenerateResult>;
|
|
156
166
|
stream(request: GenerateRequest): AsyncIterable<StreamChunk>;
|
|
167
|
+
/**
|
|
168
|
+
* Whether a request carrying `promptCache` for this model would be sent with an explicit cache
|
|
169
|
+
* mark — billed as a cache WRITE. Absent is no: the gateway prices the write only where one
|
|
170
|
+
* can happen.
|
|
171
|
+
*/
|
|
172
|
+
marksPromptCache?(model: ModelId): boolean;
|
|
157
173
|
}
|
|
158
174
|
|
|
159
175
|
/**
|
|
@@ -323,18 +339,26 @@ export class AnthropicProvider implements Provider {
|
|
|
323
339
|
};
|
|
324
340
|
}
|
|
325
341
|
|
|
342
|
+
marksPromptCache(model: ModelId): boolean {
|
|
343
|
+
return promptCacheRule('anthropic', this.config.baseUrl, model).marks === 'blocks';
|
|
344
|
+
}
|
|
345
|
+
|
|
326
346
|
/** The request body. Pure and side-effect free so a test can assert it directly. */
|
|
327
347
|
body(request: GenerateRequest): Record<string, unknown> {
|
|
328
348
|
const model = this.modelOf(request);
|
|
329
349
|
// The blocks pass through untouched below — they ARE this wire's shapes — so this is the screen.
|
|
330
350
|
assertMediaContent(request.messages, { provider: this.name, model });
|
|
351
|
+
const prompt = anthropicPrompt(
|
|
352
|
+
request,
|
|
353
|
+
request.promptCache !== undefined && this.marksPromptCache(model),
|
|
354
|
+
);
|
|
331
355
|
const body: Record<string, unknown> = {
|
|
332
356
|
model,
|
|
333
357
|
max_tokens: Math.min(request.maxTokens, modelSpec(model).maxOutput),
|
|
334
|
-
messages:
|
|
358
|
+
messages: prompt.messages,
|
|
335
359
|
...reasoningBody(model, request.effort, request.thinking),
|
|
336
360
|
};
|
|
337
|
-
if (
|
|
361
|
+
if (prompt.system !== undefined) body['system'] = prompt.system;
|
|
338
362
|
if (request.tools !== undefined && request.tools.length > 0) body['tools'] = request.tools;
|
|
339
363
|
if (request.stopSequences !== undefined) body['stop_sequences'] = request.stopSequences;
|
|
340
364
|
return body;
|
|
@@ -439,7 +463,7 @@ const completionCeiling = (request: GenerateRequest): number =>
|
|
|
439
463
|
/** The prompt half alone — what the provider bills at the input rate. */
|
|
440
464
|
export function estimateInputTokens(request: GenerateRequest): number {
|
|
441
465
|
const body = request.messages.map(messageText).join(' ');
|
|
442
|
-
const text = estimateTextTokens(body) + estimateTextTokens(request.system
|
|
466
|
+
const text = estimateTextTokens(body) + estimateTextTokens(systemText(request.system));
|
|
443
467
|
return text + mediaTokenEstimate(request.messages);
|
|
444
468
|
}
|
|
445
469
|
|
|
@@ -448,13 +472,17 @@ export function estimateInputTokens(request: GenerateRequest): number {
|
|
|
448
472
|
* `maxTokens` at the output rate. Deliberately pessimistic — a ceiling checked against an
|
|
449
473
|
* optimistic estimate is a ceiling one long completion walks through.
|
|
450
474
|
*/
|
|
451
|
-
export function estimateCost(request: GenerateRequest): Money {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
475
|
+
export function estimateCost(request: GenerateRequest, marked = false): Money {
|
|
476
|
+
const model = resolveModel('provider', request.model);
|
|
477
|
+
const prompt = estimateInputTokens(request);
|
|
478
|
+
const usage = { outputTokens: completionCeiling(request), cacheReadTokens: 0 };
|
|
479
|
+
const asInput = costOf(model, { ...usage, inputTokens: prompt, cacheWriteTokens: 0 });
|
|
480
|
+
// A request a provider WILL mark (`marked`: the gateway asks the providers serving the model) can
|
|
481
|
+
// be billed as a cache write end to end. The DEARER of the two: a row whose write is cheaper
|
|
482
|
+
// than input must not lower the ceiling's estimate below what an unmarked call costs.
|
|
483
|
+
if (!marked || request.promptCache === undefined) return asInput;
|
|
484
|
+
const asWrite = costOf(model, { ...usage, inputTokens: 0, cacheWriteTokens: prompt });
|
|
485
|
+
return asWrite.minor > asInput.minor ? asWrite : asInput;
|
|
458
486
|
}
|
|
459
487
|
|
|
460
488
|
export function estimateTextTokens(text: string): number {
|