Skip to content

plugin: add model-sequence-router with per-slot effort tiers - #5163

Open
KJ7LNW wants to merge 12 commits into
router-for-me:devfrom
KJ7LNW:model-sequence-router
Open

KJ7LNW wants to merge 12 commits into
router-for-me:devfrom
KJ7LNW:model-sequence-router

Conversation

@KJ7LNW

@KJ7LNW KJ7LNW commented Aug 22, 2026 •

Copy link
Copy Markdown

Description

A model alias rotates a conversation across an ordered list of provider and model targets, so each turn is answered by a different model family and catches what the last one missed. Load spreads across providers, too.

Rotation needs every position at a comparable level, but an effort token means a different amount of reasoning on each family. For example, Claude Opus 5 at medium matches GPT‑5.6 Sol at high, and Opus 5 at high matches Sol at xhigh. Each slot therefore takes an efforts map that remaps a level, redirects it to another model of the same provider, or both. Unwritten levels pass through.

Type of Change

  • Feature (non-breaking change that adds functionality)

Implementation Details

  • Routing

    • alias lookup strips a supported effort suffix, so alias(high) and alias reach one configuration entry
    • a per-conversation cursor walks the sequence and skips any target whose provider the host reports unavailable; every successful selection advances the cursor
    • requests without a session identifier take the first available target and move no cursor
  • Effort tiers

    • targets[].efforts keys a discrete level to an optional model of the same slot provider and an optional effort; a bare level is shorthand for effort only
    • a tier cannot name a provider, so the slot provider remains the root of its definition and availability never varies with the requested effort
    • compiledTarget.effectiveModel is the single producer of the emitted model string; a suffix written on the model outranks the tier effort, which outranks the request
    • numeric budgets, none, auto, and an absent suffix carry no level, match no tier, and pass through on the slot model
  • Validation during configuration compile

    • unrecognized effort keys, non-canonical spellings, unknown tier keys, blank tier models, and empty tier bodies are rejected rather than discarded, because the surrounding decode is non-strict
    • max is reserved: a tier emits max for a max request and for no other, so a model reaches its own ceiling exactly when asked
    • effort-only tiers must not decrease across the canonical level order

Worked example

Nothing below is a default. The plugin ships no aliases, no models, and no effort tiers; every value here illustrates the mechanism, and the model names stand in for whatever a deployment has credentials for.

Illustrative intelligence scores

An example measurement set, used to derive the tiers that follow.

Score Model Effort Cost
24 Claude Haiku 4.5 non-reasoning not published
33 Luna low $68.80
37 Claude Haiku 4.5 reasoning $619.69
38 Luna medium $105.84
40 Terra low $160.65
46 Terra medium $240.23
46 Luna high $275.02
49 Sol low $353.49
49 Terra high $495.77
51 Luna max $870.30
52 Terra xhigh $740.21
53 Claude Sonnet 5 max $4010.12
54 Sol medium $593.04
55 Terra max $2060.40
56 Sol high $955.55
56 Claude Opus 4.8 max $3752.55
58 Sol xhigh $1542.52
59 Sol max $2824.18
60 Claude Fable 5 max, with fallback $5630.52

example alias: mist — entry tier, no remapping

- alias: "mist"
  display_name: "Mist"
  targets:
    - provider: "codex"
      model: "gpt-5.6-luna"
      repeat: 1
    - provider: "claude"
      model: "claude-haiku-4-5-20251001"
      repeat: 1

Haiku 4.5 carries no effort ladder: one non-reasoning point at 24 and one reasoning point at 37, with nothing between. A tier map built from a two-point curve would collapse every sub-max request onto a single Luna setting and discard the requested level. Luna spans 33 at low to 51 at max on its own, so passthrough preserves the caller intent that any remap here would erase.

example alias: jade — balanced tier, no remapping

- alias: "jade"
  display_name: "Jade"
  targets:
    - provider: "codex"
      model: "gpt-5.6-terra"
      repeat: 1
    - provider: "claude"
      model: "claude-sonnet-5"
      repeat: 1

