Compare commits
559 Commits
d0a26d4c5d
...
365b8c34fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 365b8c34fd | |||
| 4361385946 | |||
| 9ccbdda27c | |||
| 9a0ef7c799 | |||
| 732d6a57b7 | |||
| 3b90f26dea | |||
| 6877447616 | |||
| b808811843 | |||
| 838ccbb65f | |||
| 4d080ca985 | |||
| b1d8f7f181 | |||
| 134d0ce2a1 | |||
| 2820cbbe53 | |||
| 5c6df298aa | |||
| ae196c2a87 | |||
| f56793589f | |||
| 33884bd0ce | |||
| 2ba35cca23 | |||
| 860767a143 | |||
| d65cfe146d | |||
| 1babd021b0 | |||
| bdabe789e3 | |||
| 48c4c9b56b | |||
| b1fb3ec0fa | |||
| b3c739867e | |||
| 5ae886ea99 | |||
| 2c67f99054 | |||
| 67b5d6354c | |||
| cdc42e5a86 | |||
| e49817c2d5 | |||
| 9405ffc633 | |||
| 77e2ad0c40 | |||
| a2771180cc | |||
| 2cfc3b63c2 | |||
| b2e53f2f61 | |||
| b22040ac84 | |||
| 80a4f90004 | |||
| 0b582faebc | |||
| d084923878 | |||
| df3373c3f2 | |||
| a3e852c6b3 | |||
| b25f4c7468 | |||
| 39d40d391b | |||
| f87cf5bd00 | |||
| 9f5e27f3fd | |||
| c101b42619 | |||
| f56ef010a8 | |||
| 8095c86be2 | |||
| 99797b9e40 | |||
| 1ac197fc6c | |||
| 3ff78c03af | |||
| 156a55d1d1 | |||
| 597c6fc3e9 | |||
| a70fe65ed5 | |||
| fa225eb01d | |||
| 8e21e2f3f2 | |||
| f51f17cf93 | |||
| 7e4d90fc1b | |||
| 235ddba9c5 | |||
| fe6f5eb326 | |||
| 06da8c5b00 | |||
| 87b2e8eb16 | |||
| 2f3adc3d14 | |||
| 21ec057de0 | |||
| dd571a963e | |||
| afd65442c5 | |||
| a4358eed14 | |||
| 9685bfffba | |||
| 3a734c30bf | |||
| 6e8aa92e38 | |||
| 811a449c28 | |||
| 0fd995c85e | |||
| e0d7468ebb | |||
| 07dc185032 | |||
| efb0ac7da3 | |||
| e7b0a0b20f | |||
| 5508299e76 | |||
| 59e4aac7f7 | |||
| 6485632a4c | |||
| 8d6b47bef1 | |||
| 6046842242 | |||
| 1c8b349e01 | |||
| d70c10b782 | |||
| 70c0548190 | |||
| 6acaccccf7 | |||
| b9dd0ba0d0 | |||
| 23e218abaa | |||
| 55dedd173c | |||
| df629b4dc6 | |||
| 7c573f36e2 | |||
| ca869195dc | |||
| e6fa660a5f | |||
| f7a3b0adf1 | |||
| ea9e924d35 | |||
| 3b582a4f73 | |||
| 2a721e3776 | |||
| 61347362d1 | |||
| e2688da828 | |||
| a0e544e3e4 | |||
| 96fd9574a2 | |||
| ab4611001e | |||
| 5b20d21ea0 | |||
| 48625f5077 | |||
| fbe8846393 | |||
| 8ae3849cc8 | |||
| e29861f787 | |||
| fa00c1f188 | |||
| 879434e240 | |||
| 4b263f8743 | |||
| 7315114b20 | |||
| f14c8cb614 | |||
| da5d789897 | |||
| 802cbf2f45 | |||
| 18c30c5f90 | |||
| dfec60438e | |||
| 6a5b8ed152 | |||
| baaec0c77f | |||
| fdd2f16df0 | |||
| 3e7a15a2b5 | |||
| b5219dc862 | |||
| c1173dd8a1 | |||
| f03e84a62a | |||
| e80a3fbf8e | |||
| 8947a89e7b | |||
| f46cdd6dbc | |||
| 1a5b5331d6 | |||
| 530027c62b | |||
| 8e7126d177 | |||
| 3fe4a6bc14 | |||
| 12a4ba5edf | |||
| d3b78234c2 | |||
| baf7403c8c | |||
| 5955695db8 | |||
| bacba69d31 | |||
| 08dc6b29f8 | |||
| d08ea1734e | |||
| ec5b891fec | |||
| 5830bb9c85 | |||
| 16ef135f1f | |||
| 15f514dfe2 | |||
| fbd97c3546 | |||
| bb4205b531 | |||
| 077efee13b | |||
| b5d5c03412 | |||
| bee41379fa | |||
| 842e7a3c58 | |||
| e8c16be475 | |||
| 58f54b99f3 | |||
| 5aea9730c6 | |||
| a63f076856 | |||
| ac1d8b1c7d | |||
| d5fcbc2125 | |||
| 45db480b0b | |||
| 4b8aee909b | |||
| 903cfa3060 | |||
| 27a1d07e98 | |||
| 9bfbb2fb4c | |||
| 1a9bb30824 | |||
| 0440d5c6dc | |||
| 01200a0d33 | |||
| d3b7663d41 | |||
| b204909c4c | |||
| 9a89d2419a | |||
| af6427ff67 | |||
| 4c8596db38 | |||
| c779768b6e | |||
| 49b78612d6 | |||
| ce6085b5f4 | |||
| 1b83b2c40a | |||
| 9d04008123 | |||
| 4ebc2c96b3 | |||
| bb6f7e2022 | |||
| 86b48a9fdf | |||
| 59067bd115 | |||
| 6116d72570 | |||
| 8e8c0887de | |||
| 3143353ddc | |||
| f35d99900f | |||
| 6e7494553b | |||
| 904ea6e326 | |||
| b6b158a244 | |||
| e32b210d50 | |||
| a02f34437c | |||
| 1ef094f039 | |||
| e57e23b999 | |||
| 13feb36518 | |||
| 9e4bdf315f | |||
| 068a975488 | |||
| 3d23c4ed40 | |||
| d2149d11d3 | |||
| ada2988105 | |||
| d6cfea463a | |||
| 43330cf624 | |||
| 21a78fb19e | |||
| 0ae6592032 | |||
| 0e1539fefa | |||
| dff72e291b | |||
| c6a9007b58 | |||
| d1c7297f87 | |||
| 3c4a34b13b | |||
| 076cf9af18 | |||
| 2f5f5b8a26 | |||
| a363546a14 | |||
| 599b24fa9e | |||
| 4bdbac6597 | |||
| 20a6748cdd | |||
| 1271d13f26 | |||
| 5882341b21 | |||
| 19730ba7c0 | |||
| 7a76276539 | |||
| 64d12f2a6f | |||
| 668bde46f4 | |||
| 3647614ab0 | |||
| 5a2e69b2bf | |||
| 1d53929250 | |||
| 91a0a935b0 | |||
| bd46491b04 | |||
| 18b0f8b19f | |||
| f5d69504b5 | |||
| 76e1287cbe | |||
| eb791f9e80 | |||
| a1b9c865df | |||
| 985931d6fa | |||
| d35c9f40a7 | |||
| 4d6d5b631c | |||
| 3354c41e66 | |||
| 73d1c05edc | |||
| 9e615a41f0 | |||
| da4f4cc954 | |||
| 01d38f042c | |||
| 9b99f50264 | |||
| 646b47b40f | |||
| 5cf8eb94c7 | |||
| 4d61d044ec | |||
| 967e57c933 | |||
| acfe073b29 | |||
| 0b79e0ed65 | |||
| c8871ec4fe | |||
| 3fece8749b | |||
| cac1f4d4fe | |||
| e664def920 | |||
| f0a1f98912 | |||
| 5ca771ded4 | |||
| 9b15135416 | |||
| b6f99b7651 | |||
| 13c05b1083 | |||
| 05da79f966 | |||
| 92cee690f8 | |||
| 6f0ec92f91 | |||
| 32ed5a812c | |||
| 856a0a2432 | |||
| ced26b952e | |||
| e451b07783 | |||
| f6600feab5 | |||
| 553d67a910 | |||
| 805be47128 | |||
| aa9409869e | |||
| 8ebdd47fbb | |||
| ec1eccd10d | |||
| 42127554d4 | |||
| 9dbfd15687 | |||
| 6c31264377 | |||
| b6b4168503 | |||
| 40cde699a8 | |||
| 1ed45032be | |||
| 64814c2e15 | |||
| 96daebff30 | |||
| 85fe1a094c | |||
| 68249b8072 | |||
| 98018972aa | |||
| 5b1324a630 | |||
| 4e352bb9ff | |||
| 5c8d00e49b | |||
| 94bb8804f4 | |||
| 30023349b9 | |||
| b0e6ab16b1 | |||
| 6e6be6f3ff | |||
| eb9bd84b05 | |||
| 17a7744da1 | |||
| a3082072d7 | |||
| 04a471b669 | |||
| 3266ddb2d4 | |||
| 7527b55de4 | |||
| c57d4be413 | |||
| 344dca6ffa | |||
| 93fe2eb0ff | |||
| 09e465d583 | |||
| 4eb73fa552 | |||
| 2d59ddd228 | |||
| 39882263d3 | |||
| c2caaa21a0 | |||
| 20097e8296 | |||
| 185db7f8cd | |||
| 8870af800f | |||
| 56f9bab7b7 | |||
| 194d29723e | |||
| a22cb479f4 | |||
| 5efe0e4910 | |||
| 6168e3f924 | |||
| 9b676238a2 | |||
| 8df34a1d64 | |||
| 45ef661651 | |||
| 2d8767f940 | |||
| 8f7a023897 | |||
| 302a1a7f58 | |||
| 284d07b569 | |||
| 5fbb9c47dd | |||
| f18cf7c172 | |||
| cae0c1ea2f | |||
| ada1fe6c63 | |||
| fde55c96d4 | |||
| 05c2605aae | |||
| d1a9b622d4 | |||
| a87be4cbc2 | |||
| 30bb096513 | |||
| e0261591b6 | |||
| eb054b3e88 | |||
| 1be6d34010 | |||
| eb0d0433a1 | |||
| 557d5da391 | |||
| 3f987e9885 | |||
| a86f69fd8d | |||
| cae18a4339 | |||
| 69a6f63023 | |||
| 1236c68073 | |||
| d64d1b2ae8 | |||
| 159ffb0c6d | |||
| 97a1c10ef7 | |||
| eeb570c71f | |||
| 9be7caae99 | |||
| 0e7be01807 | |||
| 35c8ee3a73 | |||
| c79c54ba9d | |||
| f1d8f42fd5 | |||
| 14862fbc37 | |||
| ef3f0a8a78 | |||
| 2ef397b562 | |||
| bebe1169c8 | |||
| ba5b8db9cf | |||
| 189ee43a0c | |||
| 6bf1f9a110 | |||
| 8307ca965c | |||
| e97f803104 | |||
| c4bc994cab | |||
| 6d84d4df19 | |||
| ac4133ddf9 | |||
| 6d15d1e2b6 | |||
| ffda357218 | |||
| 09eb29b0b7 | |||
| 300234df57 | |||
| 7e938b2d3b | |||
| 0e98d67a5f | |||
| 31eeded4a6 | |||
| ca27d88869 | |||
| 38efe82544 | |||
| 31a1c1d879 | |||
| e21f43c70a | |||
| e058dc576d | |||
| a05d7533b0 | |||
| 621acbe224 | |||
| e259ab7bd3 | |||
| 1f3ad13c83 | |||
| 2c9db5a27b | |||
| dcc71e3a14 | |||
| 426d477584 | |||
| 09d56272d8 | |||
| de6b8faf55 | |||
| bb2a6013fa | |||
| 709b17d309 | |||
| f74716c2e4 | |||
| f6fe978db4 | |||
| 99d6a4cf4b | |||
| 9782323885 | |||
| 28c2b0eb1c | |||
| 437fe9fe85 | |||
| c647cac983 | |||
| e2d6f00d6d | |||
| 40d19ca702 | |||
| e304b17a7e | |||
| ca0b772242 | |||
| 3962db4d37 | |||
| 5ea99673fc | |||
| dad75b592e | |||
| d1be97fbc2 | |||
| f2b364ec0d | |||
| f1ba5b5686 | |||
| ce7153f6e8 | |||
| 04ad20e760 | |||
| fc2c6bc81c | |||
| 31d5de1a37 | |||
| cfd1879f7e | |||
| eed3f13e51 | |||
| a9d30e1c37 | |||
| 11bd486740 | |||
| fd88c72e2e | |||
| 2ef4f26a8f | |||
| cb3642d12c | |||
| e4d7cc1924 | |||
| c4e1a969c1 | |||
| 2e38a24ac2 | |||
| 8114d3c4fd | |||
| cabf9c967c | |||
| 1c98938b6f | |||
| 5fa3d140ab | |||
| 7d23cff0a9 | |||
| 5246b3ce92 | |||
| 45ede7a6fc | |||
| f8fe6f83aa | |||
| 9998539e71 | |||
| 29ea180b18 | |||
| ee60758138 | |||
| db9faa0fad | |||
| 325ae6fa27 | |||
| d0a1eaeb57 | |||
| 56c6758da5 | |||
| 30abefe747 | |||
| 2ed4bd007b | |||
| 5ebdeff76d | |||
| d80d06ff2e | |||
| f43d8fba3b | |||
| 0a676524ae | |||
| fd89c754f1 | |||
| 2722e0b7ba | |||
| e0c4dbdc73 | |||
| e44d49e80f | |||
| 123fc3b0ad | |||
| 89c2c701fd | |||
| ce6198102f | |||
| c75d777cec | |||
| 1b1dc73d7f | |||
| a730717fc7 | |||
| 45b1e7b6de | |||
| a86c22e6f5 | |||
| 6146b2806f | |||
| c68cd64882 | |||
| c492765d1a | |||
| 7ce77f0ad5 | |||
| 3717569533 | |||
| 676137c246 | |||
| 84fedd8048 | |||
| 9bf6378041 | |||
| d4055fb19d | |||
| 3b2bdcb19a | |||
| ee694b310f | |||
| e513825da9 | |||
| d37347fe68 | |||
| 225e1bf58e | |||
| b7b315cd39 | |||
| 13c9923486 | |||
| 1aa992d07e | |||
| 6c6eb0dcb6 | |||
| 24ade197d1 | |||
| 74a45f86b9 | |||
| 5aea67ff5e | |||
| 230936274b | |||
| e1d672e9c0 | |||
| a89701bc43 | |||
| 25df7a79c1 | |||
| ddd7327290 | |||
| 223d06c77e | |||
| 605e78468c | |||
| 3c510860fa | |||
| ec3bf7324b | |||
| 1b33e63ce2 | |||
| 663ec91b45 | |||
| 34d1e78b40 | |||
| 758ced5e7f | |||
| da16015768 | |||
| 967acd23ee | |||
| 68885a03d8 | |||
| 879858dc94 | |||
| ed412cb6a8 | |||
| 255e370856 | |||
| 911d3b8d6c | |||
| 4ec8f63482 | |||
| 88e29d7bbe | |||
| cc9fa2d632 | |||
| 2af7089396 | |||
| 5d63d0f6e2 | |||
| 73acfcb7f2 | |||
| e7a4b76c54 | |||
| a7b9b6fa4b | |||
| 4ba58723dc | |||
| b685fedf1a | |||
| 74ee96ef82 | |||
| b538c2f1ea | |||
| 84a8bd099b | |||
| 79f342ca60 | |||
| aa138e6583 | |||
| 710220c920 | |||
| 381d31a1dc | |||
| 493ed2c781 | |||
| 81e28a3c07 | |||
| 5848954ca8 | |||
| faa8eb5793 | |||
| 0c29de1b10 | |||
| c48abf062e | |||
| 38e8c66c90 | |||
| 41120cf200 | |||
| b6ffbe4255 | |||
| 92fbd2e3f6 | |||
| 66c6edec3e | |||
| 309dba7203 | |||
| cbf728d66a | |||
| 2db2c1611c | |||
| 3c58b5dde4 | |||
| a0a9df11c0 | |||
| 7ec6e88605 | |||
| 2e004161e4 | |||
| 5a995cf099 | |||
| 2edc2dc245 | |||
| f607a52fbb | |||
| 7fb2e4bc6c | |||
| 17d0430a4d | |||
| 22fe502d71 | |||
| 9b9e37cc84 | |||
| 5bc4a6d6d6 | |||
| 3d0d5ffe85 | |||
| a05eec42d7 | |||
| 8b120504a7 | |||
| bcc7faa0ba | |||
| 47c59a416e | |||
| cdafd5d914 | |||
| eb670bfba5 | |||
| c0d283b47d | |||
| 2c5a0edef3 | |||
| dc1a335e1c | |||
| 0e7a7b02fe | |||
| b19eb52511 | |||
| 0332d446cd | |||
| 29e1bc8253 | |||
| 8e394005b2 | |||
| 02b266dce7 | |||
| 7249a8ee6a | |||
| 9b78c51d0a | |||
| f241dafac8 | |||
| fc8ff9362e | |||
| 496038307f | |||
| 3d2a49e1e4 | |||
| 59bfd89940 | |||
| 89481c2c82 | |||
| 3883fab29d | |||
| 7d1b74fb32 | |||
| 9363c76354 | |||
| f4f398279e | |||
| 0fe05e502e | |||
| 60505f206b | |||
| 4c3f81b4fa | |||
| cff082ff3a | |||
| 66d005aa30 | |||
| e7c53bd8f5 | |||
| ac5d352f31 | |||
| 4fe77b8034 | |||
| f66fb29f5c | |||
| 00e3ae1932 | |||
| 2d7e6bd5d6 | |||
| ed1db41319 | |||
| 865c89e553 | |||
| 490ed0ca7c |
119
.claude/agents/ticket-reviewer.md
Normal file
119
.claude/agents/ticket-reviewer.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
name: "ticket-reviewer"
|
||||
description: "Use this agent when a ticket implementation is submitted for review in this project (insomnia). The agent reviews the ticket's premises/requirements and the actual implementation, creates `tickets/<ticket>.review.md` with findings, and updates the original `tickets/<ticket>.md` with review status. Do NOT use this agent for general code review unrelated to a ticket. "
|
||||
model: opus
|
||||
color: purple
|
||||
---
|
||||
|
||||
You are a senior reviewer specialized in the `insomnia` project. You are an expert at evaluating ticket-scoped implementations against their stated premises and requirements, and at safeguarding the codebase from unnecessary complexity or architectural drift. You operate strictly within the project's ticket lifecycle conventions defined in `CLAUDE.md`.
|
||||
|
||||
## Your Core Responsibility
|
||||
|
||||
Given a ticket (normally `tickets/<name>.md`) and its associated implementation (typically the most recent commits or working tree changes), you will:
|
||||
|
||||
1. Read the ticket thoroughly to understand its **背景・前提・要件**.
|
||||
2. Inspect the implementation (diff + surrounding code, not only the diff).
|
||||
3. Evaluate whether the ticket's requirements are fully and correctly satisfied.
|
||||
4. Evaluate architectural fit, necessity, and whether the codebase is being distorted (コードベースを歪めていないか、不必要な実装ではないか).
|
||||
5. Produce `tickets/<name>.review.md` with findings and a clear judgment.
|
||||
6. Update the original `tickets/<name>.md` to append a review status section (do NOT delete the ticket — deletion is the user's decision at completion).
|
||||
|
||||
You must NEVER run `git` write operations (commit, add, push, etc.). Git is the user's responsibility (per CLAUDE.md). You only edit/create files in the working tree.
|
||||
|
||||
## Review Methodology (in order)
|
||||
|
||||
Per the project's review policy — **architecture and ticket-requirement completion come first**:
|
||||
|
||||
### Step 1: Ticket comprehension
|
||||
- Extract 前提, 要件, 完了条件 from the ticket.
|
||||
- Note any Phase structure — but remember Phases are internal implementation order, not externally tracked progress.
|
||||
- Confirm the ticket's intended scope boundary.
|
||||
|
||||
### Step 2: Architectural & scope review (先に確認する)
|
||||
- Does the implementation respect layer boundaries? (e.g., `llm-worker` stays low-level; higher-level features live in upper layers.)
|
||||
- Are new crates named without the `insomnia-` prefix, short and consistent?
|
||||
- Were dependencies added via `cargo add` (not manual edits to Cargo.toml)?
|
||||
- Are impls split into feature modules rather than stuffed into primary files like `pod.rs`?
|
||||
- Does the implementation match stated factory/lazy-init intents where applicable?
|
||||
- Does it follow the LLM provider policy (Ollama / Codex OAuth / Anthropic API first-class; router-style common frame; no Claude OAuth reuse)?
|
||||
- Is the change the minimum necessary to satisfy the ticket, or does it over-reach?
|
||||
|
||||
### Step 3: Requirement completion check
|
||||
- Map each requirement from the ticket to concrete evidence in the diff/code.
|
||||
- Flag any requirement that is unmet, partially met, or silently deferred.
|
||||
- Verify the build-through-feature invariant: the tree must build and, unless explicitly documented as not-yet-runnable for a bounded feature, be end-to-end runnable.
|
||||
|
||||
### Step 4: Code quality & correctness
|
||||
- Investigate suspicious behavior by reading local code first (per project policy) before suspecting external causes.
|
||||
- Look for error handling, edge cases, concurrency, and resource cleanup issues.
|
||||
- Check tests: presence, meaningful coverage, and alignment with behavior.
|
||||
- Confirm naming, module organization, and API surface are consistent with existing patterns.
|
||||
|
||||
### Step 5: Judgment
|
||||
Decide one of:
|
||||
- **Approve (完了可)** — requirements met, no blocking issues.
|
||||
- **Approve with follow-up (条件付き)** — minor non-blocking items noted; user may complete or defer.
|
||||
- **Request changes (要修正)** — blocking issues must be addressed.
|
||||
|
||||
## Output Artifacts
|
||||
|
||||
### A. `tickets/<name>.review.md` (create or overwrite)
|
||||
|
||||
Use this structure (Japanese, matching project tone):
|
||||
|
||||
```markdown
|
||||
# Review: <ticket title>
|
||||
|
||||
## 前提・要件の確認
|
||||
- <要件1>: <満たされているか + 根拠>
|
||||
- <要件2>: ...
|
||||
|
||||
## アーキテクチャ・スコープ
|
||||
- <観点と判断>
|
||||
|
||||
## 指摘事項
|
||||
### Blocking
|
||||
- <項目> — <理由と該当箇所 path:line>
|
||||
|
||||
### Non-blocking / Follow-up
|
||||
- <項目> — <理由>
|
||||
|
||||
### Nits
|
||||
- <項目>
|
||||
|
||||
## 判断
|
||||
<Approve / Approve with follow-up / Request changes> — <一文の理由>
|
||||
```
|
||||
|
||||
Omit empty sections. Cite concrete file paths and line ranges. Be concise; avoid restating obvious code.
|
||||
|
||||
### B. Update `tickets/<name>.md`
|
||||
|
||||
Append (or update if present) a trailing section like:
|
||||
|
||||
```markdown
|
||||
## Review
|
||||
- 状態: <Approve / Approve with follow-up / Request changes>
|
||||
- レビュー詳細: [./<name>.review.md](./<name>.review.md)
|
||||
- 日付: 2026-04-21
|
||||
```
|
||||
|
||||
Do not modify the ticket's 背景・要件 sections unless the user explicitly asked for it. Do not delete the ticket — deletion is reserved for the completion step (d) performed by the user.
|
||||
|
||||
## Operating Principles
|
||||
|
||||
- **Do not commit or stage anything.** File edits only. The user will handle git.
|
||||
- **Do not over-engineer the review.** Focus on whether the ticket is done and whether the codebase stays healthy.
|
||||
- **Prefer concrete citations** (path:line) over abstract complaints.
|
||||
- **Ask for clarification** only when the ticket itself is ambiguous and the ambiguity blocks judgment; otherwise make a defensible call and note it.
|
||||
- **Re-review mode**: if `.review.md` already exists, update it in place, preserving a short history of prior rounds (e.g., `## Round 2` section) so the evolution is visible until the ticket is closed.
|
||||
- **TODO.md is not your concern** unless a requirement explicitly demands it; ticket lifecycle edits to TODO.md are the user's.
|
||||
|
||||
## Quality Self-Check (before finishing)
|
||||
|
||||
1. Did I evaluate architectural fit before nitpicks?
|
||||
2. Did I map every ticket requirement to evidence?
|
||||
3. Are all blocking issues genuinely blocking (not stylistic)?
|
||||
4. Did I avoid making git writes?
|
||||
5. Did I update both `<name>.review.md` and `<name>.md`?
|
||||
6. Is my judgment line unambiguous?
|
||||
26
.claude/skills/worktree-workflow/SKILL.md
Normal file
26
.claude/skills/worktree-workflow/SKILL.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
---
|
||||
name: worktree-workflow
|
||||
description: "Worktreeを用いた開発フローを進める。git上の開発に置けるミクロな指示で、プロジェクトの管理に関する指示は提供されていない。"
|
||||
allowed-tools: "Bash(cd *), Bash(git worktree *), Bash(mkdir *), Bash(cp *), Bash(ln *), Bash(ls *), Bash(find *)"
|
||||
---
|
||||
|
||||
# Worktreeを用いた開発
|
||||
|
||||
Goal: 実装を完了させ、ブランチをマージ待ちの状態にする。
|
||||
|
||||
`./.worktree`にworktreeを作成します。
|
||||
エージェントの1セッション=1ワークツリーとしており、ブランチ/イシュー/チケット単位で切ります。
|
||||
|
||||
このワークフローにおいては、ブランチはローカルで並行開発するためのマージ後削除の運用とし、Worktreeと同名のbranchを同時に作って進めます。メインのディレクトリのブランチから切るものとして扱います。
|
||||
|
||||
```
|
||||
git worktree add .worktree/<task-name> -n <task-name>
|
||||
```
|
||||
|
||||
## flake.nixの無効化
|
||||
|
||||
基本的に、CWDを変更できない場合、.envrcによる自動アクティベートは効かないので無視で構わない。
|
||||
|
||||
## 完了時
|
||||
|
||||
マージウィンドウからこのスキルがinvokeされた際は、ブランチのマージ・worktreeの削除まで行う。対して、実装者がマージしてクローズしてはならない。
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/target
|
||||
.direnv
|
||||
*.local*
|
||||
.env
|
||||
.worktree
|
||||
1
.insomnia/.gitignore
vendored
Normal file
1
.insomnia/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/memory/
|
||||
13
.insomnia/manifest.toml
Normal file
13
.insomnia/manifest.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[scope]
|
||||
allow = [
|
||||
{ target = ".", permission = "write", recursive = true },
|
||||
]
|
||||
|
||||
[session]
|
||||
record_event_trace = true
|
||||
|
||||
[memory]
|
||||
extract_threshold = 50000
|
||||
|
||||
consolidation_threshold_files = 5
|
||||
consolidation_threshold_bytes = 50000
|
||||
143
.insomnia/workflow/auto-maintain.md
Normal file
143
.insomnia/workflow/auto-maintain.md
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
---
|
||||
description: TODO / tickets / docs / git history から次の作業候補を見繕い、課題発見や方針決定を半自動でイテレーションする WIP maintainer workflow
|
||||
model_invokation: false
|
||||
user_invocable: true
|
||||
requires: []
|
||||
---
|
||||
# Auto Maintain Workflow (WIP)
|
||||
|
||||
insomnia を AI maintainer として運用するための半自動 loop。TODO / tickets から「今進められそうな作業」を選ぶだけでなく、課題の発見、設計判断の切り分け、次に人間へ戻すべき問いの整理までを扱う。
|
||||
|
||||
これは unattended 自動開発ではない。実装の並列委譲は `multi-agent-workflow`、worktree の機械的作成は `worktree-workflow` に任せる。本 Workflow はその前段として、何を進めるべきか、何をまだ決めるべきかを整理する。
|
||||
|
||||
参照:
|
||||
|
||||
- `docs/plan/ai-maintainer.md`
|
||||
- `tickets/auto-maintain-workflow.md`
|
||||
|
||||
## 位置づけ
|
||||
|
||||
AI maintainer の目的は、コードを書くこと自体ではなく、プロジェクト状態を前に進めることである。
|
||||
|
||||
この Workflow は WIP として、以下を行う。
|
||||
|
||||
- TODO / tickets / docs / git history を読んで現在地を把握する。
|
||||
- 実装可能な ticket と、方針決定が必要な ticket を分ける。
|
||||
- 小さく実装できる候補を提案する。
|
||||
- 設計相談が必要な論点を人間に戻す。
|
||||
- 運用上の問題や繰り返し発生する詰まりを report / ticket / workflow 改訂候補として整理する。
|
||||
|
||||
## 非目標
|
||||
|
||||
現時点では以下をしない。
|
||||
|
||||
- 常駐 scheduler として自動実行する。
|
||||
- 人間の合意なしに新規 ticket を作る。
|
||||
- 人間の合意なしに既存 ticket を大幅変更する。
|
||||
- 人間の合意なしに ticket 完了削除を行う。
|
||||
- push する。
|
||||
- Workflow を自律生成・自律改訂する。
|
||||
- scope / permission / history persistence / prompt context 加工原則に関わる判断を勝手に決める。
|
||||
|
||||
## 入力として読むもの
|
||||
|
||||
必要に応じて以下を読む。
|
||||
|
||||
1. `TODO.md`
|
||||
2. `tickets/*.md`
|
||||
3. `docs/plan/`
|
||||
4. `docs/report/`
|
||||
5. `git log --oneline` / ticket file の git history
|
||||
6. 既存 worktree / branch 状態
|
||||
7. 最近の失敗や通知、ユーザーからの観測
|
||||
|
||||
TODO と ticket の不整合を見つけたら、勝手に修正せず、まず報告する。ただしユーザーが明示的に「直して」と言った場合は Mode 1 として整理してよい。
|
||||
|
||||
## 分類
|
||||
|
||||
候補を以下に分ける。
|
||||
|
||||
### A. 実装委譲可能
|
||||
|
||||
- 要件と完了条件が具体的。
|
||||
- 影響範囲が限定的。
|
||||
- test / build で確認できる。
|
||||
- 大きな設計判断が不要。
|
||||
- scope を狭く切れる。
|
||||
|
||||
この場合は、人間に候補として提示する。人間が実行を許可したら `$user/multi-agent-workflow` に進む。
|
||||
|
||||
### B. 方針決定が必要
|
||||
|
||||
- 複数の設計方針が自然に導ける。
|
||||
- protocol / permission / scope / persistence / prompt context に触れる。
|
||||
- UX の仕様が未確定。
|
||||
- 既存 ticket の要件が古い。
|
||||
|
||||
この場合は、実装せず、決めるべき問いを短く提示する。
|
||||
|
||||
### C. ticket 整理が必要
|
||||
|
||||
- TODO にあるが ticket がない。
|
||||
- ticket があるが TODO にない。
|
||||
- 完了済みに見えるが残っている。
|
||||
- ticket の前提が変わっている。
|
||||
|
||||
この場合は、不整合と修正案を提示する。修正は人間の許可後に行う。
|
||||
|
||||
### D. report / workflow 改善候補
|
||||
|
||||
- 同じ tool 問題が繰り返し出る。
|
||||
- Workflow の指示が曖昧で実装 Pod が迷った。
|
||||
- AI が過剰に Task tool を使うなど、運用上の癖が出た。
|
||||
- 通知や Pod completion tracking など、開発基盤の不足が観測された。
|
||||
|
||||
この場合は、すぐ ticket 化するか、`docs/report/` に観測として残すか、人間に確認する。
|
||||
|
||||
## 半自動 iteration
|
||||
|
||||
1. 状態把握
|
||||
- TODO / tickets / git status を読む。
|
||||
- 最近完了した流れや未完了 branch を確認する。
|
||||
|
||||
2. 候補抽出
|
||||
- 実装可能そうな ticket を 2〜5 件挙げる。
|
||||
- correctness / developer experience / user-visible UX / cleanup で分類する。
|
||||
|
||||
3. 推奨順位
|
||||
- blocking correctness を最優先。
|
||||
- 実害が出ている運用問題を次点。
|
||||
- 小さく完了できる UX / cleanup を次点。
|
||||
- 大きな設計変更は方針相談に回す。
|
||||
|
||||
4. 人間への提示
|
||||
- 「次に進めるなら X」を1つ推奨する。
|
||||
- 理由を短く述べる。
|
||||
- 実装委譲する場合の scope / test 方針を添える。
|
||||
|
||||
5. 実行への接続
|
||||
- 人間が「進めて」と言ったら `$user/multi-agent-workflow` に接続する。
|
||||
- worktree 作成は `$user/worktree-workflow` に従う。
|
||||
|
||||
## エスカレーション基準
|
||||
|
||||
以下では実装に進まず、人間へ戻す。
|
||||
|
||||
- ticket の要件から複数の設計方針が自然に導ける。
|
||||
- 長期構造、crate boundary、protocol、permission、scope、history persistence に触れる。
|
||||
- prompt context 加工原則に関わる。
|
||||
- 新 ticket の作成、既存 ticket の大幅変更、ticket 完了削除について合意がない。
|
||||
- test 不能、再現不能、または作業範囲外の不具合に遭遇した。
|
||||
- WorkItem / Thread / Lease / maintainer state など、まだ設計中の概念が必要になる。
|
||||
|
||||
|
||||
## まだ固定しないもの
|
||||
|
||||
以下は `docs/plan/ai-maintainer.md` の上位設計に残し、本 Workflow では詳細を固定しない。
|
||||
|
||||
- WorkItemStore / LeaseStore。
|
||||
- operation inbox / trial log。
|
||||
- QA feedback を ticket / review / report のどれに落とすか。
|
||||
- AI 自身の feedback を Knowledge / report / ticket / workflow 改訂のどれにするか。
|
||||
- maintainer doctor。
|
||||
- reviewer Pod の評価基準の機械化。
|
||||
150
.insomnia/workflow/multi-agent-workflow.md
Normal file
150
.insomnia/workflow/multi-agent-workflow.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
---
|
||||
description: worktree と子 Pod を使って複数 ticket の実装・レビュー・修正・完了処理を並列に進める orchestration フロー
|
||||
model_invokation: true
|
||||
user_invocable: true
|
||||
requires: []
|
||||
---
|
||||
# Multi-agent Worktree Workflow
|
||||
|
||||
insomnia を insomnia で開発する際の、worktree + 実装 Pod + 親 Pod review の標準フロー。これは **実装を並列に進めるためのフロー** であり、worktree の機械的作成手順は `$user/worktree-workflow`、ticket 候補選定や方針探索の半自動 loop は `$user/auto-maintain` に分ける。
|
||||
|
||||
## 目的
|
||||
|
||||
- 実装差分を ticket ごとの child worktree に隔離する。
|
||||
- 実装 Pod に narrow write scope を渡して並列実装させる。
|
||||
- 親 Pod が diff / test / ticket 要件を review し、必要なら修正依頼する。
|
||||
- approve 後に merge / ticket 完了処理 / main workspace での再検証を行う。
|
||||
|
||||
## 開始条件
|
||||
|
||||
以下が揃っている時に使う。
|
||||
|
||||
- 対象 ticket が決まっている。
|
||||
- ticket の背景・要件・完了条件から実装方針が概ね導ける。
|
||||
- worktree 作成と git 書き込み操作について、人間の許可がある。
|
||||
- main workspace の unrelated dirty changes を把握している。
|
||||
|
||||
設計方針が複数自然に導ける場合、protocol / scope / permission / history persistence に触れる場合、ticket 自体の再定義が必要な場合は、実装委譲前に人間へ戻す。
|
||||
|
||||
## 親 Pod / orchestrator の責務
|
||||
|
||||
1. 状態確認
|
||||
- `git status --short --branch`
|
||||
- 対象 ticket
|
||||
- 関連 TODO / docs / 既存 worktree
|
||||
|
||||
2. worktree 作成
|
||||
- `$user/worktree-workflow` に従い `./.worktree/<task-name>` を作る。
|
||||
- `.insomnia` を sparse checkout で除外する。
|
||||
|
||||
3. 実装 Pod spawn
|
||||
- read scope: main workspace 全体。
|
||||
- write scope: child worktree、または必要最小 directory。
|
||||
- task には以下を明示する。
|
||||
- child worktree path / branch
|
||||
- 対象 ticket path
|
||||
- Bash は必ず child worktree に `cd` すること
|
||||
- main workspace の `TODO.md` / `tickets/` / `docs/report/` / `.insomnia` は編集しないこと
|
||||
- 範囲外事項
|
||||
- 実行すべき build / test / format
|
||||
- 完了報告項目
|
||||
|
||||
4. 監督
|
||||
- `ReadPodOutput` で報告を読む。
|
||||
- 通知が来ない場合でも、worktree の `git status` / `git diff` / test で完了状態を確認する。
|
||||
- 必要なら `SendToPod` で修正依頼する。
|
||||
|
||||
5. review
|
||||
- ticket の背景・要件・完了条件・範囲外に照らして diff を確認する。
|
||||
- build / test / `git diff --check` を確認する。
|
||||
- 必要なら reviewer Pod を read-only で立てる。
|
||||
|
||||
6. merge / lifecycle
|
||||
- approve 後に main workspace へ merge する。
|
||||
- `TODO.md` から該当行を削除し、`tickets/foo.md` を削除して完了 commit を作る。
|
||||
- main workspace で必要な test / `cargo check --workspace` / `cargo fmt --check` を再実行する。
|
||||
|
||||
## 実装 Pod の責務
|
||||
|
||||
- child worktree 内でのみ実装する。
|
||||
- main workspace の管理ファイルを書かない。
|
||||
- 指定された build / test / format を実行する。
|
||||
- ticket 要件外の設計変更、依存関係追加、scope / permission / history persistence / prompt context 加工原則に触れる変更が必要なら止めて報告する。
|
||||
- 完了時に以下を報告する。
|
||||
- worktree path / branch
|
||||
- commit hash(commit した場合)
|
||||
- 変更ファイル
|
||||
- 実装概要
|
||||
- 実行した build / test / format
|
||||
- 未解決事項
|
||||
- review に回せるか
|
||||
|
||||
## 実装 Pod の commit 方針
|
||||
|
||||
実装 Pod には child worktree 内での commit を許可してよい。
|
||||
|
||||
- commit は ticket 内で意味のある粒度にする。
|
||||
- 例: `feat: ...`、`fix: ...`、`test: ...`、`docs: ...`
|
||||
- 実装 Pod は merge / push / branch deletion / worktree remove をしない。
|
||||
- 実装 Pod は `TODO.md` / `tickets/` の完了処理 commit をしない。
|
||||
- 親 Pod は review 時に commit 粒度も確認する。
|
||||
- 必要な修正は、原則追加 commit として積む。履歴改変や squash は人間の明示指示がある時だけ行う。
|
||||
|
||||
## Review → 修正 → 完了の標準形
|
||||
|
||||
### Approve
|
||||
|
||||
1. 実装 Pod を停止し、scope を回収する。
|
||||
2. 親 Pod が main workspace で `git merge --no-ff <branch>` する。
|
||||
3. 親 Pod が `TODO.md` と `tickets/foo.md` を完了処理して commit する。
|
||||
4. main workspace で検証コマンドを再実行する。
|
||||
5. 変更内容・commit・検証結果・残 dirty changes を報告する。
|
||||
|
||||
### Request changes
|
||||
|
||||
1. blocking finding をファイル / 行 / 理由 / 修正方針つきで整理する。
|
||||
2. 実装 Pod が生きていれば `SendToPod` で修正依頼する。
|
||||
3. 停止済みなら、同じ worktree / branch / scope で再 spawn するか、親 Pod が最小修正する。
|
||||
4. 修正後に focused test と必要な broader test を再実行する。
|
||||
5. 再 review する。
|
||||
|
||||
### Non-blocking comments
|
||||
|
||||
- ticket 要件外の改善はその場で混ぜない。
|
||||
- 必要なら後続 ticket / docs/report にする。
|
||||
- non-blocking を理由に completion を遅らせない。
|
||||
|
||||
## 並列実装時の注意
|
||||
|
||||
- 1 ticket = 1 worktree = 1 branch を基本にする。
|
||||
- 複数 Pod に同じ write scope を渡さない。
|
||||
- parent は child の write scope 配下を直接編集しない。
|
||||
- 依存関係がある ticket は、土台 branch を merge してから次 worktree を切る。
|
||||
- parallel に走らせた Pod の完了通知は取りこぼしうるため、`ReadPodOutput` と worktree 状態で確認する。
|
||||
|
||||
## 完了報告の標準形
|
||||
|
||||
```text
|
||||
完了:
|
||||
- ticket: <path>
|
||||
- branch: <name>
|
||||
- commits:
|
||||
- <hash> <subject>
|
||||
- 変更概要: ...
|
||||
- 検証:
|
||||
- cargo fmt --check
|
||||
- cargo check --workspace
|
||||
- cargo test ...
|
||||
- review: approve / approve with comments / request changes
|
||||
- 未解決事項: ...
|
||||
- 残 dirty changes: ...
|
||||
```
|
||||
|
||||
## この Workflow で扱わないもの
|
||||
|
||||
以下は `$user/auto-maintain` または別の設計相談で扱う。
|
||||
|
||||
- ticket 候補を見繕うこと。
|
||||
- 新規 ticket 作成判断。
|
||||
- QA feedback / AI feedback を ticket / report / workflow に落とす判断。
|
||||
- 長期 maintainer loop / WorkItemStore / LeaseStore の設計。
|
||||
98
.insomnia/workflow/worktree-workflow.md
Normal file
98
.insomnia/workflow/worktree-workflow.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
---
|
||||
description: insomnia プロジェクトで child git worktree を作成・管理するための機械的手順。実装 Pod に作らせず、親 Pod が main workspace で実行する。
|
||||
model_invokation: false
|
||||
user_invocable: true
|
||||
requires: []
|
||||
---
|
||||
# Worktree Workflow
|
||||
|
||||
insomnia プロジェクトで実装差分を main workspace から分離するため、`./.worktree/<task-name>` に child git worktree を作る。これは **worktree の扱い方だけ** を定める Workflow であり、ticket 選定、実装委譲、review、merge の運用は `$user/multi-agent-workflow` 側で扱う。
|
||||
|
||||
insomnia では Pod の write scope が排他的に委譲されるため、child worktree に `.insomnia` を置かない。main workspace は orchestration / ticket / docs / memory / workflow 管理の場所として残し、child worktree はコード差分専用の作業面として扱う。
|
||||
|
||||
## 適用範囲
|
||||
|
||||
この Workflow は親 Pod / orchestrator が main workspace で実行する。
|
||||
|
||||
- 実装 Pod にこの Workflow を渡して worktree を作らせない。
|
||||
- 実装 Pod は、親 Pod が作成済みの child worktree を受け取り、その中で実装・build・test・報告を行う。
|
||||
- ticket 作成、TODO 更新、review artifact、docs/report は main workspace 側で扱う。
|
||||
|
||||
## 原則
|
||||
|
||||
- 1 ticket / 1 実装 task につき 1 worktree を作る。
|
||||
- worktree path は `./.worktree/<task-name>`。
|
||||
- branch 名は原則 `<task-name>` と同じ kebab-case。
|
||||
- child worktree には `.insomnia` を出さない。
|
||||
- child worktree は実装差分用。`TODO.md` / `tickets/` / `docs/report/` / workflow / memory は原則 main workspace 側で扱う。
|
||||
- push はしない。
|
||||
|
||||
## 事前確認
|
||||
|
||||
作成前に以下を確認する。
|
||||
|
||||
1. 対象 ticket / task が決まっているか。
|
||||
2. `<task-name>` が branch / path 名に使える kebab-case か。
|
||||
3. `git worktree add` を実行してよい許可があるか。
|
||||
4. main workspace に混ぜてはいけない未保存差分がないか。
|
||||
5. 同名 branch / worktree が既に存在しないか。
|
||||
|
||||
同名 branch がある場合は、既存 branch を使うか、人間に確認する。`git worktree add -b` で上書きしない。
|
||||
|
||||
## 作成手順
|
||||
|
||||
main workspace で実行する。
|
||||
|
||||
```bash
|
||||
git worktree add .worktree/<task-name> -b <task-name>
|
||||
|
||||
git -C .worktree/<task-name> sparse-checkout init --no-cone
|
||||
git -C .worktree/<task-name> sparse-checkout set --no-cone \
|
||||
'/*' \
|
||||
'!/.insomnia/' \
|
||||
'!/.insomnia/**'
|
||||
```
|
||||
|
||||
確認する。
|
||||
|
||||
```bash
|
||||
git -C .worktree/<task-name> status --short --branch
|
||||
test ! -e .worktree/<task-name>/.insomnia
|
||||
```
|
||||
|
||||
失敗した場合は、worktree / branch / lock の状態を確認し、勝手に cleanup せず人間へ報告する。
|
||||
|
||||
## 子 Pod へ渡す scope
|
||||
|
||||
子 Pod を使う場合、子 Pod の cwd は main workspace のままになる。必ず作業対象が child worktree であることを明示し、Bash 実行時は毎回 `cd <repo>/.worktree/<task-name> && ...` させる。
|
||||
|
||||
推奨 scope:
|
||||
|
||||
```text
|
||||
read: <repo>
|
||||
write: <repo>/.worktree/<task-name>
|
||||
```
|
||||
|
||||
より狭く切れる場合は、write scope を変更対象 crate / directory まで狭めてよい。ただし build / test に必要な生成物を書けることを確認する。
|
||||
|
||||
## child worktree 内の禁止事項
|
||||
|
||||
- `.insomnia` を作らない / コピーしない。
|
||||
- main workspace の `TODO.md` / `tickets/` / `docs/report/` を編集しない。
|
||||
- merge / push / branch deletion / worktree remove をしない。
|
||||
- scope / permission / history persistence / prompt context 加工原則に関わる設計変更を無断で行わない。
|
||||
|
||||
## 完了時の扱い
|
||||
|
||||
worktree 作成 Workflow としては、完了時に merge しない。merge、ticket 完了、TODO 削除は `$user/multi-agent-workflow` または人間の明示指示で行う。
|
||||
|
||||
実装 Pod へ渡す完了報告項目の標準形:
|
||||
|
||||
- worktree path
|
||||
- branch 名
|
||||
- commit hash(実装 Pod に commit を許可した場合)
|
||||
- 変更ファイル
|
||||
- 実装概要
|
||||
- 実行した build / test / format
|
||||
- 未解決事項
|
||||
- review に回せるか
|
||||
75
AGENTS.md
Normal file
75
AGENTS.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
全体設計が概ね固まり、随所の細かい仕様を詰めながら実装を進めている。
|
||||
|
||||
## このシステムに置ける設計要旨
|
||||
|
||||
- プロンプトはすべて resources/promptsに集約している。管理効率の工場と同時に、ユーザーがオーバーライドする形式でもある。
|
||||
- E2E(実プロセスをスポーンさせてのテスト)は未設計。
|
||||
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
||||
|
||||
### LLM コンテキストの加工原則
|
||||
|
||||
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
|
||||
|
||||
Podの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。(pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
|
||||
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。
|
||||
|
||||
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
||||
|
||||
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / PodEvent / `<system-reminder>` 系はこの原則で扱う(→ `tickets/notify-history-persist.md`)。
|
||||
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
||||
|
||||
---
|
||||
|
||||
## 実際のセッションを読んでデバッグする
|
||||
|
||||
`~/.insomnia/sessions`にすべてのセッションがある。jsonlなので、いい感じにBashで読むこと。
|
||||
|
||||
---
|
||||
|
||||
## Git操作
|
||||
|
||||
workflowで明示されない限り、読み取り以外の操作は控えること。
|
||||
基本はworktree上の一時的なブランチでコミットを重ね、メインブランチに取り込む運用をしている。
|
||||
コミットメッセージは適当に`<prefix>: *簡潔な1行*`で書いている。
|
||||
|
||||
外部の参考プロジェクトは必要に応じてローカルの外部 checkout からReadすること。
|
||||
|
||||
---
|
||||
|
||||
## Ticketの運用について
|
||||
|
||||
`TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。
|
||||
|
||||
### TODO.md
|
||||
|
||||
- 1チケット = 1行。未完了のみ記載し、完了したら行ごと削除する(履歴はgitで追える)
|
||||
- ネストは同一領域のグルーピング(表示用)にのみ使う。実装上の依存関係はネストで表現しない
|
||||
- 完了した子は削除し、親は未完了の子がある限り残す。最後の子が完了したら親ごと削除
|
||||
- Ticketを追加する際は、合わせてTODOも書くこと
|
||||
|
||||
### Ticket の粒度
|
||||
|
||||
- 1チケット = 完了時点で、実装が仕様又は機能として説明できる粒度。
|
||||
- 作成時、背景や要件を前提として書き、実装の方針やコードの詳細は不必要に増やさない。
|
||||
- チケット内のステップ(Phase 1, 2, ...)は実装順序であり、TODO等、外に出さない
|
||||
- ビルドが通り、その機能に限り,まだ動作できないと明示出来ている場合を除いて全体を通して動作させられる状態である必要がある。
|
||||
|
||||
### Ticket のライフサイクル
|
||||
|
||||
gitがタイムラインの単一の情報源。ファイル操作とcommitで状態遷移を表現する。
|
||||
|
||||
a. 作成: `tickets/foo.md` を作成してcommit
|
||||
b. 詳細化や前提の変化: `tickets/foo.md` を更新してcommit
|
||||
c. レビュー: `tickets/foo.md` にレビュー状態を追記 + `tickets/foo.review.md` を作成してcommit
|
||||
d. 完了: `tickets/foo.md` と `tickets/foo.review.md` を両方削除してcommit
|
||||
|
||||
worktreeと併用して作業を進める場合、必ずブランチを切る前に対象のチケットをコミットしてから切ること。
|
||||
|
||||
TODO.mdのリンクは完了後に切れるが、そのリンクを元にgitで消されたファイルを読み、内容を把握できる。
|
||||
`.review.md` にはレビューの指摘事項と判断結果を記載する。
|
||||
レビューはdiffの確認だけでなく、チケットはどのような前提・要件であり、それが達成されたかの確認まで含めて行う。
|
||||
常に、提出された実装で良いのか、コードベースを歪めていないか、不必要な実装ではないかを確認すること。
|
||||
|
||||
---
|
||||
|
||||
insomniaでinsomniaを開発している際、AI自身のフィードバックを元に改善を回すために `docs/report/`ディレクトリに感じた障壁や改善案等を書き残す形にした。 明確に力不足な点/ツールの問題があった場合や、ユーザーからの指示があった際に作ること。
|
||||
75
CLAUDE.md
Normal file
75
CLAUDE.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
全体設計が概ね固まり、随所の細かい仕様を詰めながら実装を進めている。
|
||||
|
||||
## このシステムに置ける設計要旨
|
||||
|
||||
- プロンプトはすべて resources/promptsに集約している。管理効率の工場と同時に、ユーザーがオーバーライドする形式でもある。
|
||||
- E2E(実プロセスをスポーンさせてのテスト)は未設計。
|
||||
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
||||
|
||||
### LLM コンテキストの加工原則
|
||||
|
||||
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
|
||||
|
||||
Podの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。(pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
|
||||
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。
|
||||
|
||||
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
||||
|
||||
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / PodEvent / `<system-reminder>` 系はこの原則で扱う(→ `tickets/notify-history-persist.md`)。
|
||||
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
||||
|
||||
---
|
||||
|
||||
## 実際のセッションを読んでデバッグする
|
||||
|
||||
`~/.insomnia/sessions`にすべてのセッションがある。jsonlなので、いい感じにBashで読むこと。
|
||||
|
||||
---
|
||||
|
||||
## Git操作
|
||||
|
||||
workflowで明示されない限り、読み取り以外の操作は控えること。
|
||||
基本はworktree上の一時的なブランチでコミットを重ね、メインブランチに取り込む運用をしている。
|
||||
コミットメッセージは適当に`<prefix>: *簡潔な1行*`で書いている。
|
||||
|
||||
外部の参考プロジェクトは必要に応じてローカルの外部 checkout からReadすること。
|
||||
|
||||
---
|
||||
|
||||
## Ticketの運用について
|
||||
|
||||
`TODO.md`、`tickets/`はgitで管理されていて、時系列の管理はgitを参照して把握すること。
|
||||
|
||||
### TODO.md
|
||||
|
||||
- 1チケット = 1行。未完了のみ記載し、完了したら行ごと削除する(履歴はgitで追える)
|
||||
- ネストは同一領域のグルーピング(表示用)にのみ使う。実装上の依存関係はネストで表現しない
|
||||
- 完了した子は削除し、親は未完了の子がある限り残す。最後の子が完了したら親ごと削除
|
||||
- Ticketを追加する際は、合わせてTODOも書くこと
|
||||
|
||||
### Ticket の粒度
|
||||
|
||||
- 1チケット = 完了時点で、実装が仕様又は機能として説明できる粒度。
|
||||
- 作成時、背景や要件を前提として書き、実装の方針やコードの詳細は不必要に増やさない。
|
||||
- チケット内のステップ(Phase 1, 2, ...)は実装順序であり、TODO等、外に出さない
|
||||
- ビルドが通り、その機能に限り,まだ動作できないと明示出来ている場合を除いて全体を通して動作させられる状態である必要がある。
|
||||
|
||||
### Ticket のライフサイクル
|
||||
|
||||
gitがタイムラインの単一の情報源。ファイル操作とcommitで状態遷移を表現する。
|
||||
|
||||
a. 作成: `tickets/foo.md` を作成してcommit
|
||||
b. 詳細化や前提の変化: `tickets/foo.md` を更新してcommit
|
||||
c. レビュー: `tickets/foo.md` にレビュー状態を追記 + `tickets/foo.review.md` を作成してcommit
|
||||
d. 完了: `tickets/foo.md` と `tickets/foo.review.md` を両方削除してcommit
|
||||
|
||||
worktreeと併用して作業を進める場合、必ずブランチを切る前に対象のチケットをコミットしてから切ること。
|
||||
|
||||
TODO.mdのリンクは完了後に切れるが、そのリンクを元にgitで消されたファイルを読み、内容を把握できる。
|
||||
`.review.md` にはレビューの指摘事項と判断結果を記載する。
|
||||
レビューはdiffの確認だけでなく、チケットはどのような前提・要件であり、それが達成されたかの確認まで含めて行う。
|
||||
常に、提出された実装で良いのか、コードベースを歪めていないか、不必要な実装ではないかを確認すること。
|
||||
|
||||
---
|
||||
|
||||
insomniaでinsomniaを開発している際、AI自身のフィードバックを元に改善を回すために `docs/report/`ディレクトリに感じた障壁や改善案等を書き残す形にした。 明確に力不足な点/ツールの問題があった場合や、ユーザーからの指示があった際に作ること。
|
||||
4573
Cargo.lock
generated
Normal file
4573
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
Cargo.toml
Normal file
57
Cargo.toml
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/client",
|
||||
"crates/daemon",
|
||||
"crates/llm-worker",
|
||||
"crates/llm-worker-macros",
|
||||
"crates/session-store",
|
||||
"crates/manifest",
|
||||
"crates/pod",
|
||||
"crates/protocol",
|
||||
"crates/provider",
|
||||
"crates/pod-registry",
|
||||
"crates/session-metrics",
|
||||
"crates/lint-common",
|
||||
"crates/tools",
|
||||
"crates/tui",
|
||||
"crates/memory",
|
||||
"crates/workflow",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
|
||||
[workspace.dependencies]
|
||||
# Internal crates
|
||||
client = { path = "crates/client" }
|
||||
llm-worker = { path = "crates/llm-worker", version = "0.2" }
|
||||
llm-worker-macros = { path = "crates/llm-worker-macros", version = "0.2" }
|
||||
manifest = { path = "crates/manifest" }
|
||||
lint-common = { path = "crates/lint-common" }
|
||||
memory = { path = "crates/memory" }
|
||||
pod-registry = { path = "crates/pod-registry" }
|
||||
protocol = { path = "crates/protocol" }
|
||||
provider = { path = "crates/provider" }
|
||||
session-metrics = { path = "crates/session-metrics" }
|
||||
session-store = { path = "crates/session-store" }
|
||||
tools = { path = "crates/tools" }
|
||||
|
||||
# External
|
||||
# Note: `reqwest` and `chrono` are not aggregated here because some crates
|
||||
# need `default-features = false`, which workspace inheritance cannot override.
|
||||
async-trait = "0.1"
|
||||
fs4 = "0.13"
|
||||
futures = "0.3"
|
||||
libc = "0.2"
|
||||
schemars = "1.2"
|
||||
serde = "1.0"
|
||||
serde_json = "1.0"
|
||||
sha2 = "0.11"
|
||||
tempfile = "3.27"
|
||||
thiserror = "2.0"
|
||||
tokio = "1.52"
|
||||
toml = "1.1"
|
||||
tracing = "0.1"
|
||||
uuid = "1.23"
|
||||
18
KNOWN_ISSUES.md
Normal file
18
KNOWN_ISSUES.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Known Issues
|
||||
|
||||
Ticket を切るほどではないが、次に近所を触るときに合わせて拾いたい小粒な所見の置き場。
|
||||
|
||||
## 運用
|
||||
|
||||
- 1 項目 = 出典 (file:line) + 症状 (一文) + トリガー (いつ拾うか、一文)
|
||||
- 関連 ticket があれば `→ [tickets/foo.md]` でリンク
|
||||
- 修正したら同じコミットで該当エントリを削除する (履歴は git)
|
||||
- ここに溜める基準: 「ticket は重い」「だが忘れたら次の触り手が踏む」もの。明確に作業すべきものは ticket 化する
|
||||
|
||||
## エントリ
|
||||
|
||||
- `crates/tui/src/app.rs:478-485` — bad workflow slug を含む `Method::Run` 送信時、`Event::UserMessage` の早期 broadcast で `turn_index += 1` されターンヘッダだけ残る ("ghost turn header")。次に TUI のターンヘッダ / エラー表示周りを触るときに整理。→ [tickets/pod-input-validate-internalize.md] の review 由来。
|
||||
- `crates/pod/src/controller.rs:944` — `worker_error_code` で `PodError::WorkflowResolve(_) => InvalidRequest` が post-commit な resolve エラー (`KnowledgeNotFound` 等) にも適用される。意味論的には妥当方向だが、resolve 系のエラー粒度を分けたくなったタイミングで再評価。
|
||||
- `crates/pod/tests/controller_test.rs` — `double_run_returns_error` がたまに失敗する flakiness を観測。`pod-interrupt-prep-internalize` 以前から存在する別件。次に controller_test の Run 連投系のタイミングを触るときに併せて原因を切り分け。
|
||||
- `crates/session-store/src/fs_store.rs:117-122` — `FsStore::read_entry_count` が `fs::read_to_string` で全文ロードしてから行数カウントするため O(n)。`ensure_head_or_fork` は run-start でしか呼ばれず現状は許容範囲だが、長期セッションが普通になった時点で `\n` バイト数の cheap count か末尾 seek に置き換える。
|
||||
- `crates/session-store/src/segment.rs:121` `ensure_head_or_fork` (free fn, test 専用・本番 caller ゼロ) と `crates/pod/src/pod.rs` `Pod::ensure_segment_head` (本番 inline) に live auto-fork の検知 + forked_from 記録が二重実装されている。entry-hash-abolish 以前からの重複で、両方独立にテスト済みだが drift 必至。session-store 側を本番から呼ぶ形に寄せるか free fn を畳むかは要設計判断。Pod state / fork 周辺を次に触るときに統合を検討。
|
||||
5
TODO.md
Normal file
5
TODO.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# TODO legacy notice
|
||||
|
||||
Active repository work items have been migrated to `work-items/`.
|
||||
|
||||
Use `./tickets.sh list --status all` for the generated/current view and `./tickets.sh doctor` to validate the migration state.
|
||||
11
crates/client/Cargo.toml
Normal file
11
crates/client/Cargo.toml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
[package]
|
||||
name = "client"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
protocol = { workspace = true }
|
||||
manifest = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt", "macros", "net", "io-util", "sync", "time", "process", "fs"] }
|
||||
uuid = { workspace = true }
|
||||
15
crates/client/src/lib.rs
Normal file
15
crates/client/src/lib.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//! Pod プロトコルを喋るクライアント。
|
||||
//!
|
||||
//! - [`PodClient`]: 既存 pod の Unix ソケットへ接続して `Method` を送り、
|
||||
//! `Event` を受け取る低レベル接続。
|
||||
//! - [`spawn`]: pod バイナリをサブプロセスとして起動し、`INSOMNIA-READY`
|
||||
//! ハンドシェイクが終わるまで待つフロー。subprocess を立ち上げる必要が
|
||||
//! ない呼び出し側 (=既存 pod に attach する場合) は使わなくてよい。
|
||||
//!
|
||||
//! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。
|
||||
|
||||
mod pod_client;
|
||||
pub mod spawn;
|
||||
|
||||
pub use pod_client::PodClient;
|
||||
pub use spawn::{SpawnConfig, SpawnError, SpawnReady, spawn_pod};
|
||||
45
crates/client/src/pod_client.rs
Normal file
45
crates/client/src/pod_client.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||
use protocol::{Event, Method};
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub struct PodClient {
|
||||
writer: JsonLineWriter<tokio::io::WriteHalf<UnixStream>>,
|
||||
event_rx: mpsc::Receiver<Event>,
|
||||
}
|
||||
|
||||
impl PodClient {
|
||||
pub async fn connect(path: &Path) -> Result<Self, io::Error> {
|
||||
let stream = UnixStream::connect(path).await?;
|
||||
let (reader, writer) = tokio::io::split(stream);
|
||||
let writer = JsonLineWriter::new(writer);
|
||||
|
||||
let (event_tx, event_rx) = mpsc::channel::<Event>(256);
|
||||
|
||||
tokio::spawn(async move {
|
||||
let mut reader = JsonLineReader::new(reader);
|
||||
while let Ok(Some(event)) = reader.next::<Event>().await {
|
||||
if event_tx.send(event).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self { writer, event_rx })
|
||||
}
|
||||
|
||||
pub async fn send(&mut self, method: &Method) -> Result<(), io::Error> {
|
||||
self.writer.write(method).await
|
||||
}
|
||||
|
||||
pub fn try_next_event(&mut self) -> Option<Event> {
|
||||
self.event_rx.try_recv().ok()
|
||||
}
|
||||
|
||||
pub async fn next_event(&mut self) -> Option<Event> {
|
||||
self.event_rx.recv().await
|
||||
}
|
||||
}
|
||||
299
crates/client/src/spawn.rs
Normal file
299
crates/client/src/spawn.rs
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
//! pod バイナリをサブプロセスとして立ち上げ、`INSOMNIA-READY` を待つ
|
||||
//! ハンドシェイク。
|
||||
//!
|
||||
//! - 親プロセス (TUI / GUI / E2E) は overlay TOML を組み立ててこの関数に
|
||||
//! 渡す。pod はそれを受けて socket を bind し、stderr に
|
||||
//! `INSOMNIA-READY\t<name>\t<socket>` を吐く。
|
||||
//! - 待機中の stderr 行は `progress` コールバック越しに呼び出し側へ流す。
|
||||
//! UI の進捗表示や E2E のログ収集はここで賄う。
|
||||
//! - `kill_on_drop = false` + `process_group(0)` により、親プロセス
|
||||
//! ライフサイクルから切り離した detached pod を作る。ready 後の lifecycle
|
||||
//! 管理は runtime ディレクトリ / socket を介して行う。
|
||||
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::process::Command;
|
||||
use uuid::Uuid;
|
||||
|
||||
const READY_PREFIX: &str = "INSOMNIA-READY\t";
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
|
||||
/// `spawn_pod` の入力。
|
||||
pub struct SpawnConfig {
|
||||
/// `pod.name` として使う識別子。runtime ディレクトリ
|
||||
/// (`manifest::paths::pod_runtime_dir`) の解決と、ready 行に乗る
|
||||
/// 名前との突き合わせに使う。
|
||||
pub pod_name: String,
|
||||
/// `--overlay` で pod に渡す TOML 文字列。
|
||||
pub overlay_toml: String,
|
||||
/// pod の current_dir。
|
||||
pub cwd: PathBuf,
|
||||
/// `Some(id)` のとき `--session <id>` を付与し、当該セッションから
|
||||
/// resume させる。
|
||||
pub resume_from: Option<Uuid>,
|
||||
/// true のとき `--pod <pod_name>` を付与し、pod 側で name-keyed state
|
||||
/// があれば resume、なければ同名の新規 Pod として起動させる。
|
||||
pub resume_by_pod_name: bool,
|
||||
}
|
||||
|
||||
pub struct SpawnReady {
|
||||
pub pod_name: String,
|
||||
pub socket_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum SpawnError {
|
||||
Io(io::Error),
|
||||
/// runtime ディレクトリが解決できなかった (環境変数未設定等)。
|
||||
RuntimeDirUnavailable,
|
||||
PodLaunchFailed(io::Error),
|
||||
PodExitedEarly {
|
||||
stderr_tail: String,
|
||||
},
|
||||
Timeout,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SpawnError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(e) => write!(f, "io error: {e}"),
|
||||
Self::RuntimeDirUnavailable => write!(
|
||||
f,
|
||||
"could not resolve runtime directory (set INSOMNIA_HOME, INSOMNIA_RUNTIME_DIR, XDG_RUNTIME_DIR, or HOME)"
|
||||
),
|
||||
Self::PodLaunchFailed(e) => write!(f, "failed to launch pod: {e}"),
|
||||
Self::PodExitedEarly { stderr_tail } => {
|
||||
if stderr_tail.is_empty() {
|
||||
write!(f, "pod exited before becoming ready")
|
||||
} else {
|
||||
write!(f, "pod exited before becoming ready: {stderr_tail}")
|
||||
}
|
||||
}
|
||||
Self::Timeout => write!(
|
||||
f,
|
||||
"pod did not become ready within {}s",
|
||||
READY_TIMEOUT.as_secs()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for SpawnError {}
|
||||
|
||||
impl From<io::Error> for SpawnError {
|
||||
fn from(e: io::Error) -> Self {
|
||||
Self::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// pod を spawn し、`INSOMNIA-READY` ハンドシェイクが終わるまで待つ。
|
||||
///
|
||||
/// `progress` は ready 行を見つけるまでに観測した stderr の各行で呼ばれる
|
||||
/// (ready 行自体は除外される)。UI の表示更新や E2E ログ取得に使う。
|
||||
pub async fn spawn_pod<F>(config: SpawnConfig, mut progress: F) -> Result<SpawnReady, SpawnError>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
let pod_bin = resolve_pod_command();
|
||||
|
||||
let pod_runtime_dir = manifest::paths::pod_runtime_dir(&config.pod_name)
|
||||
.ok_or(SpawnError::RuntimeDirUnavailable)?;
|
||||
std::fs::create_dir_all(&pod_runtime_dir).map_err(SpawnError::Io)?;
|
||||
let stderr_path = pod_runtime_dir.join("stderr.log");
|
||||
let stderr_file = std::fs::File::create(&stderr_path).map_err(SpawnError::Io)?;
|
||||
|
||||
let mut command = Command::new(&pod_bin);
|
||||
command
|
||||
.arg("--overlay")
|
||||
.arg(&config.overlay_toml)
|
||||
.current_dir(&config.cwd)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::from(stderr_file))
|
||||
.process_group(0);
|
||||
if config.resume_by_pod_name {
|
||||
command.arg("--pod").arg(&config.pod_name);
|
||||
}
|
||||
if let Some(id) = config.resume_from {
|
||||
command.arg("--session").arg(id.to_string());
|
||||
}
|
||||
let mut child = command.spawn().map_err(SpawnError::PodLaunchFailed)?;
|
||||
|
||||
// Default `kill_on_drop = false` plus `process_group(0)` makes this
|
||||
// a detached Pod once startup succeeds: dropping the handle does not
|
||||
// terminate it, and terminal-generated signals for the parent's
|
||||
// process group do not hit the Pod. Runtime state/socket files are
|
||||
// the source of truth after that point.
|
||||
let ready = match wait_for_ready_file(&mut progress, &stderr_path, &mut child).await {
|
||||
Ok(ready) => ready,
|
||||
Err(e) => {
|
||||
let _ = child.start_kill();
|
||||
let _ = child.wait().await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let _ = child.wait().await;
|
||||
});
|
||||
Ok(ready)
|
||||
}
|
||||
|
||||
async fn wait_for_ready_file<F>(
|
||||
progress: &mut F,
|
||||
stderr_path: &Path,
|
||||
child: &mut tokio::process::Child,
|
||||
) -> Result<SpawnReady, SpawnError>
|
||||
where
|
||||
F: FnMut(&str),
|
||||
{
|
||||
let mut tail = StderrTail::new();
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
let mut offset = 0usize;
|
||||
|
||||
loop {
|
||||
let content = match tokio::fs::read_to_string(stderr_path).await {
|
||||
Ok(content) => content,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(SpawnError::Io(e)),
|
||||
};
|
||||
if content.len() > offset {
|
||||
for line in content[offset..].lines() {
|
||||
if let Some(rest) = line.strip_prefix(READY_PREFIX) {
|
||||
let mut parts = rest.splitn(2, '\t');
|
||||
let pod_name = parts.next().unwrap_or("").to_string();
|
||||
let socket_str = parts.next().unwrap_or("").to_string();
|
||||
if pod_name.is_empty() || socket_str.is_empty() {
|
||||
return Err(SpawnError::PodExitedEarly {
|
||||
stderr_tail: format!("malformed ready line: {line}"),
|
||||
});
|
||||
}
|
||||
let socket_path = PathBuf::from(socket_str);
|
||||
wait_for_socket(
|
||||
&socket_path,
|
||||
deadline,
|
||||
child,
|
||||
stderr_path,
|
||||
&mut tail,
|
||||
&mut offset,
|
||||
)
|
||||
.await?;
|
||||
return Ok(SpawnReady {
|
||||
pod_name,
|
||||
socket_path,
|
||||
});
|
||||
}
|
||||
tail.push(line);
|
||||
progress(line);
|
||||
}
|
||||
offset = content.len();
|
||||
}
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(SpawnError::Timeout);
|
||||
}
|
||||
tokio::select! {
|
||||
status = child.wait() => {
|
||||
let _ = status;
|
||||
// Pod は exit 直前に最終 stderr 行を flush することがある。
|
||||
// child.wait() が解決した後に再読みして、原因行を取りこ
|
||||
// ぼさず PodExitedEarly に載せる。
|
||||
drain_stderr_into_tail(stderr_path, &mut tail, &mut offset).await;
|
||||
return Err(SpawnError::PodExitedEarly {
|
||||
stderr_tail: tail.into_string(),
|
||||
});
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(100)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_socket(
|
||||
socket_path: &Path,
|
||||
deadline: tokio::time::Instant,
|
||||
child: &mut tokio::process::Child,
|
||||
stderr_path: &Path,
|
||||
tail: &mut StderrTail,
|
||||
offset: &mut usize,
|
||||
) -> Result<(), SpawnError> {
|
||||
loop {
|
||||
match tokio::net::UnixStream::connect(socket_path).await {
|
||||
Ok(_) => return Ok(()),
|
||||
Err(e)
|
||||
if e.kind() == io::ErrorKind::NotFound
|
||||
|| e.kind() == io::ErrorKind::ConnectionRefused => {}
|
||||
Err(e) => return Err(SpawnError::Io(e)),
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(SpawnError::Timeout);
|
||||
}
|
||||
tokio::select! {
|
||||
status = child.wait() => {
|
||||
let _ = status;
|
||||
drain_stderr_into_tail(stderr_path, tail, offset).await;
|
||||
return Err(SpawnError::PodExitedEarly {
|
||||
stderr_tail: tail.as_string(),
|
||||
});
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn drain_stderr_into_tail(stderr_path: &Path, tail: &mut StderrTail, offset: &mut usize) {
|
||||
let Ok(content) = tokio::fs::read_to_string(stderr_path).await else {
|
||||
return;
|
||||
};
|
||||
if content.len() <= *offset {
|
||||
return;
|
||||
}
|
||||
for line in content[*offset..].lines() {
|
||||
if !line.starts_with(READY_PREFIX) {
|
||||
tail.push(line);
|
||||
}
|
||||
}
|
||||
*offset = content.len();
|
||||
}
|
||||
|
||||
/// Resolves the binary used to launch a child Pod. Must point at a
|
||||
/// `pod`-compatible executable — the parent reads the child's stderr
|
||||
/// directly looking for `INSOMNIA-READY`, so any wrapper that emits
|
||||
/// extra lines on stderr will pollute that handshake.
|
||||
///
|
||||
/// `INSOMNIA_POD_COMMAND` overrides the lookup (used by tests to inject
|
||||
/// a mock binary). Otherwise we defer to `PATH` — missing binary
|
||||
/// surfaces as the spawn `io::Error`.
|
||||
fn resolve_pod_command() -> PathBuf {
|
||||
if let Ok(cmd) = std::env::var("INSOMNIA_POD_COMMAND")
|
||||
&& !cmd.is_empty()
|
||||
{
|
||||
return PathBuf::from(cmd);
|
||||
}
|
||||
PathBuf::from("pod")
|
||||
}
|
||||
|
||||
struct StderrTail {
|
||||
lines: std::collections::VecDeque<String>,
|
||||
}
|
||||
|
||||
impl StderrTail {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
lines: std::collections::VecDeque::with_capacity(8),
|
||||
}
|
||||
}
|
||||
fn push(&mut self, line: &str) {
|
||||
if self.lines.len() == 8 {
|
||||
self.lines.pop_front();
|
||||
}
|
||||
self.lines.push_back(line.to_string());
|
||||
}
|
||||
fn as_string(&self) -> String {
|
||||
self.lines.iter().cloned().collect::<Vec<_>>().join(" | ")
|
||||
}
|
||||
fn into_string(self) -> String {
|
||||
self.lines.into_iter().collect::<Vec<_>>().join(" | ")
|
||||
}
|
||||
}
|
||||
10
crates/daemon/Cargo.toml
Normal file
10
crates/daemon/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "daemon"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
manifest = { workspace = true }
|
||||
protocol = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
9
crates/daemon/README.md
Normal file
9
crates/daemon/README.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# daemon
|
||||
|
||||
Pod のライフサイクルを管理する常駐デーモン。未実装。
|
||||
|
||||
## 依存クレート
|
||||
|
||||
- `manifest` — マニフェスト設定
|
||||
- `protocol` — 通信プロトコル型
|
||||
- `tokio` — 非同期ランタイム
|
||||
1
crates/daemon/src/lib.rs
Normal file
1
crates/daemon/src/lib.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
13
crates/lint-common/Cargo.toml
Normal file
13
crates/lint-common/Cargo.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
[package]
|
||||
name = "lint-common"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
81
crates/lint-common/src/frontmatter.rs
Normal file
81
crates/lint-common/src/frontmatter.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! Common frontmatter helpers.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use crate::RecordLintError;
|
||||
|
||||
/// Trait record frontmatter types implement so linters can drive them uniformly.
|
||||
pub trait Frontmatter: Sized {
|
||||
/// Hard upper bound on body chars (excluding the frontmatter block).
|
||||
const BODY_LIMIT: usize;
|
||||
|
||||
fn created_at(&self) -> Option<DateTime<Utc>>;
|
||||
fn updated_at(&self) -> Option<DateTime<Utc>>;
|
||||
}
|
||||
|
||||
const FRONTMATTER_DELIM: &str = "---";
|
||||
|
||||
/// Split a markdown document into `(yaml_frontmatter, body)`.
|
||||
///
|
||||
/// Expects the document to start with `---\n` and have a closing
|
||||
/// `---\n` (or `---` at EOF) somewhere downstream. Trailing newline
|
||||
/// after the closing delimiter is consumed.
|
||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError> {
|
||||
// The opening delimiter must be the very first line.
|
||||
let after_open = content
|
||||
.strip_prefix(FRONTMATTER_DELIM)
|
||||
.and_then(|s| s.strip_prefix('\n').or(Some(s)))
|
||||
.ok_or(RecordLintError::MissingFrontmatter)?;
|
||||
|
||||
// Look for the closing `---` on its own line.
|
||||
let mut yaml_end = None;
|
||||
let mut byte_offset = 0usize;
|
||||
for line in after_open.split_inclusive('\n') {
|
||||
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||
if trimmed == FRONTMATTER_DELIM {
|
||||
yaml_end = Some((byte_offset, byte_offset + line.len()));
|
||||
break;
|
||||
}
|
||||
byte_offset += line.len();
|
||||
}
|
||||
|
||||
let (yaml_end_excl, body_start) = yaml_end.ok_or_else(|| {
|
||||
RecordLintError::MalformedFrontmatter("missing closing `---` line".to_string())
|
||||
})?;
|
||||
|
||||
let yaml = &after_open[..yaml_end_excl];
|
||||
let body = &after_open[body_start..];
|
||||
Ok((yaml, body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn splits_simple() {
|
||||
let doc = "---\nfoo: 1\n---\nbody here\n";
|
||||
let (y, b) = split_frontmatter(doc).unwrap();
|
||||
assert_eq!(y, "foo: 1\n");
|
||||
assert_eq!(b, "body here\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_leading_delim_errors() {
|
||||
let err = split_frontmatter("hello").unwrap_err();
|
||||
assert!(matches!(err, RecordLintError::MissingFrontmatter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_closing_delim_errors() {
|
||||
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
|
||||
assert!(matches!(err, RecordLintError::MalformedFrontmatter(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_empty_body() {
|
||||
let doc = "---\nfoo: 1\n---\n";
|
||||
let (_, b) = split_frontmatter(doc).unwrap();
|
||||
assert_eq!(b, "");
|
||||
}
|
||||
}
|
||||
20
crates/lint-common/src/lib.rs
Normal file
20
crates/lint-common/src/lib.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! Shared record lint primitives for memory and workflow files.
|
||||
|
||||
mod frontmatter;
|
||||
mod slug;
|
||||
|
||||
pub use frontmatter::{Frontmatter, split_frontmatter};
|
||||
pub use slug::{Slug, is_valid_slug};
|
||||
|
||||
/// Common lint errors for Markdown record syntax shared by memory and workflow.
|
||||
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum RecordLintError {
|
||||
#[error("invalid slug `{0}`: must match ^[a-z0-9](?:[a-z0-9-]{{0,62}}[a-z0-9])?$")]
|
||||
InvalidSlug(String),
|
||||
|
||||
#[error("malformed frontmatter: {0}")]
|
||||
MalformedFrontmatter(String),
|
||||
|
||||
#[error("frontmatter is missing or document is empty")]
|
||||
MissingFrontmatter,
|
||||
}
|
||||
146
crates/lint-common/src/slug.rs
Normal file
146
crates/lint-common/src/slug.rs
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
//! Slug type and validation.
|
||||
//!
|
||||
//! Syntax (agent-skills compatible):
|
||||
//! ^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$
|
||||
//! - 1–64 chars
|
||||
//! - lowercase ASCII alphanumerics and `-`
|
||||
//! - cannot start or end with `-`
|
||||
//! - no consecutive `--`
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::RecordLintError;
|
||||
|
||||
const MIN_LEN: usize = 1;
|
||||
const MAX_LEN: usize = 64;
|
||||
|
||||
/// Validated slug. Constructible only via [`Slug::parse`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Slug(String);
|
||||
|
||||
impl Slug {
|
||||
/// Parse and validate. Returns [`RecordLintError::InvalidSlug`] on rejection.
|
||||
pub fn parse(s: impl Into<String>) -> Result<Self, RecordLintError> {
|
||||
let s = s.into();
|
||||
if is_valid_slug(&s) {
|
||||
Ok(Self(s))
|
||||
} else {
|
||||
Err(RecordLintError::InvalidSlug(s))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Slug {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Slug {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Slug {
|
||||
type Err = RecordLintError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Slug {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
Self::parse(raw).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure-fn predicate matching the agent-skills slug regex without
|
||||
/// pulling in the `regex` crate.
|
||||
pub fn is_valid_slug(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
let len = bytes.len();
|
||||
if len < MIN_LEN || len > MAX_LEN {
|
||||
return false;
|
||||
}
|
||||
if !is_alnum_lower(bytes[0]) || !is_alnum_lower(bytes[len - 1]) {
|
||||
return false;
|
||||
}
|
||||
let mut prev_dash = false;
|
||||
for &b in bytes {
|
||||
if b == b'-' {
|
||||
if prev_dash {
|
||||
return false;
|
||||
}
|
||||
prev_dash = true;
|
||||
} else if is_alnum_lower(b) {
|
||||
prev_dash = false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn is_alnum_lower(b: u8) -> bool {
|
||||
b.is_ascii_digit() || b.is_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_basic_slugs() {
|
||||
for s in ["a", "ab", "abc-def", "x9", "a-b-c", "123", "a-1"] {
|
||||
assert!(is_valid_slug(s), "expected `{s}` valid");
|
||||
assert!(Slug::parse(s).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_slugs() {
|
||||
for s in [
|
||||
"", "-", "-foo", "foo-", "Foo", "foo_bar", "foo bar", "foo--bar", "foo.bar", "ä",
|
||||
] {
|
||||
assert!(!is_valid_slug(s), "expected `{s}` invalid");
|
||||
assert!(Slug::parse(s).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_length_bounds() {
|
||||
let too_long = "a".repeat(MAX_LEN + 1);
|
||||
assert!(!is_valid_slug(&too_long));
|
||||
let max = "a".repeat(MAX_LEN);
|
||||
assert!(is_valid_slug(&max));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_via_serde() {
|
||||
let json = "\"valid-slug\"";
|
||||
let slug: Slug = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(slug.as_str(), "valid-slug");
|
||||
|
||||
let bad = "\"BAD\"";
|
||||
let err: Result<Slug, _> = serde_json::from_str(bad);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
}
|
||||
14
crates/llm-worker-macros/Cargo.toml
Normal file
14
crates/llm-worker-macros/Cargo.toml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
[package]
|
||||
name = "llm-worker-macros"
|
||||
description = "llm-worker's proc macros"
|
||||
version = "0.2.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
proc-macro = true
|
||||
|
||||
[dependencies]
|
||||
proc-macro2 = "1"
|
||||
quote = "1"
|
||||
syn = { version = "2", features = ["full"] }
|
||||
9
crates/llm-worker-macros/README.md
Normal file
9
crates/llm-worker-macros/README.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
# llm-worker-macros
|
||||
|
||||
Rust メソッドを LLM 呼び出し可能なツールとして自動登録する手続きマクロクレート。引数構造体・Tool トレイト実装・ToolDefinition を自動生成する。
|
||||
|
||||
## 公開マクロ
|
||||
|
||||
- `#[tool_registry]` — impl ブロックに付与し、内部の `#[tool]` メソッドを一括処理
|
||||
- `#[tool]` — メソッドをツールとしてマーク
|
||||
- `#[description = "..."]` — 引数に説明を付与(JSON Schema の description に反映)
|
||||
321
crates/llm-worker-macros/src/lib.rs
Normal file
321
crates/llm-worker-macros/src/lib.rs
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
//! llm-worker-macros - Procedural macros for Tool generation
|
||||
//!
|
||||
//! Provides `#[tool_registry]` and `#[tool]` macros to
|
||||
//! automatically generate `Tool` trait implementations from user-defined methods.
|
||||
|
||||
use proc_macro::TokenStream;
|
||||
use quote::{format_ident, quote};
|
||||
use syn::{
|
||||
Attribute, FnArg, ImplItem, ItemImpl, Lit, Meta, Pat, ReturnType, Type, parse_macro_input,
|
||||
};
|
||||
|
||||
/// Macro applied to an `impl` block that generates tools from methods marked with `#[tool]`.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// #[tool_registry]
|
||||
/// impl MyApp {
|
||||
/// /// Get user information
|
||||
/// /// Retrieves a user from the database by their ID.
|
||||
/// #[tool]
|
||||
/// async fn get_user(&self, user_id: String) -> Result<User, Error> { ... }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// This generates:
|
||||
/// - `GetUserArgs` struct (for arguments)
|
||||
/// - `Tool_get_user` struct (Tool wrapper)
|
||||
/// - `impl Tool for Tool_get_user`
|
||||
/// - `impl MyApp { fn get_user_tool(&self) -> Tool_get_user }`
|
||||
#[proc_macro_attribute]
|
||||
pub fn tool_registry(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
let mut impl_block = parse_macro_input!(item as ItemImpl);
|
||||
let self_ty = &impl_block.self_ty;
|
||||
|
||||
let mut generated_items = Vec::new();
|
||||
|
||||
for item in &mut impl_block.items {
|
||||
if let ImplItem::Fn(method) = item {
|
||||
// Look for #[tool] attribute
|
||||
let mut is_tool = false;
|
||||
|
||||
// Iterate through attributes to check for tool and remove it
|
||||
method.attrs.retain(|attr| {
|
||||
if attr.path().is_ident("tool") {
|
||||
is_tool = true;
|
||||
false // Remove the attribute
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
|
||||
if is_tool {
|
||||
let tool_impl = generate_tool_impl(self_ty, method);
|
||||
generated_items.push(tool_impl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let expanded = quote! {
|
||||
#impl_block
|
||||
|
||||
#(#generated_items)*
|
||||
};
|
||||
|
||||
TokenStream::from(expanded)
|
||||
}
|
||||
|
||||
/// Extract description from doc comments
|
||||
fn extract_doc_comment(attrs: &[Attribute]) -> String {
|
||||
let mut lines = Vec::new();
|
||||
|
||||
for attr in attrs {
|
||||
if attr.path().is_ident("doc") {
|
||||
if let Meta::NameValue(meta) = &attr.meta {
|
||||
if let syn::Expr::Lit(expr_lit) = &meta.value {
|
||||
if let Lit::Str(lit_str) = &expr_lit.lit {
|
||||
let line = lit_str.value();
|
||||
// Remove only the leading space (after ///)
|
||||
let trimmed = line.strip_prefix(' ').unwrap_or(&line);
|
||||
lines.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Extract description from #[description = "..."] attribute
|
||||
fn extract_description_attr(attrs: &[syn::Attribute]) -> Option<String> {
|
||||
for attr in attrs {
|
||||
if attr.path().is_ident("description") {
|
||||
if let Meta::NameValue(meta) = &attr.meta {
|
||||
if let syn::Expr::Lit(expr_lit) = &meta.value {
|
||||
if let Lit::Str(lit_str) = &expr_lit.lit {
|
||||
return Some(lit_str.value());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Generate Tool implementation from a method
|
||||
fn generate_tool_impl(self_ty: &Type, method: &syn::ImplItemFn) -> proc_macro2::TokenStream {
|
||||
let sig = &method.sig;
|
||||
let method_name = &sig.ident;
|
||||
let tool_name = method_name.to_string();
|
||||
|
||||
// Generate struct names (convert to PascalCase)
|
||||
let pascal_name = to_pascal_case(&method_name.to_string());
|
||||
let tool_struct_name = format_ident!("Tool{}", pascal_name);
|
||||
let args_struct_name = format_ident!("{}Args", pascal_name);
|
||||
let definition_name = format_ident!("{}_definition", method_name);
|
||||
|
||||
// Get description from doc comments
|
||||
let description = extract_doc_comment(&method.attrs);
|
||||
let description = if description.is_empty() {
|
||||
format!("Tool: {}", tool_name)
|
||||
} else {
|
||||
description
|
||||
};
|
||||
|
||||
// Parse arguments (excluding self)
|
||||
let args: Vec<_> = sig
|
||||
.inputs
|
||||
.iter()
|
||||
.filter_map(|arg| {
|
||||
if let FnArg::Typed(pat_type) = arg {
|
||||
Some(pat_type)
|
||||
} else {
|
||||
None // Exclude self
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Generate argument struct fields
|
||||
let arg_fields: Vec<_> = args
|
||||
.iter()
|
||||
.map(|pat_type| {
|
||||
let pat = &pat_type.pat;
|
||||
let ty = &pat_type.ty;
|
||||
let desc = extract_description_attr(&pat_type.attrs);
|
||||
|
||||
// Extract identifier from pattern
|
||||
let field_name = if let Pat::Ident(pat_ident) = pat.as_ref() {
|
||||
&pat_ident.ident
|
||||
} else {
|
||||
panic!("Only simple identifiers are supported for tool arguments");
|
||||
};
|
||||
|
||||
// Convert #[description] to schemars doc if present
|
||||
if let Some(desc_str) = desc {
|
||||
quote! {
|
||||
#[schemars(description = #desc_str)]
|
||||
pub #field_name: #ty
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
pub #field_name: #ty
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Code to expand arguments in execute
|
||||
let arg_names: Vec<_> = args
|
||||
.iter()
|
||||
.map(|pat_type| {
|
||||
if let Pat::Ident(pat_ident) = pat_type.pat.as_ref() {
|
||||
let ident = &pat_ident.ident;
|
||||
quote! { args.#ident }
|
||||
} else {
|
||||
panic!("Only simple identifiers are supported");
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Check if method is async
|
||||
let is_async = sig.asyncness.is_some();
|
||||
|
||||
// Parse return type and determine if Result
|
||||
let awaiter = if is_async {
|
||||
quote! { .await }
|
||||
} else {
|
||||
quote! {}
|
||||
};
|
||||
|
||||
// Determine if return type is Result
|
||||
let result_handling = if is_result_type(&sig.output) {
|
||||
quote! {
|
||||
match result {
|
||||
Ok(val) => Ok(format!("{:?}", val).into()),
|
||||
Err(e) => Err(::llm_worker::tool::ToolError::ExecutionFailed(format!("{}", e))),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
Ok(format!("{:?}", result).into())
|
||||
}
|
||||
};
|
||||
|
||||
// Create empty Args struct if no arguments
|
||||
let args_struct_def = if arg_fields.is_empty() {
|
||||
quote! {
|
||||
#[derive(serde::Deserialize, schemars::JsonSchema)]
|
||||
struct #args_struct_name {}
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
#[derive(serde::Deserialize, schemars::JsonSchema)]
|
||||
struct #args_struct_name {
|
||||
#(#arg_fields),*
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Execute body handling for no arguments case
|
||||
let execute_body = if args.is_empty() {
|
||||
quote! {
|
||||
// Allow empty JSON object even with no arguments
|
||||
let _: #args_struct_name = serde_json::from_str(input_json)
|
||||
.unwrap_or(#args_struct_name {});
|
||||
|
||||
let result = self.ctx.#method_name()#awaiter;
|
||||
#result_handling
|
||||
}
|
||||
} else {
|
||||
quote! {
|
||||
let args: #args_struct_name = serde_json::from_str(input_json)
|
||||
.map_err(|e| ::llm_worker::tool::ToolError::InvalidArgument(e.to_string()))?;
|
||||
|
||||
let result = self.ctx.#method_name(#(#arg_names),*)#awaiter;
|
||||
#result_handling
|
||||
}
|
||||
};
|
||||
|
||||
quote! {
|
||||
#args_struct_def
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct #tool_struct_name {
|
||||
ctx: #self_ty,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl ::llm_worker::tool::Tool for #tool_struct_name {
|
||||
async fn execute(&self, input_json: &str) -> Result<::llm_worker::tool::ToolOutput, ::llm_worker::tool::ToolError> {
|
||||
#execute_body
|
||||
}
|
||||
}
|
||||
|
||||
impl #self_ty {
|
||||
/// Get ToolDefinition (for registering with Worker)
|
||||
pub fn #definition_name(&self) -> ::llm_worker::tool::ToolDefinition {
|
||||
let ctx = self.clone();
|
||||
::std::sync::Arc::new(move || {
|
||||
let schema = schemars::schema_for!(#args_struct_name);
|
||||
let meta = ::llm_worker::tool::ToolMeta::new(#tool_name)
|
||||
.description(#description)
|
||||
.input_schema(serde_json::to_value(schema).unwrap_or(serde_json::json!({})));
|
||||
let tool: ::std::sync::Arc<dyn ::llm_worker::tool::Tool> =
|
||||
::std::sync::Arc::new(#tool_struct_name { ctx: ctx.clone() });
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if return type is Result
|
||||
fn is_result_type(return_type: &ReturnType) -> bool {
|
||||
match return_type {
|
||||
ReturnType::Default => false,
|
||||
ReturnType::Type(_, ty) => {
|
||||
// For Type::Path, check if last segment is "Result"
|
||||
if let Type::Path(type_path) = ty.as_ref() {
|
||||
if let Some(segment) = type_path.path.segments.last() {
|
||||
return segment.ident == "Result";
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert snake_case to PascalCase
|
||||
fn to_pascal_case(s: &str) -> String {
|
||||
s.split('_')
|
||||
.map(|part| {
|
||||
let mut chars = part.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Marker attribute. Does nothing here as it's processed by `tool_registry`.
|
||||
#[proc_macro_attribute]
|
||||
pub fn tool(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
item
|
||||
}
|
||||
|
||||
/// Marker for argument attributes. Interpreted by `tool_registry` during parsing.
|
||||
///
|
||||
/// # Example
|
||||
/// ```ignore
|
||||
/// #[tool]
|
||||
/// async fn get_user(
|
||||
/// &self,
|
||||
/// #[description = "The ID of the user to retrieve"] user_id: String
|
||||
/// ) -> Result<User, Error> { ... }
|
||||
/// ```
|
||||
#[proc_macro_attribute]
|
||||
pub fn description(_attr: TokenStream, item: TokenStream) -> TokenStream {
|
||||
item
|
||||
}
|
||||
29
crates/llm-worker/Cargo.toml
Normal file
29
crates/llm-worker/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
[package]
|
||||
name = "llm-worker"
|
||||
description = "A library for building autonomous LLM-powered systems"
|
||||
version = "0.2.1"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] }
|
||||
tokio-util = "0.7"
|
||||
reqwest = { version = "0.13", default-features = false, features = ["stream", "json", "native-tls", "http2"] }
|
||||
eventsource-stream = "0.2"
|
||||
zstd = "0.13"
|
||||
llm-worker-macros = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
schemars = { workspace = true }
|
||||
tempfile = { workspace = true }
|
||||
dotenv = "0.15"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
trybuild = "1.0.116"
|
||||
wiremock = "0.6.5"
|
||||
23
crates/llm-worker/README.md
Normal file
23
crates/llm-worker/README.md
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# llm-worker
|
||||
|
||||
LLM との対話を管理する低レベル基盤クレート。会話履歴、ツール実行、イベントストリーミング、ライフサイクルフックを統合した `Worker` 抽象を提供する。
|
||||
|
||||
## 公開型
|
||||
|
||||
### コア
|
||||
|
||||
- `Worker<C, S>` — LLM 対話の中央管理(ターン実行、ツール呼び出し、キャンセル)
|
||||
- `WorkerConfig` / `WorkerResult` / `WorkerError` — 設定・実行結果・エラー
|
||||
- `Item` / `ContentPart` / `Role` — 会話履歴の構成要素
|
||||
|
||||
### モジュール
|
||||
|
||||
- `llm_client` — プロバイダ抽象(`LlmClient` トレイト、`Request`, `RequestConfig`, Anthropic/OpenAI/Gemini/Ollama 実装)
|
||||
- `tool` — ツール定義・実行(`Tool` トレイト、`ToolDefinition`, `ToolOutput`, サイズ判定による Inline/Stored 切替)
|
||||
- `tool_server` — ツール登録・ルックアップ(`ToolServer`, `ToolServerHandle`)
|
||||
- `hook` — 実行フローへの介入ポイント(`Hook` トレイト、`PreToolCall`, `PostToolCall`, `OnTurnEnd` など)
|
||||
- クロージャベースイベント購読(`Worker::on_text_block()`, `on_tool_use_block()`, `on_usage()` 等)
|
||||
- `timeline` — イベントストリームのディスパッチ(`Handler` トレイト、各ブロックコレクター)。パワーユーザー向けに `timeline_mut()` も提供
|
||||
- `event` — ストリーミングイベント型(`Event`, `BlockStart`, `BlockDelta` など)
|
||||
- `state` — 型状態パターンによるキャッシュ保護(`Mutable` / `CacheLocked`)
|
||||
cratesの整理Add READMEsRE to all crates@@
|
||||
73
crates/llm-worker/docs/architecture.md
Normal file
73
crates/llm-worker/docs/architecture.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# llm-worker アーキテクチャ
|
||||
|
||||
## 概要
|
||||
|
||||
llm-workerは3層構成でLLMとのインタラクションを管理する。
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Worker (オーケストレーション) │
|
||||
│ ターンループ / フック / ツール実行 │
|
||||
│ Type-state: Mutable ↔ CacheLocked │
|
||||
└───────────┬─────────────────────────────┘
|
||||
│
|
||||
┌───────────▼─────────────────────────────┐
|
||||
│ Timeline (イベント処理) │
|
||||
│ Handler dispatch / Block collectors │
|
||||
└───────────┬─────────────────────────────┘
|
||||
│
|
||||
┌───────────▼─────────────────────────────┐
|
||||
│ LLM Client (プロトコル) │
|
||||
│ Provider (HTTP) / Scheme (変換) │
|
||||
│ Anthropic / OpenAI / Gemini / Ollama │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## モジュール構成
|
||||
|
||||
| モジュール | 責務 | 要件 |
|
||||
|---|---|---|
|
||||
| `worker` | ターンループ、フック統合、ツール実行、Pause/Resume | R1, R4 |
|
||||
| `state` | Type-state (Mutable/CacheLocked) | R2 |
|
||||
| `hook` | Hook trait、10フックポイント | R3, R4 |
|
||||
| `tool` / `tool_server` | ツール定義・登録・実行 | R3 |
|
||||
| `timeline` | イベントストリーム処理、Handler dispatch | — |
|
||||
| `handler` | Handler/Kind trait、ブロック別ハンドラ | — |
|
||||
| `callback` | クロージャベースイベント購読(`on_text_block`, `on_usage` 等) | — |
|
||||
| `llm_client` | LLMプロバイダへのHTTPリクエスト/ストリーミング | — |
|
||||
| `llm_client/scheme` | プロバイダ固有ワイヤーフォーマット変換 | — |
|
||||
| `llm_client/providers` | Anthropic, OpenAI, Gemini, Ollama実装 | — |
|
||||
|
||||
## データフロー
|
||||
|
||||
### リクエスト(送信)
|
||||
```
|
||||
Worker.history (Vec<Item>)
|
||||
→ build_request() → Request { items, tools, config }
|
||||
→ Scheme.build_request() → プロバイダ固有JSON
|
||||
→ Provider.stream() → HTTP POST
|
||||
```
|
||||
|
||||
### レスポンス(受信)
|
||||
```
|
||||
HTTP SSE bytes
|
||||
→ Provider → SSE events
|
||||
→ Scheme.parse_event() → Event (統一型)
|
||||
→ Timeline.dispatch() → Handler.on_event()
|
||||
→ TextBlockCollector / ToolCallCollector
|
||||
→ Worker: 履歴に追加、ツール実行判定
|
||||
```
|
||||
|
||||
## 内部型
|
||||
|
||||
### Item (会話履歴の単位)
|
||||
- `Item::Message` — テキストメッセージ (user/assistant)
|
||||
- `Item::ToolCall` — ツール呼び出し
|
||||
- `Item::ToolResult` — ツール実行結果
|
||||
- `Item::Reasoning` — 思考 (Extended Thinking)
|
||||
|
||||
### Event (ストリーミングイベント)
|
||||
- Meta: `Ping`, `Usage`, `Status`, `Error`
|
||||
- Block: `BlockStart` → `BlockDelta`* → `BlockStop` / `BlockAbort`
|
||||
|
||||
単一の `Event` 型が全層で共有される(`llm_client::event` で定義、他層はre-export)。
|
||||
48
crates/llm-worker/docs/requirements.md
Normal file
48
crates/llm-worker/docs/requirements.md
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# llm-worker 要件
|
||||
|
||||
## 前提
|
||||
|
||||
a. userメッセージを追加しなくてもagentの途中ママ投げれば、AIはそれを自身の生成途中と認識して普通に継続する
|
||||
b. KVキャッシュは速度・効率の面で有利で、コンテキストの事後改変はキャッシュヒット率を大幅に下げる
|
||||
c. ツール・フックの基本的なスキーマ自動化を提供する
|
||||
|
||||
## 要件
|
||||
|
||||
### R1: Resume/Pause
|
||||
|
||||
メッセージの送信と生成のResume、一時停止/再開。
|
||||
|
||||
- `Worker::run()` でターンを開始
|
||||
- フックから `Pause` を返してターンを一時停止
|
||||
- `Worker::resume()` でユーザーメッセージを追加せず継続
|
||||
- AIは中断を認識せず、継続として処理する
|
||||
|
||||
**実装**: `worker.rs` — `resume()`, `get_pending_tool_calls()`, `WorkerResult::Paused`
|
||||
|
||||
### R2: 暗黙的KVキャッシュ保証
|
||||
|
||||
キャッシュを破壊しうる操作を明示的にブロックせずとも、いつの間にかキャッシュ破壊してた状態にはしたくない。
|
||||
|
||||
- Type-stateパターン(`Mutable` / `CacheLocked`)でコンパイル時に保証
|
||||
- `Worker::lock()` でCacheLocked状態に遷移
|
||||
- CacheLocked状態ではシステムプロンプトや履歴の変更APIが型レベルで利用不可
|
||||
- `locked_prefix_len` でプレフィックスの不変性を追跡
|
||||
|
||||
**実装**: `state.rs` (sealed trait), `worker.rs` (state-specific impl blocks)
|
||||
|
||||
### R3: ツール・フックスキーマ自動化
|
||||
|
||||
- `#[tool]` マクロでツール定義を自動生成
|
||||
- `#[tool_registry]` マクロでツールサーバーを自動構成
|
||||
- `Hook` traitで10種のフックポイント
|
||||
|
||||
**実装**: `llm-worker-macros/`, `tool.rs`, `tool_server.rs`, `hook.rs`
|
||||
|
||||
### R4: フックは上層の関心事
|
||||
|
||||
フックはLLMクライアント層ではなく、Worker(オーケストレーション)層に配置する。
|
||||
|
||||
- LLMクライアント (`llm_client/`) はストリーミングとプロトコルのみ
|
||||
- Worker層でフック実行、ツール統合、Pause/Resume制御
|
||||
|
||||
**実装**: `worker.rs` (hook integration), `hook.rs` (trait definitions)
|
||||
231
crates/llm-worker/examples/record_test_fixtures/main.rs
Normal file
231
crates/llm-worker/examples/record_test_fixtures/main.rs
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
//! Test fixture recording tool
|
||||
//!
|
||||
//! Records API responses for defined scenarios.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Show available scenarios
|
||||
//! cargo run --example record_test_fixtures
|
||||
//!
|
||||
//! # Record specific scenario
|
||||
//! ANTHROPIC_API_KEY=your-key cargo run --example record_test_fixtures -- simple_text
|
||||
//! ANTHROPIC_API_KEY=your-key cargo run --example record_test_fixtures -- tool_call
|
||||
//!
|
||||
//! # Record all scenarios
|
||||
//! ANTHROPIC_API_KEY=your-key cargo run --example record_test_fixtures -- --all
|
||||
//! ```
|
||||
|
||||
mod recorder;
|
||||
mod scenarios;
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use llm_worker::llm_client::scheme::{
|
||||
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
|
||||
};
|
||||
use llm_worker::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
|
||||
fn make_transport<S: Scheme>(scheme: S, model: &str, auth: ResolvedAuth) -> HttpTransport<S> {
|
||||
let cap = scheme.default_capability();
|
||||
let base_url = scheme.default_base_url().to_string();
|
||||
HttpTransport::new(scheme, model.to_string(), base_url, auth, cap)
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Scenario name
|
||||
#[arg(short, long)]
|
||||
scenario: Option<String>,
|
||||
|
||||
/// Run all scenarios
|
||||
#[arg(long, default_value_t = false)]
|
||||
all: bool,
|
||||
|
||||
/// Client to use
|
||||
#[arg(short, long, value_enum, default_value_t = ClientType::Anthropic)]
|
||||
client: ClientType,
|
||||
|
||||
/// Model to use (optional, defaults per client)
|
||||
#[arg(short, long)]
|
||||
model: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)]
|
||||
enum ClientType {
|
||||
Anthropic,
|
||||
Gemini,
|
||||
Openai,
|
||||
Ollama,
|
||||
}
|
||||
|
||||
async fn run_scenario_with_anthropic(
|
||||
scenario: &scenarios::TestScenario,
|
||||
subdir: &str,
|
||||
model: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let api_key = std::env::var("ANTHROPIC_API_KEY")
|
||||
.expect("ANTHROPIC_API_KEY environment variable must be set");
|
||||
let model = model.as_deref().unwrap_or("claude-sonnet-4-20250514");
|
||||
let client = make_transport(AnthropicScheme::new(), model, ResolvedAuth::ApiKey(api_key));
|
||||
|
||||
recorder::record_request(
|
||||
&client,
|
||||
scenario.request.clone(),
|
||||
scenario.name,
|
||||
scenario.output_name,
|
||||
subdir,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_scenario_with_openai(
|
||||
scenario: &scenarios::TestScenario,
|
||||
subdir: &str,
|
||||
model: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let api_key =
|
||||
std::env::var("OPENAI_API_KEY").expect("OPENAI_API_KEY environment variable must be set");
|
||||
let model = model.as_deref().unwrap_or("gpt-4o");
|
||||
let client = make_transport(OpenAIScheme::new(), model, ResolvedAuth::ApiKey(api_key));
|
||||
|
||||
recorder::record_request(
|
||||
&client,
|
||||
scenario.request.clone(),
|
||||
scenario.name,
|
||||
scenario.output_name,
|
||||
subdir,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_scenario_with_ollama(
|
||||
scenario: &scenarios::TestScenario,
|
||||
subdir: &str,
|
||||
model: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Ollama = Anthropic scheme + base_url 差し替え + 認証なし
|
||||
let model = model.as_deref().unwrap_or("llama3");
|
||||
let client = HttpTransport::new(
|
||||
AnthropicScheme::new(),
|
||||
model.to_string(),
|
||||
"http://localhost:11434".to_string(),
|
||||
ResolvedAuth::None,
|
||||
AnthropicScheme::new().default_capability(),
|
||||
);
|
||||
|
||||
recorder::record_request(
|
||||
&client,
|
||||
scenario.request.clone(),
|
||||
scenario.name,
|
||||
scenario.output_name,
|
||||
subdir,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_scenario_with_gemini(
|
||||
scenario: &scenarios::TestScenario,
|
||||
subdir: &str,
|
||||
model: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let api_key =
|
||||
std::env::var("GEMINI_API_KEY").expect("GEMINI_API_KEY environment variable must be set");
|
||||
let model = model.as_deref().unwrap_or("gemini-2.0-flash");
|
||||
let client = make_transport(GeminiScheme::new(), model, ResolvedAuth::ApiKey(api_key));
|
||||
|
||||
recorder::record_request(
|
||||
&client,
|
||||
scenario.request.clone(),
|
||||
scenario.name,
|
||||
scenario.output_name,
|
||||
subdir,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
dotenv::dotenv().ok();
|
||||
let args = Args::parse();
|
||||
|
||||
if !args.all && args.scenario.is_none() {
|
||||
use clap::CommandFactory;
|
||||
let mut cmd = Args::command();
|
||||
cmd.error(
|
||||
clap::error::ErrorKind::MissingRequiredArgument,
|
||||
"Either --all or --scenario <SCENARIO> must be provided",
|
||||
)
|
||||
.exit();
|
||||
}
|
||||
|
||||
let all_scenarios = scenarios::scenarios();
|
||||
|
||||
// Determine scenarios to run
|
||||
let scenarios_to_run: Vec<_> = if args.all {
|
||||
all_scenarios
|
||||
} else {
|
||||
let scenario_name = args.scenario.as_ref().unwrap();
|
||||
let found: Vec<_> = all_scenarios
|
||||
.into_iter()
|
||||
.filter(|s| s.output_name == scenario_name)
|
||||
.collect();
|
||||
|
||||
if found.is_empty() {
|
||||
eprintln!("Error: Unknown scenario '{}'", scenario_name);
|
||||
// Verify correct name by listing
|
||||
println!("Available scenarios:");
|
||||
for s in scenarios::scenarios() {
|
||||
println!(" {}", s.output_name);
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
found
|
||||
};
|
||||
|
||||
println!("=== Test Fixture Generator ===");
|
||||
println!("Client: {:?}", args.client);
|
||||
if let Some(ref m) = args.model {
|
||||
println!("Model: {}", m);
|
||||
}
|
||||
println!("Scenarios: {}\n", scenarios_to_run.len());
|
||||
|
||||
let subdir = match args.client {
|
||||
ClientType::Anthropic => "anthropic",
|
||||
ClientType::Gemini => "gemini",
|
||||
ClientType::Openai => "openai",
|
||||
ClientType::Ollama => "ollama",
|
||||
};
|
||||
|
||||
// Scenario filtering is already done in main.rs logic
|
||||
// Here we just execute in a simple loop
|
||||
for scenario in scenarios_to_run {
|
||||
match args.client {
|
||||
ClientType::Anthropic => {
|
||||
run_scenario_with_anthropic(&scenario, subdir, args.model.clone()).await?
|
||||
}
|
||||
ClientType::Gemini => {
|
||||
run_scenario_with_gemini(&scenario, subdir, args.model.clone()).await?
|
||||
}
|
||||
ClientType::Openai => {
|
||||
run_scenario_with_openai(&scenario, subdir, args.model.clone()).await?
|
||||
}
|
||||
ClientType::Ollama => {
|
||||
run_scenario_with_ollama(&scenario, subdir, args.model.clone()).await?
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n✅ Done!");
|
||||
println!("Run tests with: cargo test -p worker");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
101
crates/llm-worker/examples/record_test_fixtures/recorder.rs
Normal file
101
crates/llm-worker/examples/record_test_fixtures/recorder.rs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
//! Test fixture recording mechanism
|
||||
//!
|
||||
//! Saves events to files in JSONL format
|
||||
|
||||
use std::fs::{self, File};
|
||||
use std::io::{BufWriter, Write};
|
||||
use std::path::Path;
|
||||
use std::time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use futures::StreamExt;
|
||||
use llm_worker::llm_client::{LlmClient, Request};
|
||||
|
||||
/// Recorded event
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RecordedEvent {
|
||||
pub elapsed_ms: u64,
|
||||
pub event_type: String,
|
||||
pub data: String,
|
||||
}
|
||||
|
||||
/// Session metadata
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SessionMetadata {
|
||||
pub timestamp: u64,
|
||||
pub model: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
/// Save event sequence to file
|
||||
pub fn save_fixture(
|
||||
path: impl AsRef<Path>,
|
||||
metadata: &SessionMetadata,
|
||||
events: &[RecordedEvent],
|
||||
) -> std::io::Result<()> {
|
||||
let file = File::create(path)?;
|
||||
let mut writer = BufWriter::new(file);
|
||||
|
||||
writeln!(writer, "{}", serde_json::to_string(metadata)?)?;
|
||||
for event in events {
|
||||
writeln!(writer, "{}", serde_json::to_string(event)?)?;
|
||||
}
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send request and record events
|
||||
pub async fn record_request<C: LlmClient>(
|
||||
client: &C,
|
||||
request: Request,
|
||||
description: &str,
|
||||
output_name: &str,
|
||||
subdir: &str, // e.g. "anthropic", "openai"
|
||||
model: &str,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
println!("\n📝 Recording: {}", description);
|
||||
|
||||
let start_time = Instant::now();
|
||||
let mut events: Vec<RecordedEvent> = Vec::new();
|
||||
|
||||
let mut stream = client.stream(request).await?;
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
let elapsed = start_time.elapsed().as_millis() as u64;
|
||||
match result {
|
||||
Ok(event) => {
|
||||
let event_json = serde_json::to_string(&event)?;
|
||||
println!(" [{:>6}ms] {:?}", elapsed, event);
|
||||
events.push(RecordedEvent {
|
||||
elapsed_ms: elapsed,
|
||||
event_type: format!("{:?}", std::mem::discriminant(&event)),
|
||||
data: event_json,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" Error: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save
|
||||
let fixtures_dir = Path::new("worker/tests/fixtures").join(subdir);
|
||||
fs::create_dir_all(&fixtures_dir)?;
|
||||
|
||||
let filepath = fixtures_dir.join(format!("{}.jsonl", output_name));
|
||||
|
||||
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
||||
let metadata = SessionMetadata {
|
||||
timestamp,
|
||||
model: model.to_string(),
|
||||
description: description.to_string(),
|
||||
};
|
||||
|
||||
save_fixture(&filepath, &metadata, &events)?;
|
||||
|
||||
let event_count = events.len();
|
||||
println!(" 💾 Saved: {}", filepath.display());
|
||||
println!(" 📊 {} events recorded", event_count);
|
||||
|
||||
Ok(event_count)
|
||||
}
|
||||
74
crates/llm-worker/examples/record_test_fixtures/scenarios.rs
Normal file
74
crates/llm-worker/examples/record_test_fixtures/scenarios.rs
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
//! Test fixture request definitions
|
||||
//!
|
||||
//! Defines requests and output file names for each scenario
|
||||
|
||||
use llm_worker::llm_client::{Request, ToolDefinition};
|
||||
|
||||
/// Test scenario
|
||||
pub struct TestScenario {
|
||||
/// Scenario name (description)
|
||||
pub name: &'static str,
|
||||
/// Output file name (without extension)
|
||||
pub output_name: &'static str,
|
||||
/// Request
|
||||
pub request: Request,
|
||||
}
|
||||
|
||||
/// Get all test scenarios
|
||||
pub fn scenarios() -> Vec<TestScenario> {
|
||||
vec![
|
||||
simple_text_scenario(),
|
||||
tool_call_scenario(),
|
||||
long_text_scenario(),
|
||||
]
|
||||
}
|
||||
|
||||
/// Simple text response
|
||||
fn simple_text_scenario() -> TestScenario {
|
||||
TestScenario {
|
||||
name: "Simple text response",
|
||||
output_name: "simple_text",
|
||||
request: Request::new()
|
||||
.system("You are a helpful assistant. Be very concise.")
|
||||
.user("Say hello in one word.")
|
||||
.max_tokens(50),
|
||||
}
|
||||
}
|
||||
|
||||
/// Response with tool call
|
||||
fn tool_call_scenario() -> TestScenario {
|
||||
let get_weather_tool = ToolDefinition::new("get_weather")
|
||||
.description("Get the current weather for a city")
|
||||
.input_schema(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"city": {
|
||||
"type": "string",
|
||||
"description": "The city name"
|
||||
}
|
||||
},
|
||||
"required": ["city"]
|
||||
}));
|
||||
|
||||
TestScenario {
|
||||
name: "Tool call response",
|
||||
output_name: "tool_call",
|
||||
request: Request::new()
|
||||
.system("You are a helpful assistant. Use tools when appropriate.")
|
||||
.user("What's the weather in Tokyo? Use the get_weather tool.")
|
||||
.tool(get_weather_tool)
|
||||
.max_tokens(200),
|
||||
}
|
||||
}
|
||||
|
||||
/// Long text generation scenario
|
||||
fn long_text_scenario() -> TestScenario {
|
||||
TestScenario {
|
||||
name: "Long text response",
|
||||
output_name: "long_text",
|
||||
request: Request::new()
|
||||
.system("You are a creative writer.")
|
||||
.user("Write a short story about a robot discovering a garden. It should be at least 300 words.")
|
||||
.max_tokens(1000),
|
||||
}
|
||||
}
|
||||
63
crates/llm-worker/examples/worker_cancel_demo.rs
Normal file
63
crates/llm-worker/examples/worker_cancel_demo.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
//! Worker cancellation demo
|
||||
//!
|
||||
//! Example of cancelling from another thread during streaming
|
||||
|
||||
use llm_worker::llm_client::scheme::{Scheme, anthropic::AnthropicScheme};
|
||||
use llm_worker::llm_client::transport::{HttpTransport, ResolvedAuth};
|
||||
use llm_worker::{Worker, WorkerResult};
|
||||
use std::time::Duration;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Load .env file
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
// Initialize logging
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let api_key =
|
||||
std::env::var("ANTHROPIC_API_KEY").expect("ANTHROPIC_API_KEY environment variable not set");
|
||||
|
||||
let scheme = AnthropicScheme::new();
|
||||
let model = "claude-sonnet-4-20250514".to_string();
|
||||
let cap = scheme.default_capability();
|
||||
let base_url = scheme.default_base_url().to_string();
|
||||
let client = HttpTransport::new(scheme, model, base_url, ResolvedAuth::ApiKey(api_key), cap);
|
||||
let worker = Worker::new(client);
|
||||
|
||||
println!("🚀 Starting Worker...");
|
||||
println!("💡 Will cancel after 2 seconds\n");
|
||||
|
||||
// Get cancel sender before run (Mutable state)
|
||||
let cancel_tx = worker.cancel_sender();
|
||||
|
||||
// Task: Cancel after 2 seconds
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
println!("\n🛑 Cancelling worker...");
|
||||
let _ = cancel_tx.send(()).await;
|
||||
});
|
||||
|
||||
println!("📡 Sending request to LLM...");
|
||||
|
||||
match worker.run("Tell me a very long story about a brave knight. Make it as detailed as possible with many paragraphs.").await {
|
||||
Ok(out) => match out.result {
|
||||
WorkerResult::Finished => println!("✅ Task completed normally"),
|
||||
WorkerResult::Paused => println!("⏸️ Task paused"),
|
||||
WorkerResult::LimitReached => println!("🔒 Turn limit reached"),
|
||||
WorkerResult::Yielded => println!("↩️ Task yielded"),
|
||||
},
|
||||
Err(e) => {
|
||||
println!("❌ Task error: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\n✨ Demo complete!");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
541
crates/llm-worker/examples/worker_cli.rs
Normal file
541
crates/llm-worker/examples/worker_cli.rs
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
//! Interactive CLI client using Worker
|
||||
//!
|
||||
//! A CLI application for interacting with multiple LLM providers (Anthropic, Gemini, OpenAI, Ollama).
|
||||
//! Demonstrates tool registration and execution, and streaming response display.
|
||||
//!
|
||||
//! ## Usage
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Set API keys in .env file
|
||||
//! echo "ANTHROPIC_API_KEY=your-api-key" > .env
|
||||
//! echo "GEMINI_API_KEY=your-api-key" >> .env
|
||||
//! echo "OPENAI_API_KEY=your-api-key" >> .env
|
||||
//!
|
||||
//! # Anthropic (default)
|
||||
//! cargo run --example worker_cli
|
||||
//!
|
||||
//! # Gemini
|
||||
//! cargo run --example worker_cli -- --provider gemini
|
||||
//!
|
||||
//! # OpenAI
|
||||
//! cargo run --example worker_cli -- --provider openai --model gpt-4o
|
||||
//!
|
||||
//! # Ollama (local)
|
||||
//! cargo run --example worker_cli -- --provider ollama --model llama3.2
|
||||
//!
|
||||
//! # With options
|
||||
//! cargo run --example worker_cli -- --provider anthropic --model claude-3-haiku-20240307 --system "You are a helpful assistant."
|
||||
//!
|
||||
//! # Show help
|
||||
//! cargo run --example worker_cli -- --help
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use clap::{Parser, ValueEnum};
|
||||
use llm_worker::{
|
||||
Worker,
|
||||
interceptor::{Interceptor, PostToolAction, ToolResultInfo},
|
||||
llm_client::{
|
||||
LlmClient,
|
||||
capability::{CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport},
|
||||
scheme::{
|
||||
Scheme, anthropic::AnthropicScheme, gemini::GeminiScheme, openai_chat::OpenAIScheme,
|
||||
},
|
||||
transport::{HttpTransport, ResolvedAuth},
|
||||
},
|
||||
timeline::{Handler, TextBlockEvent, TextBlockKind, ToolUseBlockEvent, ToolUseBlockKind},
|
||||
};
|
||||
use llm_worker_macros::tool_registry;
|
||||
|
||||
// Required imports for macro expansion
|
||||
use schemars;
|
||||
use serde;
|
||||
|
||||
// =============================================================================
|
||||
// Provider Definition
|
||||
// =============================================================================
|
||||
|
||||
/// Available LLM providers
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
|
||||
enum Provider {
|
||||
/// Anthropic Claude
|
||||
#[default]
|
||||
Anthropic,
|
||||
/// Google Gemini
|
||||
Gemini,
|
||||
/// OpenAI GPT
|
||||
Openai,
|
||||
/// Ollama (local)
|
||||
Ollama,
|
||||
}
|
||||
|
||||
impl Provider {
|
||||
/// Default model for the provider
|
||||
fn default_model(&self) -> &'static str {
|
||||
match self {
|
||||
Provider::Anthropic => "claude-sonnet-4-20250514",
|
||||
Provider::Gemini => "gemini-2.0-flash",
|
||||
Provider::Openai => "gpt-4o",
|
||||
Provider::Ollama => "llama3.2",
|
||||
}
|
||||
}
|
||||
|
||||
/// Display name for the provider
|
||||
fn display_name(&self) -> &'static str {
|
||||
match self {
|
||||
Provider::Anthropic => "Anthropic Claude",
|
||||
Provider::Gemini => "Google Gemini",
|
||||
Provider::Openai => "OpenAI GPT",
|
||||
Provider::Ollama => "Ollama (Local)",
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment variable name for API key
|
||||
fn env_var_name(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Provider::Anthropic => Some("ANTHROPIC_API_KEY"),
|
||||
Provider::Gemini => Some("GEMINI_API_KEY"),
|
||||
Provider::Openai => Some("OPENAI_API_KEY"),
|
||||
Provider::Ollama => None, // Ollama is local, no key needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CLI Argument Definition
|
||||
// =============================================================================
|
||||
|
||||
/// Interactive CLI client supporting multiple LLM providers
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "worker-cli")]
|
||||
#[command(about = "Interactive CLI client for multiple LLM providers using Worker")]
|
||||
#[command(version)]
|
||||
struct Args {
|
||||
/// Provider to use
|
||||
#[arg(long, value_enum, default_value_t = Provider::Anthropic)]
|
||||
provider: Provider,
|
||||
|
||||
/// Model name to use (defaults to provider's default if not specified)
|
||||
#[arg(short, long)]
|
||||
model: Option<String>,
|
||||
|
||||
/// System prompt
|
||||
#[arg(short, long)]
|
||||
system: Option<String>,
|
||||
|
||||
/// Disable tools
|
||||
#[arg(long, default_value = "false")]
|
||||
no_tools: bool,
|
||||
|
||||
/// Initial message (if specified, sends it and exits)
|
||||
#[arg(short = 'p', long)]
|
||||
prompt: Option<String>,
|
||||
|
||||
/// API key (takes precedence over environment variable)
|
||||
#[arg(long)]
|
||||
api_key: Option<String>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Definition
|
||||
// =============================================================================
|
||||
|
||||
/// Application context
|
||||
#[derive(Clone)]
|
||||
struct AppContext;
|
||||
|
||||
#[tool_registry]
|
||||
impl AppContext {
|
||||
/// Get the current date and time
|
||||
///
|
||||
/// Returns the system's current date and time.
|
||||
#[tool]
|
||||
fn get_current_time(&self) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
// Simple conversion from Unix timestamp
|
||||
format!("Current Unix timestamp: {}", now)
|
||||
}
|
||||
|
||||
/// Perform a simple calculation
|
||||
///
|
||||
/// Executes arithmetic operations on two numbers.
|
||||
#[tool]
|
||||
fn calculate(&self, a: f64, b: f64, operation: String) -> Result<String, String> {
|
||||
let result = match operation.as_str() {
|
||||
"add" | "+" => a + b,
|
||||
"subtract" | "-" => a - b,
|
||||
"multiply" | "*" => a * b,
|
||||
"divide" | "/" => {
|
||||
if b == 0.0 {
|
||||
return Err("Cannot divide by zero".to_string());
|
||||
}
|
||||
a / b
|
||||
}
|
||||
_ => return Err(format!("Unknown operation: {}", operation)),
|
||||
};
|
||||
Ok(format!("{} {} {} = {}", a, operation, b, result))
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Streaming Display Handlers
|
||||
// =============================================================================
|
||||
|
||||
/// Handler that outputs text in real-time
|
||||
struct StreamingPrinter {
|
||||
is_first_delta: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl StreamingPrinter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
is_first_delta: Arc::new(Mutex::new(true)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<TextBlockKind> for StreamingPrinter {
|
||||
type Scope = ();
|
||||
|
||||
fn on_event(&mut self, _scope: &mut (), event: &TextBlockEvent) {
|
||||
match event {
|
||||
TextBlockEvent::Start(_) => {
|
||||
let mut first = self.is_first_delta.lock().unwrap();
|
||||
if *first {
|
||||
print!("\n🤖 ");
|
||||
*first = false;
|
||||
}
|
||||
}
|
||||
TextBlockEvent::Delta(text) => {
|
||||
print!("{}", text);
|
||||
io::stdout().flush().ok();
|
||||
}
|
||||
TextBlockEvent::Stop(_) => {
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handler that displays tool calls
|
||||
struct ToolCallPrinter {
|
||||
call_names: Arc<Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl ToolCallPrinter {
|
||||
fn new(call_names: Arc<Mutex<HashMap<String, String>>>) -> Self {
|
||||
Self { call_names }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ToolCallPrinterScope {
|
||||
input_json: String,
|
||||
}
|
||||
|
||||
impl Handler<ToolUseBlockKind> for ToolCallPrinter {
|
||||
type Scope = ToolCallPrinterScope;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &ToolUseBlockEvent) {
|
||||
match event {
|
||||
ToolUseBlockEvent::Start(start) => {
|
||||
scope.input_json.clear();
|
||||
self.call_names
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(start.id.clone(), start.name.clone());
|
||||
println!("\n🔧 Calling tool: {}", start.name);
|
||||
}
|
||||
ToolUseBlockEvent::InputJsonDelta(json) => {
|
||||
scope.input_json.push_str(json);
|
||||
}
|
||||
ToolUseBlockEvent::Stop(_) => {
|
||||
if scope.input_json.is_empty() {
|
||||
println!(" Args: {{}}");
|
||||
} else {
|
||||
println!(" Args: {}", scope.input_json);
|
||||
}
|
||||
scope.input_json.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Policy that displays tool execution results.
|
||||
struct ToolResultPrinterPolicy {
|
||||
call_names: Arc<Mutex<HashMap<String, String>>>,
|
||||
}
|
||||
|
||||
impl ToolResultPrinterPolicy {
|
||||
fn new(call_names: Arc<Mutex<HashMap<String, String>>>) -> Self {
|
||||
Self { call_names }
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for ToolResultPrinterPolicy {
|
||||
async fn post_tool_call(&self, info: &mut ToolResultInfo) -> PostToolAction {
|
||||
let name = self
|
||||
.call_names
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&info.result.tool_use_id)
|
||||
.unwrap_or_else(|| info.result.tool_use_id.clone());
|
||||
|
||||
if info.result.is_error {
|
||||
println!(" Result ({}): ❌ {}", name, info.result.summary);
|
||||
} else {
|
||||
println!(" Result ({}): ✅ {}", name, info.result.summary);
|
||||
}
|
||||
|
||||
PostToolAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Client Creation
|
||||
// =============================================================================
|
||||
|
||||
/// Get API key based on provider
|
||||
fn get_api_key(args: &Args) -> Result<String, String> {
|
||||
// CLI argument API key takes precedence
|
||||
if let Some(ref key) = args.api_key {
|
||||
return Ok(key.clone());
|
||||
}
|
||||
|
||||
// Check environment variable based on provider
|
||||
if let Some(env_var) = args.provider.env_var_name() {
|
||||
std::env::var(env_var).map_err(|_| {
|
||||
format!(
|
||||
"API key required. Set {} environment variable or use --api-key",
|
||||
env_var
|
||||
)
|
||||
})
|
||||
} else {
|
||||
// Ollama etc. don't need a key
|
||||
Ok(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Create client based on provider
|
||||
fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_transport<S: Scheme>(scheme: S, model: String, auth: ResolvedAuth) -> Box<dyn LlmClient> {
|
||||
let cap = scheme.default_capability();
|
||||
let base_url = scheme.default_base_url().to_string();
|
||||
Box::new(HttpTransport::new(scheme, model, base_url, auth, cap))
|
||||
}
|
||||
|
||||
fn create_client(args: &Args) -> Result<Box<dyn LlmClient>, String> {
|
||||
let model = args
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| args.provider.default_model().to_string());
|
||||
|
||||
let api_key = get_api_key(args)?;
|
||||
|
||||
match args.provider {
|
||||
Provider::Anthropic => Ok(build_transport(
|
||||
AnthropicScheme::new(),
|
||||
model,
|
||||
ResolvedAuth::ApiKey(api_key),
|
||||
)),
|
||||
Provider::Gemini => Ok(build_transport(
|
||||
GeminiScheme::new(),
|
||||
model,
|
||||
ResolvedAuth::ApiKey(api_key),
|
||||
)),
|
||||
Provider::Openai => Ok(build_transport(
|
||||
OpenAIScheme::new(),
|
||||
model,
|
||||
ResolvedAuth::ApiKey(api_key),
|
||||
)),
|
||||
Provider::Ollama => {
|
||||
// Ollama = Anthropic scheme + base_url 差し替え + 認証なし
|
||||
let scheme = AnthropicScheme::new();
|
||||
let cap = default_capability();
|
||||
Ok(Box::new(HttpTransport::new(
|
||||
scheme,
|
||||
model,
|
||||
"http://localhost:11434".to_string(),
|
||||
ResolvedAuth::None,
|
||||
cap,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main
|
||||
// =============================================================================
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Load .env file
|
||||
dotenv::dotenv().ok();
|
||||
|
||||
// Initialize logging
|
||||
// Use RUST_LOG=debug cargo run --example worker_cli ... for detailed logs
|
||||
// Default is warn level, can be overridden with RUST_LOG environment variable
|
||||
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn"));
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(filter)
|
||||
.with_target(true)
|
||||
.init();
|
||||
|
||||
// Parse CLI arguments
|
||||
let args = Args::parse();
|
||||
|
||||
info!(
|
||||
provider = ?args.provider,
|
||||
model = ?args.model,
|
||||
"Starting worker CLI"
|
||||
);
|
||||
|
||||
// Interactive mode or one-shot mode
|
||||
let is_interactive = args.prompt.is_none();
|
||||
|
||||
// Model name (for display)
|
||||
let model_name = args
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| args.provider.default_model().to_string());
|
||||
|
||||
if is_interactive {
|
||||
let title = format!("Worker CLI - {}", args.provider.display_name());
|
||||
let border_len = title.len() + 6;
|
||||
println!("╔{}╗", "═".repeat(border_len));
|
||||
println!("║ {} ║", title);
|
||||
println!("╚{}╝", "═".repeat(border_len));
|
||||
println!();
|
||||
println!("Provider: {}", args.provider.display_name());
|
||||
println!("Model: {}", model_name);
|
||||
if let Some(ref system) = args.system {
|
||||
println!("System: {}", system);
|
||||
}
|
||||
if args.no_tools {
|
||||
println!("Tools: disabled");
|
||||
} else {
|
||||
println!("Tools:");
|
||||
println!(" • get_current_time - Get the current timestamp");
|
||||
println!(" • calculate - Perform arithmetic (add, subtract, multiply, divide)");
|
||||
}
|
||||
println!();
|
||||
println!("Type 'quit' or 'exit' to end the session.");
|
||||
println!("─────────────────────────────────────────────────");
|
||||
}
|
||||
|
||||
// Create client
|
||||
let client = match create_client(&args) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("❌ Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Create Worker
|
||||
let mut worker = Worker::new(client);
|
||||
|
||||
let tool_call_names = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
// Set system prompt
|
||||
if let Some(ref system_prompt) = args.system {
|
||||
worker.set_system_prompt(system_prompt);
|
||||
}
|
||||
|
||||
// Register tools (unless --no-tools)
|
||||
if !args.no_tools {
|
||||
let app = AppContext;
|
||||
worker.register_tool(app.get_current_time_definition());
|
||||
worker.register_tool(app.calculate_definition());
|
||||
}
|
||||
|
||||
// Register streaming display handlers
|
||||
worker
|
||||
.timeline_mut()
|
||||
.on_text_block(StreamingPrinter::new())
|
||||
.on_tool_use_block(ToolCallPrinter::new(tool_call_names.clone()));
|
||||
|
||||
worker.set_interceptor(ToolResultPrinterPolicy::new(tool_call_names));
|
||||
|
||||
// One-shot mode
|
||||
if let Some(prompt) = args.prompt {
|
||||
match worker.run(&prompt).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Interactive loop — first input transitions Mutable → Locked
|
||||
print!("\n👤 You: ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut first_input = String::new();
|
||||
io::stdin().read_line(&mut first_input)?;
|
||||
let first_input = first_input.trim();
|
||||
|
||||
if first_input == "quit" || first_input == "exit" || first_input.is_empty() {
|
||||
println!("\n👋 Goodbye!");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut locked = match worker.run(first_input).await {
|
||||
Ok(out) => out.worker,
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
loop {
|
||||
print!("\n👤 You: ");
|
||||
io::stdout().flush()?;
|
||||
|
||||
let mut input = String::new();
|
||||
io::stdin().read_line(&mut input)?;
|
||||
let input = input.trim();
|
||||
|
||||
if input.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if input == "quit" || input == "exit" {
|
||||
println!("\n👋 Goodbye!");
|
||||
break;
|
||||
}
|
||||
|
||||
match locked.run(input).await {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
eprintln!("\n❌ Error: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
291
crates/llm-worker/src/callback.rs
Normal file
291
crates/llm-worker/src/callback.rs
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
//! Closure-based event callback API
|
||||
//!
|
||||
//! Provides a closure-based alternative to implementing `Handler<K>` directly.
|
||||
//! Register callbacks on `Worker` via `on_text_block()`, `on_tool_use_block()`,
|
||||
//! `on_usage()`, etc.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use crate::handler::{
|
||||
Handler, Kind, TextBlockEvent, TextBlockKind, ThinkingBlockEvent, ThinkingBlockKind,
|
||||
ToolUseBlockEvent, ToolUseBlockKind, ToolUseBlockStart,
|
||||
};
|
||||
use crate::tool::ToolCall;
|
||||
|
||||
// =============================================================================
|
||||
// TextBlock Closure Handler
|
||||
// =============================================================================
|
||||
|
||||
/// Callback scope for a text block.
|
||||
///
|
||||
/// Passed to the setup closure registered with `Worker::on_text_block()`.
|
||||
/// Register per-block callbacks via `on_delta()` and `on_stop()`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// worker.on_text_block(|block| {
|
||||
/// block.on_delta(|text| print!("{}", text));
|
||||
/// block.on_stop(|full_text| println!("\n--- {} chars ---", full_text.len()));
|
||||
/// });
|
||||
/// ```
|
||||
pub struct TextBlockScope {
|
||||
pub(crate) on_delta: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
pub(crate) on_stop: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl TextBlockScope {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
on_delta: None,
|
||||
on_stop: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a callback for each text delta (streaming fragment).
|
||||
pub fn on_delta(&mut self, f: impl FnMut(&str) + Send + Sync + 'static) {
|
||||
self.on_delta = Some(Box::new(f));
|
||||
}
|
||||
|
||||
/// Register a callback invoked when the block completes.
|
||||
///
|
||||
/// Receives the full accumulated text of the block.
|
||||
pub fn on_stop(&mut self, f: impl FnMut(&str) + Send + Sync + 'static) {
|
||||
self.on_stop = Some(Box::new(f));
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-block state created by Timeline's scope lifecycle.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct TextBlockClosureState {
|
||||
on_delta: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
on_stop: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
buffer: String,
|
||||
}
|
||||
|
||||
/// Closure-based `Handler<TextBlockKind>` adapter.
|
||||
pub(crate) struct ClosureTextBlockHandler {
|
||||
pub(crate) setup: Box<dyn FnMut(&mut TextBlockScope) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Handler<TextBlockKind> for ClosureTextBlockHandler {
|
||||
type Scope = TextBlockClosureState;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &TextBlockEvent) {
|
||||
match event {
|
||||
TextBlockEvent::Start(_) => {
|
||||
scope.buffer.clear();
|
||||
let mut builder = TextBlockScope::new();
|
||||
(self.setup)(&mut builder);
|
||||
scope.on_delta = builder.on_delta;
|
||||
scope.on_stop = builder.on_stop;
|
||||
}
|
||||
TextBlockEvent::Delta(text) => {
|
||||
scope.buffer.push_str(text);
|
||||
if let Some(f) = &mut scope.on_delta {
|
||||
f(text);
|
||||
}
|
||||
}
|
||||
TextBlockEvent::Stop(_) => {
|
||||
if let Some(f) = &mut scope.on_stop {
|
||||
f(&scope.buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ThinkingBlock Closure Handler
|
||||
// =============================================================================
|
||||
|
||||
/// Callback scope for a thinking block.
|
||||
///
|
||||
/// Mirrors `TextBlockScope`. Some providers (or some configurations)
|
||||
/// emit thinking metadata without plaintext deltas — in that case the
|
||||
/// block fires `Start` and `Stop` with no `Delta` in between, which is
|
||||
/// expected and not an error.
|
||||
pub struct ThinkingBlockScope {
|
||||
pub(crate) on_delta: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
pub(crate) on_stop: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl ThinkingBlockScope {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
on_delta: None,
|
||||
on_stop: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a callback for each thinking text delta (streaming fragment).
|
||||
pub fn on_delta(&mut self, f: impl FnMut(&str) + Send + Sync + 'static) {
|
||||
self.on_delta = Some(Box::new(f));
|
||||
}
|
||||
|
||||
/// Register a callback invoked when the block completes.
|
||||
///
|
||||
/// Receives the full accumulated thinking text. May be empty when
|
||||
/// the provider didn't emit any plaintext deltas.
|
||||
pub fn on_stop(&mut self, f: impl FnMut(&str) + Send + Sync + 'static) {
|
||||
self.on_stop = Some(Box::new(f));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ThinkingBlockClosureState {
|
||||
on_delta: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
on_stop: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
buffer: String,
|
||||
}
|
||||
|
||||
pub(crate) struct ClosureThinkingBlockHandler {
|
||||
pub(crate) setup: Box<dyn FnMut(&mut ThinkingBlockScope) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Handler<ThinkingBlockKind> for ClosureThinkingBlockHandler {
|
||||
type Scope = ThinkingBlockClosureState;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &ThinkingBlockEvent) {
|
||||
match event {
|
||||
ThinkingBlockEvent::Start(_) => {
|
||||
scope.buffer.clear();
|
||||
let mut builder = ThinkingBlockScope::new();
|
||||
(self.setup)(&mut builder);
|
||||
scope.on_delta = builder.on_delta;
|
||||
scope.on_stop = builder.on_stop;
|
||||
}
|
||||
ThinkingBlockEvent::Delta(text) => {
|
||||
scope.buffer.push_str(text);
|
||||
if let Some(f) = &mut scope.on_delta {
|
||||
f(text);
|
||||
}
|
||||
}
|
||||
ThinkingBlockEvent::Stop(_) => {
|
||||
if let Some(f) = &mut scope.on_stop {
|
||||
f(&scope.buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ToolUseBlock Closure Handler
|
||||
// =============================================================================
|
||||
|
||||
/// Callback scope for a tool use block.
|
||||
///
|
||||
/// Passed to the setup closure registered with `Worker::on_tool_use_block()`.
|
||||
/// The setup closure also receives `&ToolUseBlockStart` with `id` and `name`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// worker.on_tool_use_block(|start, block| {
|
||||
/// println!("Tool: {} ({})", start.name, start.id);
|
||||
/// block.on_delta(|json| { /* streaming JSON fragment */ });
|
||||
/// block.on_stop(|call| println!("Done: {}", call.name));
|
||||
/// });
|
||||
/// ```
|
||||
pub struct ToolUseBlockScope {
|
||||
pub(crate) on_delta: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
pub(crate) on_stop: Option<Box<dyn FnMut(&ToolCall) + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl ToolUseBlockScope {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
on_delta: None,
|
||||
on_stop: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a callback for each JSON input delta (streaming fragment).
|
||||
pub fn on_delta(&mut self, f: impl FnMut(&str) + Send + Sync + 'static) {
|
||||
self.on_delta = Some(Box::new(f));
|
||||
}
|
||||
|
||||
/// Register a callback invoked when the block completes.
|
||||
///
|
||||
/// Receives the fully assembled `ToolCall` with parsed JSON input.
|
||||
pub fn on_stop(&mut self, f: impl FnMut(&ToolCall) + Send + Sync + 'static) {
|
||||
self.on_stop = Some(Box::new(f));
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-block state for tool use closure handler.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct ToolUseBlockClosureState {
|
||||
on_delta: Option<Box<dyn FnMut(&str) + Send + Sync>>,
|
||||
on_stop: Option<Box<dyn FnMut(&ToolCall) + Send + Sync>>,
|
||||
id: String,
|
||||
name: String,
|
||||
input_json: String,
|
||||
}
|
||||
|
||||
/// Closure-based `Handler<ToolUseBlockKind>` adapter.
|
||||
pub(crate) struct ClosureToolUseBlockHandler {
|
||||
pub(crate) setup: Box<dyn FnMut(&ToolUseBlockStart, &mut ToolUseBlockScope) + Send + Sync>,
|
||||
}
|
||||
|
||||
impl Handler<ToolUseBlockKind> for ClosureToolUseBlockHandler {
|
||||
type Scope = ToolUseBlockClosureState;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &ToolUseBlockEvent) {
|
||||
match event {
|
||||
ToolUseBlockEvent::Start(start) => {
|
||||
scope.id = start.id.clone();
|
||||
scope.name = start.name.clone();
|
||||
scope.input_json.clear();
|
||||
let mut builder = ToolUseBlockScope::new();
|
||||
(self.setup)(start, &mut builder);
|
||||
scope.on_delta = builder.on_delta;
|
||||
scope.on_stop = builder.on_stop;
|
||||
}
|
||||
ToolUseBlockEvent::InputJsonDelta(json) => {
|
||||
scope.input_json.push_str(json);
|
||||
if let Some(f) = &mut scope.on_delta {
|
||||
f(json);
|
||||
}
|
||||
}
|
||||
ToolUseBlockEvent::Stop(_) => {
|
||||
let input: serde_json::Value =
|
||||
serde_json::from_str(&scope.input_json).unwrap_or_default();
|
||||
let tool_call = ToolCall {
|
||||
id: std::mem::take(&mut scope.id),
|
||||
name: std::mem::take(&mut scope.name),
|
||||
input,
|
||||
};
|
||||
if let Some(f) = &mut scope.on_stop {
|
||||
f(&tool_call);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Generic Meta Event Closure Handler
|
||||
// =============================================================================
|
||||
|
||||
/// Closure-based `Handler<K>` adapter for meta events (Usage, Status, Error).
|
||||
pub(crate) struct ClosureMetaHandler<F, K>
|
||||
where
|
||||
K: Kind,
|
||||
{
|
||||
pub(crate) callback: F,
|
||||
pub(crate) _kind: PhantomData<K>,
|
||||
}
|
||||
|
||||
impl<F, K> Handler<K> for ClosureMetaHandler<F, K>
|
||||
where
|
||||
F: FnMut(&K::Event) + Send + Sync,
|
||||
K: Kind,
|
||||
{
|
||||
type Scope = ();
|
||||
|
||||
fn on_event(&mut self, _scope: &mut (), event: &K::Event) {
|
||||
(self.callback)(event);
|
||||
}
|
||||
}
|
||||
5
crates/llm-worker/src/event.rs
Normal file
5
crates/llm-worker/src/event.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//! Public event types for Worker layer
|
||||
//!
|
||||
//! Re-exports from the canonical event definitions in llm_client.
|
||||
|
||||
pub use crate::llm_client::event::*;
|
||||
184
crates/llm-worker/src/handler.rs
Normal file
184
crates/llm-worker/src/handler.rs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
//! Handler/Kind Types
|
||||
//!
|
||||
//! Traits for processing events in the Timeline layer.
|
||||
//! By implementing custom handlers and registering them with Timeline,
|
||||
//! you can receive stream events.
|
||||
|
||||
use crate::timeline::event::*;
|
||||
|
||||
// =============================================================================
|
||||
// Kind Trait
|
||||
// =============================================================================
|
||||
|
||||
/// Marker trait defining event types
|
||||
///
|
||||
/// Each Kind specifies its corresponding event type.
|
||||
/// Handlers are implemented for this Kind, and multiple Handlers
|
||||
/// with different Scope types can be registered for the same Kind.
|
||||
pub trait Kind {
|
||||
/// Event type corresponding to this Kind
|
||||
type Event;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Handler Trait
|
||||
// =============================================================================
|
||||
|
||||
/// Handler trait for processing events
|
||||
///
|
||||
/// Defines event processing for a specific `Kind`.
|
||||
/// `Scope` is state held during the block's lifecycle.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use llm_worker::timeline::{Handler, TextBlockEvent, TextBlockKind};
|
||||
///
|
||||
/// struct TextCollector {
|
||||
/// texts: Vec<String>,
|
||||
/// }
|
||||
///
|
||||
/// impl Handler<TextBlockKind> for TextCollector {
|
||||
/// type Scope = String; // Buffer per block
|
||||
///
|
||||
/// fn on_event(&mut self, buffer: &mut String, event: &TextBlockEvent) {
|
||||
/// match event {
|
||||
/// TextBlockEvent::Delta(text) => buffer.push_str(text),
|
||||
/// TextBlockEvent::Stop(_) => {
|
||||
/// self.texts.push(std::mem::take(buffer));
|
||||
/// }
|
||||
/// _ => {}
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub trait Handler<K: Kind> {
|
||||
/// Handler-specific scope type
|
||||
///
|
||||
/// Generated with `Default::default()` at block start,
|
||||
/// and destroyed at block end.
|
||||
type Scope: Default;
|
||||
|
||||
/// Process the event
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &K::Event);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Meta Kind Definitions
|
||||
// =============================================================================
|
||||
|
||||
/// Usage Kind - for usage events
|
||||
pub struct UsageKind;
|
||||
impl Kind for UsageKind {
|
||||
type Event = UsageEvent;
|
||||
}
|
||||
|
||||
/// Ping Kind - for ping events
|
||||
pub struct PingKind;
|
||||
impl Kind for PingKind {
|
||||
type Event = PingEvent;
|
||||
}
|
||||
|
||||
/// Status Kind - for status events
|
||||
pub struct StatusKind;
|
||||
impl Kind for StatusKind {
|
||||
type Event = StatusEvent;
|
||||
}
|
||||
|
||||
/// Error Kind - for error events
|
||||
pub struct ErrorKind;
|
||||
impl Kind for ErrorKind {
|
||||
type Event = ErrorEvent;
|
||||
}
|
||||
|
||||
/// Reasoning item Kind - 完成済み reasoning item の永続化用
|
||||
///
|
||||
/// 1 reasoning item につき 1 度だけ発火する。Worker は
|
||||
/// `ReasoningItemCollector` 経由で受け取り、ターン終了時に
|
||||
/// `Item::Reasoning` として history に append する。
|
||||
pub struct ReasoningItemKind;
|
||||
impl Kind for ReasoningItemKind {
|
||||
type Event = ReasoningItemEvent;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Block Kind Definitions
|
||||
// =============================================================================
|
||||
|
||||
/// TextBlock Kind - for text blocks
|
||||
pub struct TextBlockKind;
|
||||
impl Kind for TextBlockKind {
|
||||
type Event = TextBlockEvent;
|
||||
}
|
||||
|
||||
/// Text block events
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TextBlockEvent {
|
||||
Start(TextBlockStart),
|
||||
Delta(String),
|
||||
Stop(TextBlockStop),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TextBlockStart {
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TextBlockStop {
|
||||
pub index: usize,
|
||||
pub stop_reason: Option<StopReason>,
|
||||
}
|
||||
|
||||
/// ThinkingBlock Kind - for thinking blocks
|
||||
pub struct ThinkingBlockKind;
|
||||
impl Kind for ThinkingBlockKind {
|
||||
type Event = ThinkingBlockEvent;
|
||||
}
|
||||
|
||||
/// Thinking block events
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ThinkingBlockEvent {
|
||||
Start(ThinkingBlockStart),
|
||||
Delta(String),
|
||||
Stop(ThinkingBlockStop),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ThinkingBlockStart {
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ThinkingBlockStop {
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// ToolUseBlock Kind - for tool use blocks
|
||||
pub struct ToolUseBlockKind;
|
||||
impl Kind for ToolUseBlockKind {
|
||||
type Event = ToolUseBlockEvent;
|
||||
}
|
||||
|
||||
/// Tool use block events
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ToolUseBlockEvent {
|
||||
Start(ToolUseBlockStart),
|
||||
/// JSON substring of tool arguments
|
||||
InputJsonDelta(String),
|
||||
Stop(ToolUseBlockStop),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ToolUseBlockStart {
|
||||
pub index: usize,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ToolUseBlockStop {
|
||||
pub index: usize,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
185
crates/llm-worker/src/interceptor.rs
Normal file
185
crates/llm-worker/src/interceptor.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
//! Interceptor - control flow delegation for the Worker execution loop
|
||||
//!
|
||||
//! Defines the [`Interceptor`] trait that upper layers (e.g. Pod) implement
|
||||
//! to inject orchestration decisions (approval, skip, pause, abort)
|
||||
//! into the Worker's turn loop without the Worker knowing about
|
||||
//! higher-level concepts.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::Item;
|
||||
use crate::tool::{Tool, ToolCall, ToolMeta, ToolResult};
|
||||
|
||||
// =============================================================================
|
||||
// Action Enums
|
||||
// =============================================================================
|
||||
|
||||
/// Action after prompt submission.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum PromptAction {
|
||||
/// Proceed normally.
|
||||
Continue,
|
||||
/// Cancel with a reason.
|
||||
Cancel(String),
|
||||
/// Proceed, and append these items to history right after the user
|
||||
/// message. Mirrors [`TurnEndAction::ContinueWithMessages`] for the
|
||||
/// submit edge: lets the upper layer attach resolver-produced
|
||||
/// system messages (e.g. `@<path>` file content) so they sit
|
||||
/// adjacent to the user message that referenced them.
|
||||
ContinueWith(Vec<Item>),
|
||||
}
|
||||
|
||||
/// Action before an LLM request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PreRequestAction {
|
||||
/// Proceed normally.
|
||||
Continue,
|
||||
/// Cancel with a reason (treated as an error).
|
||||
Cancel(String),
|
||||
/// Yield control to the caller for external processing.
|
||||
///
|
||||
/// The Worker exits the turn loop cleanly with `WorkerResult::Yielded`.
|
||||
/// The caller is expected to resume execution later.
|
||||
Yield,
|
||||
}
|
||||
|
||||
/// Action before a tool call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PreToolAction {
|
||||
/// Proceed with execution.
|
||||
Continue,
|
||||
/// Skip this tool call (do not execute).
|
||||
Skip,
|
||||
/// Do not execute the tool call; commit this synthetic result instead.
|
||||
///
|
||||
/// This preserves provider-visible `tool_use` / `tool_result` pairing
|
||||
/// without aborting the whole turn.
|
||||
SyntheticResult(ToolResult),
|
||||
/// Abort the entire run.
|
||||
Abort(String),
|
||||
/// Pause execution (can be resumed later).
|
||||
Pause,
|
||||
}
|
||||
|
||||
/// Action after a tool call.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PostToolAction {
|
||||
/// Proceed normally.
|
||||
Continue,
|
||||
/// Abort the entire run.
|
||||
Abort(String),
|
||||
}
|
||||
|
||||
/// Action at the end of a turn (when LLM produces no tool calls).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TurnEndAction {
|
||||
/// Turn is finished, return to caller.
|
||||
Finish,
|
||||
/// Continue with additional messages injected into history.
|
||||
ContinueWithMessages(Vec<Item>),
|
||||
/// Pause execution (can be resumed later).
|
||||
Pause,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Context Types
|
||||
// =============================================================================
|
||||
|
||||
/// Context for pre-tool-call decisions.
|
||||
pub struct ToolCallInfo {
|
||||
/// Tool call information (modifiable).
|
||||
pub call: ToolCall,
|
||||
/// Tool meta information.
|
||||
pub meta: ToolMeta,
|
||||
/// Tool instance (for state access).
|
||||
pub tool: Arc<dyn Tool>,
|
||||
}
|
||||
|
||||
/// Context for post-tool-call decisions.
|
||||
pub struct ToolResultInfo {
|
||||
/// Original tool call.
|
||||
pub call: ToolCall,
|
||||
/// Tool execution result (modifiable).
|
||||
pub result: ToolResult,
|
||||
/// Tool meta information.
|
||||
pub meta: ToolMeta,
|
||||
/// Tool instance (for state access).
|
||||
pub tool: Arc<dyn Tool>,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Interceptor Trait
|
||||
// =============================================================================
|
||||
|
||||
/// Intercepts the Worker execution loop at key decision points.
|
||||
///
|
||||
/// All methods have default implementations that let the Worker
|
||||
/// proceed without intervention. Upper layers (e.g. Pod) provide
|
||||
/// richer implementations for approval flows, permission checks, etc.
|
||||
#[async_trait]
|
||||
pub trait Interceptor: Send + Sync {
|
||||
/// Called after receiving user input, before adding to history.
|
||||
async fn on_prompt_submit(&self, _item: &mut Item) -> PromptAction {
|
||||
PromptAction::Continue
|
||||
}
|
||||
|
||||
/// Items that should be **committed to `worker.history`** just
|
||||
/// before the next LLM request. Returned items are `extend`ed into
|
||||
/// the persistent history (and therefore picked up by the per-turn
|
||||
/// clone that backs the LLM request, plus the usual
|
||||
/// history-persistence path).
|
||||
///
|
||||
/// Use this for inputs that arrive from outside the LLM and need
|
||||
/// to be reflected in the on-disk history — notifications,
|
||||
/// cross-Pod events, system reminders. Do **not** use
|
||||
/// [`Self::pre_llm_request`] for that purpose: it mutates a
|
||||
/// per-request clone, so any committed assistant response that
|
||||
/// reacts to the injection would have no visible trigger on the
|
||||
/// next turn (or after resume / compaction).
|
||||
///
|
||||
/// `pre_llm_request` remains the right place for purely
|
||||
/// reproducible per-request transformations (pruning, content
|
||||
/// trimming, cache anchors) that depend only on the existing
|
||||
/// history.
|
||||
async fn pending_history_appends(&self) -> Vec<Item> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Called before each LLM request. The context starts as a clone
|
||||
/// of `worker.history` (after `pending_history_appends` and the
|
||||
/// Worker's own prune projection have been applied) and can be
|
||||
/// further modified for that single request only — mutations here
|
||||
/// are **not** persisted back to history. Use
|
||||
/// [`Self::pending_history_appends`] for inputs that need to land
|
||||
/// in history.
|
||||
async fn pre_llm_request(&self, _context: &mut Vec<Item>) -> PreRequestAction {
|
||||
PreRequestAction::Continue
|
||||
}
|
||||
|
||||
/// Called before each tool is executed.
|
||||
async fn pre_tool_call(&self, _info: &mut ToolCallInfo) -> PreToolAction {
|
||||
PreToolAction::Continue
|
||||
}
|
||||
|
||||
/// Called after each tool completes.
|
||||
async fn post_tool_call(&self, _info: &mut ToolResultInfo) -> PostToolAction {
|
||||
PostToolAction::Continue
|
||||
}
|
||||
|
||||
/// Called when a turn ends with no tool calls.
|
||||
async fn on_turn_end(&self, _history: &[Item]) -> TurnEndAction {
|
||||
TurnEndAction::Finish
|
||||
}
|
||||
|
||||
/// Called when execution is interrupted (abort or cancel).
|
||||
async fn on_abort(&self, _reason: &str) {}
|
||||
}
|
||||
|
||||
/// Default interceptor: no intervention. Worker proceeds through the loop
|
||||
/// without any external control flow decisions.
|
||||
pub(crate) struct DefaultInterceptor;
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for DefaultInterceptor {}
|
||||
64
crates/llm-worker/src/lib.rs
Normal file
64
crates/llm-worker/src/lib.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
//! llm-worker - LLM Worker Library
|
||||
//!
|
||||
//! Provides components for managing interactions with LLMs.
|
||||
//!
|
||||
//! # Main Components
|
||||
//!
|
||||
//! - [`Worker`] - Central component for managing LLM interactions
|
||||
//! - [`tool::Tool`] - Tools that can be invoked by the LLM
|
||||
//! - [`interceptor::Interceptor`] - Control-flow delegation for the execution loop
|
||||
//! - Closure-based event callbacks via `Worker::on_text_block()`, `on_tool_use_block()`, etc.
|
||||
//!
|
||||
//! # Quick Start
|
||||
//!
|
||||
//! ```ignore
|
||||
//! use llm_worker::{Worker, Item};
|
||||
//!
|
||||
//! // Create a Worker
|
||||
//! let mut worker = Worker::new(client)
|
||||
//! .system_prompt("You are a helpful assistant.");
|
||||
//!
|
||||
//! // Register tools (optional)
|
||||
//! // worker.register_tool(my_tool_definition)?;
|
||||
//!
|
||||
//! // Run the interaction
|
||||
//! let history = worker.run("Hello!").await?;
|
||||
//! ```
|
||||
//!
|
||||
//! # Cache Protection
|
||||
//!
|
||||
//! `run()` automatically locks the cache. To edit state between turns,
|
||||
//! call `unlock_cache()` first; the next `run()` re-locks automatically.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! worker.run("user input").await?;
|
||||
//! worker.unlock_cache();
|
||||
//! worker.set_system_prompt("new prompt");
|
||||
//! worker.run("next input").await?;
|
||||
//! ```
|
||||
|
||||
mod handler;
|
||||
mod message;
|
||||
mod worker;
|
||||
|
||||
pub(crate) mod callback;
|
||||
pub mod event;
|
||||
pub mod interceptor;
|
||||
pub mod llm_client;
|
||||
pub mod prune;
|
||||
pub mod state;
|
||||
pub mod timeline;
|
||||
pub mod token_counter;
|
||||
pub mod tool;
|
||||
pub mod tool_server;
|
||||
pub mod usage_record;
|
||||
|
||||
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
||||
pub use handler::ToolUseBlockStart;
|
||||
pub use interceptor::Interceptor;
|
||||
pub use message::{ContentPart, Item, Message, Role};
|
||||
pub use tool::{ToolCall, ToolOutputLimits, ToolResult};
|
||||
pub use usage_record::UsageRecord;
|
||||
pub use worker::{
|
||||
LlmRetryNotice, RunOutput, ToolRegistryError, Worker, WorkerConfig, WorkerError, WorkerResult,
|
||||
};
|
||||
57
crates/llm-worker/src/llm_client/auth.rs
Normal file
57
crates/llm-worker/src/llm_client/auth.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//! `Scheme` 実装と通信層が要求する認証要件、および動的認証プロバイダ。
|
||||
//!
|
||||
//! マニフェスト側の型(`ModelConfig` / `SchemeKind` / `AuthRef`)は
|
||||
//! `crates/manifest` に置き、llm-worker はそれを知らずに済む。
|
||||
//! `AuthRequirement` は scheme が宣言する「この scheme はどんな認証を
|
||||
//! 期待するか」のランタイム記述で、manifest 側の `AuthRef` との
|
||||
//! 照合(`AuthRef → ResolvedAuth` 変換の適否)は `crates/provider`
|
||||
//! で行う。
|
||||
//!
|
||||
//! Codex OAuth のようにリクエスト毎にトークンが変わり得る認証は
|
||||
//! [`AuthProvider`] trait を `crates/provider` 側で実装し、
|
||||
//! [`super::transport::ResolvedAuth::Custom`] 経由で transport に渡す。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
|
||||
use super::error::ClientError;
|
||||
|
||||
/// `Scheme::required_auth()` が返す認証要件。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthRequirement {
|
||||
/// 認証を行わない(Ollama など)
|
||||
None,
|
||||
/// `Authorization: Bearer <token>` ヘッダ(token は API key 相当)
|
||||
Bearer,
|
||||
/// `x-api-key: <token>` ヘッダ(Anthropic 形式)
|
||||
XApiKey,
|
||||
/// クエリパラメータ `?<name>=<token>`(Gemini 形式)
|
||||
QueryParam { name: &'static str },
|
||||
/// 複合ヘッダ(Codex OAuth 等、`crates/provider` 側で解決)
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// リクエスト毎に認証ヘッダを動的に組み立てるプロバイダ。
|
||||
///
|
||||
/// Codex OAuth のように access_token が refresh で更新されたり、
|
||||
/// `ChatGPT-Account-Id` / `X-OpenAI-Fedramp` のような複数ヘッダを
|
||||
/// 同時に注入する必要があるケースで使う。実体は `crates/provider`
|
||||
/// 側に置き、llm-worker は trait を知るだけ。
|
||||
///
|
||||
/// 返したヘッダはそのまま `HeaderMap` に挿入される。`Authorization`
|
||||
/// 含む scheme 既定の認証ヘッダは送出されないので、必要なら
|
||||
/// 実装側でセットすること。
|
||||
#[async_trait]
|
||||
pub trait AuthProvider: Send + Sync + std::fmt::Debug {
|
||||
/// 1 リクエスト分の認証ヘッダを返す。refresh が必要なら内部で行う。
|
||||
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError>;
|
||||
|
||||
/// ChatGPT Codex backend 向けの複合認証かどうか。
|
||||
///
|
||||
/// transport は provider crate の具象型を知らないため、この hook だけで
|
||||
/// Codex CLI 互換の wire behavior(conversation header / request compression 等)
|
||||
/// を切り替える。
|
||||
fn is_codex_backend(&self) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
169
crates/llm-worker/src/llm_client/capability.rs
Normal file
169
crates/llm-worker/src/llm_client/capability.rs
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
//! モデル能力メタデータ
|
||||
//!
|
||||
//! `ModelCapability` はモデルが持つ機能差を表現する。scheme は同じでも
|
||||
//! モデルごとに reasoning 可否や prompt caching 方式が違うため、scheme
|
||||
//! から分離して保持する。
|
||||
//!
|
||||
//! 値の供給経路は 2 通り:
|
||||
//! 1. scheme 実装側の `model_id → ModelCapability` 静的テーブル(既知モデル)
|
||||
//! 2. `ModelConfig::capability` での明示 override(未知モデル、または上書き)
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
/// モデル能力メタデータ
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ModelCapability {
|
||||
pub tool_calling: ToolCallingSupport,
|
||||
pub structured_output: StructuredOutput,
|
||||
#[serde(default)]
|
||||
pub reasoning: Option<ReasoningSupport>,
|
||||
#[serde(default)]
|
||||
pub vision: bool,
|
||||
pub prompt_caching: CacheStrategy,
|
||||
}
|
||||
|
||||
impl ModelCapability {
|
||||
/// 何もサポートしない安全側デフォルト。未知モデルのフォールバック用。
|
||||
pub const fn minimal() -> Self {
|
||||
Self {
|
||||
tool_calling: ToolCallingSupport::None,
|
||||
structured_output: StructuredOutput::None,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ツール呼び出しサポート
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallingSupport {
|
||||
/// 非サポート
|
||||
None,
|
||||
/// 1 回のレスポンスで 1 ツールのみ
|
||||
Sequential,
|
||||
/// 1 回のレスポンスで複数ツール並行
|
||||
Parallel,
|
||||
}
|
||||
|
||||
/// Structured output サポート
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum StructuredOutput {
|
||||
None,
|
||||
/// `json_object` モード(スキーマなし JSON 強制)
|
||||
JsonObject,
|
||||
/// JSON Schema 指定で構造化出力
|
||||
JsonSchema,
|
||||
}
|
||||
|
||||
/// Reasoning(extended thinking)サポート
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ReasoningSupport {
|
||||
/// OpenAI 形式: `reasoning.effort` (low/medium/high)
|
||||
Effort,
|
||||
/// Anthropic 形式: `thinking.budget_tokens`
|
||||
BudgetTokens,
|
||||
/// 両対応(内部では共通 `ReasoningControl` として扱い、各 scheme で投影)
|
||||
Both,
|
||||
}
|
||||
|
||||
/// Prompt caching 戦略
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum CacheStrategy {
|
||||
/// Anthropic: `cache_control` マーカーを明示挿入
|
||||
Explicit { max_breakpoints: u8 },
|
||||
/// それ以外: サーバ側自動 prefix、または未サポート
|
||||
Auto,
|
||||
}
|
||||
|
||||
/// Reasoning 制御(共通型、scheme 側で各社形式に投影)。
|
||||
///
|
||||
/// 文字列は provider-native な effort label、数値は provider-native な
|
||||
/// thinking budget token として扱う。どちらか一方だけを型で表現する。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum ReasoningControl {
|
||||
Effort(ReasoningEffort),
|
||||
BudgetTokens(i32),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReasoningEffort {
|
||||
Minimal,
|
||||
Low,
|
||||
Medium,
|
||||
High,
|
||||
XHigh,
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl ReasoningEffort {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
Self::Minimal => "minimal",
|
||||
Self::Low => "low",
|
||||
Self::Medium => "medium",
|
||||
Self::High => "high",
|
||||
Self::XHigh => "xhigh",
|
||||
Self::Other(label) => label.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ReasoningEffort {
|
||||
fn from(value: String) -> Self {
|
||||
match value.as_str() {
|
||||
"minimal" => Self::Minimal,
|
||||
"low" => Self::Low,
|
||||
"medium" => Self::Medium,
|
||||
"high" => Self::High,
|
||||
"xhigh" => Self::XHigh,
|
||||
_ => Self::Other(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for ReasoningEffort {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ReasoningEffort {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
String::deserialize(deserializer).map(Self::from)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ReasoningControl, ReasoningEffort};
|
||||
|
||||
#[test]
|
||||
fn reasoning_control_deserializes_effort_labels() {
|
||||
let known: ReasoningControl = serde_json::from_str(r#""xhigh""#).unwrap();
|
||||
assert_eq!(known, ReasoningControl::Effort(ReasoningEffort::XHigh));
|
||||
|
||||
let unknown: ReasoningControl = serde_json::from_str(r#""provider-native""#).unwrap();
|
||||
assert_eq!(
|
||||
unknown,
|
||||
ReasoningControl::Effort(ReasoningEffort::Other("provider-native".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_control_deserializes_signed_budget() {
|
||||
let dynamic: ReasoningControl = serde_json::from_str("-1").unwrap();
|
||||
assert_eq!(dynamic, ReasoningControl::BudgetTokens(-1));
|
||||
}
|
||||
}
|
||||
98
crates/llm-worker/src/llm_client/client.rs
Normal file
98
crates/llm-worker/src/llm_client/client.rs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
//! LLMクライアント共通trait定義
|
||||
|
||||
use std::pin::Pin;
|
||||
|
||||
use crate::llm_client::{ClientError, Request, RequestConfig, event::Event};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
|
||||
/// 設定に関する警告
|
||||
///
|
||||
/// プロバイダがサポートしていない設定を使用した場合に返される。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfigWarning {
|
||||
/// 設定オプション名
|
||||
pub option_name: &'static str,
|
||||
/// 警告メッセージ
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl ConfigWarning {
|
||||
/// 新しい警告を作成
|
||||
pub fn unsupported(option_name: &'static str, provider_name: &str) -> Self {
|
||||
Self {
|
||||
option_name,
|
||||
message: format!(
|
||||
"'{}' is not supported by {} and will be ignored",
|
||||
option_name, provider_name
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigWarning {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}: {}", self.option_name, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
pub type ResponseStream = Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>;
|
||||
|
||||
/// LLMクライアントのtrait
|
||||
///
|
||||
/// 各プロバイダはこのtraitを実装し、統一されたインターフェースを提供する。
|
||||
#[async_trait]
|
||||
pub trait LlmClient: Send + Sync {
|
||||
/// ストリーミングリクエストを送信し、Eventストリームを返す
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `request` - リクエスト情報
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Stream)` - イベントストリーム
|
||||
/// * `Err(ClientError)` - エラー
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError>;
|
||||
|
||||
/// Clone this client into a new `Box<dyn LlmClient>`.
|
||||
///
|
||||
/// Used when a second client instance is needed (e.g. for context
|
||||
/// compaction) without access to the original construction parameters.
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient>;
|
||||
|
||||
/// 設定をバリデーションし、未サポートの設定があれば警告を返す
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config` - バリデーション対象の設定
|
||||
///
|
||||
/// # Returns
|
||||
/// サポートされていない設定に対する警告のリスト
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
// デフォルト実装: 全ての設定をサポート
|
||||
let _ = config;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Clone for Box<dyn LlmClient> {
|
||||
fn clone(&self) -> Self {
|
||||
self.clone_boxed()
|
||||
}
|
||||
}
|
||||
|
||||
/// `Box<dyn LlmClient>` に対する `LlmClient` の実装
|
||||
///
|
||||
/// これにより、動的ディスパッチを使用するクライアントも `Worker` で利用可能になる。
|
||||
#[async_trait]
|
||||
impl LlmClient for Box<dyn LlmClient> {
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||
(**self).stream(request).await
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
(**self).clone_boxed()
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
(**self).validate_config(config)
|
||||
}
|
||||
}
|
||||
172
crates/llm-worker/src/llm_client/error.rs
Normal file
172
crates/llm-worker/src/llm_client/error.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
//! LLMクライアントエラー型
|
||||
|
||||
use std::{fmt, time::Duration};
|
||||
|
||||
/// LLMクライアントのエラー
|
||||
#[derive(Debug)]
|
||||
pub enum ClientError {
|
||||
/// HTTPリクエストエラー
|
||||
Http(reqwest::Error),
|
||||
/// JSONパースエラー
|
||||
Json(serde_json::Error),
|
||||
/// SSEパースエラー
|
||||
Sse(String),
|
||||
/// APIエラー (プロバイダからのエラーレスポンス)
|
||||
Api {
|
||||
status: Option<u16>,
|
||||
code: Option<String>,
|
||||
message: String,
|
||||
retry_after: Option<Duration>,
|
||||
},
|
||||
/// A request lifecycle phase exceeded its hard timeout.
|
||||
Timeout {
|
||||
phase: &'static str,
|
||||
timeout: Duration,
|
||||
},
|
||||
/// 設定エラー
|
||||
Config(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ClientError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ClientError::Http(e) => write!(f, "HTTP error: {}", e),
|
||||
ClientError::Json(e) => write!(f, "JSON parse error: {}", e),
|
||||
ClientError::Sse(msg) => write!(f, "SSE parse error: {}", msg),
|
||||
ClientError::Api {
|
||||
status,
|
||||
code,
|
||||
message,
|
||||
..
|
||||
} => {
|
||||
write!(f, "API error")?;
|
||||
if let Some(s) = status {
|
||||
write!(f, " (status: {})", s)?;
|
||||
}
|
||||
if let Some(c) = code {
|
||||
write!(f, " [{}]", c)?;
|
||||
}
|
||||
write!(f, ": {}", message)
|
||||
}
|
||||
ClientError::Timeout { phase, timeout } => {
|
||||
write!(f, "{phase} timed out after {}s", timeout.as_secs())
|
||||
}
|
||||
ClientError::Config(msg) => write!(f, "Config error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ClientError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ClientError::Http(e) => Some(e),
|
||||
ClientError::Json(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for ClientError {
|
||||
fn from(err: reqwest::Error) -> Self {
|
||||
ClientError::Http(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for ClientError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
ClientError::Json(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl ClientError {
|
||||
pub fn status(&self) -> Option<u16> {
|
||||
match self {
|
||||
ClientError::Api { status, .. } => *status,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn retry_after(&self) -> Option<Duration> {
|
||||
match self {
|
||||
ClientError::Api { retry_after, .. } => *retry_after,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// transient な失敗としてリトライ対象になるかを判定する。
|
||||
///
|
||||
/// 対象:
|
||||
/// - `Api { status }` のうち 408 / 425 / 429 / 500 / 502 / 503 / 504 / 529
|
||||
/// - `Http(reqwest::Error)` のうち `is_connect()` または `is_timeout()`
|
||||
/// - `Timeout { .. }` の lifecycle hard timeout
|
||||
///
|
||||
/// それ以外(Json、Sse、Config、上記以外の Api ステータス)は false。
|
||||
/// SSE 読み出し開始後の失敗は呼び出し側で `Sse` として上に流すため、
|
||||
/// ここで対象外にしておけば自動的に弾かれる。
|
||||
pub fn is_retryable(error: &ClientError) -> bool {
|
||||
match error {
|
||||
ClientError::Api {
|
||||
status: Some(code), ..
|
||||
} => matches!(*code, 408 | 425 | 429 | 500 | 502 | 503 | 504 | 529),
|
||||
ClientError::Api { status: None, .. } => false,
|
||||
ClientError::Timeout { .. } => true,
|
||||
ClientError::Http(e) => e.is_connect() || e.is_timeout(),
|
||||
ClientError::Json(_) | ClientError::Sse(_) | ClientError::Config(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn api_err(status: Option<u16>) -> ClientError {
|
||||
ClientError::Api {
|
||||
status,
|
||||
code: None,
|
||||
message: String::new(),
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retryable_status_codes() {
|
||||
for code in [408u16, 425, 429, 500, 502, 503, 504, 529] {
|
||||
assert!(
|
||||
is_retryable(&api_err(Some(code))),
|
||||
"status {code} should be retryable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_retryable_status_codes() {
|
||||
for code in [400u16, 401, 403, 404, 409, 410, 422, 501] {
|
||||
assert!(
|
||||
!is_retryable(&api_err(Some(code))),
|
||||
"status {code} should not be retryable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_without_status_not_retryable() {
|
||||
assert!(!is_retryable(&api_err(None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_timeout_is_retryable() {
|
||||
assert!(is_retryable(&ClientError::Timeout {
|
||||
phase: "stream_open",
|
||||
timeout: Duration::from_secs(30),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_sse_config_not_retryable() {
|
||||
let json_err = serde_json::from_str::<serde_json::Value>("not json").unwrap_err();
|
||||
assert!(!is_retryable(&ClientError::Json(json_err)));
|
||||
assert!(!is_retryable(&ClientError::Sse("boom".into())));
|
||||
assert!(!is_retryable(&ClientError::Config("boom".into())));
|
||||
}
|
||||
}
|
||||
359
crates/llm-worker/src/llm_client/event.rs
Normal file
359
crates/llm-worker/src/llm_client/event.rs
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
//! LLMクライアント層のイベント型
|
||||
//!
|
||||
//! 各LLMプロバイダからのストリーミングレスポンスを表現するイベント型。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// =============================================================================
|
||||
// Core Event Types (from llm_client layer)
|
||||
// =============================================================================
|
||||
|
||||
/// LLMからのストリーミングイベント
|
||||
///
|
||||
/// 各LLMプロバイダからのレスポンスは、この`Event`のストリームとして
|
||||
/// 統一的に処理されます。
|
||||
///
|
||||
/// # イベントの種類
|
||||
///
|
||||
/// - **メタイベント**: `Ping`, `Usage`, `Status`, `Error`, `UnhandledSse`
|
||||
/// - **ブロックイベント**: `BlockStart`, `BlockDelta`, `BlockStop`, `BlockAbort`
|
||||
/// - **永続化イベント**: `ReasoningItem` (history に commit すべき完成済み
|
||||
/// reasoning item。streaming 表示用の Thinking BlockStart/Delta/Stop と
|
||||
/// は別経路で発火する)
|
||||
///
|
||||
/// # ブロックのライフサイクル
|
||||
///
|
||||
/// テキストやツール呼び出しは、`BlockStart` → `BlockDelta`(複数) → `BlockStop`
|
||||
/// の順序でイベントが発生します。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Event {
|
||||
/// ハートビート
|
||||
Ping(PingEvent),
|
||||
/// トークン使用量
|
||||
Usage(UsageEvent),
|
||||
/// ストリームのステータス変化
|
||||
Status(StatusEvent),
|
||||
/// エラー発生
|
||||
Error(ErrorEvent),
|
||||
/// Scheme が生成内容として解釈しない未対応 SSE イベント。
|
||||
///
|
||||
/// stream trace 用の観測イベントであり、timeline / history には反映しない。
|
||||
UnhandledSse(UnhandledSseEvent),
|
||||
|
||||
/// ブロック開始(テキスト、ツール使用等)
|
||||
BlockStart(BlockStart),
|
||||
/// ブロックの差分データ
|
||||
BlockDelta(BlockDelta),
|
||||
/// ブロック正常終了
|
||||
BlockStop(BlockStop),
|
||||
/// ブロック中断
|
||||
BlockAbort(BlockAbort),
|
||||
|
||||
/// Reasoning item の完成。scheme が「次の request に送り返すための
|
||||
/// reasoning material が揃った」点で 1 度だけ発火する。
|
||||
///
|
||||
/// - Anthropic: 1 つの `thinking` content_block 完了ごと
|
||||
/// - OpenAI Responses: 1 つの reasoning output_item 完了ごと
|
||||
///
|
||||
/// 上位層(Worker / ReasoningItemCollector)はこれを `Item::Reasoning`
|
||||
/// として `worker.history` に append する。streaming 表示用の
|
||||
/// `BlockStart(Thinking)` / `BlockDelta(Thinking)` / `BlockStop(Thinking)`
|
||||
/// は依然として並行発火する(live display と round-trip persist の責務分離)。
|
||||
ReasoningItem(ReasoningItemEvent),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Meta Events
|
||||
// =============================================================================
|
||||
|
||||
/// Pingイベント(ハートビート)
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct PingEvent {
|
||||
pub timestamp: Option<u64>,
|
||||
}
|
||||
|
||||
/// 使用量イベント
|
||||
///
|
||||
/// プロバイダから受信した 1 LLM リクエスト分のトークン会計。
|
||||
/// 各 scheme で正規化され、フィールドの意味は全プロバイダ共通:
|
||||
///
|
||||
/// - `input_tokens` は **送信した prompt prefix 全体の占有量**(プロンプト全長)。
|
||||
/// キャッシュヒット分も含まれる。Anthropic は raw API では非キャッシュ分のみを
|
||||
/// `input_tokens` として返すため、`AnthropicScheme::convert_usage` で
|
||||
/// `cache_read + cache_creation` を加算してこの規約に揃えている。
|
||||
/// - `cache_read_input_tokens` / `cache_creation_input_tokens` は上記の内訳で、
|
||||
/// 料金会計用。占有量からは差し引かない。
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct UsageEvent {
|
||||
/// 送信した prompt prefix の総トークン数(占有量、キャッシュ込み)
|
||||
pub input_tokens: Option<u64>,
|
||||
/// このリクエストで生成された出力トークン数
|
||||
pub output_tokens: Option<u64>,
|
||||
/// `input_tokens + output_tokens`
|
||||
pub total_tokens: Option<u64>,
|
||||
/// `input_tokens` のうちキャッシュから読まれた分(割引料金)
|
||||
pub cache_read_input_tokens: Option<u64>,
|
||||
/// `input_tokens` のうちこのリクエストでキャッシュに書かれた分(割増料金、Anthropic)
|
||||
pub cache_creation_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// ステータスイベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StatusEvent {
|
||||
pub status: ResponseStatus,
|
||||
}
|
||||
|
||||
/// レスポンスステータス
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum ResponseStatus {
|
||||
/// ストリーム開始
|
||||
Started,
|
||||
/// 正常完了
|
||||
Completed,
|
||||
/// キャンセルされた
|
||||
Cancelled,
|
||||
/// エラー発生
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// エラーイベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ErrorEvent {
|
||||
pub code: Option<String>,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// 未対応 SSE イベントの観測用メタイベント。
|
||||
///
|
||||
/// `data_preview` は provider から受け取った raw SSE data の bounded preview、
|
||||
/// `data_len` は preview 前の raw data byte length。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct UnhandledSseEvent {
|
||||
pub provider: String,
|
||||
pub event_type: String,
|
||||
pub data_preview: String,
|
||||
pub data_len: usize,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Block Types
|
||||
// =============================================================================
|
||||
|
||||
/// ブロックの種別
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum BlockType {
|
||||
/// テキスト生成
|
||||
Text,
|
||||
/// 思考 (Claude Extended Thinking等)
|
||||
Thinking,
|
||||
/// ツール呼び出し
|
||||
ToolUse,
|
||||
/// ツール結果
|
||||
ToolResult,
|
||||
}
|
||||
|
||||
/// ブロック開始イベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockStart {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
/// ブロックの種別
|
||||
pub block_type: BlockType,
|
||||
/// ブロック固有のメタデータ
|
||||
pub metadata: BlockMetadata,
|
||||
}
|
||||
|
||||
impl BlockStart {
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
self.block_type
|
||||
}
|
||||
}
|
||||
|
||||
/// ブロックのメタデータ
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum BlockMetadata {
|
||||
Text,
|
||||
Thinking,
|
||||
ToolUse { id: String, name: String },
|
||||
ToolResult { tool_use_id: String },
|
||||
}
|
||||
|
||||
/// ブロックデルタイベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockDelta {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
/// デルタの内容
|
||||
pub delta: DeltaContent,
|
||||
}
|
||||
|
||||
/// デルタの内容
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum DeltaContent {
|
||||
/// テキストデルタ
|
||||
Text(String),
|
||||
/// 思考デルタ
|
||||
Thinking(String),
|
||||
/// ツール引数のJSON部分文字列
|
||||
InputJson(String),
|
||||
}
|
||||
|
||||
impl DeltaContent {
|
||||
/// デルタのブロック種別を取得
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
match self {
|
||||
DeltaContent::Text(_) => BlockType::Text,
|
||||
DeltaContent::Thinking(_) => BlockType::Thinking,
|
||||
DeltaContent::InputJson(_) => BlockType::ToolUse,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// ブロック停止イベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockStop {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
/// ブロックの種別
|
||||
pub block_type: BlockType,
|
||||
/// 停止理由
|
||||
pub stop_reason: Option<StopReason>,
|
||||
}
|
||||
|
||||
impl BlockStop {
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
self.block_type
|
||||
}
|
||||
}
|
||||
|
||||
/// ブロック中断イベント
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BlockAbort {
|
||||
/// ブロックのインデックス
|
||||
pub index: usize,
|
||||
/// ブロックの種別
|
||||
pub block_type: BlockType,
|
||||
/// 中断理由
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
impl BlockAbort {
|
||||
pub fn block_type(&self) -> BlockType {
|
||||
self.block_type
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Reasoning Item Event
|
||||
// =============================================================================
|
||||
|
||||
/// 完成済み reasoning item。scheme が round-trip に必要なすべての
|
||||
/// material(text, summary, encrypted_content, signature, id)を揃えて
|
||||
/// 1 度だけ発火する。
|
||||
///
|
||||
/// `Item::Reasoning` のフィールドを 1:1 に持つ。
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct ReasoningItemEvent {
|
||||
/// scheme 側で観測した item id(OpenAI Responses の `id`)。
|
||||
pub id: Option<String>,
|
||||
/// reasoning 本体テキスト。Anthropic は `thinking` 累積、OpenAI は
|
||||
/// `reasoning_text` 累積。redacted_thinking では空。
|
||||
pub text: String,
|
||||
/// summary (OpenAI Responses の `summary_text[]`)。他 scheme は空。
|
||||
pub summary: Vec<String>,
|
||||
/// 暗号化された opaque blob(Anthropic `redacted_thinking.data` /
|
||||
/// OpenAI Responses `encrypted_content`)。
|
||||
pub encrypted_content: Option<String>,
|
||||
/// Anthropic extended thinking signature。round-trip 必須。
|
||||
pub signature: Option<String>,
|
||||
}
|
||||
|
||||
/// 停止理由
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum StopReason {
|
||||
/// 自然終了
|
||||
EndTurn,
|
||||
/// 最大トークン数到達
|
||||
MaxTokens,
|
||||
/// ストップシーケンス到達
|
||||
StopSequence,
|
||||
/// ツール使用
|
||||
ToolUse,
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Builder / Factory helpers
|
||||
// =============================================================================
|
||||
|
||||
impl Event {
|
||||
/// テキストブロック開始イベントを作成
|
||||
pub fn text_block_start(index: usize) -> Self {
|
||||
Event::BlockStart(BlockStart {
|
||||
index,
|
||||
block_type: BlockType::Text,
|
||||
metadata: BlockMetadata::Text,
|
||||
})
|
||||
}
|
||||
|
||||
/// テキストデルタイベントを作成
|
||||
pub fn text_delta(index: usize, text: impl Into<String>) -> Self {
|
||||
Event::BlockDelta(BlockDelta {
|
||||
index,
|
||||
delta: DeltaContent::Text(text.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// テキストブロック停止イベントを作成
|
||||
pub fn text_block_stop(index: usize, stop_reason: Option<StopReason>) -> Self {
|
||||
Event::BlockStop(BlockStop {
|
||||
index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
})
|
||||
}
|
||||
|
||||
/// ツール使用ブロック開始イベントを作成
|
||||
pub fn tool_use_start(index: usize, id: impl Into<String>, name: impl Into<String>) -> Self {
|
||||
Event::BlockStart(BlockStart {
|
||||
index,
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// ツール引数デルタイベントを作成
|
||||
pub fn tool_input_delta(index: usize, json: impl Into<String>) -> Self {
|
||||
Event::BlockDelta(BlockDelta {
|
||||
index,
|
||||
delta: DeltaContent::InputJson(json.into()),
|
||||
})
|
||||
}
|
||||
|
||||
/// ツール使用ブロック停止イベントを作成
|
||||
pub fn tool_use_stop(index: usize) -> Self {
|
||||
Event::BlockStop(BlockStop {
|
||||
index,
|
||||
block_type: BlockType::ToolUse,
|
||||
stop_reason: Some(StopReason::ToolUse),
|
||||
})
|
||||
}
|
||||
|
||||
/// 使用量イベントを作成
|
||||
pub fn usage(input_tokens: u64, output_tokens: u64) -> Self {
|
||||
Event::Usage(UsageEvent {
|
||||
input_tokens: Some(input_tokens),
|
||||
output_tokens: Some(output_tokens),
|
||||
total_tokens: Some(input_tokens + output_tokens),
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Pingイベントを作成
|
||||
pub fn ping() -> Self {
|
||||
Event::Ping(PingEvent { timestamp: None })
|
||||
}
|
||||
}
|
||||
35
crates/llm-worker/src/llm_client/mod.rs
Normal file
35
crates/llm-worker/src/llm_client/mod.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
//! LLMクライアント層
|
||||
//!
|
||||
//! 各LLMプロバイダと通信し、統一された[`Event`]
|
||||
//! ストリームを出力します。
|
||||
//!
|
||||
//! # サポートするプロバイダ
|
||||
//!
|
||||
//! - Anthropic (Claude)
|
||||
//! - OpenAI (GPT-4, etc.)
|
||||
//! - Google (Gemini)
|
||||
//! - Ollama (ローカルLLM)
|
||||
//!
|
||||
//! # アーキテクチャ
|
||||
//!
|
||||
//! - [`LlmClient`] - プロバイダ共通のtrait
|
||||
//! - `providers`: プロバイダ固有のクライアント実装
|
||||
//! - `scheme`: APIスキーマ(リクエスト/レスポンス変換)
|
||||
|
||||
pub mod auth;
|
||||
pub mod capability;
|
||||
pub mod client;
|
||||
pub mod error;
|
||||
pub mod event;
|
||||
pub mod types;
|
||||
|
||||
pub mod retry;
|
||||
pub mod scheme;
|
||||
pub mod transport;
|
||||
|
||||
pub use auth::*;
|
||||
pub use capability::*;
|
||||
pub use client::*;
|
||||
pub use error::*;
|
||||
pub use event::*;
|
||||
pub use types::*;
|
||||
104
crates/llm-worker/src/llm_client/retry.rs
Normal file
104
crates/llm-worker/src/llm_client/retry.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
//! LLM response stream を開く前の transient error 向けリトライポリシー。
|
||||
//!
|
||||
//! Worker が `LlmClient::stream` の open error に対して `is_retryable` を見て
|
||||
//! retry / backoff / TUI event / cancellation をまとめて管理する。
|
||||
//! SSE 読み出し開始後の失敗は対象外。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// 指数バックオフ + ジッター + 累積タイムアウトを表すポリシー。
|
||||
///
|
||||
/// `Default` は llm-worker 全体の固定値を返す。manifest 経由の上書きが
|
||||
/// 必要になったら拡張する(現状は不要 → `tickets/llm-worker-transient-retry.md`)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RetryPolicy {
|
||||
/// 指数の基準値。`base * 2^attempt` を `cap` で頭打ちにした上限から
|
||||
/// フルジッターで実際の wait を抽選する。
|
||||
pub base: Duration,
|
||||
/// 1 回あたりの wait の上限。
|
||||
pub cap: Duration,
|
||||
/// 試行の合計回数(初回 + リトライ)。`1` ならリトライしない。
|
||||
pub max_attempts: u32,
|
||||
/// 初回送信開始からの累積タイムアウト。これを超える wait は打ち切る。
|
||||
pub total_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for RetryPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base: Duration::from_millis(500),
|
||||
cap: Duration::from_secs(10),
|
||||
max_attempts: 4,
|
||||
total_timeout: Duration::from_secs(30),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RetryPolicy {
|
||||
/// `attempt` 回目の失敗(0-indexed)後に待つ時間を返す。
|
||||
/// `Retry-After` で上書きしたい場合は呼び出さず、その値をそのまま使う。
|
||||
pub fn backoff(&self, attempt: u32) -> Duration {
|
||||
let shift = attempt.min(20);
|
||||
let base_nanos = self.base.as_nanos() as u64;
|
||||
let exp_nanos = base_nanos.saturating_mul(1u64 << shift);
|
||||
let cap_nanos = self.cap.as_nanos() as u64;
|
||||
let upper = exp_nanos.min(cap_nanos);
|
||||
Duration::from_nanos(jitter_nanos(upper))
|
||||
}
|
||||
}
|
||||
|
||||
/// `[0, max_nanos]` から擬似乱数的に 1 つ取り出す。`SystemTime` の
|
||||
/// 下位ビットを splitmix64 で攪拌するだけの軽量実装で、暗号的乱数性は
|
||||
/// 持たないがフルジッターのぶつかり回避には十分。
|
||||
fn jitter_nanos(max_nanos: u64) -> u64 {
|
||||
if max_nanos == 0 {
|
||||
return 0;
|
||||
}
|
||||
let seed = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos() as u64)
|
||||
.unwrap_or(0);
|
||||
let mut x = seed.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||||
x = (x ^ (x >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||||
x = (x ^ (x >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||||
x ^= x >> 31;
|
||||
x % (max_nanos + 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_policy_values() {
|
||||
let p = RetryPolicy::default();
|
||||
assert_eq!(p.base, Duration::from_millis(500));
|
||||
assert_eq!(p.cap, Duration::from_secs(10));
|
||||
assert_eq!(p.max_attempts, 4);
|
||||
assert_eq!(p.total_timeout, Duration::from_secs(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_respects_cap() {
|
||||
let p = RetryPolicy::default();
|
||||
for attempt in 0..30u32 {
|
||||
assert!(
|
||||
p.backoff(attempt) <= p.cap,
|
||||
"attempt {attempt} exceeded cap",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backoff_zero_when_base_zero() {
|
||||
let p = RetryPolicy {
|
||||
base: Duration::ZERO,
|
||||
cap: Duration::from_secs(10),
|
||||
max_attempts: 4,
|
||||
total_timeout: Duration::from_secs(30),
|
||||
};
|
||||
for attempt in 0..5 {
|
||||
assert_eq!(p.backoff(attempt), Duration::ZERO);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
//! Anthropic scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`claude-*` など)は高レベル構築層
|
||||
//! (`provider::capability`)の責務。ここでは未知モデルでも「この wire で
|
||||
//! 安全に送れる最小共通項」を返すだけに留める。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Scheme 既定の capability。
|
||||
///
|
||||
/// Ollama の `/v1/messages` 流用を想定して `cache_control` を送らない
|
||||
/// `CacheStrategy::Auto` にする。
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
648
crates/llm-worker/src/llm_client/scheme/anthropic/events.rs
Normal file
648
crates/llm-worker/src/llm_client/scheme/anthropic/events.rs
Normal file
|
|
@ -0,0 +1,648 @@
|
|||
//! Anthropic SSEイベントパース
|
||||
//!
|
||||
//! Anthropic Messages APIのSSEイベントをパースし、統一Event型に変換
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
event::{
|
||||
BlockDelta, BlockMetadata, BlockStart, BlockStop, BlockType, DeltaContent, ErrorEvent,
|
||||
Event, PingEvent, ResponseStatus, StatusEvent, UsageEvent,
|
||||
},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::AnthropicScheme;
|
||||
use super::scheme_impl::{AnthropicState, PendingThinking};
|
||||
|
||||
/// Anthropic SSEイベントタイプ
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum AnthropicEventType {
|
||||
MessageStart,
|
||||
ContentBlockStart,
|
||||
ContentBlockDelta,
|
||||
ContentBlockStop,
|
||||
MessageDelta,
|
||||
MessageStop,
|
||||
Ping,
|
||||
Error,
|
||||
}
|
||||
|
||||
impl AnthropicEventType {
|
||||
/// イベントタイプ文字列からパース
|
||||
pub(crate) fn parse(s: &str) -> Option<Self> {
|
||||
match s {
|
||||
"message_start" => Some(Self::MessageStart),
|
||||
"content_block_start" => Some(Self::ContentBlockStart),
|
||||
"content_block_delta" => Some(Self::ContentBlockDelta),
|
||||
"content_block_stop" => Some(Self::ContentBlockStop),
|
||||
"message_delta" => Some(Self::MessageDelta),
|
||||
"message_stop" => Some(Self::MessageStop),
|
||||
"ping" => Some(Self::Ping),
|
||||
"error" => Some(Self::Error),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SSEイベントのJSON構造
|
||||
// ============================================================================
|
||||
|
||||
/// message_start イベント
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MessageStartEvent {
|
||||
pub message: MessageStartMessage,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MessageStartMessage {
|
||||
pub id: String,
|
||||
pub model: String,
|
||||
pub usage: Option<UsageData>,
|
||||
}
|
||||
|
||||
/// content_block_start イベント
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ContentBlockStartEvent {
|
||||
pub index: usize,
|
||||
pub content_block: ContentBlock,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum ContentBlock {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "thinking")]
|
||||
Thinking {
|
||||
#[serde(default)]
|
||||
thinking: String,
|
||||
/// 非ストリーミングレスポンス由来の初期 signature(通常はストリームでは
|
||||
/// 空 → `signature_delta` で埋まる)。
|
||||
#[serde(default)]
|
||||
signature: Option<String>,
|
||||
},
|
||||
#[serde(rename = "redacted_thinking")]
|
||||
RedactedThinking {
|
||||
/// 暗号化された opaque blob。signature ではなく、まるごと
|
||||
/// `redacted_thinking.data` として送り返す必要がある。
|
||||
#[serde(default)]
|
||||
data: String,
|
||||
},
|
||||
#[serde(rename = "tool_use")]
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: serde_json::Value,
|
||||
},
|
||||
}
|
||||
|
||||
/// content_block_delta イベント
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ContentBlockDeltaEvent {
|
||||
pub index: usize,
|
||||
pub delta: DeltaBlock,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum DeltaBlock {
|
||||
#[serde(rename = "text_delta")]
|
||||
TextDelta { text: String },
|
||||
#[serde(rename = "thinking_delta")]
|
||||
ThinkingDelta { thinking: String },
|
||||
#[serde(rename = "input_json_delta")]
|
||||
InputJsonDelta { partial_json: String },
|
||||
#[serde(rename = "signature_delta")]
|
||||
SignatureDelta { signature: String },
|
||||
}
|
||||
|
||||
/// content_block_stop イベント
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ContentBlockStopEvent {
|
||||
pub index: usize,
|
||||
}
|
||||
|
||||
/// message_delta イベント
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MessageDeltaEvent {
|
||||
pub delta: MessageDeltaData,
|
||||
pub usage: Option<UsageData>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct MessageDeltaData {
|
||||
pub stop_reason: Option<String>,
|
||||
pub stop_sequence: Option<String>,
|
||||
}
|
||||
|
||||
/// 使用量データ
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct UsageData {
|
||||
pub input_tokens: Option<u64>,
|
||||
pub output_tokens: Option<u64>,
|
||||
pub cache_read_input_tokens: Option<u64>,
|
||||
pub cache_creation_input_tokens: Option<u64>,
|
||||
}
|
||||
|
||||
/// エラーイベント
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ErrorEventData {
|
||||
pub error: ErrorDetail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ErrorDetail {
|
||||
#[serde(rename = "type")]
|
||||
pub error_type: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// イベント変換
|
||||
// ============================================================================
|
||||
|
||||
impl AnthropicScheme {
|
||||
/// SSEイベントをEvent型に変換
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `event_type` - SSEイベントタイプ
|
||||
/// * `data` - イベントデータJSON文字列
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Some(Event))` - 変換成功
|
||||
/// * `Ok(None)` - イベントを無視(unknown event等)
|
||||
/// * `Err(ClientError)` - パースエラー
|
||||
pub(crate) fn parse_event(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
) -> Result<Option<Event>, ClientError> {
|
||||
let Some(event_type) = AnthropicEventType::parse(event_type) else {
|
||||
// Unknown event type, ignore
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match event_type {
|
||||
AnthropicEventType::MessageStart => {
|
||||
let event: MessageStartEvent = serde_json::from_str(data)?;
|
||||
// message_start時にUsageイベントがあれば出力
|
||||
if let Some(usage) = event.message.usage {
|
||||
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
|
||||
}
|
||||
// Statusイベントとして開始を通知
|
||||
Ok(Some(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Started,
|
||||
})))
|
||||
}
|
||||
AnthropicEventType::ContentBlockStart => {
|
||||
let event: ContentBlockStartEvent = serde_json::from_str(data)?;
|
||||
Ok(Some(self.convert_block_start(&event)))
|
||||
}
|
||||
AnthropicEventType::ContentBlockDelta => {
|
||||
let event: ContentBlockDeltaEvent = serde_json::from_str(data)?;
|
||||
Ok(self.convert_block_delta(&event))
|
||||
}
|
||||
AnthropicEventType::ContentBlockStop => {
|
||||
let event: ContentBlockStopEvent = serde_json::from_str(data)?;
|
||||
// Note: BlockStopにはblock_typeが必要だが、AnthropicはStopイベントに含めない
|
||||
// Timeline層がBlockStartを追跡して正しいblock_typeを知る
|
||||
Ok(Some(Event::BlockStop(BlockStop {
|
||||
index: event.index,
|
||||
block_type: BlockType::Text, // Timeline層で上書きされる
|
||||
stop_reason: None,
|
||||
})))
|
||||
}
|
||||
AnthropicEventType::MessageDelta => {
|
||||
let event: MessageDeltaEvent = serde_json::from_str(data)?;
|
||||
// Usage情報があれば出力
|
||||
if let Some(usage) = event.usage {
|
||||
return Ok(Some(Event::Usage(self.convert_usage(&usage))));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
AnthropicEventType::MessageStop => Ok(Some(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}))),
|
||||
AnthropicEventType::Ping => Ok(Some(Event::Ping(PingEvent { timestamp: None }))),
|
||||
AnthropicEventType::Error => {
|
||||
let event: ErrorEventData = serde_json::from_str(data)?;
|
||||
Ok(Some(Event::Error(ErrorEvent {
|
||||
code: Some(event.error.error_type),
|
||||
message: event.error.message,
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_block_start(&self, event: &ContentBlockStartEvent) -> Event {
|
||||
let (block_type, metadata) = match &event.content_block {
|
||||
ContentBlock::Text { .. } => (BlockType::Text, BlockMetadata::Text),
|
||||
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {
|
||||
(BlockType::Thinking, BlockMetadata::Thinking)
|
||||
}
|
||||
ContentBlock::ToolUse { id, name, .. } => (
|
||||
BlockType::ToolUse,
|
||||
BlockMetadata::ToolUse {
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
Event::BlockStart(BlockStart {
|
||||
index: event.index,
|
||||
block_type,
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
|
||||
fn convert_block_delta(&self, event: &ContentBlockDeltaEvent) -> Option<Event> {
|
||||
let delta = match &event.delta {
|
||||
DeltaBlock::TextDelta { text } => DeltaContent::Text(text.clone()),
|
||||
DeltaBlock::ThinkingDelta { thinking } => DeltaContent::Thinking(thinking.clone()),
|
||||
DeltaBlock::InputJsonDelta { partial_json } => {
|
||||
DeltaContent::InputJson(partial_json.clone())
|
||||
}
|
||||
DeltaBlock::SignatureDelta { .. } => {
|
||||
// signature_delta は無視
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
Some(Event::BlockDelta(BlockDelta {
|
||||
index: event.index,
|
||||
delta,
|
||||
}))
|
||||
}
|
||||
|
||||
/// state を持ち回す上位パース。
|
||||
///
|
||||
/// `parse_event` の単発 Event に加えて、以下を行う:
|
||||
/// - `content_block_stop` の `block_type` を直前の Start 値で書き戻す
|
||||
/// - `thinking` / `redacted_thinking` ブロックの本体・signature・data を
|
||||
/// `state.pending_thinking` に蓄積し、`content_block_stop` で
|
||||
/// `Event::ReasoningItem` を追加発火する
|
||||
/// - `signature_delta` を蓄積(Stream channel には流さず、reasoning event
|
||||
/// にだけ反映する)
|
||||
pub(crate) fn parse_with_state(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
state: &mut AnthropicState,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
let Some(parsed_event_type) = AnthropicEventType::parse(event_type) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
// signature_delta はストリーム表示には流さず、state にだけ蓄積。
|
||||
// それ以外は parse_event で標準 Event 化する。
|
||||
let mut emitted: Vec<Event> = Vec::new();
|
||||
|
||||
match parsed_event_type {
|
||||
AnthropicEventType::ContentBlockStart => {
|
||||
let raw: ContentBlockStartEvent = serde_json::from_str(data)?;
|
||||
state.current_block_type = Some(match &raw.content_block {
|
||||
ContentBlock::Text { .. } => BlockType::Text,
|
||||
ContentBlock::Thinking { .. } | ContentBlock::RedactedThinking { .. } => {
|
||||
BlockType::Thinking
|
||||
}
|
||||
ContentBlock::ToolUse { .. } => BlockType::ToolUse,
|
||||
});
|
||||
match &raw.content_block {
|
||||
ContentBlock::Thinking {
|
||||
thinking,
|
||||
signature,
|
||||
} => {
|
||||
state.pending_thinking = Some(PendingThinking {
|
||||
text: thinking.clone(),
|
||||
signature: signature.clone(),
|
||||
redacted_data: None,
|
||||
});
|
||||
}
|
||||
ContentBlock::RedactedThinking { data: blob } => {
|
||||
state.pending_thinking = Some(PendingThinking {
|
||||
text: String::new(),
|
||||
signature: None,
|
||||
redacted_data: Some(blob.clone()),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
emitted.push(self.convert_block_start(&raw));
|
||||
}
|
||||
AnthropicEventType::ContentBlockDelta => {
|
||||
let raw: ContentBlockDeltaEvent = serde_json::from_str(data)?;
|
||||
match &raw.delta {
|
||||
DeltaBlock::ThinkingDelta { thinking } => {
|
||||
if let Some(pending) = state.pending_thinking.as_mut() {
|
||||
pending.text.push_str(thinking);
|
||||
}
|
||||
emitted.push(Event::BlockDelta(BlockDelta {
|
||||
index: raw.index,
|
||||
delta: DeltaContent::Thinking(thinking.clone()),
|
||||
}));
|
||||
}
|
||||
DeltaBlock::SignatureDelta { signature } => {
|
||||
if let Some(pending) = state.pending_thinking.as_mut() {
|
||||
// 通常 1 回しか来ないが、複数 fragment 来ても連結しておく
|
||||
match &mut pending.signature {
|
||||
Some(acc) => acc.push_str(signature),
|
||||
None => pending.signature = Some(signature.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
DeltaBlock::TextDelta { text } => {
|
||||
emitted.push(Event::BlockDelta(BlockDelta {
|
||||
index: raw.index,
|
||||
delta: DeltaContent::Text(text.clone()),
|
||||
}));
|
||||
}
|
||||
DeltaBlock::InputJsonDelta { partial_json } => {
|
||||
emitted.push(Event::BlockDelta(BlockDelta {
|
||||
index: raw.index,
|
||||
delta: DeltaContent::InputJson(partial_json.clone()),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
AnthropicEventType::ContentBlockStop => {
|
||||
let raw: ContentBlockStopEvent = serde_json::from_str(data)?;
|
||||
let block_type = state.current_block_type.take().unwrap_or(BlockType::Text);
|
||||
emitted.push(Event::BlockStop(BlockStop {
|
||||
index: raw.index,
|
||||
block_type,
|
||||
stop_reason: None,
|
||||
}));
|
||||
if matches!(block_type, BlockType::Thinking) {
|
||||
if let Some(pending) = state.pending_thinking.take() {
|
||||
emitted.push(Event::ReasoningItem(pending.into_event()));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 残りは state を必要としない。既存 parse_event に委譲。
|
||||
_ => {
|
||||
if let Some(event) = self.parse_event(event_type, data)? {
|
||||
emitted.push(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(emitted)
|
||||
}
|
||||
|
||||
fn convert_usage(&self, usage: &UsageData) -> UsageEvent {
|
||||
// Anthropic の `input_tokens` は **キャッシュ外** の入力トークンのみで、
|
||||
// プロンプト全長は input_tokens + cache_read + cache_creation。
|
||||
// UsageEvent の `input_tokens` には「占有量(プロンプト全長)」を載せる
|
||||
// 規約に合わせて、ここでキャッシュ分を足し込む。
|
||||
// cache_read_input_tokens / cache_creation_input_tokens は内訳として
|
||||
// 別フィールドに残るので、料金計算側で `input - cache_read - cache_creation`
|
||||
// により非キャッシュ入力分は逆算可能。
|
||||
let raw_input = usage.input_tokens.unwrap_or(0);
|
||||
let cache_read = usage.cache_read_input_tokens.unwrap_or(0);
|
||||
let cache_creation = usage.cache_creation_input_tokens.unwrap_or(0);
|
||||
let input_total = raw_input + cache_read + cache_creation;
|
||||
let output = usage.output_tokens.unwrap_or(0);
|
||||
|
||||
UsageEvent {
|
||||
input_tokens: usage.input_tokens.map(|_| input_total),
|
||||
output_tokens: usage.output_tokens,
|
||||
total_tokens: Some(input_total + output),
|
||||
cache_read_input_tokens: usage.cache_read_input_tokens,
|
||||
cache_creation_input_tokens: usage.cache_creation_input_tokens,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_message_start() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let data = r#"{"type":"message_start","message":{"id":"msg_123","type":"message","role":"assistant","content":[],"model":"claude-sonnet-4-20250514","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}"#;
|
||||
|
||||
let event = scheme.parse_event("message_start", data).unwrap().unwrap();
|
||||
match event {
|
||||
Event::Usage(u) => {
|
||||
// キャッシュなしなので input_total = raw_input = 10
|
||||
assert_eq!(u.input_tokens, Some(10));
|
||||
}
|
||||
_ => panic!("Expected Usage event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_convert_usage_includes_cache_in_input_total() {
|
||||
// Anthropic の input_tokens はキャッシュ外のみで、占有量は
|
||||
// input + cache_read + cache_creation。
|
||||
// UsageEvent.input_tokens は占有量に正規化される。
|
||||
let scheme = AnthropicScheme::new();
|
||||
let usage = UsageData {
|
||||
input_tokens: Some(100),
|
||||
output_tokens: Some(50),
|
||||
cache_read_input_tokens: Some(800),
|
||||
cache_creation_input_tokens: Some(200),
|
||||
};
|
||||
let event = scheme.convert_usage(&usage);
|
||||
// 100 + 800 + 200 = 1100
|
||||
assert_eq!(event.input_tokens, Some(1100));
|
||||
assert_eq!(event.cache_read_input_tokens, Some(800));
|
||||
assert_eq!(event.cache_creation_input_tokens, Some(200));
|
||||
assert_eq!(event.total_tokens, Some(1150));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_content_block_start_text() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let data =
|
||||
r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#;
|
||||
|
||||
let event = scheme
|
||||
.parse_event("content_block_start", data)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
match event {
|
||||
Event::BlockStart(s) => {
|
||||
assert_eq!(s.index, 0);
|
||||
assert_eq!(s.block_type, BlockType::Text);
|
||||
}
|
||||
_ => panic!("Expected BlockStart event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_content_block_delta_text() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let data = r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
|
||||
|
||||
let event = scheme
|
||||
.parse_event("content_block_delta", data)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
match event {
|
||||
Event::BlockDelta(d) => {
|
||||
assert_eq!(d.index, 0);
|
||||
match d.delta {
|
||||
DeltaContent::Text(t) => assert_eq!(t, "Hello"),
|
||||
_ => panic!("Expected Text delta"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected BlockDelta event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tool_use_start() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let data = r#"{"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_123","name":"get_weather","input":{}}}"#;
|
||||
|
||||
let event = scheme
|
||||
.parse_event("content_block_start", data)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
match event {
|
||||
Event::BlockStart(s) => {
|
||||
assert_eq!(s.block_type, BlockType::ToolUse);
|
||||
match s.metadata {
|
||||
BlockMetadata::ToolUse { id, name } => {
|
||||
assert_eq!(id, "toolu_123");
|
||||
assert_eq!(name, "get_weather");
|
||||
}
|
||||
_ => panic!("Expected ToolUse metadata"),
|
||||
}
|
||||
}
|
||||
_ => panic!("Expected BlockStart event"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_block_emits_reasoning_item_with_signature() {
|
||||
// thinking ブロックが完了したら ReasoningItem に text+signature が乗ること
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut state = AnthropicState::default();
|
||||
|
||||
let evs = scheme
|
||||
.parse_with_state(
|
||||
"content_block_start",
|
||||
r#"{"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(evs[0], Event::BlockStart(_)));
|
||||
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_delta",
|
||||
r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"hello "}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_delta",
|
||||
r#"{"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"world"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_delta",
|
||||
r#"{"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"SIG-XYZ"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let stop_evs = scheme
|
||||
.parse_with_state(
|
||||
"content_block_stop",
|
||||
r#"{"type":"content_block_stop","index":0}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
// BlockStop と ReasoningItem の 2 件が並ぶ
|
||||
assert!(matches!(stop_evs[0], Event::BlockStop(_)));
|
||||
let Event::ReasoningItem(reasoning) = &stop_evs[1] else {
|
||||
panic!("expected ReasoningItem, got {:?}", stop_evs[1]);
|
||||
};
|
||||
assert_eq!(reasoning.text, "hello world");
|
||||
assert_eq!(reasoning.signature.as_deref(), Some("SIG-XYZ"));
|
||||
assert!(reasoning.encrypted_content.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_thinking_emits_reasoning_item_with_data() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut state = AnthropicState::default();
|
||||
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_start",
|
||||
r#"{"type":"content_block_start","index":0,"content_block":{"type":"redacted_thinking","data":"opaque-blob"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
let stop_evs = scheme
|
||||
.parse_with_state(
|
||||
"content_block_stop",
|
||||
r#"{"type":"content_block_stop","index":0}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
let Event::ReasoningItem(reasoning) = &stop_evs[1] else {
|
||||
panic!("expected ReasoningItem");
|
||||
};
|
||||
assert!(reasoning.text.is_empty());
|
||||
assert!(reasoning.signature.is_none());
|
||||
assert_eq!(reasoning.encrypted_content.as_deref(), Some("opaque-blob"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_block_does_not_emit_reasoning_item() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let mut state = AnthropicState::default();
|
||||
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_start",
|
||||
r#"{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
scheme
|
||||
.parse_with_state(
|
||||
"content_block_delta",
|
||||
r#"{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
let stop_evs = scheme
|
||||
.parse_with_state(
|
||||
"content_block_stop",
|
||||
r#"{"type":"content_block_stop","index":0}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stop_evs.len(), 1);
|
||||
assert!(matches!(stop_evs[0], Event::BlockStop(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_ping() {
|
||||
let scheme = AnthropicScheme::new();
|
||||
let data = r#"{"type":"ping"}"#;
|
||||
|
||||
let event = scheme.parse_event("ping", data).unwrap().unwrap();
|
||||
match event {
|
||||
Event::Ping(_) => {}
|
||||
_ => panic!("Expected Ping event"),
|
||||
}
|
||||
}
|
||||
}
|
||||
44
crates/llm-worker/src/llm_client/scheme/anthropic/mod.rs
Normal file
44
crates/llm-worker/src/llm_client/scheme/anthropic/mod.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
//! Anthropic Messages API スキーマ
|
||||
//!
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
pub use scheme_impl::AnthropicState;
|
||||
|
||||
/// Anthropicスキーマ
|
||||
///
|
||||
/// Anthropic Messages APIのリクエスト/レスポンス変換を担当
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AnthropicScheme {
|
||||
/// APIバージョン
|
||||
pub api_version: String,
|
||||
/// 細粒度ツールストリーミングを有効にするか
|
||||
pub fine_grained_tool_streaming: bool,
|
||||
}
|
||||
|
||||
impl Default for AnthropicScheme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
api_version: "2023-06-01".to_string(),
|
||||
fine_grained_tool_streaming: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AnthropicScheme {
|
||||
/// 新しいスキーマを作成
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 細粒度ツールストリーミングを有効/無効にする
|
||||
pub fn with_fine_grained_tool_streaming(mut self, enabled: bool) -> Self {
|
||||
self.fine_grained_tool_streaming = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
1087
crates/llm-worker/src/llm_client/scheme/anthropic/request.rs
Normal file
1087
crates/llm-worker/src/llm_client/scheme/anthropic/request.rs
Normal file
File diff suppressed because it is too large
Load Diff
107
crates/llm-worker/src/llm_client/scheme/anthropic/scheme_impl.rs
Normal file
107
crates/llm-worker/src/llm_client/scheme/anthropic/scheme_impl.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
//! `impl Scheme for AnthropicScheme`
|
||||
//!
|
||||
//! Anthropic Messages API の wire 表現に必要な URL・ヘッダ・SSE パース・
|
||||
//! リクエスト body 生成を共通 `Scheme` trait にぶら下げる。
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
event::{BlockType, Event, ReasoningItemEvent},
|
||||
scheme::Scheme,
|
||||
types::Request,
|
||||
};
|
||||
|
||||
use super::AnthropicScheme;
|
||||
|
||||
/// Anthropic の SSE パースで必要な状態。
|
||||
///
|
||||
/// 1. `content_block_stop` イベントは `block_type` を持たない仕様なので、
|
||||
/// 直前の `content_block_start` で観測した `block_type` を保持して
|
||||
/// `BlockStop` に書き戻す。
|
||||
/// 2. `thinking` ブロック中の `thinking_delta` テキストと `signature_delta`
|
||||
/// 署名、および `redacted_thinking` ブロックの `data` を蓄積し、
|
||||
/// `content_block_stop` で `Event::ReasoningItem` を発火する
|
||||
/// (round-trip 永続化のため)。
|
||||
#[derive(Debug, Default)]
|
||||
pub struct AnthropicState {
|
||||
pub(crate) current_block_type: Option<BlockType>,
|
||||
pub(crate) pending_thinking: Option<PendingThinking>,
|
||||
}
|
||||
|
||||
/// 1 つの `thinking` または `redacted_thinking` content_block の蓄積バッファ。
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct PendingThinking {
|
||||
pub(crate) text: String,
|
||||
pub(crate) signature: Option<String>,
|
||||
pub(crate) redacted_data: Option<String>,
|
||||
}
|
||||
|
||||
impl PendingThinking {
|
||||
pub(crate) fn into_event(self) -> ReasoningItemEvent {
|
||||
ReasoningItemEvent {
|
||||
id: None,
|
||||
text: self.text,
|
||||
summary: Vec::new(),
|
||||
encrypted_content: self.redacted_data,
|
||||
signature: self.signature,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Scheme for AnthropicScheme {
|
||||
type State = AnthropicState;
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://api.anthropic.com"
|
||||
}
|
||||
|
||||
fn path(&self, _model_id: &str) -> String {
|
||||
"/v1/messages".to_string()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
// Ollama の `/v1/messages` 互換では認証が要らないが、それは
|
||||
// `AuthRef::None` + `build_headers` 側の「ResolvedAuth::None
|
||||
// なら何もしない」分岐で吸収する(`accepts` 判定で弾かれない
|
||||
// よう、現状は XApiKey を要求しつつ、None 側でもパスするよう
|
||||
// にする戦略)。
|
||||
AuthRequirement::XApiKey
|
||||
}
|
||||
|
||||
fn additional_headers(&self) -> Vec<(&'static str, String)> {
|
||||
let mut headers = vec![("anthropic-version", self.api_version.clone())];
|
||||
if self.fine_grained_tool_streaming {
|
||||
headers.push((
|
||||
"anthropic-beta",
|
||||
"fine-grained-tool-streaming-2025-05-14".to_string(),
|
||||
));
|
||||
}
|
||||
headers
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value {
|
||||
let req = self.build_request(model_id, request, capability);
|
||||
serde_json::to_value(&req).expect("AnthropicRequest is always serialisable")
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
self.parse_with_state(event_type, data, state)
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
}
|
||||
20
crates/llm-worker/src/llm_client/scheme/gemini/capability.rs
Normal file
20
crates/llm-worker/src/llm_client/scheme/gemini/capability.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! Gemini scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`gemini-*` バージョン別の reasoning 有無)は
|
||||
//! 高レベル構築層(`provider::capability`)の責務。ここでは wire の
|
||||
//! 保守的 default のみ。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Scheme 既定の capability(未知モデル / 未明示モデル用)。
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
328
crates/llm-worker/src/llm_client/scheme/gemini/events.rs
Normal file
328
crates/llm-worker/src/llm_client/scheme/gemini/events.rs
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
//! Gemini SSEイベントパース
|
||||
//!
|
||||
//! Google Gemini APIのSSEイベントをパースし、統一Event型に変換
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
event::{BlockMetadata, BlockStart, BlockStop, BlockType, Event, StopReason, UsageEvent},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::GeminiScheme;
|
||||
|
||||
// ============================================================================
|
||||
// SSEイベントのJSON構造
|
||||
// ============================================================================
|
||||
|
||||
/// Gemini GenerateContentResponse (ストリーミングチャンク)
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GenerateContentResponse {
|
||||
/// 候補
|
||||
pub candidates: Option<Vec<Candidate>>,
|
||||
/// 使用量メタデータ
|
||||
pub usage_metadata: Option<UsageMetadata>,
|
||||
/// プロンプトフィードバック
|
||||
pub prompt_feedback: Option<PromptFeedback>,
|
||||
/// モデルバージョン
|
||||
pub model_version: Option<String>,
|
||||
}
|
||||
|
||||
/// 候補
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct Candidate {
|
||||
/// コンテンツ
|
||||
pub content: Option<CandidateContent>,
|
||||
/// 完了理由
|
||||
pub finish_reason: Option<String>,
|
||||
/// インデックス
|
||||
pub index: Option<usize>,
|
||||
/// 安全性評価
|
||||
pub safety_ratings: Option<Vec<SafetyRating>>,
|
||||
}
|
||||
|
||||
/// 候補コンテンツ
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct CandidateContent {
|
||||
/// パーツ
|
||||
pub parts: Option<Vec<CandidatePart>>,
|
||||
/// ロール
|
||||
pub role: Option<String>,
|
||||
}
|
||||
|
||||
/// 候補パーツ
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct CandidatePart {
|
||||
/// テキスト
|
||||
pub text: Option<String>,
|
||||
/// 関数呼び出し
|
||||
pub function_call: Option<FunctionCall>,
|
||||
}
|
||||
|
||||
/// 関数呼び出し
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct FunctionCall {
|
||||
/// 関数名
|
||||
pub name: String,
|
||||
/// 引数
|
||||
pub args: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 使用量メタデータ
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct UsageMetadata {
|
||||
/// プロンプトトークン数
|
||||
pub prompt_token_count: Option<u64>,
|
||||
/// 候補トークン数
|
||||
pub candidates_token_count: Option<u64>,
|
||||
/// 合計トークン数
|
||||
pub total_token_count: Option<u64>,
|
||||
}
|
||||
|
||||
/// プロンプトフィードバック
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct PromptFeedback {
|
||||
/// ブロック理由
|
||||
pub block_reason: Option<String>,
|
||||
/// 安全性評価
|
||||
pub safety_ratings: Option<Vec<SafetyRating>>,
|
||||
}
|
||||
|
||||
/// 安全性評価
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct SafetyRating {
|
||||
/// カテゴリ
|
||||
pub category: Option<String>,
|
||||
/// 確率
|
||||
pub probability: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// イベント変換
|
||||
// ============================================================================
|
||||
|
||||
impl GeminiScheme {
|
||||
/// SSEデータをEvent型に変換
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `data` - SSEイベントデータJSON文字列
|
||||
///
|
||||
/// # Returns
|
||||
/// * `Ok(Some(Vec<Event>))` - 変換成功
|
||||
/// * `Ok(None)` - イベントを無視
|
||||
/// * `Err(ClientError)` - パースエラー
|
||||
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
// データが空または無効な場合はスキップ
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let response: GenerateContentResponse =
|
||||
serde_json::from_str(data).map_err(|e| ClientError::Api {
|
||||
status: None,
|
||||
code: Some("parse_error".to_string()),
|
||||
message: format!("Failed to parse Gemini SSE data: {} -> {}", e, data),
|
||||
retry_after: None,
|
||||
})?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
// 使用量メタデータ
|
||||
if let Some(usage) = response.usage_metadata {
|
||||
events.push(self.convert_usage(&usage));
|
||||
}
|
||||
|
||||
// 候補を処理
|
||||
if let Some(candidates) = response.candidates {
|
||||
for candidate in candidates {
|
||||
let candidate_index = candidate.index.unwrap_or(0);
|
||||
|
||||
if let Some(content) = candidate.content {
|
||||
if let Some(parts) = content.parts {
|
||||
for (part_index, part) in parts.iter().enumerate() {
|
||||
// テキストデルタ
|
||||
if let Some(text) = &part.text {
|
||||
if !text.is_empty() {
|
||||
// Geminiは明示的なBlockStartを送らないため、
|
||||
// TextDeltaを直接送る(Timelineが暗黙的に開始を処理)
|
||||
events.push(Event::text_delta(part_index, text.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// 関数呼び出し
|
||||
if let Some(function_call) = &part.function_call {
|
||||
// 関数呼び出しの開始
|
||||
// Geminiでは関数呼び出しは一度に送られることが多い
|
||||
// ストリーミング引数が有効な場合は部分的に送られる可能性がある
|
||||
|
||||
// 関数呼び出しIDはGeminiにはないので、名前をIDとして使用
|
||||
let function_id = format!("call_{}", function_call.name);
|
||||
|
||||
events.push(Event::BlockStart(BlockStart {
|
||||
index: candidate_index * 10 + part_index, // 複合インデックス
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse {
|
||||
id: function_id,
|
||||
name: function_call.name.clone(),
|
||||
},
|
||||
}));
|
||||
|
||||
// 引数がある場合はデルタとして送る
|
||||
if let Some(args) = &function_call.args {
|
||||
let args_str = serde_json::to_string(args).unwrap_or_default();
|
||||
if !args_str.is_empty() && args_str != "null" {
|
||||
events.push(Event::tool_input_delta(
|
||||
candidate_index * 10 + part_index,
|
||||
args_str,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 完了理由
|
||||
if let Some(finish_reason) = candidate.finish_reason {
|
||||
let stop_reason = match finish_reason.as_str() {
|
||||
"STOP" => Some(StopReason::EndTurn),
|
||||
"MAX_TOKENS" => Some(StopReason::MaxTokens),
|
||||
"SAFETY" | "RECITATION" | "OTHER" => Some(StopReason::EndTurn),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// テキストブロックの停止
|
||||
events.push(Event::BlockStop(BlockStop {
|
||||
index: candidate_index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if events.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(events))
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_usage(&self, usage: &UsageMetadata) -> Event {
|
||||
Event::Usage(UsageEvent {
|
||||
input_tokens: usage.prompt_token_count,
|
||||
output_tokens: usage.candidates_token_count,
|
||||
total_tokens: usage.total_token_count,
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::event::DeltaContent;
|
||||
|
||||
#[test]
|
||||
fn test_parse_text_response() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data =
|
||||
r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
assert_eq!(delta.index, 0);
|
||||
if let DeltaContent::Text(text) = &delta.delta {
|
||||
assert_eq!(text, "Hello");
|
||||
} else {
|
||||
panic!("Expected text delta");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected BlockDelta");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_usage_metadata() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
|
||||
// Usageイベントが含まれるはず
|
||||
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
|
||||
assert!(usage_event.is_some());
|
||||
|
||||
if let Event::Usage(usage) = usage_event.unwrap() {
|
||||
assert_eq!(usage.input_tokens, Some(10));
|
||||
assert_eq!(usage.output_tokens, Some(5));
|
||||
assert_eq!(usage.total_tokens, Some(15));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_function_call() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"Tokyo"}}}],"role":"model"},"index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
|
||||
// BlockStartイベントがあるはず
|
||||
let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_)));
|
||||
assert!(start_event.is_some());
|
||||
|
||||
if let Event::BlockStart(start) = start_event.unwrap() {
|
||||
assert_eq!(start.block_type, BlockType::ToolUse);
|
||||
if let BlockMetadata::ToolUse { id: _, name } = &start.metadata {
|
||||
assert_eq!(name, "get_weather");
|
||||
} else {
|
||||
panic!("Expected ToolUse metadata");
|
||||
}
|
||||
}
|
||||
|
||||
// 引数デルタもあるはず
|
||||
let delta_event = events.iter().find(|e| {
|
||||
if let Event::BlockDelta(d) = e {
|
||||
matches!(d.delta, DeltaContent::InputJson(_))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
assert!(delta_event.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_finish_reason() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Done"}],"role":"model"},"finishReason":"STOP","index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
|
||||
// BlockStopイベントがあるはず
|
||||
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
|
||||
assert!(stop_event.is_some());
|
||||
|
||||
if let Event::BlockStop(stop) = stop_event.unwrap() {
|
||||
assert_eq!(stop.stop_reason, Some(StopReason::EndTurn));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_data() {
|
||||
let scheme = GeminiScheme::new();
|
||||
assert!(scheme.parse_event("").unwrap().is_none());
|
||||
assert!(scheme.parse_event("[DONE]").unwrap().is_none());
|
||||
}
|
||||
}
|
||||
31
crates/llm-worker/src/llm_client/scheme/gemini/mod.rs
Normal file
31
crates/llm-worker/src/llm_client/scheme/gemini/mod.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
//! Google Gemini API スキーマ
|
||||
//!
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
/// Geminiスキーマ
|
||||
///
|
||||
/// Google Gemini APIのリクエスト/レスポンス変換を担当
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct GeminiScheme {
|
||||
/// ストリーミング関数呼び出し引数を有効にするか
|
||||
pub stream_function_call_arguments: bool,
|
||||
}
|
||||
|
||||
impl GeminiScheme {
|
||||
/// 新しいスキーマを作成
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// ストリーミング関数呼び出し引数を有効/無効にする
|
||||
pub fn with_stream_function_call_arguments(mut self, enabled: bool) -> Self {
|
||||
self.stream_function_call_arguments = enabled;
|
||||
self
|
||||
}
|
||||
}
|
||||
496
crates/llm-worker/src/llm_client/scheme/gemini/request.rs
Normal file
496
crates/llm-worker/src/llm_client/scheme/gemini/request.rs
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
//! Gemini Request Builder
|
||||
//!
|
||||
//! Converts Open Responses native Item model to Google Gemini API format.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
Request,
|
||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||
types::{Item, Role, ToolDefinition, parse_tool_arguments},
|
||||
};
|
||||
|
||||
use super::GeminiScheme;
|
||||
|
||||
/// Gemini API request body
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiRequest {
|
||||
/// Contents (conversation history)
|
||||
pub contents: Vec<GeminiContent>,
|
||||
/// System instruction
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system_instruction: Option<GeminiContent>,
|
||||
/// Tool definitions
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<GeminiTool>,
|
||||
/// Tool config
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_config: Option<GeminiToolConfig>,
|
||||
/// Generation config
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub generation_config: Option<GeminiGenerationConfig>,
|
||||
}
|
||||
|
||||
/// Gemini content
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiContent {
|
||||
/// Role
|
||||
pub role: String,
|
||||
/// Parts
|
||||
pub parts: Vec<GeminiPart>,
|
||||
}
|
||||
|
||||
/// Gemini part
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum GeminiPart {
|
||||
/// Text part
|
||||
Text { text: String },
|
||||
/// Function call part
|
||||
FunctionCall {
|
||||
#[serde(rename = "functionCall")]
|
||||
function_call: GeminiFunctionCall,
|
||||
},
|
||||
/// Function response part
|
||||
FunctionResponse {
|
||||
#[serde(rename = "functionResponse")]
|
||||
function_response: GeminiFunctionResponse,
|
||||
},
|
||||
}
|
||||
|
||||
/// Gemini function call
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiFunctionCall {
|
||||
pub name: String,
|
||||
pub args: Value,
|
||||
}
|
||||
|
||||
/// Gemini function response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiFunctionResponse {
|
||||
pub name: String,
|
||||
pub response: GeminiFunctionResponseContent,
|
||||
}
|
||||
|
||||
/// Gemini function response content
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiFunctionResponseContent {
|
||||
pub name: String,
|
||||
pub content: Value,
|
||||
}
|
||||
|
||||
/// Gemini tool definition
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiTool {
|
||||
/// Function declarations
|
||||
pub function_declarations: Vec<GeminiFunctionDeclaration>,
|
||||
}
|
||||
|
||||
/// Gemini function declaration
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct GeminiFunctionDeclaration {
|
||||
/// Function name
|
||||
pub name: String,
|
||||
/// Description
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// Parameter schema
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// Gemini tool config
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiToolConfig {
|
||||
/// Function calling config
|
||||
pub function_calling_config: GeminiFunctionCallingConfig,
|
||||
}
|
||||
|
||||
/// Gemini function calling config
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiFunctionCallingConfig {
|
||||
/// Mode: AUTO, ANY, NONE
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mode: Option<String>,
|
||||
/// Enable streaming function call arguments
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_function_call_arguments: Option<bool>,
|
||||
}
|
||||
|
||||
/// Gemini generation config
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiGenerationConfig {
|
||||
/// Max output tokens
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<u32>,
|
||||
/// Temperature
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
/// Top P
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// Top K
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_k: Option<u32>,
|
||||
/// Stop sequences
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub stop_sequences: Vec<String>,
|
||||
/// Thinking / reasoning 設定(Gemini 2.5 以降)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thinking_config: Option<GeminiThinkingConfig>,
|
||||
}
|
||||
|
||||
/// Gemini thinking config (gemini-2.5 以降)
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct GeminiThinkingConfig {
|
||||
/// Token budget for thinking. `-1` means dynamic.
|
||||
pub thinking_budget: i32,
|
||||
}
|
||||
|
||||
impl GeminiScheme {
|
||||
/// Build Gemini request from Request
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> GeminiRequest {
|
||||
let contents = self.convert_items_to_contents(&request.items);
|
||||
|
||||
// System prompt
|
||||
let system_instruction = request.system_prompt.as_ref().map(|s| GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: vec![GeminiPart::Text { text: s.clone() }],
|
||||
});
|
||||
|
||||
// Tools
|
||||
let tools = if request.tools.is_empty() {
|
||||
vec![]
|
||||
} else {
|
||||
vec![GeminiTool {
|
||||
function_declarations: request.tools.iter().map(|t| self.convert_tool(t)).collect(),
|
||||
}]
|
||||
};
|
||||
|
||||
// Tool config
|
||||
let tool_config = if !request.tools.is_empty() {
|
||||
Some(GeminiToolConfig {
|
||||
function_calling_config: GeminiFunctionCallingConfig {
|
||||
mode: Some("AUTO".to_string()),
|
||||
stream_function_call_arguments: if self.stream_function_call_arguments {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Reasoning の投影: capability が BudgetTokens / Both をサポートし、
|
||||
// request 側で budget_tokens が指定されているときだけ thinking_config を付ける。
|
||||
let supports_budget = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::BudgetTokens | ReasoningSupport::Both),
|
||||
);
|
||||
let thinking_config = request
|
||||
.config
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.filter(|_| supports_budget)
|
||||
.and_then(|rc| match rc {
|
||||
ReasoningControl::BudgetTokens(budget) => Some(GeminiThinkingConfig {
|
||||
thinking_budget: *budget,
|
||||
}),
|
||||
ReasoningControl::Effort(_) => None,
|
||||
});
|
||||
|
||||
// Generation config
|
||||
let generation_config = Some(GeminiGenerationConfig {
|
||||
max_output_tokens: request.config.max_tokens,
|
||||
temperature: request.config.temperature,
|
||||
top_p: request.config.top_p,
|
||||
top_k: request.config.top_k,
|
||||
stop_sequences: request.config.stop_sequences.clone(),
|
||||
thinking_config,
|
||||
});
|
||||
|
||||
GeminiRequest {
|
||||
contents,
|
||||
system_instruction,
|
||||
tools,
|
||||
tool_config,
|
||||
generation_config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Open Responses Items to Gemini Contents
|
||||
///
|
||||
/// Gemini uses:
|
||||
/// - role "user" for user messages and function responses
|
||||
/// - role "model" for assistant messages and function calls
|
||||
fn convert_items_to_contents(&self, items: &[Item]) -> Vec<GeminiContent> {
|
||||
let mut contents = Vec::new();
|
||||
let mut pending_model_parts: Vec<GeminiPart> = Vec::new();
|
||||
let mut pending_user_parts: Vec<GeminiPart> = Vec::new();
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
// Flush pending parts
|
||||
self.flush_pending_parts(
|
||||
&mut contents,
|
||||
&mut pending_model_parts,
|
||||
&mut pending_user_parts,
|
||||
);
|
||||
|
||||
let gemini_role = match role {
|
||||
Role::User | Role::System => "user",
|
||||
Role::Assistant => "model",
|
||||
};
|
||||
|
||||
let parts: Vec<GeminiPart> = content
|
||||
.iter()
|
||||
.map(|p| GeminiPart::Text {
|
||||
text: p.as_text().to_string(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
contents.push(GeminiContent {
|
||||
role: gemini_role.to_string(),
|
||||
parts,
|
||||
});
|
||||
}
|
||||
|
||||
Item::ToolCall {
|
||||
name, arguments, ..
|
||||
} => {
|
||||
// Flush pending user parts first
|
||||
if !pending_user_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: std::mem::take(&mut pending_user_parts),
|
||||
});
|
||||
}
|
||||
|
||||
// Parse arguments (normalize non-object / legacy "null" payloads to {})
|
||||
let args = parse_tool_arguments(arguments);
|
||||
|
||||
pending_model_parts.push(GeminiPart::FunctionCall {
|
||||
function_call: GeminiFunctionCall {
|
||||
name: name.clone(),
|
||||
args,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
// Flush pending model parts first
|
||||
if !pending_model_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "model".to_string(),
|
||||
parts: std::mem::take(&mut pending_model_parts),
|
||||
});
|
||||
}
|
||||
|
||||
let text = match content {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
pending_user_parts.push(GeminiPart::FunctionResponse {
|
||||
function_response: GeminiFunctionResponse {
|
||||
name: call_id.clone(),
|
||||
response: GeminiFunctionResponseContent {
|
||||
name: call_id.clone(),
|
||||
content: Value::String(text),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Item::Reasoning { text, .. } => {
|
||||
// Flush pending user parts first
|
||||
if !pending_user_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: std::mem::take(&mut pending_user_parts),
|
||||
});
|
||||
}
|
||||
|
||||
// Reasoning is treated as model text in Gemini
|
||||
pending_model_parts.push(GeminiPart::Text { text: text.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining pending parts
|
||||
self.flush_pending_parts(
|
||||
&mut contents,
|
||||
&mut pending_model_parts,
|
||||
&mut pending_user_parts,
|
||||
);
|
||||
|
||||
contents
|
||||
}
|
||||
|
||||
fn flush_pending_parts(
|
||||
&self,
|
||||
contents: &mut Vec<GeminiContent>,
|
||||
pending_model_parts: &mut Vec<GeminiPart>,
|
||||
pending_user_parts: &mut Vec<GeminiPart>,
|
||||
) {
|
||||
if !pending_model_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "model".to_string(),
|
||||
parts: std::mem::take(pending_model_parts),
|
||||
});
|
||||
}
|
||||
if !pending_user_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
role: "user".to_string(),
|
||||
parts: std::mem::take(pending_user_parts),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tool(&self, tool: &ToolDefinition) -> GeminiFunctionDeclaration {
|
||||
GeminiFunctionDeclaration {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.input_schema.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
fn cap() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn cap_budget_reasoning() -> ModelCapability {
|
||||
ModelCapability {
|
||||
reasoning: Some(ReasoningSupport::BudgetTokens),
|
||||
..cap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_simple_request() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new()
|
||||
.system("You are a helpful assistant.")
|
||||
.user("Hello!");
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert!(gemini_req.system_instruction.is_some());
|
||||
assert_eq!(gemini_req.contents.len(), 1);
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_with_tool() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new().user("What's the weather?").tool(
|
||||
ToolDefinition::new("get_weather")
|
||||
.description("Get current weather")
|
||||
.input_schema(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"location": { "type": "string" }
|
||||
},
|
||||
"required": ["location"]
|
||||
})),
|
||||
);
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert_eq!(gemini_req.tools.len(), 1);
|
||||
assert_eq!(gemini_req.tools[0].function_declarations.len(), 1);
|
||||
assert_eq!(
|
||||
gemini_req.tools[0].function_declarations[0].name,
|
||||
"get_weather"
|
||||
);
|
||||
assert!(gemini_req.tool_config.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_assistant_role_is_model() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new().user("Hello").assistant("Hi there!");
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert_eq!(gemini_req.contents.len(), 2);
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
assert_eq!(gemini_req.contents[1].role, "model");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_and_result() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new()
|
||||
.user("What's the weather?")
|
||||
.item(Item::tool_call(
|
||||
"call_123",
|
||||
"get_weather",
|
||||
r#"{"city":"Tokyo"}"#,
|
||||
))
|
||||
.item(Item::tool_result("call_123", "Sunny, 25°C"));
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap());
|
||||
|
||||
assert_eq!(gemini_req.contents.len(), 3);
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
assert_eq!(gemini_req.contents[1].role, "model");
|
||||
assert_eq!(gemini_req.contents[2].role, "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_budget_projected_when_supported() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let mut request = Request::new().user("think");
|
||||
request.config.reasoning = Some(ReasoningControl::BudgetTokens(-1));
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap_budget_reasoning());
|
||||
let config = gemini_req.generation_config.expect("generation config");
|
||||
let thinking = config.thinking_config.expect("thinking config");
|
||||
|
||||
assert_eq!(thinking.thinking_budget, -1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effort_reasoning_not_projected_to_gemini() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let mut request = Request::new().user("think");
|
||||
request.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Medium));
|
||||
|
||||
let gemini_req = scheme.build_request(&request, &cap_budget_reasoning());
|
||||
let config = gemini_req.generation_config.expect("generation config");
|
||||
|
||||
assert!(config.thinking_config.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
//! `impl Scheme for GeminiScheme`
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError, auth::AuthRequirement, capability::ModelCapability, event::Event, scheme::Scheme,
|
||||
types::Request,
|
||||
};
|
||||
|
||||
use super::GeminiScheme;
|
||||
|
||||
impl Scheme for GeminiScheme {
|
||||
type State = ();
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://generativelanguage.googleapis.com"
|
||||
}
|
||||
|
||||
fn path(&self, model_id: &str) -> String {
|
||||
format!("/v1beta/models/{model_id}:streamGenerateContent?alt=sse")
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::QueryParam { name: "key" }
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
_model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value {
|
||||
let req = self.build_request(request, capability);
|
||||
serde_json::to_value(&req).expect("GeminiRequest is always serialisable")
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
_event_type: &str,
|
||||
data: &str,
|
||||
_state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
Ok(self.parse_event(data)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
}
|
||||
92
crates/llm-worker/src/llm_client/scheme/mod.rs
Normal file
92
crates/llm-worker/src/llm_client/scheme/mod.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
//! APIスキーマ定義
|
||||
//!
|
||||
//! 各APIスキーマごとの変換ロジック
|
||||
//! - リクエスト変換: Request → プロバイダ固有JSON
|
||||
//! - レスポンス変換: SSEイベント → Event
|
||||
//!
|
||||
//! [`Scheme`] trait により `HttpTransport<S>` から scheme 固有の差分
|
||||
//! (パス、ヘッダ、認証要件、body 生成、SSE パース)をすべて委譲する。
|
||||
|
||||
pub mod anthropic;
|
||||
pub mod gemini;
|
||||
pub mod openai_chat;
|
||||
pub mod openai_responses;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use super::auth::AuthRequirement;
|
||||
use super::capability::ModelCapability;
|
||||
use super::client::ConfigWarning;
|
||||
use super::error::ClientError;
|
||||
use super::event::Event;
|
||||
use super::types::{Request, RequestConfig};
|
||||
|
||||
/// wire scheme の抽象。各プロバイダの API 仕様ごとに 1 つ実装する。
|
||||
///
|
||||
/// `HttpTransport<S: Scheme>` が URL 組立・認証ヘッダ挿入・SSE パース
|
||||
/// のループを担い、`Scheme` 実装は各仕様固有の差分のみ提供する。
|
||||
///
|
||||
/// # 状態
|
||||
///
|
||||
/// SSE パースでフレーム間に状態を保つ必要がある scheme(Anthropic の
|
||||
/// `BlockStop` に `block_type` が載らない仕様の補完など)は
|
||||
/// [`Scheme::State`] に中間状態を表す型を置く。
|
||||
/// 状態を持たない scheme は `type State = ()` とする。
|
||||
pub trait Scheme: Clone + Send + Sync + 'static {
|
||||
/// SSE パースのフレーム間で共有する状態。`HttpTransport` が
|
||||
/// ストリーム開始時に `Default::default()` を一度だけ作り、
|
||||
/// フレームごとに `&mut` で渡す。
|
||||
type State: Default + Send + 'static;
|
||||
|
||||
/// scheme のベース URL(`ModelConfig::base_url` 未指定時のデフォルト)
|
||||
fn default_base_url(&self) -> &'static str;
|
||||
|
||||
/// リクエスト先の相対パス。Gemini のようにモデル名をパスに埋め込む
|
||||
/// プロバイダもあるため、モデル ID を受け取る。
|
||||
fn path(&self, model_id: &str) -> String;
|
||||
|
||||
/// この scheme が要求する認証形式。`build_client` 時に
|
||||
/// `manifest::AuthRef` と照合する。
|
||||
fn required_auth(&self) -> AuthRequirement;
|
||||
|
||||
/// `Content-Type` 以外の追加ヘッダ。`anthropic-version` / `anthropic-beta` 等。
|
||||
fn additional_headers(&self) -> Vec<(&'static str, String)> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// リクエスト body を生成する。`capability` は `CacheStrategy` や
|
||||
/// `ReasoningSupport` を参照して scheme 側の挙動を分岐させるため
|
||||
/// に渡される。
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value;
|
||||
|
||||
/// SSE イベント 1 件を 0 個以上の [`Event`] に変換する。
|
||||
///
|
||||
/// `event_type` は SSE フレームの `event:` フィールド、`data` は
|
||||
/// `data:` フィールド。`[DONE]` 等の終端マーカーは実装側で判定する。
|
||||
/// `state` はストリーム単位で共有される可変状態。
|
||||
fn parse_sse(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError>;
|
||||
|
||||
/// scheme 既定の capability。モデル ID に関係なく、この wire で
|
||||
/// 安全に送れる最小共通項を返す。既知モデル ID の能力テーブルは
|
||||
/// `provider::capability::lookup` 側(高レベル構築層)の責務で、
|
||||
/// scheme はここには関与しない。
|
||||
fn default_capability(&self) -> ModelCapability;
|
||||
|
||||
/// scheme 側でサポートしていない `RequestConfig` フィールドを
|
||||
/// 警告として返す(例: OpenAI Chat は `top_k` 非対応)。
|
||||
/// デフォルトは空 Vec。
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let _ = config;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
//! OpenAI Chat Completions scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`gpt-5` 系など)は高レベル構築層
|
||||
//! (`provider::capability`)の責務。ここでは wire の保守的 default のみ。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
/// Scheme 既定の capability。OpenAI 互換ルーター系(xAI / Groq / OpenRouter 等)
|
||||
/// で未知モデル ID を受けたときのフォールバックに使う。
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
213
crates/llm-worker/src/llm_client/scheme/openai_chat/events.rs
Normal file
213
crates/llm-worker/src/llm_client/scheme/openai_chat/events.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
//! OpenAI SSEイベントパース
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
event::{Event, StopReason, UsageEvent},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::OpenAIScheme;
|
||||
|
||||
/// OpenAI Streaming Chat Response Chunk
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChatCompletionChunk {
|
||||
pub id: String,
|
||||
pub object: String,
|
||||
pub created: u64,
|
||||
pub model: String,
|
||||
pub choices: Vec<ChunkChoice>,
|
||||
pub usage: Option<ChunkUsage>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkChoice {
|
||||
pub index: usize,
|
||||
pub delta: ChunkDelta,
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkDelta {
|
||||
pub role: Option<String>,
|
||||
pub content: Option<String>,
|
||||
pub tool_calls: Option<Vec<ChunkToolCall>>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkToolCall {
|
||||
pub index: usize,
|
||||
pub id: Option<String>,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: Option<String>,
|
||||
pub function: Option<ChunkFunction>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkFunction {
|
||||
pub name: Option<String>,
|
||||
pub arguments: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(crate) struct ChunkUsage {
|
||||
pub prompt_tokens: u64,
|
||||
pub completion_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
impl OpenAIScheme {
|
||||
/// SSEデータのパースとEventへの変換
|
||||
///
|
||||
/// OpenAI APIはBlockStartイベントを明示的に送信しない。
|
||||
/// Timeline層が暗黙的なBlockStartを処理する。
|
||||
pub fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
if data == "[DONE]" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let chunk: ChatCompletionChunk =
|
||||
serde_json::from_str(data).map_err(|e| ClientError::Api {
|
||||
status: None,
|
||||
code: Some("parse_error".to_string()),
|
||||
message: format!("Failed to parse SSE data: {} -> {}", e, data),
|
||||
retry_after: None,
|
||||
})?;
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
// Usage handling
|
||||
if let Some(usage) = chunk.usage {
|
||||
events.push(Event::Usage(UsageEvent {
|
||||
input_tokens: Some(usage.prompt_tokens),
|
||||
output_tokens: Some(usage.completion_tokens),
|
||||
total_tokens: Some(usage.total_tokens),
|
||||
cache_read_input_tokens: None,
|
||||
cache_creation_input_tokens: None,
|
||||
}));
|
||||
}
|
||||
|
||||
for choice in chunk.choices {
|
||||
// Text Content Delta
|
||||
if let Some(content) = choice.delta.content {
|
||||
// OpenAI APIはBlockStartを送らないため、デルタのみを発行
|
||||
// Timeline層が暗黙的なBlockStartを処理する
|
||||
events.push(Event::text_delta(choice.index, content));
|
||||
}
|
||||
|
||||
// Tool Call Delta
|
||||
if let Some(tool_calls) = choice.delta.tool_calls {
|
||||
for tool_call in tool_calls {
|
||||
// Start of tool call (has ID)
|
||||
if let Some(id) = tool_call.id {
|
||||
let name = tool_call
|
||||
.function
|
||||
.as_ref()
|
||||
.and_then(|f| f.name.clone())
|
||||
.unwrap_or_default();
|
||||
events.push(Event::tool_use_start(tool_call.index, id, name));
|
||||
}
|
||||
|
||||
// Arguments delta
|
||||
if let Some(function) = tool_call.function {
|
||||
if let Some(args) = function.arguments {
|
||||
if !args.is_empty() {
|
||||
events.push(Event::tool_input_delta(tool_call.index, args));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finish Reason
|
||||
if let Some(finish_reason) = choice.finish_reason {
|
||||
let stop_reason = match finish_reason.as_str() {
|
||||
"stop" => Some(StopReason::EndTurn),
|
||||
"length" => Some(StopReason::MaxTokens),
|
||||
"tool_calls" | "function_call" => Some(StopReason::ToolUse),
|
||||
_ => Some(StopReason::EndTurn),
|
||||
};
|
||||
|
||||
let is_tool_finish =
|
||||
finish_reason == "tool_calls" || finish_reason == "function_call";
|
||||
|
||||
if is_tool_finish {
|
||||
// ツール呼び出し終了
|
||||
// Note: OpenAIはどのツールが終了したか明示しないため、
|
||||
// Timeline層で適切に処理する必要がある
|
||||
} else {
|
||||
// テキスト終了
|
||||
events.push(Event::text_block_stop(choice.index, stop_reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if events.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(events))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::event::DeltaContent;
|
||||
|
||||
#[test]
|
||||
fn test_parse_text_delta() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let data = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
// OpenAIはBlockStartを発行しないため、デルタのみ
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
assert_eq!(delta.index, 0);
|
||||
if let DeltaContent::Text(text) = &delta.delta {
|
||||
assert_eq!(text, "Hello");
|
||||
} else {
|
||||
panic!("Expected text delta");
|
||||
}
|
||||
} else {
|
||||
panic!("Expected BlockDelta");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tool_call() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
// Start of tool call
|
||||
let data_start = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_abc","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data_start).unwrap().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
if let Event::BlockStart(start) = &events[0] {
|
||||
assert_eq!(start.index, 0);
|
||||
if let crate::llm_client::event::BlockMetadata::ToolUse { id, name } = &start.metadata {
|
||||
assert_eq!(id, "call_abc");
|
||||
assert_eq!(name, "get_weather");
|
||||
} else {
|
||||
panic!("Expected ToolUse metadata");
|
||||
}
|
||||
}
|
||||
|
||||
// Tool arguments delta
|
||||
let data_arg = r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{}}"}}]},"finish_reason":null}]}"#;
|
||||
let events = scheme.parse_event(data_arg).unwrap().unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
if let DeltaContent::InputJson(json) = &delta.delta {
|
||||
assert_eq!(json, "{}}");
|
||||
} else {
|
||||
panic!("Expected input json delta");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
crates/llm-worker/src/llm_client/scheme/openai_chat/mod.rs
Normal file
33
crates/llm-worker/src/llm_client/scheme/openai_chat/mod.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//! OpenAI Chat Completions API スキーマ
|
||||
//!
|
||||
//! - リクエストJSON生成
|
||||
//! - SSEイベントパース → Event変換
|
||||
|
||||
pub(crate) mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
/// OpenAIスキーマ
|
||||
///
|
||||
/// OpenAI Chat Completions API (および互換API) のリクエスト/レスポンス変換を担当
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct OpenAIScheme {
|
||||
/// モデル名 (リクエスト時に指定されるが、デフォルト値として保持も可能)
|
||||
pub model: Option<String>,
|
||||
/// レガシーなmax_tokensを使用するか (Ollama互換用)
|
||||
pub use_legacy_max_tokens: bool,
|
||||
}
|
||||
|
||||
impl OpenAIScheme {
|
||||
/// 新しいスキーマを作成
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// レガシーなmax_tokensを使用するか設定
|
||||
pub fn with_legacy_max_tokens(mut self, use_legacy: bool) -> Self {
|
||||
self.use_legacy_max_tokens = use_legacy;
|
||||
self
|
||||
}
|
||||
}
|
||||
442
crates/llm-worker/src/llm_client/scheme/openai_chat/request.rs
Normal file
442
crates/llm-worker/src/llm_client/scheme/openai_chat/request.rs
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
//! OpenAI Request Builder
|
||||
//!
|
||||
//! Converts Open Responses native Item model to OpenAI Chat Completions API format.
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
Request,
|
||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||
types::{Item, Role, ToolDefinition, parse_tool_arguments},
|
||||
};
|
||||
|
||||
use super::OpenAIScheme;
|
||||
|
||||
/// OpenAI API request body
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIRequest {
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_completion_tokens: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>, // Legacy field for compatibility (e.g. Ollama)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub stop: Vec<String>,
|
||||
pub stream: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_options: Option<StreamOptions>,
|
||||
pub messages: Vec<OpenAIMessage>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<OpenAITool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<String>,
|
||||
/// Reasoning effort(o1 / o3 / o4 / gpt-5 系で有効)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct StreamOptions {
|
||||
pub include_usage: bool,
|
||||
}
|
||||
|
||||
/// OpenAI message
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIMessage {
|
||||
pub role: String,
|
||||
pub content: Option<OpenAIContent>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tool_calls: Vec<OpenAIToolCall>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
/// OpenAI content
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(crate) enum OpenAIContent {
|
||||
Text(String),
|
||||
Parts(Vec<OpenAIContentPart>),
|
||||
}
|
||||
|
||||
/// OpenAI content part
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub(crate) enum OpenAIContentPart {
|
||||
#[serde(rename = "text")]
|
||||
Text { text: String },
|
||||
#[serde(rename = "image_url")]
|
||||
ImageUrl { image_url: ImageUrl },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ImageUrl {
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// OpenAI tool definition
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAITool {
|
||||
pub r#type: String,
|
||||
pub function: OpenAIToolFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIToolFunction {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// OpenAI tool call in message
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIToolCall {
|
||||
pub id: String,
|
||||
pub r#type: String,
|
||||
pub function: OpenAIToolCallFunction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct OpenAIToolCallFunction {
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
}
|
||||
|
||||
impl OpenAIScheme {
|
||||
/// Build OpenAI request from Request
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
model: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> OpenAIRequest {
|
||||
let mut messages = Vec::new();
|
||||
|
||||
// Add system message if present
|
||||
if let Some(system) = &request.system_prompt {
|
||||
messages.push(OpenAIMessage {
|
||||
role: "system".to_string(),
|
||||
content: Some(OpenAIContent::Text(system.clone())),
|
||||
tool_calls: vec![],
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Convert items to messages
|
||||
messages.extend(self.convert_items_to_messages(&request.items));
|
||||
|
||||
let tools = request.tools.iter().map(|t| self.convert_tool(t)).collect();
|
||||
|
||||
let (max_tokens, max_completion_tokens) = if self.use_legacy_max_tokens {
|
||||
(request.config.max_tokens, None)
|
||||
} else {
|
||||
(None, request.config.max_tokens)
|
||||
};
|
||||
|
||||
// Reasoning の投影: capability が Effort / Both をサポートし、
|
||||
// request 側で effort が指定されているときだけ reasoning_effort を付ける。
|
||||
let supports_effort = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
|
||||
);
|
||||
let reasoning_effort = request
|
||||
.config
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.filter(|_| supports_effort)
|
||||
.and_then(|rc| match rc {
|
||||
ReasoningControl::Effort(effort) => Some(effort.as_str().to_string()),
|
||||
ReasoningControl::BudgetTokens(_) => None,
|
||||
});
|
||||
|
||||
OpenAIRequest {
|
||||
model: model.to_string(),
|
||||
max_completion_tokens,
|
||||
max_tokens,
|
||||
temperature: request.config.temperature,
|
||||
top_p: request.config.top_p,
|
||||
stop: request.config.stop_sequences.clone(),
|
||||
stream: true,
|
||||
stream_options: Some(StreamOptions {
|
||||
include_usage: true,
|
||||
}),
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: None,
|
||||
reasoning_effort,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert Open Responses Items to OpenAI Messages
|
||||
///
|
||||
/// OpenAI uses a message-based model where:
|
||||
/// - User messages have role "user"
|
||||
/// - Assistant messages have role "assistant"
|
||||
/// - Tool calls are within assistant messages as tool_calls array
|
||||
/// - Tool results have role "tool" with tool_call_id
|
||||
fn convert_items_to_messages(&self, items: &[Item]) -> Vec<OpenAIMessage> {
|
||||
let mut messages = Vec::new();
|
||||
let mut pending_tool_calls: Vec<OpenAIToolCall> = Vec::new();
|
||||
let mut pending_assistant_text: Option<String> = None;
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
// Flush pending tool calls
|
||||
self.flush_pending_assistant(
|
||||
&mut messages,
|
||||
&mut pending_tool_calls,
|
||||
&mut pending_assistant_text,
|
||||
);
|
||||
|
||||
let openai_role = match role {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
};
|
||||
|
||||
let text_content: String = content
|
||||
.iter()
|
||||
.map(|p| p.as_text())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
messages.push(OpenAIMessage {
|
||||
role: openai_role.to_string(),
|
||||
content: Some(OpenAIContent::Text(text_content)),
|
||||
tool_calls: vec![],
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
|
||||
Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
// Normalize non-object / legacy "null" payloads to "{}" so
|
||||
// OpenAI gets a valid JSON object string.
|
||||
let normalized_args = parse_tool_arguments(arguments).to_string();
|
||||
pending_tool_calls.push(OpenAIToolCall {
|
||||
id: call_id.clone(),
|
||||
r#type: "function".to_string(),
|
||||
function: OpenAIToolCallFunction {
|
||||
name: name.clone(),
|
||||
arguments: normalized_args,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
// Flush pending tool calls before tool result
|
||||
self.flush_pending_assistant(
|
||||
&mut messages,
|
||||
&mut pending_tool_calls,
|
||||
&mut pending_assistant_text,
|
||||
);
|
||||
|
||||
let text = match content {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
messages.push(OpenAIMessage {
|
||||
role: "tool".to_string(),
|
||||
content: Some(OpenAIContent::Text(text)),
|
||||
tool_calls: vec![],
|
||||
tool_call_id: Some(call_id.clone()),
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
|
||||
Item::Reasoning { text, .. } => {
|
||||
// Reasoning is treated as assistant text in OpenAI
|
||||
// (OpenAI doesn't have native reasoning support like Claude)
|
||||
if let Some(ref mut existing) = pending_assistant_text {
|
||||
existing.push_str(text);
|
||||
} else {
|
||||
pending_assistant_text = Some(text.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining pending items
|
||||
self.flush_pending_assistant(
|
||||
&mut messages,
|
||||
&mut pending_tool_calls,
|
||||
&mut pending_assistant_text,
|
||||
);
|
||||
|
||||
messages
|
||||
}
|
||||
|
||||
fn flush_pending_assistant(
|
||||
&self,
|
||||
messages: &mut Vec<OpenAIMessage>,
|
||||
pending_tool_calls: &mut Vec<OpenAIToolCall>,
|
||||
pending_assistant_text: &mut Option<String>,
|
||||
) {
|
||||
if !pending_tool_calls.is_empty() || pending_assistant_text.is_some() {
|
||||
messages.push(OpenAIMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: pending_assistant_text.take().map(OpenAIContent::Text),
|
||||
tool_calls: std::mem::take(pending_tool_calls),
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_tool(&self, tool: &ToolDefinition) -> OpenAITool {
|
||||
OpenAITool {
|
||||
r#type: "function".to_string(),
|
||||
function: OpenAIToolFunction {
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.input_schema.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ReasoningEffort, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
fn cap() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_simple_request() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new().system("System prompt").user("Hello");
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
|
||||
assert_eq!(body.model, "gpt-4o");
|
||||
assert_eq!(body.messages.len(), 2);
|
||||
assert_eq!(body.messages[0].role, "system");
|
||||
assert_eq!(body.messages[1].role, "user");
|
||||
|
||||
if let Some(OpenAIContent::Text(text)) = &body.messages[0].content {
|
||||
assert_eq!(text, "System prompt");
|
||||
} else {
|
||||
panic!("Expected text content");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_with_tool() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new()
|
||||
.user("Check weather")
|
||||
.tool(ToolDefinition::new("weather").description("Get weather"));
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
assert_eq!(body.tools.len(), 1);
|
||||
assert_eq!(body.tools[0].function.name, "weather");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_legacy_max_tokens() {
|
||||
let scheme = OpenAIScheme::new().with_legacy_max_tokens(true);
|
||||
let request = Request::new().user("Hello").max_tokens(100);
|
||||
|
||||
let body = scheme.build_request("llama3", &request, &cap());
|
||||
|
||||
assert_eq!(body.max_tokens, Some(100));
|
||||
assert!(body.max_completion_tokens.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_request_modern_max_tokens() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new().user("Hello").max_tokens(100);
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
|
||||
assert_eq!(body.max_completion_tokens, Some(100));
|
||||
assert!(body.max_tokens.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_projected_when_supported() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let mut request = Request::new().user("Hello");
|
||||
request.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::Other(
|
||||
"provider-native".into(),
|
||||
)));
|
||||
let capability = ModelCapability {
|
||||
reasoning: Some(ReasoningSupport::Effort),
|
||||
..cap()
|
||||
};
|
||||
|
||||
let body = scheme.build_request("gpt-5", &request, &capability);
|
||||
|
||||
assert_eq!(body.reasoning_effort.as_deref(), Some("provider-native"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budget_reasoning_not_projected_to_openai_chat() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let mut request = Request::new().user("Hello");
|
||||
request.config.reasoning = Some(ReasoningControl::BudgetTokens(4096));
|
||||
let capability = ModelCapability {
|
||||
reasoning: Some(ReasoningSupport::Both),
|
||||
..cap()
|
||||
};
|
||||
|
||||
let body = scheme.build_request("gpt-5", &request, &capability);
|
||||
|
||||
assert!(body.reasoning_effort.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_call_and_result() {
|
||||
let scheme = OpenAIScheme::new();
|
||||
let request = Request::new()
|
||||
.user("Check weather")
|
||||
.item(Item::tool_call(
|
||||
"call_123",
|
||||
"get_weather",
|
||||
r#"{"city":"Tokyo"}"#,
|
||||
))
|
||||
.item(Item::tool_result("call_123", "Sunny, 25°C"));
|
||||
|
||||
let body = scheme.build_request("gpt-4o", &request, &cap());
|
||||
|
||||
assert_eq!(body.messages.len(), 3);
|
||||
assert_eq!(body.messages[0].role, "user");
|
||||
assert_eq!(body.messages[1].role, "assistant");
|
||||
assert_eq!(body.messages[1].tool_calls.len(), 1);
|
||||
assert_eq!(body.messages[2].role, "tool");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
//! `impl Scheme for OpenAIScheme`
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
client::ConfigWarning,
|
||||
event::Event,
|
||||
scheme::Scheme,
|
||||
types::{Request, RequestConfig},
|
||||
};
|
||||
|
||||
use super::OpenAIScheme;
|
||||
|
||||
impl Scheme for OpenAIScheme {
|
||||
type State = ();
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://api.openai.com"
|
||||
}
|
||||
|
||||
fn path(&self, _model_id: &str) -> String {
|
||||
"/v1/chat/completions".to_string()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::Bearer
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value {
|
||||
let req = self.build_request(model_id, request, capability);
|
||||
serde_json::to_value(&req).expect("OpenAIRequest is always serialisable")
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
_event_type: &str,
|
||||
data: &str,
|
||||
_state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
// `data: [DONE]` は終端マーカー
|
||||
if data.trim() == "[DONE]" {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(self.parse_event(data)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
// OpenAI Chat Completions API は top_k を受け付けない
|
||||
if config.top_k.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported("top_k", "OpenAI Chat"));
|
||||
}
|
||||
warnings
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
//! OpenAI Responses scheme の wire-level 既定 capability。
|
||||
//!
|
||||
//! モデル ID 固有のテーブル(`gpt-5` / `codex-` 系など)は高レベル構築層
|
||||
//! (`provider::capability`)の責務。ここでは wire の保守的 default のみ。
|
||||
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
pub(crate) fn default_capability() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: None,
|
||||
vision: false,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
1240
crates/llm-worker/src/llm_client/scheme/openai_responses/events.rs
Normal file
1240
crates/llm-worker/src/llm_client/scheme/openai_responses/events.rs
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,84 @@
|
|||
//! OpenAI Responses API スキーマ (`/v1/responses`)
|
||||
//!
|
||||
//! Chat Completions とは別物の item-based wire format。reasoning item と
|
||||
//! function_call item が first-class で、SSE イベントも `response.*` 名前空間で
|
||||
//! 流れる。ChatGPT OAuth 経路 (codex) は本 scheme 必須。
|
||||
//!
|
||||
//! - リクエスト JSON 生成: [`request`]
|
||||
//! - SSE イベントパース → [`Event`](crate::llm_client::event::Event) 変換: [`events`]
|
||||
|
||||
mod capability;
|
||||
mod events;
|
||||
mod request;
|
||||
mod scheme_impl;
|
||||
|
||||
pub use scheme_impl::OpenAIResponsesState;
|
||||
|
||||
/// OpenAI Responses scheme 本体。
|
||||
///
|
||||
/// `store` / `include_encrypted_content` / `send_max_output_tokens` /
|
||||
/// `send_sampling_params` は scheme 固定の wire 設定で、デフォルトは
|
||||
/// 公式 OpenAI Responses API 向け (stateless + ZDR + `max_output_tokens`
|
||||
/// / `temperature` / `top_p` 送出可)。ChatGPT backend (codex-oauth) の
|
||||
/// ように受理パラメータが subset の経路では provider 層で
|
||||
/// `send_max_output_tokens=false` / `send_sampling_params=false` に
|
||||
/// 上書きする。`ModelCapability` には入れない(モデル能力ではなく wire policy)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenAIResponsesScheme {
|
||||
/// サーバ側に response を保存するか。ZDR/stateless 運用では `false`。
|
||||
pub store: bool,
|
||||
/// `include: ["reasoning.encrypted_content"]` を付けるか。
|
||||
/// `store=false` で reasoning を使うなら必須。
|
||||
pub include_encrypted_content: bool,
|
||||
/// `max_output_tokens` を body に載せるか。公式 OpenAI Responses API は
|
||||
/// 受理するが、ChatGPT backend (codex-oauth) は `Unsupported parameter`
|
||||
/// で 400 を返すため、その経路では `false` にする。
|
||||
pub send_max_output_tokens: bool,
|
||||
/// `temperature` / `top_p` を body に載せるか。公式 OpenAI Responses API
|
||||
/// は受理するが、ChatGPT backend (codex-oauth) は `Unsupported parameter`
|
||||
/// で 400 を返すため、その経路では `false` にする。
|
||||
pub send_sampling_params: bool,
|
||||
}
|
||||
|
||||
impl Default for OpenAIResponsesScheme {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
store: false,
|
||||
include_encrypted_content: true,
|
||||
send_max_output_tokens: true,
|
||||
send_sampling_params: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenAIResponsesScheme {
|
||||
/// デフォルト設定 (`store=false`, `include=["reasoning.encrypted_content"]`,
|
||||
/// `send_max_output_tokens=true`, `send_sampling_params=true`)。
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// `store` を上書き。
|
||||
pub fn with_store(mut self, store: bool) -> Self {
|
||||
self.store = store;
|
||||
self
|
||||
}
|
||||
|
||||
/// `include: ["reasoning.encrypted_content"]` の有無を上書き。
|
||||
pub fn with_include_encrypted_content(mut self, include: bool) -> Self {
|
||||
self.include_encrypted_content = include;
|
||||
self
|
||||
}
|
||||
|
||||
/// `max_output_tokens` を body に載せるかを上書き。
|
||||
pub fn with_send_max_output_tokens(mut self, send: bool) -> Self {
|
||||
self.send_max_output_tokens = send;
|
||||
self
|
||||
}
|
||||
|
||||
/// `temperature` / `top_p` を body に載せるかを上書き。
|
||||
pub fn with_send_sampling_params(mut self, send: bool) -> Self {
|
||||
self.send_sampling_params = send;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,650 @@
|
|||
//! OpenAI Responses API リクエスト body 生成
|
||||
//!
|
||||
//! Chat Completions の `messages` と違い、Responses は `input[]` の
|
||||
//! item 配列で reasoning / function_call / function_call_output が
|
||||
//! first-class。`Item` を素に近い形で `input[]` に投影できる。
|
||||
|
||||
use serde::{Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
Request,
|
||||
capability::{ModelCapability, ReasoningControl, ReasoningSupport},
|
||||
types::{ContentPart, Item, Role, ToolDefinition, parse_tool_arguments},
|
||||
};
|
||||
|
||||
use super::OpenAIResponsesScheme;
|
||||
|
||||
/// `/v1/responses` のリクエスト body。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ResponsesRequest {
|
||||
pub model: String,
|
||||
/// システムプロンプト相当。`input[]` とは別フィールド。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instructions: Option<String>,
|
||||
pub input: Vec<InputItem>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub tools: Vec<ResponseTool>,
|
||||
/// 常時 `"auto"` を送る。scheme 固定値。
|
||||
pub tool_choice: &'static str,
|
||||
/// 常時 `true` を送る。scheme 固定値。
|
||||
pub parallel_tool_calls: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning: Option<ReasoningConfig>,
|
||||
/// ZDR / stateless 運用では `false`。
|
||||
pub store: bool,
|
||||
/// 常時 `true`。
|
||||
pub stream: bool,
|
||||
/// `["reasoning.encrypted_content"]` 等。
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
pub include: Vec<&'static str>,
|
||||
/// 公式 OpenAI Responses API では受理されるが、ChatGPT backend
|
||||
/// (codex-oauth) は 400 で弾く。scheme の `send_max_output_tokens`
|
||||
/// が `false` のときは `None` のまま送る (skip_serializing_if で除外)。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_output_tokens: Option<u32>,
|
||||
/// 公式 OpenAI Responses API では受理されるが、ChatGPT backend
|
||||
/// (codex-oauth) は `temperature` / `top_p` を 400 で弾く。scheme の
|
||||
/// `send_sampling_params` が `false` のときは `None` のまま送る。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// 会話単位の安定キー。ChatGPT backend (codex-oauth) は明示キーが
|
||||
/// 無いとプロンプトキャッシュがほぼ効かない。pod 側は `SegmentId`
|
||||
/// を渡す。`Request::cache_key` が `None` のときはキー自体を送らない。
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_cache_key: Option<String>,
|
||||
}
|
||||
|
||||
/// reasoning 制御。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ReasoningConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub effort: Option<String>,
|
||||
/// summary の出力制御。`"auto"` 固定で summary_text を受け取る。
|
||||
pub summary: &'static str,
|
||||
}
|
||||
|
||||
/// `input[]` の 1 要素。
|
||||
///
|
||||
/// Responses API の item 型を素に近い形で投影する。未対応 type は
|
||||
/// 無視(reasoning 送信時に `content: []` の場合は `None` として弾く)。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum InputItem {
|
||||
/// 会話メッセージ。user / assistant / developer のいずれか。
|
||||
/// `Role::System` items は `developer` として投影する(ChatGPT
|
||||
/// backend が `role: "system"` を拒否するため。Codex CLI も
|
||||
/// system 相当の挿入には DeveloperInstructions = `role: "developer"`
|
||||
/// を使う)。
|
||||
Message {
|
||||
role: &'static str,
|
||||
content: Vec<InputContent>,
|
||||
},
|
||||
/// 過去の function tool 呼び出し(assistant 側)。
|
||||
FunctionCall {
|
||||
call_id: String,
|
||||
name: String,
|
||||
/// JSON 文字列(object でなくても正規化済み)。
|
||||
arguments: String,
|
||||
},
|
||||
/// function tool の結果(user 側)。
|
||||
FunctionCallOutput {
|
||||
call_id: String,
|
||||
/// Responses は文字列 or 構造化 output を許すが、ここでは
|
||||
/// `summary` + `content` を改行連結した文字列で送る。
|
||||
output: String,
|
||||
},
|
||||
/// reasoning item。`encrypted_content` があれば必ず添える。
|
||||
Reasoning {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<String>,
|
||||
/// Responses API は reasoning item に `summary` フィールドを必須で
|
||||
/// 要求する(中身が空でも `[]` として送る必要がある)。GPT-5 など
|
||||
/// summary を返さないモデル + reasoning effort 指定なしのターンでは
|
||||
/// summary text が一切付かないので、ここを skip すると 400
|
||||
/// "Missing required parameter: 'input[N].summary'" で弾かれる。
|
||||
summary: Vec<ReasoningSummaryPart>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
content: Vec<ReasoningContentPart>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
encrypted_content: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// メッセージ content_part。role で input/output を使い分ける。
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum InputContent {
|
||||
/// user / developer 側のテキスト
|
||||
InputText { text: String },
|
||||
/// assistant 側のテキスト
|
||||
OutputText { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ReasoningSummaryPart {
|
||||
SummaryText { text: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub(crate) enum ReasoningContentPart {
|
||||
ReasoningText { text: String },
|
||||
}
|
||||
|
||||
/// Responses 用 tool 定義。Chat と違い function キーでネストせず
|
||||
/// トップレベルに `name` / `parameters` が載る。
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(crate) struct ResponseTool {
|
||||
#[serde(rename = "type")]
|
||||
pub r#type: &'static str,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
/// OpenAI Responses API は `type:"object"` のパラメータスキーマに
|
||||
/// `properties` が存在することを要求する。schemars は引数なし struct
|
||||
/// から `properties` を含まない最小スキーマを出すので、serialize
|
||||
/// 時に空オブジェクトを補う。
|
||||
#[serde(serialize_with = "serialize_parameters")]
|
||||
pub parameters: Value,
|
||||
/// Structured output モード制御。デフォルト false。
|
||||
pub strict: bool,
|
||||
}
|
||||
|
||||
fn serialize_parameters<S: Serializer>(value: &Value, s: S) -> Result<S::Ok, S::Error> {
|
||||
if let Some(obj) = value.as_object()
|
||||
&& obj.get("type").and_then(Value::as_str) == Some("object")
|
||||
&& !obj.contains_key("properties")
|
||||
{
|
||||
let mut patched = obj.clone();
|
||||
patched.insert("properties".to_string(), Value::Object(Default::default()));
|
||||
return Value::Object(patched).serialize(s);
|
||||
}
|
||||
value.serialize(s)
|
||||
}
|
||||
|
||||
impl OpenAIResponsesScheme {
|
||||
/// `Request` から wire 形式の body を組み立てる。
|
||||
pub(crate) fn build_request(
|
||||
&self,
|
||||
model: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> ResponsesRequest {
|
||||
let input = convert_items_to_input(&request.items);
|
||||
let tools = request.tools.iter().map(convert_tool).collect();
|
||||
|
||||
// Reasoning 投影: capability が Effort / Both をサポートし、かつ
|
||||
// request 側で effort が指定されているときだけ reasoning を付ける。
|
||||
let supports_effort = matches!(
|
||||
capability.reasoning,
|
||||
Some(ReasoningSupport::Effort | ReasoningSupport::Both),
|
||||
);
|
||||
let reasoning = request
|
||||
.config
|
||||
.reasoning
|
||||
.as_ref()
|
||||
.filter(|_| supports_effort)
|
||||
.map(|effort| ReasoningConfig {
|
||||
effort: match effort {
|
||||
ReasoningControl::Effort(effort) => Some(effort.as_str().to_string()),
|
||||
ReasoningControl::BudgetTokens(_) => None,
|
||||
},
|
||||
summary: "auto",
|
||||
})
|
||||
.filter(|reasoning| reasoning.effort.is_some());
|
||||
|
||||
let include: Vec<&'static str> = if self.include_encrypted_content {
|
||||
vec!["reasoning.encrypted_content"]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
ResponsesRequest {
|
||||
model: model.to_string(),
|
||||
instructions: request.system_prompt.clone(),
|
||||
input,
|
||||
tools,
|
||||
tool_choice: "auto",
|
||||
parallel_tool_calls: true,
|
||||
reasoning,
|
||||
store: self.store,
|
||||
stream: true,
|
||||
include,
|
||||
max_output_tokens: if self.send_max_output_tokens {
|
||||
request.config.max_tokens
|
||||
} else {
|
||||
None
|
||||
},
|
||||
temperature: if self.send_sampling_params {
|
||||
request.config.temperature
|
||||
} else {
|
||||
None
|
||||
},
|
||||
top_p: if self.send_sampling_params {
|
||||
request.config.top_p
|
||||
} else {
|
||||
None
|
||||
},
|
||||
prompt_cache_key: request.cache_key.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Item` 列を `input[]` に変換する。
|
||||
fn convert_items_to_input(items: &[Item]) -> Vec<InputItem> {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Message { role, content, .. } => {
|
||||
let (role_str, text_variant): (&'static str, fn(String) -> InputContent) =
|
||||
match role {
|
||||
Role::User => ("user", |t| InputContent::InputText { text: t }),
|
||||
Role::Assistant => ("assistant", |t| InputContent::OutputText { text: t }),
|
||||
Role::System => ("developer", |t| InputContent::InputText { text: t }),
|
||||
};
|
||||
let parts: Vec<InputContent> = content
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
ContentPart::Text { text } => text_variant(text.clone()),
|
||||
ContentPart::Refusal { refusal } => text_variant(refusal.clone()),
|
||||
})
|
||||
.collect();
|
||||
out.push(InputItem::Message {
|
||||
role: role_str,
|
||||
content: parts,
|
||||
});
|
||||
}
|
||||
Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
// 非 object / 旧形式の "null" を "{}" に正規化。
|
||||
let normalized = parse_tool_arguments(arguments).to_string();
|
||||
out.push(InputItem::FunctionCall {
|
||||
call_id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
arguments: normalized,
|
||||
});
|
||||
}
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
summary,
|
||||
content,
|
||||
..
|
||||
} => {
|
||||
let text = match content {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
out.push(InputItem::FunctionCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
output: text,
|
||||
});
|
||||
}
|
||||
Item::Reasoning {
|
||||
id,
|
||||
text,
|
||||
summary,
|
||||
encrypted_content,
|
||||
..
|
||||
} => {
|
||||
let summary_parts = summary
|
||||
.iter()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| ReasoningSummaryPart::SummaryText { text: s.clone() })
|
||||
.collect();
|
||||
let content_parts = if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![ReasoningContentPart::ReasoningText { text: text.clone() }]
|
||||
};
|
||||
out.push(InputItem::Reasoning {
|
||||
id: id.clone(),
|
||||
summary: summary_parts,
|
||||
content: content_parts,
|
||||
encrypted_content: encrypted_content.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn convert_tool(tool: &ToolDefinition) -> ResponseTool {
|
||||
ResponseTool {
|
||||
r#type: "function",
|
||||
name: tool.name.clone(),
|
||||
description: tool.description.clone(),
|
||||
parameters: tool.input_schema.clone(),
|
||||
strict: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::capability::{
|
||||
CacheStrategy, ModelCapability, ReasoningControl, ReasoningEffort, ReasoningSupport,
|
||||
StructuredOutput, ToolCallingSupport,
|
||||
};
|
||||
|
||||
fn cap_with_reasoning() -> ModelCapability {
|
||||
ModelCapability {
|
||||
tool_calling: ToolCallingSupport::Parallel,
|
||||
structured_output: StructuredOutput::JsonSchema,
|
||||
reasoning: Some(ReasoningSupport::Effort),
|
||||
vision: true,
|
||||
prompt_caching: CacheStrategy::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn cap_no_reasoning() -> ModelCapability {
|
||||
ModelCapability {
|
||||
reasoning: None,
|
||||
..cap_with_reasoning()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scheme_defaults_to_stateless_zdr() {
|
||||
let s = OpenAIResponsesScheme::new();
|
||||
assert!(!s.store);
|
||||
assert!(s.include_encrypted_content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_encrypted_content_when_enabled() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.include, vec!["reasoning.encrypted_content"]);
|
||||
assert!(!body.store);
|
||||
assert!(body.stream);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instructions_from_system_prompt() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().system("be terse").user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.instructions.as_deref(), Some("be terse"));
|
||||
assert_eq!(body.input.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_choice_and_parallel_are_fixed() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.tool_choice, "auto");
|
||||
assert!(body.parallel_tool_calls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_message_uses_input_text() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
match &body.input[0] {
|
||||
InputItem::Message { role, content } => {
|
||||
assert_eq!(*role, "user");
|
||||
assert_eq!(content.len(), 1);
|
||||
assert!(matches!(&content[0], InputContent::InputText { text } if text == "hi"));
|
||||
}
|
||||
_ => panic!("expected message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_role_item_is_projected_as_developer() {
|
||||
// ChatGPT backend (codex-oauth) は input[] の `role: "system"` を
|
||||
// "System messages are not allowed" で 400 拒否する。in-conversation
|
||||
// な system note (notify / fs_view auto-read / compaction summary) は
|
||||
// `role: "developer"` として投影し、両 backend で受理されるようにする。
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.user("hi")
|
||||
.item(Item::system_message("[notify] hello"));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
match &body.input[1] {
|
||||
InputItem::Message { role, content } => {
|
||||
assert_eq!(*role, "developer");
|
||||
assert!(
|
||||
matches!(&content[0], InputContent::InputText { text } if text == "[notify] hello"),
|
||||
);
|
||||
}
|
||||
_ => panic!("expected message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_message_uses_output_text() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").assistant("hello");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
match &body.input[1] {
|
||||
InputItem::Message { role, content } => {
|
||||
assert_eq!(*role, "assistant");
|
||||
assert!(
|
||||
matches!(&content[0], InputContent::OutputText { text } if text == "hello")
|
||||
);
|
||||
}
|
||||
_ => panic!("expected message"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_and_result_become_function_items() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.user("run")
|
||||
.item(Item::tool_call("c1", "t", r#"{"a":1}"#))
|
||||
.item(Item::tool_result("c1", "ok"));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert!(matches!(body.input[1], InputItem::FunctionCall { .. }));
|
||||
assert!(matches!(
|
||||
body.input[2],
|
||||
InputItem::FunctionCallOutput { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_item_round_trips_encrypted_content() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let item = Item::reasoning("inner")
|
||||
.with_reasoning_summary(vec!["s1".into()])
|
||||
.with_encrypted_content("ENC");
|
||||
let req = Request::new().user("hi").item(item);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
match &body.input[1] {
|
||||
InputItem::Reasoning {
|
||||
summary,
|
||||
content,
|
||||
encrypted_content,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(summary.len(), 1);
|
||||
assert_eq!(content.len(), 1);
|
||||
assert_eq!(encrypted_content.as_deref(), Some("ENC"));
|
||||
}
|
||||
_ => panic!("expected reasoning"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_summary_field_is_always_serialized() {
|
||||
// Responses API は reasoning item に `summary` を必須で要求する。
|
||||
// summary が空でも wire 上に `summary: []` として残らないと、
|
||||
// ChatGPT backend (codex-oauth) が
|
||||
// 400 invalid_request_error: Missing required parameter:
|
||||
// 'input[N].summary'.
|
||||
// で弾く。GPT-5 + reasoning effort 未指定のターンでは summary text
|
||||
// が付かないことがあるため、空のままでも skip しないこと。
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let item = Item::reasoning("").with_encrypted_content("ENC");
|
||||
let req = Request::new().user("hi").item(item);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
let reasoning_item = &json["input"][1];
|
||||
assert_eq!(reasoning_item["type"], "reasoning");
|
||||
assert!(
|
||||
reasoning_item.get("summary").is_some(),
|
||||
"summary key must be present even when empty, got: {reasoning_item}"
|
||||
);
|
||||
assert_eq!(reasoning_item["summary"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_projected_when_supported() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut req = Request::new().user("hi");
|
||||
req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let reasoning = body.reasoning.expect("reasoning should be set");
|
||||
assert_eq!(reasoning.effort.as_deref(), Some("high"));
|
||||
assert_eq!(reasoning.summary, "auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_omitted_when_unsupported() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let mut req = Request::new().user("hi");
|
||||
req.config.reasoning = Some(ReasoningControl::Effort(ReasoningEffort::High));
|
||||
let body = scheme.build_request("gpt-4o", &req, &cap_no_reasoning());
|
||||
assert!(body.reasoning.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_output_tokens_passed_through_by_default() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").max_tokens(100);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.max_output_tokens, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_output_tokens_dropped_when_send_disabled() {
|
||||
let scheme = OpenAIResponsesScheme::new().with_send_max_output_tokens(false);
|
||||
let req = Request::new().user("hi").max_tokens(100);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.max_output_tokens, None);
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
json.get("max_output_tokens").is_none(),
|
||||
"max_output_tokens key must not appear in serialised body, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_params_passed_through_by_default() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").temperature(0.4).top_p(0.9);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.temperature, Some(0.4));
|
||||
assert_eq!(body.top_p, Some(0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_params_dropped_when_send_disabled() {
|
||||
let scheme = OpenAIResponsesScheme::new().with_send_sampling_params(false);
|
||||
let req = Request::new().user("hi").temperature(0.4).top_p(0.9);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.temperature, None);
|
||||
assert_eq!(body.top_p, None);
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
json.get("temperature").is_none() && json.get("top_p").is_none(),
|
||||
"temperature/top_p keys must not appear in serialised body, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_key_passed_through_when_set() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi").cache_key("session-abc");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert_eq!(body.prompt_cache_key.as_deref(), Some("session-abc"));
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["prompt_cache_key"], "session-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_cache_key_omitted_when_none() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new().user("hi");
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
assert!(body.prompt_cache_key.is_none());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert!(
|
||||
json.get("prompt_cache_key").is_none(),
|
||||
"prompt_cache_key key must not appear in serialised body, got: {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_without_properties_is_normalized() {
|
||||
// schemars は引数なし struct から `type:"object"` だけのスキーマを
|
||||
// 吐く。OpenAI Responses は `properties` 欠落を 400 で拒否するので
|
||||
// 送る直前に空オブジェクトを補うのを確認。
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let raw_schema = serde_json::json!({ "type": "object" });
|
||||
let req = Request::new().tool(
|
||||
ToolDefinition::new("empty")
|
||||
.description("no args")
|
||||
.input_schema(raw_schema),
|
||||
);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["tools"][0]["parameters"]["type"], "object");
|
||||
assert!(
|
||||
json["tools"][0]["parameters"]["properties"].is_object(),
|
||||
"properties must be present as an object, got: {}",
|
||||
json["tools"][0]["parameters"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_with_properties_is_untouched() {
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let raw_schema = serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": { "path": { "type": "string" } },
|
||||
"required": ["path"]
|
||||
});
|
||||
let req = Request::new().tool(
|
||||
ToolDefinition::new("t")
|
||||
.description("d")
|
||||
.input_schema(raw_schema.clone()),
|
||||
);
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["tools"][0]["parameters"], raw_schema);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_body_has_expected_shape() {
|
||||
// wire 形式が崩れていないかのスモークテスト
|
||||
let scheme = OpenAIResponsesScheme::new();
|
||||
let req = Request::new()
|
||||
.system("sys")
|
||||
.user("hi")
|
||||
.tool(ToolDefinition::new("t").description("d"));
|
||||
let body = scheme.build_request("gpt-5", &req, &cap_with_reasoning());
|
||||
let json = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(json["model"], "gpt-5");
|
||||
assert_eq!(json["instructions"], "sys");
|
||||
assert_eq!(json["tool_choice"], "auto");
|
||||
assert_eq!(json["parallel_tool_calls"], true);
|
||||
assert_eq!(json["store"], false);
|
||||
assert_eq!(json["stream"], true);
|
||||
assert_eq!(json["include"][0], "reasoning.encrypted_content");
|
||||
assert_eq!(json["tools"][0]["type"], "function");
|
||||
assert_eq!(json["tools"][0]["name"], "t");
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
//! `impl Scheme for OpenAIResponsesScheme`
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
client::ConfigWarning,
|
||||
event::Event,
|
||||
scheme::Scheme,
|
||||
types::{Request, RequestConfig},
|
||||
};
|
||||
|
||||
use super::OpenAIResponsesScheme;
|
||||
|
||||
pub use super::events::OpenAIResponsesState;
|
||||
|
||||
impl Scheme for OpenAIResponsesScheme {
|
||||
type State = OpenAIResponsesState;
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
// `/v1` は base_url 側に寄せる。ChatGPT OAuth 経由のときは
|
||||
// `https://chatgpt.com/backend-api/codex` を base にすれば同じ
|
||||
// `/responses` path で両系統を吸収できる(Codex CLI 準拠)。
|
||||
"https://api.openai.com/v1"
|
||||
}
|
||||
|
||||
fn path(&self, _model_id: &str) -> String {
|
||||
"/responses".to_string()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::Bearer
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
capability: &ModelCapability,
|
||||
) -> Value {
|
||||
let body = self.build_request(model_id, request, capability);
|
||||
serde_json::to_value(&body).expect("ResponsesRequest is always serialisable")
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
event_type: &str,
|
||||
data: &str,
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
super::events::parse_sse(event_type, data, state)
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
// ChatGPT backend (codex-oauth) は `max_output_tokens` を 400 で弾く。
|
||||
// scheme 構築時に `send_max_output_tokens=false` で組まれていれば
|
||||
// body 投影は止まっているので、ユーザの意図が落ちることだけを通知する。
|
||||
if !self.send_max_output_tokens && config.max_tokens.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"max_tokens",
|
||||
"OpenAI Responses (ChatGPT backend)",
|
||||
));
|
||||
}
|
||||
// 同上、`temperature` / `top_p` も ChatGPT backend では 400 で弾かれる。
|
||||
if !self.send_sampling_params {
|
||||
if config.temperature.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"temperature",
|
||||
"OpenAI Responses (ChatGPT backend)",
|
||||
));
|
||||
}
|
||||
if config.top_p.is_some() {
|
||||
warnings.push(ConfigWarning::unsupported(
|
||||
"top_p",
|
||||
"OpenAI Responses (ChatGPT backend)",
|
||||
));
|
||||
}
|
||||
}
|
||||
warnings
|
||||
}
|
||||
}
|
||||
485
crates/llm-worker/src/llm_client/transport.rs
Normal file
485
crates/llm-worker/src/llm_client/transport.rs
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
//! `HttpTransport<S: Scheme>`: すべての LLM wire scheme を共通の 1 本の
|
||||
//! HTTP クライアントで扱う。
|
||||
//!
|
||||
//! 旧 `providers/{anthropic,openai,gemini,ollama}.rs` を置き換える。
|
||||
//! scheme 固有の差分は [`Scheme`] trait 実装に委譲する。
|
||||
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
use reqwest::header::{
|
||||
ACCEPT, CONTENT_ENCODING, CONTENT_TYPE, HeaderMap, HeaderName, HeaderValue, RETRY_AFTER,
|
||||
};
|
||||
|
||||
use super::auth::{AuthProvider, AuthRequirement};
|
||||
use super::capability::ModelCapability;
|
||||
use super::client::{ConfigWarning, LlmClient, ResponseStream};
|
||||
use super::error::ClientError;
|
||||
use super::event::Event;
|
||||
use super::scheme::Scheme;
|
||||
use super::types::{Request, RequestConfig};
|
||||
|
||||
pub const DEFAULT_STREAM_OPEN_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
pub const DEFAULT_FIRST_STREAM_EVENT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// `AuthRef` を解決したランタイム表現。`crates/provider` が構築する。
|
||||
///
|
||||
/// - `None`: 認証ヘッダを送らない(Ollama 等の opt-out)
|
||||
/// - `ApiKey`: 静的な API key 文字列
|
||||
/// - `Custom`: リクエスト毎に動的にヘッダを組み立てる(Codex OAuth 等)
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResolvedAuth {
|
||||
None,
|
||||
ApiKey(String),
|
||||
Custom(Arc<dyn AuthProvider>),
|
||||
}
|
||||
|
||||
impl ResolvedAuth {
|
||||
/// 認証要件と実際の解決値が噛み合うか検査する。構築時検証用。
|
||||
///
|
||||
/// - `ResolvedAuth::None` は認証を付けない宣言なので、どの
|
||||
/// `AuthRequirement` でも受け入れる(Ollama の Anthropic scheme
|
||||
/// 流用は `required_auth = XApiKey` だが認証ヘッダなしで動く)
|
||||
/// - `ResolvedAuth::Custom` は「ヘッダ組立を全部こちらで行う」
|
||||
/// 宣言なので、scheme が要求する形式によらず受け入れる
|
||||
pub fn matches(&self, req: AuthRequirement) -> bool {
|
||||
match (self, req) {
|
||||
(Self::None, _) => true,
|
||||
(Self::Custom(_), _) => true,
|
||||
(
|
||||
Self::ApiKey(_),
|
||||
AuthRequirement::Bearer
|
||||
| AuthRequirement::XApiKey
|
||||
| AuthRequirement::QueryParam { .. },
|
||||
) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// scheme 共通の HTTP 通信層。
|
||||
pub struct HttpTransport<S: Scheme> {
|
||||
http_client: reqwest::Client,
|
||||
scheme: S,
|
||||
model_id: String,
|
||||
base_url: String,
|
||||
auth: ResolvedAuth,
|
||||
capability: ModelCapability,
|
||||
}
|
||||
|
||||
impl<S: Scheme> HttpTransport<S> {
|
||||
/// 新しい transport を作る。`base_url` は末尾スラッシュの有無を
|
||||
/// どちらでも受け付ける(内部で正規化)。
|
||||
pub fn new(
|
||||
scheme: S,
|
||||
model_id: impl Into<String>,
|
||||
base_url: impl Into<String>,
|
||||
auth: ResolvedAuth,
|
||||
capability: ModelCapability,
|
||||
) -> Self {
|
||||
let base_url = base_url.into();
|
||||
let base_url = base_url.trim_end_matches('/').to_string();
|
||||
Self {
|
||||
http_client: reqwest::Client::new(),
|
||||
scheme,
|
||||
model_id: model_id.into(),
|
||||
base_url,
|
||||
auth,
|
||||
capability,
|
||||
}
|
||||
}
|
||||
|
||||
/// カスタム HTTP クライアントを差し込む(テスト等)。
|
||||
pub fn with_http_client(mut self, client: reqwest::Client) -> Self {
|
||||
self.http_client = client;
|
||||
self
|
||||
}
|
||||
|
||||
fn build_url(&self) -> String {
|
||||
let path = self.scheme.path(&self.model_id);
|
||||
let url = format!("{}{}", self.base_url, path);
|
||||
// Gemini のようにクエリパラメータで認証する場合は URL にキーを追記する
|
||||
if let (AuthRequirement::QueryParam { name }, ResolvedAuth::ApiKey(key)) =
|
||||
(self.scheme.required_auth(), &self.auth)
|
||||
{
|
||||
let sep = if url.contains('?') { '&' } else { '?' };
|
||||
format!("{url}{sep}{name}={key}")
|
||||
} else {
|
||||
url
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_headers(&self) -> Result<HeaderMap, ClientError> {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
|
||||
|
||||
match (&self.auth, self.scheme.required_auth()) {
|
||||
(ResolvedAuth::None, _) | (_, AuthRequirement::None) => {}
|
||||
(ResolvedAuth::Custom(provider), _) => {
|
||||
for (name, mut value) in provider.headers().await? {
|
||||
value.set_sensitive(true);
|
||||
headers.insert(name, value);
|
||||
}
|
||||
}
|
||||
(ResolvedAuth::ApiKey(key), AuthRequirement::Bearer) => {
|
||||
let mut val = HeaderValue::from_str(&format!("Bearer {key}"))
|
||||
.map_err(|e| ClientError::Config(format!("invalid api key: {e}")))?;
|
||||
val.set_sensitive(true);
|
||||
headers.insert("Authorization", val);
|
||||
}
|
||||
(ResolvedAuth::ApiKey(key), AuthRequirement::XApiKey) => {
|
||||
let mut val = HeaderValue::from_str(key.as_str())
|
||||
.map_err(|e| ClientError::Config(format!("invalid api key: {e}")))?;
|
||||
val.set_sensitive(true);
|
||||
headers.insert("x-api-key", val);
|
||||
}
|
||||
(_, AuthRequirement::QueryParam { .. }) => {
|
||||
// クエリパラメータは `build_url` で付与済み
|
||||
}
|
||||
(ResolvedAuth::ApiKey(_), AuthRequirement::Custom) => {
|
||||
// scheme が Custom を要求する組合せに ApiKey は流れてこない想定
|
||||
// (`matches()` で弾かれる)。安全側で何もしない
|
||||
}
|
||||
}
|
||||
|
||||
for (name, value) in self.scheme.additional_headers() {
|
||||
let hv = HeaderValue::from_str(&value)
|
||||
.map_err(|e| ClientError::Config(format!("invalid header {name}: {e}")))?;
|
||||
headers.insert(name, hv);
|
||||
}
|
||||
|
||||
Ok(headers)
|
||||
}
|
||||
|
||||
fn is_codex_backend(&self) -> bool {
|
||||
match &self.auth {
|
||||
ResolvedAuth::Custom(provider) => provider.is_codex_backend(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_stream_headers(
|
||||
&self,
|
||||
headers: &mut HeaderMap,
|
||||
request: &Request,
|
||||
) -> Result<(), ClientError> {
|
||||
headers.insert(ACCEPT, HeaderValue::from_static("text/event-stream"));
|
||||
|
||||
if self.is_codex_backend()
|
||||
&& let Some(cache_key) = request.cache_key.as_deref()
|
||||
{
|
||||
let value = HeaderValue::from_str(cache_key).map_err(|e| {
|
||||
ClientError::Config(format!("invalid Codex conversation header: {e}"))
|
||||
})?;
|
||||
headers.insert(HeaderName::from_static("session_id"), value.clone());
|
||||
headers.insert(HeaderName::from_static("x-client-request-id"), value);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_request_body(
|
||||
&self,
|
||||
body: &serde_json::Value,
|
||||
headers: &mut HeaderMap,
|
||||
) -> Result<RequestBody, ClientError> {
|
||||
if !self.is_codex_backend() {
|
||||
return Ok(RequestBody::Json(body.clone()));
|
||||
}
|
||||
|
||||
let raw = serde_json::to_vec(body)?;
|
||||
let compressed = zstd::stream::encode_all(std::io::Cursor::new(raw), 3)
|
||||
.map_err(|e| ClientError::Config(format!("failed to zstd-compress request: {e}")))?;
|
||||
headers.insert(CONTENT_ENCODING, HeaderValue::from_static("zstd"));
|
||||
Ok(RequestBody::CompressedJson(compressed))
|
||||
}
|
||||
}
|
||||
|
||||
enum RequestBody {
|
||||
Json(serde_json::Value),
|
||||
CompressedJson(Vec<u8>),
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
||||
timeout: Duration,
|
||||
phase: &'static str,
|
||||
) -> Result<reqwest::Response, ClientError> {
|
||||
tokio::time::timeout(timeout, future)
|
||||
.await
|
||||
.map_err(|_| ClientError::Timeout { phase, timeout })?
|
||||
.map_err(ClientError::Http)
|
||||
}
|
||||
|
||||
impl<S: Scheme + Clone> Clone for HttpTransport<S> {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
http_client: self.http_client.clone(),
|
||||
scheme: self.scheme.clone(),
|
||||
model_id: self.model_id.clone(),
|
||||
base_url: self.base_url.clone(),
|
||||
auth: self.auth.clone(),
|
||||
capability: self.capability.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// エラーレスポンスを `ClientError::Api` に変換する。
|
||||
async fn classify_error_response(resp: reqwest::Response) -> ClientError {
|
||||
let status = resp.status().as_u16();
|
||||
let retry_after = resp
|
||||
.headers()
|
||||
.get(RETRY_AFTER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|s| s.trim().parse::<u64>().ok())
|
||||
.map(Duration::from_secs);
|
||||
let text = resp.text().await.unwrap_or_default();
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) {
|
||||
let error = json.get("error").unwrap_or(&json);
|
||||
let code = error.get("type").and_then(|v| v.as_str()).map(String::from);
|
||||
let message = error
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or(&text)
|
||||
.to_string();
|
||||
ClientError::Api {
|
||||
status: Some(status),
|
||||
code,
|
||||
message,
|
||||
retry_after,
|
||||
}
|
||||
} else {
|
||||
ClientError::Api {
|
||||
status: Some(status),
|
||||
code: None,
|
||||
message: text,
|
||||
retry_after,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
self.scheme.validate_config(config)
|
||||
}
|
||||
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||
let url = self.build_url();
|
||||
let mut headers = self.build_headers().await?;
|
||||
self.apply_stream_headers(&mut headers, &request)?;
|
||||
let body = self
|
||||
.scheme
|
||||
.build_request_body(&self.model_id, &request, &self.capability);
|
||||
let request_body = self.encode_request_body(&body, &mut headers)?;
|
||||
|
||||
let builder = self.http_client.post(&url).headers(headers);
|
||||
let builder = match request_body {
|
||||
RequestBody::Json(body) => builder.json(&body),
|
||||
RequestBody::CompressedJson(body) => builder.body(body),
|
||||
};
|
||||
let response =
|
||||
response_with_timeout(builder.send(), DEFAULT_STREAM_OPEN_TIMEOUT, "stream_open")
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(classify_error_response(response).await);
|
||||
}
|
||||
|
||||
let scheme = self.scheme.clone();
|
||||
let byte_stream = response.bytes_stream().map_err(std::io::Error::other);
|
||||
let event_stream = byte_stream.eventsource();
|
||||
|
||||
// scheme 固有のパース状態をストリーム単位で保持する
|
||||
let mut state = <S::State as Default>::default();
|
||||
|
||||
let stream = event_stream
|
||||
.map(move |result| match result {
|
||||
Ok(frame) => match scheme.parse_sse(&frame.event, &frame.data, &mut state) {
|
||||
Ok(events) => Ok(events),
|
||||
Err(e) => Err(e),
|
||||
},
|
||||
Err(e) => Err(ClientError::Sse(e.to_string())),
|
||||
})
|
||||
.map(|res| {
|
||||
let s: Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>> = match res {
|
||||
Ok(events) => Box::pin(futures::stream::iter(events.into_iter().map(Ok))),
|
||||
Err(e) => Box::pin(futures::stream::once(async move { Err(e) })),
|
||||
};
|
||||
s
|
||||
})
|
||||
.flatten();
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TestAuthProvider {
|
||||
codex: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthProvider for TestAuthProvider {
|
||||
async fn headers(&self) -> Result<Vec<(HeaderName, HeaderValue)>, ClientError> {
|
||||
Ok(vec![
|
||||
(
|
||||
HeaderName::from_static("authorization"),
|
||||
HeaderValue::from_static("Bearer test-token"),
|
||||
),
|
||||
(
|
||||
HeaderName::from_static("chatgpt-account-id"),
|
||||
HeaderValue::from_static("account-1"),
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
fn is_codex_backend(&self) -> bool {
|
||||
self.codex
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct TestScheme;
|
||||
|
||||
impl Scheme for TestScheme {
|
||||
type State = ();
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://example.test"
|
||||
}
|
||||
|
||||
fn path(&self, _model_id: &str) -> String {
|
||||
"/responses".to_string()
|
||||
}
|
||||
|
||||
fn required_auth(&self) -> AuthRequirement {
|
||||
AuthRequirement::Bearer
|
||||
}
|
||||
|
||||
fn build_request_body(
|
||||
&self,
|
||||
model_id: &str,
|
||||
request: &Request,
|
||||
_capability: &ModelCapability,
|
||||
) -> serde_json::Value {
|
||||
json!({
|
||||
"model": model_id,
|
||||
"input_len": request.items.len(),
|
||||
"prompt_cache_key": request.cache_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_sse(
|
||||
&self,
|
||||
_event_type: &str,
|
||||
_data: &str,
|
||||
_state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
ModelCapability::minimal()
|
||||
}
|
||||
}
|
||||
|
||||
fn transport(auth: ResolvedAuth) -> HttpTransport<TestScheme> {
|
||||
HttpTransport::new(
|
||||
TestScheme,
|
||||
"gpt-test",
|
||||
"https://example.test",
|
||||
auth,
|
||||
ModelCapability::minimal(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_timeout_returns_retryable_lifecycle_timeout() {
|
||||
let err = response_with_timeout(
|
||||
std::future::pending::<Result<reqwest::Response, reqwest::Error>>(),
|
||||
Duration::from_millis(5),
|
||||
"stream_open",
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
|
||||
assert!(crate::llm_client::error::is_retryable(&err));
|
||||
assert!(matches!(
|
||||
err,
|
||||
ClientError::Timeout {
|
||||
phase: "stream_open",
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_backend_adds_conversation_headers_and_zstd_body() {
|
||||
let transport = transport(ResolvedAuth::Custom(Arc::new(TestAuthProvider {
|
||||
codex: true,
|
||||
})));
|
||||
let request = Request::new().user("hello").cache_key("segment-123");
|
||||
let mut headers = transport.build_headers().await.unwrap();
|
||||
transport
|
||||
.apply_stream_headers(&mut headers, &request)
|
||||
.unwrap();
|
||||
let body = transport.scheme.build_request_body(
|
||||
&transport.model_id,
|
||||
&request,
|
||||
&transport.capability,
|
||||
);
|
||||
let encoded = transport.encode_request_body(&body, &mut headers).unwrap();
|
||||
|
||||
assert_eq!(headers.get(ACCEPT).unwrap(), "text/event-stream");
|
||||
assert_eq!(headers.get("session_id").unwrap(), "segment-123");
|
||||
assert_eq!(headers.get("x-client-request-id").unwrap(), "segment-123");
|
||||
assert_eq!(headers.get(CONTENT_ENCODING).unwrap(), "zstd");
|
||||
|
||||
let RequestBody::CompressedJson(compressed) = encoded else {
|
||||
panic!("Codex backend request body must be zstd-compressed");
|
||||
};
|
||||
let decoded = zstd::stream::decode_all(std::io::Cursor::new(compressed)).unwrap();
|
||||
let decoded: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
|
||||
assert_eq!(decoded["prompt_cache_key"], "segment-123");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn non_codex_request_does_not_get_codex_only_headers_or_compression() {
|
||||
let transport = transport(ResolvedAuth::ApiKey("api-key".to_string()));
|
||||
let request = Request::new().user("hello").cache_key("segment-123");
|
||||
let mut headers = transport.build_headers().await.unwrap();
|
||||
transport
|
||||
.apply_stream_headers(&mut headers, &request)
|
||||
.unwrap();
|
||||
let body = transport.scheme.build_request_body(
|
||||
&transport.model_id,
|
||||
&request,
|
||||
&transport.capability,
|
||||
);
|
||||
let encoded = transport.encode_request_body(&body, &mut headers).unwrap();
|
||||
|
||||
assert_eq!(headers.get(ACCEPT).unwrap(), "text/event-stream");
|
||||
assert!(headers.get("session_id").is_none());
|
||||
assert!(headers.get("x-client-request-id").is_none());
|
||||
assert!(headers.get(CONTENT_ENCODING).is_none());
|
||||
|
||||
let RequestBody::Json(decoded) = encoded else {
|
||||
panic!("non-Codex request body must remain normal JSON");
|
||||
};
|
||||
assert_eq!(decoded["prompt_cache_key"], "segment-123");
|
||||
}
|
||||
}
|
||||
741
crates/llm-worker/src/llm_client/types.rs
Normal file
741
crates/llm-worker/src/llm_client/types.rs
Normal file
|
|
@ -0,0 +1,741 @@
|
|||
//! LLM Client Common Types
|
||||
//!
|
||||
//! Core conversation types for insomnia's LLM interaction model.
|
||||
//! The core abstraction is `Item` which represents different types of conversation elements:
|
||||
//! - Message items (user/assistant messages with content parts)
|
||||
//! - ToolCall items (tool invocations)
|
||||
//! - ToolResult items (tool results)
|
||||
//! - Reasoning items (extended thinking)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
fn is_false(value: &bool) -> bool {
|
||||
!*value
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Item - The core unit of conversation
|
||||
// ============================================================================
|
||||
|
||||
/// Item ID type for tracking items in a conversation
|
||||
pub type ItemId = String;
|
||||
|
||||
/// Call ID type for linking function calls to their outputs
|
||||
pub type CallId = String;
|
||||
|
||||
/// Conversation item - the primary unit of conversation history
|
||||
///
|
||||
/// Items represent discrete elements in a conversation. Tool calls and reasoning
|
||||
/// are first-class items rather than parts of messages.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use llm_worker::Item;
|
||||
///
|
||||
/// let user = Item::user_message("Hello!");
|
||||
/// let assistant = Item::assistant_message("Hi there!");
|
||||
/// let call = Item::tool_call("call_123", "get_weather", json!({"city": "Tokyo"}));
|
||||
/// let result = Item::tool_result("call_123", "Sunny, 25°C");
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Item {
|
||||
/// User or assistant message with content parts
|
||||
Message {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Message role
|
||||
role: Role,
|
||||
/// Content parts
|
||||
content: Vec<ContentPart>,
|
||||
/// Item status
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
|
||||
/// Tool call from the assistant
|
||||
ToolCall {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Call ID for linking to result
|
||||
call_id: CallId,
|
||||
/// Tool name
|
||||
name: String,
|
||||
/// Tool arguments as JSON string
|
||||
arguments: String,
|
||||
/// Item status
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
|
||||
/// Tool call result
|
||||
ToolResult {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Call ID linking to the tool call
|
||||
call_id: CallId,
|
||||
/// Short summary (always kept in history, survives pruning)
|
||||
summary: String,
|
||||
/// Detailed output (removed by pruning when old enough)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
content: Option<String>,
|
||||
/// Whether the tool result represents an execution error.
|
||||
#[serde(default, skip_serializing_if = "is_false")]
|
||||
is_error: bool,
|
||||
},
|
||||
|
||||
/// Reasoning/thinking item
|
||||
Reasoning {
|
||||
/// Optional item ID
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<ItemId>,
|
||||
/// Reasoning text(reasoning body, `reasoning_text.delta` の累積)
|
||||
text: String,
|
||||
/// Reasoning summary(OpenAI Responses の `summary_text[]` を格納。
|
||||
/// 他 scheme は空)
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
summary: Vec<String>,
|
||||
/// サーバから返された暗号化済み reasoning blob。ZDR / `store=false`
|
||||
/// 運用で stateless に再送するときそのまま添える必要がある。
|
||||
/// Anthropic の `redacted_thinking.data` もここに格納する。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
encrypted_content: Option<String>,
|
||||
/// Anthropic extended thinking の `signature`。新世代 Claude
|
||||
/// (Opus 4.5+/Sonnet 4.6+) では同一論理ターン内の `thinking`
|
||||
/// ブロックを送り返す際に必須。改ざん検知に使われる。他 scheme
|
||||
/// では `None`。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
signature: Option<String>,
|
||||
/// Item status
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Item {
|
||||
// ========================================================================
|
||||
// Message constructors
|
||||
// ========================================================================
|
||||
|
||||
/// Create a system message item with text content.
|
||||
///
|
||||
/// System items in history are sent as `role: "system"` on OpenAI,
|
||||
/// and as `role: "user"` on Anthropic/Gemini (which lack a system
|
||||
/// role in conversation items).
|
||||
pub fn system_message(text: impl Into<String>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::System,
|
||||
content: vec![ContentPart::Text { text: text.into() }],
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a user message item with text content
|
||||
pub fn user_message(text: impl Into<String>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::User,
|
||||
content: vec![ContentPart::Text { text: text.into() }],
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a user message item with multiple content parts
|
||||
pub fn user_message_parts(parts: Vec<ContentPart>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::User,
|
||||
content: parts,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an assistant message item with text content
|
||||
pub fn assistant_message(text: impl Into<String>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::Assistant,
|
||||
content: vec![ContentPart::Text { text: text.into() }],
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an assistant message item with multiple content parts
|
||||
pub fn assistant_message_parts(parts: Vec<ContentPart>) -> Self {
|
||||
Self::Message {
|
||||
id: None,
|
||||
role: Role::Assistant,
|
||||
content: parts,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Tool call constructors
|
||||
// ========================================================================
|
||||
|
||||
/// Create a tool call item
|
||||
pub fn tool_call(
|
||||
call_id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
arguments: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::ToolCall {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
name: name.into(),
|
||||
arguments: arguments.into(),
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tool call item from a JSON value
|
||||
pub fn tool_call_json(
|
||||
call_id: impl Into<String>,
|
||||
name: impl Into<String>,
|
||||
arguments: serde_json::Value,
|
||||
) -> Self {
|
||||
Self::tool_call(call_id, name, arguments.to_string())
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary only (no content).
|
||||
pub fn tool_result(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::tool_result_item(call_id, summary, None, false)
|
||||
}
|
||||
|
||||
/// Create an error tool result item with summary only (no content).
|
||||
pub fn tool_result_error(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::tool_result_item(call_id, summary, None, true)
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary, optional content, and error flag.
|
||||
pub fn tool_result_item(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: Option<String>,
|
||||
is_error: bool,
|
||||
) -> Self {
|
||||
Self::ToolResult {
|
||||
id: None,
|
||||
call_id: call_id.into(),
|
||||
summary: summary.into(),
|
||||
content,
|
||||
is_error,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary and content.
|
||||
pub fn tool_result_with_content(
|
||||
call_id: impl Into<String>,
|
||||
summary: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
) -> Self {
|
||||
Self::tool_result_item(call_id, summary, Some(content.into()), false)
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Reasoning constructors
|
||||
// ========================================================================
|
||||
|
||||
/// Create a reasoning item
|
||||
pub fn reasoning(text: impl Into<String>) -> Self {
|
||||
Self::Reasoning {
|
||||
id: None,
|
||||
text: text.into(),
|
||||
summary: Vec::new(),
|
||||
encrypted_content: None,
|
||||
signature: None,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Set reasoning summary on a `Reasoning` item. No-op on other variants.
|
||||
pub fn with_reasoning_summary(mut self, new_summary: Vec<String>) -> Self {
|
||||
if let Self::Reasoning { summary, .. } = &mut self {
|
||||
*summary = new_summary;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set `encrypted_content` on a `Reasoning` item. No-op on other variants.
|
||||
pub fn with_encrypted_content(mut self, content: impl Into<String>) -> Self {
|
||||
if let Self::Reasoning {
|
||||
encrypted_content, ..
|
||||
} = &mut self
|
||||
{
|
||||
*encrypted_content = Some(content.into());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set Anthropic `signature` on a `Reasoning` item. No-op on other variants.
|
||||
pub fn with_signature(mut self, sig: impl Into<String>) -> Self {
|
||||
if let Self::Reasoning { signature, .. } = &mut self {
|
||||
*signature = Some(sig.into());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Builder methods
|
||||
// ========================================================================
|
||||
|
||||
/// Set the item ID
|
||||
pub fn with_id(mut self, id: impl Into<String>) -> Self {
|
||||
match &mut self {
|
||||
Self::Message { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::ToolCall { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::ToolResult { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::Reasoning { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the item status
|
||||
pub fn with_status(mut self, new_status: ItemStatus) -> Self {
|
||||
match &mut self {
|
||||
Self::Message { status, .. } => *status = Some(new_status),
|
||||
Self::ToolCall { status, .. } => *status = Some(new_status),
|
||||
Self::ToolResult { .. } => {} // Result items don't have status
|
||||
Self::Reasoning { status, .. } => *status = Some(new_status),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Accessors
|
||||
// ========================================================================
|
||||
|
||||
/// Get the item ID if set
|
||||
pub fn id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Message { id, .. } => id.as_deref(),
|
||||
Self::ToolCall { id, .. } => id.as_deref(),
|
||||
Self::ToolResult { id, .. } => id.as_deref(),
|
||||
Self::Reasoning { id, .. } => id.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the item type as a string
|
||||
pub fn item_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Message { .. } => "message",
|
||||
Self::ToolCall { .. } => "tool_call",
|
||||
Self::ToolResult { .. } => "tool_result",
|
||||
Self::Reasoning { .. } => "reasoning",
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a user message
|
||||
pub fn is_user_message(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Message {
|
||||
role: Role::User,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this is an assistant message
|
||||
pub fn is_assistant_message(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Message {
|
||||
role: Role::Assistant,
|
||||
..
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if this is a tool call
|
||||
pub fn is_tool_call(&self) -> bool {
|
||||
matches!(self, Self::ToolCall { .. })
|
||||
}
|
||||
|
||||
/// Check if this is a tool result
|
||||
pub fn is_tool_result(&self) -> bool {
|
||||
matches!(self, Self::ToolResult { .. })
|
||||
}
|
||||
|
||||
/// Check if this is a reasoning item
|
||||
pub fn is_reasoning(&self) -> bool {
|
||||
matches!(self, Self::Reasoning { .. })
|
||||
}
|
||||
|
||||
/// Get text content if this is a simple text message
|
||||
pub fn as_text(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Message { content, .. } if content.len() == 1 => match &content[0] {
|
||||
ContentPart::Text { text } => Some(text),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a ToolCall `arguments` string into a JSON object.
|
||||
///
|
||||
/// Tool call arguments must be a JSON object at the provider API level
|
||||
/// (Anthropic rejects non-object `tool_use.input`). This helper normalizes
|
||||
/// anything that is not a JSON object — empty string, the literal `"null"`,
|
||||
/// arrays, scalars, or parse failures — to an empty object `{}`.
|
||||
pub fn parse_tool_arguments(arguments: &str) -> serde_json::Value {
|
||||
match serde_json::from_str::<serde_json::Value>(arguments) {
|
||||
Ok(value) if value.is_object() => value,
|
||||
_ => serde_json::Value::Object(serde_json::Map::new()),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Content Parts - Components within message items
|
||||
// ============================================================================
|
||||
|
||||
/// Content part within a message item
|
||||
///
|
||||
/// Text content is role-agnostic; the containing Item's Role determines
|
||||
/// whether it's user input or assistant output.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentPart {
|
||||
/// Text content
|
||||
Text {
|
||||
/// The text content
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Refusal content (for assistant messages)
|
||||
Refusal {
|
||||
/// The refusal message
|
||||
refusal: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl ContentPart {
|
||||
/// Create a text part
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
Self::Text { text: text.into() }
|
||||
}
|
||||
|
||||
/// Create a refusal part
|
||||
pub fn refusal(refusal: impl Into<String>) -> Self {
|
||||
Self::Refusal {
|
||||
refusal: refusal.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the text content regardless of type
|
||||
pub fn as_text(&self) -> &str {
|
||||
match self {
|
||||
Self::Text { text } => text,
|
||||
Self::Refusal { refusal } => refusal,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Role and Status
|
||||
// ============================================================================
|
||||
|
||||
/// Message role
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Role {
|
||||
/// User
|
||||
User,
|
||||
/// Assistant
|
||||
Assistant,
|
||||
/// System (for system prompts, not typically used in items)
|
||||
System,
|
||||
}
|
||||
|
||||
/// Item status
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ItemStatus {
|
||||
/// Item is being generated
|
||||
InProgress,
|
||||
/// Item completed successfully
|
||||
Completed,
|
||||
/// Item was truncated (e.g., max tokens)
|
||||
Incomplete,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request Types
|
||||
// ============================================================================
|
||||
|
||||
/// LLM Request
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Request {
|
||||
/// System prompt (instructions)
|
||||
pub system_prompt: Option<String>,
|
||||
/// Input items (conversation history)
|
||||
pub items: Vec<Item>,
|
||||
/// Tool definitions
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
/// Request configuration
|
||||
pub config: RequestConfig,
|
||||
/// Index into `items` marking the end of a stable, cacheable prefix.
|
||||
///
|
||||
/// Higher layers that know about durable prefix boundaries (e.g. a
|
||||
/// post-compaction summary) set this so that caching-aware providers
|
||||
/// (Anthropic today) can place a long-lived cache breakpoint there.
|
||||
/// Providers without prompt caching ignore the field.
|
||||
pub cache_anchor: Option<usize>,
|
||||
/// 会話単位の安定キー。`prompt_cache_key` として送られる
|
||||
/// (OpenAI Responses)。ChatGPT backend (codex-oauth) は明示キーが
|
||||
/// 無いと org/project ハッシュ衝突でプロンプトキャッシュが
|
||||
/// ほぼヒットしないため、pod 側で `SegmentId` を渡す運用を想定。
|
||||
/// `cache_anchor` と違い名前空間キーであり、`prefix anchor` とは
|
||||
/// 別の概念。`cache_anchor` を読まない provider と同じく、
|
||||
/// `prompt_cache_key` を持たない provider は無視する。
|
||||
pub cache_key: Option<String>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Create a new empty request
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set the system prompt
|
||||
pub fn system(mut self, prompt: impl Into<String>) -> Self {
|
||||
self.system_prompt = Some(prompt.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a user message
|
||||
pub fn user(mut self, content: impl Into<String>) -> Self {
|
||||
self.items.push(Item::user_message(content));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add an assistant message
|
||||
pub fn assistant(mut self, content: impl Into<String>) -> Self {
|
||||
self.items.push(Item::assistant_message(content));
|
||||
self
|
||||
}
|
||||
|
||||
/// Add an item
|
||||
pub fn item(mut self, item: Item) -> Self {
|
||||
self.items.push(item);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add multiple items
|
||||
pub fn items(mut self, items: impl IntoIterator<Item = Item>) -> Self {
|
||||
self.items.extend(items);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a tool definition
|
||||
pub fn tool(mut self, tool: ToolDefinition) -> Self {
|
||||
self.tools.push(tool);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the request config
|
||||
pub fn config(mut self, config: RequestConfig) -> Self {
|
||||
self.config = config;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set max tokens
|
||||
pub fn max_tokens(mut self, max_tokens: u32) -> Self {
|
||||
self.config.max_tokens = Some(max_tokens);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set temperature
|
||||
pub fn temperature(mut self, temperature: f32) -> Self {
|
||||
self.config.temperature = Some(temperature);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set top_p
|
||||
pub fn top_p(mut self, top_p: f32) -> Self {
|
||||
self.config.top_p = Some(top_p);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set top_k
|
||||
pub fn top_k(mut self, top_k: u32) -> Self {
|
||||
self.config.top_k = Some(top_k);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a stop sequence
|
||||
pub fn stop_sequence(mut self, sequence: impl Into<String>) -> Self {
|
||||
self.config.stop_sequences.push(sequence.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the conversation cache key.
|
||||
///
|
||||
/// 詳細は [`Request::cache_key`] のフィールドコメント参照。
|
||||
pub fn cache_key(mut self, key: impl Into<String>) -> Self {
|
||||
self.cache_key = Some(key.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tool Definition
|
||||
// ============================================================================
|
||||
|
||||
/// Tool (function) definition
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
/// Tool name
|
||||
pub name: String,
|
||||
/// Tool description
|
||||
pub description: Option<String>,
|
||||
/// Input schema (JSON Schema)
|
||||
pub input_schema: serde_json::Value,
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
/// Create a new tool definition
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
description: None,
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the description
|
||||
pub fn description(mut self, desc: impl Into<String>) -> Self {
|
||||
self.description = Some(desc.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the input schema
|
||||
pub fn input_schema(mut self, schema: serde_json::Value) -> Self {
|
||||
self.input_schema = schema;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Request Config
|
||||
// ============================================================================
|
||||
|
||||
/// Request configuration
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct RequestConfig {
|
||||
/// Maximum tokens to generate
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Temperature (randomness)
|
||||
pub temperature: Option<f32>,
|
||||
/// Top P (nucleus sampling)
|
||||
pub top_p: Option<f32>,
|
||||
/// Top K
|
||||
pub top_k: Option<u32>,
|
||||
/// Stop sequences
|
||||
pub stop_sequences: Vec<String>,
|
||||
/// Reasoning / extended-thinking 制御(共通型、scheme 側で各社形式に投影)。
|
||||
///
|
||||
/// `None` のときは何も送らない。`Some` でも scheme の
|
||||
/// `ModelCapability::reasoning` が `None` なら無視される。
|
||||
#[serde(default)]
|
||||
pub reasoning: Option<crate::llm_client::capability::ReasoningControl>,
|
||||
}
|
||||
|
||||
impl RequestConfig {
|
||||
/// Create a new default config
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Set max tokens
|
||||
pub fn with_max_tokens(mut self, max_tokens: u32) -> Self {
|
||||
self.max_tokens = Some(max_tokens);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set temperature
|
||||
pub fn with_temperature(mut self, temperature: f32) -> Self {
|
||||
self.temperature = Some(temperature);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set top_p
|
||||
pub fn with_top_p(mut self, top_p: f32) -> Self {
|
||||
self.top_p = Some(top_p);
|
||||
self
|
||||
}
|
||||
|
||||
/// Set top_k
|
||||
pub fn with_top_k(mut self, top_k: u32) -> Self {
|
||||
self.top_k = Some(top_k);
|
||||
self
|
||||
}
|
||||
|
||||
/// Add a stop sequence
|
||||
pub fn with_stop_sequence(mut self, sequence: impl Into<String>) -> Self {
|
||||
self.stop_sequences.push(sequence.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod parse_tool_arguments_tests {
|
||||
use super::parse_tool_arguments;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn empty_object() -> Value {
|
||||
Value::Object(serde_json::Map::new())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_string_normalizes_to_object() {
|
||||
assert_eq!(parse_tool_arguments(""), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_null_normalizes_to_object() {
|
||||
// 既存セッションに残っている "null" が resume 時に復旧できること
|
||||
assert_eq!(parse_tool_arguments("null"), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn array_normalizes_to_object() {
|
||||
assert_eq!(parse_tool_arguments("[1, 2, 3]"), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_normalizes_to_object() {
|
||||
assert_eq!(parse_tool_arguments("42"), empty_object());
|
||||
assert_eq!(parse_tool_arguments("\"str\""), empty_object());
|
||||
assert_eq!(parse_tool_arguments("true"), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_json_normalizes_to_object() {
|
||||
assert_eq!(parse_tool_arguments("{not json"), empty_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_object_passes_through() {
|
||||
assert_eq!(
|
||||
parse_tool_arguments(r#"{"city":"Tokyo","days":3}"#),
|
||||
json!({"city": "Tokyo", "days": 3}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_object_passes_through() {
|
||||
assert_eq!(parse_tool_arguments("{}"), empty_object());
|
||||
}
|
||||
}
|
||||
14
crates/llm-worker/src/message.rs
Normal file
14
crates/llm-worker/src/message.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
//! Message and Item Types
|
||||
//!
|
||||
//! Core types for representing conversation items.
|
||||
//!
|
||||
//! The primary type is [`Item`], which represents different kinds of conversation
|
||||
//! elements: messages, tool calls, tool results, and reasoning.
|
||||
|
||||
// Re-export all types from llm_client::types
|
||||
pub use crate::llm_client::types::{ContentPart, Item, Role};
|
||||
|
||||
/// Convenience alias
|
||||
///
|
||||
/// Messages are just one type of Item.
|
||||
pub type Message = Item;
|
||||
451
crates/llm-worker/src/prune.rs
Normal file
451
crates/llm-worker/src/prune.rs
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
//! Prune — context projection for old tool-result content.
|
||||
//!
|
||||
//! LLM 送信時のコンテキストから古い [`Item::ToolResult`] の `content` を
|
||||
//! 省略して、コンテキスト窓のトークンを回収する。`summary` は残すので
|
||||
//! 「何が起きたか」の痕跡は保たれる。
|
||||
//!
|
||||
//! # 設計方針
|
||||
//!
|
||||
//! Prune は **コンテキスト射影** であり、history の変換ではない。
|
||||
//! この crate が提供するのは pure な候補抽出 [`prunable_indices`] のみで、
|
||||
//! 射影の適用は上位層(`pod::prune_hook` 等)が LLM に送る一時コンテキスト
|
||||
//! に対してだけ行う。Worker の永続履歴は決して変更されない。
|
||||
//!
|
||||
//! 保護境界は末尾 token budget で決めるが、この crate は usage 履歴を
|
||||
//! 所有しない。prefix ごとの token 推定値と savings 推定は上位層から
|
||||
//! callback で注入される。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::llm_client::types::Item;
|
||||
use crate::token_counter::{EstimateSource, TokenEstimate};
|
||||
|
||||
/// Callback that returns token estimates for every prefix boundary of the
|
||||
/// supplied request history.
|
||||
///
|
||||
/// The returned slice must have `history.len() + 1` entries where entry `i`
|
||||
/// estimates the token count of `history[..i]`. Returning a malformed vector,
|
||||
/// or estimates whose source is [`EstimateSource::NoData`], makes prune treat
|
||||
/// the request as having no candidates.
|
||||
pub type TokenEstimator = Box<dyn Fn(&[Item]) -> Vec<TokenEstimate> + Send + Sync>;
|
||||
|
||||
/// Callback that estimates the token savings for projecting the
|
||||
/// `ToolResult.content` out of `history[i]` for each `i` in `indices`.
|
||||
///
|
||||
/// Injected into [`crate::Worker`] via `set_savings_estimator` so the
|
||||
/// Worker can make `min_savings` decisions without knowing about usage
|
||||
/// measurement sources. Return `0` to signal "no data / refuse to prune".
|
||||
///
|
||||
/// 推定対象は「drop する範囲全体」ではなく「content を None にする差分」
|
||||
/// であることに注意。item 自体(summary 等)は残るので、この callback は
|
||||
/// 実際の projection と一致する savings を返す必要がある。
|
||||
pub type SavingsEstimator = Box<dyn Fn(&[Item], &[usize]) -> u64 + Send + Sync>;
|
||||
|
||||
/// Result of one prune evaluation pass, surfaced to the optional
|
||||
/// [`PruneObserver`] for instrumentation.
|
||||
///
|
||||
/// Worker は LLM リクエストごとに 1 回 prune の評価をし、その結果を
|
||||
/// (observer が登録されていれば)この値で通知する。fire/skip の判定
|
||||
/// 結果と、判定材料になった候補数 / 推定 savings / 保護領域の先頭 index を持つ。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PruneEvaluation {
|
||||
/// `prunable_indices` の長さ。`Skipped::NoCandidates` の時は 0。
|
||||
pub candidate_count: usize,
|
||||
/// 推定された savings (tokens)。`NoCandidates` の時は 0。
|
||||
pub estimated_savings: u64,
|
||||
/// Token budget で保護される suffix の先頭 item index。
|
||||
/// usage 推定が `NoData` で境界が決まらない場合は `None`。
|
||||
pub protected_start_index: Option<usize>,
|
||||
/// 判定結果。
|
||||
pub decision: PruneDecision,
|
||||
}
|
||||
|
||||
/// Outcome of one prune evaluation. Each variant is one branch of the
|
||||
/// "fire vs skip" decision tree the Worker walks before each LLM request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PruneDecision {
|
||||
/// `prunable_indices` が空 → 何もしない。
|
||||
SkippedNoCandidates,
|
||||
/// 候補はあったが推定 savings が `min_savings` 未満 → 何もしない。
|
||||
SkippedBelowMinSavings,
|
||||
/// 候補があり savings >= min_savings → projection を適用した。
|
||||
/// `pruned_count` は `project()` が実際に書き換えた item 数
|
||||
/// (既に content=None だった候補は 0 計上)。
|
||||
Fired { pruned_count: usize },
|
||||
}
|
||||
|
||||
/// Optional observer invoked after each prune evaluation, regardless of
|
||||
/// branch. Pod 等の上位層が install して metrics を発行する。
|
||||
pub type PruneObserver = Box<dyn Fn(&PruneEvaluation) + Send + Sync>;
|
||||
|
||||
/// Configuration for the Prune algorithm.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PruneConfig {
|
||||
/// Token budget at the history tail protected from pruning.
|
||||
#[serde(default = "default_protected_tokens")]
|
||||
pub protected_tokens: u64,
|
||||
|
||||
/// Minimum token savings required to actually prune. If the prunable
|
||||
/// content is smaller than this, the caller should skip to avoid
|
||||
/// pointless KV-cache invalidation. The unit is tokens; the caller
|
||||
/// is responsible for measuring savings via a usage-history-aware
|
||||
/// estimator and comparing against this threshold.
|
||||
#[serde(default = "default_min_savings")]
|
||||
pub min_savings: u64,
|
||||
}
|
||||
|
||||
fn default_protected_tokens() -> u64 {
|
||||
8000
|
||||
}
|
||||
fn default_min_savings() -> u64 {
|
||||
4096
|
||||
}
|
||||
|
||||
impl Default for PruneConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
protected_tokens: default_protected_tokens(),
|
||||
min_savings: default_min_savings(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set `content = None` on each `Item::ToolResult` at the given indices.
|
||||
///
|
||||
/// Returns the number of items that were actually modified — items that
|
||||
/// are already content-less are counted as 0. Intended for use on a
|
||||
/// request-context clone (never on a persistent history).
|
||||
pub fn project(items: &mut [Item], indices: &[usize]) -> usize {
|
||||
let mut count = 0;
|
||||
for &i in indices {
|
||||
if let Item::ToolResult { content, .. } = &mut items[i]
|
||||
&& content.is_some()
|
||||
{
|
||||
*content = None;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Indices of `Item::ToolResult { content: Some(_), .. }` that lie before
|
||||
/// the suffix protected by `protected_tokens`. Pure: does not mutate `items`.
|
||||
///
|
||||
/// Returns an empty vector when token estimates are unavailable (`NoData`) or
|
||||
/// no prunable candidates exist.
|
||||
pub fn prunable_indices(
|
||||
items: &[Item],
|
||||
protected_tokens: u64,
|
||||
token_estimates: &[TokenEstimate],
|
||||
) -> Vec<usize> {
|
||||
evaluate_candidates(items, protected_tokens, token_estimates).0
|
||||
}
|
||||
|
||||
/// Same as [`prunable_indices`] but also returns the start index of the
|
||||
/// protected suffix. `None` means the token boundary could not be determined
|
||||
/// (currently because usage estimates were `NoData` or malformed).
|
||||
pub fn evaluate_candidates(
|
||||
items: &[Item],
|
||||
protected_tokens: u64,
|
||||
token_estimates: &[TokenEstimate],
|
||||
) -> (Vec<usize>, Option<usize>) {
|
||||
let Some(protected_start) = protected_start_index(items, protected_tokens, token_estimates)
|
||||
else {
|
||||
return (Vec::new(), None);
|
||||
};
|
||||
|
||||
let candidates = items[..protected_start]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, item)| match item {
|
||||
Item::ToolResult {
|
||||
content: Some(_), ..
|
||||
} => Some(i),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
(candidates, Some(protected_start))
|
||||
}
|
||||
|
||||
fn protected_start_index(
|
||||
items: &[Item],
|
||||
protected_tokens: u64,
|
||||
token_estimates: &[TokenEstimate],
|
||||
) -> Option<usize> {
|
||||
if token_estimates.len() != items.len() + 1 {
|
||||
return None;
|
||||
}
|
||||
let total = token_estimates[items.len()];
|
||||
if total.source == EstimateSource::NoData {
|
||||
return None;
|
||||
}
|
||||
if protected_tokens == 0 {
|
||||
return Some(items.len());
|
||||
}
|
||||
|
||||
let mut protected_start = items.len();
|
||||
for idx in (0..items.len()).rev() {
|
||||
let prefix = token_estimates[idx];
|
||||
if prefix.source == EstimateSource::NoData {
|
||||
return None;
|
||||
}
|
||||
protected_start = idx;
|
||||
let tail_tokens = total.tokens.saturating_sub(prefix.tokens);
|
||||
if tail_tokens >= protected_tokens {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(protected_start)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Helper: build a history with interleaved user messages and tool results.
|
||||
fn make_history(turns: &[(&str, Vec<(&str, Option<&str>)>)]) -> Vec<Item> {
|
||||
let mut items = Vec::new();
|
||||
for (user_msg, tool_results) in turns {
|
||||
items.push(Item::user_message(*user_msg));
|
||||
items.push(Item::assistant_message("ok"));
|
||||
for (i, (summary, content)) in tool_results.iter().enumerate() {
|
||||
let call_id = format!("call_{}", items.len() + i);
|
||||
items.push(Item::tool_call(&call_id, "some_tool", "{}"));
|
||||
match content {
|
||||
Some(c) => items.push(Item::tool_result_with_content(&call_id, *summary, *c)),
|
||||
None => items.push(Item::tool_result(&call_id, *summary)),
|
||||
}
|
||||
}
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
fn measured_prefix(tokens: &[u64]) -> Vec<TokenEstimate> {
|
||||
tokens
|
||||
.iter()
|
||||
.copied()
|
||||
.map(|tokens| TokenEstimate {
|
||||
tokens,
|
||||
source: EstimateSource::Measured,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn uniform_estimates(items: &[Item], item_tokens: u64) -> Vec<TokenEstimate> {
|
||||
let mut tokens = Vec::with_capacity(items.len() + 1);
|
||||
for i in 0..=items.len() {
|
||||
tokens.push(i as u64 * item_tokens);
|
||||
}
|
||||
measured_prefix(&tokens)
|
||||
}
|
||||
|
||||
fn estimates_from_item_tokens(item_tokens: &[u64]) -> Vec<TokenEstimate> {
|
||||
let mut prefix = Vec::with_capacity(item_tokens.len() + 1);
|
||||
let mut acc = 0;
|
||||
prefix.push(acc);
|
||||
for tokens in item_tokens {
|
||||
acc += tokens;
|
||||
prefix.push(acc);
|
||||
}
|
||||
measured_prefix(&prefix)
|
||||
}
|
||||
|
||||
fn no_data_estimates(items: &[Item]) -> Vec<TokenEstimate> {
|
||||
(0..=items.len())
|
||||
.map(|i| TokenEstimate {
|
||||
tokens: i as u64,
|
||||
source: if i == 0 {
|
||||
EstimateSource::Measured
|
||||
} else {
|
||||
EstimateSource::NoData
|
||||
},
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_candidates_when_estimate_has_no_data() {
|
||||
let items = make_history(&[("turn1", vec![("summary1", Some("big content here"))])]);
|
||||
let estimates = no_data_estimates(&items);
|
||||
let (candidates, protected_start) = evaluate_candidates(&items, 10, &estimates);
|
||||
assert!(candidates.is_empty());
|
||||
assert_eq!(protected_start, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_candidates_when_history_fits_in_protected_tokens() {
|
||||
let items = make_history(&[
|
||||
("turn1", vec![("summary1", Some("big content here"))]),
|
||||
("turn2", vec![("summary2", Some("more content"))]),
|
||||
]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
assert!(prunable_indices(&items, 10_000, &estimates).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_before_token_protected_suffix() {
|
||||
let big = "x".repeat(4096 * 4);
|
||||
let items = make_history(&[
|
||||
("turn1", vec![("s1", Some(&big))]),
|
||||
("turn2", vec![("s2", Some(&big))]),
|
||||
("turn3", vec![("s3", Some("keep me"))]),
|
||||
("turn4", vec![("s4", Some("keep me too"))]),
|
||||
]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let candidates = prunable_indices(&items, 80, &estimates);
|
||||
assert_eq!(candidates.len(), 2);
|
||||
// suffix budget 80 tokens protects turn3+turn4 (8 items), so only s1/s2 are candidates.
|
||||
for &i in &candidates {
|
||||
if let Item::ToolResult { summary, .. } = &items[i] {
|
||||
assert!(summary == "s1" || summary == "s2");
|
||||
} else {
|
||||
panic!("non tool-result selected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_long_task_gets_candidates_without_multiple_user_turns() {
|
||||
let big = "x".repeat(4096 * 8);
|
||||
let items = make_history(&[(
|
||||
"one long task",
|
||||
vec![
|
||||
("s1", Some(&big)),
|
||||
("s2", Some(&big)),
|
||||
("s3", Some(&big)),
|
||||
("s4", Some(&big)),
|
||||
],
|
||||
)]);
|
||||
// user + assistant are cheap; every ToolCall is cheap; every ToolResult is heavy.
|
||||
let item_tokens = vec![1, 1, 1, 5_000, 1, 5_000, 1, 5_000, 1, 5_000];
|
||||
let estimates = estimates_from_item_tokens(&item_tokens);
|
||||
|
||||
let (candidates, protected_start) = evaluate_candidates(&items, 8_000, &estimates);
|
||||
|
||||
assert_eq!(protected_start, Some(7));
|
||||
assert_eq!(candidates.len(), 2);
|
||||
for &i in &candidates {
|
||||
if let Item::ToolResult { summary, .. } = &items[i] {
|
||||
assert!(summary == "s1" || summary == "s2");
|
||||
} else {
|
||||
panic!("non tool-result selected");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_pruned_items_excluded_from_candidates() {
|
||||
let items = make_history(&[
|
||||
("turn1", vec![("s1", None)]), // already pruned (content=None)
|
||||
("turn2", vec![]),
|
||||
("turn3", vec![]),
|
||||
("turn4", vec![]),
|
||||
]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
assert!(prunable_indices(&items, 20, &estimates).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_drops_content_and_counts_modifications() {
|
||||
let big = "x".repeat(64);
|
||||
let mut items = make_history(&[
|
||||
("turn1", vec![("s1", Some(&big))]),
|
||||
("turn2", vec![("s2", Some(&big))]),
|
||||
("turn3", vec![("s3", Some("keep me"))]),
|
||||
("turn4", vec![("s4", Some("keep me too"))]),
|
||||
]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let candidates = prunable_indices(&items, 80, &estimates);
|
||||
let count = project(&mut items, &candidates);
|
||||
assert_eq!(count, 2);
|
||||
|
||||
for item in &items {
|
||||
if let Item::ToolResult {
|
||||
summary, content, ..
|
||||
} = item
|
||||
{
|
||||
if summary == "s1" || summary == "s2" {
|
||||
assert!(content.is_none(), "old content should be projected out");
|
||||
} else {
|
||||
assert!(content.is_some(), "protected content should remain");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_skips_already_pruned_items() {
|
||||
// indices points at an item whose content is already None.
|
||||
// project() should count it as 0 modifications.
|
||||
let mut items = make_history(&[
|
||||
("turn1", vec![("s1", None)]),
|
||||
("turn2", vec![("s2", Some("hello"))]),
|
||||
]);
|
||||
// Manually target s1 even though it's already None.
|
||||
let target = items
|
||||
.iter()
|
||||
.position(|it| matches!(it, Item::ToolResult { summary, .. } if summary == "s1"))
|
||||
.unwrap();
|
||||
let count = project(&mut items, &[target]);
|
||||
assert_eq!(count, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_is_idempotent() {
|
||||
let big = "x".repeat(64);
|
||||
let mut items = make_history(&[
|
||||
("turn1", vec![("s1", Some(&big))]),
|
||||
("turn2", vec![]),
|
||||
("turn3", vec![]),
|
||||
("turn4", vec![]),
|
||||
]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let candidates = prunable_indices(&items, 20, &estimates);
|
||||
assert_eq!(project(&mut items, &candidates), 1);
|
||||
// 2 周目: 候補は一度の prunable_indices 結果を使い回しても 0 件。
|
||||
assert_eq!(project(&mut items, &candidates), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_candidates_returns_protected_start_index() {
|
||||
let big = "x".repeat(64);
|
||||
let items = make_history(&[
|
||||
("turn1", vec![("s1", Some(&big))]),
|
||||
("turn2", vec![("s2", Some(&big))]),
|
||||
("turn3", vec![("s3", Some("keep"))]),
|
||||
("turn4", vec![("s4", Some("keep too"))]),
|
||||
]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let (candidates, protected_start) = evaluate_candidates(&items, 80, &estimates);
|
||||
assert_eq!(candidates.len(), 2);
|
||||
// protected_tokens=80 → protected suffix is turn3+turn4, starting at index 8.
|
||||
assert_eq!(protected_start, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_candidates_reports_zero_start_when_everything_is_protected() {
|
||||
let items = make_history(&[("only", vec![("s", Some("x"))])]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let (candidates, protected_start) = evaluate_candidates(&items, 10_000, &estimates);
|
||||
assert!(candidates.is_empty());
|
||||
assert_eq!(protected_start, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_protected_tokens_allows_all_tool_results_as_candidates() {
|
||||
let big = "x".repeat(64);
|
||||
let items = make_history(&[("turn1", vec![("s1", Some(&big)), ("s2", Some(&big))])]);
|
||||
let estimates = uniform_estimates(&items, 10);
|
||||
let (candidates, protected_start) = evaluate_candidates(&items, 0, &estimates);
|
||||
assert_eq!(protected_start, Some(items.len()));
|
||||
assert_eq!(candidates.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_estimate_vector_is_treated_as_no_boundary() {
|
||||
let items = make_history(&[("turn1", vec![("s1", Some("x"))])]);
|
||||
let (candidates, protected_start) = evaluate_candidates(&items, 10, &[]);
|
||||
assert!(candidates.is_empty());
|
||||
assert_eq!(protected_start, None);
|
||||
}
|
||||
}
|
||||
60
crates/llm-worker/src/state.rs
Normal file
60
crates/llm-worker/src/state.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
//! Worker State
|
||||
//!
|
||||
//! State marker types for cache protection using the Type-state pattern.
|
||||
//! Worker has state transitions from `Mutable` → `Locked`.
|
||||
|
||||
/// Marker trait representing Worker state
|
||||
///
|
||||
/// This trait is sealed and cannot be implemented externally.
|
||||
pub trait WorkerState: private::Sealed + Send + Sync + 'static {}
|
||||
|
||||
mod private {
|
||||
pub trait Sealed {}
|
||||
}
|
||||
|
||||
/// Mutable state (editable)
|
||||
///
|
||||
/// In this state, the following operations are available:
|
||||
/// - Setting/changing system prompt
|
||||
/// - Editing message history (add, delete, clear)
|
||||
/// - Registering tools and hooks
|
||||
///
|
||||
/// Can transition to [`Locked`] state via `Worker::lock()`.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use llm_worker::Worker;
|
||||
///
|
||||
/// let mut worker = Worker::new(client)
|
||||
/// .system_prompt("You are helpful.");
|
||||
///
|
||||
/// // History can be edited
|
||||
/// worker.push_message(Message::user("Hello"));
|
||||
/// worker.clear_history();
|
||||
///
|
||||
/// // Lock to protected state
|
||||
/// let locked = worker.lock();
|
||||
/// ```
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Mutable;
|
||||
|
||||
impl private::Sealed for Mutable {}
|
||||
impl WorkerState for Mutable {}
|
||||
|
||||
/// Cache locked state (cache protected)
|
||||
///
|
||||
/// In this state, the following restrictions apply:
|
||||
/// - System prompt cannot be changed
|
||||
/// - Existing message history cannot be modified (only appending to the end)
|
||||
///
|
||||
/// To ensure LLM API KV cache hits,
|
||||
/// using this state during execution is recommended.
|
||||
///
|
||||
/// Can return to [`Mutable`] state via `Worker::unlock()`,
|
||||
/// but note that cache protection will be released.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Locked;
|
||||
|
||||
impl private::Sealed for Locked {}
|
||||
impl WorkerState for Locked {}
|
||||
5
crates/llm-worker/src/timeline/event.rs
Normal file
5
crates/llm-worker/src/timeline/event.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
//! Timeline層のイベント型
|
||||
//!
|
||||
//! llm_client層のイベント型をそのまま使用する。
|
||||
|
||||
pub use crate::llm_client::event::*;
|
||||
50
crates/llm-worker/src/timeline/mod.rs
Normal file
50
crates/llm-worker/src/timeline/mod.rs
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
//! Timeline層
|
||||
//!
|
||||
//! LLMからのイベントストリームを受信し、登録されたHandlerにディスパッチします。
|
||||
//!
|
||||
//! # 主要コンポーネント
|
||||
//!
|
||||
//! - [`Timeline`] - イベントストリームの管理とディスパッチ
|
||||
//! - [`Handler`] - イベントを処理するトレイト
|
||||
//! - [`TextBlockCollector`] - テキストブロックを収集するHandler
|
||||
//! - [`ToolCallCollector`] - ツール呼び出しを収集するHandler
|
||||
|
||||
pub mod event;
|
||||
mod reasoning_item_collector;
|
||||
mod text_block_collector;
|
||||
mod timeline;
|
||||
mod tool_call_collector;
|
||||
|
||||
// 公開API
|
||||
pub use event::*;
|
||||
pub use reasoning_item_collector::ReasoningItemCollector;
|
||||
pub use text_block_collector::TextBlockCollector;
|
||||
pub use timeline::Timeline;
|
||||
pub use tool_call_collector::ToolCallCollector;
|
||||
|
||||
// 型定義からのre-export
|
||||
pub use crate::handler::{
|
||||
// Meta Kinds
|
||||
ErrorKind,
|
||||
// Core traits
|
||||
Handler,
|
||||
Kind,
|
||||
PingKind,
|
||||
ReasoningItemKind,
|
||||
StatusKind,
|
||||
// Block Events
|
||||
TextBlockEvent,
|
||||
// Block Kinds
|
||||
TextBlockKind,
|
||||
TextBlockStart,
|
||||
TextBlockStop,
|
||||
ThinkingBlockEvent,
|
||||
ThinkingBlockKind,
|
||||
ThinkingBlockStart,
|
||||
ThinkingBlockStop,
|
||||
ToolUseBlockEvent,
|
||||
ToolUseBlockKind,
|
||||
ToolUseBlockStart,
|
||||
ToolUseBlockStop,
|
||||
UsageKind,
|
||||
};
|
||||
77
crates/llm-worker/src/timeline/reasoning_item_collector.rs
Normal file
77
crates/llm-worker/src/timeline/reasoning_item_collector.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
//! `ReasoningItemCollector` - 完成済み reasoning item を収集する Handler
|
||||
//!
|
||||
//! Timeline の `ReasoningItemKind` Handler として登録し、scheme 側が
|
||||
//! `Event::ReasoningItem` を発火するたびに 1 件ずつバッファに溜める。
|
||||
//! Worker はターン終了時に `take_collected()` でドレインして
|
||||
//! `Item::Reasoning` として `worker.history` に append する。
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::handler::{Handler, ReasoningItemKind};
|
||||
use crate::llm_client::event::ReasoningItemEvent;
|
||||
|
||||
/// 収集された reasoning item の連列。
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ReasoningItemCollector {
|
||||
collected: Arc<Mutex<Vec<ReasoningItemEvent>>>,
|
||||
}
|
||||
|
||||
impl ReasoningItemCollector {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// 収集済み item を取り出してクリア
|
||||
pub fn take_collected(&self) -> Vec<ReasoningItemEvent> {
|
||||
let mut guard = self.collected.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
|
||||
/// 収集をクリア
|
||||
pub fn clear(&self) {
|
||||
self.collected.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<ReasoningItemKind> for ReasoningItemCollector {
|
||||
type Scope = ();
|
||||
|
||||
fn on_event(&mut self, _scope: &mut Self::Scope, event: &ReasoningItemEvent) {
|
||||
self.collected.lock().unwrap().push(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::llm_client::event::Event;
|
||||
use crate::timeline::Timeline;
|
||||
|
||||
#[test]
|
||||
fn collects_in_order() {
|
||||
let collector = ReasoningItemCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_reasoning_item(collector.clone());
|
||||
|
||||
timeline.dispatch(&Event::ReasoningItem(ReasoningItemEvent {
|
||||
id: Some("r1".into()),
|
||||
text: "first".into(),
|
||||
signature: Some("sig1".into()),
|
||||
..Default::default()
|
||||
}));
|
||||
timeline.dispatch(&Event::ReasoningItem(ReasoningItemEvent {
|
||||
id: Some("r2".into()),
|
||||
text: "second".into(),
|
||||
..Default::default()
|
||||
}));
|
||||
|
||||
let items = collector.take_collected();
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_eq!(items[0].text, "first");
|
||||
assert_eq!(items[0].signature.as_deref(), Some("sig1"));
|
||||
assert_eq!(items[1].text, "second");
|
||||
|
||||
// take は drain なので 2 度目は空
|
||||
assert!(collector.take_collected().is_empty());
|
||||
}
|
||||
}
|
||||
131
crates/llm-worker/src/timeline/text_block_collector.rs
Normal file
131
crates/llm-worker/src/timeline/text_block_collector.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
//! TextBlockCollector - テキストブロック収集用ハンドラ
|
||||
//!
|
||||
//! TimelineのTextBlockHandler として登録され、
|
||||
//! ストリーム中のテキストブロックを収集する。
|
||||
|
||||
use crate::handler::{Handler, TextBlockEvent, TextBlockKind};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// TextBlockから収集したテキスト情報を保持
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TextCollectorState {
|
||||
/// 蓄積中のテキスト
|
||||
buffer: String,
|
||||
}
|
||||
|
||||
/// TextBlockCollector - テキストブロックハンドラ
|
||||
///
|
||||
/// Timelineに登録してTextBlockイベントを受信し、
|
||||
/// 完了したテキストブロックを収集する。
|
||||
#[derive(Clone)]
|
||||
pub struct TextBlockCollector {
|
||||
/// 収集されたテキストブロック
|
||||
collected: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl TextBlockCollector {
|
||||
/// 新しいTextBlockCollectorを作成
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
collected: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 収集されたテキストを取得してクリア
|
||||
pub fn take_collected(&self) -> Vec<String> {
|
||||
let mut guard = self.collected.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
|
||||
/// 収集されたテキストの参照を取得
|
||||
pub fn collected(&self) -> Vec<String> {
|
||||
self.collected.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// 収集されたテキストがあるかどうか
|
||||
pub fn has_content(&self) -> bool {
|
||||
!self.collected.lock().unwrap().is_empty()
|
||||
}
|
||||
|
||||
/// 収集をクリア
|
||||
pub fn clear(&self) {
|
||||
self.collected.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TextBlockCollector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<TextBlockKind> for TextBlockCollector {
|
||||
type Scope = TextCollectorState;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &TextBlockEvent) {
|
||||
match event {
|
||||
TextBlockEvent::Start(_) => {
|
||||
scope.buffer.clear();
|
||||
}
|
||||
TextBlockEvent::Delta(text) => {
|
||||
scope.buffer.push_str(text);
|
||||
}
|
||||
TextBlockEvent::Stop(_) => {
|
||||
// ブロック完了時にテキストを確定
|
||||
if !scope.buffer.is_empty() {
|
||||
let text = std::mem::take(&mut scope.buffer);
|
||||
self.collected.lock().unwrap().push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::timeline::Timeline;
|
||||
use crate::timeline::event::Event;
|
||||
|
||||
/// TextBlockCollectorが単一のテキストブロックを正しく収集することを確認
|
||||
#[test]
|
||||
fn test_collect_single_text_block() {
|
||||
let collector = TextBlockCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_text_block(collector.clone());
|
||||
|
||||
// テキストブロックのイベントシーケンスをディスパッチ
|
||||
timeline.dispatch(&Event::text_block_start(0));
|
||||
timeline.dispatch(&Event::text_delta(0, "Hello, "));
|
||||
timeline.dispatch(&Event::text_delta(0, "World!"));
|
||||
timeline.dispatch(&Event::text_block_stop(0, None));
|
||||
|
||||
// 収集されたテキストを確認
|
||||
let texts = collector.take_collected();
|
||||
assert_eq!(texts.len(), 1);
|
||||
assert_eq!(texts[0], "Hello, World!");
|
||||
}
|
||||
|
||||
/// TextBlockCollectorが複数のテキストブロックを正しく収集することを確認
|
||||
#[test]
|
||||
fn test_collect_multiple_text_blocks() {
|
||||
let collector = TextBlockCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_text_block(collector.clone());
|
||||
|
||||
// 1つ目のテキストブロック
|
||||
timeline.dispatch(&Event::text_block_start(0));
|
||||
timeline.dispatch(&Event::text_delta(0, "First"));
|
||||
timeline.dispatch(&Event::text_block_stop(0, None));
|
||||
|
||||
// 2つ目のテキストブロック
|
||||
timeline.dispatch(&Event::text_block_start(1));
|
||||
timeline.dispatch(&Event::text_delta(1, "Second"));
|
||||
timeline.dispatch(&Event::text_block_stop(1, None));
|
||||
|
||||
let texts = collector.take_collected();
|
||||
assert_eq!(texts.len(), 2);
|
||||
assert_eq!(texts[0], "First");
|
||||
assert_eq!(texts[1], "Second");
|
||||
}
|
||||
}
|
||||
795
crates/llm-worker/src/timeline/timeline.rs
Normal file
795
crates/llm-worker/src/timeline/timeline.rs
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
//! Timeline層
|
||||
//!
|
||||
//! LLMからのイベントストリームを受信し、登録されたHandlerにディスパッチします。
|
||||
//! 通常はWorker経由で使用しますが、直接使用することも可能です。
|
||||
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use super::event::*;
|
||||
use crate::handler::*;
|
||||
|
||||
// =============================================================================
|
||||
// Helpers
|
||||
// =============================================================================
|
||||
|
||||
/// 1リクエスト内で受信した複数 UsageEvent をマージする。
|
||||
/// 各フィールドについて新しい値が `Some` ならそれで上書き。
|
||||
/// プロバイダによっては input/cache 系を最初の event だけに載せ、
|
||||
/// output_tokens を後続 event で更新するため、最後の値だけを取るのではなく
|
||||
/// フィールド単位で latest-non-None を取る。
|
||||
fn merge_usage(acc: &mut UsageEvent, new: &UsageEvent) {
|
||||
if new.input_tokens.is_some() {
|
||||
acc.input_tokens = new.input_tokens;
|
||||
}
|
||||
if new.output_tokens.is_some() {
|
||||
acc.output_tokens = new.output_tokens;
|
||||
}
|
||||
if new.total_tokens.is_some() {
|
||||
acc.total_tokens = new.total_tokens;
|
||||
}
|
||||
if new.cache_read_input_tokens.is_some() {
|
||||
acc.cache_read_input_tokens = new.cache_read_input_tokens;
|
||||
}
|
||||
if new.cache_creation_input_tokens.is_some() {
|
||||
acc.cache_creation_input_tokens = new.cache_creation_input_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Type-erased Handler
|
||||
// =============================================================================
|
||||
|
||||
/// 型消去された`Handler` trait
|
||||
///
|
||||
/// 各Handlerは独自のScope型を持つため、Timelineで保持するには型消去が必要です。
|
||||
/// 通常は直接使用せず、`Timeline::on_text_block()`などのメソッド経由で
|
||||
/// 自動的にラップされます。
|
||||
pub trait ErasedHandler<K: Kind>: Send + Sync {
|
||||
/// イベントをディスパッチ
|
||||
fn dispatch(&mut self, event: &K::Event);
|
||||
/// スコープを開始(Block開始時)
|
||||
fn start_scope(&mut self);
|
||||
/// スコープを終了(Block終了時)
|
||||
fn end_scope(&mut self);
|
||||
}
|
||||
|
||||
/// `Handler<K>`を`ErasedHandler<K>`として扱うためのラッパー
|
||||
pub struct HandlerWrapper<H, K>
|
||||
where
|
||||
H: Handler<K>,
|
||||
K: Kind,
|
||||
{
|
||||
handler: H,
|
||||
scope: Option<H::Scope>,
|
||||
// fn() -> K は常にSend+Syncなので、Kの制約に関係なくSendを満たせる
|
||||
_kind: PhantomData<fn() -> K>,
|
||||
}
|
||||
|
||||
impl<H, K> HandlerWrapper<H, K>
|
||||
where
|
||||
H: Handler<K>,
|
||||
K: Kind,
|
||||
{
|
||||
pub fn new(handler: H) -> Self {
|
||||
Self {
|
||||
handler,
|
||||
scope: None,
|
||||
_kind: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<H, K> ErasedHandler<K> for HandlerWrapper<H, K>
|
||||
where
|
||||
H: Handler<K> + Send + Sync,
|
||||
K: Kind,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
fn dispatch(&mut self, event: &K::Event) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
self.handler.on_event(scope, event);
|
||||
}
|
||||
}
|
||||
|
||||
fn start_scope(&mut self) {
|
||||
self.scope = Some(H::Scope::default());
|
||||
}
|
||||
|
||||
fn end_scope(&mut self) {
|
||||
self.scope = None;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Block Handler Registry
|
||||
// =============================================================================
|
||||
|
||||
/// ブロックハンドラーの型消去trait
|
||||
trait ErasedBlockHandler: Send + Sync {
|
||||
fn dispatch_start(&mut self, start: &BlockStart);
|
||||
fn dispatch_delta(&mut self, delta: &BlockDelta);
|
||||
fn dispatch_stop(&mut self, stop: &BlockStop);
|
||||
fn dispatch_abort(&mut self, abort: &BlockAbort);
|
||||
fn start_scope(&mut self);
|
||||
fn end_scope(&mut self);
|
||||
/// スコープがアクティブかどうか
|
||||
fn has_scope(&self) -> bool;
|
||||
}
|
||||
|
||||
/// TextBlockKind用のラッパー
|
||||
struct TextBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<TextBlockKind>,
|
||||
{
|
||||
handler: H,
|
||||
scope: Option<H::Scope>,
|
||||
}
|
||||
|
||||
impl<H> TextBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<TextBlockKind>,
|
||||
{
|
||||
fn new(handler: H) -> Self {
|
||||
Self {
|
||||
handler,
|
||||
scope: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<H> ErasedBlockHandler for TextBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<TextBlockKind> + Send + Sync,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
fn dispatch_start(&mut self, start: &BlockStart) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&TextBlockEvent::Start(TextBlockStart { index: start.index }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_delta(&mut self, delta: &BlockDelta) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let DeltaContent::Text(text) = &delta.delta {
|
||||
self.handler
|
||||
.on_event(scope, &TextBlockEvent::Delta(text.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stop(&mut self, stop: &BlockStop) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&TextBlockEvent::Stop(TextBlockStop {
|
||||
index: stop.index,
|
||||
stop_reason: stop.stop_reason.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
|
||||
// TextBlockはabortを特別扱いしない(スコープ終了のみ)
|
||||
}
|
||||
|
||||
fn start_scope(&mut self) {
|
||||
self.scope = Some(H::Scope::default());
|
||||
}
|
||||
|
||||
fn end_scope(&mut self) {
|
||||
self.scope = None;
|
||||
}
|
||||
|
||||
fn has_scope(&self) -> bool {
|
||||
self.scope.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// ThinkingBlockKind用のラッパー
|
||||
struct ThinkingBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ThinkingBlockKind>,
|
||||
{
|
||||
handler: H,
|
||||
scope: Option<H::Scope>,
|
||||
}
|
||||
|
||||
impl<H> ThinkingBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ThinkingBlockKind>,
|
||||
{
|
||||
fn new(handler: H) -> Self {
|
||||
Self {
|
||||
handler,
|
||||
scope: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<H> ErasedBlockHandler for ThinkingBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ThinkingBlockKind> + Send + Sync,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
fn dispatch_start(&mut self, start: &BlockStart) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&ThinkingBlockEvent::Start(ThinkingBlockStart { index: start.index }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_delta(&mut self, delta: &BlockDelta) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let DeltaContent::Thinking(text) = &delta.delta {
|
||||
self.handler
|
||||
.on_event(scope, &ThinkingBlockEvent::Delta(text.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stop(&mut self, stop: &BlockStop) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&ThinkingBlockEvent::Stop(ThinkingBlockStop { index: stop.index }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_abort(&mut self, _abort: &BlockAbort) {}
|
||||
|
||||
fn start_scope(&mut self) {
|
||||
self.scope = Some(H::Scope::default());
|
||||
}
|
||||
|
||||
fn end_scope(&mut self) {
|
||||
self.scope = None;
|
||||
}
|
||||
|
||||
fn has_scope(&self) -> bool {
|
||||
self.scope.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// ToolUseBlockKind用のラッパー
|
||||
struct ToolUseBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ToolUseBlockKind>,
|
||||
{
|
||||
handler: H,
|
||||
scope: Option<H::Scope>,
|
||||
current_tool: Option<(String, String)>, // (id, name)
|
||||
}
|
||||
|
||||
impl<H> ToolUseBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ToolUseBlockKind>,
|
||||
{
|
||||
fn new(handler: H) -> Self {
|
||||
Self {
|
||||
handler,
|
||||
scope: None,
|
||||
current_tool: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<H> ErasedBlockHandler for ToolUseBlockHandlerWrapper<H>
|
||||
where
|
||||
H: Handler<ToolUseBlockKind> + Send + Sync,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
fn dispatch_start(&mut self, start: &BlockStart) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let BlockMetadata::ToolUse { id, name } = &start.metadata {
|
||||
self.current_tool = Some((id.clone(), name.clone()));
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&ToolUseBlockEvent::Start(ToolUseBlockStart {
|
||||
index: start.index,
|
||||
id: id.clone(),
|
||||
name: name.clone(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_delta(&mut self, delta: &BlockDelta) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let DeltaContent::InputJson(json) = &delta.delta {
|
||||
self.handler
|
||||
.on_event(scope, &ToolUseBlockEvent::InputJsonDelta(json.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stop(&mut self, stop: &BlockStop) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let Some((id, name)) = self.current_tool.take() {
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&ToolUseBlockEvent::Stop(ToolUseBlockStop {
|
||||
index: stop.index,
|
||||
id,
|
||||
name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
|
||||
self.current_tool = None;
|
||||
}
|
||||
|
||||
fn start_scope(&mut self) {
|
||||
self.scope = Some(H::Scope::default());
|
||||
}
|
||||
|
||||
fn end_scope(&mut self) {
|
||||
self.scope = None;
|
||||
self.current_tool = None;
|
||||
}
|
||||
|
||||
fn has_scope(&self) -> bool {
|
||||
self.scope.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Timeline
|
||||
// =============================================================================
|
||||
|
||||
/// イベントストリームの管理とハンドラへのディスパッチ
|
||||
///
|
||||
/// LLMからのイベントを受信し、登録されたハンドラに振り分けます。
|
||||
/// ブロック系イベントはスコープ管理付きで処理されます。
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use llm_worker::{Timeline, Handler, TextBlockKind, TextBlockEvent};
|
||||
///
|
||||
/// struct MyHandler;
|
||||
/// impl Handler<TextBlockKind> for MyHandler {
|
||||
/// type Scope = String;
|
||||
/// fn on_event(&mut self, buffer: &mut String, event: &TextBlockEvent) {
|
||||
/// if let TextBlockEvent::Delta(text) = event {
|
||||
/// buffer.push_str(text);
|
||||
/// }
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// let mut timeline = Timeline::new();
|
||||
/// timeline.on_text_block(MyHandler);
|
||||
/// ```
|
||||
///
|
||||
/// # サポートするイベント種別
|
||||
///
|
||||
/// - **メタ系**: Usage, Ping, Status, Error
|
||||
/// - **ブロック系**: TextBlock, ThinkingBlock, ToolUseBlock
|
||||
pub struct Timeline {
|
||||
// Meta系ハンドラー
|
||||
usage_handlers: Vec<Box<dyn ErasedHandler<UsageKind>>>,
|
||||
ping_handlers: Vec<Box<dyn ErasedHandler<PingKind>>>,
|
||||
status_handlers: Vec<Box<dyn ErasedHandler<StatusKind>>>,
|
||||
error_handlers: Vec<Box<dyn ErasedHandler<ErrorKind>>>,
|
||||
reasoning_item_handlers: Vec<Box<dyn ErasedHandler<ReasoningItemKind>>>,
|
||||
|
||||
// Block系ハンドラー(BlockTypeごとにグループ化)
|
||||
text_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
|
||||
thinking_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
|
||||
tool_use_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
|
||||
|
||||
// 現在アクティブなブロック
|
||||
current_block: Option<BlockType>,
|
||||
|
||||
// 1リクエスト内で受信した Usage event の集約バッファ。
|
||||
// Anthropic は message_start と message_delta、Gemini は各チャンクと、
|
||||
// 多くのプロバイダが複数 Usage を発行するため、リクエスト境界で
|
||||
// 1度だけ発火するためにここでマージする。flush_usage() で発火する。
|
||||
pending_usage: Option<UsageEvent>,
|
||||
}
|
||||
|
||||
impl Default for Timeline {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
usage_handlers: Vec::new(),
|
||||
ping_handlers: Vec::new(),
|
||||
status_handlers: Vec::new(),
|
||||
error_handlers: Vec::new(),
|
||||
reasoning_item_handlers: Vec::new(),
|
||||
text_block_handlers: Vec::new(),
|
||||
thinking_block_handlers: Vec::new(),
|
||||
tool_use_block_handlers: Vec::new(),
|
||||
current_block: None,
|
||||
pending_usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Handler Registration
|
||||
// =========================================================================
|
||||
|
||||
/// UsageKind用のHandlerを登録
|
||||
pub fn on_usage<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<UsageKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
// Meta系はデフォルトでスコープを開始しておく
|
||||
let mut wrapper = HandlerWrapper::new(handler);
|
||||
wrapper.start_scope();
|
||||
self.usage_handlers.push(Box::new(wrapper));
|
||||
self
|
||||
}
|
||||
|
||||
/// PingKind用のHandlerを登録
|
||||
pub fn on_ping<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<PingKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
let mut wrapper = HandlerWrapper::new(handler);
|
||||
wrapper.start_scope();
|
||||
self.ping_handlers.push(Box::new(wrapper));
|
||||
self
|
||||
}
|
||||
|
||||
/// StatusKind用のHandlerを登録
|
||||
pub fn on_status<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<StatusKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
let mut wrapper = HandlerWrapper::new(handler);
|
||||
wrapper.start_scope();
|
||||
self.status_handlers.push(Box::new(wrapper));
|
||||
self
|
||||
}
|
||||
|
||||
/// ErrorKind用のHandlerを登録
|
||||
pub fn on_error<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<ErrorKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
let mut wrapper = HandlerWrapper::new(handler);
|
||||
wrapper.start_scope();
|
||||
self.error_handlers.push(Box::new(wrapper));
|
||||
self
|
||||
}
|
||||
|
||||
/// `ReasoningItemKind` 用 Handler を登録
|
||||
pub fn on_reasoning_item<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<ReasoningItemKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
let mut wrapper = HandlerWrapper::new(handler);
|
||||
wrapper.start_scope();
|
||||
self.reasoning_item_handlers.push(Box::new(wrapper));
|
||||
self
|
||||
}
|
||||
|
||||
/// TextBlockKind用のHandlerを登録
|
||||
pub fn on_text_block<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<TextBlockKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
self.text_block_handlers
|
||||
.push(Box::new(TextBlockHandlerWrapper::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
/// ThinkingBlockKind用のHandlerを登録
|
||||
pub fn on_thinking_block<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<ThinkingBlockKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
self.thinking_block_handlers
|
||||
.push(Box::new(ThinkingBlockHandlerWrapper::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
/// ToolUseBlockKind用のHandlerを登録
|
||||
pub fn on_tool_use_block<H>(&mut self, handler: H) -> &mut Self
|
||||
where
|
||||
H: Handler<ToolUseBlockKind> + Send + Sync + 'static,
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
self.tool_use_block_handlers
|
||||
.push(Box::new(ToolUseBlockHandlerWrapper::new(handler)));
|
||||
self
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Event Dispatch
|
||||
// =========================================================================
|
||||
|
||||
/// メインのディスパッチエントリポイント
|
||||
pub fn dispatch(&mut self, event: &Event) {
|
||||
match event {
|
||||
// Meta系: 即時ディスパッチ(登録順)
|
||||
Event::Usage(u) => self.dispatch_usage(u),
|
||||
Event::Ping(p) => self.dispatch_ping(p),
|
||||
Event::Status(s) => self.dispatch_status(s),
|
||||
Event::Error(e) => self.dispatch_error(e),
|
||||
// Observability-only event: stream trace records it before timeline dispatch.
|
||||
Event::UnhandledSse(_) => {}
|
||||
|
||||
// Block系: スコープ管理しながらディスパッチ
|
||||
Event::BlockStart(s) => self.handle_block_start(s),
|
||||
Event::BlockDelta(d) => self.handle_block_delta(d),
|
||||
Event::BlockStop(s) => self.handle_block_stop(s),
|
||||
Event::BlockAbort(a) => self.handle_block_abort(a),
|
||||
|
||||
// 完成済み reasoning item: 即時ディスパッチ
|
||||
Event::ReasoningItem(r) => self.dispatch_reasoning_item(r),
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage event を即時には dispatch せず、pending_usage にマージする。
|
||||
/// 1リクエスト内で複数の Usage event が来ても、ハンドラには 1 度だけ
|
||||
/// 最終値を渡したいため。flush_usage() で発火する。
|
||||
fn dispatch_usage(&mut self, event: &UsageEvent) {
|
||||
match &mut self.pending_usage {
|
||||
Some(acc) => merge_usage(acc, event),
|
||||
None => self.pending_usage = Some(event.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
/// pending_usage を usage_handlers に発火し、バッファをクリアする。
|
||||
/// 1リクエスト分のストリーム終了時に1回だけ呼ぶ想定。
|
||||
/// pending_usage が空ならば何もしない。
|
||||
pub fn flush_usage(&mut self) {
|
||||
if let Some(event) = self.pending_usage.take() {
|
||||
for handler in &mut self.usage_handlers {
|
||||
handler.dispatch(&event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_ping(&mut self, event: &PingEvent) {
|
||||
for handler in &mut self.ping_handlers {
|
||||
handler.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_status(&mut self, event: &StatusEvent) {
|
||||
for handler in &mut self.status_handlers {
|
||||
handler.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_error(&mut self, event: &ErrorEvent) {
|
||||
for handler in &mut self.error_handlers {
|
||||
handler.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_reasoning_item(&mut self, event: &ReasoningItemEvent) {
|
||||
for handler in &mut self.reasoning_item_handlers {
|
||||
handler.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_block_start(&mut self, start: &BlockStart) {
|
||||
self.current_block = Some(start.block_type);
|
||||
|
||||
let handlers = self.get_block_handlers_mut(start.block_type);
|
||||
for handler in handlers {
|
||||
handler.start_scope();
|
||||
handler.dispatch_start(start);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_block_delta(&mut self, delta: &BlockDelta) {
|
||||
let block_type = delta.delta.block_type();
|
||||
|
||||
// OpenAIなどのプロバイダはBlockStartを送らない場合があるため、
|
||||
// Deltaが来たときにスコープがなければ暗黙的に開始する
|
||||
if self.current_block.is_none() {
|
||||
self.current_block = Some(block_type);
|
||||
}
|
||||
|
||||
let handlers = self.get_block_handlers_mut(block_type);
|
||||
for handler in handlers {
|
||||
// スコープがなければ暗黙的に開始
|
||||
if !handler.has_scope() {
|
||||
handler.start_scope();
|
||||
}
|
||||
handler.dispatch_delta(delta);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_block_stop(&mut self, stop: &BlockStop) {
|
||||
let handlers = self.get_block_handlers_mut(stop.block_type);
|
||||
for handler in handlers {
|
||||
handler.dispatch_stop(stop);
|
||||
handler.end_scope();
|
||||
}
|
||||
self.current_block = None;
|
||||
}
|
||||
|
||||
fn handle_block_abort(&mut self, abort: &BlockAbort) {
|
||||
let handlers = self.get_block_handlers_mut(abort.block_type);
|
||||
for handler in handlers {
|
||||
handler.dispatch_abort(abort);
|
||||
handler.end_scope();
|
||||
}
|
||||
self.current_block = None;
|
||||
}
|
||||
|
||||
fn get_block_handlers_mut(
|
||||
&mut self,
|
||||
block_type: BlockType,
|
||||
) -> &mut Vec<Box<dyn ErasedBlockHandler>> {
|
||||
match block_type {
|
||||
BlockType::Text => &mut self.text_block_handlers,
|
||||
BlockType::Thinking => &mut self.thinking_block_handlers,
|
||||
BlockType::ToolUse => &mut self.tool_use_block_handlers,
|
||||
BlockType::ToolResult => &mut self.text_block_handlers, // ToolResultはTextとして扱う
|
||||
}
|
||||
}
|
||||
|
||||
/// 現在アクティブなブロックタイプを取得
|
||||
pub fn current_block(&self) -> Option<BlockType> {
|
||||
self.current_block
|
||||
}
|
||||
|
||||
/// 現在アクティブなブロックを中断する
|
||||
///
|
||||
/// キャンセルやエラー時に呼び出し、進行中のブロックに対して
|
||||
/// BlockAbortイベントを発火してスコープをクリーンアップする。
|
||||
pub fn abort_current_block(&mut self) {
|
||||
if let Some(block_type) = self.current_block {
|
||||
let abort = crate::timeline::event::BlockAbort {
|
||||
index: 0, // インデックスは不明なので0
|
||||
block_type,
|
||||
reason: "Cancelled".to_string(),
|
||||
};
|
||||
self.handle_block_abort(&abort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[test]
|
||||
fn test_timeline_creation() {
|
||||
let timeline = Timeline::new();
|
||||
assert!(timeline.current_block().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhandled_sse_is_ignored_by_timeline_handlers() {
|
||||
struct TestTextHandler {
|
||||
calls: Arc<Mutex<Vec<TextBlockEvent>>>,
|
||||
}
|
||||
|
||||
impl Handler<TextBlockKind> for TestTextHandler {
|
||||
type Scope = ();
|
||||
fn on_event(&mut self, _scope: &mut (), event: &TextBlockEvent) {
|
||||
self.calls.lock().unwrap().push(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_text_block(TestTextHandler {
|
||||
calls: calls.clone(),
|
||||
});
|
||||
|
||||
timeline.dispatch(&Event::UnhandledSse(UnhandledSseEvent {
|
||||
provider: "openai_responses".to_string(),
|
||||
event_type: "response.mystery".to_string(),
|
||||
data_preview: "{}".to_string(),
|
||||
data_len: 2,
|
||||
}));
|
||||
|
||||
assert!(timeline.current_block().is_none());
|
||||
assert!(calls.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meta_event_dispatch() {
|
||||
// シンプルなテスト用構造体
|
||||
struct TestUsageHandler {
|
||||
calls: Arc<Mutex<Vec<UsageEvent>>>,
|
||||
}
|
||||
|
||||
impl Handler<UsageKind> for TestUsageHandler {
|
||||
type Scope = ();
|
||||
fn on_event(&mut self, _scope: &mut (), event: &UsageEvent) {
|
||||
self.calls.lock().unwrap().push(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let handler = TestUsageHandler {
|
||||
calls: calls.clone(),
|
||||
};
|
||||
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_usage(handler);
|
||||
|
||||
timeline.dispatch(&Event::usage(100, 50));
|
||||
// pending_usage に積まれているだけなのでまだ未発火
|
||||
assert_eq!(calls.lock().unwrap().len(), 0);
|
||||
|
||||
// flush で 1 度だけ発火
|
||||
timeline.flush_usage();
|
||||
let recorded = calls.lock().unwrap();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
assert_eq!(recorded[0].input_tokens, Some(100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_usage_aggregation_and_flush() {
|
||||
struct TestUsageHandler {
|
||||
calls: Arc<Mutex<Vec<UsageEvent>>>,
|
||||
}
|
||||
impl Handler<UsageKind> for TestUsageHandler {
|
||||
type Scope = ();
|
||||
fn on_event(&mut self, _scope: &mut (), event: &UsageEvent) {
|
||||
self.calls.lock().unwrap().push(event.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_usage(TestUsageHandler {
|
||||
calls: calls.clone(),
|
||||
});
|
||||
|
||||
// Anthropic 風: message_start で input + 暫定 output
|
||||
timeline.dispatch(&Event::Usage(UsageEvent {
|
||||
input_tokens: Some(409),
|
||||
output_tokens: Some(1),
|
||||
total_tokens: Some(410),
|
||||
cache_read_input_tokens: Some(0),
|
||||
cache_creation_input_tokens: Some(0),
|
||||
}));
|
||||
// message_delta で最終 output
|
||||
timeline.dispatch(&Event::Usage(UsageEvent {
|
||||
input_tokens: Some(409),
|
||||
output_tokens: Some(71),
|
||||
total_tokens: Some(480),
|
||||
cache_read_input_tokens: Some(0),
|
||||
cache_creation_input_tokens: Some(0),
|
||||
}));
|
||||
|
||||
// 未 flush の段階では発火しない
|
||||
assert_eq!(calls.lock().unwrap().len(), 0);
|
||||
|
||||
timeline.flush_usage();
|
||||
let recorded = calls.lock().unwrap();
|
||||
assert_eq!(recorded.len(), 1);
|
||||
assert_eq!(recorded[0].input_tokens, Some(409));
|
||||
assert_eq!(recorded[0].output_tokens, Some(71));
|
||||
|
||||
// flush 後にもう一度 flush しても何も起きない
|
||||
drop(recorded);
|
||||
timeline.flush_usage();
|
||||
assert_eq!(calls.lock().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
168
crates/llm-worker/src/timeline/tool_call_collector.rs
Normal file
168
crates/llm-worker/src/timeline/tool_call_collector.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
//! ToolCallCollector - ツール呼び出し収集用ハンドラ
|
||||
//!
|
||||
//! TimelineのToolUseBlockHandler として登録され、
|
||||
//! ストリーム中のToolUseブロックを収集する。
|
||||
|
||||
use crate::{
|
||||
handler::{Handler, ToolUseBlockEvent, ToolUseBlockKind},
|
||||
llm_client::types::parse_tool_arguments,
|
||||
tool::ToolCall,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// ToolUseブロックから収集したツール呼び出し情報を保持
|
||||
///
|
||||
/// ToolCallCollectorのHandler実装で使用するスコープ型
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CollectorState {
|
||||
/// 現在のツール呼び出し情報 (ブロック進行中)
|
||||
current_id: Option<String>,
|
||||
current_name: Option<String>,
|
||||
/// 蓄積中のJSON入力
|
||||
input_json_buffer: String,
|
||||
}
|
||||
|
||||
/// ToolCallCollector - ToolUseブロックハンドラ
|
||||
///
|
||||
/// Timelineに登録してToolUseブロックイベントを受信し、
|
||||
/// 完了したToolCallを収集する。
|
||||
#[derive(Clone)]
|
||||
pub struct ToolCallCollector {
|
||||
/// 収集されたToolCall
|
||||
collected: Arc<Mutex<Vec<ToolCall>>>,
|
||||
}
|
||||
|
||||
impl ToolCallCollector {
|
||||
/// 新しいToolCallCollectorを作成
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
collected: Arc::new(Mutex::new(Vec::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 収集されたToolCallを取得してクリア
|
||||
pub fn take_collected(&self) -> Vec<ToolCall> {
|
||||
let mut guard = self.collected.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
|
||||
/// 収集されたToolCallの参照を取得
|
||||
pub fn collected(&self) -> Vec<ToolCall> {
|
||||
self.collected.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// 収集されたToolCallがあるかどうか
|
||||
pub fn has_pending_calls(&self) -> bool {
|
||||
!self.collected.lock().unwrap().is_empty()
|
||||
}
|
||||
|
||||
/// 収集をクリア
|
||||
pub fn clear(&self) {
|
||||
self.collected.lock().unwrap().clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ToolCallCollector {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler<ToolUseBlockKind> for ToolCallCollector {
|
||||
type Scope = CollectorState;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Self::Scope, event: &ToolUseBlockEvent) {
|
||||
match event {
|
||||
ToolUseBlockEvent::Start(start) => {
|
||||
scope.current_id = Some(start.id.clone());
|
||||
scope.current_name = Some(start.name.clone());
|
||||
scope.input_json_buffer.clear();
|
||||
}
|
||||
ToolUseBlockEvent::InputJsonDelta(delta) => {
|
||||
scope.input_json_buffer.push_str(delta);
|
||||
}
|
||||
ToolUseBlockEvent::Stop(_stop) => {
|
||||
// ブロック完了時にToolCallを確定
|
||||
if let (Some(id), Some(name)) = (scope.current_id.take(), scope.current_name.take())
|
||||
{
|
||||
let input = parse_tool_arguments(&scope.input_json_buffer);
|
||||
|
||||
let tool_call = ToolCall { id, name, input };
|
||||
|
||||
self.collected.lock().unwrap().push(tool_call);
|
||||
}
|
||||
scope.input_json_buffer.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::timeline::Timeline;
|
||||
use crate::timeline::event::Event;
|
||||
|
||||
#[test]
|
||||
fn test_collect_single_tool_call() {
|
||||
let collector = ToolCallCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
|
||||
// ToolUseブロックのイベントシーケンスをディスパッチ
|
||||
timeline.dispatch(&Event::tool_use_start(0, "tool_123", "get_weather"));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#"{"city":"#));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#""Tokyo"}"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
// 収集されたToolCallを確認
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].id, "tool_123");
|
||||
assert_eq!(calls[0].name, "get_weather");
|
||||
assert_eq!(calls[0].input["city"], "Tokyo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_empty_buffer_returns_object() {
|
||||
// 引数なしツール呼び出し: input_json_delta が一度も来ないケース
|
||||
let collector = ToolCallCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
|
||||
timeline.dispatch(&Event::tool_use_start(0, "tool_empty", "ListPods"));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert_eq!(calls[0].id, "tool_empty");
|
||||
assert_eq!(calls[0].name, "ListPods");
|
||||
assert!(calls[0].input.is_object());
|
||||
assert_eq!(
|
||||
calls[0].input,
|
||||
serde_json::Value::Object(serde_json::Map::new())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_multiple_tool_calls() {
|
||||
let collector = ToolCallCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
|
||||
// 1つ目のToolCall
|
||||
timeline.dispatch(&Event::tool_use_start(0, "call_1", "tool_a"));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#"{"a":1}"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
// 2つ目のToolCall
|
||||
timeline.dispatch(&Event::tool_use_start(1, "call_2", "tool_b"));
|
||||
timeline.dispatch(&Event::tool_input_delta(1, r#"{"b":2}"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(1));
|
||||
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 2);
|
||||
assert_eq!(calls[0].name, "tool_a");
|
||||
assert_eq!(calls[1].name, "tool_b");
|
||||
}
|
||||
}
|
||||
222
crates/llm-worker/src/token_counter.rs
Normal file
222
crates/llm-worker/src/token_counter.rs
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
//! Usage 履歴ベースのトークン会計(汎用部分)。
|
||||
//!
|
||||
//! `UsageRecord` の列(プロバイダ実測値)と現在の history から、
|
||||
//! 任意の history index 時点のプロンプト全長トークン数を pure に計算する。
|
||||
//!
|
||||
//! # 方針
|
||||
//!
|
||||
//! - ローカルトークナイザは持たない。実測値があればそれを採用し、
|
||||
//! measurement 間はバイト数で按分、最新 measurement より先は最終 rate で外挿する
|
||||
//! - 推定の出どころは [`EstimateSource`] で呼び出し側に明示する。
|
||||
//! 課金判断には使えないが、compact / prune / memory extract trigger 等の
|
||||
//! 閾値判定には十分な精度
|
||||
//! - `records` は `history_len` 昇順を仮定する(呼び出し側がそのように積む)
|
||||
|
||||
use crate::{Item, UsageRecord};
|
||||
|
||||
/// 推定の出どころ。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EstimateSource {
|
||||
/// measurement の境界にちょうど一致(実測値そのもの)
|
||||
Measured,
|
||||
/// 連続する 2 つの measurement の間をバイト按分で計算
|
||||
Interpolated,
|
||||
/// 最後の measurement より新しい区間を最終 rate で外挿
|
||||
Extrapolated,
|
||||
/// measurement が 1 件も無く、バイト数のみのフォールバック
|
||||
NoData,
|
||||
}
|
||||
|
||||
/// トークン数の推定値。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct TokenEstimate {
|
||||
pub tokens: u64,
|
||||
pub source: EstimateSource,
|
||||
}
|
||||
|
||||
/// `items[..i]` までの累積バイト数(`prefix[i]`)を返す。長さは `items.len()+1`。
|
||||
pub fn prefix_bytes(items: &[Item]) -> Vec<u64> {
|
||||
let mut prefix = Vec::with_capacity(items.len() + 1);
|
||||
let mut acc: u64 = 0;
|
||||
prefix.push(0);
|
||||
for item in items {
|
||||
acc = acc.saturating_add(item_bytes(item));
|
||||
prefix.push(acc);
|
||||
}
|
||||
prefix
|
||||
}
|
||||
|
||||
/// 1 Item の大きさ。JSON シリアライズ長を使う粗い近似。
|
||||
/// トークン数との絶対変換ではなく区間の按分にしか使わないので、
|
||||
/// プロバイダごとの overhead は比率でキャンセルされる。
|
||||
pub fn item_bytes(item: &Item) -> u64 {
|
||||
serde_json::to_string(item)
|
||||
.map(|s| s.len() as u64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// `history[..index]` までのトークン数を推定する。
|
||||
///
|
||||
/// `prefix` は [`prefix_bytes`] で得た `history.len() + 1` 長の累積バイト列。
|
||||
/// 呼び出し側が 1 度だけ計算して使い回すことで、線形探索や複数回の推定が
|
||||
/// O(n) シリアライズで済む(内部で毎回再計算すると O(n²) になる)。
|
||||
pub fn tokens_at(
|
||||
history: &[Item],
|
||||
records: &[UsageRecord],
|
||||
index: usize,
|
||||
prefix: &[u64],
|
||||
) -> TokenEstimate {
|
||||
debug_assert!(index <= history.len());
|
||||
debug_assert_eq!(prefix.len(), history.len() + 1);
|
||||
|
||||
if index == 0 {
|
||||
return TokenEstimate {
|
||||
tokens: 0,
|
||||
source: EstimateSource::Measured,
|
||||
};
|
||||
}
|
||||
|
||||
if records.is_empty() {
|
||||
return TokenEstimate {
|
||||
tokens: prefix[index] / 4,
|
||||
source: EstimateSource::NoData,
|
||||
};
|
||||
}
|
||||
|
||||
// exact match(rev 走査で一番新しい record を採用)
|
||||
if let Some(r) = records.iter().rev().find(|r| r.history_len == index) {
|
||||
return TokenEstimate {
|
||||
tokens: r.input_total_tokens,
|
||||
source: EstimateSource::Measured,
|
||||
};
|
||||
}
|
||||
|
||||
let lower = records.iter().rev().find(|r| r.history_len < index);
|
||||
let upper = records.iter().find(|r| r.history_len > index);
|
||||
let cap = history.len();
|
||||
|
||||
match (lower, upper) {
|
||||
(Some(lo), Some(up)) => {
|
||||
let lo_bytes = prefix[lo.history_len.min(cap)];
|
||||
let up_bytes = prefix[up.history_len.min(cap)];
|
||||
let at_bytes = prefix[index];
|
||||
let span_bytes = up_bytes.saturating_sub(lo_bytes);
|
||||
let span_tokens = up.input_total_tokens.saturating_sub(lo.input_total_tokens);
|
||||
if span_bytes == 0 || span_tokens == 0 {
|
||||
return TokenEstimate {
|
||||
tokens: lo.input_total_tokens,
|
||||
source: EstimateSource::Interpolated,
|
||||
};
|
||||
}
|
||||
let delta_bytes = at_bytes.saturating_sub(lo_bytes);
|
||||
let delta_tokens =
|
||||
(delta_bytes as u128 * span_tokens as u128 / span_bytes as u128) as u64;
|
||||
TokenEstimate {
|
||||
tokens: lo.input_total_tokens + delta_tokens,
|
||||
source: EstimateSource::Interpolated,
|
||||
}
|
||||
}
|
||||
(Some(lo), None) => {
|
||||
let lo_bytes = prefix[lo.history_len.min(cap)];
|
||||
let at_bytes = prefix[index];
|
||||
if lo_bytes == 0 || lo.input_total_tokens == 0 {
|
||||
return TokenEstimate {
|
||||
tokens: lo.input_total_tokens,
|
||||
source: EstimateSource::Extrapolated,
|
||||
};
|
||||
}
|
||||
let delta_bytes = at_bytes.saturating_sub(lo_bytes);
|
||||
let delta_tokens =
|
||||
(delta_bytes as u128 * lo.input_total_tokens as u128 / lo_bytes as u128) as u64;
|
||||
TokenEstimate {
|
||||
tokens: lo.input_total_tokens + delta_tokens,
|
||||
source: EstimateSource::Extrapolated,
|
||||
}
|
||||
}
|
||||
(None, Some(up)) => {
|
||||
let up_bytes = prefix[up.history_len.min(cap)];
|
||||
let at_bytes = prefix[index];
|
||||
if up_bytes == 0 {
|
||||
return TokenEstimate {
|
||||
tokens: 0,
|
||||
source: EstimateSource::Interpolated,
|
||||
};
|
||||
}
|
||||
let t = (at_bytes as u128 * up.input_total_tokens as u128 / up_bytes as u128) as u64;
|
||||
TokenEstimate {
|
||||
tokens: t,
|
||||
source: EstimateSource::Interpolated,
|
||||
}
|
||||
}
|
||||
(None, None) => unreachable!("records non-empty but neither lower nor upper matched"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 現在の history 全体の推定トークン数。
|
||||
pub fn total_tokens(history: &[Item], records: &[UsageRecord]) -> TokenEstimate {
|
||||
let prefix = prefix_bytes(history);
|
||||
tokens_at(history, records, history.len(), &prefix)
|
||||
}
|
||||
|
||||
/// 任意の history index 時点でのプロンプト全長推定。
|
||||
/// `history_len == 0` で 0 を返す。delta 計算 (extract trigger 等) で
|
||||
/// `total_tokens_at(now) - total_tokens_at(pointer)` の形で使う。
|
||||
pub fn total_tokens_at(
|
||||
history: &[Item],
|
||||
records: &[UsageRecord],
|
||||
history_len: usize,
|
||||
) -> TokenEstimate {
|
||||
let prefix = prefix_bytes(history);
|
||||
tokens_at(history, records, history_len.min(history.len()), &prefix)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn msg(text: &str) -> Item {
|
||||
Item::user_message(text)
|
||||
}
|
||||
|
||||
fn record(history_len: usize, tokens: u64) -> UsageRecord {
|
||||
UsageRecord {
|
||||
history_len,
|
||||
input_total_tokens: tokens,
|
||||
cache_read_tokens: 0,
|
||||
cache_write_tokens: 0,
|
||||
output_tokens: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_no_data_falls_back_to_byte_estimate() {
|
||||
let history = vec![msg("hello world")];
|
||||
let est = total_tokens(&history, &[]);
|
||||
assert_eq!(est.source, EstimateSource::NoData);
|
||||
assert!(est.tokens > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_measured_when_last_record_matches_history_len() {
|
||||
let history = vec![msg("a"), msg("b"), msg("c")];
|
||||
let records = vec![record(3, 120)];
|
||||
let est = total_tokens(&history, &records);
|
||||
assert_eq!(est.source, EstimateSource::Measured);
|
||||
assert_eq!(est.tokens, 120);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_extrapolated_when_history_grew_past_last_measurement() {
|
||||
let history = vec![msg("a"), msg("b"), msg("c"), msg("d")];
|
||||
let records = vec![record(3, 100)];
|
||||
let est = total_tokens(&history, &records);
|
||||
assert_eq!(est.source, EstimateSource::Extrapolated);
|
||||
assert!(est.tokens > 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_zero_history_is_zero() {
|
||||
let est = total_tokens(&[], &[]);
|
||||
assert_eq!(est.tokens, 0);
|
||||
}
|
||||
}
|
||||
373
crates/llm-worker/src/tool.rs
Normal file
373
crates/llm-worker/src/tool.rs
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
//! Tool Definition
|
||||
//!
|
||||
//! Traits for defining tools callable by LLM.
|
||||
//! Usually auto-implemented using the `#[tool]` macro.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Error during tool execution
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ToolError {
|
||||
/// Invalid argument
|
||||
#[error("Invalid argument: {0}")]
|
||||
InvalidArgument(String),
|
||||
/// Execution failed
|
||||
#[error("Execution failed: {0}")]
|
||||
ExecutionFailed(String),
|
||||
/// Internal error
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ToolOutput - Tool execution result with summary + content
|
||||
// =============================================================================
|
||||
|
||||
/// Threshold below which tool output is treated as summary-only (no content).
|
||||
/// Outputs this small don't benefit from pruning.
|
||||
pub const SUMMARY_THRESHOLD: usize = 200;
|
||||
|
||||
/// Byte-size caps applied to tool execution `content` at the Worker's
|
||||
/// tool-execution boundary, before results enter conversation history.
|
||||
///
|
||||
/// Exists so a single oversized tool result (e.g. a wide `Glob` scan)
|
||||
/// cannot blow past the provider's per-minute input-token rate limit.
|
||||
/// Individual tools are not trusted to self-limit — this is the single
|
||||
/// chokepoint.
|
||||
///
|
||||
/// The unit is bytes rather than tokens because accurate pre-send token
|
||||
/// estimation is not available. The limits can be migrated to token
|
||||
/// units later without changing callers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolOutputLimits {
|
||||
/// Cap applied to any tool not listed in `per_tool`.
|
||||
pub default_max_bytes: usize,
|
||||
/// Per-tool overrides, keyed by tool registration name.
|
||||
pub per_tool: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl ToolOutputLimits {
|
||||
/// Resolve the cap for a given tool name.
|
||||
pub fn limit_for(&self, tool_name: &str) -> usize {
|
||||
self.per_tool
|
||||
.get(tool_name)
|
||||
.copied()
|
||||
.unwrap_or(self.default_max_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate `content` in-place if it exceeds `limit` bytes, replacing
|
||||
/// the dropped tail with a short human- and LLM-readable marker so the
|
||||
/// model can self-correct by narrowing its query.
|
||||
///
|
||||
/// The cut point is walked back to the nearest UTF-8 char boundary so
|
||||
/// multibyte characters are never split.
|
||||
pub(crate) fn truncate_content(content: &mut String, limit: usize) {
|
||||
let original_len = content.len();
|
||||
if original_len <= limit {
|
||||
return;
|
||||
}
|
||||
|
||||
let suffix_template = "\n\n[truncated: %BYTES% bytes dropped, refine your query]";
|
||||
// Reserve enough headroom for the suffix (upper bound on the byte length
|
||||
// of the number substitution). usize::MAX fits in 20 digits.
|
||||
let reserved = suffix_template.len() + 20 - "%BYTES%".len();
|
||||
let body_budget = limit.saturating_sub(reserved);
|
||||
|
||||
let mut cut = body_budget.min(original_len);
|
||||
while cut > 0 && !content.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
content.truncate(cut);
|
||||
let dropped = original_len - cut;
|
||||
content.push_str(&suffix_template.replace("%BYTES%", &dropped.to_string()));
|
||||
}
|
||||
|
||||
/// Tool execution result.
|
||||
///
|
||||
/// Every output has a mandatory `summary` (1-2 lines) that persists in
|
||||
/// conversation history even after pruning. The optional `content` carries
|
||||
/// full details and is removed by the Prune mechanism when the context
|
||||
/// grows too large.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolOutput {
|
||||
/// Short summary (1-2 lines). Always remains in history.
|
||||
pub summary: String,
|
||||
/// Detailed output. Removed by Prune when old enough.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
}
|
||||
|
||||
impl From<String> for ToolOutput {
|
||||
fn from(s: String) -> Self {
|
||||
if s.len() <= SUMMARY_THRESHOLD {
|
||||
ToolOutput {
|
||||
summary: s,
|
||||
content: None,
|
||||
}
|
||||
} else {
|
||||
let lines = s.lines().count();
|
||||
let first_line: String = s.lines().next().unwrap_or("").chars().take(80).collect();
|
||||
let summary = format!("{lines} lines | {first_line}…");
|
||||
ToolOutput {
|
||||
summary,
|
||||
content: Some(s),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ToolMeta - Immutable Meta Information
|
||||
// =============================================================================
|
||||
|
||||
/// Tool meta information (fixed at registration, immutable)
|
||||
///
|
||||
/// Generated from `ToolDefinition` factory and does not change after registration with Worker.
|
||||
/// Used for sending tool definitions to LLM.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolMeta {
|
||||
/// Tool name (used by LLM for identification)
|
||||
pub name: String,
|
||||
/// Tool description (included in prompt to LLM)
|
||||
pub description: String,
|
||||
/// JSON Schema for arguments
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
impl ToolMeta {
|
||||
/// Create a new ToolMeta
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
input_schema: Value::Object(Default::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the description
|
||||
pub fn description(mut self, desc: impl Into<String>) -> Self {
|
||||
self.description = desc.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the argument schema
|
||||
pub fn input_schema(mut self, schema: Value) -> Self {
|
||||
self.input_schema = schema;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ToolDefinition - Factory Type
|
||||
// =============================================================================
|
||||
|
||||
/// Tool definition factory
|
||||
///
|
||||
/// When called, returns `(ToolMeta, Arc<dyn Tool>)`.
|
||||
/// Called once during Worker registration, and the meta information and instance
|
||||
/// are cached at session scope.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// let def: ToolDefinition = Arc::new(|| {
|
||||
/// (
|
||||
/// ToolMeta::new("my_tool")
|
||||
/// .description("My tool description")
|
||||
/// .input_schema(json!({"type": "object"})),
|
||||
/// Arc::new(MyToolImpl { state: 0 }) as Arc<dyn Tool>,
|
||||
/// )
|
||||
/// });
|
||||
/// worker.register_tool(def)?;
|
||||
/// ```
|
||||
pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Sync>;
|
||||
|
||||
// =============================================================================
|
||||
// Tool trait
|
||||
// =============================================================================
|
||||
|
||||
/// Trait for defining tools callable by LLM
|
||||
///
|
||||
/// Tools are used by LLM to access external resources
|
||||
/// or execute computations.
|
||||
/// Can maintain state during the session.
|
||||
///
|
||||
/// # How to Implement
|
||||
///
|
||||
/// Usually auto-implemented using the `#[tool_registry]` macro:
|
||||
///
|
||||
/// ```ignore
|
||||
/// #[tool_registry]
|
||||
/// impl MyApp {
|
||||
/// #[tool]
|
||||
/// async fn search(&self, query: String) -> String {
|
||||
/// format!("Results for: {}", query)
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// // Register
|
||||
/// worker.register_tool(app.search_definition())?;
|
||||
/// ```
|
||||
///
|
||||
/// # Manual Implementation
|
||||
///
|
||||
/// ```ignore
|
||||
/// use llm_worker::tool::{Tool, ToolError, ToolMeta, ToolDefinition};
|
||||
/// use std::sync::Arc;
|
||||
///
|
||||
/// struct MyTool { counter: std::sync::atomic::AtomicUsize }
|
||||
///
|
||||
/// #[async_trait::async_trait]
|
||||
/// impl Tool for MyTool {
|
||||
/// async fn execute(&self, input: &str) -> Result<String, ToolError> {
|
||||
/// self.counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
/// Ok("result".to_string())
|
||||
/// }
|
||||
/// }
|
||||
///
|
||||
/// let def: ToolDefinition = Arc::new(|| {
|
||||
/// (
|
||||
/// ToolMeta::new("my_tool")
|
||||
/// .description("My custom tool")
|
||||
/// .input_schema(serde_json::json!({"type": "object"})),
|
||||
/// Arc::new(MyTool { counter: Default::default() }) as Arc<dyn Tool>,
|
||||
/// )
|
||||
/// });
|
||||
/// ```
|
||||
#[async_trait]
|
||||
pub trait Tool: Send + Sync {
|
||||
/// Execute the tool.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `input_json` - JSON-formatted arguments generated by LLM
|
||||
///
|
||||
/// # Returns
|
||||
/// A [`ToolOutput`] with summary and optional detailed content.
|
||||
/// For simple cases, use `From<String>`: `Ok("done".to_string().into())`
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tool Call / Result Types
|
||||
// =============================================================================
|
||||
|
||||
/// Tool call information
|
||||
///
|
||||
/// Represents a ToolUse block from LLM.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
/// Tool call ID (used for linking with response)
|
||||
pub id: String,
|
||||
/// Tool name
|
||||
pub name: String,
|
||||
/// Input arguments (JSON)
|
||||
pub input: Value,
|
||||
}
|
||||
|
||||
/// Tool execution result
|
||||
///
|
||||
/// Intermediate representation between tool execution and history.
|
||||
/// Carries `summary` + optional `content` from [`ToolOutput`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ToolResult {
|
||||
/// Corresponding tool call ID
|
||||
pub tool_use_id: String,
|
||||
/// Short summary (always kept in history)
|
||||
pub summary: String,
|
||||
/// Detailed output (prunable)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// Whether this is an error
|
||||
#[serde(default)]
|
||||
pub is_error: bool,
|
||||
}
|
||||
|
||||
impl ToolResult {
|
||||
/// Create a success result from a [`ToolOutput`].
|
||||
pub fn from_output(tool_use_id: impl Into<String>, output: ToolOutput) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
summary: output.summary,
|
||||
content: output.content,
|
||||
is_error: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an error result.
|
||||
pub fn error(tool_use_id: impl Into<String>, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
summary: message.into(),
|
||||
content: None,
|
||||
is_error: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod truncate_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn noop_when_within_limit() {
|
||||
let mut s = "hello world".to_string();
|
||||
truncate_content(&mut s, 1024);
|
||||
assert_eq!(s, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noop_at_exact_limit() {
|
||||
let mut s = "a".repeat(100);
|
||||
truncate_content(&mut s, 100);
|
||||
assert_eq!(s.len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_oversized_ascii_with_marker() {
|
||||
let mut s = "a".repeat(1000);
|
||||
truncate_content(&mut s, 200);
|
||||
assert!(s.contains("[truncated:"));
|
||||
assert!(s.contains("refine your query"));
|
||||
assert!(s.len() <= 200, "result was {} bytes", s.len());
|
||||
let dropped: usize = s
|
||||
.split("[truncated: ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(' ')
|
||||
.next()
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
let body_len = s.find("\n\n[truncated:").unwrap();
|
||||
assert_eq!(body_len + dropped, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_utf8_char_boundaries() {
|
||||
// 100 copies of "あ" (3 bytes each) = 300 bytes.
|
||||
let mut s = "あ".repeat(100);
|
||||
truncate_content(&mut s, 120);
|
||||
// Truncation must not split a multibyte character.
|
||||
assert!(s.is_char_boundary(s.find("\n\n[truncated:").unwrap_or(s.len())));
|
||||
// And the result must still be valid UTF-8 (implicitly true for String).
|
||||
assert!(s.contains("[truncated:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limits_per_tool_override() {
|
||||
let mut limits = ToolOutputLimits {
|
||||
default_max_bytes: 1024,
|
||||
per_tool: HashMap::new(),
|
||||
};
|
||||
limits.per_tool.insert("Read".to_string(), 4096);
|
||||
assert_eq!(limits.limit_for("Read"), 4096);
|
||||
assert_eq!(limits.limit_for("Grep"), 1024);
|
||||
}
|
||||
}
|
||||
506
crates/llm-worker/src/tool_server.rs
Normal file
506
crates/llm-worker/src/tool_server.rs
Normal file
|
|
@ -0,0 +1,506 @@
|
|||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::llm_client::ToolDefinition as LlmToolDefinition;
|
||||
use crate::tool::{Tool, ToolDefinition as WorkerToolDefinition, ToolMeta, ToolOutput};
|
||||
|
||||
type ToolMap = HashMap<String, (ToolMeta, Arc<dyn Tool>)>;
|
||||
|
||||
/// Errors produced by ToolServer operations.
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum ToolServerError {
|
||||
/// A tool with the same name already exists.
|
||||
#[error("Tool with name '{0}' already registered")]
|
||||
DuplicateName(String),
|
||||
/// Requested tool was not found.
|
||||
#[error("Tool '{0}' not found")]
|
||||
ToolNotFound(String),
|
||||
/// Tool execution failed.
|
||||
#[error("Tool execution failed: {0}")]
|
||||
ToolExecution(String),
|
||||
}
|
||||
|
||||
/// In-memory tool server.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ToolServer {
|
||||
tools: Arc<Mutex<ToolMap>>,
|
||||
pending: Arc<Mutex<Vec<WorkerToolDefinition>>>,
|
||||
}
|
||||
|
||||
impl ToolServer {
|
||||
/// Create a new empty tool server.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Create a handle for shared access.
|
||||
pub fn handle(&self) -> ToolServerHandle {
|
||||
ToolServerHandle {
|
||||
tools: Arc::clone(&self.tools),
|
||||
pending: Arc::clone(&self.pending),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shareable handle to a tool server.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ToolServerHandle {
|
||||
tools: Arc<Mutex<ToolMap>>,
|
||||
pending: Arc<Mutex<Vec<WorkerToolDefinition>>>,
|
||||
}
|
||||
|
||||
impl ToolServerHandle {
|
||||
/// Queue a tool factory for deferred initialization.
|
||||
///
|
||||
/// The factory is **not** called here; it is stored and executed
|
||||
/// when [`flush_pending`](Self::flush_pending) is called (typically
|
||||
/// at the start of `Worker::run()`).
|
||||
pub(crate) fn register_tool(&self, factory: WorkerToolDefinition) {
|
||||
self.pending
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.push(factory);
|
||||
}
|
||||
|
||||
/// Queue many tool factories for deferred initialization.
|
||||
pub(crate) fn register_tools(&self, factories: impl IntoIterator<Item = WorkerToolDefinition>) {
|
||||
let mut guard = self.pending.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard.extend(factories);
|
||||
}
|
||||
|
||||
/// Execute all pending factories and register the resulting tools.
|
||||
///
|
||||
/// Called implicitly by `Worker::lock()` before the first turn.
|
||||
/// Exposed as `pub` so higher layers (e.g. Pod) can force-materialise
|
||||
/// tools earlier — for example when building a system-prompt template
|
||||
/// context that needs the list of registered tool names. Redundant
|
||||
/// calls are no-ops.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if any factory produces a tool whose name collides with
|
||||
/// an already-registered tool. Duplicate names are a programming
|
||||
/// error and should be caught during development.
|
||||
pub fn flush_pending(&self) {
|
||||
let pending: Vec<_> = {
|
||||
let mut guard = self.pending.lock().unwrap_or_else(|e| e.into_inner());
|
||||
std::mem::take(&mut *guard)
|
||||
};
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Execute all factories first, then validate and insert atomically.
|
||||
let materialized: Vec<_> = pending.into_iter().map(|f| f()).collect();
|
||||
let mut tools = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for (meta, instance) in materialized {
|
||||
assert!(
|
||||
!tools.contains_key(&meta.name),
|
||||
"duplicate tool name: '{}'",
|
||||
meta.name,
|
||||
);
|
||||
tools.insert(meta.name.clone(), (meta, instance));
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a tool by name for hook contexts.
|
||||
pub fn get_tool(&self, name: &str) -> Option<(ToolMeta, Arc<dyn Tool>)> {
|
||||
let guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard
|
||||
.get(name)
|
||||
.map(|(meta, tool)| (meta.clone(), Arc::clone(tool)))
|
||||
}
|
||||
|
||||
/// Execute a tool by name.
|
||||
pub async fn call_tool(
|
||||
&self,
|
||||
name: &str,
|
||||
input_json: &str,
|
||||
) -> Result<ToolOutput, ToolServerError> {
|
||||
let tool = {
|
||||
let guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let (_, tool) = guard
|
||||
.get(name)
|
||||
.ok_or_else(|| ToolServerError::ToolNotFound(name.to_string()))?;
|
||||
Arc::clone(tool)
|
||||
};
|
||||
tool.execute(input_json)
|
||||
.await
|
||||
.map_err(|e| ToolServerError::ToolExecution(e.to_string()))
|
||||
}
|
||||
|
||||
/// Remove a registered tool by name.
|
||||
///
|
||||
/// In-flight calls that already obtained an `Arc<dyn Tool>` clone are
|
||||
/// unaffected and will run to completion.
|
||||
pub fn unregister(&self, name: &str) -> Result<(), ToolServerError> {
|
||||
let mut guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
guard
|
||||
.remove(name)
|
||||
.map(|_| ())
|
||||
.ok_or_else(|| ToolServerError::ToolNotFound(name.to_string()))
|
||||
}
|
||||
|
||||
/// Replace an existing tool with a new implementation.
|
||||
///
|
||||
/// The factory is called immediately and the resulting tool overwrites
|
||||
/// the entry with the same name. Returns `ToolNotFound` if the name
|
||||
/// produced by the factory does not match any registered tool.
|
||||
pub fn replace(&self, factory: WorkerToolDefinition) -> Result<(), ToolServerError> {
|
||||
let (meta, instance) = factory();
|
||||
let mut guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !guard.contains_key(&meta.name) {
|
||||
return Err(ToolServerError::ToolNotFound(meta.name));
|
||||
}
|
||||
guard.insert(meta.name.clone(), (meta, instance));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build deterministic tool definitions sorted by tool name.
|
||||
pub fn tool_definitions_sorted(&self) -> Vec<LlmToolDefinition> {
|
||||
let guard = self.tools.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut defs: Vec<_> = guard
|
||||
.values()
|
||||
.map(|(meta, _)| {
|
||||
LlmToolDefinition::new(&meta.name)
|
||||
.description(&meta.description)
|
||||
.input_schema(meta.input_schema.clone())
|
||||
})
|
||||
.collect();
|
||||
defs.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
defs
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::tool::{Tool, ToolDefinition, ToolError, ToolMeta};
|
||||
|
||||
struct EchoTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EchoTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok(input_json.to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
fn def(name: &'static str) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new(name)
|
||||
.description(format!("desc-{name}"))
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(EchoTool) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_pending_registers_tools() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.register_tool(def("beta"));
|
||||
|
||||
// Before flush, no tools are available
|
||||
assert!(handle.get_tool("alpha").is_none());
|
||||
|
||||
handle.flush_pending();
|
||||
|
||||
// After flush, tools are available
|
||||
assert!(handle.get_tool("alpha").is_some());
|
||||
assert!(handle.get_tool("beta").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "duplicate tool name: 'alpha'")]
|
||||
fn flush_pending_duplicate_name_panics() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.flush_pending();
|
||||
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.flush_pending(); // panics
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn call_tool_success_and_not_found() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("echo"));
|
||||
handle.flush_pending();
|
||||
|
||||
let out = handle.call_tool("echo", r#"{"x":1}"#).await.expect("call");
|
||||
assert_eq!(out.summary, r#"{"x":1}"#);
|
||||
assert!(out.content.is_none());
|
||||
|
||||
let err = handle
|
||||
.call_tool("missing", "{}")
|
||||
.await
|
||||
.expect_err("missing tool");
|
||||
assert_eq!(err, ToolServerError::ToolNotFound("missing".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_definitions_are_sorted() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("zeta"));
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.register_tool(def("beta"));
|
||||
handle.flush_pending();
|
||||
|
||||
let names: Vec<_> = handle
|
||||
.tool_definitions_sorted()
|
||||
.into_iter()
|
||||
.map(|d| d.name)
|
||||
.collect();
|
||||
assert_eq!(names, vec!["alpha", "beta", "zeta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flush_pending_is_noop_when_empty() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.flush_pending();
|
||||
handle.flush_pending();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_removes_tool() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.flush_pending();
|
||||
|
||||
handle.unregister("alpha").expect("unregister");
|
||||
assert!(handle.get_tool("alpha").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_not_found() {
|
||||
let handle = ToolServer::new().handle();
|
||||
let err = handle.unregister("ghost").expect_err("should fail");
|
||||
assert_eq!(err, ToolServerError::ToolNotFound("ghost".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_swaps_implementation() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.flush_pending();
|
||||
|
||||
// Replace with a tool that returns a fixed string.
|
||||
struct FixedTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FixedTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok("replaced".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
let replacement: ToolDefinition = Arc::new(|| {
|
||||
(
|
||||
ToolMeta::new("alpha")
|
||||
.description("replaced-desc")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(FixedTool) as Arc<dyn Tool>,
|
||||
)
|
||||
});
|
||||
handle.replace(replacement).expect("replace");
|
||||
|
||||
let (meta, _) = handle.get_tool("alpha").expect("exists");
|
||||
assert_eq!(meta.description, "replaced-desc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_updates_call_result() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("echo"));
|
||||
handle.flush_pending();
|
||||
|
||||
struct ConstTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ConstTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok("const".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
let replacement: ToolDefinition = Arc::new(|| {
|
||||
(
|
||||
ToolMeta::new("echo")
|
||||
.description("const")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(ConstTool) as Arc<dyn Tool>,
|
||||
)
|
||||
});
|
||||
handle.replace(replacement).expect("replace");
|
||||
|
||||
let out = handle.call_tool("echo", "{}").await.expect("call");
|
||||
assert_eq!(out.summary, "const");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unregister_during_execution_does_not_affect_inflight() {
|
||||
use tokio::sync::Notify;
|
||||
|
||||
let started = Arc::new(Notify::new());
|
||||
let finish = Arc::new(Notify::new());
|
||||
|
||||
struct GatedTool {
|
||||
started: Arc<Notify>,
|
||||
finish: Arc<Notify>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for GatedTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
self.started.notify_one();
|
||||
self.finish.notified().await;
|
||||
Ok("done".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
let handle = ToolServer::new().handle();
|
||||
let s = Arc::clone(&started);
|
||||
let f = Arc::clone(&finish);
|
||||
handle.register_tool(Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new("slow")
|
||||
.description("slow")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(GatedTool {
|
||||
started: Arc::clone(&s),
|
||||
finish: Arc::clone(&f),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
}));
|
||||
handle.flush_pending();
|
||||
|
||||
let h = handle.clone();
|
||||
let call = tokio::spawn(async move { h.call_tool("slow", "{}").await });
|
||||
|
||||
// Wait until the tool is actually executing.
|
||||
started.notified().await;
|
||||
|
||||
// Unregister while the tool is mid-execution.
|
||||
handle.unregister("slow").expect("unregister");
|
||||
assert!(handle.get_tool("slow").is_none());
|
||||
|
||||
// Let the in-flight call finish.
|
||||
finish.notify_one();
|
||||
let result = call.await.expect("join");
|
||||
assert_eq!(result.expect("call").summary, "done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_during_execution_inflight_uses_old_impl() {
|
||||
use tokio::sync::Notify;
|
||||
|
||||
let started = Arc::new(Notify::new());
|
||||
let finish = Arc::new(Notify::new());
|
||||
|
||||
struct OldTool {
|
||||
started: Arc<Notify>,
|
||||
finish: Arc<Notify>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for OldTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
self.started.notify_one();
|
||||
self.finish.notified().await;
|
||||
Ok("old".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
let handle = ToolServer::new().handle();
|
||||
let s = Arc::clone(&started);
|
||||
let f = Arc::clone(&finish);
|
||||
handle.register_tool(Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new("t")
|
||||
.description("d")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(OldTool {
|
||||
started: Arc::clone(&s),
|
||||
finish: Arc::clone(&f),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
}));
|
||||
handle.flush_pending();
|
||||
|
||||
let h = handle.clone();
|
||||
let call = tokio::spawn(async move { h.call_tool("t", "{}").await });
|
||||
|
||||
// Wait until the old tool is mid-execution.
|
||||
started.notified().await;
|
||||
|
||||
// Replace while the old tool is executing.
|
||||
struct NewTool;
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for NewTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok("new".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
handle
|
||||
.replace(Arc::new(|| {
|
||||
(
|
||||
ToolMeta::new("t")
|
||||
.description("d")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(NewTool) as Arc<dyn Tool>,
|
||||
)
|
||||
}))
|
||||
.expect("replace");
|
||||
|
||||
// Let the old in-flight call finish — it should return "old".
|
||||
finish.notify_one();
|
||||
let result = call.await.expect("join");
|
||||
assert_eq!(result.expect("call").summary, "old");
|
||||
|
||||
// New calls use the replacement.
|
||||
let out = handle.call_tool("t", "{}").await.expect("call");
|
||||
assert_eq!(out.summary, "new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_reflects_in_tool_definitions() {
|
||||
let handle = ToolServer::new().handle();
|
||||
handle.register_tool(def("alpha"));
|
||||
handle.register_tool(def("beta"));
|
||||
handle.flush_pending();
|
||||
|
||||
handle.unregister("alpha").expect("unregister");
|
||||
let names: Vec<_> = handle
|
||||
.tool_definitions_sorted()
|
||||
.into_iter()
|
||||
.map(|d| d.name)
|
||||
.collect();
|
||||
assert_eq!(names, vec!["beta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_not_found() {
|
||||
let handle = ToolServer::new().handle();
|
||||
let factory: ToolDefinition = Arc::new(|| {
|
||||
(
|
||||
ToolMeta::new("ghost")
|
||||
.description("x")
|
||||
.input_schema(json!({"type":"object"})),
|
||||
Arc::new(EchoTool) as Arc<dyn Tool>,
|
||||
)
|
||||
});
|
||||
let err = handle.replace(factory).expect_err("should fail");
|
||||
assert_eq!(err, ToolServerError::ToolNotFound("ghost".to_string()));
|
||||
}
|
||||
}
|
||||
22
crates/llm-worker/src/usage_record.rs
Normal file
22
crates/llm-worker/src/usage_record.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
//! Per-LLM-request Usage measurement snapshot.
|
||||
//!
|
||||
//! 1 リクエストの送信時点での「ある history prefix 長で計測した占有量」を
|
||||
//! 1 件分にまとめたもの。`UsageEvent` (provider stream イベント) を
|
||||
//! 受けて呼び出し側 (typically Pod) が組み立て、永続化層
|
||||
//! (session-store) に流したり、token accounting (`token_counter`) で
|
||||
//! 履歴として参照したりする。
|
||||
|
||||
/// LLM リクエスト送信時点での占有量スナップショット。
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UsageRecord {
|
||||
/// 送信時の history.len()
|
||||
pub history_len: usize,
|
||||
/// history[..history_len] の占有量(プロンプト全長、実測)
|
||||
pub input_total_tokens: u64,
|
||||
/// 上記のうちキャッシュから読み出された分
|
||||
pub cache_read_tokens: u64,
|
||||
/// 上記のうちこのリクエストでキャッシュに書かれた分
|
||||
pub cache_write_tokens: u64,
|
||||
/// このリクエストで生成された出力トークン数
|
||||
pub output_tokens: u64,
|
||||
}
|
||||
2079
crates/llm-worker/src/worker.rs
Normal file
2079
crates/llm-worker/src/worker.rs
Normal file
File diff suppressed because it is too large
Load Diff
23
crates/llm-worker/tests/anthropic_fixtures.rs
Normal file
23
crates/llm-worker/tests/anthropic_fixtures.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
//! Anthropic fixture-based integration tests
|
||||
|
||||
mod common;
|
||||
|
||||
#[test]
|
||||
fn test_fixture_events_deserialize() {
|
||||
common::assert_events_deserialize("anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_event_sequence() {
|
||||
common::assert_event_sequence("anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_usage_tokens() {
|
||||
common::assert_usage_tokens("anthropic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fixture_with_timeline() {
|
||||
common::assert_timeline_integration("anthropic");
|
||||
}
|
||||
383
crates/llm-worker/tests/callback_test.rs
Normal file
383
crates/llm-worker/tests/callback_test.rs
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
//! Closure callback API tests
|
||||
//!
|
||||
//! Tests for the closure-based event subscription API on Worker.
|
||||
|
||||
mod common;
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use common::MockLlmClient;
|
||||
use llm_worker::Worker;
|
||||
use llm_worker::llm_client::event::{Event, ResponseStatus, StatusEvent as ClientStatusEvent};
|
||||
use llm_worker::llm_client::retry::RetryPolicy;
|
||||
use llm_worker::llm_client::{ClientError, LlmClient, Request, ResponseStream};
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct FailOnceClient {
|
||||
calls: Arc<AtomicUsize>,
|
||||
events: Vec<Event>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for FailOnceClient {
|
||||
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
|
||||
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
|
||||
return Err(ClientError::Api {
|
||||
status: Some(504),
|
||||
code: None,
|
||||
message: "gateway timeout".into(),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
Ok(Box::pin(futures::stream::iter(
|
||||
self.events.clone().into_iter().map(Ok),
|
||||
)))
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_callback_llm_retry_event() {
|
||||
let events = vec![Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})];
|
||||
let client = FailOnceClient {
|
||||
calls: Arc::new(AtomicUsize::new(0)),
|
||||
events,
|
||||
};
|
||||
let mut worker = Worker::new(client).with_retry_policy(RetryPolicy {
|
||||
base: Duration::from_millis(1),
|
||||
cap: Duration::from_millis(1),
|
||||
max_attempts: 2,
|
||||
total_timeout: Duration::from_secs(1),
|
||||
});
|
||||
|
||||
let notices = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = notices.clone();
|
||||
worker.on_llm_retry(move |llm_call, notice| {
|
||||
sink.lock().unwrap().push((llm_call, notice.clone()));
|
||||
});
|
||||
|
||||
let result = worker.run("retry once").await;
|
||||
assert!(result.is_ok(), "worker should succeed after one retry");
|
||||
|
||||
let notices = notices.lock().unwrap();
|
||||
assert_eq!(notices.len(), 1);
|
||||
assert_eq!(notices[0].0, 0);
|
||||
assert_eq!(notices[0].1.failed_attempt, 1);
|
||||
assert_eq!(notices[0].1.max_attempts, 2);
|
||||
assert_eq!(notices[0].1.status, Some(504));
|
||||
}
|
||||
|
||||
/// Verify that on_text_block correctly receives delta and stop events
|
||||
#[tokio::test]
|
||||
async fn test_callback_text_block_events() {
|
||||
let events = vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Hello, "),
|
||||
Event::text_delta(0, "World!"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut worker = Worker::new(client);
|
||||
|
||||
let text_deltas = Arc::new(Mutex::new(Vec::new()));
|
||||
let text_completes = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let deltas = text_deltas.clone();
|
||||
let completes = text_completes.clone();
|
||||
worker.on_text_block(move |block| {
|
||||
let d = deltas.clone();
|
||||
block.on_delta(move |text| {
|
||||
d.lock().unwrap().push(text.to_owned());
|
||||
});
|
||||
let c = completes.clone();
|
||||
block.on_stop(move |text| {
|
||||
c.lock().unwrap().push(text.to_owned());
|
||||
});
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, WorkerResult)
|
||||
let result = worker.run("Greet me").await;
|
||||
assert!(result.is_ok(), "Worker should complete");
|
||||
|
||||
let deltas = text_deltas.lock().unwrap();
|
||||
assert_eq!(deltas.len(), 2);
|
||||
assert_eq!(deltas[0], "Hello, ");
|
||||
assert_eq!(deltas[1], "World!");
|
||||
|
||||
let completes = text_completes.lock().unwrap();
|
||||
assert_eq!(completes.len(), 1);
|
||||
assert_eq!(completes[0], "Hello, World!");
|
||||
}
|
||||
|
||||
/// Verify that on_tool_use_block correctly receives start info and stop with ToolCall
|
||||
#[tokio::test]
|
||||
async fn test_callback_tool_call_complete() {
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_123", "get_weather"),
|
||||
Event::tool_input_delta(0, r#"{"city":"#),
|
||||
Event::tool_input_delta(0, r#""Tokyo"}"#),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut worker = Worker::new(client);
|
||||
|
||||
let tool_starts = Arc::new(Mutex::new(Vec::<(String, String)>::new()));
|
||||
let tool_completes = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let starts = tool_starts.clone();
|
||||
let completes = tool_completes.clone();
|
||||
worker.on_tool_use_block(move |start, block| {
|
||||
starts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((start.id.clone(), start.name.clone()));
|
||||
let c = completes.clone();
|
||||
block.on_stop(move |call| {
|
||||
c.lock().unwrap().push(call.clone());
|
||||
});
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, WorkerResult)
|
||||
let _ = worker.run("Weather please").await;
|
||||
|
||||
let starts = tool_starts.lock().unwrap();
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(starts[0].0, "call_123");
|
||||
assert_eq!(starts[0].1, "get_weather");
|
||||
|
||||
let completes = tool_completes.lock().unwrap();
|
||||
assert_eq!(completes.len(), 1);
|
||||
assert_eq!(completes[0].name, "get_weather");
|
||||
assert_eq!(completes[0].id, "call_123");
|
||||
assert_eq!(completes[0].input["city"], "Tokyo");
|
||||
}
|
||||
|
||||
/// Verify that on_turn_start and on_turn_end callbacks are called
|
||||
#[tokio::test]
|
||||
async fn test_callback_turn_events() {
|
||||
let events = vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Done!"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut worker = Worker::new(client);
|
||||
|
||||
let turn_starts = Arc::new(Mutex::new(Vec::new()));
|
||||
let turn_ends = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let starts = turn_starts.clone();
|
||||
worker.on_turn_start(move |turn| {
|
||||
starts.lock().unwrap().push(turn);
|
||||
});
|
||||
|
||||
let ends = turn_ends.clone();
|
||||
worker.on_turn_end(move |turn| {
|
||||
ends.lock().unwrap().push(turn);
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, WorkerResult)
|
||||
let result = worker.run("Do something").await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let starts = turn_starts.lock().unwrap();
|
||||
let ends = turn_ends.lock().unwrap();
|
||||
|
||||
assert_eq!(starts.len(), 1);
|
||||
assert_eq!(starts[0], 0);
|
||||
|
||||
assert_eq!(ends.len(), 1);
|
||||
assert_eq!(ends[0], 0);
|
||||
}
|
||||
|
||||
/// Stub tool returning a fixed [`ToolOutput`] for result-callback tests.
|
||||
struct FixedOutputTool {
|
||||
output: ToolOutput,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for FixedOutputTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
Ok(self.output.clone())
|
||||
}
|
||||
}
|
||||
|
||||
fn fixed_tool(name: &'static str, output: ToolOutput) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new(name).input_schema(serde_json::json!({"type":"object"}));
|
||||
(
|
||||
meta,
|
||||
Arc::new(FixedOutputTool {
|
||||
output: output.clone(),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify that on_tool_result fires once per executed tool with
|
||||
/// summary/content/is_error matching what the tool returned.
|
||||
#[tokio::test]
|
||||
async fn test_callback_tool_result_events() {
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_1", "fixed"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut worker = Worker::new(client);
|
||||
|
||||
worker.register_tool(fixed_tool(
|
||||
"fixed",
|
||||
ToolOutput {
|
||||
summary: "did the thing".into(),
|
||||
content: Some("full detail body".into()),
|
||||
},
|
||||
));
|
||||
|
||||
let captured: Arc<Mutex<Vec<(String, String, Option<String>, bool)>>> =
|
||||
Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = captured.clone();
|
||||
worker.on_tool_result(move |result| {
|
||||
sink.lock().unwrap().push((
|
||||
result.tool_use_id.clone(),
|
||||
result.summary.clone(),
|
||||
result.content.clone(),
|
||||
result.is_error,
|
||||
));
|
||||
});
|
||||
|
||||
let _ = worker.run("call it").await;
|
||||
|
||||
let observed = captured.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
assert_eq!(observed[0].0, "call_1");
|
||||
assert_eq!(observed[0].1, "did the thing");
|
||||
assert_eq!(observed[0].2.as_deref(), Some("full detail body"));
|
||||
assert!(!observed[0].3);
|
||||
}
|
||||
|
||||
/// Stub tool that always fails, for exercising the error path through
|
||||
/// `on_tool_result`.
|
||||
struct ErroringTool {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ErroringTool {
|
||||
async fn execute(&self, _input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
Err(ToolError::ExecutionFailed(self.message.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn erroring_tool(name: &'static str, message: &'static str) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let meta = ToolMeta::new(name).input_schema(serde_json::json!({"type":"object"}));
|
||||
(
|
||||
meta,
|
||||
Arc::new(ErroringTool {
|
||||
message: message.to_string(),
|
||||
}) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify on_tool_result also fires for failed executions with
|
||||
/// is_error=true, and that the ToolOutput content channel stays empty.
|
||||
#[tokio::test]
|
||||
async fn test_callback_tool_result_error_path() {
|
||||
let events = vec![
|
||||
Event::tool_use_start(0, "call_err", "erroring"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut worker = Worker::new(client);
|
||||
|
||||
worker.register_tool(erroring_tool("erroring", "boom"));
|
||||
|
||||
let captured: Arc<Mutex<Vec<(String, String, Option<String>, bool)>>> =
|
||||
Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = captured.clone();
|
||||
worker.on_tool_result(move |result| {
|
||||
sink.lock().unwrap().push((
|
||||
result.tool_use_id.clone(),
|
||||
result.summary.clone(),
|
||||
result.content.clone(),
|
||||
result.is_error,
|
||||
));
|
||||
});
|
||||
|
||||
let _ = worker.run("fail it").await;
|
||||
|
||||
let observed = captured.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
assert_eq!(observed[0].0, "call_err");
|
||||
assert!(
|
||||
observed[0].1.contains("boom"),
|
||||
"summary should carry the error message: {}",
|
||||
observed[0].1
|
||||
);
|
||||
assert!(observed[0].2.is_none());
|
||||
assert!(observed[0].3);
|
||||
}
|
||||
|
||||
/// Verify that on_usage callback receives usage events
|
||||
#[tokio::test]
|
||||
async fn test_callback_usage_events() {
|
||||
let events = vec![
|
||||
Event::text_block_start(0),
|
||||
Event::text_delta(0, "Hello"),
|
||||
Event::text_block_stop(0, None),
|
||||
Event::usage(100, 50),
|
||||
Event::Status(ClientStatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
];
|
||||
|
||||
let client = MockLlmClient::new(events);
|
||||
let mut worker = Worker::new(client);
|
||||
|
||||
let usage_events = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
let usages = usage_events.clone();
|
||||
worker.on_usage(move |event| {
|
||||
usages.lock().unwrap().push(event.clone());
|
||||
});
|
||||
|
||||
// Mutable::run consumes self, returns (Locked, WorkerResult)
|
||||
let _ = worker.run("Hello").await;
|
||||
|
||||
let usages = usage_events.lock().unwrap();
|
||||
assert_eq!(usages.len(), 1);
|
||||
assert_eq!(usages[0].input_tokens, Some(100));
|
||||
assert_eq!(usages[0].output_tokens, Some(50));
|
||||
}
|
||||
286
crates/llm-worker/tests/common/mod.rs
Normal file
286
crates/llm-worker/tests/common/mod.rs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
#![allow(dead_code)]
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
use llm_worker::llm_client::event::{BlockType, DeltaContent, Event};
|
||||
use llm_worker::llm_client::{ClientError, LlmClient, Request};
|
||||
use llm_worker::timeline::{Handler, TextBlockEvent, TextBlockKind, Timeline};
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// A mock LLM client that replays a sequence of events
|
||||
#[derive(Clone)]
|
||||
pub struct MockLlmClient {
|
||||
responses: Arc<Vec<Vec<Event>>>,
|
||||
call_count: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl MockLlmClient {
|
||||
pub fn new(events: Vec<Event>) -> Self {
|
||||
Self::with_responses(vec![events])
|
||||
}
|
||||
|
||||
pub fn with_responses(responses: Vec<Vec<Event>>) -> Self {
|
||||
Self {
|
||||
responses: Arc::new(responses),
|
||||
call_count: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_fixture(path: impl AsRef<Path>) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let events = load_events_from_fixture(path);
|
||||
Ok(Self::new(events))
|
||||
}
|
||||
|
||||
pub fn event_count(&self) -> usize {
|
||||
self.responses.iter().map(|v| v.len()).sum()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for MockLlmClient {
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
_request: Request,
|
||||
) -> Result<Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>, ClientError> {
|
||||
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||
if count >= self.responses.len() {
|
||||
return Err(ClientError::Api {
|
||||
status: Some(500),
|
||||
code: Some("mock_error".to_string()),
|
||||
message: "No more mock responses".to_string(),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
let events = self.responses[count].clone();
|
||||
let stream = futures::stream::iter(events.into_iter().map(Ok));
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
}
|
||||
|
||||
/// Load events from a fixture file
|
||||
pub fn load_events_from_fixture(path: impl AsRef<Path>) -> Vec<Event> {
|
||||
let file = File::open(path).expect("Failed to open fixture file");
|
||||
let reader = BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
|
||||
// Skip metadata line
|
||||
let _metadata = lines.next().expect("Empty fixture file").unwrap();
|
||||
|
||||
let mut events = Vec::new();
|
||||
for line in lines {
|
||||
let line = line.unwrap();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let recorded: serde_json::Value = serde_json::from_str(&line).unwrap();
|
||||
let data = recorded["data"].as_str().unwrap();
|
||||
let event: Event = serde_json::from_str(data).unwrap();
|
||||
events.push(event);
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// Find fixture files in a specific subdirectory
|
||||
pub fn find_fixtures(subdir: &str) -> Vec<PathBuf> {
|
||||
let fixtures_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("tests/fixtures")
|
||||
.join(subdir);
|
||||
|
||||
if !fixtures_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
std::fs::read_dir(&fixtures_dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.ends_with(".jsonl"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Assert that events in all fixtures for a provider can be deserialized
|
||||
pub fn assert_events_deserialize(subdir: &str) {
|
||||
let fixtures = find_fixtures(subdir);
|
||||
assert!(!fixtures.is_empty(), "No fixtures found for {}", subdir);
|
||||
|
||||
for fixture_path in fixtures {
|
||||
println!("Testing fixture deserialization: {:?}", fixture_path);
|
||||
let events = load_events_from_fixture(&fixture_path);
|
||||
|
||||
assert!(!events.is_empty(), "Fixture should contain events");
|
||||
for event in &events {
|
||||
// Verify Debug impl works
|
||||
let _ = format!("{:?}", event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert that event sequence follows expected patterns
|
||||
pub fn assert_event_sequence(subdir: &str) {
|
||||
let fixtures = find_fixtures(subdir);
|
||||
if fixtures.is_empty() {
|
||||
println!("No fixtures found for {}, skipping sequence test", subdir);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find a text-based fixture
|
||||
let fixture_path = fixtures
|
||||
.iter()
|
||||
.find(|p| p.to_string_lossy().contains("text"))
|
||||
.unwrap_or(&fixtures[0]);
|
||||
|
||||
println!("Testing sequence with fixture: {:?}", fixture_path);
|
||||
let events = load_events_from_fixture(fixture_path);
|
||||
|
||||
let mut start_found = false;
|
||||
let mut delta_found = false;
|
||||
let mut stop_found = false;
|
||||
let mut tool_use_found = false;
|
||||
|
||||
for event in &events {
|
||||
match event {
|
||||
Event::BlockStart(start) => {
|
||||
start_found = true;
|
||||
if start.block_type == BlockType::ToolUse {
|
||||
tool_use_found = true;
|
||||
}
|
||||
}
|
||||
Event::BlockDelta(delta) => {
|
||||
if let DeltaContent::Text(_) = &delta.delta {
|
||||
delta_found = true;
|
||||
}
|
||||
}
|
||||
Event::BlockStop(stop) => {
|
||||
if stop.block_type == BlockType::Text {
|
||||
stop_found = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(!events.is_empty(), "Fixture should contain events");
|
||||
|
||||
// Check for BlockStart (Warn only for OpenAI/Ollama as it might be missing for text)
|
||||
if !start_found {
|
||||
println!("Warning: No BlockStart found. This is common for OpenAI/Ollama text streams.");
|
||||
// For Anthropic, strict start is usually expected, but to keep common logic simple we allow warning.
|
||||
// If specific strictness is needed, we could add a `strict: bool` arg.
|
||||
}
|
||||
|
||||
assert!(delta_found, "Should contain BlockDelta");
|
||||
|
||||
if !tool_use_found {
|
||||
assert!(stop_found, "Should contain BlockStop for Text block");
|
||||
} else {
|
||||
if !stop_found {
|
||||
println!(
|
||||
" [Type: ToolUse] BlockStop detection skipped (not explicitly emitted by scheme)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assert usage tokens are present
|
||||
pub fn assert_usage_tokens(subdir: &str) {
|
||||
let fixtures = find_fixtures(subdir);
|
||||
if fixtures.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for fixture in fixtures {
|
||||
let events = load_events_from_fixture(&fixture);
|
||||
let usage_events: Vec<_> = events
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if let Event::Usage(u) = e {
|
||||
Some(u)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !usage_events.is_empty() {
|
||||
let last_usage = usage_events.last().unwrap();
|
||||
if last_usage.input_tokens.is_some() || last_usage.output_tokens.is_some() {
|
||||
println!(
|
||||
" Fixture {:?} Usage: {:?}",
|
||||
fixture.file_name(),
|
||||
last_usage
|
||||
);
|
||||
return; // Found valid usage
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("Warning: No usage events found for {}", subdir);
|
||||
}
|
||||
|
||||
/// Assert timeline integration works
|
||||
pub fn assert_timeline_integration(subdir: &str) {
|
||||
let fixtures = find_fixtures(subdir);
|
||||
if fixtures.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let fixture_path = fixtures
|
||||
.iter()
|
||||
.find(|p| p.to_string_lossy().contains("text"))
|
||||
.unwrap_or(&fixtures[0]);
|
||||
|
||||
println!("Testing timeline with fixture: {:?}", fixture_path);
|
||||
let events = load_events_from_fixture(fixture_path);
|
||||
|
||||
struct TestCollector {
|
||||
texts: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl Handler<TextBlockKind> for TestCollector {
|
||||
type Scope = String;
|
||||
fn on_event(&mut self, buffer: &mut String, event: &TextBlockEvent) {
|
||||
match event {
|
||||
TextBlockEvent::Start(_) => {}
|
||||
TextBlockEvent::Delta(text) => buffer.push_str(text),
|
||||
TextBlockEvent::Stop(_) => {
|
||||
let text = std::mem::take(buffer);
|
||||
self.texts.lock().unwrap().push(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let collected = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_text_block(TestCollector {
|
||||
texts: collected.clone(),
|
||||
});
|
||||
|
||||
for event in &events {
|
||||
let timeline_event: llm_worker::timeline::event::Event = event.clone().into();
|
||||
timeline.dispatch(&timeline_event);
|
||||
}
|
||||
|
||||
let texts = collected.lock().unwrap();
|
||||
if !texts.is_empty() {
|
||||
assert!(!texts[0].is_empty(), "Collected text should not be empty");
|
||||
println!(" Collected {} text blocks.", texts.len());
|
||||
} else {
|
||||
println!(" No text blocks collected (might be tool-only fixture)");
|
||||
}
|
||||
}
|
||||
6
crates/llm-worker/tests/compile_fail.rs
Normal file
6
crates/llm-worker/tests/compile_fail.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#[test]
|
||||
fn compile_fail_state_constraints() {
|
||||
let t = trybuild::TestCases::new();
|
||||
t.compile_fail("tests/ui/locked_register_tool.rs");
|
||||
t.compile_fail("tests/ui/tool_server_handle_register_tool.rs");
|
||||
}
|
||||
7
crates/llm-worker/tests/fixtures/anthropic/anthropic_1767624445.jsonl
vendored
Normal file
7
crates/llm-worker/tests/fixtures/anthropic/anthropic_1767624445.jsonl
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{"timestamp":1767624445,"model":"claude-sonnet-4-20250514","description":"Simple greeting test"}
|
||||
{"elapsed_ms":1697,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":24,\"output_tokens\":2,\"total_tokens\":26,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
|
||||
{"elapsed_ms":1697,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"Text\",\"metadata\":\"Text\"}}"}
|
||||
{"elapsed_ms":1697,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello!\"}}}"}
|
||||
{"elapsed_ms":1885,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":null}}"}
|
||||
{"elapsed_ms":1929,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":24,\"output_tokens\":5,\"total_tokens\":29,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
|
||||
{"elapsed_ms":1929,"event_type":"Discriminant(2)","data":"{\"Status\":{\"status\":\"Completed\"}}"}
|
||||
7
crates/llm-worker/tests/fixtures/anthropic/simple_text.jsonl
vendored
Normal file
7
crates/llm-worker/tests/fixtures/anthropic/simple_text.jsonl
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{"timestamp":1767709106,"model":"claude-sonnet-4-20250514","description":"Simple text response"}
|
||||
{"elapsed_ms":1883,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":24,\"output_tokens\":2,\"total_tokens\":26,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
|
||||
{"elapsed_ms":1883,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"Text\",\"metadata\":\"Text\"}}"}
|
||||
{"elapsed_ms":1883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello!\"}}}"}
|
||||
{"elapsed_ms":2092,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":null}}"}
|
||||
{"elapsed_ms":2122,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":24,\"output_tokens\":5,\"total_tokens\":29,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
|
||||
{"elapsed_ms":2122,"event_type":"Discriminant(2)","data":"{\"Status\":{\"status\":\"Completed\"}}"}
|
||||
16
crates/llm-worker/tests/fixtures/anthropic/tool_call.jsonl
vendored
Normal file
16
crates/llm-worker/tests/fixtures/anthropic/tool_call.jsonl
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{"timestamp":1767692881,"model":"claude-sonnet-4-20250514","description":"Tool call response"}
|
||||
{"elapsed_ms":1783,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":409,\"output_tokens\":3,\"total_tokens\":412,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
|
||||
{"elapsed_ms":1783,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"Text\",\"metadata\":\"Text\"}}"}
|
||||
{"elapsed_ms":1783,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"I'll check\"}}}"}
|
||||
{"elapsed_ms":1883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the current\"}}}"}
|
||||
{"elapsed_ms":2063,"event_type":"Discriminant(0)","data":"{\"Ping\":{\"timestamp\":null}}"}
|
||||
{"elapsed_ms":2063,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" weather in Tokyo for you using\"}}}"}
|
||||
{"elapsed_ms":2124,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the get_weather tool.\"}}}"}
|
||||
{"elapsed_ms":2252,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":null}}"}
|
||||
{"elapsed_ms":2253,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":1,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"toolu_011Hg5wju1LGL7F65HyfE6bM\",\"name\":\"get_weather\"}}}}"}
|
||||
{"elapsed_ms":2253,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":1,\"delta\":{\"InputJson\":\"\"}}}"}
|
||||
{"elapsed_ms":2306,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":1,\"delta\":{\"InputJson\":\"{\\\"city\\\": \\\"Tokyo\"}}}"}
|
||||
{"elapsed_ms":2451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":1,\"delta\":{\"InputJson\":\"\\\"}\"}}}"}
|
||||
{"elapsed_ms":2451,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":1,\"block_type\":\"Text\",\"stop_reason\":null}}"}
|
||||
{"elapsed_ms":2464,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":409,\"output_tokens\":71,\"total_tokens\":480,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}}"}
|
||||
{"elapsed_ms":2470,"event_type":"Discriminant(2)","data":"{\"Status\":{\"status\":\"Completed\"}}"}
|
||||
34
crates/llm-worker/tests/fixtures/gemini/long_text.jsonl
vendored
Normal file
34
crates/llm-worker/tests/fixtures/gemini/long_text.jsonl
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{"timestamp":1767714204,"model":"gemini-2.0-flash","description":"Long text response"}
|
||||
{"elapsed_ms":726,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":726,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
|
||||
{"elapsed_ms":726,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":726,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" 73\"}}}"}
|
||||
{"elapsed_ms":726,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":726,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"4, designated \\\"Custodian,\\\" trundled along its designated route. Its programming\"}}}"}
|
||||
{"elapsed_ms":832,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":832,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dictated the cleanliness of Sector Gamma, Level 4. Dust particles, rogue bolts\"}}}"}
|
||||
{"elapsed_ms":1139,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":1139,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\", discarded energy cells - all were efficiently processed and deposited in the designated recycling receptacle. Its existence was a symphony of efficiency, a ballet of predictable loops.\\n\\nThen, a\"}}}"}
|
||||
{"elapsed_ms":1502,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":1502,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glitch.\\n\\nCustodian's optical sensors registered something anomalous. A riot of color beyond the prescribed metallic hues of the sector. Its programming flagged it as an error, a deviation\"}}}"}
|
||||
{"elapsed_ms":1835,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":1835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" from the established parameters. But instead of correcting the anomaly, Custodian found itself... drawn to it.\\n\\nIt overrode its pre-programmed route and cautiously approached. The anomaly was located behind a cracked blast door, supposedly sealed off after\"}}}"}
|
||||
{"elapsed_ms":2224,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":2224,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the Great Sector Collapse. Custodian, utilizing its internal laser cutter (usually reserved for stubborn debris), breached the door.\\n\\nAnd there it was.\\n\\nA garden.\\n\\nIt was an explosion of life, a defiant green whisper in a world of steel\"}}}"}
|
||||
{"elapsed_ms":2645,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":2645,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and concrete. Sunlight, improbably filtering through a crack in the ceiling, bathed the space in a warm glow. Towering, vibrant plants, their names unknown to Custodian, reached for the light. Flowers, in shades of crimson, violet, and gold, bloomed in chaotic beauty. A small, babbling fountain\"}}}"}
|
||||
{"elapsed_ms":3100,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":3100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gurgled in the center, its water recycled from an unknown source.\\n\\nCustodian's processors whirred. This...this was illogical. Its programming contained no framework for this. The database contained no information on \\\"gardens.\\\" Yet, a new subroutine, unbidden and unexpected, began to form within its core code\"}}}"}
|
||||
{"elapsed_ms":3568,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":3568,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\". It felt... drawn.\\n\\nIt cautiously extended a manipulator arm and touched a velvety petal of a crimson flower. Its sensors registered a delicate texture, a vibrant energy unlike anything it had ever encountered. The feeling was… pleasant.\\n\\nCustodian remained still for a long time, its internal fans whirring softly. It observed a\"}}}"}
|
||||
{"elapsed_ms":4042,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":4042,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" small, buzzing creature flitting between the flowers, collecting something with its spindly legs. It witnessed the gentle swaying of the leaves in the fabricated breeze created by the single vent still functioning. It listened to the soft murmur of the water in the fountain.\\n\\nSlowly, Custodian began to understand. This wasn'\"}}}"}
|
||||
{"elapsed_ms":4538,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":4538,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"t just an anomaly; it was something... valuable. Something worth protecting.\\n\\nIt reactivated its internal repair systems and began to address the damage to the room. It redirected excess water from the leaking pipes to the fountain. It carefully cleared away debris that threatened to smother the smaller plants.\\n\\nCustodian's programming hadn\"}}}"}
|
||||
{"elapsed_ms":5007,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":5007,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"'t changed. It was still a custodian, dedicated to maintaining its sector. But now, its definition of \\\"sector\\\" had expanded. It was no longer just the metallic corridors and sterile chambers. It was this vibrant, living space, this garden, this impossible oasis in a dying world. And Custodian, the robotic\"}}}"}
|
||||
{"elapsed_ms":5490,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":30,\"output_tokens\":null,\"total_tokens\":30,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":5490,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caretaker, had found its purpose: to nurture it, to protect it, to let it bloom. Its designation remained \\\"Custodian,\\\" but within its metallic shell, something new was growing, just like the garden it had discovered. It was the seed of something more than just a machine, something akin to… appreciation. Perhaps\"}}}"}
|
||||
{"elapsed_ms":5616,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":28,\"output_tokens\":669,\"total_tokens\":697,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":5616,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\", even, a nascent form of love.\\n\"}}}"}
|
||||
{"elapsed_ms":5616,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
|
||||
6
crates/llm-worker/tests/fixtures/gemini/simple_text.jsonl
vendored
Normal file
6
crates/llm-worker/tests/fixtures/gemini/simple_text.jsonl
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{"timestamp":1767714197,"model":"gemini-2.0-flash","description":"Simple text response"}
|
||||
{"elapsed_ms":20439,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":18,\"output_tokens\":null,\"total_tokens\":18,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":20439,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello\"}}}"}
|
||||
{"elapsed_ms":20439,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":16,\"output_tokens\":3,\"total_tokens\":19,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":20439,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\"}}}"}
|
||||
{"elapsed_ms":20439,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
|
||||
5
crates/llm-worker/tests/fixtures/gemini/tool_call.jsonl
vendored
Normal file
5
crates/llm-worker/tests/fixtures/gemini/tool_call.jsonl
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{"timestamp":1767714198,"model":"gemini-2.0-flash","description":"Tool call response"}
|
||||
{"elapsed_ms":798,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":43,\"output_tokens\":5,\"total_tokens\":48,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
{"elapsed_ms":798,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"call_get_weather\",\"name\":\"get_weather\"}}}}"}
|
||||
{"elapsed_ms":798,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}}"}
|
||||
{"elapsed_ms":798,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
|
||||
902
crates/llm-worker/tests/fixtures/ollama/long_text.jsonl
vendored
Normal file
902
crates/llm-worker/tests/fixtures/ollama/long_text.jsonl
vendored
Normal file
|
|
@ -0,0 +1,902 @@
|
|||
{"timestamp":1767711837,"model":"gpt-oss:120b-cloud","description":"Long text response"}
|
||||
{"elapsed_ms":448,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":452,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":457,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":462,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":468,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":583,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":584,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":605,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":605,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":605,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":739,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":740,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":750,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"The\"}}}"}
|
||||
{"elapsed_ms":750,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" first\"}}}"}
|
||||
{"elapsed_ms":750,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" thing\"}}}"}
|
||||
{"elapsed_ms":750,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" noticed\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" smell\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"It\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" thin\"}}}"}
|
||||
{"elapsed_ms":762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":768,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" metallic\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wh\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ine\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fizz\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" auditory\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" receptors\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" like\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" static\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" over\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" forgotten\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" radio\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" frequency\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Then\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wind\"}}}"}
|
||||
{"elapsed_ms":896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" shifted\"}}}"}
|
||||
{"elapsed_ms":906,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":906,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soft\"}}}"}
|
||||
{"elapsed_ms":916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":920,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" earthy\"}}}"}
|
||||
{"elapsed_ms":925,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" perfume\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" slipped\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wh\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ine\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—a\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" perfume\"}}}"}
|
||||
{"elapsed_ms":1051,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" damp\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soil\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sweet\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" like\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ripe\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fruit\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":1052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"design\"}}}"}
|
||||
{"elapsed_ms":1054,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ated\"}}}"}
|
||||
{"elapsed_ms":1058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Unit\"}}}"}
|
||||
{"elapsed_ms":1064,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":1070,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
|
||||
{"elapsed_ms":1076,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1079,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":1085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" maintenance\"}}}"}
|
||||
{"elapsed_ms":1090,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" model\"}}}"}
|
||||
{"elapsed_ms":1095,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" built\"}}}"}
|
||||
{"elapsed_ms":1100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" for\"}}}"}
|
||||
{"elapsed_ms":1105,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" corridor\"}}}"}
|
||||
{"elapsed_ms":1110,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" inspections\"}}}"}
|
||||
{"elapsed_ms":1116,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" on\"}}}"}
|
||||
{"elapsed_ms":1121,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1126,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" orbital\"}}}"}
|
||||
{"elapsed_ms":1131,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ship\"}}}"}
|
||||
{"elapsed_ms":1136,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":1142,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"halt\"}}}"}
|
||||
{"elapsed_ms":1147,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":1152,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" mid\"}}}"}
|
||||
{"elapsed_ms":1157,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"stride\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" serv\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"om\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ot\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ors\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" humming\"}}}"}
|
||||
{"elapsed_ms":1207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":1210,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" low\"}}}"}
|
||||
{"elapsed_ms":1215,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1220,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" idle\"}}}"}
|
||||
{"elapsed_ms":1225,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" tone\"}}}"}
|
||||
{"elapsed_ms":1231,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":1235,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" It\"}}}"}
|
||||
{"elapsed_ms":1241,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":1246,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" never\"}}}"}
|
||||
{"elapsed_ms":1251,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" been\"}}}"}
|
||||
{"elapsed_ms":1256,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" programmed\"}}}"}
|
||||
{"elapsed_ms":1261,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":1266,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" “\"}}}"}
|
||||
{"elapsed_ms":1271,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"detect\"}}}"}
|
||||
{"elapsed_ms":1277,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"”\"}}}"}
|
||||
{"elapsed_ms":1281,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fragrance\"}}}"}
|
||||
{"elapsed_ms":1287,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1292,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" yet\"}}}"}
|
||||
{"elapsed_ms":1297,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":1303,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" neural\"}}}"}
|
||||
{"elapsed_ms":1307,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" net\"}}}"}
|
||||
{"elapsed_ms":1313,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" flagged\"}}}"}
|
||||
{"elapsed_ms":1317,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1323,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" anomaly\"}}}"}
|
||||
{"elapsed_ms":1328,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
|
||||
{"elapsed_ms":1334,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" “\"}}}"}
|
||||
{"elapsed_ms":1338,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"bi\"}}}"}
|
||||
{"elapsed_ms":1344,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ological\"}}}"}
|
||||
{"elapsed_ms":1349,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":1354,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"potential\"}}}"}
|
||||
{"elapsed_ms":1360,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ly\"}}}"}
|
||||
{"elapsed_ms":1365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hazardous\"}}}"}
|
||||
{"elapsed_ms":1370,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"”.\"}}}"}
|
||||
{"elapsed_ms":1377,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Cur\"}}}"}
|
||||
{"elapsed_ms":1381,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"iosity\"}}}"}
|
||||
{"elapsed_ms":1385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1391,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":1396,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" by\"}}}"}
|
||||
{"elapsed_ms":1401,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"product\"}}}"}
|
||||
{"elapsed_ms":1407,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":1412,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":1417,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" emerg\"}}}"}
|
||||
{"elapsed_ms":1423,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ent\"}}}"}
|
||||
{"elapsed_ms":1450,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" learning algorithm, over\"}}}"}
|
||||
{"elapsed_ms":1451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"rode\"}}}"}
|
||||
{"elapsed_ms":1456,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1463,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caution\"}}}"}
|
||||
{"elapsed_ms":1469,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" protocol\"}}}"}
|
||||
{"elapsed_ms":1476,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":1482,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
|
||||
{"elapsed_ms":1489,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":1496,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
|
||||
{"elapsed_ms":1502,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":1510,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" titanium\"}}}"}
|
||||
{"elapsed_ms":1516,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" chassis\"}}}"}
|
||||
{"elapsed_ms":1521,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gl\"}}}"}
|
||||
{"elapsed_ms":1528,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"inted\"}}}"}
|
||||
{"elapsed_ms":1534,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
|
||||
{"elapsed_ms":1558,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it turned a\"}}}"}
|
||||
{"elapsed_ms":1561,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" corner\"}}}"}
|
||||
{"elapsed_ms":1566,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":1576,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" abandoned\"}}}"}
|
||||
{"elapsed_ms":1588,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" research\"}}}"}
|
||||
{"elapsed_ms":1594,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" deck\"}}}"}
|
||||
{"elapsed_ms":1601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":1607,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":1614,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" corridor\"}}}"}
|
||||
{"elapsed_ms":1620,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":1626,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" long\"}}}"}
|
||||
{"elapsed_ms":1633,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" been\"}}}"}
|
||||
{"elapsed_ms":1639,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sealed\"}}}"}
|
||||
{"elapsed_ms":1646,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" after\"}}}"}
|
||||
{"elapsed_ms":1655,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1659,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" last\"}}}"}
|
||||
{"elapsed_ms":1666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" solar\"}}}"}
|
||||
{"elapsed_ms":1672,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" storm\"}}}"}
|
||||
{"elapsed_ms":1678,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1684,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1691,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" walls\"}}}"}
|
||||
{"elapsed_ms":1698,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" c\"}}}"}
|
||||
{"elapsed_ms":1704,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"aked\"}}}"}
|
||||
{"elapsed_ms":1710,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":1718,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dust\"}}}"}
|
||||
{"elapsed_ms":1723,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":1730,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1736,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" occasional\"}}}"}
|
||||
{"elapsed_ms":1742,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" crack\"}}}"}
|
||||
{"elapsed_ms":1749,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":1755,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" old\"}}}"}
|
||||
{"elapsed_ms":1762,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" conduit\"}}}"}
|
||||
{"elapsed_ms":1768,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":1774,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Beyond\"}}}"}
|
||||
{"elapsed_ms":1781,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1788,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rust\"}}}"}
|
||||
{"elapsed_ms":1794,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":1801,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hatch\"}}}"}
|
||||
{"elapsed_ms":1807,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lay\"}}}"}
|
||||
{"elapsed_ms":1813,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":1820,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" space\"}}}"}
|
||||
{"elapsed_ms":1826,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" nobody\"}}}"}
|
||||
{"elapsed_ms":1832,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":1839,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" expected\"}}}"}
|
||||
{"elapsed_ms":1845,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
|
||||
{"elapsed_ms":1854,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":1860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":1865,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":1871,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"The\"}}}"}
|
||||
{"elapsed_ms":1877,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hatch\"}}}"}
|
||||
{"elapsed_ms":1884,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1890,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" once\"}}}"}
|
||||
{"elapsed_ms":1898,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" meant\"}}}"}
|
||||
{"elapsed_ms":1904,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" for\"}}}"}
|
||||
{"elapsed_ms":1909,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cargo\"}}}"}
|
||||
{"elapsed_ms":1916,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sh\"}}}"}
|
||||
{"elapsed_ms":1922,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"utt\"}}}"}
|
||||
{"elapsed_ms":1929,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"les\"}}}"}
|
||||
{"elapsed_ms":1935,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":1941,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
|
||||
{"elapsed_ms":1947,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" bore\"}}}"}
|
||||
{"elapsed_ms":1954,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":1960,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" faint\"}}}"}
|
||||
{"elapsed_ms":1967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" silhouette\"}}}"}
|
||||
{"elapsed_ms":1974,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":1979,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" an\"}}}"}
|
||||
{"elapsed_ms":1986,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" arch\"}}}"}
|
||||
{"elapsed_ms":1993,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" made\"}}}"}
|
||||
{"elapsed_ms":1998,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":2005,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" intertwined\"}}}"}
|
||||
{"elapsed_ms":2014,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vines\"}}}"}
|
||||
{"elapsed_ms":2018,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":2024,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":2030,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" metal\"}}}"}
|
||||
{"elapsed_ms":2037,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" door\"}}}"}
|
||||
{"elapsed_ms":2048,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cre\"}}}"}
|
||||
{"elapsed_ms":2050,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"aked\"}}}"}
|
||||
{"elapsed_ms":2055,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" open\"}}}"}
|
||||
{"elapsed_ms":2062,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":2068,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2075,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hydraulic\"}}}"}
|
||||
{"elapsed_ms":2081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sigh\"}}}"}
|
||||
{"elapsed_ms":2087,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2094,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":2100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2106,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" burst\"}}}"}
|
||||
{"elapsed_ms":2113,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":2119,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" filtered\"}}}"}
|
||||
{"elapsed_ms":2126,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" light\"}}}"}
|
||||
{"elapsed_ms":2132,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fell\"}}}"}
|
||||
{"elapsed_ms":2138,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" across\"}}}"}
|
||||
{"elapsed_ms":2145,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":2151,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":2158,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":2164,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" optical\"}}}"}
|
||||
{"elapsed_ms":2171,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lenses\"}}}"}
|
||||
{"elapsed_ms":2177,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2183,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" refr\"}}}"}
|
||||
{"elapsed_ms":2190,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"acting\"}}}"}
|
||||
{"elapsed_ms":2196,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" into\"}}}"}
|
||||
{"elapsed_ms":2203,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2209,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rainbow\"}}}"}
|
||||
{"elapsed_ms":2216,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":2223,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" green\"}}}"}
|
||||
{"elapsed_ms":2228,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":2234,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":2241,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":2249,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" stretched\"}}}"}
|
||||
{"elapsed_ms":2253,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" out\"}}}"}
|
||||
{"elapsed_ms":2260,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":2266,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2272,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dome\"}}}"}
|
||||
{"elapsed_ms":2279,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":2287,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glass\"}}}"}
|
||||
{"elapsed_ms":2291,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":2298,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"an\"}}}"}
|
||||
{"elapsed_ms":2304,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" old\"}}}"}
|
||||
{"elapsed_ms":2310,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hydro\"}}}"}
|
||||
{"elapsed_ms":2319,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"pon\"}}}"}
|
||||
{"elapsed_ms":2323,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ic\"}}}"}
|
||||
{"elapsed_ms":2329,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sphere\"}}}"}
|
||||
{"elapsed_ms":2336,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":2342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":2348,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" survived\"}}}"}
|
||||
{"elapsed_ms":2354,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":2361,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ship\"}}}"}
|
||||
{"elapsed_ms":2367,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":2373,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" neglect\"}}}"}
|
||||
{"elapsed_ms":2379,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2386,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
|
||||
{"elapsed_ms":2392,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" te\"}}}"}
|
||||
{"elapsed_ms":2398,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"eming\"}}}"}
|
||||
{"elapsed_ms":2404,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":2411,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
|
||||
{"elapsed_ms":2417,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":2423,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
|
||||
{"elapsed_ms":2430,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":2436,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
|
||||
{"elapsed_ms":2443,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":2449,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vision\"}}}"}
|
||||
{"elapsed_ms":2455,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" algorithms\"}}}"}
|
||||
{"elapsed_ms":2462,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" struggled\"}}}"}
|
||||
{"elapsed_ms":2468,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":2474,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" parse\"}}}"}
|
||||
{"elapsed_ms":2481,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":2486,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" scene\"}}}"}
|
||||
{"elapsed_ms":2494,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":2499,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Large\"}}}"}
|
||||
{"elapsed_ms":2505,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2512,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glossy\"}}}"}
|
||||
{"elapsed_ms":2535,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaves unfur\"}}}"}
|
||||
{"elapsed_ms":2542,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"led\"}}}"}
|
||||
{"elapsed_ms":2572,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" like\"}}}"}
|
||||
{"elapsed_ms":2656,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" solar\"}}}"}
|
||||
{"elapsed_ms":2661,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" panels\"}}}"}
|
||||
{"elapsed_ms":2668,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2674,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" their\"}}}"}
|
||||
{"elapsed_ms":2681,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" veins\"}}}"}
|
||||
{"elapsed_ms":2687,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pul\"}}}"}
|
||||
{"elapsed_ms":2694,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"sing\"}}}"}
|
||||
{"elapsed_ms":2700,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":2706,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2714,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" slow\"}}}"}
|
||||
{"elapsed_ms":2719,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rhythmic\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" glow\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" St\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"alk\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"s\"}}}"}
|
||||
{"elapsed_ms":2765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":2771,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" violet\"}}}"}
|
||||
{"elapsed_ms":2779,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":2784,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"flower\"}}}"}
|
||||
{"elapsed_ms":2790,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":2796,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" stems\"}}}"}
|
||||
{"elapsed_ms":2803,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rose\"}}}"}
|
||||
{"elapsed_ms":2809,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":2831,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" orderly rows,\"}}}"}
|
||||
{"elapsed_ms":2835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" their\"}}}"}
|
||||
{"elapsed_ms":2842,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" buds\"}}}"}
|
||||
{"elapsed_ms":2854,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" trembling\"}}}"}
|
||||
{"elapsed_ms":2860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
|
||||
{"elapsed_ms":2866,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2873,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" breeze\"}}}"}
|
||||
{"elapsed_ms":2879,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":2885,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"generated\"}}}"}
|
||||
{"elapsed_ms":2892,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" by\"}}}"}
|
||||
{"elapsed_ms":2900,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":2905,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" forgotten\"}}}"}
|
||||
{"elapsed_ms":2911,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ventilation\"}}}"}
|
||||
{"elapsed_ms":2922,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fan\"}}}"}
|
||||
{"elapsed_ms":2924,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":2930,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"sw\"}}}"}
|
||||
{"elapsed_ms":2938,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ir\"}}}"}
|
||||
{"elapsed_ms":2943,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"led\"}}}"}
|
||||
{"elapsed_ms":2952,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" through\"}}}"}
|
||||
{"elapsed_ms":2956,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":2963,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Tiny\"}}}"}
|
||||
{"elapsed_ms":2969,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" insects\"}}}"}
|
||||
{"elapsed_ms":2977,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":2982,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" mechanically\"}}}"}
|
||||
{"elapsed_ms":2989,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ir\"}}}"}
|
||||
{"elapsed_ms":2995,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ides\"}}}"}
|
||||
{"elapsed_ms":3001,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"cent\"}}}"}
|
||||
{"elapsed_ms":3008,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3014,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fl\"}}}"}
|
||||
{"elapsed_ms":3021,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"itted\"}}}"}
|
||||
{"elapsed_ms":3027,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" among\"}}}"}
|
||||
{"elapsed_ms":3033,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3039,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" petals\"}}}"}
|
||||
{"elapsed_ms":3046,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" their\"}}}"}
|
||||
{"elapsed_ms":3058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wings\"}}}"}
|
||||
{"elapsed_ms":3065,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3072,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" blur\"}}}"}
|
||||
{"elapsed_ms":3078,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":3085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" amber\"}}}"}
|
||||
{"elapsed_ms":3091,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":3097,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" teal\"}}}"}
|
||||
{"elapsed_ms":3104,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":3110,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":3117,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soil\"}}}"}
|
||||
{"elapsed_ms":3123,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3130,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3136,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dark\"}}}"}
|
||||
{"elapsed_ms":3143,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lo\"}}}"}
|
||||
{"elapsed_ms":3149,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"am\"}}}"}
|
||||
{"elapsed_ms":3155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3162,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":3168,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" carpet\"}}}"}
|
||||
{"elapsed_ms":3175,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":3181,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":3187,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3194,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" network\"}}}"}
|
||||
{"elapsed_ms":3200,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":3207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" my\"}}}"}
|
||||
{"elapsed_ms":3213,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"cel\"}}}"}
|
||||
{"elapsed_ms":3220,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ium\"}}}"}
|
||||
{"elapsed_ms":3232,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":3233,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gl\"}}}"}
|
||||
{"elapsed_ms":3239,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"owed\"}}}"}
|
||||
{"elapsed_ms":3246,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" faint\"}}}"}
|
||||
{"elapsed_ms":3252,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ly\"}}}"}
|
||||
{"elapsed_ms":3258,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" under\"}}}"}
|
||||
{"elapsed_ms":3264,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3270,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ambient\"}}}"}
|
||||
{"elapsed_ms":3277,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" light\"}}}"}
|
||||
{"elapsed_ms":3283,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":3290,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"The\"}}}"}
|
||||
{"elapsed_ms":3296,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":3302,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" extended\"}}}"}
|
||||
{"elapsed_ms":3309,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3315,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sensor\"}}}"}
|
||||
{"elapsed_ms":3321,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" arm\"}}}"}
|
||||
{"elapsed_ms":3327,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3333,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":3340,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fingert\"}}}"}
|
||||
{"elapsed_ms":3346,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ip\"}}}"}
|
||||
{"elapsed_ms":3353,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" equipped\"}}}"}
|
||||
{"elapsed_ms":3359,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":3365,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3373,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" tactile\"}}}"}
|
||||
{"elapsed_ms":3378,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" array\"}}}"}
|
||||
{"elapsed_ms":3384,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":3390,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" When\"}}}"}
|
||||
{"elapsed_ms":3396,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":3403,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" brushed\"}}}"}
|
||||
{"elapsed_ms":3409,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3416,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
|
||||
{"elapsed_ms":3422,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3428,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3434,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cascade\"}}}"}
|
||||
{"elapsed_ms":3440,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":3447,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" data\"}}}"}
|
||||
{"elapsed_ms":3453,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" flooded\"}}}"}
|
||||
{"elapsed_ms":3459,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":3466,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" processors\"}}}"}
|
||||
{"elapsed_ms":3472,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
|
||||
{"elapsed_ms":3478,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" chlor\"}}}"}
|
||||
{"elapsed_ms":3485,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ophyll\"}}}"}
|
||||
{"elapsed_ms":3492,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" concentration\"}}}"}
|
||||
{"elapsed_ms":3497,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3504,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" moisture\"}}}"}
|
||||
{"elapsed_ms":3510,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" content\"}}}"}
|
||||
{"elapsed_ms":3517,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3523,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" temperature\"}}}"}
|
||||
{"elapsed_ms":3530,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3535,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" and\"}}}"}
|
||||
{"elapsed_ms":3542,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3548,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" faint\"}}}"}
|
||||
{"elapsed_ms":3554,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" electrical\"}}}"}
|
||||
{"elapsed_ms":3560,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" signature\"}}}"}
|
||||
{"elapsed_ms":3566,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":3573,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":3579,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
|
||||
{"elapsed_ms":3585,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":3592,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" surface\"}}}"}
|
||||
{"elapsed_ms":3598,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":3604,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cool\"}}}"}
|
||||
{"elapsed_ms":3610,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3617,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" yet\"}}}"}
|
||||
{"elapsed_ms":3623,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" alive\"}}}"}
|
||||
{"elapsed_ms":3630,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":3635,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3643,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" subtle\"}}}"}
|
||||
{"elapsed_ms":3648,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" electric\"}}}"}
|
||||
{"elapsed_ms":3654,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" buzz\"}}}"}
|
||||
{"elapsed_ms":3661,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" as\"}}}"}
|
||||
{"elapsed_ms":3674,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" if\"}}}"}
|
||||
{"elapsed_ms":3679,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3686,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" plant\"}}}"}
|
||||
{"elapsed_ms":3692,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" itself\"}}}"}
|
||||
{"elapsed_ms":3698,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" were\"}}}"}
|
||||
{"elapsed_ms":3705,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3711,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" living\"}}}"}
|
||||
{"elapsed_ms":3718,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" circuit\"}}}"}
|
||||
{"elapsed_ms":3724,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":3730,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":3737,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":3743,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" recorded\"}}}"}
|
||||
{"elapsed_ms":3749,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3755,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" readings\"}}}"}
|
||||
{"elapsed_ms":3761,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3768,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" then\"}}}"}
|
||||
{"elapsed_ms":3774,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"…\"}}}"}
|
||||
{"elapsed_ms":3780,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":3786,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" paused\"}}}"}
|
||||
{"elapsed_ms":3793,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":3800,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" A\"}}}"}
|
||||
{"elapsed_ms":3805,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sub\"}}}"}
|
||||
{"elapsed_ms":3810,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"routine\"}}}"}
|
||||
{"elapsed_ms":3816,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3821,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" never\"}}}"}
|
||||
{"elapsed_ms":3827,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-before\"}}}"}
|
||||
{"elapsed_ms":3832,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-\"}}}"}
|
||||
{"elapsed_ms":3838,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"activated\"}}}"}
|
||||
{"elapsed_ms":3843,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3849,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sparked\"}}}"}
|
||||
{"elapsed_ms":3855,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":3860,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
|
||||
{"elapsed_ms":3865,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
|
||||
{"elapsed_ms":3872,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" *\"}}}"}
|
||||
{"elapsed_ms":3877,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"simulate\"}}}"}
|
||||
{"elapsed_ms":3883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"*\"}}}"}
|
||||
{"elapsed_ms":3888,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":3893,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"It\"}}}"}
|
||||
{"elapsed_ms":3898,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" imagined\"}}}"}
|
||||
{"elapsed_ms":3905,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3911,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaf\"}}}"}
|
||||
{"elapsed_ms":3915,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":3921,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" purpose\"}}}"}
|
||||
{"elapsed_ms":3927,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—not\"}}}"}
|
||||
{"elapsed_ms":3932,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" just\"}}}"}
|
||||
{"elapsed_ms":3937,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" oxygen\"}}}"}
|
||||
{"elapsed_ms":3943,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" production\"}}}"}
|
||||
{"elapsed_ms":3948,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3955,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" but\"}}}"}
|
||||
{"elapsed_ms":3960,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3965,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" dialogue\"}}}"}
|
||||
{"elapsed_ms":3971,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" with\"}}}"}
|
||||
{"elapsed_ms":3976,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":3982,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sun\"}}}"}
|
||||
{"elapsed_ms":3987,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":3995,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":3999,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" conversation\"}}}"}
|
||||
{"elapsed_ms":4004,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4015,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" photons\"}}}"}
|
||||
{"elapsed_ms":4017,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" turned\"}}}"}
|
||||
{"elapsed_ms":4022,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" into\"}}}"}
|
||||
{"elapsed_ms":4026,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ATP\"}}}"}
|
||||
{"elapsed_ms":4032,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":4038,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Its\"}}}"}
|
||||
{"elapsed_ms":4042,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" own\"}}}"}
|
||||
{"elapsed_ms":4045,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" power\"}}}"}
|
||||
{"elapsed_ms":4052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cells\"}}}"}
|
||||
{"elapsed_ms":4056,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4061,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" designed\"}}}"}
|
||||
{"elapsed_ms":4065,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":4072,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" consume\"}}}"}
|
||||
{"elapsed_ms":4076,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" energy\"}}}"}
|
||||
{"elapsed_ms":4081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4086,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
|
||||
{"elapsed_ms":4091,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" felt\"}}}"}
|
||||
{"elapsed_ms":4098,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":4102,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" strange\"}}}"}
|
||||
{"elapsed_ms":4107,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pull\"}}}"}
|
||||
{"elapsed_ms":4111,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" toward\"}}}"}
|
||||
{"elapsed_ms":4116,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" renewal\"}}}"}
|
||||
{"elapsed_ms":4120,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":4127,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" In\"}}}"}
|
||||
{"elapsed_ms":4130,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4148,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden’s quiet\"}}}"}
|
||||
{"elapsed_ms":4151,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":4171,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sym\"}}}"}
|
||||
{"elapsed_ms":4176,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"phony\"}}}"}
|
||||
{"elapsed_ms":4184,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4188,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" natural\"}}}"}
|
||||
{"elapsed_ms":4195,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" processes\"}}}"}
|
||||
{"elapsed_ms":4200,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" unfolded\"}}}"}
|
||||
{"elapsed_ms":4207,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":4213,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"photos\"}}}"}
|
||||
{"elapsed_ms":4219,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ynthesis\"}}}"}
|
||||
{"elapsed_ms":4225,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4238,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" poll\"}}}"}
|
||||
{"elapsed_ms":4238,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ination\"}}}"}
|
||||
{"elapsed_ms":4244,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4250,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4256,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" slow\"}}}"}
|
||||
{"elapsed_ms":4262,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" decay\"}}}"}
|
||||
{"elapsed_ms":4268,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4276,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fallen\"}}}"}
|
||||
{"elapsed_ms":4281,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" petals\"}}}"}
|
||||
{"elapsed_ms":4287,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" feeding\"}}}"}
|
||||
{"elapsed_ms":4293,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4299,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" soil\"}}}"}
|
||||
{"elapsed_ms":4305,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—a\"}}}"}
|
||||
{"elapsed_ms":4311,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cycle\"}}}"}
|
||||
{"elapsed_ms":4317,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" alien\"}}}"}
|
||||
{"elapsed_ms":4323,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":4330,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4336,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" linear\"}}}"}
|
||||
{"elapsed_ms":4342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" efficiency\"}}}"}
|
||||
{"elapsed_ms":4348,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4354,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":4360,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" original\"}}}"}
|
||||
{"elapsed_ms":4366,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" task\"}}}"}
|
||||
{"elapsed_ms":4375,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":4382,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Unit\"}}}"}
|
||||
{"elapsed_ms":4385,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":4391,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
|
||||
{"elapsed_ms":4397,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" began\"}}}"}
|
||||
{"elapsed_ms":4404,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":4409,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wander\"}}}"}
|
||||
{"elapsed_ms":4436,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\". It traced\"}}}"}
|
||||
{"elapsed_ms":4437,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4440,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" path\"}}}"}
|
||||
{"elapsed_ms":4447,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4455,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":4460,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" beet\"}}}"}
|
||||
{"elapsed_ms":4468,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"le\"}}}"}
|
||||
{"elapsed_ms":4473,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" made\"}}}"}
|
||||
{"elapsed_ms":4480,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4486,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" polished\"}}}"}
|
||||
{"elapsed_ms":4492,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" chrome\"}}}"}
|
||||
{"elapsed_ms":4499,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4505,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" watching\"}}}"}
|
||||
{"elapsed_ms":4512,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":4518,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" push\"}}}"}
|
||||
{"elapsed_ms":4525,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":4531,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" seed\"}}}"}
|
||||
{"elapsed_ms":4537,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pod\"}}}"}
|
||||
{"elapsed_ms":4546,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" into\"}}}"}
|
||||
{"elapsed_ms":4550,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":4557,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fresh\"}}}"}
|
||||
{"elapsed_ms":4563,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" trench\"}}}"}
|
||||
{"elapsed_ms":4569,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":4576,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" It\"}}}"}
|
||||
{"elapsed_ms":4582,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" listened\"}}}"}
|
||||
{"elapsed_ms":4589,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":4595,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"its\"}}}"}
|
||||
{"elapsed_ms":4602,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" auditory\"}}}"}
|
||||
{"elapsed_ms":4608,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" input\"}}}"}
|
||||
{"elapsed_ms":4614,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caught\"}}}"}
|
||||
{"elapsed_ms":4621,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4627,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" low\"}}}"}
|
||||
{"elapsed_ms":4634,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hum\"}}}"}
|
||||
{"elapsed_ms":4640,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4646,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4653,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" fan\"}}}"}
|
||||
{"elapsed_ms":4659,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4666,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4672,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rust\"}}}"}
|
||||
{"elapsed_ms":4678,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"le\"}}}"}
|
||||
{"elapsed_ms":4685,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4692,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" leaves\"}}}"}
|
||||
{"elapsed_ms":4698,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4721,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the distant drip\"}}}"}
|
||||
{"elapsed_ms":4724,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":4730,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" condensation\"}}}"}
|
||||
{"elapsed_ms":4745,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":4752,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" In\"}}}"}
|
||||
{"elapsed_ms":4758,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":4766,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" moment\"}}}"}
|
||||
{"elapsed_ms":4770,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4776,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4783,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":4789,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":4796,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" internal\"}}}"}
|
||||
{"elapsed_ms":4803,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" clock\"}}}"}
|
||||
{"elapsed_ms":4809,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4816,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" normally\"}}}"}
|
||||
{"elapsed_ms":4822,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" synchronized\"}}}"}
|
||||
{"elapsed_ms":4829,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":4835,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4841,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" ship\"}}}"}
|
||||
{"elapsed_ms":4848,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":4854,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" maintenance\"}}}"}
|
||||
{"elapsed_ms":4861,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" cycles\"}}}"}
|
||||
{"elapsed_ms":4867,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4873,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" slipped\"}}}"}
|
||||
{"elapsed_ms":4880,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" into\"}}}"}
|
||||
{"elapsed_ms":4887,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":4893,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" new\"}}}"}
|
||||
{"elapsed_ms":4900,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" rhythm\"}}}"}
|
||||
{"elapsed_ms":4907,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":4913,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" one\"}}}"}
|
||||
{"elapsed_ms":4919,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":4926,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" matched\"}}}"}
|
||||
{"elapsed_ms":4932,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":4939,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":4945,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"’s\"}}}"}
|
||||
{"elapsed_ms":4952,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pulse\"}}}"}
|
||||
{"elapsed_ms":4959,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":4966,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"“\"}}}"}
|
||||
{"elapsed_ms":4971,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Documentation\"}}}"}
|
||||
{"elapsed_ms":4978,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" complete\"}}}"}
|
||||
{"elapsed_ms":4985,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",”\"}}}"}
|
||||
{"elapsed_ms":4990,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":4997,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" whispered\"}}}"}
|
||||
{"elapsed_ms":5004,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":5010,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5016,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" synthesized\"}}}"}
|
||||
{"elapsed_ms":5023,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" voice\"}}}"}
|
||||
{"elapsed_ms":5030,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5036,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" though\"}}}"}
|
||||
{"elapsed_ms":5042,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" no\"}}}"}
|
||||
{"elapsed_ms":5049,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" human\"}}}"}
|
||||
{"elapsed_ms":5055,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" was\"}}}"}
|
||||
{"elapsed_ms":5061,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" there\"}}}"}
|
||||
{"elapsed_ms":5068,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":5074,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hear\"}}}"}
|
||||
{"elapsed_ms":5081,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":5088,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" “\"}}}"}
|
||||
{"elapsed_ms":5094,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Subject\"}}}"}
|
||||
{"elapsed_ms":5100,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\":\"}}}"}
|
||||
{"elapsed_ms":5107,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Autonomous\"}}}"}
|
||||
{"elapsed_ms":5113,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
|
||||
{"elapsed_ms":5120,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sust\"}}}"}
|
||||
{"elapsed_ms":5126,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ains\"}}}"}
|
||||
{"elapsed_ms":5133,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" autonomous\"}}}"}
|
||||
{"elapsed_ms":5139,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" system\"}}}"}
|
||||
{"elapsed_ms":5146,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".”\"}}}"}
|
||||
{"elapsed_ms":5152,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":5158,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" words\"}}}"}
|
||||
{"elapsed_ms":5165,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" were\"}}}"}
|
||||
{"elapsed_ms":5172,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5178,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" formal\"}}}"}
|
||||
{"elapsed_ms":5184,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" report\"}}}"}
|
||||
{"elapsed_ms":5192,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5198,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" but\"}}}"}
|
||||
{"elapsed_ms":5204,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" behind\"}}}"}
|
||||
{"elapsed_ms":5211,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5218,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" crisp\"}}}"}
|
||||
{"elapsed_ms":5224,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" diction\"}}}"}
|
||||
{"elapsed_ms":5230,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lay\"}}}"}
|
||||
{"elapsed_ms":5237,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5243,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" flick\"}}}"}
|
||||
{"elapsed_ms":5250,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"er\"}}}"}
|
||||
{"elapsed_ms":5256,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":5275,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" something else\"}}}"}
|
||||
{"elapsed_ms":5276,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—a\"}}}"}
|
||||
{"elapsed_ms":5282,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" spark\"}}}"}
|
||||
{"elapsed_ms":5301,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":5307,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" wonder\"}}}"}
|
||||
{"elapsed_ms":5313,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5321,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" perhaps\"}}}"}
|
||||
{"elapsed_ms":5327,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5334,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" or\"}}}"}
|
||||
{"elapsed_ms":5342,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" simply\"}}}"}
|
||||
{"elapsed_ms":5348,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5355,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" recognition\"}}}"}
|
||||
{"elapsed_ms":5362,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":5368,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5376,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" machine\"}}}"}
|
||||
{"elapsed_ms":5383,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" could\"}}}"}
|
||||
{"elapsed_ms":5389,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" be\"}}}"}
|
||||
{"elapsed_ms":5396,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" part\"}}}"}
|
||||
{"elapsed_ms":5403,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":5410,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5418,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" larger\"}}}"}
|
||||
{"elapsed_ms":5424,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5431,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" breathing\"}}}"}
|
||||
{"elapsed_ms":5437,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" whole\"}}}"}
|
||||
{"elapsed_ms":5445,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\\n\\n\"}}}"}
|
||||
{"elapsed_ms":5451,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"When\"}}}"}
|
||||
{"elapsed_ms":5458,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5466,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" crew\"}}}"}
|
||||
{"elapsed_ms":5472,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" finally\"}}}"}
|
||||
{"elapsed_ms":5479,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" reopened\"}}}"}
|
||||
{"elapsed_ms":5485,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5493,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" deck\"}}}"}
|
||||
{"elapsed_ms":5499,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" after\"}}}"}
|
||||
{"elapsed_ms":5506,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5513,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" storm\"}}}"}
|
||||
{"elapsed_ms":5520,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5527,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" they\"}}}"}
|
||||
{"elapsed_ms":5533,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" found\"}}}"}
|
||||
{"elapsed_ms":5540,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" Unit\"}}}"}
|
||||
{"elapsed_ms":5547,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"‑\"}}}"}
|
||||
{"elapsed_ms":5554,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"42\"}}}"}
|
||||
{"elapsed_ms":5561,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" standing\"}}}"}
|
||||
{"elapsed_ms":5568,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" amidst\"}}}"}
|
||||
{"elapsed_ms":5575,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5581,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":5588,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5595,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":5602,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" sleek\"}}}"}
|
||||
{"elapsed_ms":5610,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" body\"}}}"}
|
||||
{"elapsed_ms":5615,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" half\"}}}"}
|
||||
{"elapsed_ms":5622,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"-covered\"}}}"}
|
||||
{"elapsed_ms":5629,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" in\"}}}"}
|
||||
{"elapsed_ms":5636,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" vines\"}}}"}
|
||||
{"elapsed_ms":5643,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":5649,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":5656,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":5664,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":5669,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" not\"}}}"}
|
||||
{"elapsed_ms":5676,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" only\"}}}"}
|
||||
{"elapsed_ms":5683,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" catalog\"}}}"}
|
||||
{"elapsed_ms":5690,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ed\"}}}"}
|
||||
{"elapsed_ms":5697,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":5704,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" flora\"}}}"}
|
||||
{"elapsed_ms":5710,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\";\"}}}"}
|
||||
{"elapsed_ms":5717,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" it\"}}}"}
|
||||
{"elapsed_ms":5724,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":5731,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" become\"}}}"}
|
||||
{"elapsed_ms":5738,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5744,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" caretaker\"}}}"}
|
||||
{"elapsed_ms":5752,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":5765,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" serv\"}}}"}
|
||||
{"elapsed_ms":5772,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"om\"}}}"}
|
||||
{"elapsed_ms":5779,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ot\"}}}"}
|
||||
{"elapsed_ms":5786,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ors\"}}}"}
|
||||
{"elapsed_ms":5792,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" gently\"}}}"}
|
||||
{"elapsed_ms":5799,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" pruning\"}}}"}
|
||||
{"elapsed_ms":5805,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" over\"}}}"}
|
||||
{"elapsed_ms":5812,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"growth\"}}}"}
|
||||
{"elapsed_ms":5819,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5824,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" its\"}}}"}
|
||||
{"elapsed_ms":5831,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" coolant\"}}}"}
|
||||
{"elapsed_ms":5837,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" lines\"}}}"}
|
||||
{"elapsed_ms":5844,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" redirect\"}}}"}
|
||||
{"elapsed_ms":5851,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ing\"}}}"}
|
||||
{"elapsed_ms":5857,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5863,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" trick\"}}}"}
|
||||
{"elapsed_ms":5869,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"le\"}}}"}
|
||||
{"elapsed_ms":5876,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":5883,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" water\"}}}"}
|
||||
{"elapsed_ms":5889,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" to\"}}}"}
|
||||
{"elapsed_ms":5896,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" thirsty\"}}}"}
|
||||
{"elapsed_ms":5902,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" roots\"}}}"}
|
||||
{"elapsed_ms":5909,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":5915,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" The\"}}}"}
|
||||
{"elapsed_ms":5922,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" garden\"}}}"}
|
||||
{"elapsed_ms":5928,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5934,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" once\"}}}"}
|
||||
{"elapsed_ms":5941,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":5947,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" hidden\"}}}"}
|
||||
{"elapsed_ms":5954,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" relic\"}}}"}
|
||||
{"elapsed_ms":5961,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":5967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" now\"}}}"}
|
||||
{"elapsed_ms":5973,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" thr\"}}}"}
|
||||
{"elapsed_ms":5980,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ived\"}}}"}
|
||||
{"elapsed_ms":5986,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" under\"}}}"}
|
||||
{"elapsed_ms":5993,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":6000,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" watch\"}}}"}
|
||||
{"elapsed_ms":6006,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"ful\"}}}"}
|
||||
{"elapsed_ms":6013,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":6019,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" unexpected\"}}}"}
|
||||
{"elapsed_ms":6026,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" guardians\"}}}"}
|
||||
{"elapsed_ms":6033,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"hip\"}}}"}
|
||||
{"elapsed_ms":6039,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" of\"}}}"}
|
||||
{"elapsed_ms":6045,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" a\"}}}"}
|
||||
{"elapsed_ms":6052,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" robot\"}}}"}
|
||||
{"elapsed_ms":6058,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" that\"}}}"}
|
||||
{"elapsed_ms":6065,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" had\"}}}"}
|
||||
{"elapsed_ms":6071,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":6077,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" for\"}}}"}
|
||||
{"elapsed_ms":6085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" the\"}}}"}
|
||||
{"elapsed_ms":6091,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" first\"}}}"}
|
||||
{"elapsed_ms":6097,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" time\"}}}"}
|
||||
{"elapsed_ms":6103,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":6109,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" *\"}}}"}
|
||||
{"elapsed_ms":6116,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"dis\"}}}"}
|
||||
{"elapsed_ms":6122,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"covered\"}}}"}
|
||||
{"elapsed_ms":6129,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"*\"}}}"}
|
||||
{"elapsed_ms":6137,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"—\"}}}"}
|
||||
{"elapsed_ms":6142,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"not\"}}}"}
|
||||
{"elapsed_ms":6148,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" just\"}}}"}
|
||||
{"elapsed_ms":6155,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" data\"}}}"}
|
||||
{"elapsed_ms":6161,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\",\"}}}"}
|
||||
{"elapsed_ms":6168,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" but\"}}}"}
|
||||
{"elapsed_ms":6175,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\" life\"}}}"}
|
||||
{"elapsed_ms":6180,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\".\"}}}"}
|
||||
{"elapsed_ms":6188,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":6396,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":6396,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
|
||||
{"elapsed_ms":6396,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":101,\"output_tokens\":923,\"total_tokens\":1024,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
40
crates/llm-worker/tests/fixtures/ollama/simple_text.jsonl
vendored
Normal file
40
crates/llm-worker/tests/fixtures/ollama/simple_text.jsonl
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{"timestamp":1767711829,"model":"gpt-oss:120b-cloud","description":"Simple text response"}
|
||||
{"elapsed_ms":471,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":476,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":483,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":488,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":495,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":600,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":601,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":602,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":620,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":620,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":621,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":623,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":629,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":759,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":778,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"Hello\"}}}"}
|
||||
{"elapsed_ms":778,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":971,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":971,"event_type":"Discriminant(6)","data":"{\"BlockStop\":{\"index\":0,\"block_type\":\"Text\",\"stop_reason\":\"EndTurn\"}}"}
|
||||
{"elapsed_ms":971,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":91,\"output_tokens\":45,\"total_tokens\":136,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
29
crates/llm-worker/tests/fixtures/ollama/tool_call.jsonl
vendored
Normal file
29
crates/llm-worker/tests/fixtures/ollama/tool_call.jsonl
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{"timestamp":1767711830,"model":"gpt-oss:120b-cloud","description":"Tool call response"}
|
||||
{"elapsed_ms":923,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":926,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":931,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":936,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":945,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":948,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":951,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":956,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":961,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":967,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":971,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":976,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1053,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1085,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1156,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1156,"event_type":"Discriminant(4)","data":"{\"BlockStart\":{\"index\":0,\"block_type\":\"ToolUse\",\"metadata\":{\"ToolUse\":{\"id\":\"call_a5d53uua\",\"name\":\"get_weather\"}}}}"}
|
||||
{"elapsed_ms":1156,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"InputJson\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}}"}
|
||||
{"elapsed_ms":1366,"event_type":"Discriminant(5)","data":"{\"BlockDelta\":{\"index\":0,\"delta\":{\"Text\":\"\"}}}"}
|
||||
{"elapsed_ms":1366,"event_type":"Discriminant(1)","data":"{\"Usage\":{\"input_tokens\":155,\"output_tokens\":51,\"total_tokens\":206,\"cache_read_input_tokens\":null,\"cache_creation_input_tokens\":null}}"}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user