legion-llm 0.14.10 → 0.14.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +49 -0
- data/README.md +16 -0
- data/docs/curation-production-metrics.md +80 -0
- data/legion-llm.gemspec +6 -2
- data/lib/legion/llm/api/client_translators/openai_responses.rb +47 -26
- data/lib/legion/llm/api/namespaces/openai/models.rb +37 -0
- data/lib/legion/llm/call/structured_output.rb +4 -0
- data/lib/legion/llm/context/curator.rb +44 -1
- data/lib/legion/llm/inference/executor/context_window.rb +31 -10
- data/lib/legion/llm/inference/executor/routing.rb +42 -8
- data/lib/legion/llm/inference/executor.rb +24 -0
- data/lib/legion/llm/inference/native_tool_loop.rb +43 -2
- data/lib/legion/llm/inference/steps/classification.rb +4 -0
- data/lib/legion/llm/inference/steps/token_budget.rb +5 -11
- data/lib/legion/llm/inventory.rb +4 -0
- data/lib/legion/llm/router.rb +20 -1
- data/lib/legion/llm/settings.rb +26 -0
- data/lib/legion/llm/version.rb +1 -1
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6720d66411b7d2125aab243354ca9f0bf19292b10895695c7e762fe6f23180d0
|
|
4
|
+
data.tar.gz: 87910fc8387d28d65759e207f79bf6d1a2b39e178a4fefe5c8b6bd5f17187f6b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 83e7680e3083b13e523f66cdba62ff28680f85bc4e63d0e59ef15b4d7cb260c28c02d1325fa76566f5bd8a6606dc55d552bce9dbf097d45d197a02a2a486ac9d
|
|
7
|
+
data.tar.gz: 9cbc000d34b4afdc8e24dc677ea9a0a22a397868bf1e765d96e2bf7ea173016d293441dbdb6401edd2de30380dd0892cfd73e65550797aca1f4fdaa81e08db0d
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,54 @@
|
|
|
1
1
|
# Legion LLM Changelog
|
|
2
2
|
|
|
3
|
+
## [0.14.15] - 2026-07-09
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **OpenAI Responses translator now produces a canonical request equivalent to the Anthropic translator (SSOT / one-oracle).** The same logical request must parse to an identical `Canonical::Request` regardless of client dialect; three divergences were breaking that (caught by the canonical-equivalence checks):
|
|
8
|
+
- **`upstream_body` no longer leaks into `Canonical::Request.metadata`.** It was written at parse, stripped again before dispatch, and never read (the native `call_responses` path is fed the raw body directly), so it was dead weight that made the two dialects' canonical requests differ. Removed.
|
|
9
|
+
- **System prompt goes to the canonical `system:` field, not a `role: system` message.** Responses `instructions` (and inline `developer`/`system` input items) now populate `system:` like the Anthropic translator, instead of being injected as a system message.
|
|
10
|
+
- **Text-then-tool-call is one assistant turn.** A Responses assistant text item immediately followed by `function_call`(s) now merges into a single assistant message (`content` + `tool_calls`), matching the Anthropic single-message shape, instead of splitting into a text message plus an empty-content tool_calls message.
|
|
11
|
+
|
|
12
|
+
## [0.14.14] - 2026-07-09
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Curator now strips client-harness noise from the mid-conversation system role (GH#168).** The context curator curated `tool` and `assistant` roles but blindly passed every `system`-role message through — so Claude Code's harness injections (task-tool nags repeated 18×, linter file-dumps) accumulated unbounded, consuming up to ~25% of a 60–80K dispatch window and confusing non-Claude models (e.g. ornith/gemma) into hallucinating or dropping tool calls mid-turn. A new `strip_harness_noise` stage (first in both the heuristic and structural curation pipelines) drops a system message only when it (1) matches a known harness pattern or (2) is a verbatim duplicate of an earlier system message. Fail-safe: unrecognized, non-duplicate system messages and all non-system roles pass through untouched.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- `llm.context_curation.harness_noise_strip` (default `true`) and `llm.context_curation.harness_noise_patterns` (default: Claude Code task-nag and linter file-dump markers). The pattern list is a plain substring array designed to grow as new harness injections are identified.
|
|
21
|
+
|
|
22
|
+
### Internal
|
|
23
|
+
|
|
24
|
+
- Guarded four `String#include?` substring checks (`structured_output`, `classification`, `inventory`, `curator`) with `# rubocop:disable Style/ArrayIntersect`. The cop's autocorrect rewrites `array.any? { |x| str.include?(x) }` to `array.intersect?(str)`, which raises `TypeError` on a String argument — a latent trap for `rubocop -A`.
|
|
25
|
+
|
|
26
|
+
## [0.14.13] - 2026-07-09
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **Leaked chat-template tool-call tokens are synthesized into real tool calls (native_tool_loop pattern 4).** Some models (observed live with gemma served via vLLM; ledger rows 337049/337111/337145/337174) emit tool-call intent as a raw literal token in the response content instead of the structured `tool_calls` field: `<|tool_call>call:NAME{key:<|"|>value<|"|>,...}<tool_call|>` (where `<|"|>` is the string delimiter). Without synthesis the token reached the client verbatim — a "tool call" printed as text with nothing executed. `maybe_synthesize_tool_call_from_content` now recognizes this as a fourth format (after forced-choice JSON, Qwen tag markup, and Qwen single-tag): it parses the token via a scan-based parser (values terminated only by `<|"|>`, so embedded regular quotes and newlines in e.g. browser `code:` args survive — a gsub-to-quotes + JSON.parse approach breaks on those), validates the tool name against `native_dispatch_tools`, supports multiple tokens in one blob, and lets the executor run the tool server-side. Covered by unit specs (captured-verbatim ledger strings) and an in-process matrix scenario asserting all three client formats never surface the raw markers.
|
|
31
|
+
|
|
32
|
+
## [0.14.12] - 2026-07-09
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- **BYOK model discovery returns 200 for passthrough models.** Clients like GitHub Copilot validate model availability via `GET /v1/models/:id` before sending inference requests. Previously returned 404 for models not in Inventory (e.g. `copilot-utility-small`), even though the daemon auto-routes them successfully. Now returns a synthetic model object for models listed in `llm.routing.model_passthrough_ids`.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- `llm.routing.model_passthrough_ids` setting (default: `["copilot-utility-small"]`). Models in this list get dedicated `/v1/models/<id>` routes that return a synthetic model object backed by the best available lane's limits. Configurable for additional BYOK client model names.
|
|
41
|
+
|
|
42
|
+
## [0.14.11] - 2026-07-03
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **Routing token estimate now measures the payload dispatch actually sends (one oracle).** `estimate_request_tokens` walks canonical structs/content blocks via `ContextAccounting` instead of a naive `m[:content].to_s` shadow estimator (which read structured content — the shape Claude Code sends — as near-zero), and now counts the *injected* system prompt (baseline + RAG + skills + prior history) and the *full dispatch tool catalog* (special + client + registry), not just `@request.tools`. Fixes small-context local lanes passing the router's context-window filter then overflowing with `ContextOverflow` at dispatch.
|
|
47
|
+
- **Routing estimates the lane-independent-reduced message set.** Extracted a pure `reduce_messages_for_dispatch` (empty-assistant prune + leading-thinking strip + oversized-tool-result trim) so routing measures exactly what `native_dispatch_messages` puts on the wire, minus the lane-dependent `enforce_context_window` compaction (correctly excluded — the lane isn't chosen yet).
|
|
48
|
+
- **History double-send eliminated.** For a client-managed conversation (e.g. Claude Code) the client resends the full history in `@request.messages` every turn; `step_context_load` also injected the server-stored copy as "Prior conversation history" system text — sending the same turns to the provider twice (real tokens, real cost, real context pressure). `reject_client_resent_history` now drops stored turns the client already re-sent, matched by `(role, content-text)` fingerprint, while still injecting the prior turns a server-managed client omitted.
|
|
49
|
+
- **Router/dispatch context-window agreement.** New `llm.routing.context_headroom` (default `0.90`) applies the same headroom the dispatch budget guard uses, so a lane the router picks must still clear `RouteAttempts#enforce_final_context_budget!`.
|
|
50
|
+
- `token_budget` step now shares the same `ContextAccounting` estimator as the router filter and dispatch guard.
|
|
51
|
+
|
|
3
52
|
## [0.14.10] - 2026-06-26
|
|
4
53
|
|
|
5
54
|
### Fixed
|
data/README.md
CHANGED
|
@@ -93,6 +93,22 @@ Both produced correct, specific technical answers and passed a multi-question re
|
|
|
93
93
|
|
|
94
94
|
Curation runs **after** each turn (async), never inside the tool loop, so it never adds latency to an in-flight request. With a 60K-token target the validated payloads stay comfortably in the 40–56K range.
|
|
95
95
|
|
|
96
|
+
### The knowledge loop
|
|
97
|
+
|
|
98
|
+
`drop_and_archive` is the strategy that makes curation more than a bigger trash can: instead of discarding overflow context, it sends it to the Apollo knowledge store, where it stays retrievable on future turns. Client-side compaction is lossy truncation — once it's gone, it's gone. This is archival: a fact from turn 3 can resurface at turn 300.
|
|
99
|
+
|
|
100
|
+
Apollo is two stores. The local per-node store ([legion-apollo](https://github.com/LegionIO/legion-apollo)) starts knowledge at confidence 0.5, corroborates at +0.15, decays at 0.005 per pass, and archives below 0.1 ([confidence.rb](https://github.com/LegionIO/legion-apollo/blob/main/lib/legion/apollo/helpers/confidence.rb)). The shared cross-node store ([lex-apollo](https://github.com/LegionIO/lex-apollo)) corroborates at +0.3 — only when a ≥0.9-cosine match arrives from a *different* provider — and begins time decay after 168 hours. The pipeline steps here use the local store (`Legion::Apollo::Local`).
|
|
101
|
+
|
|
102
|
+
Lifecycle status in the local store moves through `pending` → `confirmed` / `disputed` → `deprecated` / `archived`. The shared store is backed by PostgreSQL+pgvector (`lib/legion/extensions/apollo.rb`).
|
|
103
|
+
|
|
104
|
+
The loop closes back up in the Inference pipeline, across three steps:
|
|
105
|
+
|
|
106
|
+
- `knowledge_capture` (`lib/legion/llm/inference/steps/knowledge_capture.rb`) evaluates the response after each turn and writes new knowledge back to Apollo.
|
|
107
|
+
- `rag_context` (`lib/legion/llm/inference/steps/rag_context.rb`) queries Apollo — via the shared-store runner when available, falling back to `Legion::Apollo.retrieve(scope: :all)`, which merges local and shared results — and injects relevant knowledge into the request before it goes out.
|
|
108
|
+
- `gaia_advisory` (`lib/legion/llm/inference/steps/gaia_advisory.rb`) queries `Legion::Apollo::Local` — the local store only — for advisory context: tone, partner history, routing hints. GAIA advises; it doesn't inject prompts. The pipeline is what pulls context in.
|
|
109
|
+
|
|
110
|
+
The receipts for this loop aren't a separate claim: RAG-injected tokens are a recorded column in the lex-llm-ledger aggregates already published in [docs/curation-production-metrics.md](docs/curation-production-metrics.md).
|
|
111
|
+
|
|
96
112
|
### And everything else you'd expect from a production gateway
|
|
97
113
|
|
|
98
114
|
Dynamic weighted routing across five tiers, automatic escalation and mid-stream provider failover, per-instance circuit breakers, capability-aware model selection, prompt compression, structured output, embeddings, fleet dispatch over AMQP, unified metering/audit on every request exit, and config-driven model-policy compliance (`model_whitelist`/`model_blacklist`, fail-closed). All of it is documented below.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Context Curation: Production Metrics
|
|
2
|
+
|
|
3
|
+
Aggregate metrics from a production LegionIO deployment, exported from
|
|
4
|
+
[lex-llm-ledger](https://github.com/LegionIO/lex-llm-ledger) — the audit ledger that
|
|
5
|
+
records every LLM request the framework makes. All requests over the measurement
|
|
6
|
+
window, bucketed by conversation length. Nothing filtered, nothing excluded.
|
|
7
|
+
|
|
8
|
+
**The honest headline:** curation saves approximately nothing on short conversations
|
|
9
|
+
and ~98% on long agent sessions — and that asymmetry is the point, because that's
|
|
10
|
+
where the cost lives. In this window, 97.8% of all would-be tokens came from the 50+
|
|
11
|
+
turn bucket, while 99% of conversations by count were single-turn and gained nothing
|
|
12
|
+
(-0.1%, the overhead is noise). Curation doesn't make cheap traffic cheaper; it
|
|
13
|
+
prevents the runaway sessions that dominate spend.
|
|
14
|
+
|
|
15
|
+
## Baseline definition (read before quoting any percentage)
|
|
16
|
+
|
|
17
|
+
"Naive" means: no client-side context management at all — the full accumulated
|
|
18
|
+
conversation resent on every turn. That is the default behavior of a plain agent loop,
|
|
19
|
+
but it is a worst-case counterfactual, not a claim about what your current tooling
|
|
20
|
+
does. A client that already trims context will see smaller deltas. All percentages
|
|
21
|
+
below use this single denominator: `actually_sent / naive_would_have_sent`.
|
|
22
|
+
|
|
23
|
+
## Savings vs. naive resend, by conversation length
|
|
24
|
+
|
|
25
|
+
| Turns in conversation | 1 | 2–3 | 4–5 | 6–9 | 10–19 | 20–49 | 50+ |
|
|
26
|
+
|---|---|---|---|---|---|---|---|
|
|
27
|
+
| Total requests | 126,202 | 1,941 | 299 | 343 | 559 | 826 | 10,956 |
|
|
28
|
+
| Conversations | 126,202 | 947 | 69 | 50 | 43 | 29 | 37 |
|
|
29
|
+
| Avg turns/conversation | 1 | 2 | 4.3 | 6.9 | 13 | 28.5 | 296.1 |
|
|
30
|
+
| Naive would have sent (tokens) | 163.4M | 2.59M | 4.00M | 6.65M | 28.2M | 74.4M | 12.35B |
|
|
31
|
+
| Actually sent (tokens) | 163.6M | 2.35M | 3.47M | 5.08M | 12.9M | 20.3M | 285.6M |
|
|
32
|
+
| Avg naive per turn | 1,295 | 1,336 | 13,382 | 19,376 | 50,360 | 90,019 | 1,127,381 |
|
|
33
|
+
| Avg actual per turn | 1,296 | 1,208 | 11,607 | 14,812 | 22,998 | 24,521 | 26,064 |
|
|
34
|
+
| **Reduction vs. naive** | **-0.1%** | **9.6%** | **13.3%** | **23.6%** | **54.3%** | **72.8%** | **97.7%** |
|
|
35
|
+
|
|
36
|
+
The mechanism is visible in the per-turn averages: in the 50+ bucket (dominated by
|
|
37
|
+
long-running agent sessions averaging 296 turns), naive resend grows unbounded toward
|
|
38
|
+
1.13M tokens per turn while curated context stays flat around 26K. Bounded per-turn
|
|
39
|
+
context is the claim this table proves; percentage reductions are a consequence of it.
|
|
40
|
+
|
|
41
|
+
Token-weighted across all buckets the reduction is ~96%, but quote that number with
|
|
42
|
+
its context: it is almost entirely the 50+ bucket's 97.7%. Reduction scales
|
|
43
|
+
monotonically with conversation depth, and the 50+ figure is not a small-sample
|
|
44
|
+
artifact — it covers 10,956 requests across 37 conversations.
|
|
45
|
+
|
|
46
|
+
## How the savings happen
|
|
47
|
+
|
|
48
|
+
The Curator runs asynchronously after each turn (no added request latency) and applies
|
|
49
|
+
deterministic strategies — each one a named method in
|
|
50
|
+
[curator.rb](../lib/legion/llm/context/curator.rb):
|
|
51
|
+
|
|
52
|
+
- `strip_thinking` — drops stale reasoning blocks from prior turns
|
|
53
|
+
- `distill_tool_result` — summarizes large tool outputs
|
|
54
|
+
- `fold_resolved_exchanges` — collapses completed tool-call/result pairs
|
|
55
|
+
- `evict_superseded` — drops an old file-read when a newer read replaces it
|
|
56
|
+
- `dedup_similar` — Jaccard-similarity dedup of near-identical content
|
|
57
|
+
- `drop_and_archive` — overflow goes to the knowledge store, not the trash
|
|
58
|
+
|
|
59
|
+
In the 50+ bucket, loaded history of 300.2M tokens was curated down to 106.6M per
|
|
60
|
+
request on average, with a further 75.8M archived to the knowledge store rather than
|
|
61
|
+
resent. The ledger records curation and archival as separate stages that partially
|
|
62
|
+
overlap on the same content, so their per-stage "saved" counters are not additive;
|
|
63
|
+
the end-to-end truth is the naive-vs-actual row above, which is stage-independent.
|
|
64
|
+
|
|
65
|
+
## Caveats and known gaps, stated plainly
|
|
66
|
+
|
|
67
|
+
- **Single deployment, single author's workload.** Your mix of conversation lengths
|
|
68
|
+
determines your savings; the -0.1% single-turn column is as real as the 97.7% one.
|
|
69
|
+
- **The baseline is modeled, not A/B tested.** See the baseline definition above.
|
|
70
|
+
- **Provider prompt caching was off in this window** (cached and cache-creation token
|
|
71
|
+
columns were zero). These savings come from payload reduction only, so they stack
|
|
72
|
+
with provider-side caching rather than replacing it.
|
|
73
|
+
- **Tool definitions are not yet curated.** They are static, resent every turn, and
|
|
74
|
+
material at scale — a known optimization gap, not a solved problem.
|
|
75
|
+
|
|
76
|
+
## Reproduce
|
|
77
|
+
|
|
78
|
+
Every number above is a `SELECT` over lex-llm-ledger tables — the same ledger any
|
|
79
|
+
LegionIO deployment gets by installing the extension. The ledger exists for
|
|
80
|
+
finance/audit; these metrics are a side effect of never throwing usage data away.
|
data/legion-llm.gemspec
CHANGED
|
@@ -7,8 +7,12 @@ Gem::Specification.new do |spec|
|
|
|
7
7
|
spec.version = Legion::LLM::VERSION
|
|
8
8
|
spec.authors = ['Esity']
|
|
9
9
|
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
-
spec.summary = 'LLM routing and
|
|
11
|
-
spec.description = '
|
|
10
|
+
spec.summary = 'LLM gateway: tiered routing, mid-stream failover, and context curation in one proxy'
|
|
11
|
+
spec.description = 'A proxy between AI clients and model backends. Requests translate once to a canonical form, ' \
|
|
12
|
+
'route to the cheapest capable tier (local, direct, fleet, cloud, frontier), and escalate on ' \
|
|
13
|
+
'failure with circuit breakers and mid-stream failover. A post-turn Curator bounds long agent ' \
|
|
14
|
+
'sessions (97.7% context reduction vs naive resend at 50+ turns, production-measured). Provider ' \
|
|
15
|
+
'adapters are separate lex-llm-* gems.'
|
|
12
16
|
spec.homepage = 'https://github.com/LegionIO/legion-llm'
|
|
13
17
|
spec.license = 'Apache-2.0'
|
|
14
18
|
spec.require_paths = ['lib']
|
|
@@ -39,15 +39,15 @@ module Legion
|
|
|
39
39
|
log.debug('[llm][client_translator][openai_responses] action=parse_request')
|
|
40
40
|
body = symbolize(body)
|
|
41
41
|
|
|
42
|
-
messages =
|
|
42
|
+
system, messages = build_system_and_messages(body[:input], body[:instructions])
|
|
43
43
|
tools = build_tools(body[:tools])
|
|
44
44
|
params = build_params(body)
|
|
45
45
|
thinking = build_thinking(body[:reasoning])
|
|
46
46
|
tool_choice = build_tool_choice(body[:tool_choice])
|
|
47
|
-
upstream_body = ensure_reasoning_summary(body)
|
|
48
47
|
|
|
49
48
|
Canonical::Request.build(
|
|
50
49
|
id: env['HTTP_X_CLIENT_REQUEST_ID'] || "resp_#{SecureRandom.hex(16)}",
|
|
50
|
+
system: system,
|
|
51
51
|
messages: messages,
|
|
52
52
|
tools: tools,
|
|
53
53
|
tool_choice: tool_choice,
|
|
@@ -60,8 +60,7 @@ module Legion
|
|
|
60
60
|
client_model: body[:model],
|
|
61
61
|
tier: env['HTTP_X_LEGION_TIER'],
|
|
62
62
|
routing_explicit: legion_routing_explicit_from_env(env),
|
|
63
|
-
external_refs: external_refs(body, env)
|
|
64
|
-
upstream_body: upstream_body # preserved for native call_responses path until executor is canonical
|
|
63
|
+
external_refs: external_refs(body, env)
|
|
65
64
|
}.compact
|
|
66
65
|
)
|
|
67
66
|
end
|
|
@@ -107,7 +106,7 @@ module Legion
|
|
|
107
106
|
thinking: thinking_to_inference(canonical_request.thinking),
|
|
108
107
|
cache: { strategy: :default, cacheable: true },
|
|
109
108
|
extra: extra,
|
|
110
|
-
metadata: canonical_request.metadata
|
|
109
|
+
metadata: canonical_request.metadata
|
|
111
110
|
}
|
|
112
111
|
|
|
113
112
|
apply_canonical_params_to_inference(request_kwargs, canonical_request.params)
|
|
@@ -533,18 +532,30 @@ module Legion
|
|
|
533
532
|
body
|
|
534
533
|
end
|
|
535
534
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
535
|
+
# SSOT: system content belongs in the canonical `system:` field (as the
|
|
536
|
+
# Anthropic translator does), NOT as a role:system message. Extract it
|
|
537
|
+
# from top-level `instructions` and from any inline system/developer
|
|
538
|
+
# input items, and return [system_string_or_nil, non_system_messages].
|
|
539
|
+
def build_system_and_messages(input, instructions)
|
|
540
|
+
raw = case input
|
|
541
|
+
when Array then normalize_input_array(input)
|
|
542
|
+
when String then [{ role: 'user', content: input }]
|
|
543
|
+
else []
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
system_parts = []
|
|
547
|
+
system_parts << instructions.to_s if instructions
|
|
548
|
+
messages = []
|
|
549
|
+
raw.each do |m|
|
|
550
|
+
role = (m[:role] || m['role']).to_s
|
|
551
|
+
if role == 'system'
|
|
552
|
+
system_parts << (m[:content] || m['content']).to_s
|
|
553
|
+
else
|
|
554
|
+
messages << m
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
normalized = messages.map do |m|
|
|
548
559
|
{
|
|
549
560
|
role: m[:role] || m['role'],
|
|
550
561
|
content: m[:content] || m['content'],
|
|
@@ -552,6 +563,9 @@ module Legion
|
|
|
552
563
|
tool_call_id: m[:tool_call_id] || m['tool_call_id']
|
|
553
564
|
}.compact
|
|
554
565
|
end
|
|
566
|
+
|
|
567
|
+
system = system_parts.reject(&:empty?).join("\n\n")
|
|
568
|
+
[system.empty? ? nil : system, normalized]
|
|
555
569
|
end
|
|
556
570
|
|
|
557
571
|
def normalize_input_array(input)
|
|
@@ -596,15 +610,22 @@ module Legion
|
|
|
596
610
|
def flush_pending_tool_calls(messages, pending)
|
|
597
611
|
return if pending.empty?
|
|
598
612
|
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
613
|
+
tool_calls = pending.map do |tc|
|
|
614
|
+
args = tc[:arguments]
|
|
615
|
+
args = safe_parse_json(args) if args.is_a?(String)
|
|
616
|
+
{ id: tc[:id], name: tc[:name].to_s, arguments: args || {} }
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
# SSOT: an assistant text item immediately followed by function_call(s)
|
|
620
|
+
# is ONE assistant turn (text + tool_calls), matching the Anthropic
|
|
621
|
+
# translator's single-message shape. Merge into the trailing assistant
|
|
622
|
+
# text message rather than emitting a separate empty-content message.
|
|
623
|
+
last = messages.last
|
|
624
|
+
if last && last[:role] == 'assistant' && !last.key?(:tool_calls)
|
|
625
|
+
last[:tool_calls] = tool_calls
|
|
626
|
+
else
|
|
627
|
+
messages << { role: 'assistant', content: '', tool_calls: tool_calls }
|
|
628
|
+
end
|
|
608
629
|
pending.clear
|
|
609
630
|
end
|
|
610
631
|
|
|
@@ -44,6 +44,26 @@ module Legion
|
|
|
44
44
|
openai_error(e.message, type: 'server_error', status_code: 500)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
Models.passthrough_model_ids.each do |passthrough_id|
|
|
48
|
+
app.get "/v1/models/#{passthrough_id}" do
|
|
49
|
+
require_llm!
|
|
50
|
+
log.debug("[llm][api][namespaces][openai][models] action=passthrough_model id=#{passthrough_id}")
|
|
51
|
+
found = Models.synthetic_model_for_auto_route(passthrough_id)
|
|
52
|
+
|
|
53
|
+
unless found
|
|
54
|
+
return openai_error("Model '#{passthrough_id}' not found",
|
|
55
|
+
type: 'invalid_request_error', code: 'model_not_found', status_code: 404)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
content_type :json
|
|
59
|
+
Legion::JSON.dump(found)
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
handle_exception(e, level: :error, handled: true,
|
|
62
|
+
operation: 'llm.api.namespaces.openai.models.passthrough')
|
|
63
|
+
openai_error(e.message, type: 'server_error', status_code: 500)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
47
67
|
app.get '/v1/models/:id' do
|
|
48
68
|
require_llm!
|
|
49
69
|
model_id = params[:id]
|
|
@@ -99,6 +119,23 @@ module Legion
|
|
|
99
119
|
[]
|
|
100
120
|
end
|
|
101
121
|
|
|
122
|
+
def self.passthrough_model_ids
|
|
123
|
+
Legion::Settings[:llm][:routing][:model_passthrough_ids]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.synthetic_model_for_auto_route(model_id)
|
|
127
|
+
return nil unless Legion::LLM::Inventory.lanes.any?
|
|
128
|
+
|
|
129
|
+
best_lane = Legion::LLM::Inventory.lanes.max_by { |l| l[:lane_weight].to_i }
|
|
130
|
+
return nil unless best_lane
|
|
131
|
+
|
|
132
|
+
Legion::LLM::API::Translators::OpenAIResponse.format_model_object(
|
|
133
|
+
model_id,
|
|
134
|
+
owned_by: 'legionio',
|
|
135
|
+
limits: best_lane[:limits]
|
|
136
|
+
)
|
|
137
|
+
end
|
|
138
|
+
|
|
102
139
|
def self.to_anthropic_model_list(openai_list)
|
|
103
140
|
openai_list.map { |m| openai_to_anthropic_model(m) }
|
|
104
141
|
end
|
|
@@ -126,7 +126,11 @@ module Legion
|
|
|
126
126
|
end
|
|
127
127
|
|
|
128
128
|
def supports_response_format?(model)
|
|
129
|
+
# rubocop:disable Style/ArrayIntersect -- substring check (each model
|
|
130
|
+
# fragment `include?`d in the model name), NOT array intersection.
|
|
131
|
+
# `intersect?` raises TypeError on the String arg.
|
|
129
132
|
SCHEMA_CAPABLE_MODELS.any? { |m| model.to_s.include?(m) }
|
|
133
|
+
# rubocop:enable Style/ArrayIntersect
|
|
130
134
|
end
|
|
131
135
|
|
|
132
136
|
def retry_enabled?
|
|
@@ -194,6 +194,43 @@ module Legion
|
|
|
194
194
|
result
|
|
195
195
|
end
|
|
196
196
|
|
|
197
|
+
# Strip client-harness noise from the mid-conversation system role
|
|
198
|
+
# (GH#168). Claude Code and similar harnesses inject unbounded
|
|
199
|
+
# system-role messages — task nags (identical text, repeated), linter
|
|
200
|
+
# file dumps — that the rest of the curator never touched, letting them
|
|
201
|
+
# consume ~25% of a dispatch window.
|
|
202
|
+
#
|
|
203
|
+
# System-role only, and fail-safe: a system message is dropped ONLY when
|
|
204
|
+
# it (a) matches a known harness pattern, or (b) is a verbatim duplicate
|
|
205
|
+
# of an earlier system message. Unrecognized, non-duplicate system
|
|
206
|
+
# messages and every non-system message pass through untouched.
|
|
207
|
+
def strip_harness_noise(messages)
|
|
208
|
+
return messages unless setting(:harness_noise_strip, true)
|
|
209
|
+
|
|
210
|
+
patterns = Array(setting(:harness_noise_patterns, []))
|
|
211
|
+
seen_system = {}
|
|
212
|
+
result = messages.reject do |msg|
|
|
213
|
+
next false unless (msg[:role] || msg['role']).to_s == 'system'
|
|
214
|
+
|
|
215
|
+
content = (msg[:content] || msg['content']).to_s
|
|
216
|
+
next true if patterns.any? { |p| content.include?(p.to_s) }
|
|
217
|
+
|
|
218
|
+
if seen_system.key?(content)
|
|
219
|
+
true
|
|
220
|
+
else
|
|
221
|
+
seen_system[content] = true
|
|
222
|
+
false
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
stripped = messages.size - result.size
|
|
227
|
+
if stripped.positive?
|
|
228
|
+
log.info "[llm][curator] action=strip_harness_noise conversation_id=#{@conversation_id} " \
|
|
229
|
+
"stripped=#{stripped} messages_before=#{messages.size} messages_after=#{result.size}"
|
|
230
|
+
end
|
|
231
|
+
result
|
|
232
|
+
end
|
|
233
|
+
|
|
197
234
|
# Heuristic: deduplicate near-identical messages using Jaccard similarity.
|
|
198
235
|
def dedup_similar(messages, threshold: nil)
|
|
199
236
|
return messages unless setting(:dedup_enabled, true)
|
|
@@ -352,6 +389,7 @@ module Legion
|
|
|
352
389
|
result = messages.map { |msg| strip_thinking(msg) }
|
|
353
390
|
end
|
|
354
391
|
|
|
392
|
+
result = strip_harness_noise(result)
|
|
355
393
|
result = fold_resolved_exchanges(result)
|
|
356
394
|
result = evict_superseded(result)
|
|
357
395
|
dedup_similar(result)
|
|
@@ -368,7 +406,8 @@ module Legion
|
|
|
368
406
|
end
|
|
369
407
|
|
|
370
408
|
def apply_structural_curation_pipeline(messages)
|
|
371
|
-
result =
|
|
409
|
+
result = strip_harness_noise(messages)
|
|
410
|
+
result = fold_resolved_exchanges(result)
|
|
372
411
|
result = evict_superseded(result)
|
|
373
412
|
dedup_similar(result)
|
|
374
413
|
rescue StandardError => e
|
|
@@ -575,8 +614,12 @@ module Legion
|
|
|
575
614
|
clarification_signals = ['clarif', 'what do you mean', 'i see', 'understood', 'got it', 'correct', 'exactly', 'yes', 'right', 'agree']
|
|
576
615
|
conclusion_signals = ['in summary', 'to summarize', 'in conclusion', 'therefore', 'so to answer', 'the answer is']
|
|
577
616
|
|
|
617
|
+
# rubocop:disable Style/ArrayIntersect -- these are substring checks
|
|
618
|
+
# (signal `include?` against a String), NOT array intersection. The
|
|
619
|
+
# cop's `intersect?` suggestion raises TypeError on a String arg.
|
|
578
620
|
has_clarification = contents.any? { |c| clarification_signals.any? { |s| c.include?(s) } }
|
|
579
621
|
has_conclusion = contents.last.length < 500 || conclusion_signals.any? { |s| contents.last.include?(s) }
|
|
622
|
+
# rubocop:enable Style/ArrayIntersect
|
|
580
623
|
|
|
581
624
|
has_clarification && has_conclusion
|
|
582
625
|
end
|
|
@@ -108,11 +108,23 @@ module Legion
|
|
|
108
108
|
end
|
|
109
109
|
end
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
111
|
+
# Lane-INDEPENDENT reduction applied before dispatch: empty-assistant
|
|
112
|
+
# prune + leading-thinking strip + oversized-tool-result trim. PURE — no
|
|
113
|
+
# @context_accounting writes, no logging — so the routing estimate can
|
|
114
|
+
# call it to measure exactly what native_dispatch_messages will send
|
|
115
|
+
# (minus the lane-dependent enforce_context_window compaction, which is
|
|
116
|
+
# correctly excluded from routing since it depends on the chosen lane).
|
|
117
|
+
def reduce_messages_for_dispatch(messages)
|
|
118
|
+
msgs = Array(messages).reject { |m| empty_assistant_message?(m) }
|
|
119
|
+
msgs = strip_thinking_pure(msgs)
|
|
120
|
+
trim_oversized_tool_results_pure(msgs)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Pure leading-thinking strip. Shared by strip_thinking_from_history
|
|
124
|
+
# (which adds accounting) and reduce_messages_for_dispatch.
|
|
125
|
+
def strip_thinking_pure(messages)
|
|
113
126
|
preserve_after = last_user_message_index(messages)
|
|
114
|
-
|
|
115
|
-
result = messages.each_with_index.map do |msg, idx|
|
|
127
|
+
messages.each_with_index.map do |msg, idx|
|
|
116
128
|
next msg if idx >= preserve_after
|
|
117
129
|
next msg unless (msg[:role] || msg['role']).to_s == 'assistant'
|
|
118
130
|
|
|
@@ -122,9 +134,14 @@ module Legion
|
|
|
122
134
|
cleaned = strip_leading_thinking_block(content)
|
|
123
135
|
next msg if cleaned == content
|
|
124
136
|
|
|
125
|
-
stripped_count += 1
|
|
126
137
|
msg.merge(content: cleaned)
|
|
127
138
|
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def strip_thinking_from_history(messages)
|
|
142
|
+
before_tokens = ContextAccounting.estimate_message_tokens(messages)
|
|
143
|
+
result = strip_thinking_pure(messages)
|
|
144
|
+
stripped_count = messages.zip(result).count { |before, after| before != after }
|
|
128
145
|
|
|
129
146
|
after_tokens = ContextAccounting.estimate_message_tokens(result)
|
|
130
147
|
saved = [before_tokens - after_tokens, 0].max
|
|
@@ -158,28 +175,32 @@ module Legion
|
|
|
158
175
|
text
|
|
159
176
|
end
|
|
160
177
|
|
|
161
|
-
|
|
178
|
+
# Pure oversized-tool-result trim. Shared by trim_oversized_tool_results
|
|
179
|
+
# (which adds logging) and reduce_messages_for_dispatch.
|
|
180
|
+
def trim_oversized_tool_results_pure(messages)
|
|
162
181
|
max_chars = Legion::Settings[:llm][:tool_result_max_dispatch_chars].to_i
|
|
163
182
|
return messages unless max_chars.positive?
|
|
164
183
|
|
|
165
184
|
preserve_after = last_user_message_index(messages)
|
|
166
|
-
|
|
167
|
-
result = messages.each_with_index.map do |msg, idx|
|
|
185
|
+
messages.each_with_index.map do |msg, idx|
|
|
168
186
|
next msg if idx >= preserve_after
|
|
169
187
|
next msg unless tool_result_message?(msg)
|
|
170
188
|
|
|
171
189
|
content = msg[:content] || msg['content']
|
|
172
190
|
next msg unless content.is_a?(String) && content.length > max_chars
|
|
173
191
|
|
|
174
|
-
trimmed_count += 1
|
|
175
192
|
msg.merge(content: "#{content[0, max_chars]}\n\n[TRUNCATED: showing first #{max_chars} of #{content.length} chars. " \
|
|
176
193
|
'If you need more content, make multiple smaller targeted requests ' \
|
|
177
194
|
'(e.g. read specific line ranges, grep for specific patterns, or request smaller sections).]')
|
|
178
195
|
end
|
|
196
|
+
end
|
|
179
197
|
|
|
198
|
+
def trim_oversized_tool_results(messages)
|
|
199
|
+
result = trim_oversized_tool_results_pure(messages)
|
|
200
|
+
trimmed_count = messages.zip(result).count { |before, after| before != after }
|
|
180
201
|
if trimmed_count.positive?
|
|
181
202
|
log.info "[llm][executor] action=trim_tool_results request_id=#{@request.id} trimmed=#{trimmed_count} " \
|
|
182
|
-
"max_chars=#{
|
|
203
|
+
"max_chars=#{Legion::Settings[:llm][:tool_result_max_dispatch_chars].to_i}"
|
|
183
204
|
end
|
|
184
205
|
result
|
|
185
206
|
end
|
|
@@ -198,18 +198,52 @@ module Legion
|
|
|
198
198
|
Legion::Settings.dig(:llm, :routing, :allow_body_routing_hints) == true
|
|
199
199
|
end
|
|
200
200
|
|
|
201
|
+
# One oracle: routing must estimate the SAME payload the dispatch guard
|
|
202
|
+
# (RouteAttempts#final_dispatch_token_estimate) measures, or a too-small
|
|
203
|
+
# lane passes the router's context-window filter then overflows at
|
|
204
|
+
# dispatch. Use ContextAccounting (walks canonical structs / content
|
|
205
|
+
# blocks) for messages, estimate the INJECTED system (baseline + RAG +
|
|
206
|
+
# skills + prior-history text), and count client tools. Messages are the
|
|
207
|
+
# lane-independent-reduced set — what actually goes on the wire.
|
|
201
208
|
def estimate_request_tokens
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
estimated
|
|
209
|
-
estimated +=
|
|
209
|
+
# Mirror the dispatch shape EXACTLY: native_dispatch_messages sends
|
|
210
|
+
# @request.messages (lane-independent-reduced); the conversation
|
|
211
|
+
# history lives in the injected SYSTEM (via EnrichmentInjector), not
|
|
212
|
+
# in the messages array — counting it in both would double-count.
|
|
213
|
+
reduced = reduce_messages_for_dispatch(Array(@request.messages))
|
|
214
|
+
|
|
215
|
+
estimated = ContextAccounting.estimate_message_tokens(reduced)
|
|
216
|
+
estimated += ContextAccounting.estimate_text_tokens(routing_estimate_injected_system)
|
|
217
|
+
estimated += ContextAccounting.estimate_json_tokens(routing_estimate_tool_defs)
|
|
218
|
+
estimated += ContextAccounting.estimate_json_tokens(@request.tool_choice) if @request.tool_choice
|
|
219
|
+
estimated += ContextAccounting.estimate_json_tokens(@request.thinking) if @request.thinking.is_a?(Hash) && @request.thinking.any?
|
|
210
220
|
estimated
|
|
211
221
|
end
|
|
212
222
|
|
|
223
|
+
# The system prompt as it will be dispatched — baseline + GAIA + prior
|
|
224
|
+
# history + RAG + skills injected. EnrichmentInjector.inject is pure, so
|
|
225
|
+
# calling it here and again at dispatch has no side effects.
|
|
226
|
+
def routing_estimate_injected_system
|
|
227
|
+
EnrichmentInjector.inject(system: @request.system, enrichments: @enrichments || {})
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# The tool catalog dispatch will send (special + client + registry), NOT
|
|
231
|
+
# just @request.tools — the dispatch guard counts native_dispatch_tools,
|
|
232
|
+
# so routing must too or a heavy-tool request under-counts and picks a
|
|
233
|
+
# too-small lane. Built read-only here (never memoized) because
|
|
234
|
+
# native_tool_definitions caches and applies the local-provider cap
|
|
235
|
+
# against @resolved_provider, which is not set at routing time; an
|
|
236
|
+
# uncapped over-estimate is the safe direction for a "will it fit" filter.
|
|
237
|
+
def routing_estimate_tool_defs
|
|
238
|
+
defs = Tools::Special.pinned_definitions.map(&:to_h)
|
|
239
|
+
Array(@request.tools).each { |t| defs << (t.respond_to?(:to_h) ? t.to_h : t) }
|
|
240
|
+
Legion::Settings::Extensions.filter_tools(deferred: false).each { |e| defs << e } if registry_tool_injection_requested?
|
|
241
|
+
defs
|
|
242
|
+
rescue StandardError => e
|
|
243
|
+
handle_exception(e, level: :warn, handled: true, operation: 'llm.pipeline.routing_estimate_tool_defs')
|
|
244
|
+
Array(@request.tools).map { |t| t.respond_to?(:to_h) ? t.to_h : t }
|
|
245
|
+
end
|
|
246
|
+
|
|
213
247
|
def chain_required_capabilities
|
|
214
248
|
caps = []
|
|
215
249
|
caps << :streaming if @request.stream == true
|
|
@@ -350,6 +350,15 @@ module Legion
|
|
|
350
350
|
@context_accounting[:component_status][:archive] = :observed
|
|
351
351
|
end
|
|
352
352
|
|
|
353
|
+
# Kill the history double-send: a client-managed client (e.g. Claude
|
|
354
|
+
# Code) resends the full conversation in @request.messages every turn,
|
|
355
|
+
# and those go on the wire as structured messages. Injecting the same
|
|
356
|
+
# turns AGAIN as "Prior conversation history" system text (via
|
|
357
|
+
# EnrichmentInjector) would send them twice — real tokens, real cost,
|
|
358
|
+
# real context pressure. Only inject the prior turns the client did NOT
|
|
359
|
+
# resend: empty for a fully client-managed turn, the whole stored prefix
|
|
360
|
+
# for a server-managed client.
|
|
361
|
+
history = reject_client_resent_history(history)
|
|
353
362
|
@enrichments['context:conversation_history'] = history
|
|
354
363
|
@timeline.record(
|
|
355
364
|
category: :internal, key: 'context:loaded',
|
|
@@ -358,6 +367,21 @@ module Legion
|
|
|
358
367
|
)
|
|
359
368
|
end
|
|
360
369
|
|
|
370
|
+
# Drop stored-history messages the client already re-sent in
|
|
371
|
+
# @request.messages, matched by (role, content-text) fingerprint. Prevents
|
|
372
|
+
# the same turns reaching the provider twice (structured messages +
|
|
373
|
+
# injected system text). See step_context_load.
|
|
374
|
+
def reject_client_resent_history(history)
|
|
375
|
+
resent = Array(@request.messages).to_set { |m| history_dedup_key(m) }
|
|
376
|
+
Array(history).reject { |m| resent.include?(history_dedup_key(m)) }
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def history_dedup_key(msg)
|
|
380
|
+
role = (msg[:role] || msg['role']).to_s
|
|
381
|
+
content = msg.is_a?(Hash) ? (msg[:content] || msg['content']) : msg
|
|
382
|
+
[role, ContextAccounting.content_text(content).to_s.strip]
|
|
383
|
+
end
|
|
384
|
+
|
|
361
385
|
def maybe_compact_history(conv_id, history)
|
|
362
386
|
# Guard against recursive compaction: if this thread is already compacting,
|
|
363
387
|
# skip to prevent infinite loops when compressor calls back into chat_direct
|
|
@@ -22,6 +22,18 @@ module Legion
|
|
|
22
22
|
\s*<tool_use_value>(?<value>[^<]*)</tool_use_value>
|
|
23
23
|
}mx
|
|
24
24
|
|
|
25
|
+
# Leaked chat-template tool-call token some models (e.g. gemma via vLLM)
|
|
26
|
+
# emit as literal text instead of the structured tool_calls field. One
|
|
27
|
+
# block per tool call; captured live from the ledger (2026-07):
|
|
28
|
+
# <|tool_call>call:NAME{key:<|"|>value<|"|>,key2:<|"|>value2<|"|>}<tool_call|>
|
|
29
|
+
# LEAKED_TOKEN_RE captures NAME + the raw {..} body; LEAKED_TOKEN_ARG_RE
|
|
30
|
+
# walks the body as key:<|"|>value<|"|> pairs. The value capture is
|
|
31
|
+
# non-greedy and terminated only by the <|"|> delimiter, so embedded
|
|
32
|
+
# regular quotes and newlines (common in browser `code:` args) survive —
|
|
33
|
+
# a gsub-to-quotes + parse-as-object approach would break on those.
|
|
34
|
+
LEAKED_TOKEN_RE = /<\|tool_call>call:(?<name>[^{]+?)(?<body>\{.*?\})<tool_call\|>/m
|
|
35
|
+
LEAKED_TOKEN_ARG_RE = /(?<key>\w+):<\|"\|>(?<value>.*?)<\|"\|>/m
|
|
36
|
+
|
|
25
37
|
private
|
|
26
38
|
|
|
27
39
|
def execute_native_tool_loop
|
|
@@ -337,7 +349,7 @@ module Legion
|
|
|
337
349
|
# When the provider's response carries tool-call intent in plain text
|
|
338
350
|
# rather than the structured tool_calls field, synthesize a structured
|
|
339
351
|
# tool call so downstream translators emit the correct client-native
|
|
340
|
-
# shape.
|
|
352
|
+
# shape. Four formats are recognized (in order):
|
|
341
353
|
# 1. forced-choice JSON args (vLLM with tool_choice forcing a tool):
|
|
342
354
|
# result text starts with `{"` and parses to a Hash.
|
|
343
355
|
# 2. Qwen tag markup (no forced choice required):
|
|
@@ -346,6 +358,9 @@ module Legion
|
|
|
346
358
|
# 3. Qwen single-tag form (live capture from qwen3.6-27b 2026-06):
|
|
347
359
|
# <TOOL>arg-string</TOOL> — only synthesizable when the named
|
|
348
360
|
# tool's schema declares a single required string param.
|
|
361
|
+
# 4. Leaked chat-template token (gemma via vLLM, live capture 2026-07):
|
|
362
|
+
# <|tool_call>call:NAME{key:<|"|>value<|"|>,...}<tool_call|>
|
|
363
|
+
# The named tool must be in native_dispatch_tools.
|
|
349
364
|
#
|
|
350
365
|
# NONDETERMINISM NOTE: qwen3.6-27b also frequently emits plain
|
|
351
366
|
# narrative text (e.g. "Running `ls -la`...") with NO recoverable
|
|
@@ -364,7 +379,33 @@ module Legion
|
|
|
364
379
|
markup = synthesize_qwen_markup_tool_call(text, round)
|
|
365
380
|
return markup if markup.any?
|
|
366
381
|
|
|
367
|
-
synthesize_qwen_single_tag_tool_call(text, round)
|
|
382
|
+
single_tag = synthesize_qwen_single_tag_tool_call(text, round)
|
|
383
|
+
return single_tag if single_tag.any?
|
|
384
|
+
|
|
385
|
+
synthesize_leaked_token_tool_call(text, round)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Pattern 4. Parse leaked chat-template tokens
|
|
389
|
+
# (<|tool_call>call:NAME{key:<|"|>value<|"|>,...}<tool_call|>) into
|
|
390
|
+
# structured tool calls. Supports multiple tokens in one blob. Only
|
|
391
|
+
# synthesizes calls whose NAME is a known native tool; unknown names are
|
|
392
|
+
# skipped (the response surfaces as text). Arguments are always strings —
|
|
393
|
+
# that is all the token encodes.
|
|
394
|
+
def synthesize_leaked_token_tool_call(text, round)
|
|
395
|
+
synthesized = text.scan(LEAKED_TOKEN_RE).filter_map do |name, body|
|
|
396
|
+
name = name.to_s.strip
|
|
397
|
+
next if name.empty? || lookup_native_tool_definition(name).nil?
|
|
398
|
+
|
|
399
|
+
arguments = body.to_s.scan(LEAKED_TOKEN_ARG_RE).each_with_object({}) do |(key, value), acc|
|
|
400
|
+
acc[key.to_s.strip] = value.to_s
|
|
401
|
+
end
|
|
402
|
+
{ id: "call_#{SecureRandom.hex(10)}", name: name, arguments: arguments }
|
|
403
|
+
end
|
|
404
|
+
return [] if synthesized.empty?
|
|
405
|
+
|
|
406
|
+
log.info "[llm][native_tool_loop] action=synthesized_tool_call source=leaked_token round=#{round} " \
|
|
407
|
+
"count=#{synthesized.size} tools=#{synthesized.map { |s| s[:name] }.join(',')}"
|
|
408
|
+
synthesized
|
|
368
409
|
end
|
|
369
410
|
|
|
370
411
|
def synthesize_forced_choice_tool_call(text, round)
|
|
@@ -110,7 +110,11 @@ module Legion
|
|
|
110
110
|
patterns << name if text.match?(regex)
|
|
111
111
|
end
|
|
112
112
|
|
|
113
|
+
# rubocop:disable Style/ArrayIntersect -- substring check (each keyword
|
|
114
|
+
# `include?`d in the text), NOT array intersection. `intersect?` raises
|
|
115
|
+
# TypeError on the String arg.
|
|
113
116
|
phi_found = PHI_KEYWORDS.any? { |kw| text.downcase.include?(kw) }
|
|
117
|
+
# rubocop:enable Style/ArrayIntersect
|
|
114
118
|
patterns << :phi_keyword if phi_found
|
|
115
119
|
if text.match?(EMAIL_PATTERN) && (standalone_email_pii? || phi_found || patterns.any?)
|
|
116
120
|
patterns.delete(:email)
|
|
@@ -54,22 +54,16 @@ module Legion
|
|
|
54
54
|
"session token budget exceeded: #{total} >= #{limit}"
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
+
# One oracle: same estimator the router filter and dispatch guard use.
|
|
58
|
+
# ContextAccounting walks canonical structs / content blocks; the
|
|
59
|
+
# injected system carries baseline + history + RAG + skills.
|
|
57
60
|
def estimate_input_tokens
|
|
58
|
-
content_chars = @request.messages.sum { |m| message_content_chars(m[:content]) }
|
|
59
61
|
injected_system = EnrichmentInjector.inject(
|
|
60
62
|
system: @request.system,
|
|
61
63
|
enrichments: @enrichments || {}
|
|
62
64
|
)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def message_content_chars(content)
|
|
68
|
-
return content.to_s.length unless content.is_a?(Array)
|
|
69
|
-
|
|
70
|
-
content.sum do |part|
|
|
71
|
-
part.is_a?(Hash) ? (part[:text] || part['text']).to_s.length : part.to_s.length
|
|
72
|
-
end
|
|
65
|
+
ContextAccounting.estimate_message_tokens(@request.messages) +
|
|
66
|
+
ContextAccounting.estimate_text_tokens(injected_system)
|
|
73
67
|
end
|
|
74
68
|
end
|
|
75
69
|
end
|
data/lib/legion/llm/inventory.rb
CHANGED
|
@@ -206,6 +206,9 @@ module Legion
|
|
|
206
206
|
|
|
207
207
|
# Whitelist takes precedence over blacklist (M2). Substring match —
|
|
208
208
|
# a model is denied if NO whitelist pattern is contained in its name.
|
|
209
|
+
# rubocop:disable Style/ArrayIntersect -- substring checks (patterns
|
|
210
|
+
# `include?`d in the model name), NOT array intersection; `intersect?`
|
|
211
|
+
# raises TypeError on the String arg.
|
|
209
212
|
if sets[:whitelist]
|
|
210
213
|
return true if sets[:whitelist].none? { |p| model.include?(p) }
|
|
211
214
|
|
|
@@ -213,6 +216,7 @@ module Legion
|
|
|
213
216
|
end
|
|
214
217
|
|
|
215
218
|
sets[:blacklist]&.any? { |p| model.include?(p) } || false
|
|
219
|
+
# rubocop:enable Style/ArrayIntersect
|
|
216
220
|
end
|
|
217
221
|
|
|
218
222
|
# Specificity cascade — same precedence as lex-llm Provider#model_whitelist:
|
data/lib/legion/llm/router.rb
CHANGED
|
@@ -209,8 +209,15 @@ module Legion
|
|
|
209
209
|
|
|
210
210
|
return false if thinking == :require && !available.include?(:thinking)
|
|
211
211
|
|
|
212
|
+
# Apply the same headroom the dispatch budget guard uses: a lane is only
|
|
213
|
+
# eligible when estimated_context fits within context_window * headroom.
|
|
214
|
+
# Keeps routing and RouteAttempts#enforce_final_context_budget! in
|
|
215
|
+
# agreement so the router never picks a lane the pre-dispatch guard rejects.
|
|
212
216
|
context_window = lane.dig(:limits, :context_window)
|
|
213
|
-
|
|
217
|
+
if estimated_context && context_window
|
|
218
|
+
usable = (context_window.to_i * context_headroom).to_i
|
|
219
|
+
return false if usable < estimated_context
|
|
220
|
+
end
|
|
214
221
|
return false if privacy == :strict && %i[cloud frontier].include?(lane[:tier])
|
|
215
222
|
|
|
216
223
|
true
|
|
@@ -229,6 +236,18 @@ module Legion
|
|
|
229
236
|
model
|
|
230
237
|
end
|
|
231
238
|
|
|
239
|
+
# Fraction of a lane's context_window the router treats as usable when
|
|
240
|
+
# applying the estimated_context filter. Mirrors the dispatch-time budget
|
|
241
|
+
# guard so routing and dispatch agree on what fits. Clamped to (0, 1].
|
|
242
|
+
def context_headroom
|
|
243
|
+
value = Legion::Settings.dig(:llm, :routing, :context_headroom)
|
|
244
|
+
value = value.to_f
|
|
245
|
+
value.positive? && value <= 1.0 ? value : 1.0
|
|
246
|
+
rescue StandardError => e
|
|
247
|
+
handle_exception(e, level: :warn, handled: true, operation: 'router.context_headroom')
|
|
248
|
+
1.0
|
|
249
|
+
end
|
|
250
|
+
|
|
232
251
|
def default_rng
|
|
233
252
|
@default_rng ||= Random.new
|
|
234
253
|
end
|
data/lib/legion/llm/settings.rb
CHANGED
|
@@ -220,9 +220,18 @@ module Legion
|
|
|
220
220
|
# Used by Inventory.write_lane to compute lane_weight = tier_w * provider_w * instance_w * model_w * health_mult.
|
|
221
221
|
tier_weights: { direct: 105, local: 110, fleet: 110, cloud: 120, frontier: 150 },
|
|
222
222
|
max_attempts: 3,
|
|
223
|
+
# Fraction of a lane's context_window the router treats as usable when
|
|
224
|
+
# applying the estimated_context hard filter. A lane is excluded when
|
|
225
|
+
# estimated_context >= context_window * context_headroom. Mirrors the
|
|
226
|
+
# dispatch-time headroom (context_curation.context_window_threshold and
|
|
227
|
+
# RouteAttempts#enforce_final_context_budget!'s 0.90) so routing and
|
|
228
|
+
# dispatch agree on what "fits" — a lane the router picks must still
|
|
229
|
+
# clear the pre-dispatch budget guard. 1.0 disables headroom.
|
|
230
|
+
context_headroom: 0.90,
|
|
223
231
|
# Body-level routing hints are gated by this flag. Auto-routing aliases
|
|
224
232
|
# like legionio/auto are still accepted as "you pick" intent.
|
|
225
233
|
allow_body_routing_hints: false,
|
|
234
|
+
model_passthrough_ids: %w[copilot-utility-small],
|
|
226
235
|
auto_routing_model_aliases: %w[legionio auto],
|
|
227
236
|
default_intent: { privacy: 'normal', effort: 'moderate', operation: 'chat', cost: 'normal' },
|
|
228
237
|
# Last-resort fallback model when both `default_model` and the
|
|
@@ -409,6 +418,23 @@ module Legion
|
|
|
409
418
|
superseded_eviction: true,
|
|
410
419
|
dedup_enabled: true,
|
|
411
420
|
dedup_threshold: 0.85,
|
|
421
|
+
# Strip client-harness noise that accumulates unbounded in the
|
|
422
|
+
# mid-conversation system role (Claude Code task nags, linter file
|
|
423
|
+
# dumps, etc.). See GH#168. Two mechanisms, both system-role only:
|
|
424
|
+
# 1. exact-dedup: a verbatim-duplicate system message is dropped
|
|
425
|
+
# (the first occurrence is kept).
|
|
426
|
+
# 2. pattern strip: a system message whose content matches any
|
|
427
|
+
# substring in harness_noise_patterns is dropped entirely.
|
|
428
|
+
# Fail-safe: an unrecognized, non-duplicate system message is never
|
|
429
|
+
# touched. The pattern list is designed to grow over time as new
|
|
430
|
+
# harness injections are identified.
|
|
431
|
+
harness_noise_strip: true,
|
|
432
|
+
harness_noise_patterns: [
|
|
433
|
+
# Claude Code "task tools haven't been used recently" nag.
|
|
434
|
+
"task tools haven't been used recently",
|
|
435
|
+
# Claude Code linter/formatter file-dump note.
|
|
436
|
+
'was modified, either by the user or by a linter'
|
|
437
|
+
],
|
|
412
438
|
target_context_tokens: 60_000,
|
|
413
439
|
context_window_threshold: 0.90,
|
|
414
440
|
archive_dropped_turns: true,
|
data/lib/legion/llm/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legion-llm
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.14.
|
|
4
|
+
version: 0.14.15
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -191,8 +191,12 @@ dependencies:
|
|
|
191
191
|
- - ">="
|
|
192
192
|
- !ruby/object:Gem::Version
|
|
193
193
|
version: '2.0'
|
|
194
|
-
description:
|
|
195
|
-
|
|
194
|
+
description: A proxy between AI clients and model backends. Requests translate once
|
|
195
|
+
to a canonical form, route to the cheapest capable tier (local, direct, fleet, cloud,
|
|
196
|
+
frontier), and escalate on failure with circuit breakers and mid-stream failover.
|
|
197
|
+
A post-turn Curator bounds long agent sessions (97.7% context reduction vs naive
|
|
198
|
+
resend at 50+ turns, production-measured). Provider adapters are separate lex-llm-*
|
|
199
|
+
gems.
|
|
196
200
|
email:
|
|
197
201
|
- matthewdiverson@gmail.com
|
|
198
202
|
executables: []
|
|
@@ -220,6 +224,7 @@ files:
|
|
|
220
224
|
- bin/h200-setup-prereboot.sh
|
|
221
225
|
- bin/h200-setup-remaining-prereboot.sh
|
|
222
226
|
- bin/h200-setup-resume-safe.sh
|
|
227
|
+
- docs/curation-production-metrics.md
|
|
223
228
|
- docs/work/planning/p1-results.md
|
|
224
229
|
- docs/work/planning/p2-results.md
|
|
225
230
|
- docs/work/planning/p3-results.md
|
|
@@ -470,5 +475,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
470
475
|
requirements: []
|
|
471
476
|
rubygems_version: 3.6.9
|
|
472
477
|
specification_version: 4
|
|
473
|
-
summary: LLM routing
|
|
478
|
+
summary: 'LLM gateway: tiered routing, mid-stream failover, and context curation in
|
|
479
|
+
one proxy'
|
|
474
480
|
test_files: []
|