Terra tracks its Claude counterpart at the only level both publish: Terra max scores 55 against Sonnet 5 max at 53. No level shows Terra trailing, so a tier would raise cost without raising parity. Terra high already returns 49 for $495.77 against Terra medium at 46 for $240.23, making an upward remap here expensive for three points.

example alias: opal — frontier tier, GPT slot retuned

- alias: "opal"
  display_name: "Opal"
  targets:
    - provider: "codex"
      model: "gpt-5.6-sol"
      repeat: 1
      efforts:
        medium: "high"
        high: "xhigh"
    - provider: "claude"
      model: "claude-opus-5"
      repeat: 1

Resolution for each requested level:

Requested Position 0 emitted Sol score Position 1 emitted Rule
low gpt-5.6-sol(low) 49 claude-opus-5(low) passthrough
medium gpt-5.6-sol(high) 56 claude-opus-5(medium) tier
high gpt-5.6-sol(xhigh) 58 claude-opus-5(high) tier
xhigh gpt-5.6-sol(xhigh) 58 claude-opus-5(xhigh) passthrough
max gpt-5.6-sol(max) 59 claude-opus-5(max) reserved
8000 gpt-5.6-sol(8000) — claude-opus-5(8000) outside the level lattice
none gpt-5.6-sol — claude-opus-5 absent suffix

The Sol ladder rises steeply at the bottom and flattens at the top: 49, 54, 56, 58, 59 across low through max. Two adjacent steps in the middle span two points each, so a request landing on Sol medium sits five points below the same request landing on Sol xhigh, while the Claude model in the same rotation answers near its own ceiling at every label. Shifting medium and high one rung up the Sol ladder closes that band without touching the ends.

The ends stay unwritten deliberately. low passes through because the 49 rung is the cheapest frontier entry and remapping it upward would nearly triple its cost. max reaches Sol max at 59 under the reservation rule. xhigh needs no entry because it already resolves to the 58 rung that high was raised to.

Replacing the model for one effort

A tier may name a different model of the same provider instead of, or in addition to, an effort, which suits a model whose strength sits at one point of the ladder rather than across it. Two tiers on Opal's Claude slot substitute the prior generation, leaving the codex slot above as written:

    - provider: "claude"
      model: "claude-opus-5"
      repeat: 1
      efforts:
        medium:
          model: "claude-opus-4-8"
          effort: "medium"
        high:
          model: "claude-opus-4-8"
Requested Position 1 emitted Tier form
low claude-opus-5(low) no tier, passthrough
medium claude-opus-4-8(medium) model and effort
high claude-opus-4-8(high) model only, caller effort carried across
max claude-opus-5(max) no tier, reserved

example alias: ruby — maximum tier, both GPT slots retuned

- alias: "ruby"
  display_name: "Ruby"
  targets:
    - provider: "codex"
      model: "gpt-5.6-sol"
      repeat: 1
      efforts:
        medium: "high"
        high: "xhigh"
    - provider: "claude"
      model: "claude-opus-5"
      repeat: 1
    - provider: "codex"
      model: "gpt-5.6-sol"
      repeat: 1
      efforts:
        medium: "high"
        high: "xhigh"
    - provider: "claude"
      model: "claude-opus-5"
      repeat: 1
    - provider: "claude"
      model: "claude-fable-5"
      repeat: 1

Ruby interleaves five positions so a long conversation alternates providers four times before reaching Fable 5, whose max score of 60 is the highest recorded and whose cost of $5630.52 is likewise the highest. Placing it last spreads four cheaper turns ahead of every expensive one.

Both Sol slots carry identical tiers for the reason given under Opal, written out rather than shared through a YAML anchor because a management-panel rewrite of the configuration would not preserve an anchor. The Claude slots carry no tiers and answer at the requested level.

Reproduction

Requesting opal(high) on successive turns of one conversation, taken from a running deployment:

