@celestea/llm 2.7.1

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.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Token-usage parsing for the OpenAI-compatible stream (P2a).
3
+ *
4
+ * Mirrors `crates/llm/src/client.rs::extract_usage` 1:1: usage arrives either
5
+ * in a usage-only final frame or attached to the last chunk; cache-hit prompt
6
+ * tokens use three different provider key shapes and all of them are probed.
7
+ * The statusline reads exactly these five flat counters.
8
+ *
9
+ * A1 (W746): the `Usage` shape itself (and `zeroUsage` / `usageIsEmpty`) is
10
+ * core's `message.rs` port — re-exported here, not redeclared. What is
11
+ * provider-specific stays: the three provider key shapes and the parser.
12
+ */
13
+ import { usageIsEmpty, zeroUsage, type Usage } from "@celestea/core";
14
+ export { usageIsEmpty, zeroUsage };
15
+ export type { Usage };
16
+ /** P0 placeholder name, kept as an alias of the real Usage shape. */
17
+ export type LlmUsageFrame = Usage;
18
+ /** The three required keys (usage_json, src/main.rs:575-600). */
19
+ export declare const USAGE_REQUIRED_KEYS: readonly ["prompt_tokens", "completion_tokens", "total_tokens"];
20
+ /** Flat cache-read keys, probed in order (DeepSeek, then Anthropic-style). */
21
+ export declare const CACHE_READ_FLAT_KEYS: readonly ["prompt_cache_hit_tokens", "cache_read_input_tokens"];
22
+ /** Nested cache-read key (OpenAI): usage.prompt_tokens_details.cached_tokens. */
23
+ export declare const CACHE_READ_NESTED: {
24
+ readonly outer: "prompt_tokens_details";
25
+ readonly inner: "cached_tokens";
26
+ };
27
+ /** Nested reasoning key: usage.completion_tokens_details.reasoning_tokens. */
28
+ export declare const REASONING_TOKENS_NESTED: {
29
+ readonly outer: "completion_tokens_details";
30
+ readonly inner: "reasoning_tokens";
31
+ };
32
+ /** An all-zero usage block (constant; do not mutate). */
33
+ export declare const ZERO_USAGE: Usage;
34
+ /**
35
+ * Parse a raw `usage` object into Usage. Returns undefined when no token
36
+ * counter is present at all (an empty usage object is not a usage frame).
37
+ */
38
+ export declare function usageFromObject(usage: Record<string, unknown>): Usage | undefined;
39
+ /**
40
+ * Extract usage from a decoded chat-completions chunk (the whole chunk JSON,
41
+ * not just the usage object). Returns undefined when the chunk carries none.
42
+ */
43
+ export declare function parseUsage(chunk: unknown): Usage | undefined;
44
+ /**
45
+ * cache_read / prompt_tokens, clamped to [0,1] and rounded to 4 decimals.
46
+ *
47
+ * NOTE (A1): this is the PROVIDER-side reading of the ratio, and it is
48
+ * deliberately not core's `cacheHitRatio(u)` (unclamped, unrounded, the
49
+ * `message.rs` port). `packages/runtime` has a third spelling
50
+ * (`cacheHitRatioRounded`). Unifying the three is queued, not done here:
51
+ * every one of them is asserted by its own test, so folding them is a
52
+ * behaviour change, not a move.
53
+ */
54
+ export declare function cacheHitRatio(u: Usage): number;
package/dist/usage.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Token-usage parsing for the OpenAI-compatible stream (P2a).
3
+ *
4
+ * Mirrors `crates/llm/src/client.rs::extract_usage` 1:1: usage arrives either
5
+ * in a usage-only final frame or attached to the last chunk; cache-hit prompt
6
+ * tokens use three different provider key shapes and all of them are probed.
7
+ * The statusline reads exactly these five flat counters.
8
+ *
9
+ * A1 (W746): the `Usage` shape itself (and `zeroUsage` / `usageIsEmpty`) is
10
+ * core's `message.rs` port — re-exported here, not redeclared. What is
11
+ * provider-specific stays: the three provider key shapes and the parser.
12
+ */
13
+ import { usageIsEmpty, zeroUsage } from "@celestea/core";
14
+ export { usageIsEmpty, zeroUsage };
15
+ /** The three required keys (usage_json, src/main.rs:575-600). */
16
+ export const USAGE_REQUIRED_KEYS = ["prompt_tokens", "completion_tokens", "total_tokens"];
17
+ /** Flat cache-read keys, probed in order (DeepSeek, then Anthropic-style). */
18
+ export const CACHE_READ_FLAT_KEYS = ["prompt_cache_hit_tokens", "cache_read_input_tokens"];
19
+ /** Nested cache-read key (OpenAI): usage.prompt_tokens_details.cached_tokens. */
20
+ export const CACHE_READ_NESTED = { outer: "prompt_tokens_details", inner: "cached_tokens" };
21
+ /** Nested reasoning key: usage.completion_tokens_details.reasoning_tokens. */
22
+ export const REASONING_TOKENS_NESTED = {
23
+ outer: "completion_tokens_details",
24
+ inner: "reasoning_tokens",
25
+ };
26
+ /** An all-zero usage block (constant; do not mutate). */
27
+ export const ZERO_USAGE = zeroUsage();
28
+ function isRecord(v) {
29
+ return typeof v === "object" && v !== null && !Array.isArray(v);
30
+ }
31
+ /** serde_json `as_u64` equivalent: JSON numbers only, non-negative integers. */
32
+ function asU64(v) {
33
+ return typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : undefined;
34
+ }
35
+ function topLevel(usage, keys) {
36
+ for (const key of keys) {
37
+ const n = asU64(usage[key]);
38
+ if (n !== undefined)
39
+ return n;
40
+ }
41
+ return undefined;
42
+ }
43
+ function nested(usage, outer, inner) {
44
+ const obj = usage[outer];
45
+ if (!isRecord(obj))
46
+ return undefined;
47
+ return asU64(obj[inner]);
48
+ }
49
+ /**
50
+ * Parse a raw `usage` object into Usage. Returns undefined when no token
51
+ * counter is present at all (an empty usage object is not a usage frame).
52
+ */
53
+ export function usageFromObject(usage) {
54
+ const parsed = {
55
+ prompt_tokens: topLevel(usage, ["prompt_tokens"]) ?? 0,
56
+ completion_tokens: topLevel(usage, ["completion_tokens"]) ?? 0,
57
+ total_tokens: topLevel(usage, ["total_tokens"]) ?? 0,
58
+ cache_read: topLevel(usage, CACHE_READ_FLAT_KEYS) ??
59
+ nested(usage, CACHE_READ_NESTED.outer, CACHE_READ_NESTED.inner) ??
60
+ 0,
61
+ reasoning_tokens: nested(usage, REASONING_TOKENS_NESTED.outer, REASONING_TOKENS_NESTED.inner) ?? 0,
62
+ };
63
+ return usageIsEmpty(parsed) ? undefined : parsed;
64
+ }
65
+ /**
66
+ * Extract usage from a decoded chat-completions chunk (the whole chunk JSON,
67
+ * not just the usage object). Returns undefined when the chunk carries none.
68
+ */
69
+ export function parseUsage(chunk) {
70
+ if (!isRecord(chunk))
71
+ return undefined;
72
+ const usage = chunk["usage"];
73
+ if (!isRecord(usage))
74
+ return undefined;
75
+ return usageFromObject(usage);
76
+ }
77
+ /**
78
+ * cache_read / prompt_tokens, clamped to [0,1] and rounded to 4 decimals.
79
+ *
80
+ * NOTE (A1): this is the PROVIDER-side reading of the ratio, and it is
81
+ * deliberately not core's `cacheHitRatio(u)` (unclamped, unrounded, the
82
+ * `message.rs` port). `packages/runtime` has a third spelling
83
+ * (`cacheHitRatioRounded`). Unifying the three is queued, not done here:
84
+ * every one of them is asserted by its own test, so folding them is a
85
+ * behaviour change, not a move.
86
+ */
87
+ export function cacheHitRatio(u) {
88
+ if (u.prompt_tokens <= 0)
89
+ return 0;
90
+ const ratio = u.cache_read / u.prompt_tokens;
91
+ return Math.round(Math.min(1, Math.max(0, ratio)) * 10_000) / 10_000;
92
+ }
package/dist/wire.d.ts ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * OpenAI-compatible request wire mapping (P2a).
3
+ *
4
+ * Mirrors `crates/llm/src/client.rs::{build_request, request_body, map_message,
5
+ * map_tool, collect_text}`:
6
+ *
7
+ * POST {base_url}/chat/completions
8
+ * { model, messages, tools?, reasoning_effort?, max_tokens?, temperature?,
9
+ * stream: true }
10
+ *
11
+ * The output cap is the request's explicit max_tokens, else the configured
12
+ * max_output_tokens. `reasoning_effort` is injected verbatim as a free-form
13
+ * string — never folded onto an enum, never renamed ("max" stays "max").
14
+ *
15
+ * W804 (multimodal P0 section 3): an image-bearing message becomes an OpenAI
16
+ * content array, `content: [{type:"text",text}, {type:"image_url",image_url:{url:DATA}}]`.
17
+ * The bytes are a REQUEST-TIME projection: the host resolves them into the
18
+ * `images` table carried on the request (attachment_id -> data URL); they NEVER
19
+ * live in a Message or the session log. A tool message with images is split into
20
+ * the tool text message followed by a user message carrying the images (shape B,
21
+ * section 3.3), because some providers silently drop tool-role images.
22
+ */
23
+ import { type Content, type ImageRef, type Message, type ModelRequestDraft, type ToolSpec } from "./seam.js";
24
+ export interface WireToolCall {
25
+ id: string;
26
+ type: "function";
27
+ function: {
28
+ name: string;
29
+ arguments: string;
30
+ };
31
+ }
32
+ /** One text part of an OpenAI content array. */
33
+ export interface WireTextPart {
34
+ type: "text";
35
+ text: string;
36
+ }
37
+ /** One image part; the url is always a `data:` URL (section 3.1: remote URLs denied). */
38
+ export interface WireImagePart {
39
+ type: "image_url";
40
+ image_url: {
41
+ url: string;
42
+ };
43
+ }
44
+ export type WireContentPart = WireTextPart | WireImagePart;
45
+ /** Request-scoped resolution table: attachment_id -> data URL. */
46
+ export type ResolvedImages = Readonly<Record<string, string>>;
47
+ export interface WireMessage {
48
+ role: string;
49
+ /** A plain string for text-only messages; an array ONLY when it carries images. */
50
+ content: string | WireContentPart[] | null;
51
+ tool_calls?: WireToolCall[];
52
+ tool_call_id?: string;
53
+ }
54
+ export interface WireTool {
55
+ type: "function";
56
+ function: {
57
+ name: string;
58
+ description: string;
59
+ parameters: Record<string, unknown>;
60
+ };
61
+ }
62
+ /** The chat-completions request body (field presence mirrors serde skip_if). */
63
+ export interface ChatCompletionsBody {
64
+ model: string;
65
+ messages: WireMessage[];
66
+ stream: true;
67
+ tools?: WireTool[];
68
+ reasoning_effort?: string;
69
+ max_tokens?: number;
70
+ temperature?: number;
71
+ }
72
+ /** Read the host-resolved image table off a request (absent = empty). */
73
+ export declare function resolvedImagesOf(req: unknown): ResolvedImages;
74
+ /** `data:<mime>;base64,<...>` for one attachment's bytes. */
75
+ export declare function dataUrlFor(mediaType: string, bytes: Uint8Array): string;
76
+ /** The image references one message carries (its image content blocks). */
77
+ export declare function messageImageRefs(msg: Message): ImageRef[];
78
+ /** True when ANY message of the request carries an image block. */
79
+ export declare function messagesHaveImages(messages: readonly Message[]): boolean;
80
+ /** Content parts of ONE message; every image must resolve or this throws. */
81
+ export declare function collectMessageParts(content: readonly Content[], images: ResolvedImages): WireContentPart[];
82
+ /**
83
+ * Wire messages for ONE seam message (section 3.3): a tool message with images
84
+ * expands to [tool text, user images]. The split lives HERE and nowhere else, so
85
+ * a future provider that accepts tool-role images changes one branch.
86
+ */
87
+ export declare function wireMessagesFor(msg: Message, images?: ResolvedImages): WireMessage[];
88
+ /** Map one seam Message onto an OpenAI-compatible chat message. */
89
+ export declare function mapMessage(msg: Message, images?: ResolvedImages): WireMessage;
90
+ /** Map a seam ToolSpec onto an OpenAI-compatible function tool. */
91
+ export declare function mapTool(spec: ToolSpec): WireTool;
92
+ export interface BuildBodyOptions {
93
+ /** Effective model (the request's model wins over the configured one). */
94
+ model: string;
95
+ /** Free-form tier string injected verbatim; null/undefined = omit. */
96
+ reasoningEffort?: string | null;
97
+ /** Configured output cap used when the request leaves max_tokens empty. */
98
+ maxOutputTokens?: number | null;
99
+ }
100
+ /** Build the serialized chat-completions body for one request draft. */
101
+ export declare function buildRequestBody(req: ModelRequestDraft, opts: BuildBodyOptions): ChatCompletionsBody;
102
+ /** chat-completions endpoint for a base URL (trailing slashes tolerated). */
103
+ export declare function chatCompletionsUrl(baseUrl: string): string;
package/dist/wire.js ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * OpenAI-compatible request wire mapping (P2a).
3
+ *
4
+ * Mirrors `crates/llm/src/client.rs::{build_request, request_body, map_message,
5
+ * map_tool, collect_text}`:
6
+ *
7
+ * POST {base_url}/chat/completions
8
+ * { model, messages, tools?, reasoning_effort?, max_tokens?, temperature?,
9
+ * stream: true }
10
+ *
11
+ * The output cap is the request's explicit max_tokens, else the configured
12
+ * max_output_tokens. `reasoning_effort` is injected verbatim as a free-form
13
+ * string — never folded onto an enum, never renamed ("max" stays "max").
14
+ *
15
+ * W804 (multimodal P0 section 3): an image-bearing message becomes an OpenAI
16
+ * content array, `content: [{type:"text",text}, {type:"image_url",image_url:{url:DATA}}]`.
17
+ * The bytes are a REQUEST-TIME projection: the host resolves them into the
18
+ * `images` table carried on the request (attachment_id -> data URL); they NEVER
19
+ * live in a Message or the session log. A tool message with images is split into
20
+ * the tool text message followed by a user message carrying the images (shape B,
21
+ * section 3.3), because some providers silently drop tool-role images.
22
+ */
23
+ import { LlmError } from "./errors.js";
24
+ import { collectMessageText, isImageContent, } from "./seam.js";
25
+ /** Read the host-resolved image table off a request (absent = empty). */
26
+ export function resolvedImagesOf(req) {
27
+ if (typeof req !== "object" || req === null)
28
+ return {};
29
+ const table = req["images"];
30
+ if (typeof table !== "object" || table === null || Array.isArray(table))
31
+ return {};
32
+ return table;
33
+ }
34
+ /** `data:<mime>;base64,<...>` for one attachment's bytes. */
35
+ export function dataUrlFor(mediaType, bytes) {
36
+ return `data:${mediaType};base64,${Buffer.from(bytes).toString("base64")}`;
37
+ }
38
+ function imageBytesUnavailable(id) {
39
+ return new LlmError(`attachment ${id} has no resolvable bytes for this request`, "generate", { retryable: false });
40
+ }
41
+ /** The image references one message carries (its image content blocks). */
42
+ export function messageImageRefs(msg) {
43
+ const out = [];
44
+ for (const part of msg.content)
45
+ if (isImageContent(part))
46
+ out.push(part.content);
47
+ return out;
48
+ }
49
+ /** True when ANY message of the request carries an image block. */
50
+ export function messagesHaveImages(messages) {
51
+ for (const msg of messages)
52
+ if (msg.content.some(isImageContent))
53
+ return true;
54
+ return false;
55
+ }
56
+ /** Content parts of ONE message; every image must resolve or this throws. */
57
+ export function collectMessageParts(content, images) {
58
+ const parts = [];
59
+ for (const part of content) {
60
+ if (part.type === "text") {
61
+ if (part.content !== "")
62
+ parts.push({ type: "text", text: part.content });
63
+ }
64
+ else if (part.type === "image") {
65
+ const url = images[part.content.attachment_id];
66
+ if (url === undefined)
67
+ throw imageBytesUnavailable(part.content.attachment_id);
68
+ parts.push({ type: "image_url", image_url: { url } });
69
+ }
70
+ }
71
+ return parts;
72
+ }
73
+ /** A system/assistant image block is a wiring bug: report it, never drop it. */
74
+ function assertNoImages(msg) {
75
+ if (messageImageRefs(msg).length === 0)
76
+ return;
77
+ throw new LlmError(`W804: a ${msg.role} message must not carry an image block`, "generate", { retryable: false });
78
+ }
79
+ function userWire(msg, images) {
80
+ if (messageImageRefs(msg).length === 0)
81
+ return { role: "user", content: collectMessageText(msg.content) };
82
+ return { role: "user", content: collectMessageParts(msg.content, images) };
83
+ }
84
+ function assistantWire(msg) {
85
+ const text = collectMessageText(msg.content);
86
+ const toolCalls = msg.content
87
+ .filter((part) => part.type === "tool_call")
88
+ .map((part) => ({
89
+ id: part.content.id,
90
+ type: "function",
91
+ function: {
92
+ name: part.content.name,
93
+ arguments: JSON.stringify(part.content.args ?? null),
94
+ },
95
+ }));
96
+ const out = { role: "assistant", content: text === "" ? null : text };
97
+ if (toolCalls.length > 0)
98
+ out.tool_calls = toolCalls;
99
+ return out;
100
+ }
101
+ /**
102
+ * Wire messages for ONE seam message (section 3.3): a tool message with images
103
+ * expands to [tool text, user images]. The split lives HERE and nowhere else, so
104
+ * a future provider that accepts tool-role images changes one branch.
105
+ */
106
+ export function wireMessagesFor(msg, images = {}) {
107
+ switch (msg.role) {
108
+ case "system":
109
+ assertNoImages(msg);
110
+ return [{ role: "system", content: collectMessageText(msg.content) }];
111
+ case "user":
112
+ return [userWire(msg, images)];
113
+ case "tool": {
114
+ const tool = {
115
+ role: "tool",
116
+ content: collectMessageText(msg.content),
117
+ tool_call_id: msg.tool_call_id ?? "",
118
+ };
119
+ const refs = messageImageRefs(msg);
120
+ if (refs.length === 0)
121
+ return [tool];
122
+ const parts = [];
123
+ for (const ref of refs) {
124
+ const url = images[ref.attachment_id];
125
+ if (url === undefined)
126
+ throw imageBytesUnavailable(ref.attachment_id);
127
+ parts.push({ type: "image_url", image_url: { url } });
128
+ }
129
+ return [tool, { role: "user", content: parts }];
130
+ }
131
+ case "assistant":
132
+ assertNoImages(msg);
133
+ return [assistantWire(msg)];
134
+ default: {
135
+ // W835 (R3 batch E / P2-3): a role outside the union must be a structured
136
+ // LlmError, never the `...undefined` spread that panicked the turn.
137
+ const role = msg.role;
138
+ throw new LlmError("unsupported message role '" + String(role) + "'", "generate", { retryable: false });
139
+ }
140
+ }
141
+ }
142
+ /** Map one seam Message onto an OpenAI-compatible chat message. */
143
+ export function mapMessage(msg, images = {}) {
144
+ const out = wireMessagesFor(msg, images);
145
+ const first = out[0];
146
+ if (out.length !== 1 || first === undefined) {
147
+ throw new LlmError("W804: a tool message with images must be split by buildRequestBody", "generate", { retryable: false });
148
+ }
149
+ return first;
150
+ }
151
+ /** Map a seam ToolSpec onto an OpenAI-compatible function tool. */
152
+ export function mapTool(spec) {
153
+ return {
154
+ type: "function",
155
+ function: {
156
+ name: spec.name,
157
+ description: spec.description,
158
+ parameters: spec.parameters,
159
+ },
160
+ };
161
+ }
162
+ /** Build the serialized chat-completions body for one request draft. */
163
+ export function buildRequestBody(req, opts) {
164
+ const images = resolvedImagesOf(req);
165
+ const messages = [];
166
+ if (req.system !== null && req.system !== undefined && req.system !== "") {
167
+ messages.push({ role: "system", content: req.system });
168
+ }
169
+ for (const message of req.messages ?? [])
170
+ messages.push(...wireMessagesFor(message, images));
171
+ const tools = (req.tools ?? []).map(mapTool);
172
+ const maxTokens = req.max_tokens ?? opts.maxOutputTokens ?? null;
173
+ const body = { model: opts.model, messages, stream: true };
174
+ if (tools.length > 0)
175
+ body.tools = tools;
176
+ // W835 (R3 batch D / P2-2): 0/negative = "no cap" (the field is omitted).
177
+ if (maxTokens !== null && maxTokens > 0)
178
+ body.max_tokens = maxTokens;
179
+ if (req.temperature !== null && req.temperature !== undefined) {
180
+ body.temperature = req.temperature;
181
+ }
182
+ if (opts.reasoningEffort !== null && opts.reasoningEffort !== undefined) {
183
+ body.reasoning_effort = opts.reasoningEffort;
184
+ }
185
+ return body;
186
+ }
187
+ /** chat-completions endpoint for a base URL (trailing slashes tolerated). */
188
+ export function chatCompletionsUrl(baseUrl) {
189
+ return `${baseUrl.trimEnd().replace(/\/+$/, "")}/chat/completions`;
190
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@celestea/llm",
3
+ "version": "2.7.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@celestea/core": "2.7.1"
14
+ },
15
+ "license": "MIT",
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit -p tsconfig.json",
26
+ "build": "tsc -p tsconfig.build.json"
27
+ }
28
+ }