symposium 3.2.0 → 3.3.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/Agent.js +7 -1
- package/CLAUDE.md +4 -3
- package/Model.js +13 -0
- package/Models/AnthropicModel.js +8 -0
- package/Models/GrokModel.js +12 -2
- package/Models/GroqModel.js +8 -1
- package/Models/LegacyOpenAIModel.js +9 -2
- package/Models/OllamaModel.js +8 -0
- package/Models/OpenAIModel.js +21 -4
- package/README.md +1 -0
- package/package.json +1 -1
- package/test/agent.test.js +26 -0
- package/test/models/anthropic.test.js +29 -0
- package/test/models/groq.test.js +15 -0
- package/test/models/legacyOpenai.test.js +42 -0
- package/test/models/ollama.test.js +15 -0
- package/test/models/openai.test.js +24 -0
package/Agent.js
CHANGED
|
@@ -672,8 +672,14 @@ ${context_string}
|
|
|
672
672
|
let step = await it.next();
|
|
673
673
|
while (!step.done) {
|
|
674
674
|
const delta = step.value;
|
|
675
|
-
if (delta && delta.type === 'text_delta')
|
|
675
|
+
if (delta && delta.type === 'text_delta') {
|
|
676
676
|
yield {type: 'chunk', content: delta.content};
|
|
677
|
+
} else if (delta && delta.type === 'usage') {
|
|
678
|
+
// Last reported usage rides on thread state too, so it survives
|
|
679
|
+
// storeState and is readable at turn_end / after a reload.
|
|
680
|
+
thread.state.usage = delta.content;
|
|
681
|
+
yield {type: 'usage', content: delta.content};
|
|
682
|
+
}
|
|
677
683
|
step = await it.next();
|
|
678
684
|
}
|
|
679
685
|
messages = step.value;
|
package/CLAUDE.md
CHANGED
|
@@ -26,7 +26,7 @@ The execution core. After Phase 6, `agent.message()` is a non-generator dispatch
|
|
|
26
26
|
|
|
27
27
|
`message(content, thread)` accepts three input shapes (Phase 3): a plain `string`, a `ContentBlock[]`, or an `AsyncIterable<string | ContentBlock | ContentBlock[] | ControlMessage>`. The first two behave as they always have — one user turn, one model loop, done. An async iterable enables streaming input: the agent drains the iterable into the initial user message (stopping on a `{type:'submit'}` control message, on iterable close, or once at least one content piece has arrived and the next read would block), kicks off the loop, then keeps reading concurrently. New content items pushed during a turn are queued and inserted as a user message at the next inter-turn boundary. A `{type:'cancel'}` control message terminates the loop gracefully after the in-flight turn. `{type:'auth', id, decision}` control messages carry tool-authorization responses — see the tool-authorization paragraph below. Use `createInputChannel()` (exported from `index.js`) for a simple promise-queue-backed `AsyncIterable` with `send(item)` / `close()` methods; under the hood it's implemented in `InputChannel.js`. For streaming input, the chat agent does NOT terminate after a no-tool-call turn — it waits for the next message; the run ends only when the iterable closes (or cancel is received).
|
|
28
28
|
|
|
29
|
-
- `chat` — yields the full event set (`start`, `chunk`, `output`, `reasoning`, `tool`, `tool_response`, `tools_auth`, `retry`, `turn_end`, `end`). `turn_end` fires after each completed turn (after the LLM finishes a response with no more tool calls), regardless of streaming vs single-shot mode — consumers use it to detect turn boundaries within a streaming run, where `start`/`end` only bracket the whole run. If `response_schema` is set, the final assistant message is parsed against it and a `{type:'result', value}` event is yielded before `end`; the run terminates after the schema-conforming answer (no further turns).
|
|
29
|
+
- `chat` — yields the full event set (`start`, `chunk`, `output`, `reasoning`, `tool`, `tool_response`, `tools_auth`, `retry`, `usage`, `turn_end`, `end`). `turn_end` fires after each completed turn (after the LLM finishes a response with no more tool calls), regardless of streaming vs single-shot mode — consumers use it to detect turn boundaries within a streaming run, where `start`/`end` only bracket the whole run. If `response_schema` is set, the final assistant message is parsed against it and a `{type:'result', value}` event is yielded before `end`; the run terminates after the schema-conforming answer (no further turns).
|
|
30
30
|
- `utility` — `await agent.message(...)` resolves to the parsed value. With no `response_schema`, the value is the raw assistant text. With `response_schema` set, the value is the parsed JSON object: structured-output-capable models with ≤100 properties use `response_format: json_schema`; otherwise the agent falls back to a forced tool call (synthetic name `'response'`) and parses its arguments. See `convertFunctionToResponseFormat()` for the OpenAI-specific schema constraints (all properties forced to required, `additionalProperties: false`). The legacy `agent.utility = {type, function, parameters}` shape was removed in Phase 6 — use `response_schema` (a raw JSON schema) instead. `response_schema` is independent of `type` and works on chat agents too.
|
|
31
31
|
|
|
32
32
|
**`response_schema` + toolkits (tool use *then* structured output).** When an agent has BOTH `response_schema` and toolkit tools, the schema is NOT forced up-front (forcing would block the model from calling its tools first). Instead `execute()` advertises an extra `response` tool *alongside* the real ones via `completion_options.append_tools` (merged by `Model.parseOptions` — additive, unlike `options.tools` which replaces). The model uses its real tools across turns and, when ready, calls `response` with the final schema-conforming answer; `handleCompletion` treats a `response` call as terminal (→ `afterHandle` → `{type:'result', value}`) and every other call as a normal tool invocation (`response` is a **reserved tool name** when `response_schema` is set). The forced-tool / `response_format` paths above apply only when the agent has **no** toolkits, so existing tool-less utility agents are unchanged. This is what lets a `chat` agent self-search and still return a validated object (consume it by draining for the `result` event).
|
|
@@ -40,8 +40,8 @@ Within a single LLM turn, tools are executed **sequentially** (in `tools_to_call
|
|
|
40
40
|
### Models (`Models/*.js`, base in `Model.js`)
|
|
41
41
|
|
|
42
42
|
Every provider extends `Model` and implements:
|
|
43
|
-
- `getModels()` — returns `Map<label, definition>` where definition flags capabilities: `tools`, `structured_output`, `audio`, `image_generation`, `tokens` (context window), `tiktoken` (encoding name).
|
|
44
|
-
- `generate(model, thread, tools, options)` — **async generator** (Phase 1 of v3 refactor). Yields streaming deltas during generation and `return`s the final assembled `Message[]`. Delta union: `{type: 'text_delta', content}`, `{type: 'reasoning_delta', content}`, `{type: 'tool_call', content: {id?, name, arguments}}` (emitted complete), `{type: 'image', content, meta}
|
|
43
|
+
- `getModels()` — returns `Map<label, definition>` where definition flags capabilities: `tools`, `structured_output`, `audio`, `image_generation`, `reasoning` (the OpenAI Responses adapter only sends `include: ['reasoning.encrypted_content']` + `reasoning: {summary}` when set — non-reasoning models like `gpt-4o` reject them), `tokens` (context window), `tiktoken` (encoding name).
|
|
44
|
+
- `generate(model, thread, tools, options)` — **async generator** (Phase 1 of v3 refactor). Yields streaming deltas during generation and `return`s the final assembled `Message[]`. Delta union: `{type: 'text_delta', content}`, `{type: 'reasoning_delta', content}`, `{type: 'tool_call', content: {id?, name, arguments}}` (emitted complete), `{type: 'image', content, meta}`, `{type: 'usage', content: {input, output, cached, context}}` (once at end-of-stream, via `Model.usageDelta()`, only when the provider reported usage — `input` includes `cached`; `context` = `input + output`; Anthropic's cache read/creation tokens are folded back into `input` since the API reports them outside `input_tokens`). `Agent.generateCompletion()` (Phase 2) is itself an async generator: it forwards `text_delta` to consumers as `{type:'chunk', content}` events, forwards `usage` as `{type:'usage', content}` (also stamping `thread.state.usage`, last call wins), and returns the assembled `Message[]`. Other delta types are not forwarded yet and only contribute to the final assembly. Tool-call deltas from chat-completions-style APIs (OpenAI legacy, Groq) are accumulated per `index` and yielded once at end-of-stream; on those APIs the usage chunk arrives last with empty `choices` (OpenAI/DeepSeek need `stream_options: {include_usage: true}`, sent by `LegacyOpenAIModel`; Groq ships it natively under `x_groq.usage`).
|
|
45
45
|
- Optionally `countTokens(thread)` (used by `Summarizer`).
|
|
46
46
|
|
|
47
47
|
A model definition's `tools: true` means the provider supports native tool calling. When false, `Agent.parseTools()` falls back to parsing `\`\`\`\nCALL <name>\n<json>\n\`\`\`` blocks out of plain text — the prompt for this is built by `Model.promptFromTools()` (in Italian; do not translate without verifying the existing parser still matches).
|
|
@@ -79,6 +79,7 @@ Two distinct concepts share the word "context":
|
|
|
79
79
|
| `{type:'tool_response', id, name, toolkit, success, response?, error?}` | tool result | After tool returns or throws. `id` matches the `tool` event's id; `name` is the tool name (same as the `tool` event); `toolkit` is the owning toolkit's name. |
|
|
80
80
|
| `{type:'tools_auth', id, tools}` | uuid + pending tool calls | When `tool.authorize()` returns false; resume by sending `{type:'auth', id, decision}` on the input channel |
|
|
81
81
|
| `{type:'retry', attempt, reason}` | 1-indexed retry number + error message | Yielded only when an error occurs AFTER at least one `chunk` has been streamed for the current turn (hybrid retry, Phase 5). Errors before any output are retried silently. |
|
|
82
|
+
| `{type:'usage', content}` | `{input, output, cached, context}` | Provider-reported token usage, one per LLM call (so several per turn when tools run). `context` = `input + output` — tokens occupying the model context after the call. Mirrored on `thread.state.usage` (last call wins, persisted with the thread). Absent when the provider reports no usage. |
|
|
82
83
|
| `{type:'result', value}` | parsed value | Utility agents only |
|
|
83
84
|
| `{type:'turn_end', thread}` | thread object | Yielded after a turn completes (the LLM finished a response with no more tool calls). Fires once per turn whether the run continues (streaming, awaiting next user message) or ends. Use this — not `end` — to detect per-turn boundaries in a streaming session. |
|
|
84
85
|
| `{type:'end', thread}` | thread object | Always yielded, even on throw (yielded from a `finally`) |
|
package/Model.js
CHANGED
|
@@ -15,9 +15,22 @@ export default class Model {
|
|
|
15
15
|
// {type: 'reasoning_delta', content: string}
|
|
16
16
|
// {type: 'tool_call', content: {id?, name, arguments}}
|
|
17
17
|
// {type: 'image', content: <image-block-content>, meta}
|
|
18
|
+
// {type: 'usage', content: {input, output, cached, context}}
|
|
19
|
+
// The usage delta is yielded once at end of stream (via usageDelta()),
|
|
20
|
+
// only when the provider reported token usage for the call.
|
|
18
21
|
return null;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
// Normalized token usage for one LLM call. `input` is the full prompt size
|
|
25
|
+
// (cache included), `cached` the portion of it served from cache, `context`
|
|
26
|
+
// the tokens occupying the model context after the call (input + output).
|
|
27
|
+
usageDelta(input, output, cached = 0) {
|
|
28
|
+
input = input || 0;
|
|
29
|
+
output = output || 0;
|
|
30
|
+
cached = cached || 0;
|
|
31
|
+
return {type: 'usage', content: {input, output, cached, context: input + output}};
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
async countTokens(thread) {
|
|
22
35
|
throw new Error('countTokens not implemented in this model');
|
|
23
36
|
}
|
package/Models/AnthropicModel.js
CHANGED
|
@@ -153,6 +153,14 @@ export default class AnthropicModel extends Model {
|
|
|
153
153
|
|
|
154
154
|
const message = await stream.finalMessage();
|
|
155
155
|
|
|
156
|
+
if (message.usage) {
|
|
157
|
+
// Anthropic reports cached tokens OUTSIDE input_tokens: the real
|
|
158
|
+
// prompt size is input + cache reads + cache writes.
|
|
159
|
+
const cached = message.usage.cache_read_input_tokens || 0;
|
|
160
|
+
const input = (message.usage.input_tokens || 0) + cached + (message.usage.cache_creation_input_tokens || 0);
|
|
161
|
+
yield this.usageDelta(input, message.usage.output_tokens, cached);
|
|
162
|
+
}
|
|
163
|
+
|
|
156
164
|
const message_content = [];
|
|
157
165
|
if (message.content) {
|
|
158
166
|
for (let m of message.content) {
|
package/Models/GrokModel.js
CHANGED
|
@@ -7,21 +7,31 @@ export default class GrokModel extends OpenAIModel {
|
|
|
7
7
|
['grok-4-1-fast-reasoning', {
|
|
8
8
|
name: 'grok-4-1-fast-reasoning',
|
|
9
9
|
tokens: 2000000,
|
|
10
|
+
reasoning: true,
|
|
10
11
|
tools: true,
|
|
11
12
|
}],
|
|
12
13
|
['grok-4-1-fast-non-reasoning', {
|
|
13
14
|
name: 'grok-4-1-fast-non-reasoning',
|
|
14
15
|
tokens: 2000000,
|
|
16
|
+
reasoning: true,
|
|
15
17
|
tools: true,
|
|
16
18
|
}],
|
|
17
19
|
['grok-4-20-fast-reasoning', {
|
|
18
20
|
name: 'grok-4.20-beta-0309-reasoning',
|
|
19
21
|
tokens: 2000000,
|
|
22
|
+
reasoning: true,
|
|
20
23
|
tools: true,
|
|
21
24
|
}],
|
|
22
25
|
['grok-4-20-fast-non-reasoning', {
|
|
23
26
|
name: 'grok-4.20-beta-0309-non-reasoning',
|
|
24
27
|
tokens: 2000000,
|
|
28
|
+
reasoning: true,
|
|
29
|
+
tools: true,
|
|
30
|
+
}],
|
|
31
|
+
['grok-4.5', {
|
|
32
|
+
name: 'grok-4.5',
|
|
33
|
+
tokens: 5000000,
|
|
34
|
+
reasoning: true,
|
|
25
35
|
tools: true,
|
|
26
36
|
}],
|
|
27
37
|
]);
|
|
@@ -38,7 +48,7 @@ export default class GrokModel extends OpenAIModel {
|
|
|
38
48
|
return this.openai;
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
async generate(model, thread, tools = [], options = {}) {
|
|
51
|
+
async *generate(model, thread, tools = [], options = {}) {
|
|
42
52
|
if (options.image_generation) {
|
|
43
53
|
tools.push({
|
|
44
54
|
name: 'generate_image',
|
|
@@ -56,7 +66,7 @@ export default class GrokModel extends OpenAIModel {
|
|
|
56
66
|
});
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
const response =
|
|
69
|
+
const response = yield* super.generate(model, thread, tools, options);
|
|
60
70
|
|
|
61
71
|
// Check for image generation response
|
|
62
72
|
if (options.image_generation) {
|
package/Models/GroqModel.js
CHANGED
|
@@ -79,10 +79,14 @@ export default class GroqModel extends Model {
|
|
|
79
79
|
|
|
80
80
|
const stream = await this.getGroq().chat.completions.create({...completion_payload, stream: true});
|
|
81
81
|
|
|
82
|
-
let fullText = '';
|
|
82
|
+
let fullText = '', usage = null;
|
|
83
83
|
const toolBuffer = new Map();
|
|
84
84
|
|
|
85
85
|
for await (const chunk of stream) {
|
|
86
|
+
// Groq ships usage on the final chunk under x_groq — grab it before the delta guard.
|
|
87
|
+
if (chunk.x_groq?.usage || chunk.usage)
|
|
88
|
+
usage = chunk.x_groq?.usage || chunk.usage;
|
|
89
|
+
|
|
86
90
|
const delta = chunk.choices?.[0]?.delta;
|
|
87
91
|
if (!delta)
|
|
88
92
|
continue;
|
|
@@ -119,6 +123,9 @@ export default class GroqModel extends Model {
|
|
|
119
123
|
yield {type: 'tool_call', content: tc};
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
if (usage)
|
|
127
|
+
yield this.usageDelta(usage.prompt_tokens, usage.completion_tokens, usage.prompt_tokens_details?.cached_tokens);
|
|
128
|
+
|
|
122
129
|
const message_content = [];
|
|
123
130
|
if (fullText)
|
|
124
131
|
message_content.push({type: 'text', content: fullText});
|
|
@@ -70,12 +70,16 @@ export default class LegacyOpenAIModel extends Model {
|
|
|
70
70
|
if (!completion_payload.tools.length)
|
|
71
71
|
delete completion_payload.tools;
|
|
72
72
|
|
|
73
|
-
const stream = await this.getOpenAi().chat.completions.create({...completion_payload, stream: true});
|
|
73
|
+
const stream = await this.getOpenAi().chat.completions.create({...completion_payload, stream: true, stream_options: {include_usage: true}});
|
|
74
74
|
|
|
75
|
-
let fullText = '';
|
|
75
|
+
let fullText = '', usage = null;
|
|
76
76
|
const toolBuffer = new Map();
|
|
77
77
|
|
|
78
78
|
for await (const chunk of stream) {
|
|
79
|
+
// The usage chunk arrives last with empty choices — grab it before the delta guard.
|
|
80
|
+
if (chunk.usage)
|
|
81
|
+
usage = chunk.usage;
|
|
82
|
+
|
|
79
83
|
const delta = chunk.choices?.[0]?.delta;
|
|
80
84
|
if (!delta)
|
|
81
85
|
continue;
|
|
@@ -112,6 +116,9 @@ export default class LegacyOpenAIModel extends Model {
|
|
|
112
116
|
yield {type: 'tool_call', content: tc};
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
if (usage)
|
|
120
|
+
yield this.usageDelta(usage.prompt_tokens, usage.completion_tokens, usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens);
|
|
121
|
+
|
|
115
122
|
const message_content = [];
|
|
116
123
|
if (fullText)
|
|
117
124
|
message_content.push({type: 'text', content: fullText});
|
package/Models/OllamaModel.js
CHANGED
|
@@ -90,9 +90,14 @@ export default class OllamaModel extends Model {
|
|
|
90
90
|
|
|
91
91
|
let fullText = '';
|
|
92
92
|
let fullThinking = '';
|
|
93
|
+
let usage = null;
|
|
93
94
|
const toolCalls = [];
|
|
94
95
|
|
|
95
96
|
for await (const chunk of stream) {
|
|
97
|
+
// The final done chunk carries the token counts — grab them before the message guard.
|
|
98
|
+
if (chunk.prompt_eval_count != null || chunk.eval_count != null)
|
|
99
|
+
usage = {input: chunk.prompt_eval_count, output: chunk.eval_count};
|
|
100
|
+
|
|
96
101
|
const m = chunk.message;
|
|
97
102
|
if (!m)
|
|
98
103
|
continue;
|
|
@@ -122,6 +127,9 @@ export default class OllamaModel extends Model {
|
|
|
122
127
|
}
|
|
123
128
|
}
|
|
124
129
|
|
|
130
|
+
if (usage)
|
|
131
|
+
yield this.usageDelta(usage.input, usage.output);
|
|
132
|
+
|
|
125
133
|
const message_content = [];
|
|
126
134
|
if (fullThinking)
|
|
127
135
|
message_content.push({type: 'reasoning', content: fullThinking});
|
package/Models/OpenAIModel.js
CHANGED
|
@@ -19,6 +19,7 @@ export default class OpenAIModel extends Model {
|
|
|
19
19
|
name: 'gpt-5',
|
|
20
20
|
tiktoken: 'gpt-4',
|
|
21
21
|
tokens: 400000,
|
|
22
|
+
reasoning: true,
|
|
22
23
|
tools: true,
|
|
23
24
|
structured_output: true,
|
|
24
25
|
audio: true,
|
|
@@ -28,6 +29,7 @@ export default class OpenAIModel extends Model {
|
|
|
28
29
|
name: 'gpt-5-mini',
|
|
29
30
|
tiktoken: 'gpt-4',
|
|
30
31
|
tokens: 400000,
|
|
32
|
+
reasoning: true,
|
|
31
33
|
tools: true,
|
|
32
34
|
structured_output: true,
|
|
33
35
|
}],
|
|
@@ -35,6 +37,7 @@ export default class OpenAIModel extends Model {
|
|
|
35
37
|
name: 'gpt-5.1',
|
|
36
38
|
tiktoken: 'gpt-4',
|
|
37
39
|
tokens: 400000,
|
|
40
|
+
reasoning: true,
|
|
38
41
|
tools: true,
|
|
39
42
|
structured_output: true,
|
|
40
43
|
audio: true,
|
|
@@ -44,6 +47,7 @@ export default class OpenAIModel extends Model {
|
|
|
44
47
|
name: 'gpt-5.2',
|
|
45
48
|
tiktoken: 'gpt-4',
|
|
46
49
|
tokens: 400000,
|
|
50
|
+
reasoning: true,
|
|
47
51
|
tools: true,
|
|
48
52
|
structured_output: true,
|
|
49
53
|
audio: true,
|
|
@@ -53,6 +57,7 @@ export default class OpenAIModel extends Model {
|
|
|
53
57
|
name: 'gpt-5.2-codex',
|
|
54
58
|
tiktoken: 'gpt-4',
|
|
55
59
|
tokens: 400000,
|
|
60
|
+
reasoning: true,
|
|
56
61
|
tools: true,
|
|
57
62
|
structured_output: true,
|
|
58
63
|
audio: false,
|
|
@@ -62,6 +67,7 @@ export default class OpenAIModel extends Model {
|
|
|
62
67
|
name: 'gpt-5.4',
|
|
63
68
|
tiktoken: 'gpt-4',
|
|
64
69
|
tokens: 1000000,
|
|
70
|
+
reasoning: true,
|
|
65
71
|
tools: true,
|
|
66
72
|
structured_output: true,
|
|
67
73
|
audio: false,
|
|
@@ -71,6 +77,7 @@ export default class OpenAIModel extends Model {
|
|
|
71
77
|
name: 'gpt-5.5',
|
|
72
78
|
tiktoken: 'gpt-5',
|
|
73
79
|
tokens: 1000000,
|
|
80
|
+
reasoning: true,
|
|
74
81
|
tools: true,
|
|
75
82
|
structured_output: true,
|
|
76
83
|
audio: false,
|
|
@@ -80,6 +87,7 @@ export default class OpenAIModel extends Model {
|
|
|
80
87
|
name: 'gpt-5.6-luna',
|
|
81
88
|
tiktoken: 'gpt-5',
|
|
82
89
|
tokens: 1000000,
|
|
90
|
+
reasoning: true,
|
|
83
91
|
tools: true,
|
|
84
92
|
structured_output: true,
|
|
85
93
|
audio: false,
|
|
@@ -89,6 +97,7 @@ export default class OpenAIModel extends Model {
|
|
|
89
97
|
name: 'gpt-5.6-terra',
|
|
90
98
|
tiktoken: 'gpt-5',
|
|
91
99
|
tokens: 1000000,
|
|
100
|
+
reasoning: true,
|
|
92
101
|
tools: true,
|
|
93
102
|
structured_output: true,
|
|
94
103
|
audio: false,
|
|
@@ -98,6 +107,7 @@ export default class OpenAIModel extends Model {
|
|
|
98
107
|
name: 'gpt-5.6-sol',
|
|
99
108
|
tiktoken: 'gpt-5',
|
|
100
109
|
tokens: 1000000,
|
|
110
|
+
reasoning: true,
|
|
101
111
|
tools: true,
|
|
102
112
|
structured_output: true,
|
|
103
113
|
audio: false,
|
|
@@ -156,13 +166,17 @@ export default class OpenAIModel extends Model {
|
|
|
156
166
|
model: model.name,
|
|
157
167
|
input: convertedMessages,
|
|
158
168
|
store: false,
|
|
159
|
-
include: ['reasoning.encrypted_content'],
|
|
160
169
|
tools: apiTools,
|
|
161
|
-
reasoning: {
|
|
162
|
-
summary: 'auto',
|
|
163
|
-
},
|
|
164
170
|
};
|
|
165
171
|
|
|
172
|
+
// Reasoning options are rejected by non-reasoning models (e.g. gpt-4o)
|
|
173
|
+
if (model.reasoning) {
|
|
174
|
+
completion_payload.include = ['reasoning.encrypted_content'];
|
|
175
|
+
completion_payload.reasoning = {
|
|
176
|
+
summary: 'auto',
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
166
180
|
if (options.force_tool) {
|
|
167
181
|
completion_payload.tool_choice = {
|
|
168
182
|
type: 'function',
|
|
@@ -223,6 +237,9 @@ export default class OpenAIModel extends Model {
|
|
|
223
237
|
|
|
224
238
|
const completion = await stream.finalResponse();
|
|
225
239
|
|
|
240
|
+
if (completion.usage)
|
|
241
|
+
yield this.usageDelta(completion.usage.input_tokens, completion.usage.output_tokens, completion.usage.input_tokens_details?.cached_tokens);
|
|
242
|
+
|
|
226
243
|
const message_content = [];
|
|
227
244
|
for (let output of completion.output) {
|
|
228
245
|
switch (output.type) {
|
package/README.md
CHANGED
|
@@ -161,6 +161,7 @@ All events yielded from the generator:
|
|
|
161
161
|
| `tool_response` | `{id, name, toolkit, success, response?, error?}` | Emitted after the tool returns or throws. `id` matches the `tool` event's id; `name` is the tool name; `toolkit` is the owning toolkit's name. |
|
|
162
162
|
| `tools_auth` | `{id, tools}` | Yielded when authorization is required — see below. |
|
|
163
163
|
| `retry` | `{attempt, reason}` | Only when an error occurs *after* at least one chunk has already streamed for the current turn. |
|
|
164
|
+
| `usage` | `{content: {input, output, cached, context}}` | Token usage reported by the provider after each LLM call (one per call, so several per turn when tools run). `input` is the full prompt size (cache included), `cached` the portion served from cache, `context` = `input + output` — the tokens occupying the model context after the call. Also stored on `thread.state.usage` (last call wins), so it survives persistence and is readable at `turn_end`. Omitted when the provider does not report usage. |
|
|
164
165
|
| `result` | `{value}` | Only when `response_schema` is set — parsed structured answer. |
|
|
165
166
|
| `turn_end` | `{thread}` | After each completed turn (LLM done with no more tool calls). Fires per-turn whether the run continues or ends — use it (not `end`) to detect turn boundaries inside a streaming run. |
|
|
166
167
|
| `end` | `{thread}` | Always yielded last, even on throw. |
|
package/package.json
CHANGED
package/test/agent.test.js
CHANGED
|
@@ -84,6 +84,32 @@ test('Agent.generateCompletion forwards text_delta as chunks and returns Message
|
|
|
84
84
|
assert.deepEqual(value[0].content, [{type: 'text', content: 'Hello world'}]);
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
+
test('Agent.generateCompletion forwards usage deltas and stamps thread.state.usage', async () => {
|
|
88
|
+
const label = 'fake-gen-usage';
|
|
89
|
+
const usage = {input: 550, output: 30, cached: 400, context: 580};
|
|
90
|
+
await Symposium.loadModel(new ScriptedModel(label, [{
|
|
91
|
+
deltas: [
|
|
92
|
+
{type: 'text_delta', content: 'Hi'},
|
|
93
|
+
{type: 'usage', content: usage},
|
|
94
|
+
],
|
|
95
|
+
messages: [new Message('assistant', [{type: 'text', content: 'Hi'}])],
|
|
96
|
+
}]));
|
|
97
|
+
|
|
98
|
+
const agent = new Agent();
|
|
99
|
+
agent.default_model = label;
|
|
100
|
+
await agent.init();
|
|
101
|
+
|
|
102
|
+
const thread = await makeThread(agent, label);
|
|
103
|
+
|
|
104
|
+
const {deltas} = await drain(agent.generateCompletion(thread));
|
|
105
|
+
|
|
106
|
+
assert.deepEqual(deltas, [
|
|
107
|
+
{type: 'chunk', content: 'Hi'},
|
|
108
|
+
{type: 'usage', content: usage},
|
|
109
|
+
]);
|
|
110
|
+
assert.deepEqual(thread.state.usage, usage);
|
|
111
|
+
});
|
|
112
|
+
|
|
87
113
|
// ────────────────────────────────────────────────────────────────────────────────
|
|
88
114
|
// Chat happy path
|
|
89
115
|
// ────────────────────────────────────────────────────────────────────────────────
|
|
@@ -98,6 +98,35 @@ test('AnthropicModel buffers input_json_delta and yields a complete tool_call',
|
|
|
98
98
|
]);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
+
test('AnthropicModel yields a normalized usage delta folding cache tokens into input', async () => {
|
|
102
|
+
const m = new AnthropicModel();
|
|
103
|
+
const finalMessage = {
|
|
104
|
+
content: [{type: 'text', text: 'Hi'}],
|
|
105
|
+
usage: {
|
|
106
|
+
input_tokens: 100,
|
|
107
|
+
cache_read_input_tokens: 400,
|
|
108
|
+
cache_creation_input_tokens: 50,
|
|
109
|
+
output_tokens: 30,
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
const stream = anthropicMessagesStream(
|
|
113
|
+
[
|
|
114
|
+
{type: 'content_block_start', index: 0, content_block: {type: 'text'}},
|
|
115
|
+
{type: 'content_block_delta', index: 0, delta: {type: 'text_delta', text: 'Hi'}},
|
|
116
|
+
{type: 'content_block_stop', index: 0},
|
|
117
|
+
],
|
|
118
|
+
finalMessage,
|
|
119
|
+
);
|
|
120
|
+
installFakeAnthropic(m, stream);
|
|
121
|
+
|
|
122
|
+
const {deltas} = await drain(m.generate(buildModelDef(), fakeThread()));
|
|
123
|
+
|
|
124
|
+
assert.deepEqual(deltas[deltas.length - 1], {
|
|
125
|
+
type: 'usage',
|
|
126
|
+
content: {input: 550, output: 30, cached: 400, context: 580},
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
101
130
|
test('AnthropicModel emits reasoning_delta from thinking_delta', async () => {
|
|
102
131
|
const m = new AnthropicModel();
|
|
103
132
|
const thinkingBlock = {type: 'thinking', thinking: 'reasoning text'};
|
package/test/models/groq.test.js
CHANGED
|
@@ -69,3 +69,18 @@ test('GroqModel accumulates tool_call deltas across chunks', async () => {
|
|
|
69
69
|
},
|
|
70
70
|
]);
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
test('GroqModel yields a normalized usage delta from the x_groq final chunk', async () => {
|
|
74
|
+
const m = new GroqModel();
|
|
75
|
+
installFakeGroq(m, [
|
|
76
|
+
{choices: [{delta: {content: 'Hi'}}]},
|
|
77
|
+
{choices: [{delta: {}, finish_reason: 'stop'}], x_groq: {usage: {prompt_tokens: 80, completion_tokens: 20}}},
|
|
78
|
+
]);
|
|
79
|
+
|
|
80
|
+
const {deltas} = await drain(m.generate(buildModelDef(), fakeThread()));
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(deltas[deltas.length - 1], {
|
|
83
|
+
type: 'usage',
|
|
84
|
+
content: {input: 80, output: 20, cached: 0, context: 100},
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -85,3 +85,45 @@ test('LegacyOpenAIModel combines text + tool_calls in one Message', async () =>
|
|
|
85
85
|
assert.equal(value[0].content[1].type, 'tool_call');
|
|
86
86
|
assert.equal(value[0].content[1].content[0].name, 'f');
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
test('LegacyOpenAIModel requests include_usage and yields a normalized usage delta', async () => {
|
|
90
|
+
const m = new LegacyOpenAIModel();
|
|
91
|
+
let capturedPayload = null;
|
|
92
|
+
m.getOpenAi = () => ({
|
|
93
|
+
chat: {
|
|
94
|
+
completions: {
|
|
95
|
+
async create(payload) {
|
|
96
|
+
capturedPayload = payload;
|
|
97
|
+
return asyncIterable([
|
|
98
|
+
{choices: [{delta: {content: 'Hi'}}]},
|
|
99
|
+
{choices: [{delta: {}, finish_reason: 'stop'}]},
|
|
100
|
+
{choices: [], usage: {prompt_tokens: 500, completion_tokens: 25, prompt_tokens_details: {cached_tokens: 300}}},
|
|
101
|
+
]);
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const {deltas} = await drain(m.generate(buildModelDef(), fakeThread()));
|
|
108
|
+
|
|
109
|
+
assert.deepEqual(capturedPayload.stream_options, {include_usage: true});
|
|
110
|
+
assert.deepEqual(deltas[deltas.length - 1], {
|
|
111
|
+
type: 'usage',
|
|
112
|
+
content: {input: 500, output: 25, cached: 300, context: 525},
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('LegacyOpenAIModel maps DeepSeek prompt_cache_hit_tokens to cached', async () => {
|
|
117
|
+
const m = new LegacyOpenAIModel();
|
|
118
|
+
installFakeOpenAi(m, [
|
|
119
|
+
{choices: [{delta: {content: 'Hi'}}]},
|
|
120
|
+
{choices: [], usage: {prompt_tokens: 500, completion_tokens: 25, prompt_cache_hit_tokens: 200}},
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const {deltas} = await drain(m.generate(buildModelDef(), fakeThread()));
|
|
124
|
+
|
|
125
|
+
assert.deepEqual(deltas[deltas.length - 1], {
|
|
126
|
+
type: 'usage',
|
|
127
|
+
content: {input: 500, output: 25, cached: 200, context: 525},
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -88,3 +88,18 @@ test('OllamaModel emulates streaming when only a single full chunk arrives', asy
|
|
|
88
88
|
]);
|
|
89
89
|
assert.deepEqual(value[0].content, [{type: 'text', content: 'One shot response'}]);
|
|
90
90
|
});
|
|
91
|
+
|
|
92
|
+
test('OllamaModel yields a normalized usage delta from the final done chunk', async () => {
|
|
93
|
+
const m = new OllamaModel();
|
|
94
|
+
installFakeOllama(m, [
|
|
95
|
+
{message: {content: 'Hello'}},
|
|
96
|
+
{message: {content: ''}, done: true, prompt_eval_count: 60, eval_count: 15},
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const {deltas} = await drain(m.generate(buildModelDef(), fakeThread()));
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(deltas[deltas.length - 1], {
|
|
102
|
+
type: 'usage',
|
|
103
|
+
content: {input: 60, output: 15, cached: 0, context: 75},
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -138,6 +138,30 @@ test('OpenAIModel emits reasoning_delta and final reasoning block', async () =>
|
|
|
138
138
|
assert.deepEqual(value[0].content[1], {type: 'text', content: 'done'});
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
test('OpenAIModel yields a normalized usage delta from the final response', async () => {
|
|
142
|
+
const m = new OpenAIModel();
|
|
143
|
+
const stream = openAiResponsesStream(
|
|
144
|
+
[{type: 'response.output_text.delta', delta: 'Hi'}],
|
|
145
|
+
{
|
|
146
|
+
output: [{type: 'message', content: [{text: 'Hi'}]}],
|
|
147
|
+
usage: {
|
|
148
|
+
input_tokens: 1200,
|
|
149
|
+
input_tokens_details: {cached_tokens: 1000},
|
|
150
|
+
output_tokens: 40,
|
|
151
|
+
total_tokens: 1240,
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
installFakeOpenAi(m, stream);
|
|
156
|
+
|
|
157
|
+
const {deltas} = await drain(m.generate(buildModelDef(), fakeThread()));
|
|
158
|
+
|
|
159
|
+
assert.deepEqual(deltas[deltas.length - 1], {
|
|
160
|
+
type: 'usage',
|
|
161
|
+
content: {input: 1200, output: 40, cached: 1000, context: 1240},
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
141
165
|
test('OpenAIModel emits image delta and final image block', async () => {
|
|
142
166
|
const m = new OpenAIModel();
|
|
143
167
|
const imageItem = {
|