Turn Position Provider Emitted model Level applied upstream
1 0 codex gpt-5.6-sol(xhigh) xhigh
2 1 claude claude-opus-5(high) high

Testing

  • Tests pass locally
  • New tests added for new functionality

go test -race ./... under examples/plugin/model-sequence-router/go covers cursor wrap, TTL expiry, random start, provider skipping, concurrent reservation, the four tier forms, and each rejection case listed above.

@github-actions
github-actions Bot changed the base branch from main to dev August 22, 2026 00:30
@github-actions

Copy link
Copy Markdown

This pull request targeted main.

The base branch has been automatically changed to dev.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1c6c7af8c0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread examples/plugin/model-sequence-router/go/router.go Outdated
Comment thread examples/plugin/model-sequence-router/go/router.go Outdated
Comment thread examples/plugin/model-sequence-router/go/main.go Outdated
Comment thread examples/plugin/model-sequence-router/README.md Outdated
Comment thread examples/plugin/Makefile
@KJ7LNW

KJ7LNW commented Aug 30, 2026 •

Copy link
Copy Markdown
Author

Went through the review. Four of the five are real and I've fixed them. The Makefile one is real too but I'm leaving it alone; reasoning at the bottom.

Session identity. Confirmed, and it's a bit worse than described. With no session header, ExtractSessionID falls back to a content hash that folds in the first assistant reply, so a conversation has one identity on turn 1 and a different one on turn 2, and both turns answer from slot 0. I didn't want to key on core's short hash instead, since that's the first 100 chars of system + first user message and would merge two conversations that happen to open the same way. So the plugin now derives its own identity from the system fingerprint plus the first history item when core hands back a msg: hash, and passes any durable client-supplied id through untouched. New file session_identity.go.

count_tokens burning a slot. Also confirmed. handlers_execution.go:115 runs the router for count requests exactly like generation, and the old state.go advanced on every successful pick, so a counting client measured slot N and generated on N+1. On a two-slot alias slot 0 never generated at all.

I tried the obvious thing first (infer "this is a count call" from Stream/SourceFormat) and threw it away: it misfires on any real non-streaming generation and nails that conversation to one slot. What's there now doesn't classify the call at all. The cursor remembers the conversation state that produced its last pick, and a request showing the same state gets the same slot back without moving anything. Count and the generation behind it carry identical history, so the pair costs one slot and the plugin never has to know which is which.

That does change the rule I wrote in the PR description: it's now "advance once per changed conversation state", not "advance on every selection". Retries and parallel calls with the same history land on the same provider on purpose, which the prompt cache absorbs. README updated to say so.

Reset ordering. Yep. configure now clears the cursor and observation maps before storing the new config. Anything an in-flight route writes under the old generation is keyed by that generation, so nothing new reads it and the sweep drops it.

README pointing at scripts that don't exist. My fault, those live in my deployment tree and were never meant to be the documented path. Replaced with the plain module commands:

mkdir -p ../../bin
go test -race ./...
go build -buildmode=c-shared -o ../../bin/model-sequence-router-go.so .

The Makefile. The observation is correct: the matrix crosses every EXAMPLES entry with go/c/rust and this example is Go only. But it's not new here. claude-web-search-router, host-callback-auth-files and host-model-callback are already in that list with no c/ or rust/ dir, nothing in .github/workflows calls examples/plugin/Makefile, and the build check on this PR is green. Fixing the matrix means touching how four unrelated examples build, which feels like its own PR rather than something to bury in this one. I did make sure the README doesn't send anyone at the broken target.

Two things I hit while doing this that I haven't touched, in case they're useful:

  • internal/pluginhost/model_router.go:88 turns a router error into a decline, so a router plugin can't return an HTTP status from model.route at all. Workaround here is a self-targeted route into the plugin's own executor, where the error survives executionErrorMessage and comes out with the status intact.
  • normalizeExecutorFormats drops format names it doesn't recognize without saying anything, so a typo in a capability declaration quietly narrows what the executor will accept.

