diff --git a/.github/workflows/engine-client.yml b/.github/workflows/engine-client.yml
index 7b956fe41e..01c2679eac 100644
--- a/.github/workflows/engine-client.yml
+++ b/.github/workflows/engine-client.yml
@@ -13,12 +13,9 @@ permissions:
jobs:
check:
- name: engine-client (${{ matrix.os }})
- runs-on: ${{ matrix.os }}
- strategy:
- fail-fast: false
- matrix:
- os: [ubuntu-latest, windows-latest, macos-latest]
+ # Linux only: the crate has no OS-specific code paths worth a per-OS leg on every PR.
+ name: engine-client
+ runs-on: ubuntu-latest
defaults:
run:
working-directory: crates/engine-client
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 3966ae5f1a..b259ed458e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,6 +1,9 @@
name: test
on:
+ # Pull requests run ONE fast linux path: typecheck, core unit tests, the HttpApi gates.
+ # The long engine suite and the Windows legs run only by hand (Actions -> test -> Run
+ # workflow), where `changes` always reports heavy=true and every job below runs.
workflow_dispatch:
# Pull requests only. A merge to `develop` or `main` is a pull request that already
# passed, so running again on push would repeat the same checks.
@@ -18,64 +21,89 @@ permissions:
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
-# The engine suite is sharded across JOBS rather than parallelised across workers inside
-# one. The suite contains tests whose assertion is "this finished within N milliseconds",
-# so their budget is really a statement about machine load: `bun test --parallel` passed on
-# a 16-core idle host and failed in CI, where four workers sharing a runner's cores pushed
-# a 3s wait past a 5s ceiling. A shard gets a runner to itself, so per-test wall clock stays
-# what it was serially and only the total divides. See docs/TEST-PARALLELISM.md.
+# Required checks on develop and main: `typecheck`, `core (linux)`, `httpapi (linux)`,
+# `unit (linux)`. Keep those job names exactly. A job skipped by `if:` reports as passing,
+# so a docs-only or version-only pull request satisfies them without running anything.
#
-# `unit (linux)` is an aggregator that does no work. It exists so the required status check
-# keeps a stable name while the jobs underneath it change shape — renaming a required check
-# in the same pull request that introduces it is a deadlock, because the check the rule
-# demands never reports.
+# The engine suite is sharded across JOBS rather than parallelised across workers inside
+# one; see docs/TEST-PARALLELISM.md. It takes ~10 runner-minutes, so it is dispatch-only.
jobs:
- engine:
- name: engine (linux ${{ matrix.shard }}/4)
+ changes:
+ name: changes
runs-on: ubuntu-latest
- timeout-minutes: 20
- strategy:
- # Every shard reports. Cancelling siblings on the first failure hides how much else
- # is broken, which is the thing you want to know from a red run.
- fail-fast: false
- matrix:
- shard: [1, 2, 3, 4]
- defaults:
- run:
- shell: bash
+ outputs:
+ heavy: ${{ steps.diff.outputs.heavy }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
-
- - name: Setup Node
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
- node-version: "24"
+ # The pull request merge commit and its first parent (the base) are enough for a
+ # two-dot diff; no merge base lookup is needed.
+ fetch-depth: 2
- - name: Setup Bun
- uses: ./.github/actions/setup-bun
-
- # The ripgrep tests resolve `rg` from PATH, then a cache dir, and only then download
- # it from github.com. Relying on that download made tests hang to the harness timeout
- # with nothing in the log naming the cause, so it is provisioned up front.
- - name: Provision ripgrep
+ - name: Docs-only or version-only?
+ id: diff
run: |
- sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep
- rg --version | head -1
+ set -euo pipefail
+ if [ "${{ github.event_name }}" != "pull_request" ]; then
+ echo "heavy=true" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+
+ heavy=false
+ while IFS= read -r f; do
+ [ -z "$f" ] && continue
+ case "$f" in
+ # Docs.
+ *.md|docs/*|LICENSE*|CHANGELOG*) continue ;;
+ # Whole-file version markers.
+ VERSION|REDROB_VERSION|UPSTREAM_VERSION|*-version.txt|*/VERSION) continue ;;
+ # Manifests and lockfiles: allowed only if every changed line is a version string.
+ package.json|*/package.json|bun.lock|Cargo.toml|*/Cargo.toml|Cargo.lock|*/Cargo.lock)
+ if git diff -U0 HEAD^1 HEAD -- "$f" \
+ | grep -E '^[+-]' | grep -vE '^(\+\+\+|---) ' \
+ | grep -vE '^[+-][[:space:]]*("version":[[:space:]]*"[^"]*",?|version[[:space:]]*=[[:space:]]*"[^"]*")[[:space:]]*$' >/dev/null; then
+ echo "non-version change in $f"
+ heavy=true
+ fi
+ ;;
+ *)
+ echo "code change: $f"
+ heavy=true
+ ;;
+ esac
+ done < <(git diff --name-only HEAD^1 HEAD)
+
+ echo "heavy=$heavy"
+ echo "heavy=$heavy" >> "$GITHUB_OUTPUT"
+
+ typecheck:
+ name: typecheck
+ needs: changes
+ if: needs.changes.outputs.heavy == 'true'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- - name: Configure git identity
- run: |
- git config --global user.email "44862514+savagemanage@users.noreply.github.com"
- git config --global user.name "redrob"
+ - name: Setup Bun
+ uses: ./.github/actions/setup-bun
- - name: Run engine tests
- run: ./scripts/test-shard.sh packages/redrob ${{ matrix.shard }} 4
- env:
- REDROB_EXPERIMENTAL_DISABLE_FILEWATCHER: "false"
+ - name: Run typecheck
+ # Bound turbo's fan-out. tsgo peaks at ~5.3 GB on packages/redrob and
+ # ~2.4 GB each on core and tui, so turbo's default concurrency of 10
+ # co-schedules them and the 16 GB ubuntu-latest runner OOM-kills the
+ # workers (exit 137 on client/protocol/tui/redrob). tsgo is already
+ # internally parallel across the 4 vCPUs, so 2 tasks at a time costs no
+ # wall time: all 30 packages finish in ~43s at either 2 or 3, and 2
+ # keeps combined tsgo RSS near 6 GB.
+ run: bun turbo typecheck --concurrency=2
core:
name: core (linux)
+ needs: changes
+ if: needs.changes.outputs.heavy == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
@@ -103,8 +131,7 @@ jobs:
git config --global user.email "44862514+savagemanage@users.noreply.github.com"
git config --global user.name "redrob"
- # Not sharded: 933 tests in ~22s. Splitting it would cost more in runner setup than
- # it saves.
+ # Not sharded: 933 tests in ~22s.
- name: Run core tests
working-directory: packages/core
run: bun test --timeout 30000 --only-failures
@@ -121,6 +148,8 @@ jobs:
httpapi:
name: httpapi (linux)
+ needs: changes
+ if: needs.changes.outputs.heavy == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
@@ -148,56 +177,91 @@ jobs:
git config --global user.email "44862514+savagemanage@users.noreply.github.com"
git config --global user.name "redrob"
- # Its own job rather than a step after the tests: ~1m38s that has no reason to sit
- # behind anything else.
- name: Run HttpApi exerciser gates
working-directory: packages/redrob
run: bun run test:httpapi
+ # Dispatch only: ~10 runner-minutes across four shards.
+ engine:
+ name: engine (linux ${{ matrix.shard }}/4)
+ if: github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ strategy:
+ # Every shard reports. Cancelling siblings on the first failure hides how much else
+ # is broken, which is the thing you want to know from a red run.
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3, 4]
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+
+ - name: Setup Node
+ uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
+ with:
+ node-version: "24"
+
+ - name: Setup Bun
+ uses: ./.github/actions/setup-bun
+
+ # The ripgrep tests resolve `rg` from PATH, then a cache dir, and only then download
+ # it from github.com. Relying on that download made tests hang to the harness timeout
+ # with nothing in the log naming the cause, so it is provisioned up front.
+ - name: Provision ripgrep
+ run: |
+ sudo apt-get update -qq && sudo apt-get install -y -qq ripgrep
+ rg --version | head -1
+
+ - name: Configure git identity
+ run: |
+ git config --global user.email "44862514+savagemanage@users.noreply.github.com"
+ git config --global user.name "redrob"
+
+ - name: Run engine tests
+ run: ./scripts/test-shard.sh packages/redrob ${{ matrix.shard }} 4
+ env:
+ REDROB_EXPERIMENTAL_DISABLE_FILEWATCHER: "false"
+
+ # `unit (linux)` is an aggregator that does no work. It exists so the required status check
+ # keeps a stable name while the jobs underneath it change shape.
unit:
name: unit (linux)
- needs: [engine, core, httpapi]
+ needs: [changes, typecheck, core, httpapi, engine]
# `always()` so a failing dependency still reports here rather than leaving the required
- # check permanently "expected" — a skipped required check never resolves and blocks the
- # pull request with no visible failure to fix.
+ # check permanently "expected". Skipped dependencies (docs/version-only pull requests, and
+ # the dispatch-only engine shards on a pull request) count as passing.
if: always()
runs-on: ubuntu-latest
steps:
- name: Report
run: |
set -euo pipefail
- printf 'engine %s\ncore %s\nhttpapi %s\n' \
- "${{ needs.engine.result }}" "${{ needs.core.result }}" "${{ needs.httpapi.result }}"
- # A matrix dependency reports one aggregated result, so a single red shard makes
- # `needs.engine.result` a failure.
- for result in "${{ needs.engine.result }}" "${{ needs.core.result }}" "${{ needs.httpapi.result }}"; do
- if [ "$result" != "success" ]; then
- echo "::error::a linux test job did not succeed"
- exit 1
- fi
+ printf 'changes %s\ntypecheck %s\ncore %s\nhttpapi %s\nengine %s\n' \
+ "${{ needs.changes.result }}" "${{ needs.typecheck.result }}" "${{ needs.core.result }}" \
+ "${{ needs.httpapi.result }}" "${{ needs.engine.result }}"
+ for result in "${{ needs.changes.result }}" "${{ needs.typecheck.result }}" "${{ needs.core.result }}" \
+ "${{ needs.httpapi.result }}" "${{ needs.engine.result }}"; do
+ case "$result" in
+ success|skipped) ;;
+ *) echo "::error::a linux test job did not succeed"; exit 1 ;;
+ esac
done
- echo "all linux test jobs succeeded"
+ echo "all linux test jobs succeeded or were skipped"
+ # Dispatch only, advisory. The engine suite takes ~15 minutes across four Windows shards.
windows:
name: unit (windows ${{ matrix.shard }}/4)
+ if: github.event_name == 'workflow_dispatch'
runs-on: windows-latest
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
- # Windows runs and reports, but does not block. Two path-normalization tests were failing
- # here for a real reason — a drive-less rooted path resolved against the runner's D:
- # workspace instead of the C: temp directory — and that is fixed; whether anything else is
- # Windows-specific is not yet established, so this stays advisory until it has been green
- # for a while.
- #
- # Sharded now, which the earlier diagnosis said not to do. That diagnosis was wrong: the
- # job was not hanging after its tests. Turbo buffers each task's output until the task
- # ends, so `core` flushing at 88s and `redrob` flushing 14 minutes later read as a stall.
- # The engine suite genuinely takes 906s here against 381s on Linux, which is what nearly
- # hit the 20-minute limit. Splitting it is the fix, and it is the same mechanism already
- # proven on Linux.
continue-on-error: true
defaults:
run:
@@ -231,6 +295,7 @@ jobs:
windows-core:
name: unit (windows core)
+ if: github.event_name == 'workflow_dispatch'
runs-on: windows-latest
timeout-minutes: 25
continue-on-error: true
diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
deleted file mode 100644
index 813b3068e5..0000000000
--- a/.github/workflows/typecheck.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-name: typecheck
-
-on:
- workflow_dispatch:
- pull_request:
-
-jobs:
- typecheck:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout repository
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
-
- - name: Setup Bun
- uses: ./.github/actions/setup-bun
-
- - name: Run typecheck
- # Bound turbo's fan-out. tsgo peaks at ~5.3 GB on packages/redrob and
- # ~2.4 GB each on core and tui, so turbo's default concurrency of 10
- # co-schedules them and the 16 GB ubuntu-latest runner OOM-kills the
- # workers (exit 137 on client/protocol/tui/redrob). tsgo is already
- # internally parallel across the 4 vCPUs, so 2 tasks at a time costs no
- # wall time: all 30 packages finish in ~43s at either 2 or 3, and 2
- # keeps combined tsgo RSS near 6 GB.
- run: bun turbo typecheck --concurrency=2
diff --git a/docs/LOCAL-ENGINE-API.md b/docs/LOCAL-ENGINE-API.md
index 1e2a9471c5..a02626e022 100644
--- a/docs/LOCAL-ENGINE-API.md
+++ b/docs/LOCAL-ENGINE-API.md
@@ -91,7 +91,8 @@ keywords:
so `홈택스` matches `홈택스에서`.
- **urls** match `redrob_context.page_url`. The scheme is ignored, `*` stays inside one
path segment, `**` crosses segments, and a trailing `/*` in `urls:` covers the whole
- site including its root.
+ site including its root. In agent sessions the same matcher reads the optional
+ `pageUrl` field of the session prompt body.
- Armed bodies go in as one extra `system` message after the caller's own system
messages, labelled with what armed each one. A skill with neither list never arms
and behaves as before; in agent sessions an armable skill is left out of the standing
diff --git a/packages/docs/favicon.svg b/packages/docs/favicon.svg
index 157edc4d75..131fb00ed5 100644
--- a/packages/docs/favicon.svg
+++ b/packages/docs/favicon.svg
@@ -1,7 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/packages/identity/mark-192x192.png b/packages/identity/mark-192x192.png
index 071d18fe0c..1d7ed05ea6 100644
Binary files a/packages/identity/mark-192x192.png and b/packages/identity/mark-192x192.png differ
diff --git a/packages/identity/mark-512x512-light.png b/packages/identity/mark-512x512-light.png
index 9f602d5ec3..b2933d97ba 100644
Binary files a/packages/identity/mark-512x512-light.png and b/packages/identity/mark-512x512-light.png differ
diff --git a/packages/identity/mark-512x512.png b/packages/identity/mark-512x512.png
index 48f38fc8c2..3176ea8593 100644
Binary files a/packages/identity/mark-512x512.png and b/packages/identity/mark-512x512.png differ
diff --git a/packages/identity/mark-96x96.png b/packages/identity/mark-96x96.png
index b635c0759c..734642ffe3 100644
Binary files a/packages/identity/mark-96x96.png and b/packages/identity/mark-96x96.png differ
diff --git a/packages/identity/mark-light.svg b/packages/identity/mark-light.svg
index ac619f1b2f..de4d9218d3 100644
--- a/packages/identity/mark-light.svg
+++ b/packages/identity/mark-light.svg
@@ -1,5 +1 @@
-
-
-
-
-
+
\ No newline at end of file
diff --git a/packages/identity/mark.svg b/packages/identity/mark.svg
index 157edc4d75..131fb00ed5 100644
--- a/packages/identity/mark.svg
+++ b/packages/identity/mark.svg
@@ -1,7 +1 @@
-
-
-
-
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/packages/redrob/src/session/prompt.ts b/packages/redrob/src/session/prompt.ts
index 2eb8313f96..86a7c23811 100644
--- a/packages/redrob/src/session/prompt.ts
+++ b/packages/redrob/src/session/prompt.ts
@@ -698,6 +698,7 @@ const layer = Layer.effect(
},
system: input.system,
format: input.format,
+ ...(input.pageUrl?.trim() ? { pageUrl: input.pageUrl.trim() } : {}),
}
const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie)
@@ -1300,18 +1301,18 @@ const layer = Layer.effect(
* otherwise arm on half of what the user said.
*
* The URL is deliberately NOT fetched. Arming by URL glob needs the active
- * tab, and the only way to get it is a browser request -- a round trip on
- * every single message, which with no extension attached spends the request
- * deadline before the model sees anything. A twenty second pause before each
- * reply is a worse product than URL arming is a better one. Keywords arm
- * today; the URL half arms once a session carries its tab URL as state the
- * engine already holds, rather than as a question it has to ask.
+ * tab, and asking the browser for it is a round trip on every single message,
+ * which with no extension attached spends the request deadline before the
+ * model sees anything. Instead the caller states it: `pageUrl` on the prompt
+ * is stored on the user message, and arms through the same matcher the
+ * chat-completions route runs on `redrob_context.page_url`.
*/
const armedSkills = yield* sys.armedSkills(agent, {
prompt: (lastUserMsg?.parts ?? [])
.filter((part) => part.type === "text")
.map((part) => (part.type === "text" ? part.text : ""))
.join("\n"),
+ url: lastUser.pageUrl,
})
const system = [
...env,
@@ -1563,6 +1564,10 @@ export const PromptInput = Schema.Struct({
format: Schema.optional(SessionV1.Format),
system: Schema.optional(Schema.String),
variant: Schema.optional(Schema.String),
+ pageUrl: Schema.optional(Schema.String).annotate({
+ description:
+ "URL of the caller's active browser tab. Skills whose `urls:` globs match it arm for this turn, as on /v1/chat/completions.",
+ }),
parts: Schema.Array(
Schema.Union([
SessionV1.TextPartInput,
diff --git a/packages/schema/src/v1/session.ts b/packages/schema/src/v1/session.ts
index 17e7a2c5aa..062506c964 100644
--- a/packages/schema/src/v1/session.ts
+++ b/packages/schema/src/v1/session.ts
@@ -351,6 +351,12 @@ export const User = Schema.Struct({
}),
system: Schema.optional(Schema.String),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
+ /**
+ * The URL of the browser tab the caller was on when it sent this message. Skills whose
+ * `urls:` globs match it arm for the turn, the same matcher `/v1/chat/completions` runs
+ * on `redrob_context.page_url`. Absent for callers with no tab.
+ */
+ pageUrl: Schema.optional(Schema.String),
}).annotate({ identifier: "UserMessage" })
export type User = Types.DeepMutable>
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 08c950a1db..1d2609c0bb 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -3871,6 +3871,7 @@ export class Session2 extends HeyApiClient {
format?: OutputFormat
system?: string
variant?: string
+ pageUrl?: string
parts?: Array
},
options?: Options,
@@ -3891,6 +3892,7 @@ export class Session2 extends HeyApiClient {
{ in: "body", key: "format" },
{ in: "body", key: "system" },
{ in: "body", key: "variant" },
+ { in: "body", key: "pageUrl" },
{ in: "body", key: "parts" },
],
},
@@ -4224,6 +4226,7 @@ export class Session2 extends HeyApiClient {
format?: OutputFormat
system?: string
variant?: string
+ pageUrl?: string
parts?: Array
},
options?: Options,
@@ -4244,6 +4247,7 @@ export class Session2 extends HeyApiClient {
{ in: "body", key: "format" },
{ in: "body", key: "system" },
{ in: "body", key: "variant" },
+ { in: "body", key: "pageUrl" },
{ in: "body", key: "parts" },
],
},
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 91f487c01a..5a62723601 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -310,6 +310,7 @@ export type UserMessage = {
tools?: {
[key: string]: boolean
}
+ pageUrl?: string
}
export type ProviderAuthError = {
@@ -10384,6 +10385,7 @@ export type SessionPromptData = {
format?: OutputFormat
system?: string
variant?: string
+ pageUrl?: string
parts: Array
}
path: {
@@ -10731,6 +10733,7 @@ export type SessionPromptAsyncData = {
format?: OutputFormat
system?: string
variant?: string
+ pageUrl?: string
parts: Array
}
path: {
diff --git a/packages/server/src/handlers/chat-completion.ts b/packages/server/src/handlers/chat-completion.ts
index 8d3570005f..a2637170b3 100644
--- a/packages/server/src/handlers/chat-completion.ts
+++ b/packages/server/src/handlers/chat-completion.ts
@@ -342,20 +342,27 @@ function toJsonBody(id: string, model: string, response: LLMResponse): unknown {
finish_reason: toFinishReason(response.finishReason),
},
],
- ...(usage
- ? {
- usage: {
- prompt_tokens: usage.inputTokens ?? 0,
- completion_tokens: usage.outputTokens ?? 0,
- total_tokens: usage.totalTokens ?? 0,
- },
- }
- : {}),
+ ...(usage ? { usage: toWireUsage(usage) } : {}),
+ }
+}
+
+/** The engine's usage, in OpenAI's `usage` shape. One mapping for the JSON body and the stream. */
+function toWireUsage(usage: NonNullable) {
+ return {
+ prompt_tokens: usage.inputTokens ?? 0,
+ completion_tokens: usage.outputTokens ?? 0,
+ total_tokens: usage.totalTokens ?? 0,
}
}
/** One SSE frame carrying an OpenAI chunk. */
-function chunkFrame(id: string, model: string, delta: unknown, finishReason: string | null): Sse.Event {
+function chunkFrame(
+ id: string,
+ model: string,
+ delta: unknown,
+ finishReason: string | null,
+ usage?: ReturnType,
+): Sse.Event {
return {
_tag: "Event",
event: "message",
@@ -366,6 +373,7 @@ function chunkFrame(id: string, model: string, delta: unknown, finishReason: str
created: seconds(),
model,
choices: [{ index: 0, delta, finish_reason: finishReason }],
+ ...(usage ? { usage } : {}),
}),
}
}
@@ -386,13 +394,25 @@ function doneFrame(): Sse.Event {
* caller would read a truncated answer as a complete one.
*/
function toChunks(id: string, model: string, events: Stream.Stream) {
+ // Usage rides on the finish frame, as OpenAI's own final chunk carries it. A provider that
+ // reports it only per step still gets counted: the last step's usage stands in. Without this the
+ // streaming path dropped usage entirely, and the browser's memory runs recorded 0 tokens.
+ let stepUsage: LLMResponse["usage"]
return events.pipe(
- Stream.flatMap((event: LLMEvent) => Stream.fromIterable(eventFrames(id, model, event))),
+ Stream.flatMap((event: LLMEvent) => {
+ if (event.type === "step-finish" && event.usage !== undefined) stepUsage = event.usage
+ return Stream.fromIterable(eventFrames(id, model, event, stepUsage))
+ }),
Stream.concat(Stream.make(doneFrame())),
)
}
-function eventFrames(id: string, model: string, event: LLMEvent): ReadonlyArray {
+function eventFrames(
+ id: string,
+ model: string,
+ event: LLMEvent,
+ stepUsage?: LLMResponse["usage"],
+): ReadonlyArray {
switch (event.type) {
case "text-delta":
return [chunkFrame(id, model, { content: event.text }, null)]
@@ -402,8 +422,10 @@ function eventFrames(id: string, model: string, event: LLMEvent): ReadonlyArray<
const [call] = toWireToolCalls([{ id: event.id, name: event.name, input: event.input }])
return [chunkFrame(id, model, { tool_calls: [{ index: 0, ...call }] }, null)]
}
- case "finish":
- return [chunkFrame(id, model, {}, toFinishReason(event.reason))]
+ case "finish": {
+ const usage = event.usage ?? stepUsage
+ return [chunkFrame(id, model, {}, toFinishReason(event.reason), usage ? toWireUsage(usage) : undefined)]
+ }
case "provider-error":
// Surfaced as data, not as a stream failure: headers are already sent, so
// this is the only way the caller learns the turn broke.