One thing I added beyond the review: a plugin-level unavailable_provider, default skip, which keeps today's behavior but now logs a warning per skipped slot. Set it to error and a conversation only ever probes its own next slot; if that provider isn't registered the router consumes no slot and the client gets a 529, so the retry comes back to the same slot. The executor capability that requires is derived from the config, not declared unconditionally, since declaring it changes how RegisterModels binds models — a skip config sends the exact same registration payload as before.

Tests, gofmt, the c-shared build and go build ./cmd/server are all clean. Nothing outside examples/plugin/model-sequence-router/ changed.

KJ7LNW pushed a commit to KJ7LNW/CLIProxyAPI that referenced this pull request Aug 30, 2026
An automated review of router-for-me#5163 reported 5 findings against the
model-sequence-router plugin. 4 identify plugin-local defects, corrected below; the 5th concerns
the shared plugin build matrix. Sequence ownership now follows the observed conversation state, so
auxiliary calls and retries hold the selected slot while genuine turns continue through the
configured order.

- finding 1, a headerless conversation took a different identity on each of its first 2 turns:
  derive the identity from the system content and first history item, which hold constant for the
  life of a conversation, and report the identity origin on every route record.
- finding 2, a token count consumed a sequence position: key selection on the observed history so
  a repeated state replays its slot, which keeps call classification out of plugin policy.
- finding 3, reconfiguration published a generation before clearing state: clear cursor and lane
  state first so a concurrent route keeps its reservation.
- finding 4, the README named absent build, launcher, and deployment assets: document the direct
  Go build, artifact deployment, and jq inspection of the diagnostics file.
- finding 5, the plugin build matrix multiplies Go-only entries by every language: this stays
  shared repository scope and keeps the registration line already present there.
- add the plugin-level on_unavailable setting, selecting between a forward scan that emits 1 notice
  per passed-over position and a held position answered with a retryable HTTP 529.
- separate request inspection and plugin RPC into their own files, and derive the executor
  capability from the compiled policy so registration matches the configured behavior.
- extend routing, cursor, configuration, identity, and RPC tests over replay and unavailability.
- bump the reported plugin version to 0.9.0 for the changed advancement contract.

Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
@KJ7LNW

KJ7LNW commented Aug 30, 2026

Copy link
Copy Markdown
Author

pushed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f7fbc257a5

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread examples/plugin/model-sequence-router/go/diagnostics.go
Comment thread examples/plugin/model-sequence-router/go/session_identity.go Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 63ae213b55

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread examples/plugin/model-sequence-router/go/request_observation.go Outdated
@KJ7LNW

KJ7LNW commented Aug 31, 2026 •

Copy link
Copy Markdown
Author

Addressed both P1 findings in commit 4870a22.

  • Diagnostic lane observations now implement the same expiring-store contract as conversation cursors and are registered in the periodic cleanup loop. Controlled-clock coverage verifies that expired lanes are removed while an unexpired lane retains continuity.
  • Router cursor identity now comes only from the shared protocol-aware conversation derivation in sdk/cliproxy/session. The plugin no longer consumes the primary credential-affinity identity, so adding prompt_cache_key cannot change the cursor key or restart YAML sequence progression.

Credential affinity still keeps its primary and fallback aliases in the authentication selector, where they select credentials within the provider and model already chosen by YAML. This preserves provider-local cache affinity without allowing cache identity to pin or replace provider order.

The plugin race suite and focused identity, sequence, and cleanup regression tests pass.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ebe94442c8

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread examples/plugin/model-sequence-router/go/request_observation.go Outdated
@KJ7LNW

KJ7LNW commented Sep 3, 2026

Copy link
Copy Markdown
Author

This plugin will be even more effective once #4731 and/or #5446 land. If you're alternating between models, typically five minutes is plenty of cash for the cash to stay hot. But if you're alternating across several different models across several different providers, it starts to spread out far enough that you might have cache misses.

If the one hour cache management pull requests land, then everything will work just fine, even across more complicated round-robin models like the ruby example above.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a2353d9f28

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread examples/plugin/model-sequence-router/go/session_identity.go Outdated
Eric Wheeler added 9 commits September 22, 2026 15:56
The shared examples/plugin/Makefile treated every example as buildable in
Go, C, and Rust, but several examples (frontend-auth-exclusive,
codex-service-tier, scheduler, host-callback-auth-files,
host-model-callback, claude-web-search-router, and the new
model-sequence-router) are Go-only, so the flat EXAMPLES list produced
targets and list output for languages those examples cannot build.

- split EXAMPLES into MULTI_LANGUAGE_EXAMPLES and GO_ONLY_EXAMPLES, deriving
  EXAMPLES from both so existing consumers keep working
- limit the build and list targets to Go-only artifacts for GO_ONLY_EXAMPLES
  instead of iterating all three languages
- make BIN_DIR relative instead of anchored to $(CURDIR)
- document the new model-sequence-router Go-only example in the plugin
  README

Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
The router previously gated cursor advancement on request operation
(generate vs count_tokens), letting count_tokens requests peek at the
current position without moving it. This split the cursor and
observation stores' advance behavior across an operation-derived
flag threaded through selectTarget and observe, adding a code path
that diverged from the simpler contract of "each successful selection
advances."

- drop the operation/advance plumbing from routeWithCallback,
  selectTarget, and observe; every successful selection now advances
  and refreshes its cursor unconditionally
- derive the logged "advanced" field from session presence instead of
  operation type, since stateless routing is the only case that does
  not move a cursor
- drop ResponseModel from the route result; response model
  presentation stays with the host and upstream provider
- add an explicit event=config discriminator to the startup/reload
  log record
- update README to match the simplified advance semantics and log
  fields

Fixes: router-for-me#4698
Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
…te split

The router previously carried a peek-versus-generate distinction through the
cursor store and only forwarded a caller thinking suffix verbatim to the
selected slot model. This left no way for one rotation slot to answer a
requested effort with a different model or a different level, and the extra
peek/generate parameter shape gave no behavioral value since every code path
advanced the cursor the same way.

- add an `efforts` map to target configuration, refining a rotation slot per
  requested level with an optional model of the same provider and an optional
  effort, validated for known keys, the reserved `max` level, and
  non-decreasing effort across untiered-model tiers
- resolve the emitted model through `compiledTarget.effectiveModel`, replacing
  the inline suffix-append in the router
- drop the `selectTarget` peek/generate boolean; every selection advances the
  cursor, since no caller relied on a non-advancing read
- fold the alias, sequence position, and requested effort into the debug log
  message, since a host chooses independently which structured fields to
  print
- bump the plugin version to 0.7.0 and add the transitive `gorilla/websocket`
  dependency pulled in by the change
- document the effort-tier resolution table, reserved `max` semantics, and the
  expanded debug log format in the README

Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
An automated review of router-for-me#5163 reported 5 findings against the
model-sequence-router plugin. 4 identify plugin-local defects, corrected below; the 5th concerns
the shared plugin build matrix. Sequence ownership now follows the observed conversation state, so
auxiliary calls and retries hold the selected slot while genuine turns continue through the
configured order.

- finding 1, a headerless conversation took a different identity on each of its first 2 turns:
  derive the identity from the system content and first history item, which hold constant for the
  life of a conversation, and report the identity origin on every route record.
- finding 2, a token count consumed a sequence position: key selection on the observed history so
  a repeated state replays its slot, which keeps call classification out of plugin policy.
- finding 3, reconfiguration published a generation before clearing state: clear cursor and lane
  state first so a concurrent route keeps its reservation.
- finding 4, the README named absent build, launcher, and deployment assets: document the direct
  Go build, artifact deployment, and jq inspection of the diagnostics file.
- finding 5, the plugin build matrix multiplies Go-only entries by every language: this stays
  shared repository scope and keeps the registration line already present there.
- add the plugin-level on_unavailable setting, selecting between a forward scan that emits 1 notice
  per passed-over position and a held position answered with a retryable HTTP 529.
- separate request inspection and plugin RPC into their own files, and derive the executor
  capability from the compiled policy so registration matches the configured behavior.
- extend routing, cursor, configuration, identity, and RPC tests over replay and unavailability.
- bump the reported plugin version to 0.9.0 for the changed advancement contract.

Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
The plugin-level setting deciding what happens when the next sequence
position names a provider the host has not registered was spelled as an
event handler, and its second value named a status code rather than a
behavior. Neither spelling matched the convention used by the other
plugin examples, where settings are noun phrases naming their subject.

The setting now names the subject the selection code actually consults,
which is the provider availability set supplied by the host, and its
second value names what the plugin does with an unavailable provider,
which is to report an error. Routing behavior, the retryable status, and
the envelope error code are unchanged.

- rename the setting key to unavailable_provider
- rename its second accepted value to error
- rename the action constants and the compiled configuration field
- restate the capability description and the README policy section
- update the tests and documentation covering the policy

Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
The plugin keyed its sequence cursor on the credential-affinity identity exported by core.
That accessor returns only the primary value of a paired extraction, so a conversation that
first carried conversation.id and later added prompt_cache_key changed key from conv: to pck:
and restarted its sequence mid-conversation. The same ladder exposed transport and credential
selection concerns to the routing layer. Cursor identity now comes from the shared protocol
aware conversation derivation, which reads caller scope, leading instructions, and the first
user input across every inbound format. Diagnostic lane snapshots separately accumulated until
reconfiguration, because the periodic loop swept cursors alone.

- Derive conversation identity through sdk/cliproxy/session, so no transport header, cache lane,
  affinity group, or account identifier reaches a cursor key
- Collapse conversationIdentity to a string and compute its diagnostic source at use, because the
  origin is no longer independent state
- Add an expiringStore contract so one periodic sweep releases both cursors and diagnostic lane
  observations under a single interval
- Group the lane store methods beside their type and add size readouts that controlled-clock tests
  observe directly
- Split router and state tests by purpose into cursor probe, observation, and identity files to
  keep each file within the module size limit
- Share one conversation transcript fixture across tests so identity cases exercise the derivation
  rather than request headers
- Raise the plugin version to 0.10.0 for the changed identity contract
- Document YAML-first provider selection, credential-only affinity, and the content-derived cursor
  limitation
- Tidy the plugin module graph after the credential-affinity import was dropped

Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
Scalar input on the Responses API bypassed history comparison, so a repeated
scalar prompt was treated as a new turn rather than a replay of the same
conversation state.

- fold scalar Responses `input` into the same user-message shape used by array
  input so scalar and array requests share one observed turn
- add a test verifying scalar and array input replay identically before an
  extended transcript advances the cursor

Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
…istory

A single Interactions turn is valid as an object carrying steps, and the shared
protocol derivation flattens that object into a conversation identity. Turn
observation accepted only arrays and scalar strings, so such a request received a
cursor while carrying no observed history. Identical retries then presented no
matching turn and consumed the next sequence position, moving the conversation to
another provider between attempts.

- Recognize a non-empty object under input as one observed history item, so every
  encoding of one conversation state resolves to the same turn
- Restructure the history converter as an exhaustive type switch, so absent keys,
  empty values, and unsupported shapes each reach a stated outcome
- Cover object retry, single-element array equivalence, and later transcript
  advancement through route records
- Share one route record assertion across the observation and identity tests, so
  expected outcome and position travel as one named record
- Name the conversation identity contract version 0.11.0

Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
…hain

Requests naming previous_response_id carry only their newest items while the
provider holds the rest of the transcript, so deriving identity from body
content alone assigned each such request a distinct conversation, breaking
sequence routing and diagnostics for incremental continuation.

- add continuationChainStore holding in-flight requests by request ID and
  provider responses by response ID, so a later request naming a response
  resolves the conversation that response belongs to
- register request and stream-chunk interceptor capabilities and observe
  their payloads without modifying them
- derive requestObservation.PreviousResponseID as trimmed text instead of a
  presence boolean, letting the router look up its bound conversation
- add response_diagnostics.go logging replay-input and reasoning-item shapes
  at stream boundaries, hashing identifiers so no payload content reaches
  the journal
- filter model_sequence_observation_test assertions to route-only records
  now that context diagnostics share the same log stream
- bump plugin version to 0.16.0

Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
@KJ7LNW
KJ7LNW force-pushed the model-sequence-router branch from a2353d9 to ae1cbdf Compare September 23, 2026 01:30
@KJ7LNW

KJ7LNW commented Sep 23, 2026

Copy link
Copy Markdown
Author

rebased and force-pushed

Eric Wheeler added 3 commits September 22, 2026 18:56
Non-streamed responses reach the plugin only once, as a complete reply
alongside the originating client request, so the earlier stream-chunk-only
binding path left continuation turns on non-streaming providers unable to
find their conversation.

- add a response interceptor capability and RPC handler so the host
  delivers the complete reply and originating request together
- factor bindResponseIdentity out of observeStreamChunk so both streamed
  and non-streamed paths share the same conversation-binding logic
- add observeCompleteResponse to resolve the conversation from the
  originating request and bind the reply id to it in one call
- bump plugin version to 0.17.0
- add tests covering conversation binding and sequence walking across
  non-streamed continuation turns

Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
Context diagnostic records reached the journal whenever a context alias was
requested, with no configuration flag to suppress them. This exposed
reasoning-shape records by default instead of only on request.

- add diagnostics.context config field, defaulting off, to gate context record emission
- check cfg.Diagnostics.Context in observeRequestContext and observeResponseContext before emitting
- thread compiledConfig into observeResponseContext so the stream-chunk path can read the flag
- document the diagnostics.context field and its journal-only, no-path behavior in README
- extract newContextDiagnosticPayloads helper so both gated and ungated tests share one payload set
- add TestContextDiagnosticsStaySilentWhenUnrequested to prove silence when the flag is unset
- bump plugin version to 0.18.0

Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
A conversation rotating between providers replays the reasoning one provider emitted into the
next provider input. The receiving upstream rejects it, for example Codex answering
400 Unknown parameter: 'input[1].status' after a Claude turn, and the turn is lost. The router now
records which lane produced each reasoning item and gives every target a projection of the
transcript that keeps its own reasoning and withholds the reasoning of other lanes. All other items
stay in place, so the answers of both providers stay interleaved and the cached prefix of each lane
grows only at its end.

- Record each finished reasoning item against the provider and base model that served it, so
  ownership rests on observed dispatch rather than on reading signatures.
- Remove reasoning items owned by another lane or by no recorded lane after credential
  selection, because a foreign item fails the turn while a withheld one costs one cache miss.
- Keep reasoning items that carry no identifier, since nothing shows where they came from.
- Resolve the lane of a request through one mapping shared by recording and removal, and resolve
  none when zero or several providers serve the model, so a degenerate sequence attributes
  nothing.
- Extend an ownership record on each lookup, so reasoning expires after its conversation idles
  for the session TTL rather than a fixed time after it was produced.
- Return an edit failure as a hook error, so the host logs it and forwards the request unchanged.
- Clear ownership records on reconfiguration and shutdown and sweep them beside cursors and
  chains, so memory tracks live conversations.
- Share one history field order between request observation and rewriting, and one expiry check
  across stored entry types, so the new store reuses both.
- Add the target lane, withheld and kept counts, and producing lane to the context diagnostics,
  so each replay shows the projection it received.
- Promote gjson and sjson to direct requirements, since removal edits arrays in place.
- Cover withholding with byte-identical survivors, return to the producing lane, each evidence
  state, renewal, and idle expiry through the plugin methods.
- Document the projection and its lifetime, and bump the plugin version to 0.20.0.

Signed-off-by: Eric Wheeler <cliproxy@z.ewheeler.org>
@KJ7LNW

KJ7LNW commented Sep 24, 2026

Copy link
Copy Markdown
Author

@codex review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant