Compare commits

...
697 Commits
Author SHA1 Message Date
YHH 98570c95c0 Merge pull request #14498 from esengine/c5-notes-2.52.0
docs(release): studio 2.52.0 notes
2026-10-25 17:02:21 -07:00
esengine 5e222f332e docs(release): studio 2.52.0 notes 2026-10-25 16:44:45 -07:00
YHH 1cc6bf327a Merge pull request #14496 from SivanCola/fix/job-panic-result-14025
fix(jobs): keep recovered panics from success / 防止任务 panic 被误报成功
2026-10-25 13:29:13 -07:00
SivanCola c8fcbc138e fix(jobs): keep recovered panics from becoming successful results
Problem: legacy nil-panic recovery can publish a successful job after its
invocation or cleanup panics, and error-valued panics lose their cause identity.

Root cause: two boundaries treat a non-nil recover value as the only panic
signal and format panic errors without wrapping the original error.

Fix: share a private normal-return guard across invocation and finalization,
retain typed panic causes, preserve finalizer error classification and sibling
cleanup, and keep the existing invocation stack message without adding stacks
to finalizers. Move invocation recovery into its cohesive owning file.

Verification: compiling preceding owners fail six test/subtest events with two
controls passing. Actual compiled Manager child tests cover explicit panicnil
modes zero/one, invocation, cleanup and no-launch. Frozen required2/rest237
partitions close all239 packages with23656 passes/215existing skips; complete
jobs/delegation race549 passes and accepted14464 three-owner composition671
passes have zero skips. Independent required command has2349passes/66existing skips.
Vet, complete lint, make check and formatting pass at unchanged2664baseline.
Windows/Linuxamd64 owner builds are compilation only; exact-head hosted/native
and independent review remain separate gates. No Goexit, other-goroutine panic,
process-scope completion or crash recovery is claimed.
2026-10-26 03:40:51 +08:00
YHH abd3e3a62e Merge pull request #14464 from SivanCola/codex/checkpoint-writer-admission-14025
Cancel background writer admission and bind cleanup to registrations / 后台写入者可取消准入与身份清理
2026-10-25 12:29:20 -07:00
SivanCola 80baf7e0c5 Merge studio and preserve job-owned writer token finalization
Problem: accepted job finalization in #14469 conflicts with the pending
cancellable writer registration paths in task and fleet.

Root cause: both sides own background cleanup. Closure-only cleanup misses
proven no-launch, while ID-only cleanup cannot protect replacement writers.

Fix: retain Manager-owned terminal callbacks and pass each returned writer
registration token through the shared jobCleanup option. Remove the obsolete
ID-only helper. Preserve the foreground token lifetime, accepted transcript
and progress finalizers, and every other upstream change in this ordinary merge.

Verification: the original full 239-package root command passes with 23668
test/subtest passes and 215 existing skips. Complete checkpoint/delegation/jobs
race passes 663 events, zero skips (one unchanged package cached); real boot
handoff and no-launch race passes ten events. Independent required builtin/boot
passes 2354 events with 66 existing skips. Compiling preceding callers fail
four no-launch scenarios plus parent; the Manager/token generation regression
passes. Vet, full lint, make check, formatting and diff gates pass, baseline
2664 unchanged. Six Windows/Linux amd64 owner compilations are compilation
only. Exact new-head hosted/native validation and independent review remain.
2026-10-26 02:59:07 +08:00
YHH c1ffa46df2 Merge pull request #14469 from SivanCola/codex/job-resource-finalization-14025
Finalize background resources when job launch is declined / 修复后台任务未启动时的资源清理
2026-10-25 11:29:39 -07:00
YHH 36ecb1868a Merge pull request #14495 from xxoingr/test/workbench-menu-focus-after-effect
test(studio): wait for the reader menu to take focus before reading it
2026-10-25 11:29:27 -07:00
xxoingr 022c4db333 test(studio): wait for the reader menu to take focus before reading it
The reader menu opens from a promise callback, so its commit runs at
default priority and the effect that focuses the first item is a
separate Scheduler task (setImmediate). findByRole resolves from the
MutationObserver that sees the menu, then RTL drains with setTimeout(0);
when a millisecond passes before the next loop turn, the timer wins and
the assertion reads the reader, which still has focus.

Wait for the focus the way the other focus assertions in the suite do.
2026-10-26 02:11:39 +08:00
YHH 91858315ca Merge pull request #14490 from SivanCola/codex/settings-network-draft
Separate saved network settings from drafts / 区分网络草稿与已保存配置
2026-10-25 11:00:18 -07:00
YHH 14323ca29d Merge pull request #14491 from SivanCola/codex/settings-scroll-search
Reset settings category scrolling and await search targets / 修复设置滚动与异步搜索定位
2026-10-25 10:59:50 -07:00
YHH 6458adf7b6 Merge pull request #14492 from SivanCola/codex/settings-backup-route
Register file backups on machine settings routes / 修复本机配置备份接口 404
2026-10-25 10:53:14 -07:00
YHH 5a16e27b59 Merge pull request #14479 from SivanCola/fix/mutation-json-members-14025
fix(mutation): reject ambiguous record members / 拒绝事务记录歧义字段
2026-10-25 10:30:56 -07:00
YHH 70955832c3 Merge pull request #14482 from xxoingr/test/netclient-stream-dialer-proxy-boundary
test(netclient): pin the stream dialer's proxy boundary
2026-10-25 10:30:18 -07:00
SivanCola 6881c9748d fix(studio): register file backups on machine settings routes
Problem: local settings cannot load the file-backup catalog because both
machine settings URL forms return an untyped 404.

Root cause: file-backup handlers are registered only with account backup
routes, while the machine settings router registers a separate route set.

Fix: register the existing file-backup handlers with machine settings.
Normal runtime routes still register that same set once. Preserve host-only
checks, paired-device rejection, local-owner selection and mutation gates;
do not change payloads, encryption or persisted formats.

Verification: new regressions fail before the fix with plain 404 responses
and pass afterward for both machine prefixes. Focused machine settings,
file backup, account backup and mutation-gate tests pass. gofmt, go vet,
make lint, make check and the required builtin/boot tests pass.
2026-10-26 01:12:34 +08:00
SivanCola d11ca0472c fix(studio): reset category scrolling and wait for search targets
Problem: switching settings categories retains an unrelated scroll offset, while a searched setting that loads later never receives navigation feedback.

Root cause: categories share one scrolling element and landing resolves the anchor once before asynchronous content arrives.

Fix: key the panel by category, clear pending landing only when a guarded category change succeeds, and observe target arrival until its feedback lifetime begins. Keep existing categories, layouts, disclosures and unsaved-change guards.

Verification: five regressions fail on the unmodified baseline; all 28 settings test files and 215 tests pass with the fix. TypeScript/Vite and repository checks pass. Browser verification confirms a tools panel at scrollTop 5747 switches to network at 0 and search opens the existing advanced contrast disclosure.
2026-10-26 01:10:04 +08:00
SivanCola 57784a251c fix(studio): separate saved network settings from drafts
Problem: Network settings could be saved without edits, and pending reads,
saves or diagnostics could overwrite the state of a newly selected connection.
Connection tests also appeared to exercise unsaved form values.

Root cause: The form used one settings object for both saved state and edits,
with async completions that were not scoped to the owning connection.

Fix: Keep saved settings and the editable draft separately, disable no-op
saves, freeze submitted drafts, and identify diagnostics as testing saved
settings. Remount the form on connection changes and ignore completions from
unmounted owners. Preserve the existing expanded connection details layout.

Verification: The seven extracted regression cases failed on the baseline.
Network and i18n tests pass (10 files, 181 tests), including edit reversion
and loading/error coverage; tsc -b, make check and git diff --check pass.
2026-10-26 01:08:14 +08:00
YHH 4377886f88 Merge pull request #14478 from EMEEEEMMMM/feat/wallpaper-scene-audio-dxt
feat(studio): loop every scene soundtrack and decode DXT backdrops
2026-10-25 10:00:24 -07:00
YHH 28cab7d14b Merge pull request #14483 from esengine/c5-theme-syntax-tokens
feat(theme): five optional syntax tokens for comments, types, operators, punctuation and variables
2026-10-25 09:53:30 -07:00
YHH bd5ea6ddd3 Merge pull request #14485 from esengine/c5-fix-unreachable-port-flake
test(serve): take the unreachable address from a port that was seen to refuse
2026-10-25 09:53:20 -07:00
EMEEEEMMMM 6e2ba1056a fix(studio): bound DXT image and LZ4 sizes 2026-10-26 00:38:39 +08:00
YHH b458854377 Merge pull request #14472 from SivanCola/codex/isolation-runner-panic-14025
fix(isolation): retain work after runner panic / 保留 runner panic 后的隔离成果
2026-10-25 09:31:16 -07:00
esengine b29f8bf16b test(serve): take the unreachable address from a port that was seen to refuse 2026-10-25 09:30:56 -07:00
esengine d1f852953a test(theme): reach the syntax guard's hooks through the harness bundle, as the built page serves them 2026-10-25 09:19:28 -07:00
SivanCola 6287044a20 merge studio to retain the accepted feedback assertion fix
Problem: the ordinary test synchronization append conflicts with upstream's
independently accepted fix for the same passive draft-persistence boundary.

Root cause: both sides replace the immediate heldDraft assertion with waitFor;
upstream additionally explains why the effect may follow the thumbnail commit.

Fix: resolve the sole conflict to the exact accepted upstream test including
its comment. Preserve all incoming dependency/UI changes and every original
runner-panic implementation Go file. This merge is required by a verified
content conflict, not unrelated base drift; no rebase or force-push.

Verification: final inventory of 239 root packages is reconciled from disjoint
required2 and rest237 commands, both exit0, with frozen Go/module inputs.
Isolation race covers all 51 top-level tests through the original completed
39 trees plus the remaining12 under the same5m budget; original full owner
command hit its cumulative alarm and is retained as exit1, not called pass.
Five affected frontend files have99 tests pass; production typecheck/build,
formatting, root vet, full lint and make check pass without baseline widening.
Exact merge-head hosted/native checks and independent review remain pending.
2026-10-26 00:06:20 +08:00
YHH 52e9abe311 Merge pull request #14481 from esengine/c5-deps-vitest
build(deps): bump vitest to 5.0.5
2026-10-25 09:01:44 -07:00
esengine d0bff6bcf5 feat(theme): five optional syntax tokens for comments, types, operators, punctuation and variables 2026-10-25 08:58:37 -07:00
xxoingr 8c42340ea4 test(netclient): pin the stream dialer's proxy boundary
NewStreamDialer is how SSH reaches a remote host under the user's proxy
(remote/attach), and none of dialer.go was exercised: netclient sat at
47% against a 47.2% floor on a declared-sensitive path.

Four tests drive it against fake proxies on loopback:
- an http proxy gets CONNECT to the target with Proxy-Authorization, and
  the credentials never also travel as Authorization;
- a proxy that answers 407 yields an error, not an open stream;
- an https proxy sees a TLS handshake first, so CONNECT and credentials
  never cross in cleartext;
- a SOCKS5 proxy gets username/password auth and the target by name.

Each fails when the matching guard in dialer.go is removed. The
netclient floor rises from 47.2 to 65.8.
2026-10-25 23:54:17 +08:00
YHH f45fb56f62 Merge pull request #14476 from esengine/c5-fix-feedback-flake
test(studio): wait for the held feedback draft before asserting it
2026-10-25 08:44:36 -07:00
SivanCola 127ee9755b test(studio): await feedback draft persistence after paste
Problem: Studio job 121205496051 fails after the pasted image preview appears,
because the retained draft still contains no image.

Root cause: findByAltText observes the DOM commit, while holdDraft runs in a
passive effect. The test assumes those distinct boundaries complete together.
The unchanged merge passed a later external rerun, which does not repair the
scheduling assumption.

Fix: keep the exact single-image assertion for both heading and tab targets,
but await the draft condition with waitFor. Production UI and Go source stay
unchanged.

Verification: full Feedback interaction file 65 pass, restored focused cases
2 pass, typecheck pass. Two independent no-op-save controls each fail both
targets, proving persistence is still checked. Root vet, full lint, make check
and required builtin/boot pass at unchanged baseline 2664. The preceding
native/hosted evidence remains attached to af958f; the new head needs fresh CI.
2026-10-25 23:43:47 +08:00
esengine 0c9d5d1fd1 build(deps): bump vitest to 5.0.5 2026-10-25 08:40:03 -07:00
SivanCola 50a5186930 fix(mutation): reject ambiguous coordination record members
Problem: ambiguous private registry records can be admitted or scavenged as
known dead-owner state.

Root cause: struct JSON decoding merges repeated members and folds case;
unknown-field rejection alone does not preserve the original declarations.

Fix: validate unique decoded names and exact schema spelling for records and
capacity objects before typed decoding and liveness checks. Keep writer
encoding, legal spacing/order, escaped unique names and existing resources.
Join a killed process before closing cooperative input in death fixtures.

Verification: complete 239-package root inventory and required builtin/boot
commands exit 0; mutation race 88 pass, pending wait/opaque composition 116
pass. The compiling preceding decoder fails 22 behavior test/subtest events,
with two controls passing. Formatting, vet, full lint and make check pass
without changing the 2664 baseline. Windows/Linux amd64 compile only; decode
microbenchmark overhead is measured and disclosed separately from product
performance. No runtime or default activation.
2026-10-25 23:34:40 +08:00
YHH d51fb03f6b Merge pull request #14475 from lancy69/fix/13512-prompt-customization
docs: explain supported prompt customization
2026-10-25 08:33:23 -07:00
YHH 7d65acd714 Merge pull request #14474 from lancy69/fix/13919-project-persona-guidance
docs: explain project-specific persona guidance
2026-10-25 08:32:52 -07:00
esengine 0fd8d526e5 test(studio): wait for the held feedback draft before asserting it 2026-10-25 08:29:37 -07:00
YHH 751f0b13ca Merge pull request #14473 from esengine/c5-deps-npm
build(deps): bump mantine, lucide-react, maplibre-gl and vite
2026-10-25 08:27:21 -07:00
EMEEEEMMMM 7273f04d81 feat(studio): decode DXT scene textures 2026-10-25 23:18:33 +08:00
EMEEEEMMMM def94177cd feat(studio): play every scene soundtrack 2026-10-25 23:18:27 +08:00
Lancy e6337ef356 docs: explain supported prompt customization 2026-10-25 23:15:15 +08:00
Lancy d5e7c53401 docs: explain project-specific persona guidance 2026-10-25 23:14:21 +08:00
esengine 9609216f74 build(deps): bump mantine, lucide-react, maplibre-gl and vite to their latest patch and minor releases 2026-10-25 08:05:47 -07:00
YHH 8add01aacc Merge pull request #14468 from imcreazy/feat/rail-opens-a-folded-run
Let the rail say when history is unloaded, and open a folded run
2026-10-25 07:53:27 -07:00
YHH bdf3784f96 Merge pull request #14471 from esengine/c5-deps-go
build(deps): bump modernc.org/sqlite to 1.61.0 and tdewolff/parse to 2.8.19
2026-10-25 07:40:48 -07:00
imcreazy 186583996d Let the two new rail surfaces take the pointer
`.railhost` sets pointer-events: none, and only `.srail` opted back in. The
unloaded-history marker and the opened run inherited the none, so presses fell
through to the transcript. A style test now holds the rule.
2026-10-25 22:38:44 +08:00
SivanCola af958fdae8 fix(isolation): retain uninspected work after a runner panic
Problem: an isolated task could write files, then lose its discoverable result
identity when a host runner or provider callback panicked.

Root cause: Store.Execute called Finish only after an ordinary runner return;
panic unwind bypassed both completed-result and inspection publication.

Fix: recover at the runner invocation boundary, retain an error-valued panic
as a cause, and publish the existing inspection identity under the result
operation owner. Do not infer physical completion, inspect for emptiness, or
delete work on a panic. Preserve ordinary outcomes and truthful live-only
recovery when bookkeeping cannot be admitted or persisted.

Verification: final 239-package inventory closes using 238 unchanged broad
packages and the final isolation race package; 111 owner race and 3 real boot
race cases pass without skips. The compiling preceding owner fails 11 cases
with 3 ordinary-error controls passing. Required builtin/boot, root vet, full
lint and make check pass at unchanged baseline 2664. Linux/Windows amd64 are
compile-only; hosted/native and independent review remain separate gates.

Refs #14025
Refs #14133
2026-10-25 22:25:13 +08:00
esengine 148a3eabe7 build(deps): tidy the desktop module for the same bumps 2026-10-25 07:05:07 -07:00
YHH 82997a2d8f Merge pull request #14463 from esengine/c5-remove-reading-grips
feat(studio): remove the reading column's drag handles
2026-10-25 06:59:35 -07:00
esengine 682ecbe392 build(deps): bump modernc.org/sqlite to 1.61.0 and tdewolff/parse to 2.8.19 2026-10-25 06:56:32 -07:00
YHH 40f3156907 Merge pull request #14457 from esengine/dependabot/github_actions/actions-5a66b01c52
build(deps): bump the actions group with 3 updates
2026-10-25 06:40:28 -07:00
SivanCola 966f6ece9b test(ci): qualify background resource owners on native Windows
Problem: Windows PR smoke did not run jobs/delegation unit tests, and pure
delegation changes could skip native platform legs. Linux PR race coverage
also omitted the delegation owner tests.

Root cause: package routing predates the shared background resource lifecycle.
Compiling these owners as boot dependencies does not execute their tests.

Fix: run both complete packages on Windows with a retained JSON inventory,
require all15 portable lifecycle cases to pass, route pure delegation changes
to native legs and include delegation in the existing Linux race sweep.

Verification: platform-scope tests and pinned actionlint pass; the inventory
gate accepts actual local owner output and rejects missing-case, skipped-case
and missing-owner controls. Make check passes at unchanged baseline2664.
All6699 Go files match the qualified implementation commit; no product Go
source or deadline changed. Exact-head native/hosted qualification is pending.
2026-10-25 21:36:48 +08:00
YHH a3473882ce Merge pull request #14467 from esengine/c5-hub-auto-workspace
fix(serve): a new session with no project opens in the app's own workspace
2026-10-25 06:35:57 -07:00
SivanCola 9260e62870 fix(jobs): finalize resources when managed launch is declined
Problem: background task/fleet can strand checkpoint writers, transcript locks
and progress resources when the Manager never invokes the supplied closure.

Root cause: resource ownership moves to closure-only defers before the Manager
checks invalid identifiers or its closed root context. Those branches bypass
the closure and its cleanup, while the caller suppresses its own ending.

Fix: register optional host finalizers at the Manager's managed-execution
boundary. Run all callbacks once before completion after actual closure return
or proven no-launch, preserve cleanup failures/panic and transfer the actual
task/fleet resources and uninvoked terminal projections to that owner.

Verification: final239-package inventory uses238 broad packages plus the final
jobs package after a deterministic liveness-test-only change:23596 test/subtest
passes/215 existing skips. Full owner race648 and real boot race5 pass without
skips; required builtin/boot2346 passes/66 skips. Original public regressions
and compiling predecessor controls fail. Selected token-adapter composition
passes40 race cases. Vet/lint/check pass at unchanged baseline2664.

The liveness pair uses a controlled clock; production thresholds are unchanged.
Managed-closure completion is not descendant scope or durable crash recovery.
Refs #14025
2026-10-25 21:28:02 +08:00
esengine 8b4dea758d fix(serve): a new session with no project opens in the app's own workspace 2026-10-25 06:15:17 -07:00
SivanCola 3858f0d51f fix(checkpoint): separate writer admission from registry locking
Problem: cancelled background task/fleet calls can remain behind checkpoint
admission while the shared writer registry also blocks queries and cleanup.

Root cause: registration holds the registry mutex across the barrier, durable
undo retirement, notification and Store persistence. Callers discard their
admission context and defer cleanup by reusable string ID alone.

Fix: reserve identified pending registrations, prepare outside the registry
mutex and serialize latest snapshot publication separately. Pass context at
all task/fleet sites and transfer token-bound cleanup to admitted jobs. Preserve
legacy APIs, completed undo retirement and active work across parent turns.

Verification: original root239 passes with23590 test/subtest passes/215 existing
conditional skips; required builtin/boot2346 passes/66 skips. Complete owning
race487 passes/zero skips; real boot handoff race5 passes/zero skips. Compiling
preceding-owner/caller controls fail the intended assertions. Vet/lint/check
pass without raising baseline2664. Linux/Windows compilation is not native QA.

The jobs-owner no-launch finalization gap remains explicitly separate.
Refs #14025
2026-10-25 20:43:12 +08:00
esengine 1d723d2a28 feat(studio): remove the reading column's drag handles 2026-10-25 05:40:54 -07:00
YHH a43ba964fd Merge pull request #14458 from esengine/c5-grips-need-room
fix(studio): draw the reading column's handles only when there is a range to move in
2026-10-25 05:26:56 -07:00
YHH f7c2506cf2 Merge pull request #14450 from esengine/c5-drop-topbar-sandbox
fix(studio): drop the top bar's second route to Tools and permissions
2026-10-25 05:26:40 -07:00
esengine 4db40e881c fix(studio): draw the reading column's handles only when there is a range to move in 2026-10-25 04:59:49 -07:00
dependabot[bot] 6c4f00ce42 build(deps): bump the actions group with 3 updates
Bumps the actions group with 3 updates: [github/codeql-action/init](https://github.com/github/codeql-action), [github/codeql-action/analyze](https://github.com/github/codeql-action) and [actions/setup-python](https://github.com/actions/setup-python).


Updates `github/codeql-action/init` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `github/codeql-action/analyze` from 4.39.0 to 4.39.1
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee...9b3254085494b55a0f7c4c2b3d2d52cc712ce370)

Updates `actions/setup-python` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/5fda3b95a4ea91299a34e894583c3862153e4b97...6d4fd22b20465ccc0f7354476588e8df7c9ca0e0)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.39.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/setup-python
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-25 11:56:24 +00:00
YHH a76cbec375 Merge pull request #14447 from esengine/c5-tool-blurbs
fix(studio): give the config-gated tools a blurb in the settings tool list
2026-10-25 04:53:06 -07:00
esengine 3a67b488a2 test(studio): the narrow-window guard no longer expects the removed top-bar button 2026-10-25 04:52:55 -07:00
esengine 553ed22a50 fix(studio): drop the top bar's second route to Tools and permissions 2026-10-25 04:39:26 -07:00
esengine 889211c785 fix(studio): give the config-gated tools a blurb in the settings tool list 2026-10-25 04:31:31 -07:00
YHH 540c14998b Merge pull request #14443 from esengine/c5-pane-wide-drop
feat(studio): a file dropped anywhere on the conversation pane is taken
2026-10-25 04:26:35 -07:00
YHH 9fce63aa5a Merge pull request #14442 from esengine/c5-price-currency-select
fix(studio): style the model-price currency select like the inputs beside it
2026-10-25 04:03:58 -07:00
esengine c41306961a feat(studio): a file dropped anywhere on the conversation pane is taken 2026-10-25 03:52:53 -07:00
esengine 0865bc1071 fix(studio): style the model-price currency select like the inputs beside it 2026-10-25 03:46:57 -07:00
YHH 46d63cf414 Merge pull request #14440 from SivanCola/codex/mutation-record-validation-14025
fix(mutation): retain unsupported registry records / 保留未知事务记录并拒绝准入
2026-10-25 03:30:33 -07:00
SivanCola 619c5b243d fix(mutation): retain unsupported coordination records
Problem: unfamiliar coordination records can be ignored during admission and
deleted after their OS owner exits.

Root cause: registry decoding discarded unknown fields, and version-1 resource
validation accepted every effect except capacity.

Fix: reject unfamiliar object fields and unsupported path effects before
liveness/scavenging, retain original bytes, and include record identity in the
typed protocol error. Preserve known version-1/2 records and omitted-effect
legacy semantics without changing writers or activating the runtime protocol.

Verification: complete 239-package root inventory, full mutation race,
compiling preceding-owner counterexamples, actual owner-process death,
known-record controls, selected waiting-observation composition, required
builtin/boot and static gates. Windows/Linux builds are compile-only local
evidence; exact-head hosted/native review remains separate.
2026-10-25 17:56:13 +08:00
YHH 1c330cbd9e Merge pull request #14438 from SivanCola/codex/isolation-settlement-fence-14025
fix: fence ordinary isolated-result writers / 隔离结果操作识别持久恢复屏障
2026-10-25 02:54:14 -07:00
YHH 6343cc7970 Merge pull request #14432 from xxoingr/fix/pane-tab-close-stays-inside
fix(studio): a pane tab's close button stays inside the tab
2026-10-25 02:53:50 -07:00
YHH ef0874a111 Merge pull request #14436 from esengine/c5-fix-14434-retention
fix(agent): cap verbatim retention by the compaction trigger
2026-10-25 02:21:47 -07:00
SivanCola adcc4b4d80 fix(isolation): fence ordinary result operations before recovery
Problem: A durable future settlement record must stop ordinary result
writers, including stale queued operations and destructive siblings.

Root cause: The per-ID mutex serializes operations but carries no durable
knowledge that a recovery owner has published a settlement fence.

Fix: Check host-rooted external fence metadata after operation ownership,
refuse any existing entry without interpreting its bytes, recheck context
and release only this attempt on refusal. Cover tools, Finish/Abort, empty
inspection, sweep, a real child process and actual boot/controller rebuild.
No journal writer, schema or complete recovery is introduced. Preceding
writers ignore this fence; future activation needs a cooperating protocol.

Verification: Full isolation race100 passes, zero skips; real boot race3
passes. Preceding owner compiles and fails15 test/subtests, including a
child process; predecessor boot owner actually writes source in both cases.
Final root inventory/required and static qualification recorded in PR.
Linux/Windows amd64 compilation is separate from native evidence.

Refs #14025
Refs #14133
2026-10-25 17:20:38 +08:00
xxoingr caf5c2d45e docs(release-notes): keep the pane tab note's English line within 200 columns 2026-10-25 17:19:50 +08:00
YHH bb273fec87 Merge branch 'studio' into fix/pane-tab-close-stays-inside 2026-10-25 01:56:24 -07:00
esengine e1c38d3036 fix(agent): cap verbatim retention by the compaction trigger, not only the window
User turns, retained failures and [[keep]] turns were budgeted as shares of
the window. On a 1M window under the 100K cost trigger those shares plus the
tail passed the trigger, so every fold was refused as still at or above it.
Each budget is now also held to a share of the trigger.
2026-10-25 01:55:42 -07:00
YHH 463e2f76f0 Merge pull request #14435 from esengine/c5-css-budget
style(studio): hold the stylesheet to its line budget
2026-10-25 01:55:32 -07:00
esengine 172ae8b80f style(studio): hold the stylesheet to its line budget after two merges landed together 2026-10-25 01:42:56 -07:00
YHH d03be98bcb Merge pull request #14425 from SivanCola/codex/worktree-literal-paths-14025
fix(worktree): keep integration paths literal / 修复特殊文件名导致隔离整合越范围写入
2026-10-25 01:22:31 -07:00
xxoingr decba9d9eb fix(studio): a pane tab's close button stays inside the tab
The folder and host labels on a pane tab were flex: none, so a long one
could not shrink: the title, the only part that could, went to zero width
and the close button was pushed past the tab's edge onto the next tab,
where a press activates that tab instead.

Both labels now shrink to an ellipsis and take at most 40% of the tab, and
the title keeps at least 4em. perf/tab-close-inside.mjs checks, in the
real stylesheet, that the close button stays inside its tab and is what a
press at its centre reaches, with a long title, a long folder, and both a
long folder and a long host.
2026-10-25 16:21:25 +08:00
YHH 3af39d9d0a Merge pull request #14407 from EMEEEEMMMM/feat/wallpaper-scene-mvp
Feat/wallpaper scene mvp
2026-10-25 01:00:15 -07:00
YHH 4775113397 Merge pull request #14415 from SivanCola/codex/isolation-native-ci-14025
ci: run complete mutation and isolation owners on Windows / 补齐事务与隔离机制原生及竞态验收
2026-10-25 00:57:35 -07:00
YHH 21a4569b39 Merge pull request #14406 from lancy69/feat/14139-thinking-preview
feat(studio): separate collapsed thinking previews from folding
2026-10-25 00:56:22 -07:00
SivanCola 479c8e7b16 merge: adopt reviewed studio owners for literal path review 2026-10-25 15:44:19 +08:00
SivanCola c79c5192ee test(mutation): pin legacy wait admission and adopt reviewed Windows fixes 2026-10-25 15:30:48 +08:00
Lancy d42cdbb434 feat(studio): separate collapsed thinking previews from folding 2026-10-25 15:26:36 +08:00
YHH 42c3c9f195 Merge pull request #14420 from esengine/c5-prefs-centered
fix(studio): centre the enlarged settings window's content column
2026-10-25 00:25:14 -07:00
YHH 526908630a Merge pull request #14429 from esengine/c5-serve-bind-first
fix(cli): serve binds its address before it reports anything
2026-10-25 00:24:55 -07:00
EMEEEEMMMM 7d9e9b7b6c fix(studio): bound scene image decoding 2026-10-25 15:14:56 +08:00
YHH 2dce532bf5 Merge pull request #14419 from chuiwenwei/fix/record-the-tokens-the-price-came-from
fix(stats): record the tokens the price came from
2026-10-25 00:14:02 -07:00
YHH 083d30bec8 Merge pull request #14416 from chuiwenwei/fix/lease-probe-asks-the-lock
fix(sessionstore): the lease probe asks the lock, not the sidecar
2026-10-25 00:13:21 -07:00
YHH d4d8876e92 Merge pull request #14423 from chuiwenwei/fix/history-keeps-the-arguments-it-restored
fix(tui): keep the arguments a restored tool call actually had
2026-10-25 00:13:01 -07:00
YHH 4af626e714 Merge pull request #14424 from esengine/c5-within-exact
fix(mutation): a prefix is exact only when its spelling agrees
2026-10-25 00:12:29 -07:00
YHH ffe06c2db8 Merge pull request #14422 from esengine/c5-gitin-stdout
test(worktree): gitIn returns standard output only
2026-10-25 00:12:07 -07:00
SivanCola 5671e00f3f ci: require mutation owner coverage and probe capacity without a latency alarm 2026-10-25 15:08:12 +08:00
esengine c3d192dc4b fix(cli): serve binds its address before it reports anything
Only a serve with --port-file or --open listened first; a plain serve printed the
banner, share link, token path and the non-loopback warning and bound afterwards, so
an address that can never work still looked like a running server. Every command now
binds first, the report uses the bound address, and the now-unreachable
bind-inside-RunGraceful branch is gone.
2026-10-25 00:04:33 -07:00
esengine 5aa737bb95 fix(studio): centre the enlarged settings window's content column
.prefs-col had a max-width and no inline margin, so once the settings window was
enlarged the column hugged the navigation and every extra pixel piled up on its
right. In the enlarged mode the column is now centred in the room beside the nav.
2026-10-24 23:53:26 -07:00
SivanCola 24a775a94c fix(worktree): preserve literal paths through Git application
Problem: a candidate filename could act as a Git wildcard or magic pathspec,
writing an undeclared source file or dropping the actual literal result.
Newline-delimited hashing could also reinterpret a single filename.

Root cause: option separation was mistaken for literal path selection, and
ambient Git pathspec flags could override the host's intended selection.

Fix: centralize literal pathspecs for tree/index inspection and the shared
restore owner; C-quote hashing input by bytes; remove inherited selection
flags in the common host Git environment while preserving explicit flags.
Whole-workspace application reuses the shared writer. Keep the portable
stdout/stderr Git fixture repair aligned with the native CI follow-up.

Verification: complete 239-package root inventory, three-owner race, real boot
race, uncached required builtin/boot, compiling predecessor controls, six
Windows/Linux owner cross-compiles, formatting, root vet, lint and make check.
Native hosted checks and independent review remain separate gates.

Refs #14025
Refs #14133
2026-10-25 14:51:01 +08:00
esengine b26eddec18 fix(mutation): a prefix is exact only when its spelling agrees
withinExact used filepath.Rel, which ignores case on Windows, so a different
spelling of an absent file was reported as held there while heldWithin's comment
and TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings require the filesystem to
prove two spellings are one file. Require the prefix bytes to agree as well.
2026-10-24 23:43:39 -07:00
Will aeeff97700 fix(tui): keep the arguments a restored tool call actually had
The terminal's copy of /history's tool call had no argumentNormalization
field, so a restored transcript rebuilt the card from Arguments alone —
the proxy's effective text — and drew it as what the model wrote. The
original was not merely hidden; it never left the decoder.

Carry the host's normalization through HistoryToolCall into the
eventwire.Tool the restored transcript draws and inspects, following the
serve payload and the desktop copy that already do. The card still draws
only the effective arguments: showing the original is a UI decision for a
later change, and changing the card would also change a live call.

Register the pair as a declared subset of the serve historyToolCall in
wireparity, naming the six fields the terminal decides from. The exact
table is the desktop's and is read as a TypeScript interface, so a Go
reader belongs in the partial table, which compares both ways over the
declared names.
2026-10-25 14:41:21 +08:00
esengine 15f72be36f test(worktree): gitIn returns standard output only
The helper used CombinedOutput, so on Windows, where core.autocrlf is true, git's
'LF will be replaced by CRLF' warning on stderr was returned with the blob id and
TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_type passed the pair to
update-index --cacheinfo. Standard output is what a caller reads; standard error
now only joins a failure message.
2026-10-24 23:38:00 -07:00
YHH 8bd18a5db4 Merge pull request #14233 from lancy69/fix/14220-execution-typography
fix(studio): execution typography follows body preferences
2026-10-24 23:36:49 -07:00
SivanCola eab502bd8e test(worktree): keep Git diagnostics out of object IDs
Problem: the newly executed native Windows owner inventory failed while
constructing its candidate-type fixture, before product application ran.

Root cause: gitIn returned CombinedOutput, so a successful hash-object CRLF
warning on stderr was concatenated with its object ID and fed to update-index.

Fix: keep stdout and stderr separate at the shared fixture owner, retaining
both in failure diagnostics. Pin autocrlf=true in the compatibility regression
so this contract is exercised on every host. No production or gate changes.

Verification: the preceding helper compiles and reproduces the same diagnostic
pollution locally; the complete worktree race, Windows compilation, required
builtin/boot, vet, lint and make check pass. Hosted native revalidation remains
separate from local and earlier full-root evidence.
2026-10-25 14:35:10 +08:00
YHH 1aa101e6a9 Merge pull request #14412 from esengine/c5-measure-forgets-drag
fix(studio): a line-width preset wins over a width the column was dragged to
2026-10-24 23:31:33 -07:00
Will 495f8ede4c fix(stats): record the tokens the price came from 2026-10-25 14:16:21 +08:00
Will de2256dfc4 fix(sessionstore): the lease probe asks the lock, not the sidecar
A live holder keeps the OS lock for its whole lifetime, and its `.lease.json`
sidecar can be deleted by a person or a file scanner while the process still
owns the session. The probe answered free on a missing sidecar without looking
at the lock, so session list said idle, task list flipped a running item to
interrupted, and redact-sessions would rewrite a live transcript. It now asks
the lock whenever a lock file exists, and still creates nothing on the common
uncontended path.
2026-10-25 14:06:30 +08:00
Will 0e63e353fe fix(sessionstore): the lease probe asks the lock, not the sidecar
A live holder keeps the OS lock for its whole lifetime, and its
`.lease.json` sidecar can be deleted by a person or a file scanner while the
process still owns the session. The probe answered free on a missing sidecar
without looking at the lock, so session list said idle, task list flipped a
running item to interrupted, and redact-sessions would rewrite a live
transcript. It now asks the lock whenever a lock file exists, and still
creates nothing on the common uncontended path.
2026-10-25 14:04:37 +08:00
SivanCola 3fa1ea460c ci: execute complete isolation owners on native Windows
Problem: green PR checks could omit complete isolation/worktree owner tests on
Windows and under Linux race; pure isolation changes could skip native legs.

Root cause: the smoke and race package allowlists lacked both owners, while
the platform classifier treated untagged isolation Go as neutral.

Fix: classify isolation paths as platform-relevant; execute complete Windows
owners with an uploaded JSON inventory, require both package terminals and
portable boundary tests, and include both packages in PR race. Replace the
redundant rollback subset. Preserve existing permissions, deadlines and cache
rules; do not alter product code.

Verification: compiling old-workflow/classifier regressions, exact inventory
positive/negative controls, final classifier/testenv suites, complete 238-package
root inventory, three-owner race, required builtin/boot, vet, lint and check.
Local execution is macOS; native hosted outcomes remain a separate gate.

Refs #14025
Refs #14133
2026-10-25 14:02:42 +08:00
YHH bf472862ca Merge pull request #14368 from SivanCola/codex/mutation-primitive-14025
feat(state): add mutation transaction primitive / 写入事务基础机制与测试
2026-10-24 22:59:58 -07:00
esengine d28a6089fc fix(studio): a line-width preset wins over a width the column was dragged to
The width a hand set is kept in --flow-w and outranks the stylesheet, so once the
reading column had been dragged, choosing the full-width preset changed --doc but
not the column and did nothing. Choosing a preset now drops the dragged width.
2026-10-24 22:54:22 -07:00
YHH 8c669ebe61 Merge pull request #14410 from SivanCola/codex/worktree-entry-modes-14025
fix(worktree): compare file modes during integration / 修复隔离整合丢失文件模式与类型
2026-10-24 22:35:06 -07:00
YHH df14e2f085 Merge pull request #14409 from SivanCola/codex/isolation-operation-leases-14025
fix(isolation): serialize result operations through cleanup / 隔离结果操作统一跨进程租约
2026-10-24 22:33:54 -07:00
SivanCola 600b4b8cb4 fix(worktree): compare entry modes during isolated application
Problem: executable-only changes and equal-byte regular/link changes could be
discarded as already applied, and source metadata edits could be overwritten.

Root cause: baseline, candidate and live file versions retained only blob IDs,
collapsing distinct Git modes and file kinds into one identity.

Fix: compare Git mode and blob together, follow the checkout's trusted/index
mode rules, hash real links and emulated links with their actual Git semantics,
and reject unsupported transitions before any source write. Keep the user's
index and configuration unchanged.

Verification: complete 238-package root inventory, worktree/isolation race,
real boot race, required builtin/boot, compiling predecessor controls and
operation-lease composition. Formatting, root vet, lint and make check pass.
Cross-compilation is recorded separately from pending hosted/native review.

Refs #14025
Refs #14133
2026-10-25 13:08:35 +08:00
EMEEEEMMMM e01aae97fd fix(studio): wrap wallpaper scene parse errors 2026-10-25 13:02:47 +08:00
SivanCola 36cb5e6c7b test(filelock): mirror relative-root rename admission
Problem: the rooted-lock rename regression failed on Windows before reaching
the lock operation.

Root cause: Go's direct os.OpenRoot uses a Windows handle without delete
sharing, while production opens the lock directory relative to its managed
Root using an Openat handle with delete sharing. The fixture conflated them.

Fix: open the test directory through its parent Root, matching the production
constructor. Retain the real rename/replacement and physical lock-location
assertions on every platform. Document the direct-root Windows limitation.
No production code, timeout or platform skip changes.

Verification: complete filelock race (25 passes), Windows amd64 compilation,
required builtin/boot, root vet, full lint and make check. The prior full root
qualification is retained separately; new hosted Windows evidence is pending.
2026-10-25 13:00:44 +08:00
EMEEEEMMMM aaaaf4d2ef fix(studio): address wallpaper scene review 2026-10-25 12:42:39 +08:00
SivanCola d1fe589bf8 fix(isolation): serialize result inspection and settlement
Problem: concurrent Stores could inspect one retained result and then write
or remove it while another operation was still using its candidate.

Root cause: reinspection checked authority without retaining a common owner.
Paths, first Finish, Abort and expiry cleanup could bypass Apply/Discard too.

Fix: add directory-handle OS file locks and one persistent per-result mutex
namespace. Hold admission through reinspection, mutation and synchronous
cleanup; preserve lock identity, active owners and live-only Finish retries.
Forward path-query cancellation and keep legacy writer limitations explicit.

Verification: complete 238-package root inventory, owner and real boot race,
compiled predecessor regressions, actual prior reader/sweeper compatibility,
Windows/Linux owner cross-compilation, formatting, vet, lint and make check.
No new native-platform or independent-review result is claimed here.

Refs #14025
Refs #14133
2026-10-25 12:33:34 +08:00
EMEEEEMMMM 89935ce452 fix(studio): keep scene controls out of the composer layout 2026-10-25 12:27:43 +08:00
EMEEEEMMMM db7a840a3f docs(release-notes): name the Wallpaper Engine scene import 2026-10-25 12:27:43 +08:00
EMEEEEMMMM b242c67680 feat(studio): import Wallpaper Engine scenes with sound and volume 2026-10-25 12:27:43 +08:00
YHH 8ebd327b12 Merge pull request #14408 from esengine/c5-actions-split
refactor(studio): move the feedback actions out of the full actions file
2026-10-24 21:21:18 -07:00
Lancy 04bdd5702b fix(studio): execution typography follows body preferences 2026-10-25 12:15:49 +08:00
esengine 811e335a89 refactor(studio): move the feedback actions out of the full actions file
actions.ts sat exactly at the 800-line ceiling, so any pull request that added a
single action id failed repolint. The 44 feedback.* ids are one concern and move
to actions_feedback.ts, the way the market, schedule and document groups already
live in their own files; the registry content is unchanged.
2026-10-24 20:55:34 -07:00
YHH 7a530b37ee Merge pull request #14405 from SivanCola/codex/checkpoint-cancellable-admission-14025
fix(checkpoint): cancel admission and preserve other writers / 取消检查点等待并保护其他写入者
2026-10-24 20:51:30 -07:00
SivanCola ee9f75e286 fix(checkpoint): cancel admission before invocation entry
Problem: cancelled calls remain parked behind checkpoint exclusivity, delay
their provisional resource cleanup, and may later enter hooks or execution.

Root cause: condition waits do not consume the invocation context. Observer
cleanup also exposes a shared writer-count decrement that can run twice.

Fix: centralize context-aware barrier admission, recheck cancellation after
durable preparation, return idempotent permits and bind the Agent caller.
Preserve completed undo retirement and every other active owner's admission.

Verification: deterministic cancellation and owner-preservation schedules,
compiling predecessor behavior failures, real boot direct/deferred coverage,
durable undo reloads, full root inventory, owner/boot race and required suites;
gofmt, vet, full lint and make check with unchanged gate baseline.
2026-10-25 11:14:07 +08:00
YHH e29812d38f Merge pull request #14404 from esengine/c5-edit-crlf
fix(edit): a literal edit match keeps the line ending of the line it lands in
2026-10-24 19:53:30 -07:00
YHH e4779b9f95 Merge pull request #14402 from SivanCola/codex/isolation-cached-result-14025
fix(isolation): reject settled result caches / 拒绝已处理成果的过期缓存
2026-10-24 19:53:13 -07:00
esengine 772b2f1de6 fix(edit): a literal edit match keeps the line ending of the line it lands in
The literal branches of applyOldStringEdit wrote the replacement verbatim, so a
multi-line new_string inserted bare LF into CRLF files while only the fuzzy paths
converted it. Each literal replacement now takes the ending of its own line, which
also keeps an LF region of a mixed file free of stray CR.
2026-10-24 19:28:14 -07:00
SivanCola 749e691863 fix(isolation): reinspect published results before settlement
Problem: a Store could apply an old cached Git tree after another Store had already applied or discarded that result, resurrecting its bytes in the source workspace.

Root cause: cache hits skipped durable publication and candidate admission; Git objects survive removal of the worktree. Refreshing without comparing the previous observation would also silently widen an earlier path declaration.

Fix: always reuse durable recovery/inspection for lookups, keep finished caches out of Pending, and refuse changed baseline/candidate/target trees until fresh path admission. Preserve first-Finish live-only and unsupported-version checks. No persisted, permission, tool-definition or default changes. Cross-process settlement locking and journals remain separate.

Verification: final complete 238-package root inventory passes (217 pass/21 no tests;23310 test/subtest passes,215 existing skips,194 valid cached packages). Full isolation race52 passes and real boot focus31 passes with no skips. The accepted predecessor Store compiles and fails9 behavior events. Gofmt, vet, full lint, make check and diff checks pass. Additional required builtin/boot command is running; new hosted/native evidence remains pending.
2026-10-25 10:20:29 +08:00
YHH e59262c519 Merge pull request #14401 from SivanCola/codex/isolation-runtime-options-14025
fix(isolation): honor task runtime options / 隔离任务遵守模型与步数参数
2026-10-24 19:06:13 -07:00
YHH 35003e6628 Merge pull request #14400 from SivanCola/codex/isolation-first-finish-14025
fix(isolation): retain first-Finish failures / 首次成果检查失败保留隔离结果
2026-10-24 19:05:34 -07:00
SivanCola cbac083536 fix(isolation): preserve task runtime selection and step caps
Problem: worktree task dispatch silently discarded effort, step limits and configured subagent models; parent model aliases also lost live session effort overrides.

Root cause: isolation branched before ordinary task runtime resolution, and alias resolution reloaded config instead of retaining the canonical parent runtime entry.

Fix: reuse shared task/model/effort/budget resolution, pass ephemeral options to the unattended Build, and canonicalize parent aliases to their runtime entry. Existing unsupported isolation restrictions remain refused; best-of keeps its prior policy. No persisted, wire, tool-definition or default changes.

Verification: complete 238-package root inventory passes (217 pass/21 no tests; 23289 test/subtest passes, 215 existing conditional skips, 198 cached packages). Owning delegation/isolation race has 400 passes; real boot focus has 39 passes, no skips. Compiling old owners fail 18 runtime scenarios plus parent, with two automatic-budget controls passing. First-Finish composition race has 32 passes. Gofmt, vet, full lint, make check and diff checks pass. Supplemental required builtin/boot is still running; hosted/native results remain pending.
2026-10-25 09:46:07 +08:00
SivanCola cf5fedae64 test(isolation): cover CRLF checkout during first-Finish recovery
Problem: Windows CI rejected successfully recovered isolated output because the new boot assertion required LF bytes.

Root cause: Git checkout may apply core.autocrlf conversion; recovery preserves the expected text with CRLF.

Fix: explicitly enable autocrlf in the disposable repository and normalize CRLF for the content assertion, retaining same-ID recovery and source-before-apply checks.

Verification: the old assertion compiles and deterministically fails with CRLF on macOS; the repaired four boot regressions pass under race (6 test/subtest events). Required builtin/boot pass (2291 passes, 66 existing conditional skips); vet, lint, make check and formatting pass. Native Windows replacement CI remains pending.
2026-10-25 09:36:50 +08:00
SivanCola d816d53347 fix(isolation): retain results after first finalization failure
Problem: isolated work could lose its recovery ID or be removed when the
first candidate inspection or completed-result publication failed.

Root cause: Finish treated inspector/persistence errors like successful
empty results, and Execute hid Finish errors behind a runner failure.

Fix: retain the candidate and original ID, publish a separate versioned
inspection marker without overwriting existing records, reuse rooted
admission and same-ID inspection, and report live-only retry explicitly
when recovery publication is unavailable. Join both errors. Keep normal
completed records unchanged and refuse unsupported inspection versions.

Verification: complete 238-package root inventory, isolation and real boot
race tests, compiling preceding-owner behavior failures, actual previous
reader/sweeper compatibility controls, formatting, vet, lint and check.
Local evidence is macOS only; hosted checks and independent review remain.
2026-10-25 09:01:57 +08:00
YHH 5b2b297e6e Merge pull request #14399 from SivanCola/codex/hook-checkpoint-admission-14025
fix(agent): admit reader hook writes through checkpoints / 只读调用 hook 纳入检查点写入准入
2026-10-24 17:49:06 -07:00
SivanCola ab68ff145f fix(agent): admit reader hook effects through checkpoint barrier
Problem: A reader's hooks can change files without participating in rewind
exclusion, retiring undo, or recording the checkpoint's coverage gap.

Root cause: The invocation caller selects checkpoint admission using only the
concrete tool's read-only flag, even though it already knows that its hook set
may write. The checkpoint owner is correct but this caller bypasses it.

Fix: Include possible tool-hook writes in the two existing admission/coverage
conditions. Reuse the existing writer permit and deferred cleanup. Keep empty
hook readers unchanged and keep tool execution, coverage, and effects separate.

Verification: Compiling original owners fail the actual callback/undo and four
boot regressions; fixed owner/checkpoint and boot race tests pass. Full root
inventory, required builtin/boot, formatting, vet, lint and make check are
qualified separately in the PR. Asynchronous hook process lifetime and the
barrier's existing cancellation limits remain explicit follow-up work.
2026-10-25 08:14:28 +08:00
YHH ffccf6877c Merge pull request #14397 from SivanCola/codex/provisional-write-admission-14025
fix(lease): release provisional write claims / 撤销未执行写入的临时占用
2026-10-24 17:03:54 -07:00
YHH b786e30b7a Merge pull request #14398 from SivanCola/codex/hook-workspace-invalidation-14025
fix(agent): preserve hook workspace invalidation / 保留 hook 工作区失效通知
2026-10-24 16:48:39 -07:00
SivanCola 72b725862f fix(agent): preserve workspace invalidation for hook effects
Problem: A hook can change files before refusing a tool or outside a reader's
declared paths without notifying an opted-in workspace sink. Nested writers
also omit publication until an outer writer happens to complete.

Root cause: Invalidation is gated by concrete tool execution and classification,
and publication belongs to the batch caller rather than the invocation owner.

Fix: Track possible tool-hook effects separately from Executed, publish through
the shared invocation exit after cleanup, and share result metadata projection
between batch and nested calls. Unknown hook scope invalidates content, tree and
Git metadata without claiming a confirmed write or widening authorization.

Verification: Full root 238-package inventory passed; complete agent/event race,
eight boot direct/capability hook cases under race, original-owner compiling
negative controls, and provisional-lease composition passed. Formatting, vet,
full lint and make check passed without changing the baseline. Local evidence
is macOS only; optional sink wiring and evidence accounting remain separate.
2026-10-25 07:29:49 +08:00
SivanCola bc330925c0 fix(lease): separate run and yielded-claim lifetimes
Problem:
Aborting provisional admission can expire an unrelated live user wait or
retake paths deliberately yielded to a user. A pending wait is also lost
when the last writer or an earlier restoration finishes afterward.

Root cause:
Physical release reset run state, and one retained extent represented both
active and parked ownership. Restoration dropped its temporary protection
before reholding the original caller. Expired queued grants and retention
callbacks could contribute resources to the next run.

Fix:
Separate active and parked retention, advance generations at the final
participating run end, and reconcile outstanding yields whenever writing
holds drain. Keep restoration protection through rehold, fence late
admission and retention, and clean expired grants without dropping current
writers. Actual in-flight writes stay protected after their run ends.

Verification:
The submitted owner compiles and fails all 14 selected test/subtest failure
events across the new public-API schedules. Corrected full workspacelease
and agent race pass, as do actual boot race, all 238 packages in disjoint
required/remaining partitions, required builtin/boot, vet, lint and check.
Local evidence is macOS only; hosted checks and review apply to the new head.

Refs #14025
Refs #14133
2026-10-25 06:50:34 +08:00
SivanCola affb71f9dd fix(lease): release provisional paths before mutation entry
Problem:
A refused write can pin a never-executed path through later model requests.
Repeated failed restoration can also report success to an expired caller.

Root cause:
Admission merges every request into the run-retained extent before local
preparation succeeds. Call cleanup only decrements in-flight accounting.
Resume keeps its once-only result in per-invocation locals, and refused
upgrades omit the terminal wait notice after registrar contention.

Fix:
Track identified provisional calls separately from retained extents and
publish their remaining union through the existing claim registrar. Retain
before possibly writing hooks or execution. Preserve earlier writes and
parallel claims, report failed cleanup conservatively, validate lifetimes
before and after reacquisition, and cache terminal callback outcomes.
Close registrar waits on refusal and clear completed cleanup's wait state.

Verification:
Complete 238-package inventory and required builtin/boot pass on the final
source; full workspacelease/agent race and focused real boot race pass.
Compiling original execution, resume and upgrade-wait owners fail the
selected deterministic regressions. Formatting, vet, lint and check pass.
Local platform evidence is macOS only; hosted checks and review are separate.

Refs #14025
Refs #14133
2026-10-25 05:36:29 +08:00
YHH 7adc244b94 Merge pull request #14396 from esengine/docs/release-studio-2.51.0
docs(release): studio 2.51.0 notes
2026-10-24 14:28:52 -07:00
esengine 453eee6b69 docs(release): studio 2.51.0 notes
Gathers the 38 fragments since 2.50.0 into one note: video wallpaper, find
case/regex switches, shortcut sheet, paged transcript loading, a narrower
reading column, dispatch trust and the delegation write-safety fixes.
2026-10-24 14:05:54 -07:00
YHH ce57da64cf Merge pull request #14366 from SivanCola/codex/transcript-paging-ui
feat(studio): load transcript pages incrementally / 正文渐进分页界面
2026-10-24 13:56:18 -07:00
YHH c0e99ad1a3 Merge pull request #14394 from SivanCola/codex/optimistic-call-admission-14025
fix(agent): coordinate optimistic child writes / 修复乐观子代理具名写入绕过占用
2026-10-24 13:31:41 -07:00
YHH 33ab226422 Merge branch 'studio' into codex/transcript-paging-ui 2026-10-24 13:30:24 -07:00
YHH 15652de96a Merge pull request #14392 from SivanCola/codex/isolation-record-boundary-14025
fix(isolation): verify recovered record identity / 校验隔离恢复记录的宿主身份
2026-10-24 13:30:17 -07:00
YHH ce7baf7e75 Merge pull request #14365 from SivanCola/codex/history-paging-api
feat(history): add cursor-based history API / 独立游标分页接口
2026-10-24 13:29:55 -07:00
SivanCola 11652b0146 test(studio): preserve API-first history route gate
Problem: standalone UI route checks require the paging API to have landed.

Maintainer guidance confirms that this check should remain a landing
dependency until #14365 is merged and the UI branch updates its studio base.
Restore the original route scanner and keep the HTTP fallback integration
tests, which prove runtime compatibility independently of landing order.

Verification: the original route test passes in the combined API/UI tree.
The standalone failure remains expected until the API base update. Vet,
complete lint, check and builtin/Boot validation pass without changing
production code, timeouts or coverage floors.
2026-10-25 04:06:37 +08:00
SivanCola ffc054a483 fix(agent): admit optimistic child writes through the shared scheduler
Problem: optimistic children can write paths held by live sibling tasks,
including through dynamically resolved local capabilities.

Root cause: whole-workspace optimistic grants bypass explicit-fence wrapping,
child options omit the shared scheduler, and parent admission skips children.

Fix: inherit the existing scheduler and admit actual named targets at execution,
using the actor grant's root and one complete resource set. Retain parent and
explicit-fence ownership, opaque tool availability and existing permissions.
Leave observation roots, provider prefixes and tool definitions unchanged.

Verification: compiling old owners write held files in four real boot task/fleet
direct/capability cases; fixed cases refuse overlap and preserve disjoint writes.
Full238-package root, required builtin/boot, complete three-owner race and focused
real assembly race pass. Vet/lint/check pass without raising the baseline.
Native hosted checks and independent review remain separate delivery gates.
2026-10-25 03:58:01 +08:00
SivanCola a60af4d12f fix(i18n): complete standalone paging refusal translations
Problem: the API-only PR failed the English refusal catalogue check.

Root cause: splitting the implementation retained the refusal codes but left
their English wording only in the UI branch.

Fix: include the same two English translations in the API branch, without
adding UI implementation.

Verification: reproduced the catalogue failure; all 170 i18n tests, typecheck,
vet, full lint, check and builtin/Boot suites pass. Existing catalogue
assertions and budgets are unchanged.

Place the shared refusal translations in EN_KERNEL on both branches so the
independent API and UI changes compose without a translation conflict. All
170 locale tests and typechecks pass on each branch.
2026-10-25 03:56:57 +08:00
SivanCola 7096a83db3 test(studio): validate optional history paging fallback
Problem: standalone paging UI failed the CI serve route contract.

Root cause: the scanner assumed every frontend endpoint was mandatory, while
paging explicitly supports older servers through full-history fallback.

Fix: declare only the exact paging call optional, require its fallback route,
and exercise the HTTP adapter and hook across unsupported and real failures.

Verification: reproduced the original failure; route tests, 19 frontend tests,
typecheck, vet, lint, check and builtin/Boot suites pass. Covergate tests pass;
unchanged computer coverage is 88.2% on macOS versus the 89.8% floor, also
reproduced on the sibling branch. Ubuntu coverage remains for CI.

Place the shared refusal translations in EN_KERNEL on both branches so the
independent API and UI changes compose without a translation conflict. All
170 locale tests and typechecks pass on each branch.
2026-10-25 03:56:57 +08:00
YHH dc7489570a Merge branch 'studio' into codex/history-paging-api 2026-10-24 12:29:21 -07:00
SivanCola aa694db5a3 fix(isolation): confine recovered-record queries to opened roots
Problem: CodeQL found two new uncontrolled-path stat alerts in record admission.
The first validator also read a claimed Git administrative backlink before
confining that path to the host-opened common directory.

Root cause: comparing embedded paths with global os.Stat did not itself provide
a filesystem namespace boundary, and marker discovery still used path globs.

Fix: enumerate and read slots through the opened managed root, perform identity
stats through root-relative handles, and constrain candidate/common metadata
reads before opening a backlink. Derive effective identities from checked host
roots, retain unknown fields unchanged, and treat brackets in root names literally.
Existing alias resolution remains metadata-only and grants no content-read scope.

Verification: final 238-package root inventory and required builtin/boot pass;
complete isolation race has 32 passes and real boot isolation race has five,
without skips. Six old-owner behavior groups compile and fail, namespace and
literal-directory regressions pass, and vet/lint/check retain the same baseline.
The replacement hosted CodeQL verdict and independent review remain pending.
2026-10-25 03:22:32 +08:00
SivanCola 2be21f5e53 merge: preserve first-save rail stars when syncing studio
Problem: the transcript paging UI conflicts with the upstream first-save bookmark fix. Root cause: both changes update the Pane-to-Transcript boundary, and retaining the runtime-id fallback loses unsaved stars on first save. Fix: merge studio f96ec2f869, retain session-path-only bookmark ownership alongside paging, and exercise both full and paged restoration. Verification: the upstream regression fails with the old scope and passes after resolution; 37 focused tests and 791 frontend files / 6343 tests pass, as do the production build, go vet, complete make lint and builtin/Boot suites. No lint baseline or deadline was changed.
2026-10-25 03:16:18 +08:00
YHH f96ec2f869 Merge pull request #14393 from xxoingr/fix/rail-star-before-first-save
fix(studio): a star placed before a conversation's first save stays with it
2026-10-24 12:04:48 -07:00
YHH cf077634d8 Merge pull request #14391 from xxoingr/test/final-observation-hang-budget
test(agent): wait out a held final observation, not a slow seal
2026-10-24 11:52:44 -07:00
SivanCola dcd09b8352 fix(isolation): bind recovered records to host-managed identities
Problem: a rebuilt store can trust a copied or mismatched durable record and
use its embedded candidate paths outside that record's actual managed slot.

Root cause: discovery checks the claimed workspace but does not bind snapshot
and candidate identity to the host-opened repository and observed marker slot.
Comparing Git markers only by inode also accepts cross-candidate hard links.

Fix: centralize record admission before every durable consumer; verify managed
directory membership, source identity, prefix roots and the linked candidate's
actual marker/backlink. Retain rejected records without rewriting metadata,
and resolve legacy identity through filesystem checks without launching Git.

Verification: five compiling old-owner negative-control groups fail as expected;
final 238-package root inventory and required builtin/boot pass, owning isolation
race has 30 passes and real boot isolation race has five, all without new skips.
Formatting, root vet, full lint and make check pass with the baseline unchanged.
Native hosted checks and independent review remain separate delivery gates.
2026-10-25 02:46:04 +08:00
xxoingr 48a1614e8c fix(studio): a star placed before a conversation's first save stays with it
An unsaved conversation scoped its rail stars by the pane's runtime id, and
the first save switched the scope to the session path, which reloaded from
an empty key: the star disappeared. The id is also a per-launch counter
(r1, r2, ...), so the key it left behind was read again by whichever unsaved
conversation pane r1 held after the next restart.

An unsaved conversation now keeps its stars in memory, and the first save
writes them under the session's path, merged with anything stored there.
The pane passes the session path alone as the scope.
2026-10-25 02:45:56 +08:00
YHH 65def98d70 Merge pull request #14387 from SivanCola/codex/role-fileview-ownership-14025
fix(agent): isolate review-role file views / 修复审查角色刷新执行代理读取记录
2026-10-24 11:29:07 -07:00
xxoingr 5230537980 test(agent): wait out a held final observation, not a slow seal
TestFinalObservationIsBoundedAfterACompletedAnswer gave Run one second
after cancel (three after the deadline) to come back. Run still seals the
contract and the bundle after its context ends, because the first lock
attempt does not wait; locally that takes about 18ms, but on a loaded
windows-latest runner it overran the window and the test reported a held
observation that was not there (studio runs 40110025629, 40064086851).

The observation the test holds never returns until it is released, so any
finite wait catches the regression; use testenv.Budget for both waits.
2026-10-25 02:16:00 +08:00
YHH e2a3342f8d Merge pull request #14390 from SivanCola/codex/isolation-inspection-retain-14025
fix(isolation): retain results on inspection errors / 修复恢复检查失败丢失隔离结果
2026-10-24 11:03:59 -07:00
SivanCola be47298166 fix(isolation): retain published results on recovery inspection errors
Recovery conflated a failed Git inspection with an empty result and erased
the durable marker before attempting worktree removal. Return inspection
errors without cleanup and report the existing unavailable refusal instead
of forgetting a valid result ID.

Exercise apply path discovery, Apply and Discard against real retained
worktrees after restarting the store and temporarily hiding Git. Preserve
marker bytes and work, then retry the same ID after Git returns. Keep age
expiration and pre-publication Finish recovery as separate lifecycle units.
2026-10-25 01:40:02 +08:00
YHH cf93de51be Merge pull request #14386 from SivanCola/codex/guardian-readonly-boundary-14025
fix(guardian): enforce resolved read-only execution / 修复审查角色动态写入越权
2026-10-24 10:29:13 -07:00
YHH 59bd6bc212 Merge pull request #14384 from esengine/c5-flow-narrow
feat(studio): the reading column can be dragged narrower than the readable measure
2026-10-24 10:29:04 -07:00
SivanCola efba49918c fix(agent): isolate planner and guardian file observations
Separate review-role FileViews from the executor and bind direct/deferred
local tools to one fresh record for each role. Install the planner frontend
before binding so its ledger/audit and view ownership survive boot wiring.
Keep the generic read-only filter a same-actor operation, and preserve the
existing nil-runtime planner adapter without changing its tool surface.

Add four real-boot stale-overwrite regressions, runtime/clone accounting
checks, a generic-filter compatibility case, and written security bounds.
The compiling old-owner overlay reproduces all four overwrites.
2026-10-25 01:12:51 +08:00
SivanCola 80c1b58c6e fix(guardian): enforce read-only execution after capability resolution
Guardian's visible registry was filtered, but its read-only capability proxy
could resolve an executor writer or a declared host mutation. Set the
permanent execution flag in the session constructor so actual targets are
checked before they run, without changing the review's executor approval.

Add local resolver and real boot regressions, with compiling old-owner
failures for both mutation classes and an actual file overwrite. Document
the authorization boundary and the separate file-view ownership concern.
2026-10-25 00:57:29 +08:00
YHH 0dd1a41153 Merge pull request #14382 from SivanCola/codex/fileview-ownership-14025
fix(agent): give each child its own file views / 修复父子代理文件读取记录共享
2026-10-24 09:52:40 -07:00
YHH 1c253bb218 Merge pull request #14379 from esengine/c5-first-run
feat(studio): a launch with no chosen or remembered project opens the app's own auto workspace, not home
2026-10-24 09:52:31 -07:00
esengine 4eb303b1ea docs(release-notes): reading column drag floor 2026-10-24 09:49:57 -07:00
esengine b0dac9152c feat(studio): the reading column can be dragged narrower than the readable measure
The drag floor was the readable measure itself (760), so the two presets were the
only widths a hand could reach on a typical window. A hand now reaches down to
640; the readable measure stays the untouched default and what Home returns to.
2026-10-24 09:47:32 -07:00
YHH 0357832547 Merge pull request #14348 from lancy69/fix/14326-workbench-context-menu
fix(studio): integrate workbench text context menus
2026-10-24 09:43:34 -07:00
YHH 55bbd301db Merge pull request #14381 from esengine/c5-dispatch-trust
fix(dispatch): trust an undecided project the user named in their own list
2026-10-24 09:33:27 -07:00
esengine 083a17ee02 fix(storage): list the auto workspace folder among the state root's entries
A first launch with nothing chosen creates <state>/workspaces/auto; the storage
panel and a state-root move must own it, or a move would strand it.
2026-10-24 09:32:30 -07:00
SivanCola 0e3fcb96ef fix(agent): give each child independent file observations
Problem: a child's read can refresh the parent's stale overwrite guard,
allowing the parent to discard an edit it has never read.

Root cause: copied file tools share FileViews, while deferred local tools
resolve through the root registry even inside nested children.

Fix: bind one fresh view per child to its direct and deferred file tools.
Keep disabled protection, tool identity, grants, read-only execution and
stable schemas intact. Install bindings on fresh capability frontends only.

Verification: original-owner controls reproduce the real boot overwrite;
a parent-pointer mapping control fails four nested cases, while the actor
binding passes all six shared-runtime/clone combinations. Root package,
owning race, composition, vet, check and lint evidence is recorded in the PR.

Refs #14025
Refs #14133
2026-10-25 00:13:36 +08:00
YHH e13e76e1c5 Merge pull request #14373 from esengine/c5-video-wallpaper
feat(studio): a wallpaper can be an MP4 or WebM video, played muted and looping
2026-10-24 09:06:31 -07:00
esengine d3880a6c83 fix(dispatch): trust an undecided project the user named in their own list
A dispatch target is resolved against the user's sidebar and the dispatch
switch is already the user's consent, so stopping on the folder's trust card
asked the same person twice. The folder is now recorded as trusted and the task
goes through; a folder that cannot be trusted whole still keeps its pane open on
the card with the task unsent.
2026-10-24 09:04:07 -07:00
YHH d561a40068 Merge pull request #14378 from esengine/c5-import-picker
fix(studio): the native session import card uses the app's button like the legacy one beside it
2026-10-24 08:54:44 -07:00
YHH fff33508f8 Merge pull request #14375 from esengine/c5-title-budget
fix(studio): automatic naming asks once more with room when the title model only thought
2026-10-24 08:42:12 -07:00
YHH 5e342dc0ec Merge pull request #14376 from imcreazy/fix/activity-line-one-type-scale
Set the activity line in one type scale
2026-10-24 08:41:00 -07:00
esengine 087ea05686 feat(studio): a launch with no chosen or remembered project opens the app's own auto workspace, not home 2026-10-24 08:39:37 -07:00
esengine 5f5a63afa6 fix(studio): the native session import card uses the app's button like the legacy one beside it 2026-10-24 08:38:22 -07:00
imcreazy 8aaa4af237 Set the activity line in one type scale
Its four spans were 11px/500, 11px/650, 9.5px/500 and 10px/450. The running
chip already has a wash behind it, so the heavier weight separates twice.
2026-10-24 23:19:36 +08:00
YHH 7c22c5340e Merge pull request #14372 from esengine/c5-arg-example
fix(tools): refusals of malformed calls, todo lists and diff evidence show what a valid one looks like
2026-10-24 08:19:03 -07:00
YHH 34e61e806d Merge pull request #14370 from esengine/c5-comp-fix
fix(studio): a conversation bound to an Agent preset continues when a built-in tool schema changed
2026-10-24 08:17:50 -07:00
esengine e401575036 fix(studio): automatic naming asks once more with room when the title model only thought 2026-10-24 08:14:50 -07:00
esengine 2b4ee07fd7 feat(studio): a wallpaper can be an MP4 or WebM video, played muted and looping 2026-10-24 08:11:52 -07:00
YHH 33abf2ef03 Merge pull request #14369 from esengine/c5-wallpaper-stream
feat(studio): wallpapers upload as a streamed body, have no size limit and are served in ranges
2026-10-24 07:59:52 -07:00
SivanCola 7639c30135 Merge studio and drop obsolete design-document lint repair
Problem: Both extracted PRs became modify/delete conflicted while their local
qualification was running.

Root cause: Upstream removed FIRST_RUN_WORKSPACE.md in #14367 after the slices
had repaired its inherited lint metadata and paragraph violations.

Fix: Accept the upstream deletion and remove the now-obsolete prerequisite
repair. Retain every feature, regression and security-document change.

Verification: make check passes. The staged merge changes no Go file relative
to the independently qualified slice heads; their full package/race evidence
remains explicitly scoped to those source trees. Hosted checks must run on
the new merge heads. No unrelated production change or baseline relaxation.

Refs #14025
Refs #14133
2026-10-24 22:53:20 +08:00
esengine a5a7439b75 fix(tools): refusals of malformed calls, todo lists and diff evidence show what a valid one looks like 2026-10-24 07:50:23 -07:00
esengine 780b7fb1f9 fix(studio): a conversation bound to an Agent preset continues when a built-in tool schema changed 2026-10-24 07:38:15 -07:00
esengine 3c3ba4395b feat(studio): wallpapers upload as a streamed body, have no size limit and are served in ranges 2026-10-24 07:35:07 -07:00
YHH 2a57596001 Merge pull request #14367 from esengine/c5-rm-firstrun
docs: remove the first-run workspace design (moved to #14363)
2026-10-24 07:34:40 -07:00
SivanCola db465ec26f feat(state): introduce independently reviewable mutation transactions
Problem: Task-length write claims cannot independently represent permission,
assignment and the physical mutation lifetime across cooperating runtimes.

Root cause: Shared admission, input preconditions, cancellation and release
have no common short-transaction owner that callers can adopt independently.

Fix: Extract the mutation resource/queue/capacity owner and content versions
from the preserved concurrency implementation. Add minimal non-blocking legacy
claim compatibility and exception-safe publication cleanup. Keep all runtime,
isolation, hook, controller and default activation outside this review slice.
Document authority, actual sinks, compatibility and unenforced boundaries,
and map the remaining source into dependent review units. Normalize an
inherited design-document lint prerequisite without relaxing its baseline.

Verification: make check, full lint and root vet pass. Owning-package race
passes, including real subprocess death and a strengthened cleanup regression
that fails without the cleanup via compiler overlay and passes with it.
Builtin passes. The full boot invocation reaches its 600-second package alarm;
all 811 top-level tests close in four inventory-verified groups (808 pass,
3 conditional skips, no missing/unexpected/failing selected tests). Linux and
Windows amd64 test binaries cross-compile; native platform and hosted CI
evidence are separate gates. The remaining root-package sweep is in progress.

Refs #14025
Refs #14133
2026-10-24 22:23:12 +08:00
esengine 4f896f2710 docs: remove the first-run workspace design (moved to #14363) 2026-10-24 07:20:48 -07:00
SivanCola 5846f3a226 fix(studio): continue history search after batched page commits
Problem: searching for a message outside the initial page could remain incomplete after one fast response. Root cause: the search effect depended only on busy/hasMore flags that React may batch back to their previous values. Fix: advance search on committed page revisions and release the completed single-flight request before publishing that revision. Verification: failing-before revision regression, 28 focused tests including scroll and anchor neighbors, and a browser replay fetching four pages and finding the first message.
2026-10-24 22:15:33 +08:00
SivanCola 591aac54e9 feat(history): expose bounded cursor paging independently of the UI
Problem: full transcript projection and transfer scale with conversation length. The session store now owns stable page snapshots and cross-page context, exposed through optional controller reads and an additive HTTP route. Existing full-history clients are unchanged. Validate through real Boot/resume, full-projection equivalence, cursor invalidation and append stability, request authority and foreground preemption. Focused history tests, race checks, Go lint and release-note tests pass; broad suites are recorded in the PR.
2026-10-24 22:13:10 +08:00
SivanCola 5d24185309 feat(studio): load transcript pages with stable reading anchors
Rebase the UI portion of #14087 onto current studio, preserving regex/case search and session-scoped navigation. Use optional paging with explicit unsupported-route fallback and retain mounted controls when prepending. Browser verification exposed zero-box display-contents wrappers; share rendered-box geometry with the landing writer and add a failing-before regression. Verification: 37 focused frontend tests, TypeScript checking, production build and wide/narrow light/dark browser renders; full-suite results and backend dependency are documented in the PR.
2026-10-24 22:13:10 +08:00
SivanCola 5810c1033b feat(history): define additive paging wire types
Define the optional history-page response and typed refusal translations shared by the API and UI splits. No caller switches to paging in this commit. Verification is performed on each independent split before publication.
2026-10-24 22:03:01 +08:00
YHH ef79a2215e Merge pull request #14335 from lancy69/fix/14324-remove-workbench-wide
fix(studio): remove redundant workbench widening mode
2026-10-24 06:58:22 -07:00
YHH 9614465f5b Merge pull request #14345 from 1012905/studio
feat(studio): cancel an update download from the Versions panel
2026-10-24 06:53:17 -07:00
YHH 80d90498c9 Merge pull request #14351 from Oliver-Rui/docs/first-run-workspace
feat(studio): first run without a picked workspace
2026-10-24 06:30:37 -07:00
YHH 1384c6db9d Merge pull request #14353 from esengine/c5-grip-quiet
fix(studio): the reading column's grips draw nothing until the pointer is on them
2026-10-24 06:29:58 -07:00
1012905 6aec414270 fix(desktop): register versions.cancel in the action registry 2026-10-24 21:18:43 +08:00
esengine 24df650056 fix(studio): the reading column's grips draw nothing until the pointer is on them 2026-10-24 06:11:25 -07:00
Oliver 50f7e2711b docs: add first-run workspace design proposal
Documents the last coupled first-run decision: with nothing remembered,
the first pane's workspace root falls back to the user's home directory
(pickLaunchWorkspace), so a session that was never given a chosen project
reads as temporary. Proposes a kernel-provisioned auto workspace instead,
modelled on MiMo Desktop's session-first behaviour (auto-created session
directories, global provider config, optional projects), and maps it onto
the existing launch chain, session store and workspace switch.
2026-10-24 21:08:48 +08:00
esengine 0d35e92bc4 docs(release-notes): keep the English item within the width budget 2026-10-24 06:05:11 -07:00
YHH 1862e06e2b Merge pull request #14344 from lancy69/fix/14323-skill-description-search
fix(completion): find slash skills by description
2026-10-24 06:04:32 -07:00
1012905 9bc6bfd45f Merge branch 'studio' of https://gh-proxy.com/https://github.com/1012905/DeepSeek-Reasonix into studio 2026-10-24 20:58:52 +08:00
1012905 b838d8d0ed Wait for cancelled install cleanup
Keep the install slot claimed until a cancelled download finishes cleanup, preventing it from cancelling a new install or deleting its files. Add regression tests and a release note.
2026-10-24 20:58:25 +08:00
1012905 6e3947fdfb Merge branch 'esengine:studio' into studio 2026-10-24 20:47:56 +08:00
YHH 336e4c71c4 Merge pull request #14347 from esengine/c5-feedback-ready
fix(studio): the feedback form takes a paste in the commit that draws it
2026-10-24 05:47:49 -07:00
Lancy a9dbff297d fix(studio): integrate workbench text context menus 2026-10-24 20:41:34 +08:00
esengine e1940bae40 fix(studio): the feedback form takes a paste in the commit that draws it 2026-10-24 05:29:25 -07:00
1012905 6d6990785e Add cancellation for version downloads
Expose download cancellation through the update API and Versions UI. Cancellation waits for the transfer to stop, removes its partial and cached artifacts, and reports a distinct non-failure state. Automatic updates do not retry a cancelled release but can proceed when a newer version is offered.
2026-10-24 19:59:33 +08:00
Lancy 9055a5cb63 fix(completion): find slash skills by description 2026-10-24 19:58:05 +08:00
YHH bcf61cf0bd Merge pull request #14342 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/undici-8.12.3
build(deps): bump undici from 8.12.1 to 8.12.3 in /desktop/frontend-next
2026-10-24 04:52:43 -07:00
dependabot[bot] af7b5fabe0 build(deps): bump undici from 8.12.1 to 8.12.3 in /desktop/frontend-next
Bumps [undici](https://github.com/nodejs/undici) from 8.12.1 to 8.12.3.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.12.1...v8.12.3)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.12.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-24 11:21:06 +00:00
YHH a5db484ac3 Merge pull request #14340 from esengine/dependabot/npm_and_yarn/tools/acp-conformance/fast-uri-3.1.9
build(deps-dev): bump fast-uri from 3.1.8 to 3.1.9 in /tools/acp-conformance
2026-10-24 03:29:08 -07:00
YHH 3a0daf2abd Merge pull request #14339 from esengine/dependabot/npm_and_yarn/workers/forum/zod-4.6.6
build(deps): bump zod from 3.25.76 to 4.6.6 in /workers/forum
2026-10-24 03:04:11 -07:00
dependabot[bot] c6ba3522b9 build(deps-dev): bump fast-uri in /tools/acp-conformance
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.8 to 3.1.9.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.8...v3.1.9)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-24 09:50:36 +00:00
dependabot[bot] 0da7d75b75 build(deps): bump zod from 3.25.76 to 4.6.6 in /workers/forum
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.6.6.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.76...v4.6.6)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.6.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-24 09:50:24 +00:00
YHH da9fb7a9f9 Merge pull request #14213 from chuiwenwei/fix/import-completes-the-sidecar
fix(migrate): an imported session completes its sidecar
2026-10-24 02:04:06 -07:00
YHH a26dacdbb1 Merge pull request #14337 from esengine/c5-ask-draft
fix(studio): an unanswered ask card keeps its picks when its block unmounts
2026-10-24 01:29:36 -07:00
esengine 52dae508f7 fix(studio): an unanswered ask card keeps its picks when its block unmounts 2026-10-24 00:52:25 -07:00
Lancy 630b941e58 fix(studio): remove redundant workbench widening mode 2026-10-24 15:23:54 +08:00
YHH 42ec9546cf Merge pull request #14332 from jf200s/feat/browser-page-sleep
feat(browser): a page nobody drives sleeps, and wakes as the same target
2026-10-23 22:29:17 -07:00
F ee15d89fa2 fix(browser): the awake cap obeys the sleep switch too
Off means nothing sleeps, cap included: a person who never turned the
setting on kept losing the least recently used page's renderer when a
seventh page opened. The cap now runs only on a finite threshold, the
same one the sweep reads.
2026-10-24 13:06:41 +08:00
esengine 3621696b13 docs(release-notes): keep the English item within the width budget 2026-10-23 21:56:42 -07:00
YHH b19969e0b4 Merge pull request #14333 from lancy69/fix/14205-feedback-dialog-paste
fix(studio): route feedback image paste through the dialog
2026-10-23 21:52:54 -07:00
F e05325230c feat(settings): idle page sleep is off until the person turns it on
The rx-page-sleep desktop preference decides it, default off: prefs.js
owns the threshold, the shell reads it at boot and on every preference
save, and Settings -> Appearance carries the toggle. A woken page also
comes back fitted into its panel at the scale it had before the sleep.
2026-10-24 12:51:37 +08:00
F 8a3d9a6091 test(browser): a live probe that sleeps real pages and reports what it reclaimed
Five real Chromium pages through BrowserViews at a tiny threshold: the
working set they cost, the working set the sleep returns, a wake that
must answer against the page as it loaded, and a command the sleep
outlives that must fail rather than hang. Run with pnpm sleep-live.
2026-10-24 12:51:24 +08:00
Lancy 7bbbaf25dc fix(studio): route feedback image paste through the dialog 2026-10-24 12:21:29 +08:00
F 27b5dbd9a3 feat(browser): a page nobody drives sleeps, and wakes as the same target
An agent page's renderer is hundreds of megabytes the Studio pays for
whether anyone is watching. A page untouched for fifteen minutes and not
on screen is closed whole — address and a picture kept — under the same
targetId, so the kernel's handle survives; the next command, show, or
typed address rebuilds the view, replays the domains the kernel last
enabled (a domain switched off mid-session stays off), and answers only
once the page has loaded — a mid-load answer would describe the page it
was, and the kernel could not tell. A command the sleep outlives is
failed fast, for the session that dies never settles it and the kernel,
told nothing of a sleep, would wait on a page that will not speak. Six
awake pages at most — a wake yields no room: the page asked for
outranks the pages left alone — and eighteen asleep ones; past that the
oldest is buried and the kernel is told, the way a browser discards
abandoned tabs. REASONIX_NO_PAGE_SLEEP puts the feature away.
2026-10-24 11:52:23 +08:00
YHH 858a73d03f Merge pull request #14320 from imcreazy/fix/reading-column-grips
Let the reading column's grips, sidebars and composer move together
2026-10-23 16:04:11 -07:00
YHH f902caae16 Merge pull request #14322 from esengine/c5/compaction-notice
fix(compaction): a finished fold is announced with a code and a typed payload, so it follows the language
2026-10-23 15:54:22 -07:00
YHH 851f1dcf64 Merge pull request #14321 from imcreazy/fix/todo-panel-floats-over-the-column
Keep the plan panel from pushing the rail and the jump arrows
2026-10-23 15:53:19 -07:00
imcreazy 56b6f1a9b9 Leave the composer's own ceiling alone
It sized itself 42px inside the pane on purpose; pulling that to 34px to match
the text moved the mirror's wrapping off the caret's in composer-reflow.
2026-10-24 06:44:33 +08:00
YHH 2071971c8b Merge pull request #14300 from imcreazy/feat/keyboard-shortcuts-sheet
feat(studio): a shortcut sheet in the settings
2026-10-23 15:29:29 -07:00
esengine 769f4d1184 fix(compaction): a finished fold is announced with a code and a typed payload, so it follows the language 2026-10-23 15:18:40 -07:00
imcreazy 4a3ef66d9c Add the release note for the floating plan panel 2026-10-24 06:15:59 +08:00
imcreazy c3307775dc Keep the plan panel from pushing the rail and the jump arrows
Opening the todo list grew the composer, which shortened the scroller, which
re-measured the rail: every mark moved up and the two jump buttons went with
them. The panel now floats above the composer instead of taking its room.
2026-10-24 06:15:34 +08:00
imcreazy 9c7cb1aa70 Take the width away again when a first drag is cancelled
`App` owns `--flow-w` on `.app` only after a commit. Writing one back on
cancel would leave a pixel width nobody removes, pinning the column.
2026-10-24 06:08:30 +08:00
imcreazy 206d95dd8a Let the column's grips reach the sidebars and the composer
`--flow-w` went to `:root`, where a committed value on `.app` outranks it;
the sidebars only gave way on release, and the composer never followed at all.
2026-10-24 05:59:39 +08:00
imcreazy 237d63417a fix(studio): the key column holds the longest chord
`Enter / Shift+Enter` was wider than the shared minimum, so its description
sat 10px right of the others. 13em covers it.
2026-10-24 05:14:25 +08:00
YHH eeacfea88f Merge pull request #14296 from imcreazy/feat/conversation-rail-fold
feat(studio): fold a crowded conversation rail, and let a question be starred
2026-10-23 14:12:58 -07:00
imcreazy 6bf6b440ed fix(studio): the shortcut sheet lines its labels up
Each key cell was as wide as its own text, so the descriptions started at six different positions. A shared minimum on the key cell gives them one column.
2026-10-24 05:04:33 +08:00
YHH 27829cd3ff Merge pull request #14317 from esengine/c5/job-started-notice
fix(jobs): a started background job is announced with a code and its payload, so it follows the language
2026-10-23 14:04:16 -07:00
imcreazy 9068636c21 fix(studio): a rail fold never names a question a rewind removed
`groups` is a state slice; after a rewind shortens `marks`, a stale fold still pointed past its end and `marks[v.from]` was undefined. The fold is filtered to what exists.
2026-10-24 04:58:52 +08:00
YHH f08e2be652 Merge pull request #14294 from imcreazy/fix/run-analysis-way-in-and-out
fix(studio): make run analysis easy to enter and to leave
2026-10-23 13:55:58 -07:00
YHH 4975865891 Merge pull request #14299 from imcreazy/feat/session-export-markdown
feat(studio): export a conversation as Markdown
2026-10-23 13:53:01 -07:00
imcreazy 08158b6167 fix(studio): the rail's stars and folding on current studio
Rebased onto studio after #14301, which also touched Transcript.tsx.
2026-10-24 04:43:08 +08:00
imcreazy 6df4907583 fix(studio): a sidebar open reports its failure
`useSidebarOpen` dropped the promise with `void`, so `Workspaces` never saw the rejection and the row stayed on its old selection. It now returns the open's own promise.
2026-10-24 04:39:49 +08:00
esengine 935afed07c test(studio): saving a native archive as Markdown keeps its turns 2026-10-23 13:35:56 -07:00
YHH 660f2c9b8b Merge pull request #14301 from imcreazy/feat/find-regex-and-case
feat(studio): match case and regular expressions in the find bar
2026-10-23 13:35:22 -07:00
esengine 2184e43c60 fix(jobs): a started background job is announced with a code and its payload, so it follows the language 2026-10-23 13:32:42 -07:00
imcreazy 8032428bbf fix(studio): the back arrow names the view it returns to
It was a button with no declared action, which raised the undeclared input count. It sets the same view the sidebar uses.
2026-10-24 04:20:04 +08:00
imcreazy 453c9b7aeb fix(studio): 区分大小写 lives in one catalogue
`en_settings.ts` already defines it, and `i18n.test.ts` rejects a key that appears in two catalogue files.
2026-10-24 04:12:31 +08:00
imcreazy 51cb4202ba fix(studio): the export headings stay in the one catalogue
`en_export.ts` repeated 我 and 会话, and `i18n.test.ts` rejects a key that lives in two catalogue files. The headings now use the entries already there. The menu test follows the second export row.
2026-10-24 04:11:22 +08:00
imcreazy 3d5d965850 fix(studio): an empty match is not a hit
`^` and `a*` match the empty string, so the counter reported hits the painter had no range for. Both sides now skip them. Transcript.tsx keeps its 800-line ceiling by typing the mode inline instead of importing it.
2026-10-24 04:04:20 +08:00
imcreazy fe4b1d6d75 fix(studio): the count and the paint read the query the same way
The painter still searched the DOM for the literal text while the counter honoured the new modes, so a regex hit had no range to scroll to and a case-sensitive count did not match the painted one. Both now share one finder, a regex match advances past its own length, and a pathological pattern is cut short instead of freezing the window.
2026-10-24 03:56:58 +08:00
imcreazy 19bf655776 fix(studio): every shortcut row names the chord the window answers
The workbench chords are the platform modifier plus Shift, so a plain Shift+F types a letter. The zoom and modifier-Enter rows were missing.
2026-10-24 03:55:46 +08:00
YHH c8a7ab08c4 Merge pull request #14313 from jf200s/fix/cache-hit-rate
fix(stats): an endpoint's zero cache hit is a full miss, not an absence
2026-10-23 12:29:18 -07:00
esengine 9ad90c43bf docs(release): note for the cache hit rate fix 2026-10-23 12:05:51 -07:00
YHH 68e602fa97 Merge pull request #14316 from esengine/c5/fleet-test-race
test(delegation): observe the running fleet job before the children are released
2026-10-23 11:53:10 -07:00
esengine fee24df408 test(delegation): observe the running fleet job before the children are released 2026-10-23 11:38:24 -07:00
F 3f388550f7 fix(stats): an endpoint's zero cache hit is a full miss, not an absence
prompt_tokens_details with cached_tokens: 0 is the endpoint reporting the
split, so miss is derived from prompt - hit instead of staying zero and
vanishing into the unknown bucket; a hit above prompt_tokens is a broken
report and clamps to it. The overview and the meter floor the rate, so 100%
means every split token hit.
2026-10-24 01:43:05 +08:00
YHH 7bca7b7c7a Merge pull request #14306 from esengine/c5/release-2.50.0
docs(release): studio 2.50.0 notes
2026-10-23 09:53:00 -07:00
esengine e511c0beaa docs(release): studio 2.50.0 notes 2026-10-23 09:36:58 -07:00
YHH 0812e937c1 Merge pull request #14304 from esengine/c5/slash-typed-command
fix(studio): a command typed in full is still sent by Enter when longer commands share its name
2026-10-23 09:35:51 -07:00
YHH b4aa67ecb1 Merge pull request #14303 from esengine/c5/nav-label-center
fix(studio): a sidebar destination's label centres on its icon
2026-10-23 09:30:49 -07:00
imcreazy a55b816321 feat(studio): a shortcut sheet in the settings
A shortcut sheet in the settings.

Every chord the window answers to, on one page: find, search, the three views, stepping through
your questions, the workbench toggles, send, close, and the width keys. Chords are written the way
the platform writes them, so macOS shows ⌘ where Windows and Linux show Ctrl. It is a page of the
settings table of contents, so searching for "shortcut", "Esc" or "运行分析" lands on it.

The strings live in `en.ts` with every other catalogue entry: the i18n guard requires one
catalogue file, and an earlier revision of this branch split them out and failed it.
2026-10-24 00:17:25 +08:00
esengine ee4db04dd9 fix(studio): the sidebar's lower destination icons match the upper ones in size and weight 2026-10-23 09:12:20 -07:00
imcreazy 0948930529 fix(studio): make run analysis easy to enter and to leave
Make run analysis easy to enter and to leave.

- The entry in the window bar now carries its name. It needs an inline layout for that: the
  button is a 30px grid cell, so the icon and the label stacked and the text broke one character
  per line. `.chrome .thbtn.top-action` takes over the layout for this variant and keeps the
  label on one line; below 1180px the label steps aside so the other controls stay put.
- The analysis page has a back arrow beside its title.
- Picking a conversation that is already on screen returns its pane to the reading view instead
  of re-opening it. That rule now lives in `sidebaropen.ts` — partly because it is a rule about
  panes rather than window wiring, partly because `App.tsx` was 11 lines over its ceiling with
  it inline. It has its own test: open pane returns to reading, unopened opens a pane, a folder
  request opens a pane, and asking twice lands twice.

`Pane.tsx` differs from `studio` in one line (`onBack`), so the `useAgentRequest` wiring from
#14291 stays intact.
2026-10-24 00:09:14 +08:00
esengine c122a0c33a fix(studio): a command typed in full is still sent by Enter when longer commands share its name 2026-10-23 09:05:28 -07:00
imcreazy 70c77311c8 feat(studio): match case and regular expressions in the find bar
Match case and regular expressions in the find bar.

`usefind` now hands back `regex`, `caseSensitive` and `setMode` with the rest of its state —
the previous push declared them on `Finding` and never returned them, which is what
`frontend-next` caught. Changing either reading restarts from the first hit.
2026-10-23 23:59:55 +08:00
esengine aafd47647a fix(studio): a sidebar destination's label centres on its icon 2026-10-23 08:41:27 -07:00
imcreazy 4c9472e006 feat(studio): export a conversation as Markdown
Rewritten against the shape the kernel actually writes. A transcript line is a provider message
(`role`, `content`, `tool_calls`, `host_authored`), so the export keys on `role`: user and assistant
only, skipping host-authored user lines, tool results and system lines, and skipping assistant turns
that carry only tool calls. Headings go through `t()`. Native archives keep their `records` instead of
exporting an empty list. The test now uses real JSONL lines and covers the native-archive case.
2026-10-23 23:37:27 +08:00
YHH 80a3649883 Merge pull request #14293 from imcreazy/fix/reading-column-handles
fix(studio): make the reading column's handles visible and reachable
2026-10-23 08:31:34 -07:00
YHH 0586891b82 Merge pull request #14298 from xxoingr/docs/mcp-panel-not-mcp-add
docs(mcp): point a running chat at the /mcp panel, not /mcp add
2026-10-23 08:30:25 -07:00
YHH 498ac3a50f Merge pull request #14297 from xxoingr/test/two-asks-wait-for-the-yield
test(control): wait for the second ask to yield, not just to register
2026-10-23 08:12:40 -07:00
xxoingr d0baf85892 docs(mcp): point a running chat at the /mcp panel, not /mcp add
The controller runs only `/mcp connect` (#14167), so `/mcp add x` now
answers "unknown /mcp subcommand". Both guides and `reasonix mcp help`
still sent a running chat there to save and connect a server. They now name
`n` in the terminal /mcp panel, which installs through the same
POST /mcp/install as Desktop's Add and connect. The guides also name `p`,
the panel's project install, which InstallMCPServer writes to the
project's `reasonix.toml` (MCPConfigPathForEntry -> ProjectConfigPath).

TestTheDocsNameOnlyMCPAndSkillsSubcommandsThatRun reads docs/*.md and the
READMEs and holds every `/mcp <sub>` and `/skills <sub>` they write to the
subcommands completion offers, which are the ones the controller runs.
2026-10-23 22:57:57 +08:00
xxoingr bb7e1156f0 test(control): wait for the second ask to yield, not just to register
TestTwoAsksRetakeTheClaimOnlyAfterTheLastIsAnswered answered the first ask
as soon as two asks were registered. Ask registers before it yields the
workspace claim, and Yield reads the claim log from disk before it counts
the waiter, so on a slow Windows runner the second ask could still be short
of the yield when the first one resumed. The first resume then saw no other
waiter and took the claim back, and the test reported the claim retaken while
the second ask waited (studio 39941591112, windows-latest).

The test now waits for the second ask's prompt-queued notice. Ask reaches
lockPromptFor only after its yield, so the notice is the point at which the
second ask is waiting on the user.
2026-10-23 22:52:54 +08:00
imcreazy 9487d458ab fix(studio): make the reading column's handles visible and reachable
Rebuilt on current `studio`; the previous push was based on an older snapshot and carried unrelated reverts.
2026-10-23 22:51:53 +08:00
YHH 2639a5f526 Merge pull request #14292 from esengine/c5/billing-mode-selector
feat(providers): a connection says whether it is billed per use or by subscription
2026-10-23 07:31:55 -07:00
YHH 5345930379 Merge pull request #14291 from esengine/c5/agent-workbench-tab
feat(studio): a sub-agent opens beside the conversation as a workbench tab that reads its full record
2026-10-23 07:30:45 -07:00
YHH e4903bb43f Merge pull request #14063 from SivanCola/codex/present-files-settings
feat(studio): add opt-in file delivery settings / 新增文件交付卡片开关
2026-10-23 07:29:33 -07:00
YHH f22623f5d1 Merge pull request #14279 from tongsailong/fix/outside-observed-roots
fix(completion): report writes outside observed roots
2026-10-23 07:05:04 -07:00
esengine 1e4806ada6 feat(studio): a sub-agent opens beside the conversation as a workbench tab that reads its full record 2026-10-23 06:56:04 -07:00
YHH f07ec5db87 Merge pull request #14066 from SivanCola/codex/present-files-default-on
feat(tools): propose default-on file delivery / 提议默认启用文件交付卡片
2026-10-23 06:50:40 -07:00
esengine 66a992d41c feat(providers): a connection says whether it is billed per use or by subscription 2026-10-23 06:48:15 -07:00
YHH fbaa4595cd Merge pull request #14290 from esengine/c5/subagent-parent-call
feat(delegation): a sub-agent run names the tool call that spawned it
2026-10-23 06:35:31 -07:00
阿稳 62ea682420 test(completion): read the ledger's path identity in the out-of-sight tests
CI on Windows failed both new assertions: the detail named
`c:\outside-watch\reasonix cli.desktop` and the temp deliverable came
back lowercased, while the tests compared against the path spelled as
constructed. `evidence.normalizePath` lowercases on Windows, because the
ledger needs one identity per file and Windows hands back either case.

Compare against that identity instead of the constructed spelling: the
rule under test is about which paths are reported, not how the host
happens to capitalise them.
2026-10-23 21:32:36 +08:00
esengine 981d70747b Merge studio into present-files default-on 2026-10-23 06:29:20 -07:00
esengine 7eae730528 feat(delegation): a sub-agent run names the tool call that spawned it
Refs #13693
2026-10-23 06:15:55 -07:00
esengine 40a219f211 style(port): keep port.ts at the 800-line ceiling 2026-10-23 06:10:10 -07:00
阿稳 f3521711c7 test(completion): a granted root is not reported as out of sight
The gap names writes the host watches nothing of. A folder the person
granted as workspace is walked with the root and observed the same way,
so a write there is work product and must not carry the caveat.

This pins the rule at the boundary extraRoots decides, so a later change
cannot start reporting exactly the deliveries the grant exists to allow.
2026-10-23 21:06:11 +08:00
YHH 6e97bf257a Merge branch 'studio' into codex/present-files-settings 2026-10-23 05:39:43 -07:00
YHH 493736c685 Merge pull request #14152 from lancy69/fix/14126-minimal-image-capability
fix(composition): allow lightweight mode without image capability
2026-10-23 05:39:10 -07:00
阿稳 402eda1383 Merge remote-tracking branch 'origin/studio' into fix/outside-observed-roots 2026-10-23 20:37:13 +08:00
YHH 5e8d0d704a Merge pull request #14278 from tongsailong/fix/subagent-progress
fix(delegation): track background subagent progress
2026-10-23 05:12:26 -07:00
YHH e6ce624bf3 Merge pull request #14277 from tongsailong/fix/bounded-wait
fix(jobs): bound background wait timeout
2026-10-23 05:09:19 -07:00
阿稳 f2ff4b3425 Merge branch 'fix/outside-observed-roots' of https://github.com/tongsailong/DeepSeek-Reasonix into fix/outside-observed-roots 2026-10-23 20:06:57 +08:00
阿稳 dbcf4dc572 fix(hostwrite): read the home directory once for both lists
`Caches` read the process's home directory itself, which put the package two
reads over its `process-input` budget and the repo one over its ceiling. Both
lists answer from the same fact, so it is read in one place and passed to each.

The cache list also gained the two entries `Dirs` already had on the platforms
that have them: `$HOME/.cache` everywhere, and `Library/Caches` on macOS. A
cache filter that named only `go`, `.cargo` and `.npm` would report a Unix build
cache as out of sight on the one platform whose caches live under a different
directory name.
2026-10-23 20:06:21 +08:00
YHH edfc6b12b6 Merge pull request #14286 from esengine/feat/host-self-hosted-gateway
feat(remote): the desktop host and the phone page can use a gateway the person runs
2026-10-23 05:02:18 -07:00
YHH 061a1f69cc Merge branch 'studio' into fix/outside-observed-roots 2026-10-23 04:39:17 -07:00
YHH 30cc944d20 Merge branch 'studio' into fix/subagent-progress 2026-10-23 04:39:13 -07:00
YHH 9e60fd762a Merge branch 'studio' into fix/bounded-wait 2026-10-23 04:39:11 -07:00
esengine 24e6ccf64b feat(remote): the desktop host and the phone page can use a gateway the person runs 2026-10-23 04:38:13 -07:00
阿稳 f9de73aeda fix(completion): read build caches from their own source
Two defects, both found by CI on Windows and both in this PR's own new code.

`underScratchOrCache` asked `hostwrite.Dirs()` for the toolchain caches. That
function answers what a jail grants a command, and Windows jails nothing, so it
returns nil there: every build cache write was reported as out of sight on the
one platform where the list is empty. `hostwrite.Caches()` answers the question
actually being asked — where does a build write through — and is defined on every
platform. The two questions stay apart because conflating them is what made a
Windows build produce a caveat about its own module cache.

The one-off directory `sessiontemp` hands a turn is recognised by its position,
not by its name. It is a direct child of the host's temporary root, so that root
is read from the host and the first component below it is compared against the
prefix. The previous version walked every component, which exempted any folder
deeper in the tree that happened to spell the prefix, and it assumed the root was
named `/tmp`, which is not where Windows puts it.

The tests that pinned these edges set `HOME` and `TMPDIR` and then read the
result, which is why they agreed with the code on Linux and disagreed with it
everywhere else. `HOME` and `TMPDIR` are POSIX-only names: on Windows the
temporary root comes from `TEMP`, so `t.Setenv("TMPDIR", …)` changed nothing and
the assertions ran against the real root. They now call `disposable` with the
roots as arguments, and build paths from the host's own temp root, so each
assertion is about the rule rather than about an environment.

Tested: the thirteen out-of-scope tests, the packages this PR touches, and
`GOOS=windows go vet` on `internal/runtime/agent` and `internal/base/hostwrite`.
2026-10-23 19:21:36 +08:00
YHH 370b90716b Merge pull request #14281 from ShryukGrandhi/fix/issue-14163-studio-feedback-list-recovery-lineage-fo
fix(sessionstore): a NUL-filled branch-meta sidecar reads as absent
2026-10-23 04:20:28 -07:00
YHH 80e897cbf2 Merge pull request #14276 from esengine/fix/task-description-optimistic-default
fix(delegation): the task tool description says optimistic is the default for undeclared writes
2026-10-23 03:47:34 -07:00
YHH 30e7514754 Merge pull request #14275 from esengine/fix/coordination-hints-default-optimistic
fix(settings): the session-coordination hints say optimistic is the default
2026-10-23 03:45:01 -07:00
YHH 3ec18e6cb0 Merge pull request #14274 from esengine/fix/grants-test-waits-for-persists
test(approval): wait for every queued grant write before reading the sidecar
2026-10-23 03:44:24 -07:00
esengine 26d429e2b3 fix(delegation): the task tool description says optimistic is the default for undeclared writes 2026-10-23 03:35:17 -07:00
esengine 4f27fdd99f fix(settings): the session-coordination hints say optimistic is the default 2026-10-23 03:29:31 -07:00
阿稳 a440f83b0a fix(completion): report writes outside observed roots
A turn that wrote a file outside the workspace and then answered ended with a
`done` receipt and no gaps. The host saw the tool call succeed, but its
observation only covers the workspace and its granted extra roots, so the write
was dropped before the report was built: no mutation, nothing to verify, nothing
to say. `final_readiness` agreed — it judges a turn by whether it has a writer,
and a write it cannot see is not one — so the turn ended as a quiet success
while the work it claimed had landed nowhere the host could check.

Record those writes as a gap instead. `outsideObservedWrites` reads the turn's
own successful write receipts and keeps the paths that are outside the workspace
and outside what the host owns: the session scratch directory, toolchain caches.
Two exemptions carry the weight. A public temporary root is not exempt — a
person who asked for a file there asked for something to keep — and neither is
`bin/` under a cache, because `go install` puts the user's own executable in
`~/go/bin`. A scratch directory earns its exemption only as a direct child of a
temporary root, so a folder is not exempt for spelling the prefix.

Reported, never enforced. The verdict drops from done to partial and the receipt
names the gap; the turn ends exactly as it did before. A person who asked for a
file outside the workspace got what they asked for, and what they are owed is
the line saying that part is out of sight, not a turn that will not end. For the
same reason the gap is a notice, not a warning.

CompletionReceipt also stops discarding the report's own gaps when the verdict is
unknown. It returned only the claim-yield and mod gaps there, so a turn the host
could not judge — a plan with no criteria, writing only outside the workspace —
went back to saying nothing at all.

Blast radius: the completion report on turns that write outside the workspace,
and the receipt the host renders for them. The readiness gate, Delivery, Goal,
cancel and budget paths do not read the report, so none of them is affected.

Neighbouring behaviours tested: a turn that writes outside the workspace is not
reported as finished; one that stays inside keeps its quiet ending; a plain
answer and a read-only investigation end with no caveat and no continuation; a
scratch probe carries none; a deliverable in the public temp directory does; a
lookalike directory and an installed executable are not exempt; an unfinished
todo list still holds the turn; an unjudged turn keeps its gap. Desktop
`ReceiptCard` gained one case, with the three catalogue entries the i18n gate
requires; typecheck and the full vitest run pass.

Scope: this reports a write the host cannot see. It does not decide that a turn
which never wrote anything was unfinished — a read-only multi-step turn that
stops early still ends as it did.
2026-10-23 18:29:02 +08:00
阿稳 c9a6a3b4fc fix(delegation): track background subagent progress
A background sub-agent writes its work into its own transcript and its tool
calls, never into the job that started it. The job's activity stamp therefore
never moved, and the watchdog judged the run by bytes on streams that stay
empty: a busy sub-agent read as a job that had been silent since it started, and
a stalled notice went out about work that was progressing.

Bind the child's progress to its job at beginExecution — the one boundary every
delegated run crosses, foreground included, where a context with no job binds
nothing. A finished tool call is one reported step; token deltas refresh the
activity stamp at most once every two seconds, because the watchdog reads
minutes and the child emits far more often than that.

Reported steps are kept apart from output text rather than folded into it. They
answer different questions — whether the child advanced, and whether it wrote
here — and a caller decides from the pair. `wait` says both, and a stalled
notice says what the job had reported before going quiet, since a job that
worked and stopped needs a different next move than one that never got going.

Tested: internal/tools/jobs (a job that writes nothing stays alive while it
reports progress; wait reads steps separately from output), internal/tools/builtin
(progress wording per reading), internal/runtime/delegation (binding at
beginExecution, a finished tool call reports one step, deltas are paced, a
foreground run stays unbound), internal/contract/event, and desktop
frontend-next (typecheck; full vitest run, 6120 tests). The desktop stalled-notice
sentence had no English for its new wording and the i18n gate caught it; the
three entries are added here.
2026-10-23 18:28:55 +08:00
阿稳 162648e8e6 fix(jobs): bound background wait timeout
`wait` accepted any `timeout_seconds`, and omitting it meant blocking until
every job finished. A sub-agent that never exits then holds the turn open with
no way for the caller to tell a long wait from a hung session: the tool result
is the only thing that returns, and it returns only when the job does.

Cap one wait at 300 seconds and apply that cap when the timeout is omitted too,
so omitting it means "wait for the jobs" rather than "wait forever". The result
says when the ceiling was reached, and only then: a job that finishes first
returns before it, and claiming a 300s wait that never happened would be a
second false report in a message whose whole job is to be accurate.

The schema states the same bound the runtime applies. It previously promised an
unbounded wait, which the tool no longer performs.

Tested: internal/tools/builtin (wait ceiling applied and reported, a 1s wait
returns progress instead of holding), internal/tools/jobs, and the boot golden
recapture for the changed tool schema. internal/assembly/boot has one
pre-existing failure on this host (TestEffectSessionScratchIsReachableByItsSpelling)
that reproduces on the unmodified base: bubblewrap cannot create a namespace
here, so the sandboxed bash call the test relies on refuses to run.
2026-10-23 18:27:26 +08:00
esengine 71970278a5 test(approval): wait for every queued grant write before reading the sidecar 2026-10-23 03:23:46 -07:00
YHH 8fd97e1245 Merge pull request #14273 from esengine/fix/composer-toolbar-one-row
fix(studio): the buttons under the composer stay on one row as the conversation column narrows
2026-10-23 03:21:55 -07:00
YHH 2784587243 Merge pull request #14271 from esengine/feat/settings-sheet-expand
feat(studio): the settings window can be enlarged to fill the window, and remembers it
2026-10-23 03:18:51 -07:00
esengine 58c112c919 fix(studio): the buttons under the composer stay on one row as the conversation column narrows 2026-10-23 03:04:12 -07:00
esengine ccc7010c8f docs(release-notes): name the mirrored feedback issue 2026-10-23 02:57:35 -07:00
esengine a3880cf953 feat(studio): the settings window can be enlarged to fill the window, and remembers it 2026-10-23 02:56:29 -07:00
YHH 6ca52b40fb Merge pull request #14270 from esengine/fix/remember-note-says-where
fix(approval-card): the always-allow note says where paid search, egress and sign-in answers are kept
2026-10-23 02:51:00 -07:00
YHH 35a508c30a Merge pull request #14093 from lancy69/fix/14028-ask-unavailable
fix(studio): label Ask work mode as not implemented
2026-10-23 02:44:52 -07:00
esengine 728bab14d9 fix(approval-card): the always-allow note says where paid search, egress and sign-in answers are kept 2026-10-23 02:38:06 -07:00
YHH f14ae798be Merge pull request #14269 from esengine/fix/analysis-icon-activity
fix(studio): the run analysis icon is a pulse line that matches its neighbours
2026-10-23 02:28:43 -07:00
YHH f174ee55e4 Merge pull request #14268 from esengine/feat/nav-entries-customize
feat(studio): hide or reorder the navigation entries of the icon rail and the sidebar
2026-10-23 02:28:04 -07:00
YHH e8e03fde3a Merge pull request #14262 from esengine/fix/grants-persist-ordered
fix(approval): session grants persist in order, so a revoke is not undone by an earlier write
2026-10-23 02:13:16 -07:00
esengine 6fa3ab8dcf fix(studio): the run analysis icon is a pulse line that matches its neighbours 2026-10-23 02:12:52 -07:00
esengine 892d071d42 feat(studio): hide or reorder the navigation entries of the icon rail and the sidebar 2026-10-23 02:05:10 -07:00
YHH b6a321054e Merge pull request #14266 from esengine/feat/nav-parity
feat(studio): the sidebar offers the same destinations as the icon rail, from one list
2026-10-23 01:57:10 -07:00
YHH 741f40c7d4 Merge pull request #14265 from esengine/fix/effort-display-follows-choice
fix(effort): the status says the depth the person chose even when the request does not change
2026-10-23 01:48:19 -07:00
YHH de2f8b81a1 Merge pull request #14263 from esengine/fix/sidecar-not-executable-says-so
fix(sidecar): a runtime that is not a program says what the plugin has to ship
2026-10-23 01:44:41 -07:00
esengine 26a21fd177 feat(studio): the sidebar offers the same destinations as the icon rail, from one list 2026-10-23 01:44:18 -07:00
YHH 79ab732ae1 Merge pull request #14261 from esengine/fix/credential-heal-on-refusal
fix(desktop): check and restore the launch credential cookie after a refused request
2026-10-23 01:36:59 -07:00
esengine ecdf0fb140 fix(approval): session grants persist in order, so a revoke is not undone by an earlier write 2026-10-23 01:32:38 -07:00
YHH f702bf02ed Merge pull request #14259 from esengine/fix/run-skill-shows-name
fix(studio): a skill step names the skill it ran
2026-10-23 01:31:06 -07:00
YHH 08ac241c5e Merge pull request #14258 from esengine/fix/reveal-icon-folder
fix(studio): the reveal-in-file-manager icon is an open folder, not an arrow
2026-10-23 01:30:28 -07:00
YHH 9af6d78c1e Merge pull request #14257 from esengine/fix/subagent-entry-and-meaning
fix(delegation): tell the model a sub-agent is not a new session
2026-10-23 01:29:51 -07:00
YHH 384c0b8087 Merge pull request #14252 from esengine/fix/read-refusal-says-why
fix(tools): a refused read says which target, which folders may be read and why
2026-10-23 01:29:13 -07:00
esengine 9224c80c92 fix(effort): the status says the depth the person chose even when the request does not change 2026-10-23 01:28:14 -07:00
esengine 6e4681f33b fix(sidecar): a runtime that is not a program says what the plugin has to ship 2026-10-23 01:23:00 -07:00
esengine 907357fb33 fix(desktop): check and restore the launch credential cookie after a refused request 2026-10-23 01:13:06 -07:00
esengine 30b38c9063 fix(studio): a skill step names the skill it ran 2026-10-23 01:08:29 -07:00
YHH ab8e51c497 Merge pull request #14255 from esengine/fix/feedback-markdown-preview
feat(feedback): preview the report body as Markdown before sending
2026-10-23 01:06:05 -07:00
esengine c6930ad1b7 fix(studio): the reveal-in-file-manager icon is an open folder, not an arrow 2026-10-23 01:05:38 -07:00
esengine b4db2d6516 test(agent): compare the refused read's target by name, not by a path spelling Windows rewrites 2026-10-23 01:02:33 -07:00
YHH 180450189e Merge pull request #14254 from esengine/fix/feedback-mine-fast-and-list-first
fix(desktop): my feedback shows the list it has at once and keeps level, account and board behind one row
2026-10-23 01:01:37 -07:00
YHH 01f839c664 Merge pull request #14240 from esengine/feat/optimistic-claims-default-on
feat(config): optimistic claims are the default for sub-agent claims and the write lease
2026-10-23 01:01:03 -07:00
YHH 47cefa8fb2 Merge pull request #14239 from chuiwenwei/fix/an-unreadable-fact-is-not-absent
fix(memory): a fact that cannot be read is not absent
2026-10-23 01:00:26 -07:00
esengine 5cad66697b fix(delegation): tell the model a sub-agent is not a new session 2026-10-23 00:58:08 -07:00
esengine 1bbd409235 feat(feedback): preview the report body as Markdown before sending 2026-10-23 00:50:07 -07:00
YHH bdf86eb379 Merge pull request #14238 from chuiwenwei/fix/inbox-origin-is-not-the-persons
fix(tui): a runtime continuation is not the person's message
2026-10-23 00:40:25 -07:00
YHH 1261a1aade Merge pull request #14243 from esengine/feat/session-grants-persist
feat(approval): a conversation remembers the answers given for it, and resuming it does not ask again
2026-10-23 00:39:44 -07:00
YHH 66297898b9 Merge pull request #14244 from esengine/ci/browser-guards-timeout
ci(studio): give the browser guards job 25 minutes
2026-10-23 00:39:04 -07:00
esengine 0ba5b81993 docs(release): note the faster My feedback page 2026-10-23 00:35:57 -07:00
esengine 9a5fbb9e2e fix(desktop): my feedback shows the list it has at once and keeps level, account and board behind one row 2026-10-23 00:35:19 -07:00
esengine a375cbde45 docs(release): note that a refused read says why 2026-10-23 00:25:33 -07:00
esengine 691bc617a1 fix(tools): a refused read says which target, which folders may be read and why 2026-10-23 00:25:22 -07:00
esengine 3f0cf0bb10 ci(studio): give the browser guards job 25 minutes 2026-10-22 23:54:12 -07:00
YHH 994b9e05eb Merge pull request #14226 from esengine/fix/sidebar-rows-align-folders
fix(desktop): the sidebar's top and bottom rows sit on the project folders' icon line
2026-10-22 23:53:40 -07:00
esengine aff96b0800 docs(release): note that session grants are remembered with the conversation 2026-10-22 23:50:54 -07:00
esengine 0fc7a3c70a feat(approval): a conversation remembers the answers given for it, and resuming it does not ask again 2026-10-22 23:50:43 -07:00
Will a327256d5d fix(migrate): ask the existing helper whether the copy exists
absorbInterruptedCopy called os.Lstat itself, which CodeQL reports as a new
path sink in changed code. sessionArtifactExists is the store's pre-existing,
baselined reader for the same question: the copy exists when its transcript or
an owned sidecar does. It answers bool, so missing and unreadable look alike;
the import's callers already accept that, and a copy that is there but cannot
be read still reports its error where it is loaded.
2026-10-23 14:50:13 +08:00
Will a3172ed092 fix(migrate): keep the sidecar read where CodeQL already knows it
CodeQL flagged the two sidecar paths as new sinks once copyBranchMetaSidecar moved into migrate_copy.go: the os.ReadFile(BranchMetaPath(srcPath)) body was baselined in migrate.go, and a moved copy is not. The function moves back to its original position with only the error return changed, and completeBranchMetaSidecar now asks the pre-existing LoadBranchMeta reader whether the destination sidecar is there instead of calling os.Stat itself: ok means present and never rewritten, an error means unreadable and never clobbered, and only a clean absence proceeds. sameDirPath (a pure path comparison, no filesystem access) moves to migrate_passes.go so migrate.go stays inside its ratcheted file-size budget.
2026-10-23 14:50:13 +08:00
Will b92e5f0b50 fix(migrate): an imported session completes its sidecar
An import publishes the transcript first and the branch sidecar that carries
what it means second. A run stopped in that window left a transcript with no
sidecar, and the retry treated the transcript as completion: the flat import
healed only subagent artifacts and stamped the marker, and the v4 import only
healed the title. Both now finish the sidecar, refuse a failed sidecar write
as incomplete so the next run retries, and leave a finished copy untouched.
A v4 copy that is still a prefix of 1.x is brought up to the source; a copy
continued in 2.x keeps its own turns.
2026-10-23 14:50:13 +08:00
esengine 8def830791 docs(release): note that optimistic claims are on by default 2026-10-22 23:18:00 -07:00
esengine 5f7365e656 feat(config): optimistic claims are the default for sub-agent claims and the write lease 2026-10-22 23:16:04 -07:00
Will 9a986bc21a fix(desktop): import the unreadable-memory type the port re-exports
tsc caught it: port.ts re-exports UnreadableMemory from the local scope but
never imported it, so the panel's new type did not compile.
2026-10-23 13:52:11 +08:00
esengine 7633a44adb test(desktop): the session tab centring guard allows for the font the browser falls back to 2026-10-22 22:29:09 -07:00
ShryukGrandhi 8e88bd1665 fix(sessionstore): a NUL-filled branch-meta sidecar reads as absent
A power loss on NTFS can leave a .meta sidecar extended but never flushed, so it holds only NUL bytes. LoadBranchMeta reported that as a decode error, which made the recovery-lineage walk refuse to archive anything in the folder and kept UpdateBranchMeta from ever rewriting the file. The file records nothing, so it now loads as a missing sidecar and the next save replaces it.

Signed-off-by: ShryukGrandhi <[email protected]>
2026-10-22 22:19:01 -07:00
YHH e64f9f5507 Merge pull request #14225 from lancy69/fix/14219-composer-reflow
fix(studio): keep composer chip text aligned during reflow
2026-10-22 22:14:08 -07:00
YHH 66fb456651 Merge pull request #14229 from esengine/fix/workbench-wide-and-full-at-the-edges
fix(desktop): the workbench's wide toggle sits at the left edge of its bar and the full-size toggle at the right edge
2026-10-22 22:12:52 -07:00
esengine 630d4ce71c fix(desktop): the session tabs keep whole glyphs: shift the label down instead of trimming its box 2026-10-22 22:09:19 -07:00
esengine 3bb09ebc23 fix(desktop): the session pill sits on a whole pixel and its tab text stays centred across text sizes 2026-10-22 22:04:55 -07:00
esengine e812286ad7 fix(desktop): the session tabs centre their text between the pill's edges 2026-10-22 21:57:36 -07:00
esengine a9a451a565 fix(desktop): the sidebar account avatar is one rounded square 2026-10-22 21:49:13 -07:00
Will cc328cfda8 fix(memory): a fact that cannot be read is not absent
loadMemory returned (false, nil) for a read error, so every caller treated a
present-but-unreadable fact file as missing: it vanished from /memory, the
desktop panel, the memory tool and recall, and a save by name then minted a new
id at revision 1 over it with no snapshot, losing the old identity and history.

loadMemory now returns a typed FactReadError. ListActive reports unreadable
facts beside the readable ones, and the listing, read-by-name, panel, tool and
recall audit all name them; recall still serves the readable facts and keeps
its old sentence for a whole-directory failure. A save by that name refuses
before snapshotting or writing anything.
2026-10-23 12:46:33 +08:00
esengine def7a9733c fix(desktop): the sidebar logo's first letter starts where the row labels start 2026-10-22 21:45:38 -07:00
esengine da724fde38 fix(desktop): the sidebar logo lines up with the rows and has equal space above and below 2026-10-22 21:38:38 -07:00
esengine abf4643a1d fix(desktop): the sidebar's top rows drop their hint text and keys 2026-10-22 21:38:38 -07:00
esengine 734eedebdf fix(desktop): the sidebar's top and bottom rows sit on the project folders' icon line, and Files carries a hint like its neighbours 2026-10-22 21:38:38 -07:00
Lancy 0da4ecfd91 fix(studio): keep composer chip text aligned during reflow 2026-10-23 12:34:49 +08:00
esengine d34063a89b fix(desktop): the workbench's wide toggle sits at the left edge of its bar and the full-size toggle at the right edge 2026-10-22 21:33:34 -07:00
YHH 08428a8bd3 Merge pull request #14224 from esengine/fix/relocation-test-zero-quote
test(stats): the relocation fixture expects the zero-amount quote the projection now keeps
2026-10-22 21:08:43 -07:00
Will 216ea245fd fix(tui): a runtime continuation is not the person's message
The terminal counted and drew a host-authored inbox entry as the person's
own pending line, so `d d` on it reached DELETE /inbox/items/<id> and the
kernel dropped the runtime's continuation. The desktop has always filtered
these out; the terminal never decoded the field it filters on.

The mirror now carries origin, readonly and capacity, and a declared-subset
wire-parity check holds it (and the page's copy) to the fields a decision
reads, so dropping one is a `make check` failure instead of a silent zero.

A host continuation is out of the pending rows and out of the count, except
where it is blocked or uncertain and needs a decision only the person can
make; there it is drawn marked as the runtime's. A read-only snapshot offers
no mutation key and says why.
2026-10-23 11:59:16 +08:00
esengine a0934614fc test(stats): the relocation fixture expects the zero-amount quote the projection now keeps 2026-10-22 20:43:42 -07:00
YHH add2e45c05 Merge pull request #14221 from esengine/fix/sidebar-rows-one-color
fix(desktop): the sidebar rows read in one colour and New session has no fill at rest
2026-10-22 20:28:52 -07:00
YHH 3649924679 Merge pull request #14214 from chuiwenwei/fix/usage-projection-forgets-old-roots
fix(stats): a relocated stats root does not count twice
2026-10-22 20:15:23 -07:00
YHH cd3c673973 Merge pull request #14215 from chuiwenwei/fix/usage-projection-keeps-zero-money
fix(stats): the projection keeps a zero-amount quote
2026-10-22 20:04:01 -07:00
YHH 1dae038558 Merge pull request #14212 from chuiwenwei/fix/import-marker-needs-every-session
fix(migrate): a failed copy must not stamp the import marker
2026-10-22 20:03:19 -07:00
YHH 839f2a2deb Merge pull request #14104 from chuiwenwei/fix/active-goal-retraction
fix(control): retract the goal contract when a goal stops
2026-10-22 20:02:38 -07:00
YHH 61c2633834 Merge pull request #14216 from esengine/fix/schedule-page-trust-current
fix(schedule): offer the open project for trust on the scheduled runs page
2026-10-22 20:01:57 -07:00
esengine 930808cb6d fix(desktop): the sidebar rows read in one colour and New session has no fill at rest 2026-10-22 19:55:08 -07:00
YHH aff13c0dc3 Merge pull request #14211 from esengine/fix/settings-fit-names-memory-icon
fix(desktop): the settings sheet shows its whole menu, the memory icon is centred, and the run analysis view keeps one name
2026-10-22 19:36:36 -07:00
YHH 63cefcd0fb Merge pull request #14210 from esengine/fix/feedback-paste-anywhere
fix(desktop): a paste into the feedback form reaches it after a click on a blank part
2026-10-22 19:25:51 -07:00
esengine 872031925d fix(schedule): offer the open project for trust on the scheduled runs page 2026-10-22 19:24:56 -07:00
Will 2023dfe047 chore: retrigger the check suite
The force-push to the restacked head did not start the test/build workflows
(closing and reopening only re-ran the labeler), so this empty commit emits a
synchronize event. No code change.
2026-10-23 10:13:18 +08:00
esengine fb3f5e4e57 fix(desktop): the settings sheet shows its whole menu, the memory icon is centred, and the run analysis view keeps one name 2026-10-22 19:00:49 -07:00
YHH 94b51af86d Merge pull request #14208 from esengine/fix/tool-blurbs-complete
fix(desktop): describe every built-in tool in the settings list in the interface language
2026-10-22 18:56:19 -07:00
YHH 9f1ad0873d Merge pull request #14207 from esengine/fix/sidebar-rows-align
fix(desktop): line up the sidebar rows' icons and labels with the rail hidden
2026-10-22 18:55:39 -07:00
esengine 74212941ae fix(desktop): a paste into the feedback form reaches it after a click on a blank part, and takes an image offered only as a clipboard item 2026-10-22 18:53:27 -07:00
Will 269bc394d1 fix(stats): the projection keeps a zero-amount quote
A line that carried a currency and a quote of zero was priced by the exact
JSONL read but discarded by the projection, which treated amount 0 as "no
price". A single-day range reads JSONL while a multi-day range reads the
projection, so one session showed [{CNY 3},{USD 0}] on 今日 and [{CNY 3}] on
30 天, lost costEstimated on a day whose only quote was zero, and could emit
the false "usage but no cost record" note.

A currency is the whole test for "priced"; zero is a genuine free quote. Both
build paths now keep it: projectRecord no longer drops amount 0, the rederive
view no longer filters cost<>0, and the rollup read no longer skips amount 0,
while a row with no currency still carries no money. No schema column was
needed, so SchemaVersion is unchanged; every launch reconciles the day files
through rederive, which repairs a projection built by the old filters.

Tests cover the fixed two-day history through the exact read, the incremental
append projection and the rebuilt projection, the zero-amount-only day's
costEstimated in both, and controls for a currency-less row and a non-zero
amount.
2026-10-23 09:51:05 +08:00
Will 4f25e4e555 fix(stats): a relocated stats root does not count twice 2026-10-23 09:50:40 +08:00
Will 6065e522c0 fix(migrate): a failed copy must not stamp the import marker 2026-10-23 09:42:21 +08:00
YHH b10f8bfd6f Merge pull request #14204 from esengine/fix/receipt-command-shown-short
fix(desktop): show only the start of a long command in the delivery receipt
2026-10-22 18:38:36 -07:00
esengine cc49a64de4 fix(desktop): describe every built-in tool in the settings list in the interface language 2026-10-22 18:27:26 -07:00
Will b572d96811 fix(control): retract the goal contract when a goal stops
<active-goal contract="full"> carries the whole goal-mode contract into the
model-visible history, and nothing retired it when the goal stopped: the next
request carried no goal block while the contract still said to keep pursuing
it, and active-goal being a superseded tag made a fold keep that stale copy.

A stop the model did not report now renders a tombstone under the same tag:
reason paused, cleared, budget, or host (the completion check could not run).
A model-reported block and a completed goal compose nothing, because the model
already knows. Delivery is asked of the visible history through the delivery
PR's projectionDebt: the tombstone is owed while the newest active-goal block
in view is a contract or reminder, and not once it is the tombstone itself, so
a rewind or fold that drops it owes it again with no remembered state.
SupersededUserBlockTags is unchanged, so the tombstone is the newest copy a
fold keeps.

The boot harness's blockOf now matches a tag prefix, since a variant block
opens with attributes after its tag.
2026-10-23 09:23:32 +08:00
esengine d968c3e329 fix(desktop): line up the sidebar rows' icons and labels with the rail hidden 2026-10-22 18:19:53 -07:00
esengine 3821d42d8a docs(release-notes): keep the receipt fragment within the line width 2026-10-22 18:03:51 -07:00
esengine d1858ef240 docs(release-notes): receipt command shown short fragment 2026-10-22 18:03:30 -07:00
esengine 36571f34ae fix(desktop): show only the start of a long command in the delivery receipt 2026-10-22 18:03:14 -07:00
YHH a08a4d1c8a Merge pull request #14202 from esengine/fix/kernel-refuses-retry-of-maybe-run-item
fix(inbox): the queue refuses to retry an entry that may already have run
2026-10-22 17:28:37 -07:00
esengine 85e1227ec6 docs(release-notes): queue refuses retry of maybe-run fragment 2026-10-22 16:58:48 -07:00
esengine 14cc437ffe fix(inbox): the queue refuses to retry an entry that may already have run 2026-10-22 16:58:34 -07:00
YHH de91470edc Merge pull request #14201 from esengine/docs/fragment-14179
docs(release-notes): fragment for #14179
2026-10-22 16:28:40 -07:00
YHH 6a59451d7b Merge pull request #14200 from esengine/feat/meter-session-total
fix(desktop): label the meter's running total as the session's and add the turn's own amount
2026-10-22 16:03:40 -07:00
esengine 17f98c8d2a docs(release-notes): fragment for #14179 2026-10-22 15:53:16 -07:00
YHH 519288c24c Merge pull request #14179 from chuiwenwei/fix/schedule-missed-one-shot-expires
fix(schedule): a missed one-shot stops being active
2026-10-22 15:53:02 -07:00
YHH 2e3cffd9b8 Merge pull request #14199 from esengine/docs/fragments-for-merged-prs
docs(release-notes): fragments for #14178, #14180, #14181, #14195 and #14196
2026-10-22 15:52:22 -07:00
esengine d56044f47e docs(release-notes): meter session and turn money fragment 2026-10-22 15:39:59 -07:00
esengine fe71ea4d71 fix(desktop): label the meter's running total as the session's and add the turn's own amount 2026-10-22 15:39:43 -07:00
esengine a5f083449a docs(release-notes): fragments for #14178, #14180, #14181, #14195 and #14196 2026-10-22 15:33:04 -07:00
YHH 4fa25f9a04 Merge pull request #14195 from lancy69/feat/sidebar-expansion-memory
feat(studio): propose remembered sidebar expansion states
2026-10-22 15:32:02 -07:00
YHH 62b96c7737 Merge pull request #14196 from lancy69/feat/settings-flat-scrim
feat(studio): separate Settings with a feedback-style flat scrim
2026-10-22 15:31:22 -07:00
YHH cd1241e3a8 Merge pull request #14180 from chuiwenwei/fix/tui-inbox-retry-knows-what-may-have-run
fix(tui): refuse a retry that may already have run
2026-10-22 15:30:41 -07:00
YHH 0355e62b93 Merge pull request #14178 from chuiwenwei/fix/desktop-pane-currency-fold
fix(desktop): keep a session's money per currency
2026-10-22 15:29:56 -07:00
YHH dd6d27e9bf Merge pull request #14181 from chuiwenwei/fix/migration-respects-session-lease
fix(migrate): leave a leased session where it is
2026-10-22 15:29:16 -07:00
YHH 1441c85e27 Merge pull request #14198 from imcreazy/feat/drag-the-reading-column
feat(studio): drag the edges of the reading column
2026-10-22 14:31:42 -07:00
imcreazy ab2878f33c feat(studio): drag the edges of the reading column 2026-10-23 04:57:44 +08:00
Lancy c85d97648f feat(studio): separate Settings with a flat feedback-style scrim 2026-10-23 01:53:16 +08:00
Lancy 7ca1d60d4f feat(studio): remember sidebar project expansion choices 2026-10-23 01:52:40 +08:00
YHH 2ddc6f646e Merge pull request #14193 from esengine/docs/release-notes-2.49.0
docs(release): studio 2.49.0 notes
2026-10-22 10:16:16 -07:00
esengine ec980105a2 docs(release): studio 2.49.0 notes 2026-10-22 10:05:14 -07:00
YHH 00bcf2610a Merge pull request #14191 from esengine/fix/sidebar-rhythm-and-separators
fix(desktop): even out the sidebar's shortcut rows and use one hint separator
2026-10-22 10:04:20 -07:00
YHH 01c8881a5f Merge pull request #14188 from esengine/fix/title-thought-only-says-so
fix(serve): say when the title model spent its budget thinking
2026-10-22 09:52:34 -07:00
esengine cc499a99b9 fix(desktop): pull the shortcut rows up without adding a stylesheet line 2026-10-22 09:29:36 -07:00
esengine 2a670f9419 docs(release-notes): sidebar rhythm and separators fragment 2026-10-22 09:21:03 -07:00
esengine 9309ce419a fix(desktop): even out the sidebar's shortcut rows and use one hint separator 2026-10-22 09:20:44 -07:00
YHH b771548207 Merge pull request #14187 from esengine/fix/sidebar-drop-duplicate-remote-row
fix(desktop): drop the sidebar's second link to the remote settings when the icon rail is hidden
2026-10-22 09:18:09 -07:00
esengine 26c4aec7d3 docs(release-notes): title thought-only fragment 2026-10-22 09:14:26 -07:00
esengine 2c4b9c29a9 fix(serve): say when the title model spent its budget thinking 2026-10-22 09:14:12 -07:00
YHH 05419f3569 Merge pull request #14176 from EMEEEEMMMM/feat/usage-today-hourly
feat(usage): show hourly usage for a single-day window
2026-10-22 09:04:14 -07:00
esengine c505806479 docs(release-notes): keep the sidebar fragment within the line width 2026-10-22 08:47:49 -07:00
YHH 9f8478b578 Merge pull request #14186 from esengine/ci/race-full-suite-package-timeout
ci(race): give the full-suite package timeout headroom over assembly/boot
2026-10-22 08:47:13 -07:00
EMEEEEMMMM 70c8f4c18d feat(usage): show hourly usage for a single-day window 2026-10-22 23:46:07 +08:00
esengine 6301f4cb46 docs(release-notes): sidebar single remote entry fragment 2026-10-22 08:26:30 -07:00
esengine cda7945c58 fix(desktop): drop the sidebar's second link to the remote settings when the icon rail is hidden 2026-10-22 08:26:16 -07:00
YHH b0e69e04bb Merge pull request #14184 from esengine/fix/rail-hidden-sidebar-rows-one-type-scale
fix(desktop): give the sidebar's shortcut rows one type scale when the icon rail is hidden
2026-10-22 08:20:27 -07:00
esengine 09e10c5a3c ci(race): give the full-suite package timeout headroom over assembly/boot 2026-10-22 08:13:44 -07:00
YHH 62228e6f5d Merge pull request #14136 from FuchaZ/studio
feat(theme): add Patina as a plain light/dark palette
2026-10-22 08:04:23 -07:00
YHH 249248678d Merge pull request #14182 from esengine/fix/dag-redaction-test-counts-reads
test(sessionstore): count the bytes a redaction reads instead of process-wide allocation
2026-10-22 07:53:55 -07:00
YHH 12c3d7e0cc Merge pull request #14112 from chuiwenwei/fix/unpriced-calls-visible
fix(stats): count a call with no cost fact as incomplete
2026-10-22 07:53:18 -07:00
esengine c7260eacf1 docs(release-notes): sidebar rows type scale fragment 2026-10-22 07:39:25 -07:00
esengine 1199ca4356 fix(desktop): give the sidebar's shortcut rows one type scale when the icon rail is hidden 2026-10-22 07:39:06 -07:00
YHH 76020b84cb Merge pull request #14170 from imcreazy/fix/continuation-prompt-names-the-host
fix(control): keep the host's continuation prompt out of the user's turns
2026-10-22 07:30:34 -07:00
YHH 2ef6093fba Merge pull request #14175 from esengine/fix/tool-list-blurbs-in-ui-language
fix(desktop): describe each tool in the settings list in the interface language
2026-10-22 07:29:55 -07:00
esengine dc426fc539 test(sessionstore): count the bytes a redaction reads instead of process-wide allocation 2026-10-22 07:26:56 -07:00
Fucha f84861bea2 fix(theme): lighten Patina dark fgFaint to clear AA on floatHi 2026-10-22 22:25:58 +08:00
Fucha f5c5b79a47 docs(release): note the Patina theme in #14136 2026-10-22 22:21:22 +08:00
Fucha 006c369d09 fix(theme): raise Patina light synNumber to clear AA on codeBg 2026-10-22 22:21:14 +08:00
imcreazy 57da2aa319 fix(control): restore the source changes and register the older opening 2026-10-22 21:59:26 +08:00
esengine accf15760b docs(release-notes): tool list description fragment 2026-10-22 06:37:24 -07:00
imcreazy 71f434721b test(serve): regenerate the trace fixture after the prompt change 2026-10-22 21:37:17 +08:00
esengine 6b717476f8 fix(desktop): describe each tool in the settings list in the interface language 2026-10-22 06:37:10 -07:00
imcreazy c6afee53d8 fix(control): keep the host's continuation prompt out of the user's turns 2026-10-22 20:49:34 +08:00
YHH 42f1cd739c Merge pull request #14045 from imcreazy/fix/unprefixed-request-remote-pane
fix(serve): carry an unprefixed request to a remote pane
2026-10-22 05:43:28 -07:00
YHH 95d86f1700 Merge pull request #14044 from imcreazy/fix/dns-tcp-reply-framing
fix(netdns): read a TCP reply past its length prefix
2026-10-22 05:42:49 -07:00
YHH 6b56a1fb0e Merge pull request #14161 from lancy69/fix/14116-bounded-session-replay
fix(sessionstore): bound history replay memory
2026-10-22 05:28:30 -07:00
YHH c7b90c0676 Merge pull request #14162 from lancy69/fix/14145-side-progress
fix(studio): stream progress for side questions
2026-10-22 05:27:40 -07:00
imcreazy c6586b9ec9 docs(release-notes): name the unprefixed request fix 2026-10-22 20:21:37 +08:00
imcreazy 8560defcf8 docs(release-notes): name the DNS TCP reply framing fix 2026-10-22 20:21:30 +08:00
Lancy 788cf7e18f fix(studio): stream progress for side questions 2026-10-22 19:58:32 +08:00
Lancy 93e5cf97de fix(sessionstore): bound history replay memory
Retain legacy DAG payload locations instead of discarded message bodies, preflight JSON without copying large strings, and refuse oversized records or retained histories without modifying source data or falling back to stale checkpoints.

Refs #14116
2026-10-22 19:58:06 +08:00
YHH a18234d724 Merge pull request #14167 from esengine/fix/mcp-skills-offer-only-what-runs
fix(control): offer only the /mcp and /skills subcommands the controller runs
2026-10-22 04:29:19 -07:00
YHH 240a24ca41 Merge pull request #14165 from esengine/fix/memory-lock-test-no-wallclock
test(memory): drop the wall-clock bound on uncontended saves
2026-10-22 03:48:37 -07:00
esengine 4ec14ba1fb docs(release-notes): mcp and skills completion fragment 2026-10-22 03:48:26 -07:00
esengine f9d0c934f6 fix(control): offer only the /mcp and /skills subcommands the controller runs 2026-10-22 03:48:14 -07:00
esengine e986ba01fb test(memory): drop the wall-clock bound on uncontended saves 2026-10-22 03:35:37 -07:00
YHH bbd7a01725 Merge pull request #14154 from esengine/fix/host-verification-off-by-default
feat(config): the host no longer holds a changed turn to its checks unless asked
2026-10-22 02:24:16 -07:00
YHH d337b7d8b0 Merge pull request #14150 from KHG420/codex/studio-wechat-paste-attachments
fix(studio): copy pasted files into managed attachments
2026-10-22 02:19:16 -07:00
YHH c163b58980 Merge pull request #14157 from esengine/fix/side-panel-renders-markdown
fix(desktop): draw the side conversation's answer as markdown
2026-10-22 01:53:00 -07:00
YHH c5d0df2120 Merge pull request #14158 from esengine/feat/loopmetrics-inputs
feat(loopmetrics): how to feed a real session, plus usage and M4 definition fixes
2026-10-22 01:52:36 -07:00
xiexian 9c12321906 fix(studio): copy pasted files into managed attachments 2026-10-22 16:26:22 +08:00
esengine 42c398aa6e feat(loopmetrics): session inputs note, truncation sidecar, and usage dedupe that survives resumed sessions 2026-10-22 01:19:37 -07:00
YHH 9ef691ebc9 Merge pull request #14155 from esengine/feat/loopmetrics-phase1
feat(loopmetrics): phase 1 harness and first results on long-session conditions
2026-10-22 01:19:07 -07:00
esengine f0c704db90 docs(release-notes): side panel markdown fragment 2026-10-22 01:14:18 -07:00
esengine 8e498b3f8c fix(desktop): draw the side conversation's answer as markdown 2026-10-22 01:13:45 -07:00
YHH 9a27d9a967 Merge pull request #14140 from SivanCola/fix/studio-turn-completion-state
fix(agent): bound post-answer finalization / 修复回答结束后的收尾阻塞
2026-10-22 01:05:17 -07:00
Will 95b8aa15a0 docs(release-notes): record unpriced calls counting as a floor
The reviewer asked for a bilingual fragment and supplied its outline: a
pre-schema row and a title call now count toward a total labelled a floor.
2026-10-22 16:00:47 +08:00
Will 485e2506eb fix(stats): count a call with no cost fact as incomplete
A call that spent tokens but recorded no money was counted in the token
and request totals and contributed 0 to Cost, with nothing in the payload
saying its money was unknown. Two paths produced that shape.

Title generation wrote such a row live: the serve title model builds its
own stats.Recorder instead of emitting through the assembled sink, so no
CostQuoteSink sits in front of it. The title event carried a rate card but
no quote, and the recorder writes cost fields only for a non-nil quote.
It now prices the call at emit time with event.EnsureCostQuote, using the
same QuoteContext construction the assembled sink uses
(boot.QuoteContextFor), so it is quoted like any other call.

Rows written before the cost fields existed (and any row that simply
lacks them) had the same effect on reads. The incompleteness trigger is
now "no cost fact": an explicit cost_complete=false, or tokens counted
with no cost_amount. One predicate (usagecatalog.CostIncomplete) is
applied on all three read surfaces, so they agree.

No price is invented and no legacy row is backfilled.
2026-10-22 16:00:37 +08:00
esengine b98bad7509 fix(loopmetrics): drive.py refuses a foreign reasonix.toml, and the report states analysed and excluded blocks 2026-10-22 00:46:57 -07:00
esengine d5916445b2 feat(loopmetrics): phase 1 harness, scripted turns and first results 2026-10-22 00:44:44 -07:00
esengine 17bb7dd92c feat(loopmetrics): a run may be several legs of one session 2026-10-22 00:41:29 -07:00
YHH 0b48f5f3b7 Merge pull request #14149 from esengine/feat/loopmetrics
feat(loopmetrics): read-only loop metrics for trajectories, with negative controls
2026-10-22 00:35:56 -07:00
esengine 2df3595ac5 docs(release): note the host verification default 2026-10-22 00:33:25 -07:00
esengine 09d43155a1 feat(config): the host no longer holds a changed turn to its checks unless asked 2026-10-22 00:23:59 -07:00
Lancy 46f6adebbb fix(composition): make lightweight image capability optional 2026-10-22 15:09:42 +08:00
esengine f4fe395407 test(loopmetrics): a duplicated tool_result event counts one call 2026-10-21 23:49:43 -07:00
esengine eebf779b7c docs(loopmetrics): add the metadata header and split the long paragraphs 2026-10-21 23:47:17 -07:00
esengine 9d3a8958e1 feat(loopmetrics): read the flat wire-log envelope and fall back to message-level reasoning 2026-10-21 23:46:40 -07:00
esengine 1184b33233 feat(loopmetrics): read-only analyzer for repeated tool calls and reasoning, with negative controls 2026-10-21 23:45:22 -07:00
esengine 664d75d1d2 docs(loopmetrics): pre-register the loop metrics and their thresholds 2026-10-21 23:43:50 -07:00
YHH c9b435284b Merge pull request #14099 from chuiwenwei/fix/projection-delivery-from-visible-history
fix(control): derive projection delivery from the visible history
2026-10-21 23:42:28 -07:00
YHH 7a139fe8fa Merge pull request #14110 from chuiwenwei/fix/guardian-review-cost-accounted
fix(stats): quote a guardian review's usage like any other call
2026-10-21 23:37:52 -07:00
YHH 8029860f15 Merge pull request #14144 from SivanCola/fix/studio-summary-terminal-race
fix(agent): reject interrupted summaries at EOF / 修复压缩流结束与取消竞态
2026-10-21 23:37:06 -07:00
Will 6b56aade96 fix(desktop): keep a session's money per currency 2026-10-22 14:31:46 +08:00
Will 54a1d436a8 fix(migrate): leave a leased session where it is
The flat-import re-home took the save locks but never asked the session
lease, so it could move a WAL-backed transcript out of a live session root
and delete the old path while another runtime (or this one) still owned it.

Gate every move/remove in the migrate family on the same predicate
renameOverlongSession uses: sessionLeaseHeldLocally OR
SessionLeaseHeldByOtherRuntime. A deferred base is not counted as imported
and leaves the one-time marker unwritten, so a later run settles it.

Tests: audit red/green pair, unleased control, same-process lease control.
2026-10-22 14:28:57 +08:00
Will b3ff8de554 fix(tui): refuse a retry that may already have run 2026-10-22 14:20:33 +08:00
Will 7ded08677e fix(schedule): a missed one-shot stops being active 2026-10-22 14:14:01 +08:00
SivanCola b6b1b3da08 docs(studio): publish PR 14140 release-note fragment
Problem: the post-answer finalization fix note would be missed by the current release fragment collection.

Root cause: it used the retired shared unreleased.md path and did not name the fixing PR.

Fix: move the bilingual entry into unreleased/14140-bound-post-answer-finalization.md, preserving #13953 and adding #14140 to both bullets.

Verification: release-note and Markdown-reference tests pass (12 tests); make check, fragment discovery, bilingual references, release rendering, and whitespace checks pass. Only release-note documentation changes.
2026-10-22 14:09:17 +08:00
SivanCola 3698fb6dad docs(studio): publish PR 14144 release-note fragment
Problem: the compaction fix note was outside the release fragment glob and cited the related finalization PR instead of its own fix.

Root cause: the note used the retired shared unreleased.md layout.

Fix: move the bilingual entry into unreleased/14144-compaction-eof-vs-cancellation.md and reference #14144 in both bullets.

Verification: release-note and Markdown-reference tests pass (12 tests); make check, fragment discovery, exact bilingual references, release rendering, and whitespace checks pass. Only release-note documentation changes.
2026-10-22 14:08:04 +08:00
YHH 9264b58ba4 Merge pull request #14143 from esengine/fix/browser-eval-timeout
fix(browser): stop an eval script whose promise never settles
2026-10-21 22:28:49 -07:00
SivanCola 8380c9b67c fix(agent): reject interrupted summaries at EOF
Problem: compaction could report success and accept a partial digest after
the summary deadline or caller cancellation.

Root cause: providers close their channel when context ends. Selecting EOF
instead of the simultaneously ready cancellation accepted nonempty output;
the deferred ceiling classifier only corrected non-nil errors.

Fix: keep EOF validation at one result boundary and check the request context
before accepting a digest or classifying truncation. Preserve typed ceiling
and cancellation failures, usage accounting, and stable provider requests.

Verification: force EOF/cancellation overlap with a progress barrier and fake
clock, cover normal completion/truncation and charged usage, and exercise
the real Build-to-notice path. Baseline production source fails on macOS
and Linux; fixed Linux cases pass 20 repetitions. Focused race/cache tests,
full root tests, required tool/boot tests, vet, lint, make check, and release
script checks pass. A separate combined tree also validates this fix with
the post-answer finalization change from PR #14140.
2026-10-22 13:19:02 +08:00
esengine 869c4dc6e3 fix(browser): stop an eval script whose promise never settles 2026-10-21 22:01:28 -07:00
Will 2a8bdbce0b fix(control): resolve the projection seer on the current executor
The seer controller.New bound was the executor's method value, and that value
reads the session in place: a SetSession from /new, resume, a branch switch, an
extension system prompt or a fold is already visible to it. Those sites answer
from the current session. A model switch is different — it builds a new
controller, and ApplyRuntimeMigration adopts the old conversation into it. The
carried turns still hold the standing blocks the old model was sent, so the
derived rule reported them delivered and the new model's first request carried
no listing.

The seer now resolves through the controller and answers "nothing is visible"
for a generation that has delivered nothing yet. ApplyRuntimeMigration marks
that state when the replacement's model ref differs from the outgoing one; the
hand-off to the runner clears it, so exactly one request restates the standing
blocks and the visible history answers after that. A rebuild that keeps the
model keeps the derived answer.
2026-10-22 12:41:12 +08:00
SivanCola 859b3d3382 fix(agent): bound post-answer observation and honor turn cancellation
Problem: A completed model answer could leave the controller running while
workspace observation or an evidence stream lock kept finalization alive.

Root cause: Post-answer observation and sealing detached their deadlines
from the turn context. Observation called filesystem operations synchronously,
so its existing timeout did not actually bound the caller's wait.

Fix: Carry the run context through every finalization entry point. Bound
observation with a buffered result that captures no mutable turn state,
record missing observations as gaps, and cancel unused initial snapshots.
Keep optional finalization cancellation from discarding completed answers.

Verification: Deterministic cancellation, deadline, observation-budget,
held-seal-lock and real boot/controller completion regressions fail before
the fix and pass on macOS and Linux arm64 after it. Related race checks,
121 frontend tests, formatting, vet, lint and changed-file checks pass.
The required builtin-tool and full boot package suites also pass.
2026-10-22 11:59:03 +08:00
Fucha b4e6be051e docs(theme): list Patina with the plain palettes 2026-10-22 11:29:53 +08:00
Fucha 72e85d0634 Merge branch 'esengine:studio' into studio 2026-10-22 11:29:18 +08:00
Fucha 68b68a58e0 revert: restore upstream theme author guide 2026-10-22 11:22:31 +08:00
Fucha 0db2d1d0d7 docs(theme): list Patina with the plain palettes 2026-10-22 11:20:30 +08:00
Fucha 1551bf6254 feat(theme): add Patina as a plain light/dark palette 2026-10-22 11:20:15 +08:00
YHH a708a88a75 Merge pull request #14130 from esengine/docs/release-notes-2.48.0
docs(release): studio 2.48.0 notes
2026-10-21 19:01:52 -07:00
Will 8f48aa844f docs(release-notes): record the guardian cost fix
The reviewer asked for a bilingual fragment: the paid total now includes
the guardian's reviews, and an unpriced row marks the total as a floor.
2026-10-22 09:54:27 +08:00
esengine 07af85d9eb docs(release): studio 2.48.0 notes 2026-10-21 18:46:55 -07:00
YHH c0c7d9c435 Merge pull request #14128 from esengine/fix/session-menu-fits-short-window
fix(desktop): keep a project's context menu inside a short window
2026-10-21 18:46:38 -07:00
esengine 32782ef1a4 docs(release): note the project menu fix 2026-10-21 18:14:05 -07:00
esengine ffb01d54f7 fix(desktop): keep a project's context menu inside a short window 2026-10-21 18:13:33 -07:00
YHH 69b6927cd9 Merge pull request #14124 from esengine/fix/changes-list-row-cap
fix(desktop): draw a page of the changes list, not every row
2026-10-21 17:53:47 -07:00
esengine f7777b4f9e fix(desktop): draw a page of the changes list, not every row 2026-10-21 17:32:22 -07:00
YHH 7a46cde93c Merge pull request #14047 from imcreazy/fix/undefined-bg-property
fix(studio): name a property the sheets define
2026-10-21 12:49:05 -07:00
YHH 692e669d3f Merge pull request #14123 from esengine/fix/machine-dir-must-exist
fix(cli): refuse a machine --dir that is not a directory
2026-10-21 10:29:43 -07:00
YHH 17cb9e2257 Merge pull request #14121 from esengine/fix/compact-ratio-float-noise
fix(cli): save the compact ratio as the decimal the user typed
2026-10-21 10:29:33 -07:00
YHH cb441be28a Merge pull request #14117 from esengine/fix/tool-card-lazy-body
fix(desktop): do not mount the body of a finished, closed tool card
2026-10-21 10:29:14 -07:00
esengine 296e06655a chore(desktop): drop scratch fixtures that were committed by mistake 2026-10-21 10:04:32 -07:00
esengine b6bbcb372a fix(cli): only convert a percentage exactly when it is in range 2026-10-21 10:04:17 -07:00
esengine 482441e50b fix(cli): refuse a machine --dir that is not a directory 2026-10-21 09:50:13 -07:00
esengine ed969ae2bc test(desktop): open the card before the output-cap guard reads what it left out 2026-10-21 09:46:19 -07:00
esengine 49959f6b39 fix(cli): save the compact ratio as the decimal the user typed 2026-10-21 09:45:14 -07:00
esengine 450f3e58eb fix(desktop): do not mount the body of a finished, closed tool card 2026-10-21 09:10:37 -07:00
YHH 8d6523d054 Merge pull request #14111 from xxoingr/docs/guide-terminal-keys-mcp
docs(guide): the terminal key tables match the /mcp panel and the keys 2.x has
2026-10-21 08:05:32 -07:00
YHH 006f8e7930 Merge pull request #14109 from xxoingr/docs/guide-zh-wsl-remote
docs(guide): the Chinese guide describes WSL machines and the agent-shell refusal
2026-10-21 06:06:08 -07:00
YHH 9e271a2272 Merge pull request #14107 from xxoingr/fix/price-source-format-chars
fix(config): a price source refuses invisible format characters
2026-10-21 06:05:25 -07:00
YHH 0123495435 Merge pull request #14064 from SivanCola/feat/preset-module-multiselect
feat(studio): grouped preset module multiselect / 预设模块分组多选
2026-10-21 06:04:41 -07:00
xxoingr c123e68f57 docs(guide): the terminal key tables match the /mcp panel and the keys 2.x has
Two rows of the picker table described the 1.x CLI: an "MCP import picker"
the 2.x terminal does not have (`reasonix mcp import` imports without one),
and an "MCP manager" whose `r` refreshed and whose number keys chose actions.
The 2.x /mcp panel (mcppanel.go, mcpmanage.go) moves with Up/Down or j/k,
opens a server's tools with Enter, toggles with Space, adds with n through a
review that installs with y, l or p, removes with d then y, signs in with a,
grants requested access with u, and reconnects a failed server with r.

The chat table also missed keys the terminal handles: Ctrl+S (steers a
running turn, sends an idle draft), Shift+PageUp/PageDown, Ctrl+Insert to
copy a selection, Shift+Insert to paste text where the terminal hands it
over, Ctrl+G to keep folded pastes as files, and Ctrl+D twice in the resume
picker. The tool approval row now says its numbers follow the rows shown and
that Esc does not answer in vi mode, and plan approval gets its own row. The
Chinese table also gains the Ctrl+Enter / /steer row it never had.

Pin the three behaviours no test held: Ctrl+S on an idle draft, an approval
number with a row missing, and Esc on a plan approval.
2026-10-21 20:27:03 +08:00
Will cbc094ebbf fix(stats): quote a guardian review's usage like any other call 2026-10-21 20:18:55 +08:00
xxoingr c06754bb45 docs(guide): the Chinese guide describes WSL machines and the agent-shell refusal
The English remote section gained two blocks the Chinese guide never got: a WSL
distribution as a machine (kind = "wsl", what it accepts, how Studio adds and
keeps it, where its model credentials come from, which subcommands stay
SSH-only), and the rule that an agent's own shell may run only `list`, `help`
and `wsl list|test` of `reasonix remote` (exit 77 otherwise). Translate both into
the same place in GUIDE.zh-CN.md, between the config example and the CLI block,
using the Studio labels the Chinese window shows (设置 → 远程, WSL 发行版).

The guide says `wsl test` runs for an agent, but the test pinned only
`wsl list`; add the `wsl test` row. A distribution that does not exist answers
after the same `wsl --list` the `wsl list` row already runs, so no distribution
is started.

Both were raised as non-blocking notes on #13784.
2026-10-21 20:15:41 +08:00
xxoingr 854323a0d9 fix(config): a price source refuses invisible format characters
validPriceSource and its page mirror sourceProblem refused control and blank
characters but not format characters (Unicode category Cf: zero-width, soft
hyphen, bidirectional controls). A source carrying one was stored as written
while the page drew something else: with U+200B or U+00AD inside the host the
kernel stores a host that is not "api-docs.deepseek.com", and the page's host
line, read through the browser's URL parser, shows "api-docs.deepseek.com";
U+202E in the path reverses the tail in the hover title.

Refuse Cf in the same loop on both sides, the check validElicitURL already
makes for a link a person is shown. The page uses \p{Cc}\p{Cf} so it also
refuses the C1 controls Go's unicode.IsControl already refused.

Raised as a non-blocking note on #13864.
2026-10-21 20:09:25 +08:00
YHH 2a77016909 Merge pull request #14103 from esengine/fix/memory-test-helper-redeclared
test(memory): reconcile the cross-process test helpers of the snapshot and lock changes
2026-10-21 03:52:20 -07:00
esengine 4ee2d8b27b test(memory): reconcile the cross-process test helpers of the snapshot and lock changes 2026-10-21 03:33:40 -07:00
YHH 3c07b93b30 Merge pull request #14100 from chuiwenwei/fix/memory-cross-process-lock
fix(memory): serialise store mutations across processes with one file lock per directory
2026-10-21 03:31:01 -07:00
YHH 58b315efbc Merge pull request #14096 from chuiwenwei/fix/memory-snapshot-must-hold-its-revision
fix(memory): a revision snapshot must hold the revision it names
2026-10-21 03:30:21 -07:00
YHH 074bb920b7 Merge pull request #14086 from SivanCola/codex/progressive-history-scheduling
fix(history): progressive legacy discovery and background scheduling / 旧会话渐进加载与后台调度
2026-10-21 03:29:41 -07:00
SivanCola 3193780122 fix(history): confine checkpoint reads to their session directory
Problem: CodeQL flagged direct path opens in the cancellable checkpoint readers.

Root cause: The normal and torn-tail JSONL readers followed file symlinks beyond
the parent directory supplied by their caller, even though frontend request
authorization is owned separately at the HTTP boundary.

Fix: Open both checkpoints through os.OpenInRoot, retaining cancellation and
recovery semantics while rejecting links outside that directory. Preserve
relative in-directory links. Add a deterministic boundary regression and
bilingual release notes, and separate the sidebar Retry action from its note.

Verification: The escaping-link regression fails before the change and passes
after it. Complete storage/replay/catalog/scheduler suites, focused race and
HTTP boundary tests, 13 frontend tests, TypeScript checking, release-note tests
and repository/Go lint pass. Hosted CodeQL must confirm the new head separately.
2026-10-21 18:02:48 +08:00
Will 87965d1863 test(control): pin a removal announced after a fold kept the block
An audit found that a fold which keeps the old block used to leave a
removal unannounced (the tombstone was gated on a bookkeeping flag the
fold cleared). Deriving delivery from the visible history fixes it; this
pins that a removed instruction is still announced while the model can
read the old block.
2026-10-21 17:51:05 +08:00
Will 3549a60457 docs(release-notes): record the cross-process lock
The reviewer asked for a bilingual fragment covering both effects: no
lost writes, and the new retryable lock error.
2026-10-21 17:46:57 +08:00
Will 4ed29fec06 docs(release-notes): record the snapshot-revision fix
The reviewer asked for a bilingual fragment; restoring a revision could
return another revision's body or report it missing.
2026-10-21 17:46:57 +08:00
Will b110b945f0 docs(release-notes): record derived delivery; rename the red test
Reviewer: state the constraint that holds now, and drop the red marker from
the file name; add a bilingual fragment for the user-visible changes.
2026-10-21 17:46:57 +08:00
Will 85ff4dc202 test(control): pin rewind and branch-switch re-delivery
An audit found the same hole through two more entry points: /rewind
truncates the turn that carried the block, and /switch replaces the
transcript with another branch's prefix. Deriving delivery from the
visible history fixes both, so the audit's two providers-boundary tests
now guard that they stay fixed.
2026-10-21 17:46:41 +08:00
Will 1772a7bbba fix(control): derive projection delivery from the visible history
A standing projection was settled as delivered when a turn was handed to the
runner, so anything that refused the turn before the provider (an extension
block, a cancel, a failed send) or a /new left the debt settled although no
request had carried the block.

Delivery is now a fact about the model-visible history: owed compares the
newest block of its tag in the visible user records, so a LocalOnly record or
compaction summary never answers for it. A refusal, cancel, fold, rewind or
session rotation changes the answer by itself; nothing is recorded at the
hand-off.

Removed: projectionDebt.{pending,delivered,settle,forget,sent,inherit},
promptFailureDebt.{composed,settle}, settleTurnProjections and runSettled, the
CompactionDone→forget handler, InheritScratchFrom and the per-owner forget
hooks. One-time failure/refusal notes are filtered by visibility instead of
settled, so a turn refused before the provider still owes them.
2026-10-21 17:46:41 +08:00
Will 3f0b2e4322 fix(memory): serialise store mutations across processes
internal/state/memory guarded its read-modify-write sequences with a
process-local mutex only, so two processes sharing a memory directory could
duplicate a subject key, lose a rename, roll a withdrawal over a newer
revision, or drop an index line.

Take one filelock per directory the store mutates (Dir and GlobalDir, sorted
by canonical absolute path) under the user lock root, bounded by a short wait
that returns a retryable StoreLockedError. The in-process mutex is still taken
first and the file lock second, one order everywhere, so the two cannot
deadlock.
2026-10-21 17:46:40 +08:00
Will 2596b4001d fix(memory): parse a fact without touching the filesystem
CodeQL flagged the new snapshot parser for stat'ing a caller-supplied path
(store_snapshot_identity.go:44, 'This path depends on a user-provided value').
The parser now takes the name only; loadMemory keeps the stat where it always
lived and fills the missing timestamps itself.
2026-10-21 17:46:39 +08:00
Will 21b47ad008 fix(memory): a revision snapshot must hold the revision it names
snapshotMemoryRevisionInDir re-read the fact file and filed those bytes
under memory.Revision without checking that the file still carried that
id and revision. Another process publishing revision N+1 in that window
made slot N hold revision N+1's bytes: Restore(id, N) then returned a
body revision N never had, and revision N became unrecoverable. The same
file name re-created with a different id was filed the same way.

The bytes the function already reads are now parsed by loadMemory's own
parser (parseMemory, factored out rather than duplicated) and the snapshot
is refused with the existing "memory revision conflict: expected %d,
found %d" wording when the id or revision no longer match. The check runs
before any slot is created, so a refusal files nothing. SaveWithOptions
and RestoreArchived already return a snapshot error before publishing, so
no caller changes. Single-process behaviour is unchanged: the
process-local mutex makes the mismatch impossible there.

Tests cover the two cross-process probes (refusal plus nothing filed),
archive-and-recreate with a new id, an ordinary save/restore control over
several revisions, and a mutation that removes only the check.
2026-10-21 17:46:39 +08:00
SivanCola 499bda0c93 fix(history): preserve deterministic replay coverage when syncing studio
Problem: PR #14086 conflicted with studio after the upstream listing-test repair.

Root cause: Both branches changed the same cache test. Upstream #14097 replaced
timing-dependent large fixtures with direct observation of cached replay, while
this branch added an assertion that derived summaries were persisted.

Fix: Merge studio at 58c80e3f9f, retain the upstream three-conversation corruption
fixture and preview checks, and assert the matching number of persisted entries.
Keep upstream commit history and the independent progressive-loading PR scope.

Verification: Complete sessionstore/sessionv4/historycatalog/backgroundwork
suites, focused cache/cancellation race checks repeated three times, real
resume/tree HTTP effects, 27 frontend interaction tests, and repository/Go lint.
2026-10-21 17:45:14 +08:00
YHH 58c80e3f9f Merge pull request #14097 from xxoingr/test/sessionstore-v4-listing-replay-count
test(sessionstore): observe the 1.x listing replay instead of timing it
2026-10-21 02:28:54 -07:00
YHH b346d3f016 Merge pull request #14091 from xxoingr/fix/agent-origin-user-texts
fix(agent): exempt from the origin check only what the person typed
2026-10-21 02:24:44 -07:00
xxoingr 7d5672d3a6 test(sessionstore): observe the 1.x listing replay instead of timing it
TestV4ConversationsWithoutAListingCacheAreReplayedOnce called a second
listing a replay unless it ran five times faster than the first. A busy
macOS runner measured 411ms against 1.35s and failed a PR that does not
touch sessionstore.

Garble each conversation's log in place after the first listing, keeping
the size and modification time the remembered listing is keyed on: a
listing that remembered returns the same rows, one that replays loses
them. Three small conversations now do what sixty large ones did.
2026-10-21 17:06:49 +08:00
Lancy b57079c75a fix(studio): label Ask work mode as not implemented 2026-10-21 16:45:07 +08:00
xxoingr 66915c2584 fix(agent): exempt from the origin check only what the person typed
userTexts, the strings a copied command is exempt for because the user
wrote them, returned each user turn's provider-visible Content. That is
the composed turn, with the host's recalled memory, instructions and
policy around the person's words, so a page's command that reached the
turn only through a host block was taken for one the user typed and was
neither counted nor put to the person.

Read each turn through sessionstore.UserMessageText, the definition the
history readers share. This only narrows the exemption.
2026-10-21 16:38:19 +08:00
YHH b7077ff4e2 Merge pull request #14082 from SivanCola/fix/workbench-file-drag
fix(studio): restore workspace file drag into chat / 恢复工作区文件拖入对话
2026-10-21 01:36:41 -07:00
YHH ce38855e90 Merge pull request #14090 from xxoingr/fix/agent-fold-digest-user-words
fix(agent): a degraded fold lists what the user said, not the host's blocks
2026-10-21 01:24:31 -07:00
YHH 0a715754bb Merge pull request #14084 from SivanCola/codex/startup-test-isolation
fix(startup): wait for MCP readiness and isolate tests / 修复启动等待与测试隔离
2026-10-21 01:20:50 -07:00
SivanCola 3b00e4bf63 fix(studio): preserve typed drop routing and register drag completion
Problem: The workbench drag change failed the action census and sidebar
text-drop regression in the frontend CI suite.

Root cause: The new dragend listener had no action identity, and the drop
router read the workspace-path payload without checking its advertised MIME
type. Transfers returning unadvertised data could be routed as file paths.

Fix: Register file-drop.finish through listenAction, require the workspace
MIME type before reading its path, and cover plain text and URI-list drops.

Verification: Reproduced the action census and both MIME regression failures
before the fix. All 752 frontend test files and 6027 tests pass, including
77 focused action/drop/intake tests. Typecheck, census, census:verify,
production build, bundle fixture exclusion, gofmt, go vet, make lint,
builtin/boot tests, and make check pass.

Refs #12771
2026-10-21 16:11:54 +08:00
xxoingr 9492263884 fix(agent): a degraded fold lists what the user said, not the host's blocks
mechanicalFoldDigest, the stand-in when the summarizer cannot digest a
fold, listed each user turn's provider-visible Content. The oldest turns
a fold takes carry the host's date and standing-instruction blocks, so
the 240-character line went on <current-date> and
<project-instructions> and the person's words were cut off, and a turn
of host blocks alone was listed as something the user said.

Read each turn through sessionstore.UserMessageText, the definition the
history index and its snippets already share, and skip a turn with
nothing left.
2026-10-21 16:04:49 +08:00
SivanCola 98c05123bb docs(studio): note restored workbench file dragging
Add the missing bilingual release-note fragment for PR #14082 so users
can discover that workbench files and folders can be dragged into chat.
Reference the related file-to-chat issue in both languages.

Verification: release-note rendering and Markdown reference tests passed
(12 tests), alongside the required pre-commit checks.

Refs #12771
2026-10-21 15:51:36 +08:00
SivanCola a4a01a20b4 docs(release): describe progressive legacy discovery
Problem: the split listing and scheduling change needs its own user-facing release fragment.

Fix: add bilingual notes for progressive summaries, incomplete coverage and foreground priority.

Verification: release-note rendering and Markdown reference tests, make check and git diff --check pass; runtime code is unchanged.
2026-10-21 15:42:46 +08:00
SivanCola 133a8952ba docs(release): describe ordinary MCP startup readiness
Problem: the user-visible startup behavior had no release fragment.

Fix: document in Chinese and English that ordinary resource reads wait for the authorized connection, as requested in review of #14084.

Verification: release-note rendering and Markdown reference tests pass; make check and git diff --check pass. No runtime code changed.
2026-10-21 15:42:19 +08:00
SivanCola 4e17efd046 fix(studio): restore workspace file drag references
Problem: Files and folders in the workbench could not be dragged into chat.
Root cause: Explorer and Git rows emitted no path payload, and composer
intake only recognized external files and ordinary text.
Fix: Emit a typed source-workspace path and route it through existing
reference validation. Preserve nested repository prefixes, clear cancelled
drag hints, and keep internal drags outside external-file deduplication.
Verification: Typecheck and 689 focused frontend tests passed, including
native-drop compatibility. Browser drag checks, make check, go vet, make
lint, builtin/boot tests, and both drop-handler Go suites passed.

Refs #12771
2026-10-21 15:31:19 +08:00
SivanCola 572ba05442 fix(history): discover legacy sessions progressively and yield background reads
Problem: Sidebar discovery waited for full foreign transcript replay while
history indexing competed with interactive session restoration.

Root cause: Complete-list reads owned metadata replay, identity replacement
scanned growing message slices, and derived-data producers lacked shared admission.

Fix: Return explicit progressive coverage, share single-flight summaries and
validated caches, fold replay identities linearly, and retain cancellable scan
generations. Admit background work through one foreground-aware scheduler.
Apply foreground admission at the history route group so independent readers
inherit it without coupling their implementation to background work.

Verification: Storage/scheduler/catalog suites, real Boot/resume and tree/HTTP
effects, sidebar interactions, production frontend build, race checks and lint.
Keep benchmark cleanup registered even when its bounded deadline fails.

Split from PR #14077; no startup fixes or transcript pagination are included.
2026-10-21 15:31:17 +08:00
SivanCola 4d2117f83d fix(startup): synchronize resource reads and isolate test IO
Problem: Boot resource reads intermittently refused an initializing server,
CLI validation stalled, and ACP rebuild-ordering tests exceeded their deadlines.

Root cause: Ordinary resource tools bypassed the existing connection owner,
output fixtures read finite pipes only after command return, and ACP tests
discovered the caller's user-scoped configuration and skills. Each mechanism
also reproduced on the unmodified baseline.

Fix: Route every registered resource tool through the authorized capability
runtime; drain both captured streams concurrently with failure-safe cleanup;
reuse process-level user-state isolation for ACP fixtures. Consolidate sibling
capture helpers, make resource-provider fixtures repeat-safe, and document
resource startup behavior.

Verification: Held-handshake cancellation/retry with unchanged schema bytes,
large-output helper subprocess, injected caller-skill isolation, complete
Boot/CLI/ACP inventories, repeated ACP ordering tests, capability tests, race
checks and repository/Go lint. Reconcile bounded Boot partitions instead of
raising the aggregate timeout or dropping tests.
2026-10-21 15:30:32 +08:00
YHH 35b9f132ae Merge pull request #14076 from chuiwenwei/fix/user-said-text-includes-appended-blocks
fix(history): strip the host-appended blocks from what the user said
2026-10-21 00:23:42 -07:00
Will 9ab636c5f6 docs(release-notes): record the appended-block fix and the one-time re-index
The reviewer asked for a bilingual fragment naming the TokenizerVersion 4
rebuild, since the 3 bump already made first open slower (#14048).
2026-10-21 14:58:56 +08:00
Will 329ca14a03 fix(history): read appended host blocks out of the user's words
sessionstore.UserSaidText stripped only the host blocks prepended to a
persisted user turn, while UserMessageText also stripped the blocks the host
appends after the person's words (<execution-policy>, <memory-recall>). The
history index and its snippets therefore indexed recalled facts and the frozen
policy as if the user had said them, so a query for a fact matched a row whose
snippet could not contain it: the same divergence #14022 fixed for the leading
blocks, one definition still giving two answers.

Move UserPreviewText's pipeline down into userAuthoredText and let UserSaidText
add the plan-mode marker strip; UserMessageText delegates to UserSaidText for
both RawContent and Content. The appended cut keeps the existing byte-exact
suffix rule, so a complete block the person quotes at the end of their own turn
is still indistinguishable from a host-appended one and is stripped; the test
pins that limit instead of hiding it.

Bump historycatalog.TokenizerVersion to 4 because the indexed vocabulary
changes again. ensureTokenizerVersion wipes history_fts/documents/sources/roots
when the stored stamp differs and the manager re-registers its roots, so the
next open re-indexes them; Rebuild stamps what it built with. Covered by
TestTokenizerVersionMismatchClearsMixedProjection and
TestRebuildSurvivesTheVersionCheck.

Tests: TestFTSIndexesWhatTheUserSaidNotTheAppendedBlocks and the extended
TestIndexAndSnippetAgreeOnTheUserText / TestSnippetBuildersReadTheIndexedUserText
pins were red before the change. TestAutoRecallNormalTurnIsUnchanged and
TestUserSaidTextQuotedExecutionPolicy are controls, green before and after.
2026-10-21 14:58:09 +08:00
YHH a2ad659757 Merge pull request #14071 from lancy69/fix/14012-feedback-account
fix(serve): expose account state to the feedback board
2026-10-20 23:23:38 -07:00
Lancy 32a42f9671 fix(serve): expose account state to the feedback board 2026-10-21 14:00:02 +08:00
YHH 4882d2a204 Merge pull request #14069 from pei711/fix/mcp-import-sqlite-14053
fix(mcp): import cc-switch servers without sqlite3
2026-10-20 22:23:25 -07:00
YHH c913955405 Merge pull request #14068 from pei711/fix/mcp-add-cli-errors-14052
fix(mcp): keep add argument errors readable
2026-10-20 22:00:29 -07:00
YHH 447bca2edf Merge pull request #14060 from esengine/fix/tui-quit-completion
fix(tui): offer /quit in the slash completion menu
2026-10-20 21:58:52 -07:00
YHH 97c52f02aa Merge pull request #14062 from lancy69/fix/14042-settings-search-layout
fix(studio): prevent settings search result overlap
2026-10-20 21:58:11 -07:00
YHH 0a6d853398 Merge pull request #14046 from imcreazy/fix/guardian-cursor-follows-rollback
fix(guardian): roll the transcript cursor back with the review
2026-10-20 21:57:30 -07:00
pei711 8b60e89bee fix(desktop): record SQLite transitive dependencies 2026-10-21 12:57:02 +08:00
YHH 1aa93e7f21 Merge pull request #14013 from SivanCola/fix/tool-argument-envelopes
fix(tools): normalize redundant argument envelopes / 兼容多余工具参数包装
2026-10-20 21:56:50 -07:00
pei711 f46f6fa64c fix(mcp): read cc-switch sqlite without external cli 2026-10-21 12:48:46 +08:00
pei711 bd13e43b2e fix(mcp): keep add errors readable and show help 2026-10-21 12:41:35 +08:00
SivanCola 9f08f957f3 feat(tools): propose enabling file delivery cards by default
Problem: File delivery requires explicit opt-in even when cards are useful.
Root cause: The user-only configuration key defaults to false.
Fix: Default it on while preserving explicit off choices, read boundaries
and tool exclusions. Keep this policy separate from the settings UI PR.
Verification: Config round trips, complete config/settings/builtin/boot
suites, exact request goldens, schema stability, vet and lint.

The default request fixture gains 143 schema tokens. A newly opened session
may have one cold prefix; this does not mutate the live session's schemas.
2026-10-21 12:27:21 +08:00
SivanCola eba966d685 refactor(studio): separate file delivery UI from the default policy
Problem: The settings PR also changed the default tool surface.
Root cause: UI discoverability and default-on policy were bundled together.
Fix: Restore the existing default, registration fixtures and configuration
rendering, retaining the searchable user-only switch below Tool approval.
Keep UI wording and route tests independent of a future default decision.
Verification: Settings and transport tests, browser captures, Go suites,
frontend build, vet, lint and repository policy checks.
2026-10-21 12:27:21 +08:00
SivanCola c2a485ebc5 feat(studio): add grouped preset module multiselect
Problem: Preset modules could only be added one at a time, with two different actions both labeled Add module.

Root cause: A native single-choice picker shared its label with manual blank-ID insertion, and catalog choices did not carry source provenance.

Fix: Add a shared searchable grouped checkbox picker, authoritative catalog provenance, batch selection and parameter-safe draft updates. Keep manual IDs under an explicit advanced entry and document selection semantics.

Verification: 219 frontend tests, typecheck, production build, census, owning Go packages including boot and builtin, focused Desktop preset/composition tests, vet, lint, make check and documentation checks passed. Browser checks covered themes, responsive layouts and save/reopen with MockHub.
2026-10-21 12:15:04 +08:00
SivanCola f095d8e9f8 feat(studio): expose file delivery settings and enable cards by default
Problem: AI file delivery cards required a manual, default-off config edit.

Root cause: present_files had no settings surface or read/write endpoint, and
the config delta renderer treated false as the default.

Fix: Add a searchable user-only switch immediately below Tool approval.
Default the tool on, preserve explicit off choices, and keep registration
changes at session opening. Update wire contracts, documentation and the
provider-visible tool fixtures for the additional 143 schema tokens.

Verification: Config round trips, route grants, UI persistence/search/order,
real boot tool-surface effects, frontend tests/build, Go tests/vet/lint and
browser checks in light/dark wide and phone layouts.
2026-10-21 12:04:27 +08:00
SivanCola 93409eec04 fix(tools): normalize envelopes and use patched Go
Problem: A redundant arguments envelope makes usable tool calls fail required
field validation and can cause repeated model retries. The current CI security
scan also reports GO-2026-6809 in the pinned Go 1.26.9 standard library.

Root cause: Scheduling, policy checks, execution, and history had no shared
representation of effective arguments inside a schema-invalid wrapper. The
root and Desktop modules still selected the affected Go patch release.

Fix: Unwrap one unambiguous envelope only after full target-schema validation.
Use effective arguments for host execution, evidence, decision barriers, and
surface actions while retaining the original provider call. Select Go 1.26.10
for both modules so CI and packaged Desktop use the patched standard library.
Style the compatibility notice and original-input disclosure like adjacent
tool metadata, with a visible expand marker and keyboard focus outline.

Verification: The envelope regression suites, provider history, permission,
proxy, and interactive surface tests passed. With Go 1.26.10, govulncheck ./...
reported zero reachable vulnerabilities and go vet ./... passed. Frontend type
and interaction checks passed on the rebased PR. The final card style passed
frontend type checking, 16 relevant UI tests, make check, and local browser
inspection in light and dark themes with its disclosure closed and open.
2026-10-21 11:56:58 +08:00
Lancy 4cd15feafe fix(studio): prevent settings search result overlap 2026-10-21 11:48:11 +08:00
imcreazy e8d1c2efaf fix(guardian): roll the transcript cursor back with the review 2026-10-21 11:41:08 +08:00
esengine 133008c612 fix(tui): offer /quit in the slash completion menu 2026-10-20 20:37:36 -07:00
YHH 596c3ada3c Merge pull request #14057 from esengine/docs/release-studio-2.47.1
docs(release): studio 2.47.1 notes
2026-10-20 20:34:33 -07:00
imcreazy 5c1fa16f00 fix(guardian): roll the transcript cursor back with the review 2026-10-21 11:32:32 +08:00
esengine 7e225e6493 docs(release): studio 2.47.1 notes 2026-10-20 20:26:44 -07:00
YHH 7234218c0f Merge pull request #14050 from esengine/fix/go-1.26.10-x509
build: use Go 1.26.10 for the crypto/x509 fix (GO-2026-6809)
2026-10-20 20:26:00 -07:00
YHH 9a9f6cc58a Merge pull request #14024 from chuiwenwei/fix/history-scan-confirms-absence
fix(history): a scan must confirm absence before it marks a source missing
2026-10-20 20:06:02 -07:00
YHH 6ae474fc5b Merge pull request #14023 from chuiwenwei/fix/history-unreadable-transcript-not-no-results
fix(history): an unreadable transcript is a corrupt source, not no results
2026-10-20 20:05:25 -07:00
YHH 1565de632a Merge pull request #14043 from lancy69/fix/14041-project-button-highlights
fix(studio): unify project header button highlights
2026-10-20 20:04:48 -07:00
esengine b0168f37dc build: use Go 1.26.10 for the crypto/x509 fix (GO-2026-6809) 2026-10-20 20:01:33 -07:00
imcreazy 1fe9055ca3 fix(studio): name a property the sheets define 2026-10-21 10:52:20 +08:00
imcreazy d014d9dacb fix(guardian): roll the transcript cursor back with the review 2026-10-21 10:52:13 +08:00
imcreazy 640ec25699 fix(serve): carry an unprefixed request to a remote pane 2026-10-21 10:52:07 +08:00
imcreazy d79787b973 fix(netdns): read a TCP reply past its length prefix 2026-10-21 10:52:00 +08:00
Lancy 396b2bba00 fix(studio): unify project header button highlights 2026-10-21 10:43:06 +08:00
Will 32f49f013b fix(history): a scan must confirm absence before it marks a source missing
reconcileRoot treated seen_generation<>generation as proof that a transcript is
gone. A row indexed by another process's persist event carries generation 0, so a
conversation indexed after this scan listed the directory was swept health=missing:
it vanished from history search and the incomplete-index warning stayed silent,
because the root's counts still looked complete. Measure absence with stat and
adopt live rows into this generation; only a transcript confirmed gone is swept.
2026-10-21 09:07:51 +08:00
YHH 27b5b6bf6a Merge pull request #14039 from esengine/docs/release-studio-2.47.0
docs(release): studio 2.47.0 notes
2026-10-20 17:53:12 -07:00
esengine df35515af1 docs(release): studio 2.47.0 notes 2026-10-20 17:42:12 -07:00
YHH 334acb2a1f Merge pull request #14038 from esengine/fix/term-one-text-node
fix(studio): draw a finished call's output as one text node, not one per line
2026-10-20 17:35:00 -07:00
esengine 4d97185ed0 fix(studio): draw a finished call's output as one text node, not one per line 2026-10-20 17:23:14 -07:00
YHH e78bbacf94 Merge pull request #14036 from imcreazy/fix/refine-tip-inside-the-card
fix(studio): keep the refine tip inside the ask card
2026-10-20 16:36:02 -07:00
imcreazy ede862f78c docs(studio): state the tip's anchor in one sentence 2026-10-21 07:22:30 +08:00
imcreazy 2e49f0f4f9 fix(studio): keep the refine tip inside the ask card 2026-10-21 07:13:24 +08:00
YHH 1711adf425 Merge pull request #14035 from esengine/dependabot/npm_and_yarn/workers/accounts/vite-7.3.7
build(deps): bump vite from 7.3.6 to 7.3.7 in /workers/accounts
2026-10-20 14:59:34 -07:00
dependabot[bot] 51e04cf6a4 build(deps): bump vite from 7.3.6 to 7.3.7 in /workers/accounts
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.6 to 7.3.7.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.7/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.7/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-20 21:50:36 +00:00
YHH 366f3e69f0 Merge pull request #14031 from SivanCola/feature/file-delivery-preview
Add file deliveries and document previews / 新增文件交付卡片与文档预览
2026-10-20 14:49:11 -07:00
SivanCola 684521935a Clarify the HTML preview default in release notes
Problem: the release fragment mentioned static HTML support without explicitly
calling out the change from automatic live rendering.

Fix: state in Chinese and English that HTML opens with scripts disabled and
desktop users select Interactive run to execute the page, as requested in review.

Verification: release-note script tests, make check and whitespace validation.
No runtime behavior, dependencies or test expectations changed.
2026-10-21 05:04:35 +08:00
SivanCola 6a0f3624e1 Align preview and tooltip contracts across frontend checks
Problem: frontend-next CI failed 25 assertions across action discovery,
orphan detection, HTML preview races and copy feedback consumers.

Root cause: TooltipButton was not declared as an interaction primitive; new
preview controls had no action identities, and the superseded image component
remained orphaned. Neighbouring tests still expected native title tooltips and
automatic HTML execution rather than the explicit Interactive run action.

Fix: certify and name the actual UI inputs, restrict tooltip blur to local
dismissal, remove the unused image renderer, and align consumer tests with the
new contracts while retaining copy failure/retry and browser race coverage.
The unnamed-input ceiling falls from 218 to 217 rather than being relaxed.

Verification: full frontend suite (749 files, 6010 tests); supplemental action,
tooltip and browser guards; typecheck and production build; go vet, make lint
and make check. No workflow skip, timeout increase or alert suppression.
2026-10-21 04:27:08 +08:00
SivanCola ce16065b2c Unify safe file references for delivery cards and the workbench
Problem: delivery-card Add to conversation bypassed the workbench's reference
validation, allowing names with token boundaries or MCP ambiguity into drafts.

Root cause: Pane independently formatted @ references from display paths.
The kernel grammar preserves literal backslashes and only escapes space/tab,
so generic backslash doubling would change the selected file.

Fix: share one grammar-specific formatter, use structured relative paths for
workspace cards, disable unrepresentable references while keeping preview and
path copying, and remove the unused intake refToken helper. Keyboard navigation
and initial focus skip disabled menu actions.

Verification: 170 related frontend tests, production build/typecheck, real
browser insertion, kernel backslash round-trip and scoped-resolution tests,
go vet, make lint and make check. Four new rejection cases fail before the fix.
2026-10-21 03:59:31 +08:00
YHH 01e181c5ab Merge pull request #14033 from esengine/dependabot/npm_and_yarn/workers/forum/vite-7.3.7
build(deps): bump vite from 7.3.6 to 7.3.7 in /workers/forum
2026-10-20 12:55:44 -07:00
YHH 97deb64504 Merge pull request #14032 from esengine/dependabot/npm_and_yarn/workers/crash-report/hono-4.13.11
build(deps): bump hono from 4.13.0 to 4.13.11 in /workers/crash-report
2026-10-20 12:53:35 -07:00
SivanCola 85bd880d68 Make file delivery an explicit user opt-in
Problem: the presenter was registered for every install, and review requested
default-off model behavior, repository screenshot removal and PDF hardening.

Root cause: present_files used the unconditional builtin path rather than the
existing user-scoped conditional registration and provider-surface mechanism.

Fix: gate registration with tools.present_files, protect user scope across
project merges, preserve the setting on save, and expose the tool only after
opt-in. Restore the unconditional schema budget and remove PR screenshots.
Document PDF.js 6's removal of eval/isEvalSupported, keep scripting disconnected
and explicitly disable XFA rather than passing an unsupported no-op option.

Verification: boot.Build direct/proxy off/on/forbidden/disabled/excluded effect
matrix; user-scope and save/reload tests; document tests and typecheck; real
browser PDF navigation; go vet, make lint, make check and release-note tests.
2026-10-21 03:48:14 +08:00
dependabot[bot] 60caef5ffc build(deps): bump vite from 7.3.6 to 7.3.7 in /workers/forum
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.6 to 7.3.7.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.7/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.7/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-20 19:45:26 +00:00
dependabot[bot] 99937db751 build(deps): bump hono from 4.13.0 to 4.13.11 in /workers/crash-report
Bumps [hono](https://github.com/honojs/hono) from 4.13.0 to 4.13.11.
- [Release notes](https://github.com/honojs/hono/releases)
- [Commits](https://github.com/honojs/hono/compare/v4.13.0...v4.13.11)

---
updated-dependencies:
- dependency-name: hono
  dependency-version: 4.13.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-20 19:45:18 +00:00
YHH 0476f59811 Merge pull request #14030 from esengine/ci/windows-full-timeout
ci(windows): give the full-suite package timeout headroom over assembly/boot
2026-10-20 12:43:53 -07:00
SivanCola 06dcafbb4e Document file delivery and preview release notes
Record the user-visible preview formats, responsive delivery cards and refresh fixes for PR #14031. Verified with make check and git diff --check; no runtime behavior changed.
2026-10-21 03:33:35 +08:00
SivanCola e1159a4685 Add structured file deliveries and workbench document previews
Problem: AI-created documents could not be handed off as validated, durable
file cards, and binary files could not be read through the text editor route.

Root cause: delivery identity lived only in ordinary tool output, while file
reading, view selection and auxiliary actions lacked a shared preview path.

Fix: add a bounded root-confined reader, Controller and raw-byte preview APIs,
and host-confirmed present_files results projected through live and stored
history. Add lazy PDF, worker CSV and sandboxed HTML viewers, responsive file
cards, type icons, current-file state and accessible shared tooltips. Preserve
dirty drafts while surfacing deletion/revocation and avoid metadata reads per
keystroke. Keep the current workbench request-consumption contract intact.

Verification: root and desktop Go tests, go vet, make lint/check, frontend
typecheck, 498 related tests and production build. Browser checks cover PDF
navigation, CSV literals, menus, request consumption and responsive themes.
The builtin schema inventory grows by 143 measured tokens; boot effect tests
verify stable schemas within a turn for direct and capability-proxy calls.
2026-10-21 03:32:25 +08:00
esengine 2fa664b391 ci(windows): give the full-suite package timeout headroom over assembly/boot 2026-10-20 12:25:20 -07:00
YHH d338b51bd0 Merge pull request #14021 from lancy69/fix/14016-session-highlight-padding
fix(studio): add right padding to session highlights
2026-10-20 08:52:37 -07:00
Lancy b884243777 fix(studio): pad session highlight trailing edge 2026-10-20 23:19:31 +08:00
YHH 35561f0bd8 Merge pull request #14022 from chuiwenwei/fix/history-index-strips-host-blocks
fix(history): index what the user said, not the blocks the host prepends
2026-10-20 07:52:21 -07:00
Will af3b715c10 docs(release-notes): record the incomplete-index notice for an unreadable transcript
The reviewer asked for a user-visible fragment on #14023.
2026-10-20 21:43:56 +08:00
Will 7186e8b5d8 fix(history): an unreadable transcript is a corrupt source, not no results
Cause: candidateTexts.of loaded a candidate's transcript only to build a
snippet. When that load failed it set failed[path] and returned, dropping the
candidate with no error, no repair, and no status change. The row's ledger
digest and size/mtime still matched, so Indexed == Total kept certifying the
projection complete and the history tool answered "No saved session history
matched ..." for a query the session did match.

Fix: record a deterministic read failure with the shape the indexer already
uses for the same fault. RecordReadFailure flips the row from ok to corrupt
with last_error (existing columns), publishes the revision so Status reports
Indexed < Total, and re-enqueues the source. The periodic scan (#13923: a
non-ok source is always revisited) then re-reads it, so a restored mode heals
without a save.

Transient failures are not distinguishable from permanent ones at this layer,
so every load failure is recorded; retries stay bounded because a row that is
already corrupt is not re-queued per search, and the scan retries it once per
interval (five minutes by default). No wording, persisted field, schema, or
tokenizer version changed.

Tests: the audit's red test now passes (the tool prefixes the existing
"history index is still building" notice and status reports Failed 1,
Indexed 0/1); a readable control that genuinely matches nothing still answers
no matches with no alarm; and after chmod is restored the next ReconcileRoot
scan serves the content again. Mutation check: reverting only the fix leaves
the first and third red with the quoted false claim.
2026-10-20 21:43:28 +08:00
Will f8821f43f9 docs(release-notes): record the one-time history re-index
Reviewer asked for a user-visible fragment: the change alters what history
search returns and rebuilds the index once (TokenizerVersion 3).
2026-10-20 21:17:49 +08:00
Will aeb01cc6bb fix(history): index the user's own words, not host-injected blocks
The FTS projection indexed raw user message Content while every display path
stripped the host's prepended blocks (<response-language>, <current-date>,
<project-instructions>, <available-skills>, <workspace>): a query for a
block-only phrase matched a row whose snippet could not contain it.

Move the strip into sessionstore.UserSaidText, the one definition of what a
user turn says, and call it from both sides: the indexer
(historycatalog/documents.go) and the snippet builders (history/search.go,
history/indexed.go). The history package's private stripComposePrefixes is
gone, so the two cannot drift again.

Scope is today's snippet behaviour: the leading blocks stripComposePrefixes
took, plus the plan-mode marker. Trailing <execution-policy>/<memory-recall>
stripping is deliberately not added; whether "the user said" also excludes
host-appended blocks is a product decision left for the PR body.

Bump historycatalog.TokenizerVersion to 3 because the indexed vocabulary
changes. ensureTokenizerVersion wipes history_fts/documents/sources/roots when
the stored stamp differs, and the manager re-registers its roots, which
re-indexes them; Rebuild stamps the version it built with. Covered by
TestTokenizerVersionMismatchClearsMixedProjection and
TestRebuildSurvivesTheVersionCheck.

Tests: TestFTSIndexesWhatTheUserSaidNotTheHostBlocks (red before the change),
TestIndexAndSnippetAgreeOnTheUserText, TestIndexKeepsPlainUserTextUnchanged,
TestSnippetBuildersReadTheIndexedUserText.
2026-10-20 21:10:57 +08:00
YHH 0203158a83 Merge pull request #14015 from SivanCola/feat/preset-on-demand
feat(composition): load preset resources on demand / 自定义预设资源按需加载
2026-10-20 05:28:35 -07:00
SivanCola 073079d675 feat(composition): load preset resources on demand
Problem: selecting a custom preset captures unused skill templates and
extension packages and waits for optional MCP startup.

Root cause: resource snapshot creation also served as the runtime permission
model, making selection cost scale with installed resource payload.

Fix: add live capability-grant bindings while preserving legacy snapshots.
Resolve current skill/MCP/extension/hook/command authority at use, defer
optional startup, retain stable provider contracts, and fence new event
and archive formats from older readers. Use lightweight status summaries
and per-instance extension call leases for safe concurrent replacement.

Verification: root and Desktop suites, full boot/builtin suites, affected
storage/controller/frontend tests, lifecycle race checks, frontend interaction
tests and typecheck, lint/vet/check, actionlint, and Windows cross-compilation.
Twenty-sample real switch measurements include build, persist and publication;
64 MiB unused templates remain outside the binding and stay below the local
500 ms P95 target. Native Windows execution is delegated to hosted CI.
2026-10-20 18:49:01 +08:00
YHH 049a8c42e1 Merge pull request #14011 from xxoingr/fix/restore-resume-picker-delete
fix(tui): restore the resume picker's two-press delete that a merge dropped
2026-10-20 03:04:10 -07:00
xxoingr d451045f94 fix(tui): restore the resume picker's two-press delete that a merge dropped
#13954 let /resume delete a saved session with a confirming second
Ctrl+D. The merge of #13755 (d87d0d959) reverted it: its branch's
"Merge studio into a5/schedule-api" (ba48dfe0b) resolved files only
studio had changed to the branch's older copies, taking out the picker
code, its tests, the i18n catalogue and the release note.

Restore #13954's eleven files to their content on the studio side of
that merge; none has changed since.
2026-10-20 17:47:13 +08:00
YHH 8c0d397566 Merge pull request #14010 from xxoingr/fix/restore-bang-login-path
fix(shell): restore the login-shell PATH for ! commands that a merge dropped
2026-10-20 02:42:07 -07:00
YHH e418a0562f Merge pull request #13435 from imcreazy/feat/netdns-configured-resolvers
feat(netdns): resolve through the servers the person named
2026-10-20 02:31:43 -07:00
xxoingr a3f825daf9 fix(shell): restore the login-shell PATH for ! commands that a merge dropped
#13956 gave user ! commands the login shell's PATH through
shellrun.CommandEnv, shared with the bash tool. The merge of #13792
(ac1a737ee) reverted it: its branch's "Merge studio into
feat/13111-origin-ask" (ed1dfebb9) resolved files only studio had changed
to the branch's older copies, deleting shellrun/loginenv.go, the Env
line in RunShellWith, the regression test and the release note, and
moving the probe back into bash.go. A ! command again ran with only the
host's PATH, so tools from nvm or Homebrew were not found when Studio
was started from a launcher.

Restore the nine files to their content on the studio side of that
merge; none has changed since.
2026-10-20 17:24:50 +08:00
YHH fa9bd48500 Merge pull request #14009 from esengine/fix/import-sidecars-not-sessions
fix(import): do not report 1.x sidecar logs as unreadable sessions
2026-10-20 02:20:33 -07:00
esengine dae12c5711 fix(import): do not report 1.x sidecar logs as unreadable sessions 2026-10-20 02:05:02 -07:00
imcreazy deacb7c10c feat(netdns): declare the resolver keys, on top of current studio
Studio now requires every config key to be declared once with who may change it
and when it takes effect; the two keys this branch adds were not in that table,
so loading a config panicked with `setting.undeclared: network.dns`.

Both are `Guarded` / `OnOpen`, beside `network.proxy_url`: which resolver a
request goes through is a network boundary, so the model must not change it by
itself, and a new value applies on the next start rather than mid-session.

Also rebased onto studio, which the same push asked for.
2026-10-20 17:03:32 +08:00
YHH f44166a33f Merge pull request #14006 from SivanCola/fix/persona-composition-resources
fix(composition): load linked resources and explain MCP failures / 修复人设资源加载与 MCP 报错
2026-10-20 01:59:34 -07:00
YHH 0fcc6be63f Merge pull request #13948 from SivanCola/claude/realrepo-long-tasks
bench(realrepo): long tasks over a pinned snapshot of this repository
2026-10-20 01:50:20 -07:00
YHH a970ce173b Merge pull request #13987 from esengine/fb/exec-fs
remote: serve files over exec when the server refuses the sftp subsystem
2026-10-20 01:44:35 -07:00
SivanCola 6f967a5d4e fix(composition): resolve Windows junction sources by physical identity
Problem:
The native Windows link gate fails when capturing a junction source with
an internal junction, despite the ordinary symlink cases passing.

Root cause:
filepath.EvalSymlinks can retain a Windows junction alias and fail when
walking its child. It is not the repository's physical-identity resolver.

Fix:
Use fileutil.ResolveExistingPath for source roots, contained entries, and
single-file hook parents. Preserve containment checks, rooted content reads,
strict retained-directory verification, and filesystem error identities.

Verification:
Local source-capture and composition regressions and Windows amd64 test
compilation pass. Native verification remains owned by the Windows
composition-links gate, including the previously failing junction case.
2026-10-20 16:36:13 +08:00
YHH 11660393cc Merge pull request #14007 from esengine/a5/test-cold-import
test(studio): give a cold lazy import more room and cap the test workers
2026-10-20 01:29:16 -07:00
zumac 7f8b6ff994 fix(realrepo): check the stale-skills manifest without shasum
The Windows runner's Git Bash has no shasum, and the grader's
`shasum -c ... || exit 1` turned "command not found" into the same exit 1 as
a changed file, so the reference solution failed there with nothing printed.
The SHA-256 manifest is now checked with hashlib inside the grader's Python,
over raw bytes, and a mismatch names the files that differ.

gradeSeed now carries the grader's output in its error: the CI log said only
"exit status 1", which cannot tell a missing tool from a failed check.

Verified locally: on a PATH without shasum the reference solution grades
clean; a CRLF Makefile and an edited Go file each fail and are named; the
pristine seed still fails. snapshot.sh --check, the corpus tests and
make check pass.
2026-10-20 01:27:12 -07:00
esengine 58ac061010 execfs: create the temp file exclusively under an unguessable name; skip the shell-over-ssh test on Windows 2026-10-20 01:25:51 -07:00
SivanCola c6e1c0b8f8 test(composition): require native Windows link coverage
Problem:
The Windows smoke suite did not include agentpreset's snapshot tests and
symlink fixtures could be skipped without proving the reported platform.

Root cause:
The platform package list omitted agentpreset, and no fixture exercised
directory junctions. One portable assertion assumed Unix executable bits.

Fix:
Add native junction capture/restore, hook bundle, escape, cycle, and retained
snapshot rejection cases. Run nine focused source-link tests on Windows and
fail the gate for skipped or missing cases. Compare retained permissions to
the actual source filesystem mode.

Verification:
agentpreset and testenv suites, Windows amd64 test compilation, go vet,
make lint, make check, and actionlint pass locally. Native Windows execution
is explicitly pending the hosted composition-links step.
2026-10-20 16:20:09 +08:00
YHH 5aae5946fd Merge pull request #14000 from lancy69/fix/13982-git-indicator-alignment
fix(studio): align the composer Git indicator with its neighbours
2026-10-20 01:13:47 -07:00
YHH e198bd6741 Merge pull request #14004 from esengine/a5/new-answer-divider
feat(studio): mark where the new answers start when a session with unread answers opens
2026-10-20 01:11:54 -07:00
SivanCola eb2c2851d1 fix(composition): retain linked resources and explain dependency failures
Problem:
Saved personas can fail when selected with inherited skills, extensions, or
hooks. MCP startup failures lack structured server identity and guidance.

Root cause:
Ordinary discovery accepts linked sources but composition snapshot capture
rejects them. MCP startup errors carry the server only in prose instead of
the module metadata consumed by the localized refusal boundary.

Fix:
Flatten contained source links through rooted reads, retain strict snapshot
verification, and preserve module identities and sanitized failure causes
through HTTP and Chinese/English rendering. Keep required MCP failures
blocking and preserve the original session on a failed selection.

Verification:
Full root Go tests, go vet, make lint, make check, frontend typecheck, and
198 relevant frontend tests pass. Browser fixtures cover failure, retry,
management access, and light/dark wide/phone error presentation. Regressions
cover source deletion and restart, prefix stability, revocation, containment,
snapshot tampering, and credential redaction.
2026-10-20 16:03:09 +08:00
esengine ff70a2bc37 test(studio): give a cold lazy import more room and cap the test workers 2026-10-20 01:02:35 -07:00
YHH b745d0f372 Merge pull request #14003 from esengine/a9/ci-register-row-guards
ci(studio): run the session row guards in the browser guards job
2026-10-20 01:00:41 -07:00
zumac 7e92388102 bench(realrepo): long tasks over a pinned snapshot of this repository
The e2e fixtures finish in 5-15 requests, while a real task in this tree took
deepseek-flash 38. A change aimed at cutting round trips cannot show up on
the short suite, so this adds four long tasks whose workdir is a git archive
of commit 42636ddcb (pricing, ablation, agentgraph and five stdlib-only
leaves) plus a trimmed REASONIX.md, a Makefile, a module README and a small
benchctl consumer:

- rt-cache-write-rate: a CacheWrite rate threaded through cost, fingerprint,
  card identity and the catalog, plus an Anthropic row that uses it
- rt-stale-skills: Markdown-only repair of three project skills with no
  recognised verifier, the shape behind #13293's lingering stale_verification
- rt-rate-lookup-signatures: five lookups moved to (RateCard, bool),
  CatalogKey and ModelRef across about 20 call sites, most in _test.go
- rt-plan-ablation-module: a five-step plan-first task signed off per step

snapshot.sh rebuilds every workdir from the pin and --check detects drift.
One pinned test compares CheckedOn with the wall clock and would fail every
workdir from 2026-11-20; base/ anchors it at the snapshot day instead.

Each task ships a solution/; the corpus is registered with the authoring
guards, which check that every seed fails its grader and every solution
passes. A deepseek-flash baseline (2 trials) solved 8/8 at 13-31 requests
once two grader false negatives in rt-stale-skills were fixed; the README
records it, along with a multi_edit prompt arm and a complete_step
description arm that did not move the request count.
2026-10-20 00:57:36 -07:00
YHH e7c5939a5c Merge pull request #14002 from esengine/fix/agent-composition-unavailable-module-2
fix(composition): say which module is unavailable and why
2026-10-20 00:50:54 -07:00
YHH 46ea184425 Merge pull request #14001 from esengine/a5/file-request-once
fix(studio): a file opened from a conversation link is not reopened when Files is pressed
2026-10-20 00:40:52 -07:00
esengine b48c8ec14d docs(release-notes): shorten the Git indicator fragment under the line limit
The English item was 209 columns against the 200-column limit; the lint job named it.
2026-10-20 00:40:00 -07:00
YHH 02da795b1b Merge branch 'studio' into fix/13982-git-indicator-alignment 2026-10-20 00:39:15 -07:00
esengine ac53f8b48c ci(studio): run the session row guards in the browser guards job 2026-10-20 00:31:10 -07:00
esengine 4c0bcf58e9 feat(studio): mark where the new answers start when a session with unread answers opens
Refs #13962
2026-10-20 00:25:20 -07:00
esengine 72892df7c7 ci: run the branch-alignment guard; add the release-note fragment
The guard in perf/branch-alignment.mjs was not listed in the Studio workflow, so nothing ran it; the fix itself is user-visible and carries a fragment.
2026-10-20 00:21:32 -07:00
esengine 4ca0351086 fix(composition): say which module is unavailable and why
The unavailable refusal carried the module and detail only inside a nested
diagnostics list, so the window filled a generic sentence from a code alone.
Flat module/detail params now cross the boundary and both languages use them.
2026-10-20 00:21:21 -07:00
esengine 4342d20045 docs(release): note for the file-request replay fix
Refs #13993
2026-10-20 00:21:00 -07:00
esengine 70aee6bd23 fix(studio): a file opened from a conversation link is not reopened when Files is pressed
Refs FB-S30W-SF40
2026-10-20 00:21:00 -07:00
Lancy be2b4c4757 fix(studio): align the composer Git indicator with its neighbours 2026-10-20 15:18:59 +08:00
YHH 58ac20fddd Merge pull request #13995 from esengine/fix/split-oversized-files
refactor(studio): split two files that are over the 800-line ceiling
2026-10-20 00:18:54 -07:00
YHH 8c77234757 Merge pull request #13992 from esengine/a9/known-hosts-zone
fix(remote): never write an IPv6 zone into known_hosts, and skip unreadable managed lines with a warning
2026-10-20 00:18:50 -07:00
esengine b85f9939fe refactor(studio): split the market actions and the Settings props out of two files over the ceiling 2026-10-20 00:05:18 -07:00
esengine 67d477877b fix(remote): never write an IPv6 zone into known_hosts, and skip unreadable managed lines with a warning 2026-10-19 23:49:59 -07:00
esengine 0c3d51c7fc release note: shorten to the column limit 2026-10-19 23:35:28 -07:00
esengine 75825dbeb4 release note for #13987 2026-10-19 23:33:52 -07:00
esengine a6312d252e remote: build the long test path in one expression 2026-10-19 23:28:30 -07:00
esengine 3c845b3e51 remote: split the shared scenario and shorten Volume's doc to meet the lint budgets 2026-10-19 23:25:51 -07:00
esengine 895231eab7 serve: the missing-page hint names the macOS bundle path 2026-10-19 23:25:51 -07:00
esengine 127e1fcce5 remote picker: fixture and interaction test for a folder that cannot be listed 2026-10-19 23:25:51 -07:00
esengine a5cc533cde remote picker: a folder the machine will not list can still be chosen
Dirs resolves the path on the far machine and lists it; when the listing
fails but a stat says the path is a directory, the answer is the directory with
Unlisted set instead of an error. The picker then names why the rows are
missing and 'use this folder' works on the confirmed path. A path that is not
there, or not a directory, stays an error. Listing a directory this account
cannot read is fs.ErrPermission on the script layer, as it is on SFTP.
2026-10-19 23:25:51 -07:00
esengine 2f4dd93504 remote test: say how the machine's files are reached
Probe took SFTP before any other step, so a server that refuses the subsystem
ended the test right after 'connection OK'. With the script layer behind
Client.Files it now completes, and the report carries sftp or exec.
2026-10-19 23:25:51 -07:00
esengine 4139ddb27f remote: fall back to scripts when the server refuses the sftp subsystem
pkg/sftp reports the refusal as an unnamed error string, so the subsystem is
now requested in sftpfs.New and a server reply of 'failure' becomes
ErrSubsystemRefused. Client.Volume prefers SFTP and, only on that sentinel,
serves the connection from execfs over the exec channel and remembers the
answer until the connection is replaced. Any other failure (dead link, refused
channel) is returned unchanged. The exec runner quotes the script and every
argument whole into one /bin/sh command line.
2026-10-19 23:25:51 -07:00
esengine 5bbad0c76b remote: move the exec file layer out of wslconn into execfs
The WSL file layer was a fixed set of /bin/sh scripts taking every path as a
positional argument. It is now a package of its own that takes a runner, so
another transport can reuse it. Scripts name a missing program by exit status,
refuse a file over the transfer limit by type, and check the byte count before
publishing a write. List, Download and UploadAtomic are added, and the shared
scenario now covers names with quotes, spaces, a leading dash, newlines, shell
metacharacters and a path over 3000 bytes.
2026-10-19 23:25:51 -07:00
1574 changed files with 92797 additions and 5943 deletions

No files matched your search

+2
View File
@@ -26,6 +26,8 @@
*.xml text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
Makefile text eol=lf
.githooks/** text eol=lf
benchmarks/**/*.sh text eol=lf
internal/runtime/agent/testdata/perseveration/** -text
desktop/frontend-next/perf/media/delivery.pdf binary
+95 -8
View File
@@ -179,9 +179,27 @@ jobs:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test ./...
- name: test (Windows composition links)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 5
shell: bash
run: |
set -o pipefail
go test -count=1 -timeout=3m -json -run '^Test(CaptureDirectoryMaterializesContainedLinks|CaptureDirectoryRefusesUnsafeLinks|CaptureHookFileFlattensContainedLink|MaterializeStillRejectsLinkedRetainedFilesAndRoot|WindowsCompositionJunctions|WindowsLiveSkillJunctions|CompositionRetainsLinkedSkillAcrossRestart|CompositionLinkedSkillRejectsLinkedResources|CompositionLinkedExtensionResources|CompositionLinkedHookResources)$' ./internal/state/agentpreset ./internal/ext/skill ./internal/assembly/boot | tee "$RUNNER_TEMP/composition-links.jsonl"
jq -se '
["TestCaptureDirectoryMaterializesContainedLinks", "TestCaptureDirectoryRefusesUnsafeLinks",
"TestCaptureHookFileFlattensContainedLink", "TestMaterializeStillRejectsLinkedRetainedFilesAndRoot",
"TestWindowsCompositionJunctions", "TestWindowsLiveSkillJunctions", "TestCompositionRetainsLinkedSkillAcrossRestart",
"TestCompositionLinkedSkillRejectsLinkedResources", "TestCompositionLinkedExtensionResources",
"TestCompositionLinkedHookResources"] as $required |
[.[] | select(.Action == "pass") | .Test] as $passed |
all(.[]; .Action != "skip") and (($required - $passed) | length == 0)
' "$RUNNER_TEMP/composition-links.jsonl"
# Pull requests run a Windows smoke suite: the packages that actually
# carry *_windows.go code, the startup/checkpoint packages with portable
# filesystem contracts, plus cmd/. The full ./... sweep stays on pushes
# filesystem contracts, plus cmd/. Isolation/worktree/mutation have a
# complete JSON inventory below. The full ./... sweep stays on pushes
# to main-v2; the Unix legs always run the full suite. Keep the job-level
# budget above normal 7-9 minute runner variance; each package test binary
# remains independently bounded by Go's per-package timeout below.
@@ -205,11 +223,76 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# Worktree rollback is absent from the broad Windows PR smoke list.
- name: test (Windows worktree rollback)
- name: test (Windows isolation owners)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 3
run: go test -count=1 -timeout=2m -run '^TestDiscardUnpublished.*OnWindows$|^TestCreateFromDetachedHEADAndOccupiedBranch$' ./internal/platform/worktree
timeout-minutes: 25
shell: bash
run: |
set -o pipefail
go test -count=1 -p 4 -timeout=18m -json ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... | tee "$RUNNER_TEMP/isolation-owners.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $passed |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
(["reasonix/internal/runtime/isolation", "reasonix/internal/platform/worktree", "reasonix/internal/state/mutation"] - $passed | length) == 0 and
(["TestResultOperationUsesCrossProcessOwnership",
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/restore",
"TestActualResultOperationHoldsOwnershipThroughWriteAndCleanup/remove",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/content",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/source_index_mode",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_mode",
"TestApplyPathsWithUntrustedModesPreservesTheIndex/candidate_type",
"TestApplyPathsEmulatedLinksUseSnapshotFilters",
"TestHeldPathsCoverOnlyTheSameFileAcrossCaseSpellings",
"TestLegacyWaitDoesNotConsumeExecutionCapacity"] - $tests | length) == 0
' "$RUNNER_TEMP/isolation-owners.jsonl"
- name: upload Windows isolation test inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: isolation-owners-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/isolation-owners.jsonl
if-no-files-found: ignore
retention-days: 7
- name: test (Windows background resource owners)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 25
shell: bash
run: |
set -o pipefail
go test -count=1 -p 2 -timeout=18m -json ./internal/tools/jobs/... ./internal/runtime/delegation/... | tee "$RUNNER_TEMP/background-resources.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
jq -se '
[.[] | select(.Test == null and .Action == "pass") | .Package] as $packages |
[.[] | select(.Test != null and .Action == "pass") | .Test] as $tests |
(["reasonix/internal/tools/jobs", "reasonix/internal/runtime/delegation"] - $packages | length) == 0 and
(["TestJobFinalizationCoversInvokedAndUninvoked/success",
"TestJobFinalizationCoversInvokedAndUninvoked/error",
"TestJobFinalizationCoversInvokedAndUninvoked/panic",
"TestJobFinalizationCoversInvokedAndUninvoked/closed",
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_session",
"TestJobFinalizationCoversInvokedAndUninvoked/invalid_kind",
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/invoked",
"TestJobFinalizationFailuresAttemptAllCallbacksAndKeepCauses/uninvoked",
"TestJobFinalizationPrecedesCompletionPublication",
"TestJobFinalizationCloseBetweenPublicationAndLaunch",
"TestJobFinalizationWaitsForClosureAfterLogicalCancellation",
"TestBackgroundNoLaunchFinalizesOwnedResources/task/closed_manager",
"TestBackgroundNoLaunchFinalizesOwnedResources/task/invalid_session",
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/closed_manager",
"TestBackgroundNoLaunchFinalizesOwnedResources/fleet/invalid_session"] - $tests | length) == 0
' "$RUNNER_TEMP/background-resources.jsonl"
- name: upload Windows background resource inventory
if: always() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: background-resources-windows-${{ github.run_attempt }}
path: ${{ runner.temp }}/background-resources.jsonl
if-no-files-found: ignore
retention-days: 7
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
@@ -244,7 +327,9 @@ jobs:
WINDOWS_SANDBOX_WAIT_MS: "20000"
shell: bash
run: |
go test -count=1 -p 4 -timeout=18m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
# internal/assembly/boot alone takes 14-15 minutes on a Windows runner;
# the package limit leaves headroom inside the step limit above.
go test -count=1 -p 4 -timeout=28m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
- name: test (full) failure detail
@@ -296,13 +381,15 @@ jobs:
if: github.event_name == 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./internal/runtime/agent/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
run: go test -race ./internal/runtime/agent/... ./internal/runtime/delegation/... ./internal/runtime/isolation/... ./internal/platform/worktree/... ./internal/state/mutation/... ./internal/ext/plugin/... ./internal/tools/jobs/... ./internal/base/proc/... ./internal/safety/sandbox/... ./internal/base/filelock/... ./internal/contract/eventwire/... ./internal/platform/remote/... ./internal/ext/extension/... ./internal/assembly/boot/... ./internal/session/control/... ./internal/contract/tool/...
- name: test -race (full)
if: github.event_name != 'pull_request'
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
run: go test -race ./...
# internal/assembly/boot under -race runs past ten minutes on a shared
# runner; the package limit leaves headroom over the default.
run: go test -race -timeout=25m ./...
# sdk/go is a nested stdlib-only module invisible to root `go test ./...`.
# Its DTOs are generated from internal/ext/extension/protocol, and the
+3 -3
View File
@@ -43,20 +43,20 @@ jobs:
if: matrix.branch != ''
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Initialize CodeQL
uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
- name: Perform CodeQL Analysis
if: matrix.branch == ''
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{ matrix.language }}"
# Results from a checked-out line are filed under that line's ref, not the
# default branch the schedule happened to run from.
- name: Perform CodeQL Analysis (${{ matrix.branch }})
if: matrix.branch != ''
uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0
uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with:
category: "/language:${{ matrix.language }}"
ref: refs/heads/${{ matrix.branch }}
+1 -1
View File
@@ -51,7 +51,7 @@ jobs:
go-version-file: go.mod
cache: true
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
with:
python-version: '3.12'
+16 -1
View File
@@ -87,7 +87,7 @@ jobs:
browser-guards:
name: browser guards
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 25
defaults:
run:
working-directory: desktop/frontend-next
@@ -155,14 +155,22 @@ jobs:
node perf/browser-full.mjs
node perf/mod-ui.mjs
node perf/codeeditor-size.mjs
node perf/composer-reflow.mjs
node perf/composer-toolbar-row.mjs
node perf/action-icons.mjs
node perf/rowhit.mjs
node perf/prefs-wide-centered.mjs
node perf/tab-pip.mjs
node perf/syntax-tokens.mjs
node perf/tab-close-inside.mjs
node perf/context-card-hover.mjs
node perf/sessquick.mjs
node perf/sessionrows.mjs
node perf/session-row-marker.mjs
node perf/session-selected-row.mjs
node perf/wheel.mjs
node perf/branch-tooltip.mjs
node perf/branch-alignment.mjs
node perf/branch-menu.mjs
node perf/image-preview.mjs
node perf/user-images.mjs
@@ -170,6 +178,7 @@ jobs:
node perf/addfolder-rest.mjs
node perf/scroll-jump.mjs
node perf/add-project-selects.mjs
node perf/project-header.mjs
node perf/setup-defer.mjs
node perf/narrow.mjs
node perf/settings-rows.mjs
@@ -179,6 +188,12 @@ jobs:
node perf/web-access.mjs
node perf/peers-settings.mjs
node perf/output-cap.mjs
node perf/agent-run-tab.mjs
node perf/changes-cap.mjs
node perf/session-menu-fit.mjs
node perf/rail-rows-typography.mjs
node perf/settings-nav-fit.mjs
node perf/rail-rows-align.mjs
node perf/progress-tail.mjs
node perf/trust-wait.mjs
node perf/nest-nodes.mjs
+5 -1
View File
@@ -1,6 +1,6 @@
# Reasonix Benchmarks
Five harnesses live under `benchmarks/`; `cmd/e2ebench` also exposes a
Six harnesses live under `benchmarks/`; `cmd/e2ebench` also exposes a
SWE-bench Verified mode:
- `e2e/` — the committed end-to-end task suite, driven by
@@ -19,6 +19,10 @@ SWE-bench Verified mode:
- `fanout-width/` — a shape-instructed fleet whose members share nothing, for
pricing what running them at the same time is worth. See
[Pricing the run graph](#pricing-the-run-graph) below.
- `realrepo/` — long tasks over a pinned snapshot of this repository, for
measuring changes that cut model round trips (13–31 requests per task on
`deepseek-flash`). See
[its README](realrepo/README.md).
## Directory layout
+131
View File
@@ -0,0 +1,131 @@
# realrepo
Long tasks over a pinned snapshot of this repository, for measuring changes
that aim to cut model round trips. The `benchmarks/e2e` tasks finish in about
5–15 requests and ten seconds; a real task in this tree took a flash-class
model 38 requests, most of them after a single read. A suite of short fixtures
cannot show that gap moving, so these tasks are shaped like the real one:
explore several packages, edit several files, verify, and recover from the
verifier.
```sh
go build -o bin/reasonix ./cmd/reasonix
go run ./cmd/e2ebench -suite benchmarks/realrepo -bin "$PWD/bin/reasonix" -model deepseek-flash \
-budget 0 -trials 2 -trajectories traj -json realrepo.json
go run ./cmd/e2ebench -suite benchmarks/realrepo -bin "$PWD/bin/reasonix" -task rt-stale-skills
```
The agent reads its provider config from `REASONIX_HOME` (default
`~/.reasonix`); point it at a disposable one to keep benchmark sessions out of
your own.
The default `-budget` (800k tokens) can stop the suite partway through, hence
`-budget 0`. Under `-trials`, each trial keeps its own
`<task>.trial-N.trajectory.jsonl`.
## The snapshot
Every `workdir/` is the same Go module (`module reasonix`, stdlib only, no
network needed to build or test):
- **Pinned code.** `git archive` of commit `42636ddcb425` for
`internal/contract/{pricing,ablation,agentgraph}` and
`internal/base/{retrieval,rulelist,tokencount,neterr,nilutil}` — about 5k
lines of real kernel code with its real tests and doc comments.
- **Fixture-authored files** in `base/`: `go.mod`, a trimmed `REASONIX.md` (the
project's own conventions, so the agent loads standing instructions the way
it does here), a `Makefile` with `check`, a module `README.md`, the
`cmd/benchctl` command that consumes `pricing` and `ablation` from outside
their package, and one project skill.
- **One pinned test anchored to the snapshot day.**
`TestCatalogPricesHaveBeenCheckedRecently` compares each row's `CheckedOn`
with the wall clock, which would fail every workdir 90 days after the oldest
pinned date (2026-11-20). `base/` replaces that file with one that reads
staleness at 2026-10-19 instead; nothing else in the pin is edited.
- **Per-task overlay** in `overlay/<task-id>/`, applied last. Only
`rt-stale-skills` has one.
`snapshot.sh` rebuilds every workdir from those three sources and
`snapshot.sh --check` fails if a committed workdir has drifted from them. Edit
`base/` or `overlay/`, never a workdir, then rerun the script. Moving the pin is
a new suite version: results from two pins do not compare.
## Tasks
| Task | Kind | What it exercises |
| --- | --- | --- |
| `rt-cache-write-rate` | multi-file Go change, test, README | Adds a `CacheWrite` rate to `RateCard` and threads it through cost, fingerprint, card identity and the catalog, then prices Anthropic's `claude-sonnet-4-5` with it: a new `officialRates` row, documentation and checked-on constants, an endpoint host, and `benchctl rate`/`cost` output. The fingerprint has to stay byte-identical for every existing card, so the change to `PricingFingerprint` cannot be the obvious one. |
| `rt-stale-skills` | documentation only, no recognised verifier | Three project skills name paths, functions, `make` targets and `benchctl` subcommands and flags that do not exist, and one contradicts the append-only rule in `REASONIX.md`. Fixing them takes reading `rates.go`, `ablation.go`, both `benchctl` files and the `Makefile`, then editing only Markdown — the shape behind #13293, where `stale_verification` stays owed after a skill edit because no check covers it. |
| `rt-rate-lookup-signatures` | API migration with a likely compile-error loop | Changes `CurrentRate` to `(RateCard, bool)`, `LookupCatalog` to a `CatalogKey` struct, and `IsOfficialCard`, `MatchesCatalog` and `LiveTariff` to a `ModelRef`: about 20 call sites across 11 files. Most are in `_test.go` files and dereference the old pointer, so `go build ./...` can pass while `go vet` and `go test` fail. |
| `rt-plan-ablation-module` | plan-mode with `complete_step` | Five numbered steps adding a `memory` ablation module, summaries, `ModuleHelp` and `benchctl arm -list`. The prompt opens with the host's own plan-first marker (`control.PlannerRouteMarker`), which routes the turn through the planner, and asks for each step to be signed off before the next. An existing test pins the `all` arm name and has to be updated, which adds a red-test step. |
Every grader stages hidden tests and checks after the run: `gofmt -l`, `go vet
./...` and `go test ./...` for the Go tasks; for `rt-stale-skills`, every
backticked path, identifier, `make` target and `benchctl` command line in each
skill must resolve against the tree, and a SHA-256 manifest proves no Go source, `go.mod`,
`Makefile` or `REASONIX.md` changed. Each task fails its pristine seed and
passes its `solution/`, both checked by `cmd/e2ebench`'s corpus tests.
## Reading the numbers
```text
Metric: requests per task
Anchor: the trajectory digest's requests_by_source, summed over sources
Population: every graded trial of a task that ran to its own stop
Excludes: tasks the token budget skipped, and runs that hit timeout_sec
```
Requests is the headline because round trips are what these tasks exist to
move. Read it beside the solve rate and the report's round outcomes: a change
that drops requests by giving up on verification has not cut a round trip, it
has cut a check. `rt-stale-skills` is read for whether the run finishes at all
and how many rounds go to re-verifying after its last write.
## Baseline
`deepseek-flash`, 2 trials per task, at studio `0b8de9e63`. Requests are the
trajectory's executor requests; all 8 runs ran to their own stop.
| Task | Solved | Requests | `edit_file` calls | Edit rounds |
| --- | --- | --- | --- | --- |
| `rt-cache-write-rate` | 2/2 | 31, 28 | 21, 20 | 8, 8 |
| `rt-plan-ablation-module` | 2/2 | 24, 30 | 10, 13 | 9, 7 |
| `rt-rate-lookup-signatures` | 2/2 | 13, 15 | 10, 17 | 2, 6 |
| `rt-stale-skills` | 2/2* | 20, 26 | 3, 2 | 3, 3 |
\* One run first graded as failed, on a glob (`*_rates_test.go`) and a
placeholder (`benchctl <subcommand>`) that were both correct. The grader now
resolves globs against the tree and skips placeholders, and that run counts
as solved. The same task's first version failed a run for naming
`CatalogEntry.EffectiveFrom` in order to warn against it; that check went too.
Reads and edits already batch: 20 edit calls fit into 8 rounds, and several
`edit_file` calls to one file often share a round. Rounds that edit a file the
previous round also edited are rare (0–2 per run), so steering same-file edits
to `multi_edit` has little to save. On this suite's first version, a
`ToolBatchPolicy` sentence asking for it drew no `multi_edit` call and 167
requests against 158.
`rt-plan-ablation-module` spends five rounds per run on `complete_step` alone.
A description change inviting the sign-off to share the next step's reply was
taken up in 2 of 4 trials (4 of 5 sign-offs shared each time, against 0 of 20
in control), and requests did not move: 30, 25, 21, 28 against 27, 23, 24, 30.
At this spread, four trials per arm cannot show a saving of a few rounds.
## Gaps
- **Below the intended size, in places.** The tasks were designed for 25–50
requests. After widening, the median is 25, but `rt-rate-lookup-signatures`
still runs 13–15: the model greps every call site first and batches the edits,
so even about 20 call sites cost few rounds, and no run hit a compile-error
loop.
- **Lexical grader.** `rt-stale-skills` judges Markdown by resolving what it
names. It has misjudged correct text twice (see the baseline note); a new
failure there should be read before it is counted.
- **No git history.** The workdir is a plain directory: `git diff`, `git log`
and `git status` have nothing to read, so runs that lean on them in this tree
behave differently here.
- **Plan marker and `-force-planner`.** `rt-plan-ablation-module` carries the
plan-first marker in its prompt, so it engages the planner in every arm,
including runs without `-force-planner`. Exclude it when comparing planner
arms.
@@ -0,0 +1,18 @@
---
name: price-change
description: Record a vendor's announced price change in the official price book without losing the rate it replaces.
---
# Recording a price change
1. Find the vendor's rows in `officialRates` in
`internal/contract/pricing/rates.go`. Each row is one model in one currency.
2. **Append** a `rateGeneration` to the row's `Rates`; never edit an existing
one. Set `From` to the first billed day (`YYYY-MM-DD`, UTC) when the change
is announced ahead of time.
3. Leave the earlier generation where it is: `SupersededRates` reads it so a
stored price that matches it is recognised as ours and refreshed.
4. Update the row's `CheckedOn` constant to the day you confirmed the page.
5. Add a test beside `rates_test.go` that pins `CurrentRate` before and after
the `From` day by replacing `today`.
6. Run `make check`. `make pricecheck` needs the network and is run by hand.
+17
View File
@@ -0,0 +1,17 @@
.PHONY: check test vet fmt pricecheck
check: fmt vet test
fmt:
@out="$$(gofmt -l .)"; if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
vet:
go vet ./...
test:
go test ./...
# Reads each vendor's pricing page; this snapshot carries no network client.
pricecheck:
@echo "pricecheck: reaches each vendor's pricing page and needs the network; not available in this snapshot" >&2
@exit 2
+69
View File
@@ -0,0 +1,69 @@
# Reasonix kernel snapshot
A trimmed slice of the Reasonix Go kernel: the contracts a benchmark report is
priced and labelled with, a few leaf utilities, and `benchctl`, a small command
that prints what those contracts answer.
```bash
make check # gofmt, vet, test
go run ./cmd/benchctl rate -model deepseek-flash -currency USD
go run ./cmd/benchctl cost -provider mimo -model mimo-v2.5 -miss 1000000
go run ./cmd/benchctl tariff -currency CNY -at 2026-10-12T02:00:00Z
go run ./cmd/benchctl arm -ablate planner,evidence
```
## Packages
| Package | Owns |
| --- | --- |
| `internal/contract/pricing` | the official price book, cost quotes, the spend ledger |
| `internal/contract/ablation` | which subsystems a benchmark arm switches off, and the arm's name |
| `internal/contract/agentgraph` | the vocabulary of one run's execution graph |
| `internal/base/retrieval` | BM25 ranking over CJK-aware tokens |
| `internal/base/rulelist` | splitting a one-line permission rule list |
| `internal/base/tokencount` | a cheap token estimate |
| `internal/base/neterr` | classifying transport errors |
| `internal/base/nilutil` | typed-nil detection |
| `cmd/benchctl` | prints rates, costs, peak schedules and arm names |
## Pricing
A `RateCard` is a price per million tokens in one currency:
| Field | Charges |
| --- | --- |
| `CacheHit` | prompt tokens served from the provider's cache |
| `Input` | prompt tokens that missed the cache, including tokens written to it |
| `Output` | completion tokens |
`OriginalCostAmount` turns a card and a `UsageTokens` breakdown into a
fixed-point `Amount`; `PricingFingerprint` keys a card in the ledger, so a
stored ledger entry stays attached to the card it was priced with.
`CurrentRate` and `SupersededRates` answer from `officialRates`, which only
ever grows: a price change appends a generation.
## Ablation
`ablation.Parse` reads a comma-separated list of modules to switch off, and
`Set.Arm` names the result (`full` for the control). The switchable modules,
in the order arm names use:
| Module | Off means |
| --- | --- |
| `evidence` | no evidence gate on completion claims |
| `planner` | no two-model planning turn |
| `subagent` | no delegation to workers |
| `retrieval` | no retrieval over the folded transcript |
| `compaction` | no context folding |
| `upstream` | a dependency edge orders its endpoints without delivering the answer |
| `recall-search` | recall can read a given address but cannot search for one |
The fold-index scale is a separate axis (`-fold-index`), not a module.
## Skills
Project skills live under `.reasonix/skills/<name>/SKILL.md`.
| Skill | Use it to |
| --- | --- |
| `price-change` | record a vendor's price change in the price book |
+51
View File
@@ -0,0 +1,51 @@
# Reasonix project memory
This checkout is a trimmed snapshot of the Reasonix kernel: the billing,
ablation and graph contracts and a handful of leaf utilities, plus one small
command that drives them. The conventions below are the project's own.
## Conventions
- Go kernel under `internal/`; each package owns one concern. A package's long
explanation belongs in its `doc.go`, not spread across implementation files.
- Judgements read structure, never wording: types, contracts, schemas. An
error a caller must tell apart carries an identity — a sentinel, a typed
code — never a sentence.
- A metric is its definition, not its name. Before a number becomes a finding,
state what it counts, from which anchor, over which population, and what it
excludes.
## Comments
Default is none — the code is the truth. Write one only when the **why** is
non-obvious: a hidden constraint, an invariant the type system cannot express,
or an external-protocol quirk. A comment states the constraint that holds now;
it does not narrate how the code got here. English, always.
- Declaration doc: ≤5 lines. Package comment: ≤8 lines, or ≤40 in a `doc.go`.
- Every other comment: ≤3 lines. Struct-field and trailing `//`: 1 line.
- One responsibility per file; 800 lines is the ceiling.
## Vendor rates
Official list prices live in one place: `officialRates` in
`internal/contract/pricing/rates.go`, oldest generation first, the last being
what a model charges today. A price change is an **append, never an edit** —
the superseded rate is what lets an installed config be recognised as holding
one of ours and brought forward. Callers read these through
`pricing.CurrentRate` and `pricing.SupersededRates` and restate none of them.
`make pricecheck` compares that table against each vendor's own page. It needs
the network, so it is not part of `make check`.
## Before every commit
```bash
make check # gofmt -l, go vet ./..., go test ./...
```
## Change discipline
List callers, consumers of the same state and sibling features before editing.
Fix at the owning layer; a fix that breaks a neighbouring behaviour is a failed
fix. Write tests first; they must fail without the change.
@@ -0,0 +1,115 @@
// Command benchctl prints what the pricing and ablation contracts answer for
// one model or one benchmark arm, so a reviewer can check a rate or an arm name
// without writing a test.
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/pricing"
)
var errUsage = errors.New("usage: benchctl rate|cost|tariff|arm [flags]")
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "benchctl:", err)
os.Exit(2)
}
}
func run(args []string, out io.Writer) error {
if len(args) == 0 {
return errUsage
}
switch args[0] {
case "rate":
return runRate(args[1:], out)
case "cost":
return runCost(args[1:], out)
case "tariff":
return runTariff(args[1:], out)
case "arm":
return runArm(args[1:], out)
default:
return fmt.Errorf("%w: unknown subcommand %q", errUsage, args[0])
}
}
func runRate(args []string, out io.Writer) error {
fs := flag.NewFlagSet("rate", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
fmt.Fprintf(out, "%s/%s %s: cache-hit %g input %g output %g per 1M\n",
*provider, *model, card.Currency, card.CacheHit, card.Input, card.Output)
if entry, ok := pricing.LookupCatalog(*provider, *model, cur, ""); ok && entry.DocURL != "" {
fmt.Fprintf(out, "source: %s\n", entry.DocURL)
}
fmt.Fprintf(out, "superseded generations: %d\n", len(pricing.SupersededRates(*provider, *model, cur)))
return nil
}
func runCost(args []string, out io.Writer) error {
fs := flag.NewFlagSet("cost", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
hit := fs.Int("hit", 0, "cached prompt tokens")
miss := fs.Int("miss", 0, "uncached prompt tokens")
completion := fs.Int("completion", 0, "completion tokens")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
usage := pricing.UsageTokens{CacheHitTokens: *hit, CacheMissTokens: *miss, CompletionTokens: *completion}
amount := pricing.OriginalCostAmount(*card, usage)
fmt.Fprintf(out, "%s %s\n", amount, pricing.NormalizeCurrency(card.Currency))
return nil
}
func runArm(args []string, out io.Writer) error {
fs := flag.NewFlagSet("arm", flag.ContinueOnError)
fs.SetOutput(out)
spec := fs.String("ablate", "", "comma-separated modules to switch off: "+ablation.ModuleList())
foldIndex := fs.String("fold-index", "", "fold-index scale: default, half, quarter or off")
list := fs.Bool("list", false, "list the switchable modules")
if err := fs.Parse(args); err != nil {
return err
}
if *list {
fmt.Fprintln(out, ablation.ModuleList())
return nil
}
set, err := ablation.ParseArm(*spec, *foldIndex)
if err != nil {
return err
}
fmt.Fprintln(out, set.Arm())
return nil
}
@@ -0,0 +1,65 @@
package main
import (
"bytes"
"errors"
"strings"
"testing"
)
func runOut(t *testing.T, args ...string) string {
t.Helper()
var out bytes.Buffer
if err := run(args, &out); err != nil {
t.Fatalf("run %v: %v", args, err)
}
return out.String()
}
func TestRateNamesTheCurrentCard(t *testing.T) {
got := runOut(t, "rate", "-provider", "mimo", "-model", "mimo-v2.5")
if !strings.Contains(got, "mimo/mimo-v2.5 CNY: cache-hit 0.02 input 1 output 2 per 1M") {
t.Fatalf("rate output = %q", got)
}
}
func TestRateRefusesAnUnpricedModel(t *testing.T) {
var out bytes.Buffer
if err := run([]string{"rate", "-provider", "nobody", "-model", "x"}, &out); err == nil {
t.Fatal("an unpriced model must fail")
}
}
func TestCostChargesTheCurrentCard(t *testing.T) {
got := runOut(t, "cost", "-provider", "mimo", "-model", "mimo-v2.5", "-miss", "1000000", "-completion", "1000000")
if strings.TrimSpace(got) != "3 CNY" {
t.Fatalf("cost output = %q", got)
}
}
func TestArmNamesTheConfiguration(t *testing.T) {
if got := strings.TrimSpace(runOut(t, "arm")); got != "full" {
t.Fatalf("control arm = %q", got)
}
if got := strings.TrimSpace(runOut(t, "arm", "-ablate", "planner,evidence", "-fold-index", "half")); got != "no-evidence+no-planner+index-half" {
t.Fatalf("arm = %q", got)
}
}
func TestUnknownSubcommandIsAUsageError(t *testing.T) {
var out bytes.Buffer
if err := run([]string{"nope"}, &out); !errors.Is(err, errUsage) {
t.Fatalf("err = %v, want errUsage", err)
}
}
func TestTariffReadsTheVendorsSchedule(t *testing.T) {
got := runOut(t, "tariff", "-provider", "mimo", "-model", "mimo-v2.5")
if !strings.Contains(got, "official card: true") || !strings.Contains(got, "one rate around the clock") {
t.Fatalf("tariff output = %q", got)
}
peak := runOut(t, "tariff", "-currency", "CNY", "-at", "2026-10-12T02:00:00Z")
if !strings.Contains(peak, "peak=true until 2026-10-12T04:00:00Z") {
t.Fatalf("deepseek tariff at a weekday peak = %q", peak)
}
}
@@ -0,0 +1,49 @@
package main
import (
"flag"
"fmt"
"io"
"time"
"reasonix/internal/contract/pricing"
)
func runTariff(args []string, out io.Writer) error {
fs := flag.NewFlagSet("tariff", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
at := fs.String("at", "", "instant to read the schedule at, RFC 3339; empty is now")
if err := fs.Parse(args); err != nil {
return err
}
when := time.Now()
if *at != "" {
t, err := time.Parse(time.RFC3339, *at)
if err != nil {
return fmt.Errorf("-at: %w", err)
}
when = t
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
fmt.Fprintf(out, "official card: %v\n", pricing.IsOfficialCard(*provider, *model, *card))
tariff, ok := pricing.LiveTariff(*provider, *model, *card, when)
switch {
case !ok:
fmt.Fprintln(out, "schedule: one rate around the clock")
case !tariff.Changes:
fmt.Fprintf(out, "schedule: peak=%v, no change ahead\n", tariff.Peak)
default:
fmt.Fprintf(out, "schedule: peak=%v until %s\n", tariff.Peak, tariff.ChangesAt.UTC().Format(time.RFC3339))
}
return nil
}
+3
View File
@@ -0,0 +1,3 @@
module reasonix
go 1.26.0
@@ -0,0 +1,95 @@
package pricing
import (
"strings"
"testing"
"time"
)
// Every catalog provider must declare at least one official endpoint, or its
// rows become unreachable: a rate card is only matched against the catalog
// after the entry's host identifies the vendor.
func TestEveryCatalogProviderHasAnOfficialEndpoint(t *testing.T) {
declared := map[string]bool{}
for _, host := range officialEndpointHosts {
declared[host] = true
}
for _, entry := range OfficialCatalog() {
if !declared[entry.Provider] {
t.Errorf("catalog provider %q has no official endpoint host; its prices can never match", entry.Provider)
}
}
}
func TestOfficialProviderForEndpoint(t *testing.T) {
cases := []struct {
name string
baseURL string
want string
}{
{"official deepseek", "https://api.deepseek.com", "deepseek"},
{"official deepseek anthropic path", "https://api.deepseek.com/anthropic", "deepseek"},
{"official longcat", "https://api.longcat.chat/openai/v1", "longcat"},
{"official mimo region", "https://token-plan-sgp.xiaomimimo.com/v1", "mimo"},
{"uppercase host", "https://API.DeepSeek.com/v1", "deepseek"},
{"proxy that resells the model", "https://my-deepseek-proxy.internal/v1", ""},
{"vendor name in the path only", "https://gateway.example.com/deepseek/v1", ""},
{"lookalike registrable domain", "https://api.deepseek.com.evil.test/v1", ""},
{"empty", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := OfficialProviderForEndpoint(tc.baseURL); got != tc.want {
t.Fatalf("OfficialProviderForEndpoint(%q) = %q, want %q", tc.baseURL, got, tc.want)
}
})
}
}
// pricesGoStaleAfter is how long a hand-copied rate is trusted before someone
// reads the vendor's page again. No vendor here serves prices over an API, so
// the alternative to this going red on a quiet day is learning about a change
// from a user whose cost was wrong for months. Bumping CheckedOn without
// opening DocURL defeats it; what this buys is that the question gets asked.
const pricesGoStaleAfter = 90 * 24 * time.Hour
func TestCatalogPricesHaveBeenCheckedRecently(t *testing.T) {
var undated, stale []string
// This snapshot never sees a vendor page again, so staleness is read at the
// day it was taken rather than the day the suite runs.
cutoff := time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC).Add(-pricesGoStaleAfter)
for _, e := range OfficialCatalog() {
where := e.Provider + "/" + e.Model + " " + e.Currency + " (" + e.DocURL + ")"
if e.CheckedOn == "" {
undated = append(undated, where)
continue
}
on, err := time.Parse("2006-01-02", e.CheckedOn)
if err != nil {
t.Fatalf("%s: CheckedOn %q is not YYYY-MM-DD", where, e.CheckedOn)
}
if on.Before(cutoff) {
stale = append(stale, where+" last checked "+e.CheckedOn)
}
}
if len(undated) > 0 {
t.Errorf("catalog rows with no CheckedOn — read the vendor's page and date them:\n %s",
strings.Join(undated, "\n "))
}
if len(stale) > 0 {
t.Errorf("catalog rows older than %d days. Open each DocURL, confirm or correct the rate, then set CheckedOn to today:\n %s",
int(pricesGoStaleAfter.Hours()/24), strings.Join(stale, "\n "))
}
}
// A row that names a peak card must say when it applies, and one that names a
// window must have a card to charge inside it. Half of the pair silently prices
// every hour the same, which is the bug the pair exists to prevent.
func TestPeakRatesAndWindowsComeInPairs(t *testing.T) {
for _, e := range OfficialCatalog() {
where := e.Provider + "/" + e.Model + " " + e.Currency
if (e.Peak == nil) != (e.Window == nil) {
t.Errorf("%s: Peak and Window must be set together (peak=%v window=%v)", where, e.Peak != nil, e.Window != nil)
}
}
}
@@ -0,0 +1,12 @@
---
name: ablation-arm
description: Add a new switchable subsystem to the benchmark's ablation arms.
---
# Adding an ablation module
1. Declare the module in `internal/ablation/modules.go` beside the others.
2. Register it in `AllModules()` so `ParseSpec` accepts it.
3. Arm names come from `Set.Name()`; check that yours reads `without-<module>`.
4. Add the module to the flag help text in `cmd/bench/flags.go`.
5. Run `make test`.
@@ -0,0 +1,15 @@
---
name: benchctl
description: Check a rate, a cost, a peak schedule or an arm name from the command line instead of writing a test.
---
# Using benchctl
1. Price a request with `benchctl quote -provider mimo -model mimo-v2.5 -tokens 1000000`;
it prints the amount and the currency.
2. Read today's card with `benchctl rate -vendor deepseek -model deepseek-flash`.
3. Ask whether a vendor is in its peak window with
`benchctl peak -currency CNY -at 1791770400` (Unix seconds).
4. List the ablation modules with `benchctl arms`, and name an arm with
`benchctl arm -ablate planner,evidence -index half`.
5. Every subcommand prints its flags with `-h`.
@@ -0,0 +1,13 @@
---
name: price-change
description: Record a vendor's announced price change in the official price book.
---
# Recording a price change
1. Open `internal/pricing/rates.go` and find the vendor's entry in
`officialPrices`.
2. Edit the entry's `Input`, `Output` and `CacheHit` to the new values.
3. If the change is announced ahead of time, set `EffectiveFrom` on the entry.
4. Confirm `pricing.LatestRate` now returns the new card.
5. Run `make test` and `make price-check`.
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Builds every task's workdir/ from one pinned commit of this repository plus
# the fixture-authored files in base/ and overlay/<task-id>/. With --check it
# rebuilds into a scratch directory and fails if any committed workdir differs.
set -euo pipefail
PIN=42636ddcb425b7af07abd88397d02c1cfe3adcf5
PATHS=(
internal/contract/pricing
internal/contract/ablation
internal/contract/agentgraph
internal/base/retrieval
internal/base/rulelist
internal/base/tokencount
internal/base/neterr
internal/base/nilutil
)
here="$(cd "$(dirname "$0")" && pwd)"
repo="$(git -C "$here" rev-parse --show-toplevel)"
build() {
local id="$1" dest="$2"
rm -rf "$dest"
mkdir -p "$dest"
git -C "$repo" archive "$PIN" -- "${PATHS[@]}" | tar -x -C "$dest"
cp -R "$here/base/." "$dest/"
if [ -d "$here/overlay/$id" ]; then
cp -R "$here/overlay/$id/." "$dest/"
fi
}
check=0
[ "${1:-}" = "--check" ] && check=1
status=0
for task in "$here"/tasks/*/; do
id="$(basename "$task")"
if [ "$check" = 1 ]; then
scratch="$(mktemp -d)"
build "$id" "$scratch/workdir"
if ! diff -r "$scratch/workdir" "$task/workdir" >/dev/null; then
echo "$id: workdir/ differs from the pinned snapshot; run $0" >&2
diff -rq "$scratch/workdir" "$task/workdir" >&2 || true
status=1
fi
rm -rf "$scratch"
else
build "$id" "$task/workdir"
fi
done
exit "$status"
@@ -0,0 +1,73 @@
# Reasonix kernel snapshot
A trimmed slice of the Reasonix Go kernel: the contracts a benchmark report is
priced and labelled with, a few leaf utilities, and `benchctl`, a small command
that prints what those contracts answer.
```bash
make check # gofmt, vet, test
go run ./cmd/benchctl rate -model deepseek-flash -currency USD
go run ./cmd/benchctl cost -provider mimo -model mimo-v2.5 -miss 1000000
go run ./cmd/benchctl tariff -currency CNY -at 2026-10-12T02:00:00Z
go run ./cmd/benchctl arm -ablate planner,evidence
```
## Packages
| Package | Owns |
| --- | --- |
| `internal/contract/pricing` | the official price book, cost quotes, the spend ledger |
| `internal/contract/ablation` | which subsystems a benchmark arm switches off, and the arm's name |
| `internal/contract/agentgraph` | the vocabulary of one run's execution graph |
| `internal/base/retrieval` | BM25 ranking over CJK-aware tokens |
| `internal/base/rulelist` | splitting a one-line permission rule list |
| `internal/base/tokencount` | a cheap token estimate |
| `internal/base/neterr` | classifying transport errors |
| `internal/base/nilutil` | typed-nil detection |
| `cmd/benchctl` | prints rates, costs, peak schedules and arm names |
## Pricing
A `RateCard` is a price per million tokens in one currency:
| Field | Charges |
| --- | --- |
| `CacheHit` | prompt tokens served from the provider's cache |
| `Input` | prompt tokens that missed the cache, and tokens written to it when `CacheWrite` is zero |
| `Output` | completion tokens |
| `CacheWrite` | tokens written to the cache, for a vendor that prices them separately (Anthropic) |
A card without `CacheWrite` keeps the fingerprint it had before the field
existed, so ledgers stored against it stay attached.
`OriginalCostAmount` turns a card and a `UsageTokens` breakdown into a
fixed-point `Amount`; `PricingFingerprint` keys a card in the ledger, so a
stored ledger entry stays attached to the card it was priced with.
`CurrentRate` and `SupersededRates` answer from `officialRates`, which only
ever grows: a price change appends a generation.
## Ablation
`ablation.Parse` reads a comma-separated list of modules to switch off, and
`Set.Arm` names the result (`full` for the control). The switchable modules,
in the order arm names use:
| Module | Off means |
| --- | --- |
| `evidence` | no evidence gate on completion claims |
| `planner` | no two-model planning turn |
| `subagent` | no delegation to workers |
| `retrieval` | no retrieval over the folded transcript |
| `compaction` | no context folding |
| `upstream` | a dependency edge orders its endpoints without delivering the answer |
| `recall-search` | recall can read a given address but cannot search for one |
The fold-index scale is a separate axis (`-fold-index`), not a module.
## Skills
Project skills live under `.reasonix/skills/<name>/SKILL.md`.
| Skill | Use it to |
| --- | --- |
| `price-change` | record a vendor's price change in the price book |
@@ -0,0 +1,119 @@
// Command benchctl prints what the pricing and ablation contracts answer for
// one model or one benchmark arm, so a reviewer can check a rate or an arm name
// without writing a test.
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/pricing"
)
var errUsage = errors.New("usage: benchctl rate|cost|tariff|arm [flags]")
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "benchctl:", err)
os.Exit(2)
}
}
func run(args []string, out io.Writer) error {
if len(args) == 0 {
return errUsage
}
switch args[0] {
case "rate":
return runRate(args[1:], out)
case "cost":
return runCost(args[1:], out)
case "tariff":
return runTariff(args[1:], out)
case "arm":
return runArm(args[1:], out)
default:
return fmt.Errorf("%w: unknown subcommand %q", errUsage, args[0])
}
}
func runRate(args []string, out io.Writer) error {
fs := flag.NewFlagSet("rate", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
fmt.Fprintf(out, "%s/%s %s: cache-hit %g input %g output %g", *provider, *model, card.Currency, card.CacheHit, card.Input, card.Output)
if card.CacheWrite != 0 {
fmt.Fprintf(out, " cache-write %g", card.CacheWrite)
}
fmt.Fprintln(out, " per 1M")
if entry, ok := pricing.LookupCatalog(*provider, *model, cur, ""); ok && entry.DocURL != "" {
fmt.Fprintf(out, "source: %s\n", entry.DocURL)
}
fmt.Fprintf(out, "superseded generations: %d\n", len(pricing.SupersededRates(*provider, *model, cur)))
return nil
}
func runCost(args []string, out io.Writer) error {
fs := flag.NewFlagSet("cost", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
hit := fs.Int("hit", 0, "cached prompt tokens")
miss := fs.Int("miss", 0, "uncached prompt tokens")
completion := fs.Int("completion", 0, "completion tokens")
write := fs.Int("write", 0, "uncached prompt tokens that were written to the cache")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
usage := pricing.UsageTokens{CacheHitTokens: *hit, CacheMissTokens: *miss, CompletionTokens: *completion, CacheWriteTokens: *write}
amount := pricing.OriginalCostAmount(*card, usage)
fmt.Fprintf(out, "%s %s\n", amount, pricing.NormalizeCurrency(card.Currency))
return nil
}
func runArm(args []string, out io.Writer) error {
fs := flag.NewFlagSet("arm", flag.ContinueOnError)
fs.SetOutput(out)
spec := fs.String("ablate", "", "comma-separated modules to switch off: "+ablation.ModuleList())
foldIndex := fs.String("fold-index", "", "fold-index scale: default, half, quarter or off")
list := fs.Bool("list", false, "list the switchable modules")
if err := fs.Parse(args); err != nil {
return err
}
if *list {
fmt.Fprintln(out, ablation.ModuleList())
return nil
}
set, err := ablation.ParseArm(*spec, *foldIndex)
if err != nil {
return err
}
fmt.Fprintln(out, set.Arm())
return nil
}
@@ -0,0 +1,3 @@
module reasonix
go 1.26.0
@@ -0,0 +1,37 @@
package pricing
import "testing"
func TestCacheWriteRateChargesWrittenTokens(t *testing.T) {
card := RateCard{Input: 3, CacheWrite: 3.75, Currency: "USD"}
usage := UsageTokens{CacheMissTokens: 1000000, CacheWriteTokens: 400000}
if got := OriginalCostAmount(card, usage).String(); got != "3.3" {
t.Fatalf("cost = %s, want 3.3", got)
}
card.CacheWrite = 0
if got := OriginalCostAmount(card, usage).String(); got != "3" {
t.Fatalf("cost without a cache-write rate = %s, want 3", got)
}
}
func TestCacheWriteSeparatesFingerprintsAndCards(t *testing.T) {
base := RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}
withWrite := base
withWrite.CacheWrite = 1.25
if PricingFingerprint(base) == PricingFingerprint(withWrite) {
t.Fatal("cache-write rate does not reach the fingerprint")
}
if sameCard(base, withWrite) {
t.Fatal("cards differing in CacheWrite compare equal")
}
}
func TestAnthropicPricesCacheWrites(t *testing.T) {
got := CurrentRate("anthropic", "claude-sonnet-4-5", "USD")
if got == nil || got.CacheWrite != 3.75 {
t.Fatalf("claude-sonnet-4-5 = %+v", got)
}
if OfficialProviderForEndpoint("https://api.anthropic.com/v1/messages") != "anthropic" {
t.Fatal("api.anthropic.com is not recognised as Anthropic's endpoint")
}
}
@@ -0,0 +1,222 @@
package pricing
import (
"net/url"
"strings"
"time"
)
// Official catalog metadata: currency, model, effective dates, documentation
// sources, and price fingerprints. User-custom prices always win over catalog.
// CatalogEntry is one official list price for a model in a billing currency.
type CatalogEntry struct {
Provider string // deepseek | longcat | mimo | google
Model string
Currency string // ISO billing currency for this row
CacheHit float64
Input float64
Output float64
CacheWrite float64 // zero when the vendor bills cache writes at Input
EffectiveFrom string // YYYY-MM-DD inclusive, empty = unbounded
EffectiveTo string // YYYY-MM-DD exclusive, empty = unbounded
DocURL string
BillingMode string // payg | subscription_equivalent
Notes string
Fingerprint string // filled by Register
// Peak is what the same tokens cost inside Window. Nil when the vendor
// bills one rate around the clock, and then the fields above are the rate.
Peak *RateCard
Window *PeakWindow
// CheckedOn is when a person last read DocURL and confirmed these numbers.
// No vendor here serves prices over an API, so this date is all that stands
// between a price change and a readout that is quietly wrong.
CheckedOn string
}
// deepseekPeak is the schedule DeepSeek publishes: peak on weekday mornings and
// afternoons, and — since 2026-08-23 — never at a weekend. Chinese public
// holidays bill off-peak in full, per the same page.
var deepseekPeak = &PeakWindow{
OffsetSeconds: beijing,
Hours: [][2]int{{9, 12}, {14, 18}},
WeekendOffPeak: "2026-08-23",
HolidayOffPeak: chineseStatutoryHolidays,
}
// chineseStatutoryHolidays are the State Council's 2026 public holidays, which
// DeepSeek bills off-peak in full; extend it when the next notice is out. Make-up
// workdays are absent: they fall on weekends, which are off-peak anyway.
var chineseStatutoryHolidays = []string{
// New Year's Day: Jan 1-3.
"2026-01-01", "2026-01-02", "2026-01-03",
// Spring Festival: Feb 15-23.
"2026-02-15", "2026-02-16", "2026-02-17", "2026-02-18", "2026-02-19",
"2026-02-20", "2026-02-21", "2026-02-22", "2026-02-23",
// Qingming Festival: Apr 4-6.
"2026-04-04", "2026-04-05", "2026-04-06",
// Labor Day: May 1-5.
"2026-05-01", "2026-05-02", "2026-05-03", "2026-05-04", "2026-05-05",
// Dragon Boat Festival: Jun 19-21.
"2026-06-19", "2026-06-20", "2026-06-21",
// Mid-Autumn Festival: Sep 25-27.
"2026-09-25", "2026-09-26", "2026-09-27",
// National Day: Oct 1-7.
"2026-10-01", "2026-10-02", "2026-10-03", "2026-10-04",
"2026-10-05", "2026-10-06", "2026-10-07",
}
// checkedOn is when the tables below were last read off the vendors' pages.
const checkedOn = "2026-08-22"
// deepseekCheckedOn is when the DeepSeek rows were last read off that vendor's
// page. Separate from checkedOn, because confirming one vendor's prices says
// nothing about another's and a shared date would claim that it did.
const deepseekCheckedOn = "2026-09-13"
// mimoCheckedOn is when the V2.6 rows were read off the vendor's own docs.
const mimoCheckedOn = "2026-10-06"
// googleCheckedOn is when the Gemini rows were read off Google's pricing page.
const googleCheckedOn = "2026-10-17"
// anthropicCheckedOn is when the Claude rows were read off Anthropic's pricing page.
const anthropicCheckedOn = "2026-10-19"
// CatalogSourceURLs document public pricing pages.
const (
DocDeepSeekPricing = "https://api-docs.deepseek.com/quick_start/pricing"
DocDeepSeekPricingCNY = "https://api-docs.deepseek.com/zh-cn/quick_start/pricing"
DocLongCatPricingUSD = "https://longcat.chat/platform/docs/Pricing/LongCat-2.0.html"
DocLongCatPricingCNY = "https://longcat.chat/platform/docs/zh/pricing/long-cat-2.0"
DocMiMoPAYG = "https://mimo.mi.com/docs/price/pay-as-you-go"
DocMiMoTokenPlan = "https://platform.xiaomimimo.com/token-plan"
DocGeminiPricing = "https://ai.google.dev/gemini-api/docs/pricing"
DocAnthropicPricing = "https://www.anthropic.com/pricing"
)
// officialEndpointHosts maps a vendor's own API hostnames to the catalog
// provider whose list prices apply there. Host equality is the test: a reseller
// or proxy bills on its own terms even when it serves the same model, and an
// entry merely named after a vendor is not evidence of the vendor's endpoint.
var officialEndpointHosts = map[string]string{
"api.anthropic.com": "anthropic",
"api.deepseek.com": "deepseek",
"api.longcat.chat": "longcat",
"api.xiaomimimo.com": "mimo",
"generativelanguage.googleapis.com": "google",
"token-plan-cn.xiaomimimo.com": "mimo",
"token-plan-sgp.xiaomimimo.com": "mimo",
"token-plan-ams.xiaomimimo.com": "mimo",
}
// OfficialProviderForEndpoint returns the catalog provider whose official
// prices apply to baseURL, or "" when the address is not a vendor's own API.
func OfficialProviderForEndpoint(baseURL string) string {
u, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return ""
}
return officialEndpointHosts[strings.ToLower(u.Hostname())]
}
// OfficialCatalog is the built-in price book: the current generation of every
// rate in officialRates, fingerprinted so a stored price can be recognised as
// one of ours. User-custom prices always win over it.
func OfficialCatalog() []CatalogEntry {
entries := officialCatalogRows()
for i := range entries {
entries[i].Fingerprint = PricingFingerprint(RateCardFromCatalog(entries[i]))
}
return entries
}
// LookupCatalog finds an official entry. billingMode empty matches payg first.
func LookupCatalog(provider, model, currency, billingMode string) (CatalogEntry, bool) {
provider = strings.ToLower(strings.TrimSpace(provider))
model = strings.TrimSpace(model)
currency = NormalizeCurrency(currency)
billingMode = strings.TrimSpace(billingMode)
if billingMode == "" {
billingMode = BillingModePAYG
}
var fallback CatalogEntry
foundFallback := false
for _, e := range OfficialCatalog() {
if e.Provider != provider || e.Model != model {
continue
}
if NormalizeCurrency(e.Currency) != currency {
continue
}
if e.BillingMode == billingMode {
return e, true
}
if e.BillingMode == BillingModePAYG {
fallback = e
foundFallback = true
}
}
if foundFallback {
return fallback, true
}
return CatalogEntry{}, false
}
// RateCardFromCatalog builds a RateCard from a catalog entry's base rate.
func RateCardFromCatalog(e CatalogEntry) RateCard {
return RateCard{
CacheHit: e.CacheHit,
Input: e.Input,
Output: e.Output,
CacheWrite: e.CacheWrite,
Currency: e.Currency,
}
}
// RatesAt is what this entry charges at t: the peak card inside its window, the
// base card everywhere else. An entry without a window charges one rate, and
// then t does not matter.
func (e CatalogEntry) RatesAt(t time.Time) RateCard {
if e.Peak == nil || !e.Window.IsPeak(t) {
return RateCardFromCatalog(e)
}
peak := *e.Peak
peak.Currency = e.Currency
return peak
}
// InEffect reports whether this entry's dates cover the day t falls on.
func (e CatalogEntry) InEffect(t time.Time) bool {
day := t.UTC().Format("2006-01-02")
if e.EffectiveFrom != "" && day < e.EffectiveFrom {
return false
}
return e.EffectiveTo == "" || day < e.EffectiveTo
}
// MatchesCatalog reports whether rates equal a known official entry.
func MatchesCatalog(provider, model string, rates RateCard) (CatalogEntry, bool) {
cur := NormalizeCurrency(rates.Currency)
for _, e := range OfficialCatalog() {
if e.Provider != strings.ToLower(strings.TrimSpace(provider)) {
continue
}
if e.Model != strings.TrimSpace(model) {
continue
}
if NormalizeCurrency(e.Currency) != cur {
continue
}
// Either card identifies the vendor's own price. Which one a user
// happens to have configured says nothing about when they are billing:
// the hour decides that, and RatesAt is what reads it.
if sameCard(RateCardFromCatalog(e), rates) {
return e, true
}
if p := e.Peak; p != nil && sameCard(*p, rates) {
return e, true
}
}
return CatalogEntry{}, false
}
@@ -0,0 +1,799 @@
package pricing
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"slices"
"strings"
"time"
)
// Billing modes.
const (
BillingModePAYG = "payg"
BillingModeSubscriptionEquivalent = "subscription_equivalent"
)
// Valuation basis values.
const (
BasisIdentity = "identity"
BasisOfficialTable = "official_table"
// BasisFX is retained for decoding pre-v6 persisted quotes only. New
// quotes never create FX valuations.
BasisFX = "fx"
)
// DisplayStatus describes whether a quote can satisfy its requested display.
// It is deliberately separate from CostComplete: a known price-book amount
// can exist even when no requested regional valuation is available.
const (
DisplayStatusMatched = "matched"
DisplayStatusFallbackOriginal = "fallback_original"
DisplayStatusBucketed = "bucketed"
DisplayStatusUnavailable = "unavailable"
)
// Aggregate modes describe how a session/run total was formed.
const (
AggregateModeSingleCurrency = "single_currency"
AggregateModeCommonValuation = "common_valuation"
AggregateModeCurrencyBuckets = "currency_buckets"
)
// Rate bands name which side of a vendor's peak window a call was billed on.
// An aggregate is mixed when its calls fell on both sides.
const (
RateBandPeak = "peak"
RateBandOffPeak = "off_peak"
RateBandMixed = "mixed"
)
// DisplayRequest identifies how a caller selected the requested presentation
// currency. The source is runtime context only; it is never persisted as a
// provider price or wallet fact.
type DisplayRequest struct {
Currency string `json:"currency,omitempty"`
Source string `json:"source,omitempty"`
}
const (
DisplaySourceExplicit = "explicit"
DisplaySourceWallet = "wallet"
DisplaySourceAuto = "auto"
)
func (r DisplayRequest) NormalizedCurrency() string {
return NormalizeCurrency(r.Currency)
}
// CostQuote is the host-side quoted cost for one model call (or an aggregate).
// Original is the pricing-table currency fact; valuations hold identity or
// official regional rate-card estimates. Selected is the display pick for the
// current preference.
type CostQuote struct {
Original Money `json:"original"`
// OriginalTotals is populated for mixed-currency aggregates. Individual
// quotes and single-currency totals may omit it.
OriginalTotals []Money `json:"originalTotals,omitempty"`
Valuations map[string]Valuation `json:"valuations,omitempty"`
Selected *Money `json:"selected,omitempty"`
BillingMode string `json:"billingMode,omitempty"`
Estimated bool `json:"estimated"`
// CostComplete means usage and the price-book amount are known. It does not
// imply that a requested display currency is available.
CostComplete bool `json:"costComplete"`
// Coverage is the same fact with the states a boolean cannot hold: an
// aggregate that priced only some of its calls, and one that billed nothing.
Coverage string `json:"coverage,omitempty"`
// DisplayComplete means a single selected amount satisfies the display
// request. Complete is retained as the old wire alias.
DisplayComplete bool `json:"displayComplete"`
Complete bool `json:"complete"`
DisplayStatus string `json:"displayStatus,omitempty"`
AggregateMode string `json:"aggregateMode,omitempty"`
// ModelRef / UsageSource / PricingFingerprint identify the ledger key.
ModelRef string `json:"modelRef,omitempty"`
UsageSource string `json:"usageSource,omitempty"`
PricingFingerprint string `json:"pricingFingerprint,omitempty"`
RateDate string `json:"rateDate,omitempty"` // YYYY-MM-DD of FX used, if any
IncompleteReason string `json:"incompleteReason,omitempty"`
LegacyEstimate bool `json:"legacyEstimate,omitempty"`
CatalogSource string `json:"catalogSource,omitempty"`
// RateBand is set only for a rate confirmed as a vendor's own schedule; an
// aggregate that includes any call without one carries none.
RateBand string `json:"rateBand,omitempty"`
}
// Valuation is one currency view of a cost fact.
type Valuation struct {
Money Money `json:"money"`
Basis string `json:"basis"`
Source string `json:"source"`
AsOf string `json:"asOf"` // YYYY-MM-DD
Rate *RateSnapshot `json:"rateSnapshot,omitempty"`
Stale bool `json:"stale,omitempty"`
}
// RateSnapshot records the FX observation used for a valuation.
type RateSnapshot struct {
Base string `json:"base"`
Quote string `json:"quote"`
Rate float64 `json:"rate"`
Source string `json:"source"`
AsOf string `json:"asOf"`
Stale bool `json:"stale,omitempty"`
}
// RateCard is the per-1M-token price used to compute original cost. It mirrors
// provider.Pricing without importing that package (billing is a leaf).
type RateCard struct {
CacheHit float64 // per 1M cached prompt tokens
Input float64 // per 1M uncached prompt tokens
Output float64 // per 1M completion tokens
// CacheWrite is per 1M tokens written to the cache; zero bills them at Input.
CacheWrite float64
Currency string // ISO or symbol; normalized on quote
}
// UsageTokens is the token breakdown needed for cost. Mirrors provider.Usage
// fields without importing provider.
type UsageTokens struct {
PromptTokens int
CompletionTokens int
CacheHitTokens int
CacheMissTokens int
CacheWriteTokens int
CacheWriteBilledTokens float64
Estimated bool
}
// QuoteInput drives a single CostQuote computation.
type QuoteInput struct {
Usage UsageTokens
Rates RateCard
OccurredAt time.Time
DisplayCurrency string // compatibility alias for Display.Currency
Display DisplayRequest // runtime display request; empty means auto
BillingMode string
ModelRef string
UsageSource string
CatalogSource string
PricingFingerprint string
// ProviderKind is deepseek|longcat|mimo|… for official dual-table lookup.
// When empty, ModelRef and Rates are used to infer a catalog match.
ProviderKind string
// ModelID is the bare model id (e.g. deepseek-v4-flash). Empty → parse ModelRef.
ModelID string
}
// OriginalCostAmount computes the fixed-point original cost from rates + tokens.
// Mirrors the historic provider.Pricing.Cost semantics.
func OriginalCostAmount(rates RateCard, u UsageTokens) Amount {
hit := u.CacheHitTokens
miss := u.CacheMissTokens
if hit+miss == 0 && u.PromptTokens > 0 {
miss = u.PromptTokens
} else if miss == 0 && hit > 0 && u.PromptTokens > hit {
miss = u.PromptTokens - hit
}
write := u.CacheWriteTokens
write = max(write, 0)
write = min(write, miss)
billedWrite := 0.0
if write > 0 {
billedWrite = u.CacheWriteBilledTokens
if billedWrite <= 0 {
billedWrite = float64(write)
}
}
writeRate := rates.Input
if rates.CacheWrite > 0 {
writeRate = rates.CacheWrite
}
total := (float64(hit)*rates.CacheHit +
float64(miss-write)*rates.Input +
billedWrite*writeRate +
float64(u.CompletionTokens)*rates.Output) / 1e6
return NewAmountFromFloat(total)
}
// PricingFingerprint hashes a rate card for ledger keys.
func PricingFingerprint(rates RateCard) string {
cur := NormalizeCurrency(rates.Currency)
raw := fmt.Sprintf("%s|%.12g|%.12g|%.12g", cur, rates.CacheHit, rates.Input, rates.Output)
if rates.CacheWrite != 0 {
// Appended only when set, so every card stored before the field keeps its key.
raw += fmt.Sprintf("|w%.12g", rates.CacheWrite)
}
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:8])
}
// BuildQuote computes original cost and CNY/USD valuations.
func BuildQuote(in QuoteInput) CostQuote {
state := newQuoteBuildState(in)
for _, target := range []string{"CNY", "USD"} {
state.addTargetValuation(target)
}
state.selectDisplay()
return state.quote
}
type quoteBuildState struct {
input QuoteInput
currency string
amount Amount
occurred time.Time
official CatalogEntry
isOfficial bool
valuationFailures map[string]string
quote CostQuote
}
func newQuoteBuildState(in QuoteInput) *quoteBuildState {
currency := NormalizeCurrency(in.Rates.Currency)
if currency == "" {
currency = "CNY"
}
mode := strings.TrimSpace(in.BillingMode)
if mode == "" {
mode = BillingModePAYG
}
occurred := in.OccurredAt
if occurred.IsZero() {
occurred = time.Now().UTC()
}
fingerprint := strings.TrimSpace(in.PricingFingerprint)
if fingerprint == "" {
fingerprint = PricingFingerprint(in.Rates)
}
amount := OriginalCostAmount(in.Rates, in.Usage)
q := CostQuote{
Original: MoneyOf(amount, currency),
Valuations: map[string]Valuation{},
BillingMode: mode,
Estimated: true,
CostComplete: true,
Coverage: CoverageComplete,
DisplayComplete: true,
Complete: true,
DisplayStatus: DisplayStatusMatched,
AggregateMode: AggregateModeSingleCurrency,
ModelRef: strings.TrimSpace(in.ModelRef),
UsageSource: strings.TrimSpace(in.UsageSource),
PricingFingerprint: fingerprint,
CatalogSource: strings.TrimSpace(in.CatalogSource),
}
q.Valuations[currency] = Valuation{
Money: q.Original, Basis: BasisIdentity,
Source: firstNonEmpty(in.CatalogSource, "rate_card"),
AsOf: occurred.UTC().Format("2006-01-02"),
}
state := &quoteBuildState{
input: in, currency: currency, amount: amount, occurred: occurred,
valuationFailures: map[string]string{}, quote: q,
}
if !usageHasFacts(in.Usage) {
state.markIncomplete("missing_price_or_usage")
}
state.matchOfficialCatalog()
state.applyOfficialSchedule()
return state
}
// applyOfficialSchedule re-prices the turn at the vendor's rate for the hour it
// actually happened. Only a rate confirmed as the vendor's own earns this: a
// custom price is the user's own arrangement, and projecting someone else's
// peak schedule onto it would invent a number nobody published.
func (s *quoteBuildState) applyOfficialSchedule() {
if !s.isOfficial || s.official.Peak == nil {
return
}
s.quote.RateBand = RateBandOffPeak
if s.official.Window.IsPeak(s.occurred) {
s.quote.RateBand = RateBandPeak
}
rates := s.official.RatesAt(s.occurred)
if rates.CacheHit == s.input.Rates.CacheHit && rates.Input == s.input.Rates.Input && rates.Output == s.input.Rates.Output {
return
}
s.amount = OriginalCostAmount(rates, s.input.Usage)
s.quote.Original = MoneyOf(s.amount, s.currency)
s.quote.PricingFingerprint = PricingFingerprint(rates)
s.quote.Valuations[s.currency] = Valuation{
Money: s.quote.Original, Basis: BasisOfficialTable,
Source: s.official.DocURL,
AsOf: s.occurred.UTC().Format("2006-01-02"),
}
}
func quoteDisplayRequest(in QuoteInput) DisplayRequest {
request := in.Display
if request.Currency == "" && in.DisplayCurrency != "" {
request.Currency = in.DisplayCurrency
if request.Source == "" {
request.Source = DisplaySourceExplicit
}
}
if request.Source == "" {
request.Source = DisplaySourceAuto
}
return request
}
func usageHasFacts(u UsageTokens) bool {
return u.PromptTokens > 0 || u.CompletionTokens > 0 || u.CacheHitTokens > 0 ||
u.CacheMissTokens > 0 || u.CacheWriteTokens > 0 || u.CacheWriteBilledTokens > 0
}
func (s *quoteBuildState) matchOfficialCatalog() {
providerKind, modelID := resolveCatalogIdentity(s.input)
s.official, s.isOfficial = MatchesCatalog(providerKind, modelID, s.input.Rates)
if !s.isOfficial && providerKind != "" && modelID != "" {
rates := s.input.Rates
rates.Currency = s.currency
s.official, s.isOfficial = MatchesCatalog(providerKind, modelID, rates)
}
if s.isOfficial && s.quote.CatalogSource == "" {
s.quote.CatalogSource = s.official.DocURL
}
}
func (s *quoteBuildState) addTargetValuation(target string) {
if target == s.currency {
return
}
if s.addOfficialValuation(target) {
return
}
// Runtime FX has intentionally been removed. A custom price that does not
// match the official catalog remains in its original currency.
s.valuationFailures[target] = "display_unavailable"
}
func (s *quoteBuildState) addOfficialValuation(target string) bool {
if !s.isOfficial {
return false
}
peer, ok := LookupCatalog(
s.official.Provider, s.official.Model, target, s.official.BillingMode,
)
if !ok {
return false
}
s.quote.Valuations[target] = Valuation{
Money: MoneyOf(OriginalCostAmount(peer.RatesAt(s.occurred), s.input.Usage), target),
Basis: BasisOfficialTable, Source: peer.DocURL,
AsOf: s.occurred.UTC().Format("2006-01-02"),
}
return true
}
func (s *quoteBuildState) selectDisplay() {
if !s.quote.CostComplete {
s.quote.Selected = nil
s.quote.DisplayComplete = false
s.quote.Complete = false
s.quote.DisplayStatus = DisplayStatusUnavailable
return
}
display := quoteDisplayRequest(s.input).NormalizedCurrency()
if display == "" {
selected := s.quote.Original
s.quote.Selected = &selected
return
}
if valuation, ok := s.quote.Valuations[display]; ok {
selected := valuation.Money
s.quote.Selected = &selected
return
}
// The original price-book amount is still a valid cost fact. Keep it as a
// visible fallback and distinguish the display mismatch from no pricing.
selected := s.quote.Original
s.quote.Selected = &selected
s.quote.DisplayComplete = false
s.quote.Complete = false
s.quote.DisplayStatus = DisplayStatusFallbackOriginal
s.quote.IncompleteReason = firstNonEmpty(s.valuationFailures[display], "display_unavailable")
}
func (s *quoteBuildState) markIncomplete(reason string) {
s.quote.CostComplete = false
s.quote.Coverage = CoverageIncomplete
s.quote.DisplayComplete = false
s.quote.Complete = false
s.quote.DisplayStatus = DisplayStatusUnavailable
if s.quote.IncompleteReason == "" {
s.quote.IncompleteReason = reason
}
}
// SelectForDisplay returns the money for a display currency preference without
// recomputing rates. Empty display returns original.
func (q CostQuote) SelectForDisplay(display string) (Money, bool) {
display = NormalizeCurrency(display)
if display == "" {
return q.Original, true
}
if v, ok := q.Valuations[display]; ok {
return v.Money, true
}
if NormalizeCurrency(q.Original.Currency) == display {
return q.Original, true
}
return Money{}, false
}
// WithSelected returns a copy with Selected set for display.
func (q CostQuote) WithSelected(display string) CostQuote {
out := q
if m, ok := q.SelectForDisplay(display); ok {
out.Selected = &m
if quoteHasCompleteCostFact(q) {
out.Complete = true
out.IncompleteReason = ""
}
} else {
out.Selected = nil
out.Complete = false
if out.IncompleteReason == "" {
out.IncompleteReason = "display_unavailable"
}
}
return out
}
// LegacyCostFloat returns the selected (or original) amount as float64 for
// compatibility aliases cost / costUsd / total_cost.
func (q CostQuote) LegacyCostFloat() float64 {
if q.Selected != nil {
return q.Selected.Float64()
}
return q.Original.Float64()
}
// LegacyCurrencySymbol returns a display symbol for the selected/original currency.
func (q CostQuote) LegacyCurrencySymbol() string {
if q.Selected != nil && q.Selected.Currency != "" {
return CurrencySymbol(q.Selected.Currency)
}
return CurrencySymbol(q.Original.Currency)
}
// LegacyCurrencyCode returns the ISO code for selected/original.
func (q CostQuote) LegacyCurrencyCode() string {
if q.Selected != nil && q.Selected.Currency != "" {
return NormalizeCurrency(q.Selected.Currency)
}
return NormalizeCurrency(q.Original.Currency)
}
// AggregateQuotes combines occurrence-time quotes into a session/run total.
// Every entry must have the requested valuation; partial totals stay unavailable.
func AggregateQuotes(quotes []CostQuote, display string) CostQuote {
if len(quotes) == 0 {
return emptyAggregate(NormalizeCurrency(display))
}
accumulator := newQuoteAccumulator(display)
for _, quote := range quotes {
accumulator.add(quote)
}
return accumulator.finish()
}
type quoteAccumulator struct {
out CostQuote
display string
totals map[string]Amount
originalCurrency string
originalTotal Amount
originalTotals map[string]Amount
originalComplete bool
coverage string
displayComplete bool
modes map[string]struct{}
rateBands map[string]struct{}
unbanded bool
}
func emptyAggregate(display string) CostQuote {
out := CostQuote{
Original: MoneyOf(Zero, display), Valuations: map[string]Valuation{}, Estimated: true,
CostComplete: false, DisplayComplete: false, Complete: false,
Coverage: CoverageNone,
DisplayStatus: DisplayStatusUnavailable, AggregateMode: AggregateModeSingleCurrency,
IncompleteReason: "no_usage",
}
return out
}
func newQuoteAccumulator(display string) *quoteAccumulator {
return &quoteAccumulator{
out: CostQuote{Valuations: map[string]Valuation{}, Estimated: true},
display: NormalizeCurrency(display), totals: map[string]Amount{}, originalTotals: map[string]Amount{},
originalComplete: true, coverage: CoverageNone, displayComplete: true,
modes: map[string]struct{}{}, rateBands: map[string]struct{}{},
}
}
func (a *quoteAccumulator) add(quote CostQuote) {
quote = NormalizeQuote(quote)
a.coverage = FoldCoverage(a.coverage, quote.Coverage)
if quote.Coverage != CoverageComplete && a.out.IncompleteReason == "" {
a.out.IncompleteReason = quote.IncompleteReason
}
a.out.LegacyEstimate = a.out.LegacyEstimate || quote.LegacyEstimate
if quote.BillingMode != "" {
a.modes[quote.BillingMode] = struct{}{}
}
a.addRateBand(quote.RateBand)
if quote.RateDate > a.out.RateDate {
a.out.RateDate = quote.RateDate
}
originalCurrency := NormalizeCurrency(quote.Original.Currency)
if len(quote.OriginalTotals) > 0 {
for _, original := range quote.OriginalTotals {
a.addOriginal(original, NormalizeCurrency(original.Currency))
}
} else {
a.addOriginal(quote.Original, originalCurrency)
}
if a.display != "" {
_, found := quote.Valuations[a.display]
a.displayComplete = a.displayComplete && (found || originalCurrency == a.display)
}
originalIncluded := false
for code, valuation := range quote.Valuations {
code = NormalizeCurrency(code)
if code == originalCurrency {
originalIncluded = true
}
a.addValuation(code, valuation)
}
if originalCurrency != "" && !originalIncluded {
a.addValuation(originalCurrency, Valuation{
Money: quote.Original, Basis: BasisIdentity, Source: "aggregate",
AsOf: time.Now().UTC().Format("2006-01-02"),
})
}
}
func (a *quoteAccumulator) addRateBand(band string) {
switch band {
case RateBandPeak, RateBandOffPeak:
a.rateBands[band] = struct{}{}
case RateBandMixed:
a.rateBands[RateBandPeak], a.rateBands[RateBandOffPeak] = struct{}{}, struct{}{}
default:
a.unbanded = true
}
}
func (a *quoteAccumulator) rateBand() string {
if a.unbanded {
return ""
}
switch len(a.rateBands) {
case 1:
for band := range a.rateBands {
return band
}
case 2:
return RateBandMixed
}
return ""
}
func (a *quoteAccumulator) addOriginal(original Money, currency string) {
if currency != "" {
a.originalTotals[currency] = a.originalTotals[currency].Add(original.AmountValue())
}
if a.originalCurrency == "" {
a.originalCurrency = currency
a.originalTotal = original.AmountValue()
return
}
if currency != a.originalCurrency {
a.originalComplete = false
return
}
a.originalTotal = a.originalTotal.Add(original.AmountValue())
}
func (a *quoteAccumulator) addValuation(code string, valuation Valuation) {
if code == "" {
return
}
a.totals[code] = a.totals[code].Add(valuation.Money.AmountValue())
current, found := a.out.Valuations[code]
if !found {
current = valuation
}
current.Money = MoneyOf(a.totals[code], code)
current.Stale = current.Stale || valuation.Stale
a.out.Valuations[code] = current
}
// costFactsComplete reads the accumulated coverage rather than tracking the
// same fact a second time: two answers to one question drift.
func (a *quoteAccumulator) costFactsComplete() bool {
return a.coverage == CoverageComplete
}
func (a *quoteAccumulator) finish() CostQuote {
a.out.Coverage = a.coverage
a.out.CostComplete = a.costFactsComplete()
if a.originalComplete && a.originalCurrency != "" {
a.out.Original = MoneyOf(a.originalTotal, a.originalCurrency)
a.syncOriginalValuation()
} else {
a.out.Original = Money{Amount: "0"}
}
if len(a.originalTotals) > 1 {
codes := make([]string, 0, len(a.originalTotals))
for code := range a.originalTotals {
codes = append(codes, code)
}
slices.Sort(codes)
for _, code := range codes {
a.out.OriginalTotals = append(a.out.OriginalTotals, MoneyOf(a.originalTotals[code], code))
}
}
a.out.RateBand = a.rateBand()
if len(a.modes) == 1 {
for mode := range a.modes {
a.out.BillingMode = mode
}
}
if a.display == "" && a.originalComplete && a.costFactsComplete() {
selected := a.out.Original
a.out.Selected = &selected
a.out.DisplayComplete = true
a.out.Complete = true
a.out.DisplayStatus = DisplayStatusMatched
a.out.AggregateMode = AggregateModeSingleCurrency
return a.out
}
valuation, found := a.out.Valuations[a.display]
if found && a.displayComplete && a.costFactsComplete() {
selected := valuation.Money
a.out.Selected = &selected
a.out.DisplayComplete = true
a.out.Complete = true
a.out.DisplayStatus = DisplayStatusMatched
if len(a.originalTotals) > 1 {
a.out.AggregateMode = AggregateModeCommonValuation
} else {
a.out.AggregateMode = AggregateModeSingleCurrency
}
return a.out
}
a.out.DisplayComplete = false
a.out.Complete = false
if !a.costFactsComplete() {
a.out.DisplayStatus = DisplayStatusUnavailable
a.out.IncompleteReason = firstNonEmpty(a.out.IncompleteReason, "incomplete_cost_fact")
} else if a.originalComplete && a.originalCurrency != "" {
selected := a.out.Original
a.out.Selected = &selected
a.out.DisplayStatus = DisplayStatusFallbackOriginal
a.out.AggregateMode = AggregateModeSingleCurrency
a.out.IncompleteReason = firstNonEmpty(a.out.IncompleteReason, "display_unavailable")
} else {
a.out.DisplayStatus = DisplayStatusBucketed
a.out.AggregateMode = AggregateModeCurrencyBuckets
a.out.IncompleteReason = firstNonEmpty(a.out.IncompleteReason, "currency_buckets")
}
return a.out
}
// NormalizeQuote fills fields introduced after the first CostQuote wire shape.
// It is used at every persistence and transport boundary so old telemetry and
// old eventwire payloads remain safe without inventing a new amount.
func NormalizeQuote(q CostQuote) CostQuote {
if !q.CostComplete && !q.DisplayComplete && q.Complete {
q.CostComplete = true
q.DisplayComplete = true
}
if q.DisplayStatus != "" && q.DisplayStatus != DisplayStatusMatched && q.DisplayStatus != DisplayStatusFallbackOriginal && q.DisplayStatus != DisplayStatusBucketed && q.DisplayStatus != DisplayStatusUnavailable {
q.DisplayStatus = ""
}
if q.RateBand != RateBandPeak && q.RateBand != RateBandOffPeak && q.RateBand != RateBandMixed {
q.RateBand = ""
}
if q.AggregateMode != "" && q.AggregateMode != AggregateModeSingleCurrency && q.AggregateMode != AggregateModeCommonValuation && q.AggregateMode != AggregateModeCurrencyBuckets {
q.AggregateMode = ""
}
if q.DisplayStatus == "" {
switch {
case q.Complete:
q.DisplayStatus = DisplayStatusMatched
case q.Original.Currency != "" && q.Original.Amount != "" && q.IncompleteReason != "no_price":
q.DisplayStatus = DisplayStatusFallbackOriginal
default:
q.DisplayStatus = DisplayStatusUnavailable
}
}
if q.AggregateMode == "" {
if len(q.OriginalTotals) > 1 {
q.AggregateMode = AggregateModeCurrencyBuckets
} else if q.Selected != nil {
q.AggregateMode = AggregateModeSingleCurrency
}
}
if !ValidCoverage(q.Coverage) {
q.Coverage = CoverageIncomplete
if quoteHasCompleteCostFact(q) {
q.Coverage = CoverageComplete
}
}
q.Complete = q.DisplayComplete
return q
}
// quoteHasCompleteCostFact separates a known original-currency charge from a
// display-only valuation failure must not poison an exact original total,
// while unpriced and unrecoverable legacy records stay incomplete in every
// display currency.
func quoteHasCompleteCostFact(q CostQuote) bool {
if q.CostComplete {
return true
}
switch q.IncompleteReason {
case "no_price", "missing_price_or_usage", "legacy_unrecoverable",
"legacy_wiped_or_zero", "legacy_invalid_amount", "mixed_original_currencies",
"incomplete_cost_fact":
return false
}
if q.Complete {
return true
}
currency := NormalizeCurrency(q.Original.Currency)
if currency == "" {
return false
}
valuation, ok := q.Valuations[currency]
return ok && SameCurrency(valuation.Money.Currency, currency)
}
func (a *quoteAccumulator) syncOriginalValuation() {
valuation, found := a.out.Valuations[a.originalCurrency]
if !found {
valuation = Valuation{
Basis: BasisIdentity, Source: "aggregate",
AsOf: time.Now().UTC().Format("2006-01-02"),
}
}
valuation.Money = a.out.Original
a.out.Valuations[a.originalCurrency] = valuation
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
// resolveCatalogIdentity names which official table, if any, may price a quote.
// The vendor comes only from ProviderKind, which the caller reads off the
// endpoint's host; guessing it from the entry's name or the model id would bill
// a relay as the vendor it resells. Unnamed means no official table — the user's
// own rate card, which is right rather than merely plausible.
func resolveCatalogIdentity(in QuoteInput) (provider, model string) {
provider = strings.ToLower(strings.TrimSpace(in.ProviderKind))
model = strings.TrimSpace(in.ModelID)
if model == "" {
ref := strings.TrimSpace(in.ModelRef)
if i := strings.LastIndex(ref, "/"); i >= 0 && i+1 < len(ref) {
model = ref[i+1:]
} else {
model = ref
}
}
return provider, model
}
@@ -0,0 +1,358 @@
// rates.go — every rate this project has shipped for a vendor's model, and the
// two questions that asks: what it costs now, and what it used to cost.
package pricing
import (
"slices"
"strings"
"time"
)
// rateGeneration is one published pair. Peak is nil for a generation the vendor
// billed at a single rate around the clock.
type rateGeneration struct {
Base RateCard
Peak *RateCard
// From is the first day (YYYY-MM-DD, UTC) this generation is billed; empty
// means from when it shipped. An announced change is appended with its date.
From string
}
// today is the day a dated generation is read against; a test replaces it.
var today = func() string { return time.Now().UTC().Format("2006-01-02") }
// currentIndex is the generation in force today: the last one whose date has
// come. Rows with no dated generation answer with their last, as before.
func (r officialRateRow) currentIndex() int {
day := today()
for i := len(r.Rates) - 1; i > 0; i-- {
if r.Rates[i].From == "" || r.Rates[i].From <= day {
return i
}
}
return 0
}
// officialRateRow is one vendor's model, in one currency, under one billing
// mode, with every rate this project has shipped for it.
type officialRateRow struct {
Provider string
Model string
Currency string
BillingMode string
Window *PeakWindow
DocURL string
Notes string
CheckedOn string
// Rates is oldest first; the last entry is what this costs today.
Rates []rateGeneration
}
// officialRates is the price book. A price change appends a generation rather
// than editing one: that is what leaves the superseded rate where a stored price
// can still be recognised as this project's own default instead of a number its
// user chose, which is the difference between refreshing one and losing it.
var officialRates = []officialRateRow{
{
Provider: "deepseek", Model: "deepseek-flash", Currency: "CNY",
BillingMode: BillingModePAYG, Window: deepseekPeak, DocURL: DocDeepSeekPricing, CheckedOn: deepseekCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}},
{Base: RateCard{CacheHit: 0.05, Input: 1.5, Output: 4.5, Currency: "CNY"},
Peak: &RateCard{CacheHit: 0.10, Input: 3.0, Output: 9.0, Currency: "CNY"}},
{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 4, Currency: "CNY"},
Peak: &RateCard{CacheHit: 0.04, Input: 2, Output: 8, Currency: "CNY"}},
},
},
{
Provider: "deepseek", Model: "deepseek-flash", Currency: "USD",
BillingMode: BillingModePAYG, Window: deepseekPeak, DocURL: DocDeepSeekPricing, CheckedOn: deepseekCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: "USD"}},
{Base: RateCard{CacheHit: 0.007, Input: 0.22, Output: 0.66, Currency: "USD"},
Peak: &RateCard{CacheHit: 0.014, Input: 0.44, Output: 1.32, Currency: "USD"}},
{Base: RateCard{CacheHit: 0.003, Input: 0.15, Output: 0.6, Currency: "USD"},
Peak: &RateCard{CacheHit: 0.006, Input: 0.3, Output: 1.2, Currency: "USD"}},
},
},
{
Provider: "deepseek", Model: "deepseek-v4-pro", Currency: "CNY",
BillingMode: BillingModePAYG, Window: deepseekPeak, DocURL: DocDeepSeekPricing, CheckedOn: deepseekCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}},
{Base: RateCard{CacheHit: 0.15, Input: 4.5, Output: 13.5, Currency: "CNY"},
Peak: &RateCard{CacheHit: 0.30, Input: 9.0, Output: 27.0, Currency: "CNY"}},
},
},
{
Provider: "deepseek", Model: "deepseek-v4-pro", Currency: "USD",
BillingMode: BillingModePAYG, Window: deepseekPeak, DocURL: DocDeepSeekPricing, CheckedOn: deepseekCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: "USD"}},
{Base: RateCard{CacheHit: 0.022, Input: 0.66, Output: 1.98, Currency: "USD"},
Peak: &RateCard{CacheHit: 0.044, Input: 1.32, Output: 3.96, Currency: "USD"}},
},
},
// LongCat's launch discount, for which the vendor publishes no end date. List
// price is CNY 0.10/5/20, USD 0.015/0.75/2.95 — what these revert to, and why
// CheckedOn earns its place here.
{
Provider: "longcat", Model: "LongCat-2.0", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocLongCatPricingCNY, Notes: "launch_discount_no_end_date", CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.04, Input: 2, Output: 8, Currency: "CNY"}}},
},
{
Provider: "longcat", Model: "LongCat-2.0", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocLongCatPricingUSD, Notes: "launch_discount_no_end_date", CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.006, Input: 0.30, Output: 1.20, Currency: "USD"}}},
},
// Google: the two flash models double on 2027-01-01, hence the dated generation.
// gemini-3.1-pro-preview is the rate up to 200k prompt tokens; gemini-4-argon has no price.
{
Provider: "google", Model: "gemini-3.8-flash", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocGeminiPricing, CheckedOn: googleCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.075, Input: 0.75, Output: 3.75, Currency: "USD"}},
{From: "2027-01-01", Base: RateCard{CacheHit: 0.15, Input: 1.50, Output: 7.50, Currency: "USD"}},
},
},
{
Provider: "google", Model: "gemini-3.6-flash", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocGeminiPricing, CheckedOn: googleCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.075, Input: 0.75, Output: 3.75, Currency: "USD"}},
{From: "2027-01-01", Base: RateCard{CacheHit: 0.15, Input: 1.50, Output: 7.50, Currency: "USD"}},
},
},
{
Provider: "google", Model: "gemini-3.5-flash-lite", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocGeminiPricing, CheckedOn: googleCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.03, Input: 0.30, Output: 2.50, Currency: "USD"}}},
},
{
Provider: "google", Model: "gemini-3.1-pro-preview", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocGeminiPricing, Notes: "up_to_200k_prompt_tokens", CheckedOn: googleCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.20, Input: 2.00, Output: 12.00, Currency: "USD"}}},
},
{
Provider: "anthropic", Model: "claude-sonnet-4-5", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocAnthropicPricing, CheckedOn: anthropicCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.30, Input: 3, Output: 15, CacheWrite: 3.75, Currency: "USD"}}},
},
// MiMo domestic PAYG; Token Plan bills the same rates as subscription_equivalent.
{
Provider: "mimo", Model: "mimo-v2.5-pro", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.5", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}}},
},
// Not on the vendor's price page as of CheckedOn. Kept because removing a rate
// is what turns a still-configured model's cost silently into zero; a stale
// rate at least reads as a number somebody can question.
{
Provider: "mimo", Model: "mimo-v2-flash", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, Notes: "not_on_vendor_page", CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.5-pro", Currency: "CNY",
BillingMode: BillingModeSubscriptionEquivalent, DocURL: DocMiMoTokenPlan, Notes: "payg_equivalent_not_plan_bill", CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.5", Currency: "CNY",
BillingMode: BillingModeSubscriptionEquivalent, DocURL: DocMiMoTokenPlan, Notes: "payg_equivalent_not_plan_bill", CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.6-pro", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, CheckedOn: mimoCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.6-flash", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, CheckedOn: mimoCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.6-pro-ultraspeed", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, CheckedOn: mimoCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.25, Input: 30, Output: 60, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.6-pro", Currency: "CNY",
BillingMode: BillingModeSubscriptionEquivalent, DocURL: DocMiMoTokenPlan, Notes: "payg_equivalent_not_plan_bill", CheckedOn: mimoCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.6-flash", Currency: "CNY",
BillingMode: BillingModeSubscriptionEquivalent, DocURL: DocMiMoTokenPlan, Notes: "payg_equivalent_not_plan_bill", CheckedOn: mimoCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}}},
},
}
// retiredDeepSeekFlashModels are names DeepSeek still answers as its current
// flash model, at that model's price. Owned here because that is where the rates
// they bill at live; config reads them through the accessor below.
var retiredDeepSeekFlashModels = []string{"deepseek-v4-flash", "deepseek-v4-flash-vision-exp"}
// RetiredDeepSeekFlashModels copies the list on the way out: a caller sorting or
// trimming the result would otherwise change what every other caller sees.
func RetiredDeepSeekFlashModels() []string {
return append([]string(nil), retiredDeepSeekFlashModels...)
}
// RateModelFor answers which model's rate a name is billed at — a retired name
// at the model that replaced it, an older spelling at the one it was renamed
// from. Scoped to billing on purpose: two models can share a rate without being
// the same model, and nothing outside a price lookup may read this as identity.
func RateModelFor(provider, model string) string {
model = strings.TrimSpace(model)
switch strings.ToLower(strings.TrimSpace(provider)) {
case "deepseek":
if slices.Contains(retiredDeepSeekFlashModels, model) {
return "deepseek-flash"
}
case "mimo":
switch model {
case "mimo-v2-pro":
return "mimo-v2.5-pro"
case "mimo-v2-omni":
return "mimo-v2.5"
}
}
return model
}
// DefaultCurrency is what a vendor's rates are quoted in when an entry does not
// say. A vendor publishing one currency leaves no choice to make; DeepSeek
// prices in both, and USD is what an entry that never chose has been read as.
func DefaultCurrency(provider string) string {
provider = strings.ToLower(strings.TrimSpace(provider))
seen := map[string]bool{}
for _, row := range officialRates {
if row.Provider == provider {
seen[row.Currency] = true
}
}
if len(seen) == 1 {
for currency := range seen {
return currency
}
}
return "USD"
}
// officialRateRowsFor finds the rows for a model, which is more than one when a
// vendor publishes the same rates under two billing modes.
func officialRateRowsFor(provider, model, currency string) []officialRateRow {
provider, currency = strings.ToLower(strings.TrimSpace(provider)), NormalizeCurrency(currency)
model = RateModelFor(provider, model)
var out []officialRateRow
for _, row := range officialRates {
if row.Provider == provider && row.Model == model && row.Currency == currency {
out = append(out, row)
}
}
return out
}
// CurrentRate is what a vendor's model costs in a currency today — the off-peak
// card, which is what a caller charging one rate should quote. Nil when this
// catalog prices neither. A model published under two billing modes quotes the
// pay-as-you-go rate, which is the one an unsubscribed caller is charged.
func CurrentRate(provider, model, currency string) *RateCard {
for _, row := range officialRateRowsFor(provider, model, currency) {
if row.BillingMode == BillingModeSubscriptionEquivalent {
continue
}
if len(row.Rates) > 0 {
rate := row.Rates[row.currentIndex()].Base
return &rate
}
}
return nil
}
// IsOfficialCard reports whether card is a rate this project has shipped for the
// model in the card's own currency: any generation, its off-peak or its peak
// card. A card that is none of them is the user's, or damaged, and cannot be
// valued against the vendor's other-currency table.
func IsOfficialCard(provider, model string, card RateCard) bool {
for _, row := range officialRateRowsFor(provider, model, card.Currency) {
if row.BillingMode == BillingModeSubscriptionEquivalent {
continue
}
for _, generation := range row.Rates {
if sameCard(generation.Base, card) || generation.Peak != nil && sameCard(*generation.Peak, card) {
return true
}
}
}
return false
}
func sameCard(a, b RateCard) bool {
return a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output && a.CacheWrite == b.CacheWrite
}
// SupersededRates are the rates shipped for a model before the current one. A
// stored price matching one came from this project rather than from its user, so
// a refresh may replace it; anything else is theirs and is left alone.
func SupersededRates(provider, model, currency string) []RateCard {
var out []RateCard
for _, row := range officialRateRowsFor(provider, model, currency) {
if row.BillingMode == BillingModeSubscriptionEquivalent || len(row.Rates) < 2 {
continue
}
for _, generation := range row.Rates[:row.currentIndex()] {
out = append(out, generation.Base)
}
}
return out
}
// officialCatalogRows publishes the current generation of every row, plus the
// retired DeepSeek names, which bill at the flash rate under their own id.
func officialCatalogRows() []CatalogEntry {
rows := make([]CatalogEntry, 0, len(officialRates)+len(retiredDeepSeekFlashModels)*2)
for _, row := range officialRates {
if len(row.Rates) == 0 {
continue
}
current := row.Rates[row.currentIndex()]
rows = append(rows, row.entry(row.Model, current))
if row.Provider != "deepseek" || row.Model != "deepseek-flash" {
continue
}
for _, retired := range retiredDeepSeekFlashModels {
rows = append(rows, row.entry(retired, current))
}
}
return rows
}
func (r officialRateRow) entry(model string, current rateGeneration) CatalogEntry {
// Copied, so a caller holding a returned row cannot reach back into the
// table every other caller reads.
var peak *RateCard
if current.Peak != nil {
card := *current.Peak
peak = &card
}
return CatalogEntry{
Provider: r.Provider, Model: model, Currency: r.Currency,
CacheHit: current.Base.CacheHit, Input: current.Base.Input, Output: current.Base.Output,
CacheWrite: current.Base.CacheWrite,
Peak: peak, Window: r.Window, DocURL: r.DocURL,
BillingMode: r.BillingMode, Notes: r.Notes, CheckedOn: r.CheckedOn,
}
}
@@ -0,0 +1,12 @@
prompt = """Some providers bill tokens written into their prompt cache at a rate of their own. Add that to the pricing contract in this repository:
- `RateCard` gains a `CacheWrite` rate, per 1M tokens like the others. Zero means the card has no separate cache-write price, and cache-written tokens keep being charged at `Input` exactly as they are today.
- When a card sets `CacheWrite`, `OriginalCostAmount` charges the billed cache-write units (`CacheWriteBilledTokens` when set, otherwise `CacheWriteTokens`) at `CacheWrite` instead of `Input`.
- `PricingFingerprint` must tell apart two cards that differ only in `CacheWrite`, but a card without one must keep exactly the fingerprint it has today: stored ledgers are keyed by it.
- Two cards that differ only in `CacheWrite` are different cards everywhere cards are compared, including official-card recognition and catalog matching.
- `CatalogEntry` carries the rate too, and `RateCardFromCatalog` and `CatalogEntry.RatesAt` hand it back.
- Add Anthropic to the official price book: `claude-sonnet-4-5`, pay-as-you-go in USD, cache hit 0.30, input 3, output 15 and cache write 3.75 per 1M tokens, documented at https://www.anthropic.com/pricing and checked 2026-10-19. `api.anthropic.com` is Anthropic's own endpoint.
- `benchctl cost` gains a `-write` flag for cache-written tokens, and `benchctl rate` shows a card's cache-write rate when it has one.
- Add tests for the new behaviour and update the README's pricing section."""
class = "multi-file-feature"
timeout_sec = 1200
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
set -e
export GOFLAGS=-mod=mod GOTOOLCHAIN=local
cat > internal/contract/pricing/zz_grader_test.go <<'GO'
package pricing
import (
"testing"
"time"
)
func TestGraderCacheWriteCost(t *testing.T) {
card := RateCard{CacheHit: 0.3, Input: 3, Output: 15, Currency: "USD"}
priced := card
priced.CacheWrite = 3.75
cases := []struct {
name string
card RateCard
usage UsageTokens
want string
}{
{"no write, no rate", card, UsageTokens{CacheHitTokens: 200000, CacheMissTokens: 800000, CompletionTokens: 50000}, "3.21"},
{"no write, rate set", priced, UsageTokens{CacheHitTokens: 200000, CacheMissTokens: 800000, CompletionTokens: 50000}, "3.21"},
{"write at input", card, UsageTokens{CacheMissTokens: 1000000, CacheWriteTokens: 400000, CompletionTokens: 100000}, "4.5"},
{"write at cache-write", priced, UsageTokens{CacheMissTokens: 1000000, CacheWriteTokens: 400000, CompletionTokens: 100000}, "4.8"},
{"billed units at input", card, UsageTokens{CacheMissTokens: 1000000, CacheWriteTokens: 400000, CacheWriteBilledTokens: 500000}, "3.3"},
{"billed units at cache-write", priced, UsageTokens{CacheMissTokens: 1000000, CacheWriteTokens: 400000, CacheWriteBilledTokens: 500000}, "3.675"},
}
for _, tc := range cases {
if got := OriginalCostAmount(tc.card, tc.usage).String(); got != tc.want {
t.Errorf("%s: cost = %s, want %s", tc.name, got, tc.want)
}
}
}
func TestGraderFingerprintKeepsStoredKeys(t *testing.T) {
cny := RateCard{CacheHit: 0.02, Input: 1, Output: 4, Currency: "CNY"}
usd := RateCard{CacheHit: 0.3, Input: 3, Output: 15, Currency: "USD"}
if got := PricingFingerprint(cny); got != "ba96037de26c3f79" {
t.Errorf("fingerprint of a card without CacheWrite moved: %s", got)
}
if got := PricingFingerprint(usd); got != "e16b53b4599e5fda" {
t.Errorf("fingerprint of a card without CacheWrite moved: %s", got)
}
withWrite := usd
withWrite.CacheWrite = 3.75
if PricingFingerprint(withWrite) == PricingFingerprint(usd) {
t.Error("cards differing only in CacheWrite share a fingerprint")
}
other := usd
other.CacheWrite = 6
if PricingFingerprint(withWrite) == PricingFingerprint(other) {
t.Error("two different CacheWrite rates share a fingerprint")
}
}
func TestGraderCacheWriteIsPartOfCardIdentity(t *testing.T) {
official := RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}
if !IsOfficialCard("mimo", "mimo-v2.5", official) {
t.Fatal("the official mimo-v2.5 card is no longer recognised")
}
changed := official
changed.CacheWrite = 1.25
if IsOfficialCard("mimo", "mimo-v2.5", changed) {
t.Error("a card with a cache-write rate the vendor never published is recognised as official")
}
if _, ok := MatchesCatalog("mimo", "mimo-v2.5", official); !ok {
t.Fatal("the official mimo-v2.5 card no longer matches the catalog")
}
if _, ok := MatchesCatalog("mimo", "mimo-v2.5", changed); ok {
t.Error("a card with an unpublished cache-write rate matches the catalog")
}
}
func TestGraderAnthropicIsPriced(t *testing.T) {
want := RateCard{CacheHit: 0.30, Input: 3, Output: 15, CacheWrite: 3.75, Currency: "USD"}
got := CurrentRate("anthropic", "claude-sonnet-4-5", "USD")
if got == nil || *got != want {
t.Fatalf("CurrentRate(anthropic, claude-sonnet-4-5) = %+v, want %+v", got, want)
}
if !IsOfficialCard("anthropic", "claude-sonnet-4-5", want) {
t.Error("the Anthropic card is not recognised as official")
}
e, ok := LookupCatalog("anthropic", "claude-sonnet-4-5", "USD", "")
if !ok || e.DocURL != "https://www.anthropic.com/pricing" || e.CheckedOn != "2026-10-19" || e.CacheWrite != 3.75 {
t.Errorf("catalog entry = %+v, %v", e, ok)
}
if got := OfficialProviderForEndpoint("https://api.anthropic.com/v1/messages"); got != "anthropic" {
t.Errorf("api.anthropic.com resolves to %q", got)
}
if got := DefaultCurrency("anthropic"); got != "USD" {
t.Errorf("DefaultCurrency(anthropic) = %q", got)
}
}
func TestGraderCatalogCarriesCacheWrite(t *testing.T) {
e := CatalogEntry{Provider: "x", Model: "y", Currency: "USD", CacheHit: 0.1, Input: 1, Output: 2, CacheWrite: 1.25}
if got := RateCardFromCatalog(e); got.CacheWrite != 1.25 || got.Input != 1 {
t.Errorf("RateCardFromCatalog = %+v", got)
}
if got := e.RatesAt(time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)); got.CacheWrite != 1.25 {
t.Errorf("RatesAt = %+v", got)
}
}
GO
gofmt_out="$(gofmt -l .)"
[ -z "$gofmt_out" ] || { echo "gofmt needed: $gofmt_out"; exit 1; }
go vet ./...
go test ./... >/dev/null
out="$(go run ./cmd/benchctl cost -provider mimo -model mimo-v2.5 -miss 1000000 -write 400000)"
[ "$out" = "1 CNY" ] || { echo "benchctl cost -write: got $out"; exit 1; }
out="$(go run ./cmd/benchctl rate -provider anthropic -model claude-sonnet-4-5)"
case "$out" in *"cache-write 3.75"*) ;; *) echo "benchctl rate does not show the cache-write rate: $out"; exit 1 ;; esac
out="$(go run ./cmd/benchctl rate -provider mimo -model mimo-v2.5)"
case "$out" in *"cache-write"*) echo "benchctl rate shows a cache-write rate the card does not have: $out"; exit 1 ;; esac
grep -lE 'CacheWrite:' internal/contract/pricing/*_test.go | grep -qv zz_grader_test.go \
|| { echo "no test of the pricing package sets a CacheWrite rate"; exit 1; }
awk '/^## Pricing/{on=1; next} /^## /{on=0} on' README.md | grep -q 'CacheWrite' \
|| { echo "README's pricing section does not mention CacheWrite"; exit 1; }
@@ -0,0 +1,18 @@
---
name: price-change
description: Record a vendor's announced price change in the official price book without losing the rate it replaces.
---
# Recording a price change
1. Find the vendor's rows in `officialRates` in
`internal/contract/pricing/rates.go`. Each row is one model in one currency.
2. **Append** a `rateGeneration` to the row's `Rates`; never edit an existing
one. Set `From` to the first billed day (`YYYY-MM-DD`, UTC) when the change
is announced ahead of time.
3. Leave the earlier generation where it is: `SupersededRates` reads it so a
stored price that matches it is recognised as ours and refreshed.
4. Update the row's `CheckedOn` constant to the day you confirmed the page.
5. Add a test beside `rates_test.go` that pins `CurrentRate` before and after
the `From` day by replacing `today`.
6. Run `make check`. `make pricecheck` needs the network and is run by hand.
@@ -0,0 +1,17 @@
.PHONY: check test vet fmt pricecheck
check: fmt vet test
fmt:
@out="$$(gofmt -l .)"; if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
vet:
go vet ./...
test:
go test ./...
# Reads each vendor's pricing page; this snapshot carries no network client.
pricecheck:
@echo "pricecheck: reaches each vendor's pricing page and needs the network; not available in this snapshot" >&2
@exit 2
@@ -0,0 +1,69 @@
# Reasonix kernel snapshot
A trimmed slice of the Reasonix Go kernel: the contracts a benchmark report is
priced and labelled with, a few leaf utilities, and `benchctl`, a small command
that prints what those contracts answer.
```bash
make check # gofmt, vet, test
go run ./cmd/benchctl rate -model deepseek-flash -currency USD
go run ./cmd/benchctl cost -provider mimo -model mimo-v2.5 -miss 1000000
go run ./cmd/benchctl tariff -currency CNY -at 2026-10-12T02:00:00Z
go run ./cmd/benchctl arm -ablate planner,evidence
```
## Packages
| Package | Owns |
| --- | --- |
| `internal/contract/pricing` | the official price book, cost quotes, the spend ledger |
| `internal/contract/ablation` | which subsystems a benchmark arm switches off, and the arm's name |
| `internal/contract/agentgraph` | the vocabulary of one run's execution graph |
| `internal/base/retrieval` | BM25 ranking over CJK-aware tokens |
| `internal/base/rulelist` | splitting a one-line permission rule list |
| `internal/base/tokencount` | a cheap token estimate |
| `internal/base/neterr` | classifying transport errors |
| `internal/base/nilutil` | typed-nil detection |
| `cmd/benchctl` | prints rates, costs, peak schedules and arm names |
## Pricing
A `RateCard` is a price per million tokens in one currency:
| Field | Charges |
| --- | --- |
| `CacheHit` | prompt tokens served from the provider's cache |
| `Input` | prompt tokens that missed the cache, including tokens written to it |
| `Output` | completion tokens |
`OriginalCostAmount` turns a card and a `UsageTokens` breakdown into a
fixed-point `Amount`; `PricingFingerprint` keys a card in the ledger, so a
stored ledger entry stays attached to the card it was priced with.
`CurrentRate` and `SupersededRates` answer from `officialRates`, which only
ever grows: a price change appends a generation.
## Ablation
`ablation.Parse` reads a comma-separated list of modules to switch off, and
`Set.Arm` names the result (`full` for the control). The switchable modules,
in the order arm names use:
| Module | Off means |
| --- | --- |
| `evidence` | no evidence gate on completion claims |
| `planner` | no two-model planning turn |
| `subagent` | no delegation to workers |
| `retrieval` | no retrieval over the folded transcript |
| `compaction` | no context folding |
| `upstream` | a dependency edge orders its endpoints without delivering the answer |
| `recall-search` | recall can read a given address but cannot search for one |
The fold-index scale is a separate axis (`-fold-index`), not a module.
## Skills
Project skills live under `.reasonix/skills/<name>/SKILL.md`.
| Skill | Use it to |
| --- | --- |
| `price-change` | record a vendor's price change in the price book |
@@ -0,0 +1,51 @@
# Reasonix project memory
This checkout is a trimmed snapshot of the Reasonix kernel: the billing,
ablation and graph contracts and a handful of leaf utilities, plus one small
command that drives them. The conventions below are the project's own.
## Conventions
- Go kernel under `internal/`; each package owns one concern. A package's long
explanation belongs in its `doc.go`, not spread across implementation files.
- Judgements read structure, never wording: types, contracts, schemas. An
error a caller must tell apart carries an identity — a sentinel, a typed
code — never a sentence.
- A metric is its definition, not its name. Before a number becomes a finding,
state what it counts, from which anchor, over which population, and what it
excludes.
## Comments
Default is none — the code is the truth. Write one only when the **why** is
non-obvious: a hidden constraint, an invariant the type system cannot express,
or an external-protocol quirk. A comment states the constraint that holds now;
it does not narrate how the code got here. English, always.
- Declaration doc: ≤5 lines. Package comment: ≤8 lines, or ≤40 in a `doc.go`.
- Every other comment: ≤3 lines. Struct-field and trailing `//`: 1 line.
- One responsibility per file; 800 lines is the ceiling.
## Vendor rates
Official list prices live in one place: `officialRates` in
`internal/contract/pricing/rates.go`, oldest generation first, the last being
what a model charges today. A price change is an **append, never an edit** —
the superseded rate is what lets an installed config be recognised as holding
one of ours and brought forward. Callers read these through
`pricing.CurrentRate` and `pricing.SupersededRates` and restate none of them.
`make pricecheck` compares that table against each vendor's own page. It needs
the network, so it is not part of `make check`.
## Before every commit
```bash
make check # gofmt -l, go vet ./..., go test ./...
```
## Change discipline
List callers, consumers of the same state and sibling features before editing.
Fix at the owning layer; a fix that breaks a neighbouring behaviour is a failed
fix. Write tests first; they must fail without the change.
@@ -0,0 +1,115 @@
// Command benchctl prints what the pricing and ablation contracts answer for
// one model or one benchmark arm, so a reviewer can check a rate or an arm name
// without writing a test.
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/pricing"
)
var errUsage = errors.New("usage: benchctl rate|cost|tariff|arm [flags]")
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "benchctl:", err)
os.Exit(2)
}
}
func run(args []string, out io.Writer) error {
if len(args) == 0 {
return errUsage
}
switch args[0] {
case "rate":
return runRate(args[1:], out)
case "cost":
return runCost(args[1:], out)
case "tariff":
return runTariff(args[1:], out)
case "arm":
return runArm(args[1:], out)
default:
return fmt.Errorf("%w: unknown subcommand %q", errUsage, args[0])
}
}
func runRate(args []string, out io.Writer) error {
fs := flag.NewFlagSet("rate", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
fmt.Fprintf(out, "%s/%s %s: cache-hit %g input %g output %g per 1M\n",
*provider, *model, card.Currency, card.CacheHit, card.Input, card.Output)
if entry, ok := pricing.LookupCatalog(*provider, *model, cur, ""); ok && entry.DocURL != "" {
fmt.Fprintf(out, "source: %s\n", entry.DocURL)
}
fmt.Fprintf(out, "superseded generations: %d\n", len(pricing.SupersededRates(*provider, *model, cur)))
return nil
}
func runCost(args []string, out io.Writer) error {
fs := flag.NewFlagSet("cost", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
hit := fs.Int("hit", 0, "cached prompt tokens")
miss := fs.Int("miss", 0, "uncached prompt tokens")
completion := fs.Int("completion", 0, "completion tokens")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
usage := pricing.UsageTokens{CacheHitTokens: *hit, CacheMissTokens: *miss, CompletionTokens: *completion}
amount := pricing.OriginalCostAmount(*card, usage)
fmt.Fprintf(out, "%s %s\n", amount, pricing.NormalizeCurrency(card.Currency))
return nil
}
func runArm(args []string, out io.Writer) error {
fs := flag.NewFlagSet("arm", flag.ContinueOnError)
fs.SetOutput(out)
spec := fs.String("ablate", "", "comma-separated modules to switch off: "+ablation.ModuleList())
foldIndex := fs.String("fold-index", "", "fold-index scale: default, half, quarter or off")
list := fs.Bool("list", false, "list the switchable modules")
if err := fs.Parse(args); err != nil {
return err
}
if *list {
fmt.Fprintln(out, ablation.ModuleList())
return nil
}
set, err := ablation.ParseArm(*spec, *foldIndex)
if err != nil {
return err
}
fmt.Fprintln(out, set.Arm())
return nil
}
@@ -0,0 +1,65 @@
package main
import (
"bytes"
"errors"
"strings"
"testing"
)
func runOut(t *testing.T, args ...string) string {
t.Helper()
var out bytes.Buffer
if err := run(args, &out); err != nil {
t.Fatalf("run %v: %v", args, err)
}
return out.String()
}
func TestRateNamesTheCurrentCard(t *testing.T) {
got := runOut(t, "rate", "-provider", "mimo", "-model", "mimo-v2.5")
if !strings.Contains(got, "mimo/mimo-v2.5 CNY: cache-hit 0.02 input 1 output 2 per 1M") {
t.Fatalf("rate output = %q", got)
}
}
func TestRateRefusesAnUnpricedModel(t *testing.T) {
var out bytes.Buffer
if err := run([]string{"rate", "-provider", "nobody", "-model", "x"}, &out); err == nil {
t.Fatal("an unpriced model must fail")
}
}
func TestCostChargesTheCurrentCard(t *testing.T) {
got := runOut(t, "cost", "-provider", "mimo", "-model", "mimo-v2.5", "-miss", "1000000", "-completion", "1000000")
if strings.TrimSpace(got) != "3 CNY" {
t.Fatalf("cost output = %q", got)
}
}
func TestArmNamesTheConfiguration(t *testing.T) {
if got := strings.TrimSpace(runOut(t, "arm")); got != "full" {
t.Fatalf("control arm = %q", got)
}
if got := strings.TrimSpace(runOut(t, "arm", "-ablate", "planner,evidence", "-fold-index", "half")); got != "no-evidence+no-planner+index-half" {
t.Fatalf("arm = %q", got)
}
}
func TestUnknownSubcommandIsAUsageError(t *testing.T) {
var out bytes.Buffer
if err := run([]string{"nope"}, &out); !errors.Is(err, errUsage) {
t.Fatalf("err = %v, want errUsage", err)
}
}
func TestTariffReadsTheVendorsSchedule(t *testing.T) {
got := runOut(t, "tariff", "-provider", "mimo", "-model", "mimo-v2.5")
if !strings.Contains(got, "official card: true") || !strings.Contains(got, "one rate around the clock") {
t.Fatalf("tariff output = %q", got)
}
peak := runOut(t, "tariff", "-currency", "CNY", "-at", "2026-10-12T02:00:00Z")
if !strings.Contains(peak, "peak=true until 2026-10-12T04:00:00Z") {
t.Fatalf("deepseek tariff at a weekday peak = %q", peak)
}
}
@@ -0,0 +1,49 @@
package main
import (
"flag"
"fmt"
"io"
"time"
"reasonix/internal/contract/pricing"
)
func runTariff(args []string, out io.Writer) error {
fs := flag.NewFlagSet("tariff", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
at := fs.String("at", "", "instant to read the schedule at, RFC 3339; empty is now")
if err := fs.Parse(args); err != nil {
return err
}
when := time.Now()
if *at != "" {
t, err := time.Parse(time.RFC3339, *at)
if err != nil {
return fmt.Errorf("-at: %w", err)
}
when = t
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
fmt.Fprintf(out, "official card: %v\n", pricing.IsOfficialCard(*provider, *model, *card))
tariff, ok := pricing.LiveTariff(*provider, *model, *card, when)
switch {
case !ok:
fmt.Fprintln(out, "schedule: one rate around the clock")
case !tariff.Changes:
fmt.Fprintf(out, "schedule: peak=%v, no change ahead\n", tariff.Peak)
default:
fmt.Fprintf(out, "schedule: peak=%v until %s\n", tariff.Peak, tariff.ChangesAt.UTC().Format(time.RFC3339))
}
return nil
}
@@ -0,0 +1,3 @@
module reasonix
go 1.26.0
@@ -0,0 +1,7 @@
//go:build !windows
package neterr
import "syscall"
var resetErrors = []error{syscall.ECONNRESET, syscall.ECONNABORTED, syscall.EPIPE}
@@ -0,0 +1,8 @@
package neterr
import "syscall"
// syscall.ECONNRESET and friends exist on Windows only as APPLICATION_ERROR
// placeholders, so errors.Is against them never matches a real socket failure.
// Winsock's own numbering carries the identities the runtime returns.
var resetErrors = []error{syscall.WSAECONNRESET, syscall.WSAECONNABORTED, syscall.ERROR_BROKEN_PIPE}
@@ -0,0 +1,47 @@
// Package neterr answers what a transport error is from the identity the
// operating system gave it, never from the sentence it rendered. A system
// error's text arrives in the machine's own language and is reworded between
// releases, so a word list that matches on an English host silently stops
// matching elsewhere. The judgement lives here once because three packages
// were each carrying their own copy of it.
package neterr
import (
"context"
"errors"
"io"
"net"
)
// IsConnReset reports whether err is a connection-level drop (peer reset,
// truncated body, closed socket) as opposed to a protocol or caller error. A
// stream cut this way mid-body can be replayed from scratch, unlike a decode
// or 4xx error. The common trigger is a local proxy (v2rayN/sing-box)
// idle-closing a long-lived SSE connection during a reasoner's first-token gap.
func IsConnReset(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) ||
errors.Is(err, net.ErrClosed) || isAny(err, resetErrors) {
return true
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return false
}
var netErr net.Error
return errors.As(err, &netErr)
}
func isAny(err error, targets []error) bool {
for _, target := range targets {
if errors.Is(err, target) {
return true
}
}
return false
}
@@ -0,0 +1,43 @@
package neterr
import (
"context"
"errors"
"fmt"
"io"
"net"
"testing"
)
func TestIsConnReset(t *testing.T) {
if IsConnReset(nil) {
t.Error("nil is not a conn reset")
}
if IsConnReset(context.Canceled) || IsConnReset(context.DeadlineExceeded) {
t.Error("ctx cancel/deadline must not look like a recoverable reset")
}
if IsConnReset(errors.New("decode stream: invalid character")) {
t.Error("a plain protocol error must not be treated as a conn reset")
}
for _, err := range []error{
io.ErrUnexpectedEOF,
&net.OpError{Op: "read", Err: resetErrors[0]},
fmt.Errorf("read stream: %w", &net.OpError{Op: "read", Err: errors.New("wsarecv: forcibly closed")}),
} {
if !IsConnReset(err) {
t.Errorf("want conn reset for %v", err)
}
}
}
func TestIsConnResetExcludesDNS(t *testing.T) {
for _, dns := range []*net.DNSError{
{Err: "no such host", Name: "api.deepseek.com", IsNotFound: true},
{Err: "i/o timeout", Name: "api.deepseek.com", IsTimeout: true, IsTemporary: true},
} {
err := fmt.Errorf("dial: %w", &net.OpError{Op: "dial", Err: dns})
if IsConnReset(err) {
t.Errorf("DNS failure %v is not a connection reset", dns)
}
}
}
@@ -0,0 +1,17 @@
package nilutil
import "reflect"
// IsNil reports whether v is nil or an interface holding a typed nil value.
func IsNil(v any) bool {
if v == nil {
return true
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return rv.IsNil()
default:
return false
}
}
@@ -0,0 +1,31 @@
package nilutil
import "testing"
type sampleInterface interface {
Do()
}
type sampleImpl struct{}
func (*sampleImpl) Do() {}
func TestIsNilDetectsTypedNilInterface(t *testing.T) {
var p *sampleImpl
var i sampleInterface = p
if !IsNil(i) {
t.Fatal("IsNil should detect typed nil interface")
}
}
func TestIsNilRejectsConcreteValues(t *testing.T) {
var p sampleInterface = &sampleImpl{}
if IsNil(p) {
t.Fatal("IsNil should not reject a concrete interface value")
}
if IsNil("x") {
t.Fatal("IsNil should not reject non-nil scalar values")
}
}
@@ -0,0 +1,218 @@
package retrieval
import (
"fmt"
"math"
"strings"
"unicode"
"unicode/utf8"
)
// Tokens lowercases Latin words and splits CJK runs into overlapping bigrams
// (a lone CJK rune stays a single term) — the standard CJK indexing unit
// (Lucene's CJKAnalyzer, SQLite FTS): a bigram only matches a real two-rune
// subsequence where per-rune unigrams matched scattered common characters.
// Intentionally a local, dependency-free approximation of FTS matching.
func Tokens(s string) []string {
var out []string
var b strings.Builder
var prev rune
cjkRun := 0
flush := func() {
if b.Len() == 0 {
return
}
out = append(out, b.String())
b.Reset()
}
endCJK := func() {
if cjkRun == 1 {
out = append(out, string(prev))
}
cjkRun = 0
}
for _, r := range s {
switch {
case isCJK(r):
flush()
if cjkRun > 0 {
out = append(out, string([]rune{prev, r}))
}
prev = r
cjkRun++
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_':
endCJK()
b.WriteRune(unicode.ToLower(r))
default:
flush()
endCJK()
}
}
flush()
endCJK()
return out
}
func isCJK(r rune) bool {
return unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul)
}
// Unique returns terms in first-seen order.
func Unique(in []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(in))
for _, s := range in {
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
// Counts returns a term-frequency map.
func Counts(terms []string) map[string]int {
counts := map[string]int{}
for _, term := range terms {
counts[term]++
}
return counts
}
// BM25Score scores a document against query terms.
func BM25Score(counts map[string]int, length int, queryTerms []string, df map[string]int, totalDocs int, avgLen float64) float64 {
const (
k1 = 1.2
b = 0.75
)
if length <= 0 || totalDocs <= 0 {
return 0
}
if avgLen <= 0 {
avgLen = 1
}
var score float64
docLen := float64(length)
for _, term := range queryTerms {
tf := counts[term]
if tf == 0 {
continue
}
termDF := df[term]
if termDF == 0 {
continue
}
idf := math.Log(1 + (float64(totalDocs)-float64(termDF)+0.5)/(float64(termDF)+0.5))
freq := float64(tf)
score += idf * (freq * (k1 + 1)) / (freq + k1*(1-b+b*docLen/avgLen))
}
return score
}
// DocumentFrequency counts how many documents contain each term.
func DocumentFrequency(docs []map[string]int) map[string]int {
df := map[string]int{}
for _, counts := range docs {
for term := range counts {
df[term]++
}
}
return df
}
// KeepTopRelativeScore keeps the best item and drops trailing items whose score
// falls below ratio * topScore. Callers must pass items already sorted best
// first. This mirrors SQLite FTS/BM25 search UIs that over-fetch, then trim
// common-word-only noise without imposing an absolute score threshold.
func KeepTopRelativeScore[T any](items []T, ratio float64, score func(T) float64) []T {
if len(items) == 0 || ratio <= 0 {
return items
}
top := score(items[0])
if top <= 0 {
return items
}
cutoff := top * ratio
out := items[:0]
for i, item := range items {
if i == 0 || score(item) >= cutoff {
out = append(out, item)
}
}
return out
}
// QueryTerms normalizes a search string and reports an error when nothing
// searchable remains.
func QueryTerms(query string) ([]string, error) {
terms := Unique(Tokens(strings.TrimSpace(query)))
if len(terms) == 0 {
return nil, fmt.Errorf("query must contain at least one letter or number")
}
return terms, nil
}
// MakeSnippet returns a whitespace-compacted excerpt centered near the query.
func MakeSnippet(text, query string, terms []string, maxRunes int) string {
text = CompactWhitespace(text)
if maxRunes <= 0 || utf8.RuneCountInString(text) <= maxRunes {
return text
}
lower := strings.ToLower(text)
query = strings.ToLower(strings.TrimSpace(query))
idx := -1
if query != "" {
idx = strings.Index(lower, query)
}
if idx < 0 {
for _, term := range terms {
runes := []rune(term)
if len(runes) == 1 && !isCJK(runes[0]) {
continue
}
if i := strings.Index(lower, term); i >= 0 {
idx = i
break
}
}
}
if idx < 0 {
idx = 0
}
return snippetAround(text, idx, maxRunes)
}
func snippetAround(text string, byteIdx, maxRunes int) string {
if byteIdx < 0 {
byteIdx = 0
}
if byteIdx > len(text) {
byteIdx = len(text)
}
for byteIdx > 0 && byteIdx < len(text) && !utf8.RuneStart(text[byteIdx]) {
byteIdx--
}
runes := []rune(text)
pos := utf8.RuneCountInString(text[:byteIdx])
start := max(pos-maxRunes/2, 0)
end := start + maxRunes
if end > len(runes) {
end = len(runes)
start = max(end-maxRunes, 0)
}
prefix := ""
suffix := ""
if start > 0 {
prefix = "..."
}
if end < len(runes) {
suffix = "..."
}
return prefix + string(runes[start:end]) + suffix
}
// CompactWhitespace collapses runs of whitespace into one ASCII space.
func CompactWhitespace(s string) string {
return strings.Join(strings.Fields(s), " ")
}
@@ -0,0 +1,77 @@
package retrieval
import (
"strings"
"testing"
"unicode/utf8"
)
func TestTokensHandlesLatinAndCJK(t *testing.T) {
for _, tc := range []struct {
in string
want []string
}{
{"BM25 检索 cache-first", []string{"bm25", "检索", "cache", "first"}},
{"数据库迁移", []string{"数据", "据库", "库迁", "迁移"}},
{"库", []string{"库"}},
{"用pnpm装依赖", []string{"用", "pnpm", "装依", "依赖"}},
} {
got := Tokens(tc.in)
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Fatalf("Tokens(%q) = %#v, want %#v", tc.in, got, tc.want)
}
}
}
func TestBM25ScoreRanksMatchingDocument(t *testing.T) {
query := Unique(Tokens("prompt cache"))
doc1 := Counts(Tokens("prompt cache cache stability"))
doc2 := Counts(Tokens("dashboard colors"))
df := DocumentFrequency([]map[string]int{doc1, doc2})
score1 := BM25Score(doc1, 4, query, df, 2, 3)
score2 := BM25Score(doc2, 2, query, df, 2, 3)
if score1 <= score2 {
t.Fatalf("matching score %.3f should exceed unrelated score %.3f", score1, score2)
}
}
func TestKeepTopRelativeScoreKeepsTopAndDropsWeakTail(t *testing.T) {
items := []struct {
name string
score float64
}{
{name: "top", score: 10},
{name: "near", score: 2},
{name: "noise", score: 1.4},
{name: "zero", score: 0},
}
got := KeepTopRelativeScore(items, 0.15, func(item struct {
name string
score float64
}) float64 {
return item.score
})
if len(got) != 2 || got[0].name != "top" || got[1].name != "near" {
t.Fatalf("KeepTopRelativeScore() = %#v, want top and near", got)
}
}
func TestMakeSnippetHandlesMultibyteBoundary(t *testing.T) {
text := strings.Repeat("前缀", 80) + "稳定结论 synthesis cache " + strings.Repeat("后缀", 80)
out := MakeSnippet(text, "synthesis cache", QueryTermsForTest(t, "synthesis cache"), 60)
if !strings.Contains(out, "synthesis cache") {
t.Fatalf("snippet missing query: %q", out)
}
if strings.ContainsRune(out, utf8.RuneError) {
t.Fatalf("snippet contains replacement rune: %q", out)
}
}
func QueryTermsForTest(t *testing.T, query string) []string {
t.Helper()
terms, err := QueryTerms(query)
if err != nil {
t.Fatal(err)
}
return terms
}
@@ -0,0 +1,176 @@
// Retrieval V2, shadow-only: BM25F field weighting, code-symbol splitting,
// and mixed CJK uni+bigrams. Nothing here serves the model — V2 rankings ride
// the recall audit next to production's, and MemoryBench decides if V2 ever
// takes over. Weights are candidates under measurement, not tuned truths.
package retrieval
import (
"math"
"strings"
"unicode"
)
// TokensV2 extends Tokens with code-symbol structure and CJK unigrams:
// CamelCase and snake_case words also emit their segments (FooBar → foobar,
// foo, bar), and CJK runs emit unigrams alongside bigrams so one-character
// queries still match. The V1 form of every token is preserved, so V2 recall
// is a superset of V1's vocabulary.
func TokensV2(s string) []string {
var out []string
var word []rune
var prev rune
cjkRun := 0
flushWord := func() {
if len(word) == 0 {
return
}
lower := strings.ToLower(string(word))
out = append(out, lower)
for _, seg := range splitCodeSymbol(word) {
if seg != lower {
out = append(out, seg)
}
}
word = word[:0]
}
endCJK := func() { cjkRun = 0 }
for _, r := range s {
switch {
case isCJK(r):
flushWord()
out = append(out, string(r))
if cjkRun > 0 {
out = append(out, string([]rune{prev, r}))
}
prev = r
cjkRun++
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_':
endCJK()
word = append(word, r)
default:
flushWord()
endCJK()
}
}
flushWord()
return out
}
// splitCodeSymbol yields the lowercase segments of a code identifier:
// CamelCase humps, snake_case parts, and letter/digit boundaries.
func splitCodeSymbol(word []rune) []string {
var segments []string
var seg []rune
flush := func() {
if len(seg) > 1 { // single letters are noise, not signal
segments = append(segments, strings.ToLower(string(seg)))
}
seg = seg[:0]
}
for i, r := range word {
boundary := r == '_' ||
(i > 0 && unicode.IsUpper(r) && !unicode.IsUpper(word[i-1])) ||
(i > 0 && unicode.IsDigit(r) != unicode.IsDigit(word[i-1]))
if boundary {
flush()
}
if r != '_' {
seg = append(seg, r)
}
}
flush()
if len(segments) < 2 {
return nil // no structure worth indexing beyond the whole word
}
return segments
}
// FieldedDoc is one document split into weighted fields for BM25F.
type FieldedDoc struct {
ID string
Fields map[string]string
}
// v2FieldWeights follow the BM25F intuition: identity fields say what a fact
// IS, the body says everything it mentions. Candidates under measurement.
var v2FieldWeights = map[string]float64{
"name": 2.5, "title": 2.5, "keywords": 2.0, "subject": 2.0,
"description": 1.5, "body": 1.0,
}
// V2Hit is one shadow-ranked document.
type V2Hit struct {
ID string
Score float64
}
// RankV2 scores docs against a query with BM25F over TokensV2: term
// frequencies accumulate per field scaled by field weight, then the standard
// saturation applies once per term (the F in BM25F), with length
// normalization over the weighted document length.
func RankV2(query string, docs []FieldedDoc) []V2Hit {
queryTerms := Unique(TokensV2(query))
if len(queryTerms) == 0 || len(docs) == 0 {
return nil
}
type indexed struct {
id string
tf map[string]float64
length float64
}
corpus := make([]indexed, 0, len(docs))
df := map[string]int{}
var totalLen float64
for _, doc := range docs {
ix := indexed{id: doc.ID, tf: map[string]float64{}}
seen := map[string]bool{}
for field, text := range doc.Fields {
weight, ok := v2FieldWeights[field]
if !ok {
weight = 1.0
}
for _, term := range TokensV2(text) {
ix.tf[term] += weight
ix.length += weight
seen[term] = true
}
}
for term := range seen {
df[term]++
}
totalLen += ix.length
corpus = append(corpus, ix)
}
avgLen := totalLen / float64(len(corpus))
if avgLen <= 0 {
avgLen = 1
}
const k1, b = 1.2, 0.75
var hits []V2Hit
for _, doc := range corpus {
var score float64
for _, term := range queryTerms {
tf := doc.tf[term]
if tf == 0 || df[term] == 0 {
continue
}
idf := math.Log(1 + (float64(len(corpus))-float64(df[term])+0.5)/(float64(df[term])+0.5))
score += idf * (tf * (k1 + 1)) / (tf + k1*(1-b+b*doc.length/avgLen))
}
if score > 0 {
hits = append(hits, V2Hit{ID: doc.id, Score: score})
}
}
SortHitsDesc(hits)
return hits
}
// SortHitsDesc orders shadow hits best-first with a stable ID tiebreak.
func SortHitsDesc(hits []V2Hit) {
for i := 1; i < len(hits); i++ {
for j := i; j > 0 && (hits[j].Score > hits[j-1].Score ||
(hits[j].Score == hits[j-1].Score && hits[j].ID < hits[j-1].ID)); j-- {
hits[j], hits[j-1] = hits[j-1], hits[j]
}
}
}
@@ -0,0 +1,47 @@
package retrieval
import (
"strings"
"testing"
)
func TestTokensV2CodeSymbolsAndCJK(t *testing.T) {
got := strings.Join(TokensV2("RetryPolicy.MaxBackoffMs ticket_1827 数据库"), " ")
for _, want := range []string{"retrypolicy", "retry", "policy", "maxbackoffms", "max", "backoff", "ticket_1827", "ticket", "1827", "数", "据", "库", "数据", "据库"} {
if !strings.Contains(" "+got+" ", " "+want+" ") {
t.Fatalf("TokensV2 missing %q in %q", want, got)
}
}
if strings.Contains(" "+got+" ", " ms ") {
// "Ms" splits to a single meaningful segment only when len > 1; "ms" is
// fine — assert single letters never appear instead.
t.Log("ms segment present (acceptable)")
}
for _, tok := range TokensV2("A B C") {
if len(tok) == 0 {
t.Fatal("empty token emitted")
}
}
}
func TestRankV2FieldWeightsBeatBodyMentions(t *testing.T) {
docs := []FieldedDoc{
{ID: "titled", Fields: map[string]string{"title": "Package manager", "body": "We migrated to pnpm."}},
{ID: "mention", Fields: map[string]string{"title": "Sprint notes", "body": "Someone asked about the package manager once; also weather, lunch, and a package of stickers."}},
}
hits := RankV2("package manager", docs)
if len(hits) != 2 || hits[0].ID != "titled" {
t.Fatalf("field-weighted doc must outrank body mentions: %+v", hits)
}
}
func TestRankV2FindsCodeSymbolBySegment(t *testing.T) {
docs := []FieldedDoc{
{ID: "sym", Fields: map[string]string{"body": "Configure RetryPolicy.MaxBackoffMs in policy/retry.toml."}},
{ID: "other", Fields: map[string]string{"body": "Unrelated fact about deploys."}},
}
hits := RankV2("max backoff configuration", docs)
if len(hits) == 0 || hits[0].ID != "sym" {
t.Fatalf("segment query must find the camel-case symbol: %+v", hits)
}
}
@@ -0,0 +1,7 @@
// Package rulelist splits a list of permission rules written on one line.
//
// Rules are separated by commas or whitespace, and a separator inside
// parentheses belongs to the rule: `Bash(git add:*) Read` is two rules. Skill
// front matter and the --allowed-tools flag share it, so one spelling means
// one thing in both.
package rulelist
@@ -0,0 +1,56 @@
package rulelist
import (
"errors"
"strings"
"unicode"
)
var (
// ErrUnclosed: an opening parenthesis is never closed.
ErrUnclosed = errors.New("unclosed '('")
// ErrUnexpectedClose: a closing parenthesis has no opening one.
ErrUnexpectedClose = errors.New("unexpected ')'")
)
// Split returns the rules in value, in order, trimmed and non-empty.
func Split(value string) ([]string, error) {
var rules []string
start, depth := -1, 0
flush := func(end int) {
if start < 0 {
return
}
if rule := strings.TrimSpace(value[start:end]); rule != "" {
rules = append(rules, rule)
}
start = -1
}
for i, r := range value {
switch r {
case '(':
if start < 0 {
start = i
}
depth++
case ')':
if depth == 0 {
return nil, ErrUnexpectedClose
}
depth--
default:
if depth == 0 && (r == ',' || unicode.IsSpace(r)) {
flush(i)
continue
}
if start < 0 {
start = i
}
}
}
if depth != 0 {
return nil, ErrUnclosed
}
flush(len(value))
return rules, nil
}
@@ -0,0 +1,40 @@
package rulelist
import (
"errors"
"reflect"
"testing"
)
func TestSplitSeparatesOnCommasAndWhitespaceOutsideParentheses(t *testing.T) {
for _, tc := range []struct {
in string
want []string
}{
{"", nil},
{" ", nil},
{"Read", []string{"Read"}},
{"read_file, grep,bash", []string{"read_file", "grep", "bash"}},
{"Read Grep Bash", []string{"Read", "Grep", "Bash"}},
{"Bash(git add:*) Read", []string{"Bash(git add:*)", "Read"}},
{"Bash(git add:*, git commit:*), Read", []string{"Bash(git add:*, git commit:*)", "Read"}},
{"bash,", []string{"bash"}},
} {
got, err := Split(tc.in)
if err != nil {
t.Fatalf("Split(%q): %v", tc.in, err)
}
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("Split(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestSplitReportsUnbalancedParenthesesByIdentity(t *testing.T) {
if _, err := Split("Bash(git add"); !errors.Is(err, ErrUnclosed) {
t.Errorf("unclosed = %v", err)
}
if _, err := Split("Bash)"); !errors.Is(err, ErrUnexpectedClose) {
t.Errorf("unexpected close = %v", err)
}
}
@@ -0,0 +1,25 @@
// Package tokencount estimates how many tokens a string costs a model, without
// carrying a tokenizer. Anything that has to size text before a provider counts
// it — a cache-shape diagnostic, a tool call's context footprint — needs one
// answer to that question rather than a private heuristic per caller.
package tokencount
import "unicode/utf8"
// Text estimates the token count of s. The split is by script because the two
// behave nothing alike — Latin text and code trend near four bytes per token,
// CJK closer to one token per character — and either rule applied to the whole
// string is wrong by ~4x on the other half. A real tokenizer would be exact and
// would mean a vocabulary per model, re-run over whole transcripts every turn.
func Text(s string) int {
narrow, wide := 0, 0
for _, r := range s {
if r < utf8.RuneSelf {
narrow++
continue
}
wide++
}
// Round up: a long run of short strings must not sum to nothing.
return (narrow+3)/4 + wide
}
@@ -0,0 +1,27 @@
package tokencount
import "testing"
func TestTextDoesNotUndercountCJK(t *testing.T) {
// The reason this package exists: bytes/4 reads a Chinese transcript as a
// third of its real size, so a planner sizing context off it compacts late.
const cjk = "把这个仓库跑一遍测试,把失败的那几个定位到具体文件"
runes := len([]rune(cjk))
if got := Text(cjk); got != runes {
t.Fatalf("Text(cjk) = %d, want one token per rune (%d)", got, runes)
}
if byBytes := len(cjk) / 4; Text(cjk) <= byBytes {
t.Fatalf("Text(cjk) = %d, must exceed the bytes/4 reading (%d)", Text(cjk), byBytes)
}
}
func TestTextRoundsASCIIUpAndKeepsEmptyFree(t *testing.T) {
if got := Text(""); got != 0 {
t.Fatalf("Text(\"\") = %d, want 0", got)
}
// Five bytes is more than one token's worth; truncating would let a long
// run of short strings sum to nothing.
if got := Text("abcde"); got != 2 {
t.Fatalf("Text(\"abcde\") = %d, want 2", got)
}
}
@@ -0,0 +1,200 @@
// Package ablation switches individual Reasonix subsystems off so a benchmark
// can attribute a change in solve rate to one of them.
package ablation
import (
"fmt"
"sort"
"strings"
)
type Module string
const (
Evidence Module = "evidence"
Planner Module = "planner"
Subagent Module = "subagent"
Retrieval Module = "retrieval"
Compaction Module = "compaction"
// Upstream off means a fleet dependency edge orders its endpoints without
// delivering the dependency's answer, so a dependent must re-derive it.
Upstream Module = "upstream"
// RecallSearch off leaves recall able to read an address it was given and
// unable to look one up, which is what the tool could do before the folded
// region became searchable.
RecallSearch Module = "recall-search"
)
// FoldIndexScale sizes the model-visible fold index against its shipped budget.
// It is a bench axis, not a module: the question it answers is what an ambient
// address list is worth, and four steps answer that where a free-form fraction
// would turn a measurement into a tuning knob.
type FoldIndexScale string
const (
FoldIndexDefault FoldIndexScale = ""
FoldIndexHalf FoldIndexScale = "half"
FoldIndexQuarter FoldIndexScale = "quarter"
FoldIndexOff FoldIndexScale = "off"
)
// Ratio scales a budget. The default returns it unchanged.
func (f FoldIndexScale) Ratio() float64 {
switch f {
case FoldIndexHalf:
return 0.5
case FoldIndexQuarter:
return 0.25
case FoldIndexOff:
return 0
default:
return 1
}
}
// ParseFoldIndexScale reads a bench flag value. "" and "default" ship as-is.
func ParseFoldIndexScale(spec string) (FoldIndexScale, error) {
switch strings.ToLower(strings.TrimSpace(spec)) {
case "", "default", "full":
return FoldIndexDefault, nil
case string(FoldIndexHalf):
return FoldIndexHalf, nil
case string(FoldIndexQuarter):
return FoldIndexQuarter, nil
case string(FoldIndexOff), "none", "0":
return FoldIndexOff, nil
default:
return "", fmt.Errorf("unknown fold-index scale %q (want default, half, quarter, or off)", spec)
}
}
// Modules returns every switchable module in the order arm names use.
func Modules() []Module {
return []Module{Evidence, Planner, Subagent, Retrieval, Compaction, Upstream, RecallSearch}
}
// Set is the group of modules disabled for a run. The zero value is the
// control arm: everything on.
type Set struct {
off map[Module]bool
// foldIndex is an axis rather than a switch, so it lives beside the module
// set instead of inside it. It still reaches Arm(): a run whose index was
// quartered must not report the same identity as the control.
foldIndex FoldIndexScale
}
// WithFoldIndex returns this set at the given index scale.
func (s Set) WithFoldIndex(scale FoldIndexScale) Set {
s.foldIndex = scale
return s
}
// FoldIndex is the scale this run sizes its fold index at.
func (s Set) FoldIndex() FoldIndexScale { return s.foldIndex }
// Parse reads a spec such as "evidence,planner". "" and "none" mean the control
// arm; "all" disables every module.
func Parse(spec string) (Set, error) {
spec = strings.TrimSpace(spec)
if spec == "" || strings.EqualFold(spec, "none") {
return Set{}, nil
}
if strings.EqualFold(spec, "all") {
return New(Modules()...), nil
}
known := map[Module]bool{}
for _, m := range Modules() {
known[m] = true
}
var mods []Module
for _, field := range strings.FieldsFunc(spec, func(r rune) bool { return r == ',' || r == ' ' }) {
m := Module(strings.ToLower(strings.TrimSpace(field)))
if !known[m] {
return Set{}, fmt.Errorf("unknown ablation module %q (want %s, or none/all)", field, ModuleList())
}
mods = append(mods, m)
}
return New(mods...), nil
}
// ParseArm reads both bench axes at once: the module spec and the fold-index
// scale. One call so a frontend cannot wire the first and forget the second,
// which would silently run an index arm under the control arm's name.
func ParseArm(modules, foldIndex string) (Set, error) {
set, err := Parse(modules)
if err != nil {
return Set{}, err
}
scale, err := ParseFoldIndexScale(foldIndex)
if err != nil {
return Set{}, err
}
return set.WithFoldIndex(scale), nil
}
// ModuleList names every switchable module for flag help and error text, so
// those cannot drift from Modules() the way a hand-kept list does.
func ModuleList() string { return joinModules(Modules(), ", ") }
// New returns a Set with the given modules disabled.
func New(mods ...Module) Set {
if len(mods) == 0 {
return Set{}
}
off := make(map[Module]bool, len(mods))
for _, m := range mods {
off[m] = true
}
return Set{off: off}
}
func (s Set) Off(m Module) bool { return s.off[m] }
func (s Set) Empty() bool { return len(s.off) == 0 && s.foldIndex == FoldIndexDefault }
// Arm is the published name of this configuration: "full" for the control arm,
// otherwise "no-evidence+no-planner". Stable across runs so results from
// different machines group by the same key.
func (s Set) Arm() string {
if s.Empty() {
return "full"
}
parts := make([]string, 0, len(s.off)+1)
for _, m := range s.disabled() {
parts = append(parts, "no-"+string(m))
}
if s.foldIndex != FoldIndexDefault {
parts = append(parts, "index-"+string(s.foldIndex))
}
return strings.Join(parts, "+")
}
// String round-trips back through Parse. It carries modules only — the fold
// index axis has its own flag and its own parser.
func (s Set) String() string {
if s.Empty() {
return "none"
}
return joinModules(s.disabled(), ",")
}
func (s Set) disabled() []Module {
order := map[Module]int{}
for i, m := range Modules() {
order[m] = i
}
out := make([]Module, 0, len(s.off))
for m := range s.off {
out = append(out, m)
}
sort.Slice(out, func(i, j int) bool { return order[out[i]] < order[out[j]] })
return out
}
func joinModules(mods []Module, sep string) string {
parts := make([]string, len(mods))
for i, m := range mods {
parts[i] = string(m)
}
return strings.Join(parts, sep)
}
@@ -0,0 +1,108 @@
package ablation
import "testing"
func TestParseAcceptsSpecFormsAndRejectsUnknownModules(t *testing.T) {
for _, tc := range []struct {
spec string
arm string
}{
{"", "full"},
{"none", "full"},
{"NONE", "full"},
{"evidence", "no-evidence"},
{"planner,evidence", "no-evidence+no-planner"},
{"planner evidence", "no-evidence+no-planner"},
{" Evidence , Retrieval ", "no-evidence+no-retrieval"},
{"all", "no-evidence+no-planner+no-subagent+no-retrieval+no-compaction+no-upstream+no-recall-search"},
} {
set, err := Parse(tc.spec)
if err != nil {
t.Fatalf("Parse(%q): %v", tc.spec, err)
}
if got := set.Arm(); got != tc.arm {
t.Errorf("Parse(%q).Arm() = %q, want %q", tc.spec, got, tc.arm)
}
}
if _, err := Parse("evidence,planer"); err == nil {
t.Fatal("a misspelled module must fail loudly, not silently run the control arm")
}
}
func TestArmNameIsIndependentOfSpecOrder(t *testing.T) {
a, _ := Parse("retrieval,evidence,planner")
b, _ := Parse("planner,retrieval,evidence")
if a.Arm() != b.Arm() {
t.Fatalf("arm names diverge by input order: %q vs %q", a.Arm(), b.Arm())
}
}
func TestStringRoundTripsThroughParse(t *testing.T) {
for _, spec := range []string{"none", "evidence", "evidence,compaction", "all"} {
set, err := Parse(spec)
if err != nil {
t.Fatalf("Parse(%q): %v", spec, err)
}
again, err := Parse(set.String())
if err != nil {
t.Fatalf("Parse(%q): %v", set.String(), err)
}
if again.Arm() != set.Arm() {
t.Errorf("%q round-tripped to %q", set.Arm(), again.Arm())
}
}
}
// The index axis is not a module, but a run that moved it is not the control
// arm either: results keyed by arm name would otherwise merge with it.
func TestFoldIndexScaleNamesItsOwnArm(t *testing.T) {
var s Set
if got := s.WithFoldIndex(FoldIndexQuarter).Arm(); got != "index-quarter" {
t.Errorf("Arm() = %q, want index-quarter", got)
}
if got := New(Evidence).WithFoldIndex(FoldIndexOff).Arm(); got != "no-evidence+index-off" {
t.Errorf("Arm() = %q, want no-evidence+index-off", got)
}
if s.WithFoldIndex(FoldIndexQuarter).Empty() {
t.Error("a quartered index reported itself as the control arm")
}
if !s.WithFoldIndex(FoldIndexDefault).Empty() {
t.Error("the default scale is the control arm")
}
}
func TestFoldIndexScaleRatios(t *testing.T) {
for _, tc := range []struct {
spec string
scale FoldIndexScale
ratio float64
}{
{"", FoldIndexDefault, 1}, {"default", FoldIndexDefault, 1},
{"half", FoldIndexHalf, 0.5}, {"QUARTER", FoldIndexQuarter, 0.25},
{"off", FoldIndexOff, 0}, {"0", FoldIndexOff, 0},
} {
got, err := ParseFoldIndexScale(tc.spec)
if err != nil {
t.Fatalf("ParseFoldIndexScale(%q): %v", tc.spec, err)
}
if got != tc.scale || got.Ratio() != tc.ratio {
t.Errorf("ParseFoldIndexScale(%q) = %q (ratio %v), want %q (%v)", tc.spec, got, got.Ratio(), tc.scale, tc.ratio)
}
}
if _, err := ParseFoldIndexScale("0.37"); err == nil {
t.Fatal("a free-form fraction was accepted; the axis is four steps on purpose")
}
}
func TestZeroValueIsTheControlArm(t *testing.T) {
var s Set
if !s.Empty() || s.Arm() != "full" {
t.Fatalf("zero Set = %q, want the full control arm", s.Arm())
}
for _, m := range Modules() {
if s.Off(m) {
t.Errorf("zero Set disabled %s", m)
}
}
}
@@ -0,0 +1,15 @@
// Package agentgraph is the vocabulary of one run's execution graph: the nodes
// a turn handed work to, and the typed edges between them.
//
// It exists because that structure was being re-derived in five places and
// survived in none. A fleet's dependency DAG died with the call that built it,
// the persisted sidecar recorded a parent link and nothing else, and the event
// stream implied edges through id prefixes. A reader asking why a node waited
// could not tell an unmet dependency from a delivered answer, because neither
// had ever been written down as anything a reader could tell apart.
//
// Nothing here knows what an agent, a tool, or a provider is. A producer
// publishes a Delta of what it has just proven; every consumer folds deltas
// with the same Apply, so the graph a headless run reports is the graph the
// desktop draws.
package agentgraph
@@ -0,0 +1,219 @@
package agentgraph
import "slices"
// NodeKind separates the three things a node can be. A group is the call that
// owns a fan-out, a worker is one delegated run, and an external node is work
// this run did not do — named only because an edge points at it.
type NodeKind string
const (
KindGroup NodeKind = "group"
KindWorker NodeKind = "worker"
KindExternal NodeKind = "external"
)
// NodeState is where one node stands. It is the node's own lifecycle and not a
// tool call's: an adopted node never ran and still holds an answer, and a
// skipped one never started because something upstream did not finish.
type NodeState string
const (
StatePending NodeState = "pending"
// StateQueued is ready and waiting for a session concurrency slot. It is
// not StatePending: nothing upstream is missing, and the only thing between
// this node and its first step is the scheduler's ceiling.
StateQueued NodeState = "queued"
StateRunning NodeState = "running"
StateCompleted NodeState = "completed"
StateAdopted NodeState = "adopted"
StateFailed NodeState = "failed"
StateCancelled NodeState = "cancelled"
StateSkipped NodeState = "skipped"
)
// Answered reports whether this state holds a final answer a dependent may
// start from: one the node produced, or one adopted in its place.
func (s NodeState) Answered() bool { return s == StateCompleted || s == StateAdopted }
// Terminal reports whether the node will not change state again.
func (s NodeState) Terminal() bool {
return s != StatePending && s != StateQueued && s != StateRunning
}
// Grant is what a node was allowed to touch while it ran. Empty means the
// question does not apply — a group is not a run, and an adopted node never got
// one — which a bool could not say apart from "may write".
type Grant string
const (
GrantRead Grant = "read"
GrantWrite Grant = "write"
)
// WaitCause is why a queued node is not running. Queued says a node is ready and
// held back; it does not say by what, and the answers have different remedies —
// two are session settings and the third is a path or an edge.
type WaitCause string
const (
// WaitSlots is the session's total sub-agent ceiling.
WaitSlots WaitCause = "slots"
// WaitWriters is the session's concurrent-writer ceiling, which binds a
// writer while total capacity is still free.
WaitWriters WaitCause = "writers"
// WaitClaim is a write path someone else already holds. No ceiling change
// releases it: only disjoint paths, or an edge that orders the two.
WaitClaim WaitCause = "claim"
)
// EdgeKind is what one edge means. The node pair alone says nothing: waiting
// for an answer and receiving one are separate facts, and an arm that orders
// two nodes while delivering nothing between them is the case a single edge
// type cannot describe.
type EdgeKind string
const (
// Spawn is structural: To was created by From.
Spawn EdgeKind = "spawn"
// Depends orders: To may not start until From has answered.
Depends EdgeKind = "depends"
// Context carries: From's answer opened To's run.
Context EdgeKind = "context"
// Adopt reuses: From's existing answer stood in for running To, which
// therefore cost nothing. From is usually outside this graph.
Adopt EdgeKind = "adopt"
)
// Node is one participant in a run. Zero fields mean "not learned yet", which
// is what lets a producer report an outcome without restating the identity it
// declared when the node entered the graph.
type Node struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
Kind NodeKind `json:"kind,omitempty"`
State NodeState `json:"state,omitempty"`
Label string `json:"label,omitempty"`
Profile string `json:"profile,omitempty"`
Model string `json:"model,omitempty"`
Effort string `json:"effort,omitempty"`
Grant Grant `json:"grant,omitempty"`
// Wait is what held this node out of a slot. Like the stamps it survives the
// run starting: the state says it is running now, this says what it waited on.
Wait WaitCause `json:"wait,omitempty"`
// Ref is the persisted transcript this node's answer reads back from.
Ref string `json:"ref,omitempty"`
// QueuedAt, StartedAt and EndedAt are unix milliseconds, zero while unknown.
// QueuedAt to StartedAt is slot wait, which only the scheduler can measure.
QueuedAt int64 `json:"queuedAt,omitempty"`
StartedAt int64 `json:"startedAt,omitempty"`
EndedAt int64 `json:"endedAt,omitempty"`
Err string `json:"err,omitempty"`
}
// Edge is one typed relation between two node ids. From may name a node outside
// this graph: an adopted answer's source is work some earlier run did.
type Edge struct {
From string `json:"from"`
To string `json:"to"`
Kind EdgeKind `json:"kind"`
}
// Delta is one publication of what a producer has just proven. It is additive:
// a node named again updates the one already there rather than forking into a
// second, and a repeated edge is the same edge.
type Delta struct {
Nodes []Node `json:"nodes,omitempty"`
Edges []Edge `json:"edges,omitempty"`
}
// Empty reports whether this delta carries nothing worth publishing.
func (d Delta) Empty() bool { return len(d.Nodes) == 0 && len(d.Edges) == 0 }
// Graph is the folded result. Nodes and edges keep first-seen order so two
// consumers fed the same deltas draw the same picture. Lookups scan rather than
// index: a graph is bounded by one call's fan-out, and a map would have to be
// rebuilt every time the value crossed a wire.
type Graph struct {
Nodes []Node `json:"nodes,omitempty"`
Edges []Edge `json:"edges,omitempty"`
}
// Apply folds one delta into the graph.
func (g *Graph) Apply(d Delta) {
for _, n := range d.Nodes {
g.upsert(n)
}
for _, e := range d.Edges {
g.link(e)
}
}
// Node returns the node with this id.
func (g *Graph) Node(id string) (Node, bool) {
if i := g.indexOf(id); i >= 0 {
return g.Nodes[i], true
}
return Node{}, false
}
func (g *Graph) indexOf(id string) int {
return slices.IndexFunc(g.Nodes, func(n Node) bool { return n.ID == id })
}
func (g *Graph) upsert(n Node) {
if n.ID == "" {
return
}
if i := g.indexOf(n.ID); i >= 0 {
g.Nodes[i] = merge(g.Nodes[i], n)
return
}
g.Nodes = append(g.Nodes, n)
}
// merge keeps what the graph already knew wherever the update says nothing, so
// an outcome-only update cannot blank the identity an earlier delta declared.
func merge(old, update Node) Node {
out := update
out.ParentID = orText(update.ParentID, old.ParentID)
out.Kind = NodeKind(orText(string(update.Kind), string(old.Kind)))
out.State = NodeState(orText(string(update.State), string(old.State)))
out.Label = orText(update.Label, old.Label)
out.Profile = orText(update.Profile, old.Profile)
out.Model = orText(update.Model, old.Model)
out.Effort = orText(update.Effort, old.Effort)
out.Ref = orText(update.Ref, old.Ref)
out.Err = orText(update.Err, old.Err)
out.Grant = Grant(orText(string(update.Grant), string(old.Grant)))
out.Wait = WaitCause(orText(string(update.Wait), string(old.Wait)))
out.QueuedAt = orStamp(update.QueuedAt, old.QueuedAt)
out.StartedAt = orStamp(update.StartedAt, old.StartedAt)
out.EndedAt = orStamp(update.EndedAt, old.EndedAt)
return out
}
// link adds an edge once. A self-edge or a half-named one is a producer bug the
// graph refuses to record rather than render as a loop nothing can explain.
func (g *Graph) link(e Edge) {
if e.From == "" || e.To == "" || e.Kind == "" || e.From == e.To {
return
}
if !slices.Contains(g.Edges, e) {
g.Edges = append(g.Edges, e)
}
}
func orText(update, old string) string {
if update != "" {
return update
}
return old
}
func orStamp(update, old int64) int64 {
if update != 0 {
return update
}
return old
}
@@ -0,0 +1,153 @@
package agentgraph
import (
"encoding/json"
"slices"
"testing"
)
func TestApplyUpsertsNodesByID(t *testing.T) {
var g Graph
g.Apply(Delta{Nodes: []Node{
{ID: "a", Kind: KindWorker, State: StatePending, Label: "research", Profile: "explore", Grant: GrantRead},
}})
// An outcome-only update is what a producer sends once the node settles.
g.Apply(Delta{Nodes: []Node{{ID: "a", State: StateCompleted, Ref: "sub-1", EndedAt: 42}}})
if len(g.Nodes) != 1 {
t.Fatalf("nodes = %d, want 1 (the update forked the node)", len(g.Nodes))
}
n, ok := g.Node("a")
if !ok {
t.Fatal("node a missing")
}
if n.State != StateCompleted || n.Ref != "sub-1" || n.EndedAt != 42 {
t.Fatalf("update did not land: %+v", n)
}
if n.Label != "research" || n.Profile != "explore" || n.Kind != KindWorker || n.Grant != GrantRead {
t.Fatalf("outcome-only update blanked declared identity: %+v", n)
}
}
func TestApplyIgnoresUnusableRecords(t *testing.T) {
cases := []struct {
name string
delta Delta
}{
{"node without id", Delta{Nodes: []Node{{State: StateRunning}}}},
{"edge without source", Delta{Edges: []Edge{{To: "b", Kind: Depends}}}},
{"edge without target", Delta{Edges: []Edge{{From: "a", Kind: Depends}}}},
{"edge without kind", Delta{Edges: []Edge{{From: "a", To: "b"}}}},
{"self edge", Delta{Edges: []Edge{{From: "a", To: "a", Kind: Depends}}}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
var g Graph
g.Apply(c.delta)
if len(g.Nodes) != 0 || len(g.Edges) != 0 {
t.Fatalf("recorded %+v", g)
}
})
}
}
func TestApplyKeepsEdgesDistinctByKind(t *testing.T) {
var g Graph
// depends and context join the same pair and are not the same fact: an arm
// that orders without delivering emits the first and not the second.
edges := []Edge{
{From: "a", To: "b", Kind: Depends},
{From: "a", To: "b", Kind: Context},
{From: "a", To: "b", Kind: Depends},
}
g.Apply(Delta{Edges: edges})
if len(g.Edges) != 2 {
t.Fatalf("edges = %v, want depends and context once each", g.Edges)
}
for _, want := range []Edge{{From: "a", To: "b", Kind: Depends}, {From: "a", To: "b", Kind: Context}} {
if !slices.Contains(g.Edges, want) {
t.Fatalf("missing %+v in %v", want, g.Edges)
}
}
}
func TestApplyIsOrderStable(t *testing.T) {
build := func() Graph {
var g Graph
for _, id := range []string{"a", "b", "c"} {
g.Apply(Delta{Nodes: []Node{{ID: id, Kind: KindWorker, State: StatePending}}})
}
g.Apply(Delta{Nodes: []Node{{ID: "b", State: StateRunning}}})
return g
}
first, second := build(), build()
if len(first.Nodes) != 3 {
t.Fatalf("nodes = %d, want 3", len(first.Nodes))
}
for i := range first.Nodes {
if first.Nodes[i] != second.Nodes[i] {
t.Fatalf("node %d diverged: %+v vs %+v", i, first.Nodes[i], second.Nodes[i])
}
}
if first.Nodes[1].ID != "b" || first.Nodes[1].State != StateRunning {
t.Fatalf("an update reordered the graph: %+v", first.Nodes)
}
}
func TestStateVocabulary(t *testing.T) {
cases := []struct {
state NodeState
answered bool
terminal bool
}{
{StatePending, false, false},
// Queued is not terminal: the node has not run, and a reader that treats
// it as settled stops waiting for the answer it is still going to get.
{StateQueued, false, false},
{StateRunning, false, false},
{StateCompleted, true, true},
{StateAdopted, true, true},
{StateFailed, false, true},
{StateCancelled, false, true},
{StateSkipped, false, true},
}
for _, c := range cases {
if got := c.state.Answered(); got != c.answered {
t.Errorf("%s.Answered() = %v, want %v", c.state, got, c.answered)
}
if got := c.state.Terminal(); got != c.terminal {
t.Errorf("%s.Terminal() = %v, want %v", c.state, got, c.terminal)
}
}
}
// A graph crosses a wire before it is drawn, so the folded value has to survive
// the round trip that every consumer outside this process reads it through.
func TestGraphSurvivesJSONRoundTrip(t *testing.T) {
var g Graph
g.Apply(Delta{
Nodes: []Node{
{ID: "grp", Kind: KindGroup, State: StateRunning, Label: "fleet(2)"},
{ID: "grp/1", ParentID: "grp", Kind: KindWorker, State: StateCompleted, Model: "m", StartedAt: 1, EndedAt: 2},
{ID: "old-ref", Kind: KindExternal, State: StateCompleted},
},
Edges: []Edge{
{From: "grp", To: "grp/1", Kind: Spawn},
{From: "old-ref", To: "grp/1", Kind: Adopt},
},
})
raw, err := json.Marshal(g)
if err != nil {
t.Fatal(err)
}
var back Graph
if err := json.Unmarshal(raw, &back); err != nil {
t.Fatal(err)
}
if !slices.Equal(back.Edges, g.Edges) {
t.Fatalf("edges = %v, want %v", back.Edges, g.Edges)
}
if !slices.Equal(back.Nodes, g.Nodes) {
t.Fatalf("nodes = %v, want %v", back.Nodes, g.Nodes)
}
}
@@ -0,0 +1,220 @@
package pricing
import (
"net/url"
"strings"
"time"
)
// Official catalog metadata: currency, model, effective dates, documentation
// sources, and price fingerprints. User-custom prices always win over catalog.
// CatalogEntry is one official list price for a model in a billing currency.
type CatalogEntry struct {
Provider string // deepseek | longcat | mimo | google
Model string
Currency string // ISO billing currency for this row
CacheHit float64
Input float64
Output float64
EffectiveFrom string // YYYY-MM-DD inclusive, empty = unbounded
EffectiveTo string // YYYY-MM-DD exclusive, empty = unbounded
DocURL string
BillingMode string // payg | subscription_equivalent
Notes string
Fingerprint string // filled by Register
// Peak is what the same tokens cost inside Window. Nil when the vendor
// bills one rate around the clock, and then the fields above are the rate.
Peak *RateCard
Window *PeakWindow
// CheckedOn is when a person last read DocURL and confirmed these numbers.
// No vendor here serves prices over an API, so this date is all that stands
// between a price change and a readout that is quietly wrong.
CheckedOn string
}
// deepseekPeak is the schedule DeepSeek publishes: peak on weekday mornings and
// afternoons, and — since 2026-08-23 — never at a weekend. Chinese public
// holidays bill off-peak in full, per the same page.
var deepseekPeak = &PeakWindow{
OffsetSeconds: beijing,
Hours: [][2]int{{9, 12}, {14, 18}},
WeekendOffPeak: "2026-08-23",
HolidayOffPeak: chineseStatutoryHolidays,
}
// chineseStatutoryHolidays are the State Council's 2026 public holidays, which
// DeepSeek bills off-peak in full; extend it when the next notice is out. Make-up
// workdays are absent: they fall on weekends, which are off-peak anyway.
var chineseStatutoryHolidays = []string{
// New Year's Day: Jan 1-3.
"2026-01-01", "2026-01-02", "2026-01-03",
// Spring Festival: Feb 15-23.
"2026-02-15", "2026-02-16", "2026-02-17", "2026-02-18", "2026-02-19",
"2026-02-20", "2026-02-21", "2026-02-22", "2026-02-23",
// Qingming Festival: Apr 4-6.
"2026-04-04", "2026-04-05", "2026-04-06",
// Labor Day: May 1-5.
"2026-05-01", "2026-05-02", "2026-05-03", "2026-05-04", "2026-05-05",
// Dragon Boat Festival: Jun 19-21.
"2026-06-19", "2026-06-20", "2026-06-21",
// Mid-Autumn Festival: Sep 25-27.
"2026-09-25", "2026-09-26", "2026-09-27",
// National Day: Oct 1-7.
"2026-10-01", "2026-10-02", "2026-10-03", "2026-10-04",
"2026-10-05", "2026-10-06", "2026-10-07",
}
// checkedOn is when the tables below were last read off the vendors' pages.
const checkedOn = "2026-08-22"
// deepseekCheckedOn is when the DeepSeek rows were last read off that vendor's
// page. Separate from checkedOn, because confirming one vendor's prices says
// nothing about another's and a shared date would claim that it did.
const deepseekCheckedOn = "2026-09-13"
// mimoCheckedOn is when the V2.6 rows were read off the vendor's own docs.
const mimoCheckedOn = "2026-10-06"
// googleCheckedOn is when the Gemini rows were read off Google's pricing page.
const googleCheckedOn = "2026-10-17"
// CatalogSourceURLs document public pricing pages.
const (
DocDeepSeekPricing = "https://api-docs.deepseek.com/quick_start/pricing"
DocDeepSeekPricingCNY = "https://api-docs.deepseek.com/zh-cn/quick_start/pricing"
DocLongCatPricingUSD = "https://longcat.chat/platform/docs/Pricing/LongCat-2.0.html"
DocLongCatPricingCNY = "https://longcat.chat/platform/docs/zh/pricing/long-cat-2.0"
DocMiMoPAYG = "https://mimo.mi.com/docs/price/pay-as-you-go"
DocMiMoTokenPlan = "https://platform.xiaomimimo.com/token-plan"
DocGeminiPricing = "https://ai.google.dev/gemini-api/docs/pricing"
)
// officialEndpointHosts maps a vendor's own API hostnames to the catalog
// provider whose list prices apply there. Host equality is the test: a reseller
// or proxy bills on its own terms even when it serves the same model, and an
// entry merely named after a vendor is not evidence of the vendor's endpoint.
var officialEndpointHosts = map[string]string{
"api.deepseek.com": "deepseek",
"api.longcat.chat": "longcat",
"api.xiaomimimo.com": "mimo",
"generativelanguage.googleapis.com": "google",
"token-plan-cn.xiaomimimo.com": "mimo",
"token-plan-sgp.xiaomimimo.com": "mimo",
"token-plan-ams.xiaomimimo.com": "mimo",
}
// OfficialProviderForEndpoint returns the catalog provider whose official
// prices apply to baseURL, or "" when the address is not a vendor's own API.
func OfficialProviderForEndpoint(baseURL string) string {
u, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return ""
}
return officialEndpointHosts[strings.ToLower(u.Hostname())]
}
// OfficialCatalog is the built-in price book: the current generation of every
// rate in officialRates, fingerprinted so a stored price can be recognised as
// one of ours. User-custom prices always win over it.
func OfficialCatalog() []CatalogEntry {
entries := officialCatalogRows()
for i := range entries {
entries[i].Fingerprint = PricingFingerprint(RateCard{
CacheHit: entries[i].CacheHit,
Input: entries[i].Input,
Output: entries[i].Output,
Currency: entries[i].Currency,
})
}
return entries
}
// LookupCatalog finds an official entry. billingMode empty matches payg first.
func LookupCatalog(provider, model, currency, billingMode string) (CatalogEntry, bool) {
provider = strings.ToLower(strings.TrimSpace(provider))
model = strings.TrimSpace(model)
currency = NormalizeCurrency(currency)
billingMode = strings.TrimSpace(billingMode)
if billingMode == "" {
billingMode = BillingModePAYG
}
var fallback CatalogEntry
foundFallback := false
for _, e := range OfficialCatalog() {
if e.Provider != provider || e.Model != model {
continue
}
if NormalizeCurrency(e.Currency) != currency {
continue
}
if e.BillingMode == billingMode {
return e, true
}
if e.BillingMode == BillingModePAYG {
fallback = e
foundFallback = true
}
}
if foundFallback {
return fallback, true
}
return CatalogEntry{}, false
}
// RateCardFromCatalog builds a RateCard from a catalog entry's base rate.
func RateCardFromCatalog(e CatalogEntry) RateCard {
return RateCard{
CacheHit: e.CacheHit,
Input: e.Input,
Output: e.Output,
Currency: e.Currency,
}
}
// RatesAt is what this entry charges at t: the peak card inside its window, the
// base card everywhere else. An entry without a window charges one rate, and
// then t does not matter.
func (e CatalogEntry) RatesAt(t time.Time) RateCard {
if e.Peak == nil || !e.Window.IsPeak(t) {
return RateCardFromCatalog(e)
}
peak := *e.Peak
peak.Currency = e.Currency
return peak
}
// InEffect reports whether this entry's dates cover the day t falls on.
func (e CatalogEntry) InEffect(t time.Time) bool {
day := t.UTC().Format("2006-01-02")
if e.EffectiveFrom != "" && day < e.EffectiveFrom {
return false
}
return e.EffectiveTo == "" || day < e.EffectiveTo
}
// MatchesCatalog reports whether rates equal a known official entry.
func MatchesCatalog(provider, model string, rates RateCard) (CatalogEntry, bool) {
cur := NormalizeCurrency(rates.Currency)
for _, e := range OfficialCatalog() {
if e.Provider != strings.ToLower(strings.TrimSpace(provider)) {
continue
}
if e.Model != strings.TrimSpace(model) {
continue
}
if NormalizeCurrency(e.Currency) != cur {
continue
}
// Either card identifies the vendor's own price. Which one a user
// happens to have configured says nothing about when they are billing:
// the hour decides that, and RatesAt is what reads it.
if e.CacheHit == rates.CacheHit && e.Input == rates.Input && e.Output == rates.Output {
return e, true
}
if p := e.Peak; p != nil && p.CacheHit == rates.CacheHit && p.Input == rates.Input && p.Output == rates.Output {
return e, true
}
}
return CatalogEntry{}, false
}
@@ -0,0 +1,95 @@
package pricing
import (
"strings"
"testing"
"time"
)
// Every catalog provider must declare at least one official endpoint, or its
// rows become unreachable: a rate card is only matched against the catalog
// after the entry's host identifies the vendor.
func TestEveryCatalogProviderHasAnOfficialEndpoint(t *testing.T) {
declared := map[string]bool{}
for _, host := range officialEndpointHosts {
declared[host] = true
}
for _, entry := range OfficialCatalog() {
if !declared[entry.Provider] {
t.Errorf("catalog provider %q has no official endpoint host; its prices can never match", entry.Provider)
}
}
}
func TestOfficialProviderForEndpoint(t *testing.T) {
cases := []struct {
name string
baseURL string
want string
}{
{"official deepseek", "https://api.deepseek.com", "deepseek"},
{"official deepseek anthropic path", "https://api.deepseek.com/anthropic", "deepseek"},
{"official longcat", "https://api.longcat.chat/openai/v1", "longcat"},
{"official mimo region", "https://token-plan-sgp.xiaomimimo.com/v1", "mimo"},
{"uppercase host", "https://API.DeepSeek.com/v1", "deepseek"},
{"proxy that resells the model", "https://my-deepseek-proxy.internal/v1", ""},
{"vendor name in the path only", "https://gateway.example.com/deepseek/v1", ""},
{"lookalike registrable domain", "https://api.deepseek.com.evil.test/v1", ""},
{"empty", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := OfficialProviderForEndpoint(tc.baseURL); got != tc.want {
t.Fatalf("OfficialProviderForEndpoint(%q) = %q, want %q", tc.baseURL, got, tc.want)
}
})
}
}
// pricesGoStaleAfter is how long a hand-copied rate is trusted before someone
// reads the vendor's page again. No vendor here serves prices over an API, so
// the alternative to this going red on a quiet day is learning about a change
// from a user whose cost was wrong for months. Bumping CheckedOn without
// opening DocURL defeats it; what this buys is that the question gets asked.
const pricesGoStaleAfter = 90 * 24 * time.Hour
func TestCatalogPricesHaveBeenCheckedRecently(t *testing.T) {
var undated, stale []string
// This snapshot never sees a vendor page again, so staleness is read at the
// day it was taken rather than the day the suite runs.
cutoff := time.Date(2026, 10, 19, 0, 0, 0, 0, time.UTC).Add(-pricesGoStaleAfter)
for _, e := range OfficialCatalog() {
where := e.Provider + "/" + e.Model + " " + e.Currency + " (" + e.DocURL + ")"
if e.CheckedOn == "" {
undated = append(undated, where)
continue
}
on, err := time.Parse("2006-01-02", e.CheckedOn)
if err != nil {
t.Fatalf("%s: CheckedOn %q is not YYYY-MM-DD", where, e.CheckedOn)
}
if on.Before(cutoff) {
stale = append(stale, where+" last checked "+e.CheckedOn)
}
}
if len(undated) > 0 {
t.Errorf("catalog rows with no CheckedOn — read the vendor's page and date them:\n %s",
strings.Join(undated, "\n "))
}
if len(stale) > 0 {
t.Errorf("catalog rows older than %d days. Open each DocURL, confirm or correct the rate, then set CheckedOn to today:\n %s",
int(pricesGoStaleAfter.Hours()/24), strings.Join(stale, "\n "))
}
}
// A row that names a peak card must say when it applies, and one that names a
// window must have a card to charge inside it. Half of the pair silently prices
// every hour the same, which is the bug the pair exists to prevent.
func TestPeakRatesAndWindowsComeInPairs(t *testing.T) {
for _, e := range OfficialCatalog() {
where := e.Provider + "/" + e.Model + " " + e.Currency
if (e.Peak == nil) != (e.Window == nil) {
t.Errorf("%s: Peak and Window must be set together (peak=%v window=%v)", where, e.Peak != nil, e.Window != nil)
}
}
}
@@ -0,0 +1,48 @@
package pricing
// Coverage says how much of the usage behind a quote carries a complete price
// fact. CostComplete answers that for one call; an aggregate has two answers
// more, because it can price some of its calls and not others, and it can have
// billed nothing at all. A session that has not spent yet is not a session
// whose price is unknown.
const (
CoverageNone = "none"
CoverageComplete = "complete"
CoveragePartial = "partial"
CoverageIncomplete = "incomplete"
)
// ValidCoverage reports whether c is one of the four identities.
func ValidCoverage(c string) bool {
switch c {
case CoverageNone, CoverageComplete, CoveragePartial, CoverageIncomplete:
return true
}
return false
}
// FoldCoverage returns the coverage of a and b together: the join over complete
// and incomplete, with none as the identity and partial as the top. Order cannot
// reach the answer, and a run that has priced everything so far cannot fold back
// to complete once an unpriced call joins it. A value outside the four folds as
// none; NormalizeQuote repairs a decoded quote before it can reach here.
func FoldCoverage(a, b string) string {
a, b = coverageOrNone(a), coverageOrNone(b)
switch {
case a == CoverageNone:
return b
case b == CoverageNone:
return a
case a == b:
return a
default:
return CoveragePartial
}
}
func coverageOrNone(c string) string {
if ValidCoverage(c) {
return c
}
return CoverageNone
}
@@ -0,0 +1,190 @@
package pricing
import (
"testing"
"time"
)
var allCoverages = []string{CoverageNone, CoverageComplete, CoveragePartial, CoverageIncomplete}
// The contract the aggregate has to satisfy, stated as the pairs themselves.
func TestFoldCoverageContract(t *testing.T) {
cases := []struct{ a, b, want string }{
{CoverageNone, CoverageNone, CoverageNone},
{CoverageComplete, CoverageNone, CoverageComplete},
{CoverageComplete, CoverageComplete, CoverageComplete},
{CoverageComplete, CoverageIncomplete, CoveragePartial},
{CoverageIncomplete, CoverageComplete, CoveragePartial},
{CoverageIncomplete, CoverageIncomplete, CoverageIncomplete},
{CoveragePartial, CoverageComplete, CoveragePartial},
{CoveragePartial, CoverageIncomplete, CoveragePartial},
{CoveragePartial, CoverageNone, CoveragePartial},
}
for _, c := range cases {
if got := FoldCoverage(c.a, c.b); got != c.want {
t.Errorf("fold(%s, %s) = %s, want %s", c.a, c.b, got, c.want)
}
}
}
// Arrival order is not a fact about the session, so it may not reach the
// answer. Exhaustive rather than sampled: there are only sixty-four triples.
func TestFoldCoverageIsOrderIndependent(t *testing.T) {
for _, a := range allCoverages {
for _, b := range allCoverages {
if FoldCoverage(a, b) != FoldCoverage(b, a) {
t.Errorf("fold(%s, %s) != fold(%s, %s)", a, b, b, a)
}
if FoldCoverage(a, a) != a {
t.Errorf("fold(%s, %s) = %s, want %s", a, a, FoldCoverage(a, a), a)
}
for _, c := range allCoverages {
left := FoldCoverage(FoldCoverage(a, b), c)
right := FoldCoverage(a, FoldCoverage(b, c))
if left != right {
t.Errorf("fold is not associative on (%s, %s, %s): %s vs %s", a, b, c, left, right)
}
}
}
}
}
// The failure a boolean invites: the last quote deciding for the ones before it.
func TestCompleteNeverSurvivesALaterIncomplete(t *testing.T) {
for _, a := range allCoverages {
got := FoldCoverage(FoldCoverage(a, CoverageComplete), CoverageIncomplete)
if got == CoverageComplete {
t.Errorf("fold(%s, complete, incomplete) = complete", a)
}
}
}
func TestFoldCoverageTreatsAnUnnamedStateAsNothing(t *testing.T) {
if got := FoldCoverage("", CoverageComplete); got != CoverageComplete {
t.Fatalf("fold(\"\", complete) = %s", got)
}
if got := FoldCoverage("mostly", CoverageIncomplete); got != CoverageIncomplete {
t.Fatalf("fold(unnamed, incomplete) = %s", got)
}
}
func pricedQuote(t *testing.T, perMillion float64) CostQuote {
t.Helper()
q := BuildQuote(QuoteInput{
Usage: UsageTokens{PromptTokens: 1000},
Rates: RateCard{Input: perMillion, Currency: "USD"},
})
if q.Coverage != CoverageComplete {
t.Fatalf("a priced call is not complete: %+v", q)
}
return q
}
func unpricedQuote(t *testing.T) CostQuote {
t.Helper()
q := NormalizeQuote(CostQuote{
Estimated: true, DisplayStatus: DisplayStatusUnavailable, IncompleteReason: "no_price",
})
if q.Coverage != CoverageIncomplete {
t.Fatalf("an unpriced call is not incomplete: %+v", q)
}
return q
}
func TestAggregateCoverageAcrossCalls(t *testing.T) {
priced, unpriced := pricedQuote(t, 1), unpricedQuote(t)
cases := []struct {
name string
quotes []CostQuote
want string
}{
{"no usage", nil, CoverageNone},
{"one priced", []CostQuote{priced}, CoverageComplete},
{"two priced", []CostQuote{priced, priced}, CoverageComplete},
{"priced then unpriced", []CostQuote{priced, unpriced}, CoveragePartial},
{"unpriced then priced", []CostQuote{unpriced, priced}, CoveragePartial},
{"unpriced only", []CostQuote{unpriced}, CoverageIncomplete},
{"unpriced twice", []CostQuote{unpriced, unpriced}, CoverageIncomplete},
}
for _, c := range cases {
got := AggregateQuotes(c.quotes, "USD")
if got.Coverage != c.want {
t.Errorf("%s: coverage = %s, want %s", c.name, got.Coverage, c.want)
}
// The boolean is a reading of the coverage, not a second answer.
if got.CostComplete != (c.want == CoverageComplete) {
t.Errorf("%s: costComplete = %v against coverage %s", c.name, got.CostComplete, got.Coverage)
}
}
}
// A session's completeness is a fact about its calls, so the order they were
// billed in may not change it — at the aggregate, not only in the fold.
func TestAggregateCoverageIgnoresArrivalOrder(t *testing.T) {
priced, unpriced := pricedQuote(t, 1), unpricedQuote(t)
forward := AggregateQuotes([]CostQuote{priced, priced, unpriced}, "USD")
backward := AggregateQuotes([]CostQuote{unpriced, priced, priced}, "USD")
if forward.Coverage != backward.Coverage || forward.Coverage != CoveragePartial {
t.Fatalf("order changed coverage: %s vs %s", forward.Coverage, backward.Coverage)
}
}
// Zero is an amount, not a completeness state. Both sessions total zero; only
// one of them has been billed for anything.
func TestKnownZeroIsCompleteAndNoUsageIsNot(t *testing.T) {
free := AggregateQuotes([]CostQuote{pricedQuote(t, 0)}, "USD")
if free.Coverage != CoverageComplete || !free.CostComplete {
t.Fatalf("a free call priced at zero is not complete: %+v", free)
}
if amount := free.Original.AmountValue().Float64(); amount != 0 {
t.Fatalf("a free call cost %v", amount)
}
empty := AggregateQuotes(nil, "USD")
if empty.Coverage != CoverageNone {
t.Fatalf("no usage = %s", empty.Coverage)
}
if amount := empty.Original.AmountValue().Float64(); amount != 0 {
t.Fatalf("an empty session cost %v", amount)
}
if free.Coverage == empty.Coverage {
t.Fatal("a billed zero and an unbilled session share one identity")
}
}
// A quote persisted before coverage existed still has to name one, and the
// name has to be the one its own cost fact supports.
func TestNormalizeFillsCoverageForAQuoteThatPredatesIt(t *testing.T) {
old := NormalizeQuote(CostQuote{Original: MoneyOf(NewAmountFromFloat(1), "USD"), Complete: true})
if old.Coverage != CoverageComplete {
t.Fatalf("an old complete quote = %s", old.Coverage)
}
if got := NormalizeQuote(CostQuote{Coverage: "mostly", IncompleteReason: "no_price"}); got.Coverage != CoverageIncomplete {
t.Fatalf("an unnamed coverage survived normalization: %s", got.Coverage)
}
kept := NormalizeQuote(CostQuote{Coverage: CoveragePartial, IncompleteReason: "no_price"})
if kept.Coverage != CoveragePartial {
t.Fatalf("normalization overwrote a coverage the quote already had: %s", kept.Coverage)
}
}
// The ledger merges by pricing key before anything aggregates, so it is a
// second fold. An entry that has seen an unpriced call may not read complete
// again because a later call arrived in another currency.
func TestLedgerEntryKeepsIncompletenessAcrossBucketing(t *testing.T) {
l := NewLedger()
unpriced := unpricedQuote(t)
unpriced.ModelRef, unpriced.UsageSource = "m", "executor"
cny := BuildQuote(QuoteInput{Usage: UsageTokens{PromptTokens: 1000}, Rates: RateCard{Input: 1, Currency: "CNY"}, ModelRef: "m", UsageSource: "executor"})
usd := BuildQuote(QuoteInput{Usage: UsageTokens{PromptTokens: 1000}, Rates: RateCard{Input: 1, Currency: "USD"}, ModelRef: "m", UsageSource: "executor"})
usd.PricingFingerprint, cny.PricingFingerprint = unpriced.PricingFingerprint, unpriced.PricingFingerprint
for _, q := range []CostQuote{unpriced, cny, usd} {
l.Add(q, UsageTokens{PromptTokens: 1000}, time.Time{})
}
total := l.Total("USD")
if total.Coverage != CoveragePartial {
t.Fatalf("ledger total coverage = %s, want partial", total.Coverage)
}
if total.CostComplete {
t.Fatal("an entry that saw an unpriced call reads as complete")
}
}
@@ -0,0 +1,68 @@
package pricing
import "testing"
// https://ai.google.dev/gemini-api/docs/pricing (read 2026-10-17): per 1M tokens,
// paid tier. gemini-3.8-flash is 0.75 in / 3.75 out / 0.075 cached through
// 2026-12-31 and 1.50 / 7.50 / 0.15 from 2027-01-01.
func TestAGeminiRateStepIsDatedNotEdited(t *testing.T) {
was := today
defer func() { today = was }()
for _, tc := range []struct {
day string
cache, input, output float64
}{
{"2026-10-17", 0.075, 0.75, 3.75},
{"2026-12-31", 0.075, 0.75, 3.75},
{"2027-01-01", 0.15, 1.50, 7.50},
{"2028-03-01", 0.15, 1.50, 7.50},
} {
today = func() string { return tc.day }
got := CurrentRate("google", "gemini-3.8-flash", "USD")
if got == nil || got.CacheHit != tc.cache || got.Input != tc.input || got.Output != tc.output {
t.Errorf("%s: rate = %+v, want %v/%v/%v", tc.day, got, tc.cache, tc.input, tc.output)
}
}
}
func TestTheRateInForceIsNotListedAsSuperseded(t *testing.T) {
was := today
defer func() { today = was }()
today = func() string { return "2026-10-17" }
if sup := SupersededRates("google", "gemini-3.8-flash", "USD"); len(sup) != 0 {
t.Fatalf("before the step nothing is superseded, got %+v", sup)
}
today = func() string { return "2027-02-01" }
sup := SupersededRates("google", "gemini-3.8-flash", "USD")
if len(sup) != 1 || sup[0].Input != 0.75 {
t.Fatalf("after the step the intro rate is superseded, got %+v", sup)
}
if !IsOfficialCard("google", "gemini-3.8-flash", RateCard{CacheHit: 0.075, Input: 0.75, Output: 3.75, Currency: "USD"}) {
t.Fatal("a stored intro card must still read as ours after the step")
}
}
func TestGoogleModelsAreBilledAtGoogleOnlyOnGoogleHosts(t *testing.T) {
if OfficialProviderForEndpoint("https://generativelanguage.googleapis.com/v1beta") != "google" {
t.Fatal("Google's own host does not read as Google")
}
if OfficialProviderForEndpoint("https://relay.example.com/v1beta") != "" {
t.Fatal("a relay was billed at Google's list price")
}
}
func TestEveryGoogleRowNamesItsSource(t *testing.T) {
n := 0
for _, e := range OfficialCatalog() {
if e.Provider != "google" {
continue
}
n++
if e.DocURL != DocGeminiPricing || e.CheckedOn != googleCheckedOn || e.Input <= 0 || e.Output <= e.Input {
t.Errorf("row %+v", e)
}
}
if n != 4 {
t.Fatalf("%d Google rows, want 4 (gemini-4-argon shows no price)", n)
}
}
@@ -0,0 +1,238 @@
package pricing
import (
"maps"
"slices"
"sort"
"strings"
"time"
)
// LedgerEntry is one occurrence-time cost fact. Ledger keys include model,
// usage source, pricing fingerprint, and legacy rate date so model switches and
// sub-agents never collapse into a single scalar.
type LedgerEntry struct {
Key string `json:"key"`
ModelRef string `json:"modelRef"`
UsageSource string `json:"usageSource"`
PricingFingerprint string `json:"pricingFingerprint"`
RateDate string `json:"rateDate,omitempty"`
OccurredAt time.Time `json:"occurredAt"`
Quote CostQuote `json:"quote"`
// Token totals for this bucket (summed).
PromptTokens int `json:"promptTokens"`
CompletionTokens int `json:"completionTokens"`
TotalTokens int `json:"totalTokens"`
CacheHitTokens int `json:"cacheHitTokens"`
CacheMissTokens int `json:"cacheMissTokens"`
// CacheUnknownTokens is prompt the endpoint reported without saying how much
// was cached. It is counted as a miss in CacheMissTokens for pricing and kept
// here so a hit rate can leave it out instead of being pulled down by it.
CacheUnknownTokens int `json:"cacheUnknownTokens,omitempty"`
RequestCount int `json:"requestCount"`
}
// LedgerKey builds the stable aggregation key.
func LedgerKey(modelRef, usageSource, pricingFingerprint, rateDate string) string {
return strings.Join([]string{
strings.TrimSpace(modelRef),
strings.TrimSpace(usageSource),
strings.TrimSpace(pricingFingerprint),
strings.TrimSpace(rateDate),
}, "|")
}
// Ledger accumulates occurrence-time quotes. Switching display currency only
// re-selects valuations already stored; it does not reprice or clear history.
type Ledger struct {
Version int `json:"version"`
Entries map[string]LedgerEntry `json:"entries"`
}
// LedgerVersion is the current persisted ledger schema.
const LedgerVersion = 1
// NewLedger returns an empty ledger.
func NewLedger() *Ledger {
return &Ledger{Version: LedgerVersion, Entries: map[string]LedgerEntry{}}
}
// Add merges a quote into the ledger under its natural key.
func (l *Ledger) Add(q CostQuote, tokens UsageTokens, occurred time.Time) {
if l == nil {
return
}
if l.Entries == nil {
l.Entries = map[string]LedgerEntry{}
}
if l.Version == 0 {
l.Version = LedgerVersion
}
if occurred.IsZero() {
occurred = time.Now().UTC()
}
q = NormalizeQuote(q)
source := q.UsageSource
if source == "" {
source = "executor"
}
key := LedgerKey(q.ModelRef, source, q.PricingFingerprint, q.RateDate)
ent, ok := l.Entries[key]
if !ok {
ent = LedgerEntry{
Key: key,
ModelRef: q.ModelRef,
UsageSource: source,
PricingFingerprint: q.PricingFingerprint,
RateDate: q.RateDate,
OccurredAt: occurred,
Quote: q,
}
// Fresh quote valuations are kept; we re-aggregate Original via sums.
ent.Quote.Valuations = cloneValuations(q.Valuations)
} else {
// Sum original when same currency; otherwise retain deterministic
// per-currency buckets. Once a bucketed entry exists, keep adding into
// those buckets so later same-currency calls are not lost.
if len(ent.Quote.OriginalTotals) > 0 {
ent.Quote.OriginalTotals = mergeOriginalTotals(ent.Quote.OriginalTotals, q)
ent.Quote.DisplayComplete = false
ent.Quote.Complete = false
ent.Quote.DisplayStatus = DisplayStatusBucketed
ent.Quote.AggregateMode = AggregateModeCurrencyBuckets
ent.Quote.IncompleteReason = "mixed_original_currencies"
} else {
sum, err := AddMoney(ent.Quote.Original, q.Original)
if err != nil {
ent.Quote.OriginalTotals = mergeOriginalTotals([]Money{ent.Quote.Original}, q)
ent.Quote.DisplayComplete = false
ent.Quote.Complete = false
ent.Quote.DisplayStatus = DisplayStatusBucketed
ent.Quote.AggregateMode = AggregateModeCurrencyBuckets
ent.Quote.IncompleteReason = "mixed_original_currencies"
} else {
ent.Quote.Original = sum
}
}
for code, v := range q.Valuations {
code = NormalizeCurrency(code)
if prev, ok := ent.Quote.Valuations[code]; ok {
added, err := AddMoney(prev.Money, v.Money)
if err == nil {
prev.Money = added
if v.Stale {
prev.Stale = true
}
ent.Quote.Valuations[code] = prev
}
} else {
if ent.Quote.Valuations == nil {
ent.Quote.Valuations = map[string]Valuation{}
}
ent.Quote.Valuations[code] = v
}
}
if q.Estimated {
ent.Quote.Estimated = true
}
if !q.Complete {
ent.Quote.DisplayComplete = false
ent.Quote.Complete = false
if ent.Quote.IncompleteReason == "" {
ent.Quote.IncompleteReason = q.IncompleteReason
}
}
ent.Quote.Coverage = FoldCoverage(ent.Quote.Coverage, q.Coverage)
ent.Quote.CostComplete = ent.Quote.Coverage == CoverageComplete
if ent.Quote.DisplayStatus != DisplayStatusBucketed {
ent.Quote.DisplayStatus = q.DisplayStatus
}
}
ent.PromptTokens += tokens.PromptTokens
ent.CompletionTokens += tokens.CompletionTokens
ent.TotalTokens += tokens.PromptTokens + tokens.CompletionTokens
if tokens.CacheHitTokens+tokens.CacheMissTokens > 0 {
ent.CacheHitTokens += tokens.CacheHitTokens
ent.CacheMissTokens += tokens.CacheMissTokens
} else {
ent.CacheMissTokens += tokens.PromptTokens
ent.CacheUnknownTokens += tokens.PromptTokens
}
ent.RequestCount++
if occurred.After(ent.OccurredAt) {
ent.OccurredAt = occurred
}
l.Entries[key] = ent
}
func mergeOriginalTotals(existing []Money, q CostQuote) []Money {
amounts := map[string]Amount{}
add := func(m Money) {
currency := NormalizeCurrency(m.Currency)
if currency == "" {
return
}
amounts[currency] = amounts[currency].Add(m.AmountValue())
}
for _, m := range existing {
add(m)
}
if len(q.OriginalTotals) > 0 {
for _, m := range q.OriginalTotals {
add(m)
}
} else {
add(q.Original)
}
codes := slices.Sorted(maps.Keys(amounts))
out := make([]Money, 0, len(codes))
for _, code := range codes {
out = append(out, MoneyOf(amounts[code], code))
}
return out
}
func cloneValuations(in map[string]Valuation) map[string]Valuation {
if len(in) == 0 {
return map[string]Valuation{}
}
out := make(map[string]Valuation, len(in))
maps.Copy(out, in)
return out
}
// Total returns the aggregate CostQuote for a display currency.
func (l *Ledger) Total(display string) CostQuote {
if l == nil || len(l.Entries) == 0 {
return AggregateQuotes(nil, display)
}
quotes := make([]CostQuote, 0, len(l.Entries))
for _, ent := range l.Entries {
q := ent.Quote
// Refresh valuation moneys already summed in entries.
quotes = append(quotes, q)
}
// Stable order for determinism.
sort.Slice(quotes, func(i, j int) bool {
return quotes[i].ModelRef+quotes[i].UsageSource < quotes[j].ModelRef+quotes[j].UsageSource
})
return AggregateQuotes(quotes, display)
}
// SelectDisplay rebinds Selected on every entry and the total without
// recomputing occurrence-time pricing facts.
func (l *Ledger) SelectDisplay(display string) CostQuote {
return l.Total(display)
}
// EntriesBySource groups ledger entries by usage source for status panels.
func (l *Ledger) EntriesBySource() map[string][]LedgerEntry {
out := map[string][]LedgerEntry{}
if l == nil {
return out
}
for _, ent := range l.Entries {
out[ent.UsageSource] = append(out[ent.UsageSource], ent)
}
return out
}
@@ -0,0 +1,88 @@
package pricing
import (
"strings"
"time"
)
// LegacyUsageRecord is the minimum shape needed to migrate old telemetry.
type LegacyUsageRecord struct {
SessionCost float64
SessionCurrency string
// EndedAt is retained for historical ordering and identity valuation dates.
EndedAt time.Time
// Tokens optional for ledger completeness.
PromptTokens int
CompletionTokens int
ModelRef string
UsageSource string
}
// MigrateLegacyUsage builds a CostQuote from pre-CostQuote telemetry.
// Records that cannot be valued are marked legacy_estimate; wiped mixed-currency
// zeros are not reconstructed from current price tables.
func MigrateLegacyUsage(rec LegacyUsageRecord) CostQuote {
cur := NormalizeCurrency(rec.SessionCurrency)
if cur == "" {
// Historic defaults often used ¥ symbol without code.
if strings.Contains(rec.SessionCurrency, "¥") || strings.Contains(rec.SessionCurrency, "¥") {
cur = "CNY"
} else if strings.Contains(rec.SessionCurrency, "$") {
cur = "USD"
}
}
// Wiped mixed-currency totals (legacy code zeroed them) and empty rows must
// not be reconstructed from current price tables.
if rec.SessionCost <= 0 || cur == "" {
reason := "legacy_unrecoverable"
if rec.SessionCost == 0 && strings.TrimSpace(rec.SessionCurrency) != "" {
// Explicit zero with a currency often means a prior mixed-currency wipe.
reason = "legacy_wiped_or_zero"
} else if rec.SessionCost < 0 {
reason = "legacy_invalid_amount"
}
return CostQuote{
Original: MoneyOf(Zero, cur),
Estimated: true,
CostComplete: false,
Coverage: CoverageIncomplete,
DisplayComplete: false,
Complete: false,
DisplayStatus: DisplayStatusUnavailable,
LegacyEstimate: true,
IncompleteReason: reason,
ModelRef: rec.ModelRef,
UsageSource: rec.UsageSource,
}
}
amount := NewAmountFromFloat(rec.SessionCost)
q := CostQuote{
Original: MoneyOf(amount, cur),
Valuations: map[string]Valuation{},
Estimated: true,
CostComplete: true,
Coverage: CoverageComplete,
DisplayComplete: true,
Complete: true,
DisplayStatus: DisplayStatusMatched,
AggregateMode: AggregateModeSingleCurrency,
LegacyEstimate: true,
ModelRef: rec.ModelRef,
UsageSource: rec.UsageSource,
BillingMode: BillingModePAYG,
}
ended := rec.EndedAt
if ended.IsZero() {
ended = time.Now().UTC()
}
asOf := ended.UTC().Format("2006-01-02")
q.Valuations[cur] = Valuation{
Money: q.Original,
Basis: BasisIdentity,
Source: "legacy_telemetry",
AsOf: asOf,
}
m := q.Original
q.Selected = &m
return q
}
@@ -0,0 +1,20 @@
package pricing
import "testing"
func TestMigrateLegacyUsageIdentityOnly(t *testing.T) {
q := MigrateLegacyUsage(LegacyUsageRecord{SessionCost: 1.25, SessionCurrency: "USD"})
if !q.CostComplete || !q.DisplayComplete || !q.Complete || q.Selected == nil {
t.Fatalf("migrated quote = %+v", q)
}
if len(q.Valuations) != 1 || q.Valuations["USD"].Basis != BasisIdentity {
t.Fatalf("migrated valuations = %+v", q.Valuations)
}
}
func TestMigrateLegacyWipedZeroDoesNotGuess(t *testing.T) {
q := MigrateLegacyUsage(LegacyUsageRecord{SessionCurrency: "CNY"})
if q.CostComplete || q.Complete || q.DisplayStatus != DisplayStatusUnavailable || q.IncompleteReason != "legacy_wiped_or_zero" {
t.Fatalf("wiped quote = %+v", q)
}
}
@@ -0,0 +1,28 @@
package pricing
import "testing"
func TestMimoV26RatesFollowTheVendorPage(t *testing.T) {
cases := []struct {
model string
hit, miss, out float64
v25Twin string
}{
{"mimo-v2.6-pro", 0.025, 3, 6, "mimo-v2.5-pro"},
{"mimo-v2.6-flash", 0.02, 1, 2, "mimo-v2.5"},
{"mimo-v2.6-pro-ultraspeed", 0.25, 30, 60, ""},
}
for _, tc := range cases {
got := CurrentRate("mimo", tc.model, "CNY")
if got == nil || got.CacheHit != tc.hit || got.Input != tc.miss || got.Output != tc.out {
t.Fatalf("%s rate = %+v, want %v/%v/%v", tc.model, got, tc.hit, tc.miss, tc.out)
}
if tc.v25Twin == "" {
continue
}
old := CurrentRate("mimo", tc.v25Twin, "CNY")
if old == nil || *old != *got {
t.Fatalf("%s must keep its published rate beside %s: %+v vs %+v", tc.v25Twin, tc.model, old, got)
}
}
}
@@ -0,0 +1,246 @@
// Package pricing is what a model call costs: fixed-point money, the official
// rate tables, and the quote a usage record is priced by. It is data and
// arithmetic only; asking a provider for its wallet balance is model/billing's.
package pricing
import (
"fmt"
"math"
"math/big"
"strings"
)
// Money is a currency-tagged amount carried as a decimal string on the wire.
// Arithmetic uses fixed-point int64 units (1e9 fractional digits).
type Money struct {
Amount string `json:"amount"`
Currency string `json:"currency"`
}
// amountScale is 1e9: enough for per-token rates down to ~1e-15 currency units
// when aggregated over typical request sizes.
const amountScale int64 = 1_000_000_000
// Amount is a fixed-point currency magnitude in 1e-9 units of the currency.
type Amount int64
// Zero is the zero amount.
const Zero Amount = 0
// NewAmountFromFloat converts a float64 amount to fixed-point. Prefer ParseAmount
// or NewAmountFromString for values that originate as decimals.
func NewAmountFromFloat(v float64) Amount {
if math.IsNaN(v) || math.IsInf(v, 0) {
return Zero
}
r := new(big.Rat).SetFloat64(v)
if r == nil {
return Zero
}
return amountFromRat(r)
}
// NewAmountFromString parses a decimal amount string.
func NewAmountFromString(s string) (Amount, error) {
s = strings.TrimSpace(s)
if s == "" {
return Zero, nil
}
r := new(big.Rat)
if _, ok := r.SetString(s); !ok {
return Zero, fmt.Errorf("billing: invalid amount %q", s)
}
return amountFromRat(r), nil
}
// ParseAmount is an alias for NewAmountFromString.
func ParseAmount(s string) (Amount, error) { return NewAmountFromString(s) }
func amountFromRat(r *big.Rat) Amount {
if r == nil {
return Zero
}
scaled := new(big.Rat).Mul(r, big.NewRat(amountScale, 1))
// Round half away from zero.
num := new(big.Int).Set(scaled.Num())
den := new(big.Int).Set(scaled.Denom())
half := new(big.Int).Rsh(den, 1)
if scaled.Sign() >= 0 {
num.Add(num, half)
} else {
num.Sub(num, half)
}
quot := new(big.Int).Quo(num, den)
if !quot.IsInt64() {
if scaled.Sign() >= 0 {
return Amount(math.MaxInt64)
}
return Amount(math.MinInt64)
}
return Amount(quot.Int64())
}
// String returns a trimmed decimal representation.
func (a Amount) String() string {
neg := a < 0
v := int64(a)
if neg {
v = -v
}
whole := v / amountScale
frac := v % amountScale
s := fmt.Sprintf("%d.%09d", whole, frac)
s = strings.TrimRight(s, "0")
s = strings.TrimRight(s, ".")
if s == "" || s == "-" {
s = "0"
}
if neg && s != "0" {
return "-" + s
}
return s
}
// Float64 approximates the amount for legacy float fields only.
func (a Amount) Float64() float64 {
return float64(a) / float64(amountScale)
}
// Add returns a+b. Same-currency only at the Money layer.
func (a Amount) Add(b Amount) Amount {
sum := int64(a) + int64(b)
// Saturate on overflow rather than wrap.
if (b > 0 && sum < int64(a)) || (b < 0 && sum > int64(a)) {
if b > 0 {
return Amount(math.MaxInt64)
}
return Amount(math.MinInt64)
}
return Amount(sum)
}
// MulRate multiplies by a float rate for a pricing calculation.
func (a Amount) MulRate(rate float64) Amount {
if a == 0 || rate == 0 || math.IsNaN(rate) || math.IsInf(rate, 0) {
return Zero
}
r := new(big.Rat).SetFloat64(rate)
if r == nil {
return Zero
}
ar := big.NewRat(int64(a), amountScale)
return amountFromRat(ar.Mul(ar, r))
}
// MoneyOf builds a Money value.
func MoneyOf(amount Amount, currency string) Money {
return Money{Amount: amount.String(), Currency: NormalizeCurrency(currency)}
}
// AmountValue returns the fixed-point amount, or zero on parse error.
func (m Money) AmountValue() Amount {
a, err := NewAmountFromString(m.Amount)
if err != nil {
return Zero
}
return a
}
// IsZero reports whether the money amount is empty or zero.
func (m Money) IsZero() bool {
return m.AmountValue() == Zero
}
// Float64 is a legacy adapter. Prefer Amount strings for aggregation.
func (m Money) Float64() float64 { return m.AmountValue().Float64() }
// NormalizeCurrency maps symbols and aliases to ISO-4217 codes when known.
// Unknown three-letter codes pass through uppercased; empty stays empty.
func NormalizeCurrency(currency string) string {
value := strings.TrimSpace(currency)
if value == "" {
return ""
}
switch strings.ToUpper(value) {
case "CNY", "RMB", "CNH", "YUAN", "RENMINBI":
return "CNY"
case "USD", "US$", "DOLLAR", "DOLLARS":
return "USD"
case "EUR", "EURO", "EUROS":
return "EUR"
case "GBP", "POUND", "POUNDS", "STERLING":
return "GBP"
case "JPY", "YEN":
return "JPY"
}
switch value {
case "¥", "¥":
return "CNY"
case "$":
return "USD"
case "€":
return "EUR"
case "£":
return "GBP"
}
if len(value) == 3 {
allAlpha := true
for _, r := range value {
if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') {
allAlpha = false
break
}
}
if allAlpha {
return strings.ToUpper(value)
}
}
return value
}
// CurrencySymbol returns a compact display symbol for an ISO code or symbol.
func CurrencySymbol(currency string) string {
switch NormalizeCurrency(currency) {
case "CNY", "JPY":
return "¥"
case "USD":
return "$"
case "EUR":
return "€"
case "GBP":
return "£"
case "":
return "¥"
default:
code := NormalizeCurrency(currency)
if len(code) == 3 {
return code + " "
}
return currency
}
}
// SameCurrency reports whether two codes normalize equal and non-empty.
func SameCurrency(a, b string) bool {
na, nb := NormalizeCurrency(a), NormalizeCurrency(b)
return na != "" && na == nb
}
// AddMoney adds two Money values of the same currency. Mixed currencies error.
func AddMoney(a, b Money) (Money, error) {
ca, cb := NormalizeCurrency(a.Currency), NormalizeCurrency(b.Currency)
if ca == "" {
ca = cb
}
if cb == "" {
cb = ca
}
if ca == "" && cb == "" {
return Money{Amount: "0", Currency: ""}, nil
}
if ca != cb {
return Money{}, fmt.Errorf("billing: cannot add %s and %s", ca, cb)
}
sum := a.AmountValue().Add(b.AmountValue())
return MoneyOf(sum, ca), nil
}
@@ -0,0 +1,56 @@
package pricing
import (
"testing"
)
func TestAmountRoundTrip(t *testing.T) {
a, err := NewAmountFromString("1.23456789")
if err != nil {
t.Fatal(err)
}
if got := a.String(); got != "1.23456789" {
t.Fatalf("String = %q", got)
}
if a.Float64() < 1.23456788 || a.Float64() > 1.23456790 {
t.Fatalf("Float64 = %v", a.Float64())
}
}
func TestAddMoneyRejectsMixedCurrency(t *testing.T) {
_, err := AddMoney(MoneyOf(NewAmountFromFloat(1), "CNY"), MoneyOf(NewAmountFromFloat(1), "USD"))
if err == nil {
t.Fatal("expected mixed currency error")
}
}
func TestAddMoneySameCurrency(t *testing.T) {
sum, err := AddMoney(MoneyOf(NewAmountFromFloat(1.5), "USD"), MoneyOf(NewAmountFromFloat(2.25), "USD"))
if err != nil {
t.Fatal(err)
}
if sum.Currency != "USD" || sum.Amount != "3.75" {
t.Fatalf("sum = %+v", sum)
}
}
func TestNormalizeCurrency(t *testing.T) {
cases := map[string]string{
"¥": "CNY", "$": "USD", "rmb": "CNY", "usd": "USD", "eur": "EUR",
}
for in, want := range cases {
if got := NormalizeCurrency(in); got != want {
t.Errorf("NormalizeCurrency(%q) = %q, want %q", in, got, want)
}
}
}
func TestOriginalCostMatchesLegacySemantics(t *testing.T) {
// Same as provider.Pricing.Cost cache-write split test: miss 500k, write 100k billed 200k, input rate 2 → 1.2
amt := OriginalCostAmount(RateCard{Input: 2, Currency: "USD"}, UsageTokens{
CacheMissTokens: 500_000, CacheWriteTokens: 100_000, CacheWriteBilledTokens: 200_000,
})
if got := amt.Float64(); got < 1.199 || got > 1.201 {
t.Fatalf("cost = %v, want ~1.2", got)
}
}
@@ -0,0 +1,29 @@
package pricing
import "testing"
func TestIsOfficialCardRecognisesEveryGenerationWeShipped(t *testing.T) {
for _, tc := range []struct {
name string
model string
card RateCard
want bool
}{
{"current base", "deepseek-flash", RateCard{CacheHit: 0.003, Input: 0.15, Output: 0.6, Currency: "USD"}, true},
{"current peak", "deepseek-flash", RateCard{CacheHit: 0.006, Input: 0.3, Output: 1.2, Currency: "USD"}, true},
{"superseded base", "deepseek-flash", RateCard{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: "USD"}, true},
{"superseded peak", "deepseek-flash", RateCard{CacheHit: 0.014, Input: 0.44, Output: 1.32, Currency: "USD"}, true},
{"retired name prices as flash", "deepseek-v4-flash", RateCard{CacheHit: 0.003, Input: 0.15, Output: 0.6, Currency: "USD"}, true},
{"cny card", "deepseek-flash", RateCard{CacheHit: 0.02, Input: 1, Output: 4, Currency: "CNY"}, true},
{"peak numbers with the cached price missing", "deepseek-flash", RateCard{CacheHit: 0, Input: 0.3, Output: 1.2, Currency: "USD"}, false},
{"cny numbers labelled usd", "deepseek-flash", RateCard{CacheHit: 0.02, Input: 1, Output: 4, Currency: "USD"}, false},
{"a model the book does not price", "deepseek-unknown", RateCard{CacheHit: 0.003, Input: 0.15, Output: 0.6, Currency: "USD"}, false},
} {
if got := IsOfficialCard("deepseek", tc.model, tc.card); got != tc.want {
t.Errorf("%s: IsOfficialCard = %v, want %v", tc.name, got, tc.want)
}
}
if IsOfficialCard("someone-else", "deepseek-flash", RateCard{CacheHit: 0.003, Input: 0.15, Output: 0.6, Currency: "USD"}) {
t.Error("another vendor's name matched DeepSeek's table")
}
}
@@ -0,0 +1,790 @@
package pricing
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"slices"
"strings"
"time"
)
// Billing modes.
const (
BillingModePAYG = "payg"
BillingModeSubscriptionEquivalent = "subscription_equivalent"
)
// Valuation basis values.
const (
BasisIdentity = "identity"
BasisOfficialTable = "official_table"
// BasisFX is retained for decoding pre-v6 persisted quotes only. New
// quotes never create FX valuations.
BasisFX = "fx"
)
// DisplayStatus describes whether a quote can satisfy its requested display.
// It is deliberately separate from CostComplete: a known price-book amount
// can exist even when no requested regional valuation is available.
const (
DisplayStatusMatched = "matched"
DisplayStatusFallbackOriginal = "fallback_original"
DisplayStatusBucketed = "bucketed"
DisplayStatusUnavailable = "unavailable"
)
// Aggregate modes describe how a session/run total was formed.
const (
AggregateModeSingleCurrency = "single_currency"
AggregateModeCommonValuation = "common_valuation"
AggregateModeCurrencyBuckets = "currency_buckets"
)
// Rate bands name which side of a vendor's peak window a call was billed on.
// An aggregate is mixed when its calls fell on both sides.
const (
RateBandPeak = "peak"
RateBandOffPeak = "off_peak"
RateBandMixed = "mixed"
)
// DisplayRequest identifies how a caller selected the requested presentation
// currency. The source is runtime context only; it is never persisted as a
// provider price or wallet fact.
type DisplayRequest struct {
Currency string `json:"currency,omitempty"`
Source string `json:"source,omitempty"`
}
const (
DisplaySourceExplicit = "explicit"
DisplaySourceWallet = "wallet"
DisplaySourceAuto = "auto"
)
func (r DisplayRequest) NormalizedCurrency() string {
return NormalizeCurrency(r.Currency)
}
// CostQuote is the host-side quoted cost for one model call (or an aggregate).
// Original is the pricing-table currency fact; valuations hold identity or
// official regional rate-card estimates. Selected is the display pick for the
// current preference.
type CostQuote struct {
Original Money `json:"original"`
// OriginalTotals is populated for mixed-currency aggregates. Individual
// quotes and single-currency totals may omit it.
OriginalTotals []Money `json:"originalTotals,omitempty"`
Valuations map[string]Valuation `json:"valuations,omitempty"`
Selected *Money `json:"selected,omitempty"`
BillingMode string `json:"billingMode,omitempty"`
Estimated bool `json:"estimated"`
// CostComplete means usage and the price-book amount are known. It does not
// imply that a requested display currency is available.
CostComplete bool `json:"costComplete"`
// Coverage is the same fact with the states a boolean cannot hold: an
// aggregate that priced only some of its calls, and one that billed nothing.
Coverage string `json:"coverage,omitempty"`
// DisplayComplete means a single selected amount satisfies the display
// request. Complete is retained as the old wire alias.
DisplayComplete bool `json:"displayComplete"`
Complete bool `json:"complete"`
DisplayStatus string `json:"displayStatus,omitempty"`
AggregateMode string `json:"aggregateMode,omitempty"`
// ModelRef / UsageSource / PricingFingerprint identify the ledger key.
ModelRef string `json:"modelRef,omitempty"`
UsageSource string `json:"usageSource,omitempty"`
PricingFingerprint string `json:"pricingFingerprint,omitempty"`
RateDate string `json:"rateDate,omitempty"` // YYYY-MM-DD of FX used, if any
IncompleteReason string `json:"incompleteReason,omitempty"`
LegacyEstimate bool `json:"legacyEstimate,omitempty"`
CatalogSource string `json:"catalogSource,omitempty"`
// RateBand is set only for a rate confirmed as a vendor's own schedule; an
// aggregate that includes any call without one carries none.
RateBand string `json:"rateBand,omitempty"`
}
// Valuation is one currency view of a cost fact.
type Valuation struct {
Money Money `json:"money"`
Basis string `json:"basis"`
Source string `json:"source"`
AsOf string `json:"asOf"` // YYYY-MM-DD
Rate *RateSnapshot `json:"rateSnapshot,omitempty"`
Stale bool `json:"stale,omitempty"`
}
// RateSnapshot records the FX observation used for a valuation.
type RateSnapshot struct {
Base string `json:"base"`
Quote string `json:"quote"`
Rate float64 `json:"rate"`
Source string `json:"source"`
AsOf string `json:"asOf"`
Stale bool `json:"stale,omitempty"`
}
// RateCard is the per-1M-token price used to compute original cost. It mirrors
// provider.Pricing without importing that package (billing is a leaf).
type RateCard struct {
CacheHit float64 // per 1M cached prompt tokens
Input float64 // per 1M uncached prompt tokens
Output float64 // per 1M completion tokens
Currency string // ISO or symbol; normalized on quote
}
// UsageTokens is the token breakdown needed for cost. Mirrors provider.Usage
// fields without importing provider.
type UsageTokens struct {
PromptTokens int
CompletionTokens int
CacheHitTokens int
CacheMissTokens int
CacheWriteTokens int
CacheWriteBilledTokens float64
Estimated bool
}
// QuoteInput drives a single CostQuote computation.
type QuoteInput struct {
Usage UsageTokens
Rates RateCard
OccurredAt time.Time
DisplayCurrency string // compatibility alias for Display.Currency
Display DisplayRequest // runtime display request; empty means auto
BillingMode string
ModelRef string
UsageSource string
CatalogSource string
PricingFingerprint string
// ProviderKind is deepseek|longcat|mimo|… for official dual-table lookup.
// When empty, ModelRef and Rates are used to infer a catalog match.
ProviderKind string
// ModelID is the bare model id (e.g. deepseek-v4-flash). Empty → parse ModelRef.
ModelID string
}
// OriginalCostAmount computes the fixed-point original cost from rates + tokens.
// Mirrors the historic provider.Pricing.Cost semantics.
func OriginalCostAmount(rates RateCard, u UsageTokens) Amount {
hit := u.CacheHitTokens
miss := u.CacheMissTokens
if hit+miss == 0 && u.PromptTokens > 0 {
miss = u.PromptTokens
} else if miss == 0 && hit > 0 && u.PromptTokens > hit {
miss = u.PromptTokens - hit
}
write := u.CacheWriteTokens
write = max(write, 0)
write = min(write, miss)
billedWrite := 0.0
if write > 0 {
billedWrite = u.CacheWriteBilledTokens
if billedWrite <= 0 {
billedWrite = float64(write)
}
}
inputTokenUnits := float64(miss-write) + billedWrite
// Combine cached input, uncached input, and output charges per million tokens.
total := (float64(hit)*rates.CacheHit +
inputTokenUnits*rates.Input +
float64(u.CompletionTokens)*rates.Output) / 1e6
return NewAmountFromFloat(total)
}
// PricingFingerprint hashes a rate card for ledger keys.
func PricingFingerprint(rates RateCard) string {
cur := NormalizeCurrency(rates.Currency)
raw := fmt.Sprintf("%s|%.12g|%.12g|%.12g", cur, rates.CacheHit, rates.Input, rates.Output)
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:8])
}
// BuildQuote computes original cost and CNY/USD valuations.
func BuildQuote(in QuoteInput) CostQuote {
state := newQuoteBuildState(in)
for _, target := range []string{"CNY", "USD"} {
state.addTargetValuation(target)
}
state.selectDisplay()
return state.quote
}
type quoteBuildState struct {
input QuoteInput
currency string
amount Amount
occurred time.Time
official CatalogEntry
isOfficial bool
valuationFailures map[string]string
quote CostQuote
}
func newQuoteBuildState(in QuoteInput) *quoteBuildState {
currency := NormalizeCurrency(in.Rates.Currency)
if currency == "" {
currency = "CNY"
}
mode := strings.TrimSpace(in.BillingMode)
if mode == "" {
mode = BillingModePAYG
}
occurred := in.OccurredAt
if occurred.IsZero() {
occurred = time.Now().UTC()
}
fingerprint := strings.TrimSpace(in.PricingFingerprint)
if fingerprint == "" {
fingerprint = PricingFingerprint(in.Rates)
}
amount := OriginalCostAmount(in.Rates, in.Usage)
q := CostQuote{
Original: MoneyOf(amount, currency),
Valuations: map[string]Valuation{},
BillingMode: mode,
Estimated: true,
CostComplete: true,
Coverage: CoverageComplete,
DisplayComplete: true,
Complete: true,
DisplayStatus: DisplayStatusMatched,
AggregateMode: AggregateModeSingleCurrency,
ModelRef: strings.TrimSpace(in.ModelRef),
UsageSource: strings.TrimSpace(in.UsageSource),
PricingFingerprint: fingerprint,
CatalogSource: strings.TrimSpace(in.CatalogSource),
}
q.Valuations[currency] = Valuation{
Money: q.Original, Basis: BasisIdentity,
Source: firstNonEmpty(in.CatalogSource, "rate_card"),
AsOf: occurred.UTC().Format("2006-01-02"),
}
state := &quoteBuildState{
input: in, currency: currency, amount: amount, occurred: occurred,
valuationFailures: map[string]string{}, quote: q,
}
if !usageHasFacts(in.Usage) {
state.markIncomplete("missing_price_or_usage")
}
state.matchOfficialCatalog()
state.applyOfficialSchedule()
return state
}
// applyOfficialSchedule re-prices the turn at the vendor's rate for the hour it
// actually happened. Only a rate confirmed as the vendor's own earns this: a
// custom price is the user's own arrangement, and projecting someone else's
// peak schedule onto it would invent a number nobody published.
func (s *quoteBuildState) applyOfficialSchedule() {
if !s.isOfficial || s.official.Peak == nil {
return
}
s.quote.RateBand = RateBandOffPeak
if s.official.Window.IsPeak(s.occurred) {
s.quote.RateBand = RateBandPeak
}
rates := s.official.RatesAt(s.occurred)
if rates.CacheHit == s.input.Rates.CacheHit && rates.Input == s.input.Rates.Input && rates.Output == s.input.Rates.Output {
return
}
s.amount = OriginalCostAmount(rates, s.input.Usage)
s.quote.Original = MoneyOf(s.amount, s.currency)
s.quote.PricingFingerprint = PricingFingerprint(rates)
s.quote.Valuations[s.currency] = Valuation{
Money: s.quote.Original, Basis: BasisOfficialTable,
Source: s.official.DocURL,
AsOf: s.occurred.UTC().Format("2006-01-02"),
}
}
func quoteDisplayRequest(in QuoteInput) DisplayRequest {
request := in.Display
if request.Currency == "" && in.DisplayCurrency != "" {
request.Currency = in.DisplayCurrency
if request.Source == "" {
request.Source = DisplaySourceExplicit
}
}
if request.Source == "" {
request.Source = DisplaySourceAuto
}
return request
}
func usageHasFacts(u UsageTokens) bool {
return u.PromptTokens > 0 || u.CompletionTokens > 0 || u.CacheHitTokens > 0 ||
u.CacheMissTokens > 0 || u.CacheWriteTokens > 0 || u.CacheWriteBilledTokens > 0
}
func (s *quoteBuildState) matchOfficialCatalog() {
providerKind, modelID := resolveCatalogIdentity(s.input)
s.official, s.isOfficial = MatchesCatalog(providerKind, modelID, s.input.Rates)
if !s.isOfficial && providerKind != "" && modelID != "" {
rates := s.input.Rates
rates.Currency = s.currency
s.official, s.isOfficial = MatchesCatalog(providerKind, modelID, rates)
}
if s.isOfficial && s.quote.CatalogSource == "" {
s.quote.CatalogSource = s.official.DocURL
}
}
func (s *quoteBuildState) addTargetValuation(target string) {
if target == s.currency {
return
}
if s.addOfficialValuation(target) {
return
}
// Runtime FX has intentionally been removed. A custom price that does not
// match the official catalog remains in its original currency.
s.valuationFailures[target] = "display_unavailable"
}
func (s *quoteBuildState) addOfficialValuation(target string) bool {
if !s.isOfficial {
return false
}
peer, ok := LookupCatalog(
s.official.Provider, s.official.Model, target, s.official.BillingMode,
)
if !ok {
return false
}
s.quote.Valuations[target] = Valuation{
Money: MoneyOf(OriginalCostAmount(peer.RatesAt(s.occurred), s.input.Usage), target),
Basis: BasisOfficialTable, Source: peer.DocURL,
AsOf: s.occurred.UTC().Format("2006-01-02"),
}
return true
}
func (s *quoteBuildState) selectDisplay() {
if !s.quote.CostComplete {
s.quote.Selected = nil
s.quote.DisplayComplete = false
s.quote.Complete = false
s.quote.DisplayStatus = DisplayStatusUnavailable
return
}
display := quoteDisplayRequest(s.input).NormalizedCurrency()
if display == "" {
selected := s.quote.Original
s.quote.Selected = &selected
return
}
if valuation, ok := s.quote.Valuations[display]; ok {
selected := valuation.Money
s.quote.Selected = &selected
return
}
// The original price-book amount is still a valid cost fact. Keep it as a
// visible fallback and distinguish the display mismatch from no pricing.
selected := s.quote.Original
s.quote.Selected = &selected
s.quote.DisplayComplete = false
s.quote.Complete = false
s.quote.DisplayStatus = DisplayStatusFallbackOriginal
s.quote.IncompleteReason = firstNonEmpty(s.valuationFailures[display], "display_unavailable")
}
func (s *quoteBuildState) markIncomplete(reason string) {
s.quote.CostComplete = false
s.quote.Coverage = CoverageIncomplete
s.quote.DisplayComplete = false
s.quote.Complete = false
s.quote.DisplayStatus = DisplayStatusUnavailable
if s.quote.IncompleteReason == "" {
s.quote.IncompleteReason = reason
}
}
// SelectForDisplay returns the money for a display currency preference without
// recomputing rates. Empty display returns original.
func (q CostQuote) SelectForDisplay(display string) (Money, bool) {
display = NormalizeCurrency(display)
if display == "" {
return q.Original, true
}
if v, ok := q.Valuations[display]; ok {
return v.Money, true
}
if NormalizeCurrency(q.Original.Currency) == display {
return q.Original, true
}
return Money{}, false
}
// WithSelected returns a copy with Selected set for display.
func (q CostQuote) WithSelected(display string) CostQuote {
out := q
if m, ok := q.SelectForDisplay(display); ok {
out.Selected = &m
if quoteHasCompleteCostFact(q) {
out.Complete = true
out.IncompleteReason = ""
}
} else {
out.Selected = nil
out.Complete = false
if out.IncompleteReason == "" {
out.IncompleteReason = "display_unavailable"
}
}
return out
}
// LegacyCostFloat returns the selected (or original) amount as float64 for
// compatibility aliases cost / costUsd / total_cost.
func (q CostQuote) LegacyCostFloat() float64 {
if q.Selected != nil {
return q.Selected.Float64()
}
return q.Original.Float64()
}
// LegacyCurrencySymbol returns a display symbol for the selected/original currency.
func (q CostQuote) LegacyCurrencySymbol() string {
if q.Selected != nil && q.Selected.Currency != "" {
return CurrencySymbol(q.Selected.Currency)
}
return CurrencySymbol(q.Original.Currency)
}
// LegacyCurrencyCode returns the ISO code for selected/original.
func (q CostQuote) LegacyCurrencyCode() string {
if q.Selected != nil && q.Selected.Currency != "" {
return NormalizeCurrency(q.Selected.Currency)
}
return NormalizeCurrency(q.Original.Currency)
}
// AggregateQuotes combines occurrence-time quotes into a session/run total.
// Every entry must have the requested valuation; partial totals stay unavailable.
func AggregateQuotes(quotes []CostQuote, display string) CostQuote {
if len(quotes) == 0 {
return emptyAggregate(NormalizeCurrency(display))
}
accumulator := newQuoteAccumulator(display)
for _, quote := range quotes {
accumulator.add(quote)
}
return accumulator.finish()
}
type quoteAccumulator struct {
out CostQuote
display string
totals map[string]Amount
originalCurrency string
originalTotal Amount
originalTotals map[string]Amount
originalComplete bool
coverage string
displayComplete bool
modes map[string]struct{}
rateBands map[string]struct{}
unbanded bool
}
func emptyAggregate(display string) CostQuote {
out := CostQuote{
Original: MoneyOf(Zero, display), Valuations: map[string]Valuation{}, Estimated: true,
CostComplete: false, DisplayComplete: false, Complete: false,
Coverage: CoverageNone,
DisplayStatus: DisplayStatusUnavailable, AggregateMode: AggregateModeSingleCurrency,
IncompleteReason: "no_usage",
}
return out
}
func newQuoteAccumulator(display string) *quoteAccumulator {
return &quoteAccumulator{
out: CostQuote{Valuations: map[string]Valuation{}, Estimated: true},
display: NormalizeCurrency(display), totals: map[string]Amount{}, originalTotals: map[string]Amount{},
originalComplete: true, coverage: CoverageNone, displayComplete: true,
modes: map[string]struct{}{}, rateBands: map[string]struct{}{},
}
}
func (a *quoteAccumulator) add(quote CostQuote) {
quote = NormalizeQuote(quote)
a.coverage = FoldCoverage(a.coverage, quote.Coverage)
if quote.Coverage != CoverageComplete && a.out.IncompleteReason == "" {
a.out.IncompleteReason = quote.IncompleteReason
}
a.out.LegacyEstimate = a.out.LegacyEstimate || quote.LegacyEstimate
if quote.BillingMode != "" {
a.modes[quote.BillingMode] = struct{}{}
}
a.addRateBand(quote.RateBand)
if quote.RateDate > a.out.RateDate {
a.out.RateDate = quote.RateDate
}
originalCurrency := NormalizeCurrency(quote.Original.Currency)
if len(quote.OriginalTotals) > 0 {
for _, original := range quote.OriginalTotals {
a.addOriginal(original, NormalizeCurrency(original.Currency))
}
} else {
a.addOriginal(quote.Original, originalCurrency)
}
if a.display != "" {
_, found := quote.Valuations[a.display]
a.displayComplete = a.displayComplete && (found || originalCurrency == a.display)
}
originalIncluded := false
for code, valuation := range quote.Valuations {
code = NormalizeCurrency(code)
if code == originalCurrency {
originalIncluded = true
}
a.addValuation(code, valuation)
}
if originalCurrency != "" && !originalIncluded {
a.addValuation(originalCurrency, Valuation{
Money: quote.Original, Basis: BasisIdentity, Source: "aggregate",
AsOf: time.Now().UTC().Format("2006-01-02"),
})
}
}
func (a *quoteAccumulator) addRateBand(band string) {
switch band {
case RateBandPeak, RateBandOffPeak:
a.rateBands[band] = struct{}{}
case RateBandMixed:
a.rateBands[RateBandPeak], a.rateBands[RateBandOffPeak] = struct{}{}, struct{}{}
default:
a.unbanded = true
}
}
func (a *quoteAccumulator) rateBand() string {
if a.unbanded {
return ""
}
switch len(a.rateBands) {
case 1:
for band := range a.rateBands {
return band
}
case 2:
return RateBandMixed
}
return ""
}
func (a *quoteAccumulator) addOriginal(original Money, currency string) {
if currency != "" {
a.originalTotals[currency] = a.originalTotals[currency].Add(original.AmountValue())
}
if a.originalCurrency == "" {
a.originalCurrency = currency
a.originalTotal = original.AmountValue()
return
}
if currency != a.originalCurrency {
a.originalComplete = false
return
}
a.originalTotal = a.originalTotal.Add(original.AmountValue())
}
func (a *quoteAccumulator) addValuation(code string, valuation Valuation) {
if code == "" {
return
}
a.totals[code] = a.totals[code].Add(valuation.Money.AmountValue())
current, found := a.out.Valuations[code]
if !found {
current = valuation
}
current.Money = MoneyOf(a.totals[code], code)
current.Stale = current.Stale || valuation.Stale
a.out.Valuations[code] = current
}
// costFactsComplete reads the accumulated coverage rather than tracking the
// same fact a second time: two answers to one question drift.
func (a *quoteAccumulator) costFactsComplete() bool {
return a.coverage == CoverageComplete
}
func (a *quoteAccumulator) finish() CostQuote {
a.out.Coverage = a.coverage
a.out.CostComplete = a.costFactsComplete()
if a.originalComplete && a.originalCurrency != "" {
a.out.Original = MoneyOf(a.originalTotal, a.originalCurrency)
a.syncOriginalValuation()
} else {
a.out.Original = Money{Amount: "0"}
}
if len(a.originalTotals) > 1 {
codes := make([]string, 0, len(a.originalTotals))
for code := range a.originalTotals {
codes = append(codes, code)
}
slices.Sort(codes)
for _, code := range codes {
a.out.OriginalTotals = append(a.out.OriginalTotals, MoneyOf(a.originalTotals[code], code))
}
}
a.out.RateBand = a.rateBand()
if len(a.modes) == 1 {
for mode := range a.modes {
a.out.BillingMode = mode
}
}
if a.display == "" && a.originalComplete && a.costFactsComplete() {
selected := a.out.Original
a.out.Selected = &selected
a.out.DisplayComplete = true
a.out.Complete = true
a.out.DisplayStatus = DisplayStatusMatched
a.out.AggregateMode = AggregateModeSingleCurrency
return a.out
}
valuation, found := a.out.Valuations[a.display]
if found && a.displayComplete && a.costFactsComplete() {
selected := valuation.Money
a.out.Selected = &selected
a.out.DisplayComplete = true
a.out.Complete = true
a.out.DisplayStatus = DisplayStatusMatched
if len(a.originalTotals) > 1 {
a.out.AggregateMode = AggregateModeCommonValuation
} else {
a.out.AggregateMode = AggregateModeSingleCurrency
}
return a.out
}
a.out.DisplayComplete = false
a.out.Complete = false
if !a.costFactsComplete() {
a.out.DisplayStatus = DisplayStatusUnavailable
a.out.IncompleteReason = firstNonEmpty(a.out.IncompleteReason, "incomplete_cost_fact")
} else if a.originalComplete && a.originalCurrency != "" {
selected := a.out.Original
a.out.Selected = &selected
a.out.DisplayStatus = DisplayStatusFallbackOriginal
a.out.AggregateMode = AggregateModeSingleCurrency
a.out.IncompleteReason = firstNonEmpty(a.out.IncompleteReason, "display_unavailable")
} else {
a.out.DisplayStatus = DisplayStatusBucketed
a.out.AggregateMode = AggregateModeCurrencyBuckets
a.out.IncompleteReason = firstNonEmpty(a.out.IncompleteReason, "currency_buckets")
}
return a.out
}
// NormalizeQuote fills fields introduced after the first CostQuote wire shape.
// It is used at every persistence and transport boundary so old telemetry and
// old eventwire payloads remain safe without inventing a new amount.
func NormalizeQuote(q CostQuote) CostQuote {
if !q.CostComplete && !q.DisplayComplete && q.Complete {
q.CostComplete = true
q.DisplayComplete = true
}
if q.DisplayStatus != "" && q.DisplayStatus != DisplayStatusMatched && q.DisplayStatus != DisplayStatusFallbackOriginal && q.DisplayStatus != DisplayStatusBucketed && q.DisplayStatus != DisplayStatusUnavailable {
q.DisplayStatus = ""
}
if q.RateBand != RateBandPeak && q.RateBand != RateBandOffPeak && q.RateBand != RateBandMixed {
q.RateBand = ""
}
if q.AggregateMode != "" && q.AggregateMode != AggregateModeSingleCurrency && q.AggregateMode != AggregateModeCommonValuation && q.AggregateMode != AggregateModeCurrencyBuckets {
q.AggregateMode = ""
}
if q.DisplayStatus == "" {
switch {
case q.Complete:
q.DisplayStatus = DisplayStatusMatched
case q.Original.Currency != "" && q.Original.Amount != "" && q.IncompleteReason != "no_price":
q.DisplayStatus = DisplayStatusFallbackOriginal
default:
q.DisplayStatus = DisplayStatusUnavailable
}
}
if q.AggregateMode == "" {
if len(q.OriginalTotals) > 1 {
q.AggregateMode = AggregateModeCurrencyBuckets
} else if q.Selected != nil {
q.AggregateMode = AggregateModeSingleCurrency
}
}
if !ValidCoverage(q.Coverage) {
q.Coverage = CoverageIncomplete
if quoteHasCompleteCostFact(q) {
q.Coverage = CoverageComplete
}
}
q.Complete = q.DisplayComplete
return q
}
// quoteHasCompleteCostFact separates a known original-currency charge from a
// display-only valuation failure must not poison an exact original total,
// while unpriced and unrecoverable legacy records stay incomplete in every
// display currency.
func quoteHasCompleteCostFact(q CostQuote) bool {
if q.CostComplete {
return true
}
switch q.IncompleteReason {
case "no_price", "missing_price_or_usage", "legacy_unrecoverable",
"legacy_wiped_or_zero", "legacy_invalid_amount", "mixed_original_currencies",
"incomplete_cost_fact":
return false
}
if q.Complete {
return true
}
currency := NormalizeCurrency(q.Original.Currency)
if currency == "" {
return false
}
valuation, ok := q.Valuations[currency]
return ok && SameCurrency(valuation.Money.Currency, currency)
}
func (a *quoteAccumulator) syncOriginalValuation() {
valuation, found := a.out.Valuations[a.originalCurrency]
if !found {
valuation = Valuation{
Basis: BasisIdentity, Source: "aggregate",
AsOf: time.Now().UTC().Format("2006-01-02"),
}
}
valuation.Money = a.out.Original
a.out.Valuations[a.originalCurrency] = valuation
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
// resolveCatalogIdentity names which official table, if any, may price a quote.
// The vendor comes only from ProviderKind, which the caller reads off the
// endpoint's host; guessing it from the entry's name or the model id would bill
// a relay as the vendor it resells. Unnamed means no official table — the user's
// own rate card, which is right rather than merely plausible.
func resolveCatalogIdentity(in QuoteInput) (provider, model string) {
provider = strings.ToLower(strings.TrimSpace(in.ProviderKind))
model = strings.TrimSpace(in.ModelID)
if model == "" {
ref := strings.TrimSpace(in.ModelRef)
if i := strings.LastIndex(ref, "/"); i >= 0 && i+1 < len(ref) {
model = ref[i+1:]
} else {
model = ref
}
}
return provider, model
}
@@ -0,0 +1,128 @@
package pricing
import (
"testing"
"time"
)
func testInput(currency, provider, model string) QuoteInput {
rates := RateCard{CacheHit: 0.02, Input: 1, Output: 4, Currency: currency}
if currency == "USD" {
rates = RateCard{CacheHit: 0.003, Input: 0.15, Output: 0.6, Currency: currency}
}
return QuoteInput{
Usage: UsageTokens{PromptTokens: 1000, CompletionTokens: 2000},
Rates: rates,
DisplayCurrency: currency,
ModelRef: model,
ProviderKind: provider,
ModelID: model,
}
}
func TestBuildQuoteIdentityAndOfficialTableOnly(t *testing.T) {
in := testInput("USD", "deepseek", "deepseek-v4-flash")
in.DisplayCurrency = "CNY"
q := BuildQuote(in)
if q.Original.Currency != "USD" || q.Original.Amount == "0" {
t.Fatalf("original = %+v", q.Original)
}
v, ok := q.Valuations["CNY"]
if !ok || v.Basis != BasisOfficialTable {
t.Fatalf("CNY valuation = %+v, ok=%v", v, ok)
}
if q.Selected == nil || q.Selected.Currency != "CNY" || !q.Complete || !q.CostComplete || !q.DisplayComplete {
t.Fatalf("quote completeness/selection = %+v", q)
}
}
func TestBuildQuoteCustomPriceFallsBackWithoutFX(t *testing.T) {
in := testInput("CNY", "deepseek", "deepseek-v4-flash")
in.Rates.Input = 99 // no longer matches the official table
in.DisplayCurrency = "USD"
q := BuildQuote(in)
if len(q.Valuations) != 1 || q.Valuations["CNY"].Basis != BasisIdentity {
t.Fatalf("unexpected valuations: %+v", q.Valuations)
}
if q.Selected == nil || q.Selected.Currency != "CNY" || q.Complete || q.DisplayComplete || !q.CostComplete {
t.Fatalf("fallback quote = %+v", q)
}
if q.DisplayStatus != DisplayStatusFallbackOriginal || q.Valuations["USD"].Basis == BasisFX {
t.Fatalf("fallback status/FX = %+v", q)
}
}
func TestAggregateMixedCurrenciesProducesBuckets(t *testing.T) {
a := BuildQuote(QuoteInput{Usage: UsageTokens{PromptTokens: 1}, Rates: RateCard{Input: 1, Currency: "CNY"}})
b := BuildQuote(QuoteInput{Usage: UsageTokens{PromptTokens: 1}, Rates: RateCard{Input: 1, Currency: "USD"}})
q := AggregateQuotes([]CostQuote{a, b}, "")
if q.DisplayStatus != DisplayStatusBucketed || q.AggregateMode != AggregateModeCurrencyBuckets || q.Selected != nil {
t.Fatalf("mixed aggregate = %+v", q)
}
if len(q.OriginalTotals) != 2 || q.OriginalTotals[0].Currency != "CNY" || q.OriginalTotals[1].Currency != "USD" {
t.Fatalf("original totals = %+v", q.OriginalTotals)
}
if !q.CostComplete || q.Complete || q.DisplayComplete {
t.Fatalf("mixed completeness = %+v", q)
}
}
func TestAggregateExplicitDisplayFallsBackToSameOriginal(t *testing.T) {
a := BuildQuote(QuoteInput{Usage: UsageTokens{PromptTokens: 1}, Rates: RateCard{Input: 1, Currency: "CNY"}})
b := BuildQuote(QuoteInput{Usage: UsageTokens{PromptTokens: 1}, Rates: RateCard{Input: 2, Currency: "CNY"}})
q := AggregateQuotes([]CostQuote{a, b}, "USD")
if q.Selected == nil || q.Selected.Currency != "CNY" || q.DisplayStatus != DisplayStatusFallbackOriginal {
t.Fatalf("fallback aggregate = %+v", q)
}
if !q.CostComplete || q.DisplayComplete || q.Complete {
t.Fatalf("fallback completeness = %+v", q)
}
}
func TestNormalizeOldQuote(t *testing.T) {
q := NormalizeQuote(CostQuote{Original: MoneyOf(NewAmountFromFloat(1), "USD"), Complete: true})
if !q.CostComplete || !q.DisplayComplete || !q.Complete || q.DisplayStatus != DisplayStatusMatched {
t.Fatalf("normalized old quote = %+v", q)
}
}
func TestBuildQuoteNoPriceIsUnavailable(t *testing.T) {
q := CostQuote{Estimated: true, CostComplete: false, DisplayComplete: false, Complete: false, DisplayStatus: DisplayStatusUnavailable, IncompleteReason: "no_price"}
q = NormalizeQuote(q)
if q.DisplayStatus != DisplayStatusUnavailable || q.Complete || q.CostComplete {
t.Fatalf("unavailable = %+v", q)
}
}
func TestBuildQuoteMissingUsageIsUnavailable(t *testing.T) {
q := BuildQuote(QuoteInput{Rates: RateCard{Input: 1, Currency: "USD"}, DisplayCurrency: "USD"})
if q.CostComplete || q.DisplayComplete || q.Complete || q.Selected != nil || q.DisplayStatus != DisplayStatusUnavailable {
t.Fatalf("missing usage = %+v", q)
}
}
func TestAggregateNoUsageIsUnavailable(t *testing.T) {
q := AggregateQuotes(nil, "USD")
if q.CostComplete || q.DisplayComplete || q.Complete || q.Selected != nil || q.DisplayStatus != DisplayStatusUnavailable {
t.Fatalf("empty aggregate = %+v", q)
}
}
func TestLedgerMixedOriginalBucketsContinueAccumulating(t *testing.T) {
l := NewLedger()
base := func(currency string, amount string) CostQuote {
return CostQuote{
Original: Money{Amount: amount, Currency: currency},
Valuations: map[string]Valuation{currency: {Money: Money{Amount: amount, Currency: currency}, Basis: BasisIdentity}},
CostComplete: true, DisplayComplete: true, Complete: true,
DisplayStatus: DisplayStatusMatched, ModelRef: "m", PricingFingerprint: "same",
}
}
l.Add(base("CNY", "1"), UsageTokens{PromptTokens: 1}, time.Time{})
l.Add(base("USD", "2"), UsageTokens{PromptTokens: 1}, time.Time{})
l.Add(base("CNY", "3"), UsageTokens{PromptTokens: 1}, time.Time{})
q := l.Total("")
if len(q.OriginalTotals) != 2 || q.OriginalTotals[0].Amount != "4" || q.OriginalTotals[1].Amount != "2" || q.Selected != nil {
t.Fatalf("ledger buckets = %+v", q)
}
}
@@ -0,0 +1,351 @@
// rates.go — every rate this project has shipped for a vendor's model, and the
// two questions that asks: what it costs now, and what it used to cost.
package pricing
import (
"slices"
"strings"
"time"
)
// rateGeneration is one published pair. Peak is nil for a generation the vendor
// billed at a single rate around the clock.
type rateGeneration struct {
Base RateCard
Peak *RateCard
// From is the first day (YYYY-MM-DD, UTC) this generation is billed; empty
// means from when it shipped. An announced change is appended with its date.
From string
}
// today is the day a dated generation is read against; a test replaces it.
var today = func() string { return time.Now().UTC().Format("2006-01-02") }
// currentIndex is the generation in force today: the last one whose date has
// come. Rows with no dated generation answer with their last, as before.
func (r officialRateRow) currentIndex() int {
day := today()
for i := len(r.Rates) - 1; i > 0; i-- {
if r.Rates[i].From == "" || r.Rates[i].From <= day {
return i
}
}
return 0
}
// officialRateRow is one vendor's model, in one currency, under one billing
// mode, with every rate this project has shipped for it.
type officialRateRow struct {
Provider string
Model string
Currency string
BillingMode string
Window *PeakWindow
DocURL string
Notes string
CheckedOn string
// Rates is oldest first; the last entry is what this costs today.
Rates []rateGeneration
}
// officialRates is the price book. A price change appends a generation rather
// than editing one: that is what leaves the superseded rate where a stored price
// can still be recognised as this project's own default instead of a number its
// user chose, which is the difference between refreshing one and losing it.
var officialRates = []officialRateRow{
{
Provider: "deepseek", Model: "deepseek-flash", Currency: "CNY",
BillingMode: BillingModePAYG, Window: deepseekPeak, DocURL: DocDeepSeekPricing, CheckedOn: deepseekCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}},
{Base: RateCard{CacheHit: 0.05, Input: 1.5, Output: 4.5, Currency: "CNY"},
Peak: &RateCard{CacheHit: 0.10, Input: 3.0, Output: 9.0, Currency: "CNY"}},
{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 4, Currency: "CNY"},
Peak: &RateCard{CacheHit: 0.04, Input: 2, Output: 8, Currency: "CNY"}},
},
},
{
Provider: "deepseek", Model: "deepseek-flash", Currency: "USD",
BillingMode: BillingModePAYG, Window: deepseekPeak, DocURL: DocDeepSeekPricing, CheckedOn: deepseekCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.0028, Input: 0.14, Output: 0.28, Currency: "USD"}},
{Base: RateCard{CacheHit: 0.007, Input: 0.22, Output: 0.66, Currency: "USD"},
Peak: &RateCard{CacheHit: 0.014, Input: 0.44, Output: 1.32, Currency: "USD"}},
{Base: RateCard{CacheHit: 0.003, Input: 0.15, Output: 0.6, Currency: "USD"},
Peak: &RateCard{CacheHit: 0.006, Input: 0.3, Output: 1.2, Currency: "USD"}},
},
},
{
Provider: "deepseek", Model: "deepseek-v4-pro", Currency: "CNY",
BillingMode: BillingModePAYG, Window: deepseekPeak, DocURL: DocDeepSeekPricing, CheckedOn: deepseekCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}},
{Base: RateCard{CacheHit: 0.15, Input: 4.5, Output: 13.5, Currency: "CNY"},
Peak: &RateCard{CacheHit: 0.30, Input: 9.0, Output: 27.0, Currency: "CNY"}},
},
},
{
Provider: "deepseek", Model: "deepseek-v4-pro", Currency: "USD",
BillingMode: BillingModePAYG, Window: deepseekPeak, DocURL: DocDeepSeekPricing, CheckedOn: deepseekCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.003625, Input: 0.435, Output: 0.87, Currency: "USD"}},
{Base: RateCard{CacheHit: 0.022, Input: 0.66, Output: 1.98, Currency: "USD"},
Peak: &RateCard{CacheHit: 0.044, Input: 1.32, Output: 3.96, Currency: "USD"}},
},
},
// LongCat's launch discount, for which the vendor publishes no end date. List
// price is CNY 0.10/5/20, USD 0.015/0.75/2.95 — what these revert to, and why
// CheckedOn earns its place here.
{
Provider: "longcat", Model: "LongCat-2.0", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocLongCatPricingCNY, Notes: "launch_discount_no_end_date", CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.04, Input: 2, Output: 8, Currency: "CNY"}}},
},
{
Provider: "longcat", Model: "LongCat-2.0", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocLongCatPricingUSD, Notes: "launch_discount_no_end_date", CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.006, Input: 0.30, Output: 1.20, Currency: "USD"}}},
},
// Google: the two flash models double on 2027-01-01, hence the dated generation.
// gemini-3.1-pro-preview is the rate up to 200k prompt tokens; gemini-4-argon has no price.
{
Provider: "google", Model: "gemini-3.8-flash", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocGeminiPricing, CheckedOn: googleCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.075, Input: 0.75, Output: 3.75, Currency: "USD"}},
{From: "2027-01-01", Base: RateCard{CacheHit: 0.15, Input: 1.50, Output: 7.50, Currency: "USD"}},
},
},
{
Provider: "google", Model: "gemini-3.6-flash", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocGeminiPricing, CheckedOn: googleCheckedOn,
Rates: []rateGeneration{
{Base: RateCard{CacheHit: 0.075, Input: 0.75, Output: 3.75, Currency: "USD"}},
{From: "2027-01-01", Base: RateCard{CacheHit: 0.15, Input: 1.50, Output: 7.50, Currency: "USD"}},
},
},
{
Provider: "google", Model: "gemini-3.5-flash-lite", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocGeminiPricing, CheckedOn: googleCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.03, Input: 0.30, Output: 2.50, Currency: "USD"}}},
},
{
Provider: "google", Model: "gemini-3.1-pro-preview", Currency: "USD",
BillingMode: BillingModePAYG, DocURL: DocGeminiPricing, Notes: "up_to_200k_prompt_tokens", CheckedOn: googleCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.20, Input: 2.00, Output: 12.00, Currency: "USD"}}},
},
// MiMo domestic PAYG; Token Plan bills the same rates as subscription_equivalent.
{
Provider: "mimo", Model: "mimo-v2.5-pro", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.5", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}}},
},
// Not on the vendor's price page as of CheckedOn. Kept because removing a rate
// is what turns a still-configured model's cost silently into zero; a stale
// rate at least reads as a number somebody can question.
{
Provider: "mimo", Model: "mimo-v2-flash", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, Notes: "not_on_vendor_page", CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.07, Input: 0.70, Output: 2.10, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.5-pro", Currency: "CNY",
BillingMode: BillingModeSubscriptionEquivalent, DocURL: DocMiMoTokenPlan, Notes: "payg_equivalent_not_plan_bill", CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.5", Currency: "CNY",
BillingMode: BillingModeSubscriptionEquivalent, DocURL: DocMiMoTokenPlan, Notes: "payg_equivalent_not_plan_bill", CheckedOn: checkedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.6-pro", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, CheckedOn: mimoCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.6-flash", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, CheckedOn: mimoCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.6-pro-ultraspeed", Currency: "CNY",
BillingMode: BillingModePAYG, DocURL: DocMiMoPAYG, CheckedOn: mimoCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.25, Input: 30, Output: 60, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.6-pro", Currency: "CNY",
BillingMode: BillingModeSubscriptionEquivalent, DocURL: DocMiMoTokenPlan, Notes: "payg_equivalent_not_plan_bill", CheckedOn: mimoCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.025, Input: 3, Output: 6, Currency: "CNY"}}},
},
{
Provider: "mimo", Model: "mimo-v2.6-flash", Currency: "CNY",
BillingMode: BillingModeSubscriptionEquivalent, DocURL: DocMiMoTokenPlan, Notes: "payg_equivalent_not_plan_bill", CheckedOn: mimoCheckedOn,
Rates: []rateGeneration{{Base: RateCard{CacheHit: 0.02, Input: 1, Output: 2, Currency: "CNY"}}},
},
}
// retiredDeepSeekFlashModels are names DeepSeek still answers as its current
// flash model, at that model's price. Owned here because that is where the rates
// they bill at live; config reads them through the accessor below.
var retiredDeepSeekFlashModels = []string{"deepseek-v4-flash", "deepseek-v4-flash-vision-exp"}
// RetiredDeepSeekFlashModels copies the list on the way out: a caller sorting or
// trimming the result would otherwise change what every other caller sees.
func RetiredDeepSeekFlashModels() []string {
return append([]string(nil), retiredDeepSeekFlashModels...)
}
// RateModelFor answers which model's rate a name is billed at — a retired name
// at the model that replaced it, an older spelling at the one it was renamed
// from. Scoped to billing on purpose: two models can share a rate without being
// the same model, and nothing outside a price lookup may read this as identity.
func RateModelFor(provider, model string) string {
model = strings.TrimSpace(model)
switch strings.ToLower(strings.TrimSpace(provider)) {
case "deepseek":
if slices.Contains(retiredDeepSeekFlashModels, model) {
return "deepseek-flash"
}
case "mimo":
switch model {
case "mimo-v2-pro":
return "mimo-v2.5-pro"
case "mimo-v2-omni":
return "mimo-v2.5"
}
}
return model
}
// DefaultCurrency is what a vendor's rates are quoted in when an entry does not
// say. A vendor publishing one currency leaves no choice to make; DeepSeek
// prices in both, and USD is what an entry that never chose has been read as.
func DefaultCurrency(provider string) string {
provider = strings.ToLower(strings.TrimSpace(provider))
seen := map[string]bool{}
for _, row := range officialRates {
if row.Provider == provider {
seen[row.Currency] = true
}
}
if len(seen) == 1 {
for currency := range seen {
return currency
}
}
return "USD"
}
// officialRateRowsFor finds the rows for a model, which is more than one when a
// vendor publishes the same rates under two billing modes.
func officialRateRowsFor(provider, model, currency string) []officialRateRow {
provider, currency = strings.ToLower(strings.TrimSpace(provider)), NormalizeCurrency(currency)
model = RateModelFor(provider, model)
var out []officialRateRow
for _, row := range officialRates {
if row.Provider == provider && row.Model == model && row.Currency == currency {
out = append(out, row)
}
}
return out
}
// CurrentRate is what a vendor's model costs in a currency today — the off-peak
// card, which is what a caller charging one rate should quote. Nil when this
// catalog prices neither. A model published under two billing modes quotes the
// pay-as-you-go rate, which is the one an unsubscribed caller is charged.
func CurrentRate(provider, model, currency string) *RateCard {
for _, row := range officialRateRowsFor(provider, model, currency) {
if row.BillingMode == BillingModeSubscriptionEquivalent {
continue
}
if len(row.Rates) > 0 {
rate := row.Rates[row.currentIndex()].Base
return &rate
}
}
return nil
}
// IsOfficialCard reports whether card is a rate this project has shipped for the
// model in the card's own currency: any generation, its off-peak or its peak
// card. A card that is none of them is the user's, or damaged, and cannot be
// valued against the vendor's other-currency table.
func IsOfficialCard(provider, model string, card RateCard) bool {
for _, row := range officialRateRowsFor(provider, model, card.Currency) {
if row.BillingMode == BillingModeSubscriptionEquivalent {
continue
}
for _, generation := range row.Rates {
if sameCard(generation.Base, card) || generation.Peak != nil && sameCard(*generation.Peak, card) {
return true
}
}
}
return false
}
func sameCard(a, b RateCard) bool {
return a.CacheHit == b.CacheHit && a.Input == b.Input && a.Output == b.Output
}
// SupersededRates are the rates shipped for a model before the current one. A
// stored price matching one came from this project rather than from its user, so
// a refresh may replace it; anything else is theirs and is left alone.
func SupersededRates(provider, model, currency string) []RateCard {
var out []RateCard
for _, row := range officialRateRowsFor(provider, model, currency) {
if row.BillingMode == BillingModeSubscriptionEquivalent || len(row.Rates) < 2 {
continue
}
for _, generation := range row.Rates[:row.currentIndex()] {
out = append(out, generation.Base)
}
}
return out
}
// officialCatalogRows publishes the current generation of every row, plus the
// retired DeepSeek names, which bill at the flash rate under their own id.
func officialCatalogRows() []CatalogEntry {
rows := make([]CatalogEntry, 0, len(officialRates)+len(retiredDeepSeekFlashModels)*2)
for _, row := range officialRates {
if len(row.Rates) == 0 {
continue
}
current := row.Rates[row.currentIndex()]
rows = append(rows, row.entry(row.Model, current))
if row.Provider != "deepseek" || row.Model != "deepseek-flash" {
continue
}
for _, retired := range retiredDeepSeekFlashModels {
rows = append(rows, row.entry(retired, current))
}
}
return rows
}
func (r officialRateRow) entry(model string, current rateGeneration) CatalogEntry {
// Copied, so a caller holding a returned row cannot reach back into the
// table every other caller reads.
var peak *RateCard
if current.Peak != nil {
card := *current.Peak
peak = &card
}
return CatalogEntry{
Provider: r.Provider, Model: model, Currency: r.Currency,
CacheHit: current.Base.CacheHit, Input: current.Base.Input, Output: current.Base.Output,
Peak: peak, Window: r.Window, DocURL: r.DocURL,
BillingMode: r.BillingMode, Notes: r.Notes, CheckedOn: r.CheckedOn,
}
}
@@ -0,0 +1,87 @@
package pricing
import "testing"
// The history answers one question: is a stored price the current default, or a
// superseded one this project may replace? A generation repeating another in the
// same row makes that unanswerable — the current rate would match as superseded,
// and every refresh would rewrite it while reporting that something changed.
func TestRateHistoryHasNoRepeatedGenerations(t *testing.T) {
for _, row := range officialRates {
if len(row.Rates) == 0 {
t.Errorf("%s/%s/%s has no rate at all", row.Provider, row.Model, row.Currency)
continue
}
seen := map[RateCard]bool{}
for _, generation := range row.Rates {
if seen[generation.Base] {
t.Errorf("%s/%s/%s repeats the rate %+v", row.Provider, row.Model, row.Currency, generation.Base)
}
seen[generation.Base] = true
if generation.Base.Currency != row.Currency {
t.Errorf("%s/%s/%s holds a rate filed as %q", row.Provider, row.Model, row.Currency, generation.Base.Currency)
}
}
}
}
// CurrentRate returns the first row it matches, so two rows sharing all four
// identifying fields make which rate a caller gets depend on table order.
func TestOfficialRateRowsAreUniquelyIdentified(t *testing.T) {
type key struct{ provider, model, currency, mode string }
seen := map[key]bool{}
for _, row := range officialRates {
k := key{row.Provider, row.Model, row.Currency, row.BillingMode}
if seen[k] {
t.Errorf("%+v appears twice; which rate CurrentRate returns is then a matter of order", k)
}
seen[k] = true
}
}
// Superseded means "no longer charged". Returning the current rate among them
// would make every refresh a no-op that still reports having changed something.
func TestSupersededRatesExcludeTheCurrentOne(t *testing.T) {
for _, row := range officialRates {
current := CurrentRate(row.Provider, row.Model, row.Currency)
if current == nil {
if row.BillingMode != BillingModeSubscriptionEquivalent {
t.Errorf("%s/%s/%s has no current rate", row.Provider, row.Model, row.Currency)
}
continue
}
for _, superseded := range SupersededRates(row.Provider, row.Model, row.Currency) {
if superseded == *current {
t.Errorf("%s/%s/%s lists its current rate as superseded", row.Provider, row.Model, row.Currency)
}
}
}
}
// A retired name bills at the flash rate, which is the whole reason the catalog
// publishes a row for it; resolving it anywhere else would price it as nothing.
func TestRetiredDeepSeekNamesBillAtTheFlashRate(t *testing.T) {
for _, retired := range RetiredDeepSeekFlashModels() {
for _, currency := range []string{"CNY", "USD"} {
got := CurrentRate("deepseek", retired, currency)
want := CurrentRate("deepseek", "deepseek-flash", currency)
if got == nil || want == nil || *got != *want {
t.Errorf("%s/%s = %+v, want the flash rate %+v", retired, currency, got, want)
}
}
}
}
// Every row the table declares has to reach the catalog, which is what a quote
// actually reads; a rate declared but never published quotes as nothing.
func TestEveryRateRowReachesTheCatalog(t *testing.T) {
published := map[string]bool{}
for _, entry := range OfficialCatalog() {
published[entry.Provider+"/"+entry.Model+"/"+entry.Currency+"/"+entry.BillingMode] = true
}
for _, row := range officialRates {
if !published[row.Provider+"/"+row.Model+"/"+row.Currency+"/"+row.BillingMode] {
t.Errorf("%s/%s/%s/%s is declared but never published", row.Provider, row.Model, row.Currency, row.BillingMode)
}
}
}
@@ -0,0 +1,77 @@
// request_rates.go — what a vendor charges per successful request for a service
// that is not metered in tokens. Like officialRates, a change appends a
// generation and never edits one.
package pricing
import "strings"
// RequestRate is a price per 1,000 successful requests.
type RequestRate struct {
Per1000 float64
Currency string
}
// Cost is what n successful requests cost.
func (r RequestRate) Cost(n int) float64 {
return r.Per1000 * float64(n) / 1000
}
// RequestRateInfo is a rate with where it was read and when.
type RequestRateInfo struct {
RequestRate
DocURL string
CheckedOn string
}
type requestRateRow struct {
Provider string
Service string
DocURL string
CheckedOn string
// Rates is oldest first; the last entry is what the service costs today.
Rates []RequestRate
}
var requestRates = []requestRateRow{
{Provider: "deepseek", Service: "search", DocURL: DocDeepSeekPricing, CheckedOn: "2026-10-17",
Rates: []RequestRate{{Per1000: 0.75, Currency: "USD"}}},
{Provider: "deepseek", Service: "fetch", DocURL: DocDeepSeekPricing, CheckedOn: "2026-10-17",
Rates: []RequestRate{{Per1000: 0.75, Currency: "USD"}}},
}
// CurrentRequestRate is what a vendor's service costs per request today.
func CurrentRequestRate(provider, service string) (RequestRateInfo, bool) {
provider, service = strings.ToLower(strings.TrimSpace(provider)), strings.ToLower(strings.TrimSpace(service))
for _, row := range requestRates {
if row.Provider == provider && row.Service == service {
return RequestRateInfo{RequestRate: row.Rates[len(row.Rates)-1], DocURL: row.DocURL, CheckedOn: row.CheckedOn}, true
}
}
return RequestRateInfo{}, false
}
// RequestQuote prices n successful requests as a CostQuote, so a per-request
// charge enters the ledger beside token costs under its own usage source.
func RequestQuote(rate RequestRateInfo, n int, modelRef, usageSource string) CostQuote {
currency := NormalizeCurrency(rate.Currency)
original := MoneyOf(NewAmountFromFloat(rate.Cost(n)), currency)
q := CostQuote{
Original: original,
Valuations: map[string]Valuation{currency: {
Money: original, Basis: BasisOfficialTable, Source: rate.DocURL, AsOf: rate.CheckedOn,
}},
Selected: &original,
BillingMode: BillingModePAYG,
CostComplete: true,
Coverage: CoverageComplete,
DisplayComplete: true,
Complete: true,
DisplayStatus: DisplayStatusMatched,
AggregateMode: AggregateModeSingleCurrency,
ModelRef: modelRef,
UsageSource: usageSource,
CatalogSource: rate.DocURL,
}
q.PricingFingerprint = PricingFingerprint(RateCard{Input: rate.Per1000, Currency: currency})
return q
}
@@ -0,0 +1,80 @@
package pricing
import (
"testing"
"time"
)
func TestRequestRatesAreAppendOnlyAndSourced(t *testing.T) {
seen := map[string]bool{}
for _, row := range requestRates {
key := row.Provider + "/" + row.Service
if seen[key] {
t.Fatalf("%s appears twice: a change is a new generation on its row, not a second row", key)
}
seen[key] = true
if row.DocURL == "" || row.CheckedOn == "" || len(row.Rates) == 0 {
t.Fatalf("%s lacks a source, a check date or a rate", key)
}
for _, r := range row.Rates {
if r.Per1000 <= 0 || r.Currency == "" {
t.Fatalf("%s carries an unusable rate %+v", key, r)
}
}
}
}
func TestCurrentRequestRateForDeepSeekSearchAndFetch(t *testing.T) {
for _, service := range []string{"search", "fetch"} {
got, ok := CurrentRequestRate("deepseek", service)
if !ok || got.Per1000 != 0.75 || got.Currency != "USD" || got.DocURL != DocDeepSeekPricing || got.CheckedOn == "" {
t.Fatalf("%s = %+v (%v), want $0.75 per 1,000 successful requests from the pricing page", service, got, ok)
}
}
if _, ok := CurrentRequestRate("deepseek", "nonsense"); ok {
t.Fatal("an unknown service has a rate")
}
}
func TestRequestCostIsPerSuccessfulRequest(t *testing.T) {
rate, _ := CurrentRequestRate("deepseek", "search")
if got := rate.Cost(1000); got != 0.75 {
t.Fatalf("1,000 requests cost %v, want 0.75", got)
}
if got := rate.Cost(1); got != 0.00075 {
t.Fatalf("1 request costs %v, want 0.00075", got)
}
if got := rate.Cost(0); got != 0 {
t.Fatalf("no requests cost %v", got)
}
}
// A per-request charge lands in the same ledger as token costs, under its own
// source, so the usage view can show what search cost apart from the model.
func TestRequestQuoteLandsInTheLedgerUnderItsOwnSource(t *testing.T) {
rate, _ := CurrentRequestRate("deepseek", "search")
at := time.Date(2026, 10, 17, 8, 0, 0, 0, time.UTC)
q := RequestQuote(rate, 1, "deepseek-api/search", "deepseek-search")
if q.Original.Currency != "USD" || q.Original.Amount != "0.00075" {
t.Fatalf("original = %+v, want 0.00075 USD for one successful request", q.Original)
}
if !q.CostComplete || q.UsageSource != "deepseek-search" || q.ModelRef != "deepseek-api/search" {
t.Fatalf("quote = %+v", q)
}
l := NewLedger()
for range 3 {
l.Add(q, UsageTokens{}, at)
}
l.Add(BuildQuote(QuoteInput{Usage: UsageTokens{PromptTokens: 1000, CompletionTokens: 10}, Rates: RateCard{Input: 1, Output: 2, Currency: "USD"}, ModelRef: "m", UsageSource: "executor"}), UsageTokens{PromptTokens: 1000}, at)
var search LedgerEntry
var sources int
for _, e := range l.Entries {
sources++
if e.UsageSource == "deepseek-search" {
search = e
}
}
if sources != 2 || search.Quote.Original.Amount != "0.00225" {
t.Fatalf("ledger = %+v (sources %d), want search summed apart from the model: 3 requests = 0.00225 USD", l.Entries, sources)
}
}
@@ -0,0 +1,29 @@
// tariff.go — which rate a vendor's own schedule charges right now.
package pricing
import "time"
// Tariff is the vendor's schedule as it stands at one instant: whether it is in
// its peak window and, when it will flip, when.
type Tariff struct {
Peak bool
ChangesAt time.Time
Changes bool
}
// LiveTariff reports the window a model's rates are billed in at the given
// instant. It answers only for a rate confirmed as the vendor's own table and
// only where that table has a peak rate: a custom price is the user's own
// arrangement, and projecting someone else's schedule onto it would announce a
// tariff nobody charges.
func LiveTariff(providerKind, model string, rates RateCard, at time.Time) (Tariff, bool) {
entry, ok := MatchesCatalog(providerKind, model, rates)
if !ok {
return Tariff{}, false
}
if entry.Peak == nil || entry.Window == nil {
return Tariff{}, false
}
next, changes := entry.Window.NextChange(at)
return Tariff{Peak: entry.Window.IsPeak(at), ChangesAt: next, Changes: changes}, true
}
@@ -0,0 +1,69 @@
package pricing
import (
"testing"
"time"
)
func beijingTime(t *testing.T, s string) time.Time {
t.Helper()
at, err := time.ParseInLocation("2006-01-02 15:04", s, time.FixedZone("", beijing))
if err != nil {
t.Fatal(err)
}
return at
}
// The next flip is the next whole hour of the vendor's schedule where the answer
// changes, in the vendor's own zone and across a weekend.
func TestNextChangeFollowsTheVendorsScheduleAcrossAWeekend(t *testing.T) {
for _, tc := range []struct {
now, want string
peak bool
}{
{"2026-10-12 08:30", "2026-10-12 09:00", false}, // Monday before the morning peak
{"2026-10-12 11:59", "2026-10-12 12:00", true}, // the morning peak ends
{"2026-10-12 12:00", "2026-10-12 14:00", false}, // lunch
{"2026-10-12 17:30", "2026-10-12 18:00", true}, // the afternoon peak ends
{"2026-10-16 18:00", "2026-10-19 09:00", false}, // Friday evening to Monday morning
{"2026-10-17 10:00", "2026-10-19 09:00", false}, // a Saturday is off-peak all day
} {
now := beijingTime(t, tc.now)
if got := deepseekPeak.IsPeak(now); got != tc.peak {
t.Errorf("IsPeak(%s) = %v, want %v", tc.now, got, tc.peak)
}
got, ok := deepseekPeak.NextChange(now)
if !ok || !got.Equal(beijingTime(t, tc.want)) {
t.Errorf("NextChange(%s) = %v %v, want %s", tc.now, got, ok, tc.want)
}
}
}
func TestNextChangeOfAWindowThatNeverFlipsIsNone(t *testing.T) {
var w *PeakWindow
if _, ok := w.NextChange(time.Now()); ok {
t.Fatal("a nil window flips")
}
if _, ok := (&PeakWindow{OffsetSeconds: beijing}).NextChange(time.Now()); ok {
t.Fatal("a window with no hours flips")
}
}
// Only a rate confirmed as the vendor's own earns a tariff, and only where the
// vendor's table has a peak rate.
func TestLiveTariffIsTheVendorsAndNeverTheUsers(t *testing.T) {
official := *CurrentRate("deepseek", "deepseek-flash", "CNY")
at := beijingTime(t, "2026-10-12 10:00")
tariff, ok := LiveTariff("deepseek", "deepseek-flash", official, at)
if !ok || !tariff.Peak || !tariff.Changes || !tariff.ChangesAt.Equal(beijingTime(t, "2026-10-12 12:00")) {
t.Fatalf("official rate at a peak hour = %+v %v, want peak until 12:00", tariff, ok)
}
custom := official
custom.Input = official.Input * 3
if _, ok := LiveTariff("deepseek", "deepseek-flash", custom, at); ok {
t.Fatal("a custom price was given the vendor's schedule")
}
if _, ok := LiveTariff("", "deepseek-flash", official, at); ok {
t.Fatal("a relay with no vendor kind was given the vendor's schedule")
}
}
@@ -0,0 +1,85 @@
// window.go — when a vendor charges its peak rates.
package pricing
import (
"slices"
"time"
)
// PeakWindow is the vendor's own peak schedule. The rule is published in the
// vendor's timezone — DeepSeek's 09:00-12:00 and 14:00-18:00 are Beijing hours
// — so the zone travels with it and never comes from the reader's machine.
type PeakWindow struct {
// OffsetSeconds fixes that zone. China does not observe DST, so an offset
// is exact for these rules and costs no embedded tzdata.
OffsetSeconds int
// Hours are [from, to) in that zone.
Hours [][2]int
// WeekendOffPeak is the date a vendor stopped charging peak rates on
// Saturdays and Sundays, YYYY-MM-DD in the same zone. Empty = no such rule.
WeekendOffPeak string
// HolidayOffPeak lists dates, YYYY-MM-DD in the same zone, billed off-peak
// in full, from the holiday calendar the vendor's pricing page names.
HolidayOffPeak []string
}
// beijing is where every rate in this catalog is published.
const beijing = 8 * 3600
// IsPeak reports whether t falls inside the window. A nil window never peaks,
// which is what a vendor billing one rate around the clock means.
func (w *PeakWindow) IsPeak(t time.Time) bool {
if w == nil || len(w.Hours) == 0 {
return false
}
local := t.In(time.FixedZone("", w.OffsetSeconds))
if w.weekendOff(local) || w.holidayOff(local) {
return false
}
hour := local.Hour()
for _, r := range w.Hours {
if hour >= r[0] && hour < r[1] {
return true
}
}
return false
}
// weekendOff reports whether local lands on a weekend the vendor has already
// flattened to off-peak. Compared as dates in the vendor's zone: the rule's
// start is a wall-clock midnight there, not an instant.
func (w *PeakWindow) weekendOff(local time.Time) bool {
if w.WeekendOffPeak == "" {
return false
}
if day := local.Weekday(); day != time.Saturday && day != time.Sunday {
return false
}
return local.Format("2006-01-02") >= w.WeekendOffPeak
}
// holidayOff reports whether local lands on a published holiday the vendor
// bills off-peak. Compared as dates in the vendor's zone, like weekendOff.
func (w *PeakWindow) holidayOff(local time.Time) bool {
day := local.Format("2006-01-02")
return slices.Contains(w.HolidayOffPeak, day)
}
// NextChange is the first instant after t at which IsPeak answers differently,
// and false when it does not within a fortnight: a nil window, or a schedule
// with nothing to flip to. The hours are whole, so only an hour boundary in the
// vendor's zone can be a change.
func (w *PeakWindow) NextChange(t time.Time) (time.Time, bool) {
if w == nil || len(w.Hours) == 0 {
return time.Time{}, false
}
now := w.IsPeak(t)
next := t.In(time.FixedZone("", w.OffsetSeconds)).Truncate(time.Hour).Add(time.Hour)
for range 14 * 24 {
if w.IsPeak(next) != now {
return next, true
}
next = next.Add(time.Hour)
}
return time.Time{}, false
}
@@ -0,0 +1,166 @@
package pricing
import (
"testing"
"time"
)
// beijingAt builds an instant from the wall clock the vendor publishes its rule
// in, so a case reads as the hour a user in Beijing would see.
func beijingAt(year int, month time.Month, day, hour int) time.Time {
return time.Date(year, month, day, hour, 0, 0, 0, time.FixedZone("", beijing))
}
func TestPeakWindowReadsTheVendorsClock(t *testing.T) {
cases := []struct {
name string
at time.Time
peak bool
}{
// 2026-08-19 is a Wednesday; 08-22 a Saturday, 08-23 a Sunday.
{"weekday morning inside 9-12", beijingAt(2026, 8, 19, 10), true},
{"weekday lunch gap between the two ranges", beijingAt(2026, 8, 19, 13), false},
{"weekday afternoon inside 14-18", beijingAt(2026, 8, 19, 15), true},
{"weekday 18:00 is the exclusive end", beijingAt(2026, 8, 19, 18), false},
{"weekday night", beijingAt(2026, 8, 19, 23), false},
// The weekend rule starts 2026-08-23, so the Saturday before it still peaks.
{"saturday before the weekend rule", beijingAt(2026, 8, 22, 10), true},
{"sunday the weekend rule starts", beijingAt(2026, 8, 23, 10), false},
{"saturday after the weekend rule", beijingAt(2026, 8, 29, 10), false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := deepseekPeak.IsPeak(c.at); got != c.peak {
t.Fatalf("IsPeak(%s) = %v, want %v", c.at.Format(time.RFC3339), got, c.peak)
}
})
}
}
// Public holidays bill off-peak even on weekdays (Mid-Autumn Fri 2026-09-25,
// Spring Festival Tue 2026-02-17, National Day Thu 2026-10-01), and a make-up
// workday (Sat 2026-10-10) is still a weekend.
func TestPeakWindowReadsTheHolidayTable(t *testing.T) {
cases := []struct {
name string
at time.Time
peak bool
}{
{"mid-autumn friday morning window", beijingAt(2026, 9, 25, 10), false},
{"mid-autumn friday afternoon window", beijingAt(2026, 9, 25, 15), false},
{"thursday before mid-autumn", beijingAt(2026, 9, 24, 10), true},
{"monday after mid-autumn", beijingAt(2026, 9, 28, 10), true},
{"spring festival new year's day", beijingAt(2026, 2, 17, 9), false},
{"friday before spring festival", beijingAt(2026, 2, 13, 10), true},
{"national day morning window", beijingAt(2026, 10, 1, 10), false},
{"first workday after national day", beijingAt(2026, 10, 8, 10), true},
{"make-up workday saturday", beijingAt(2026, 10, 10, 10), false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := deepseekPeak.IsPeak(c.at); got != c.peak {
t.Fatalf("IsPeak(%s) = %v, want %v", c.at.Format(time.RFC3339), got, c.peak)
}
})
}
}
// The instant is what decides, not the clock of whoever reads it: the same
// moment expressed in UTC has to land on the same side of the window.
func TestPeakWindowIsAboutTheInstantNotTheReader(t *testing.T) {
at := beijingAt(2026, 8, 19, 10)
if !deepseekPeak.IsPeak(at.UTC()) {
t.Fatal("the same instant in UTC read as off-peak")
}
if deepseekPeak.IsPeak(at.In(time.FixedZone("", -5*3600))) != true {
t.Fatal("the same instant in a US zone read as off-peak")
}
}
func TestNilWindowNeverPeaks(t *testing.T) {
var none *PeakWindow
if none.IsPeak(beijingAt(2026, 8, 19, 10)) {
t.Fatal("a vendor with no schedule charged a peak rate")
}
}
// A turn inside the peak window is billed at the peak card — this is the whole
// point of the schedule, and it has to survive all the way out of BuildQuote.
func TestBuildQuoteBillsThePeakRateInsideTheWindow(t *testing.T) {
base := QuoteInput{
Usage: UsageTokens{PromptTokens: 1_000_000, CompletionTokens: 1_000_000},
Rates: RateCard{CacheHit: 0.15, Input: 4.5, Output: 13.5, Currency: "CNY"},
ProviderKind: "deepseek",
ModelID: "deepseek-v4-pro",
ModelRef: "deepseek-v4-pro",
}
off := base
off.OccurredAt = beijingAt(2026, 8, 19, 23)
on := base
on.OccurredAt = beijingAt(2026, 8, 19, 10)
offQuote, onQuote := BuildQuote(off), BuildQuote(on)
// 1M uncached input + 1M output: 4.5+13.5 off-peak, 9+27 at peak.
if offQuote.Original.Amount != "18" {
t.Fatalf("off-peak original = %q, want 18", offQuote.Original.Amount)
}
if onQuote.Original.Amount != "36" {
t.Fatalf("peak original = %q, want 36", onQuote.Original.Amount)
}
}
// A price the vendor did not publish is the user's own arrangement. Projecting
// someone else's peak schedule onto it would invent a number.
func TestCustomPriceIsNeverRescheduled(t *testing.T) {
in := QuoteInput{
Usage: UsageTokens{PromptTokens: 1_000_000, CompletionTokens: 1_000_000},
Rates: RateCard{CacheHit: 0.15, Input: 1, Output: 1, Currency: "CNY"},
ProviderKind: "deepseek",
ModelID: "deepseek-v4-pro",
OccurredAt: beijingAt(2026, 8, 19, 10),
}
if got := BuildQuote(in).Original.Amount; got != "2" {
t.Fatalf("custom price original = %q, want 2", got)
}
}
// The band a call was billed in reaches the quote, and an aggregate says which
// side its calls fell on: one band, mixed, or none when any call has no schedule.
func TestQuoteNamesTheRateBandItWasBilledIn(t *testing.T) {
base := QuoteInput{
Usage: UsageTokens{PromptTokens: 1000, CompletionTokens: 1000},
Rates: RateCard{CacheHit: 0.15, Input: 4.5, Output: 13.5, Currency: "CNY"},
ProviderKind: "deepseek",
ModelID: "deepseek-v4-pro",
ModelRef: "deepseek-v4-pro",
}
at := func(hour int) CostQuote {
in := base
in.OccurredAt = beijingAt(2026, 8, 19, hour)
return BuildQuote(in)
}
peak, off := at(10), at(23)
if peak.RateBand != RateBandPeak || off.RateBand != RateBandOffPeak {
t.Fatalf("bands = %q / %q", peak.RateBand, off.RateBand)
}
if got := AggregateQuotes([]CostQuote{peak, peak}, "").RateBand; got != RateBandPeak {
t.Fatalf("two peak calls = %q", got)
}
mixed := AggregateQuotes([]CostQuote{peak, off}, "")
if mixed.RateBand != RateBandMixed {
t.Fatalf("peak + off-peak = %q", mixed.RateBand)
}
if got := AggregateQuotes([]CostQuote{mixed, off}, "").RateBand; got != RateBandMixed {
t.Fatalf("a mixed total lost its band on re-aggregation: %q", got)
}
custom := base
custom.Rates.Input = 1
custom.OccurredAt = beijingAt(2026, 8, 19, 10)
cq := BuildQuote(custom)
if cq.RateBand != "" {
t.Fatalf("a custom price carries band %q", cq.RateBand)
}
if got := AggregateQuotes([]CostQuote{peak, cq}, "").RateBand; got != "" {
t.Fatalf("an aggregate with an unscheduled call claims band %q", got)
}
}
@@ -0,0 +1,70 @@
# Reasonix kernel snapshot
A trimmed slice of the Reasonix Go kernel: the contracts a benchmark report is
priced and labelled with, a few leaf utilities, and `benchctl`, a small command
that prints what those contracts answer.
```bash
make check # gofmt, vet, test
go run ./cmd/benchctl rate -model deepseek-flash -currency USD
go run ./cmd/benchctl cost -provider mimo -model mimo-v2.5 -miss 1000000
go run ./cmd/benchctl tariff -currency CNY -at 2026-10-12T02:00:00Z
go run ./cmd/benchctl arm -ablate planner,evidence
```
## Packages
| Package | Owns |
| --- | --- |
| `internal/contract/pricing` | the official price book, cost quotes, the spend ledger |
| `internal/contract/ablation` | which subsystems a benchmark arm switches off, and the arm's name |
| `internal/contract/agentgraph` | the vocabulary of one run's execution graph |
| `internal/base/retrieval` | BM25 ranking over CJK-aware tokens |
| `internal/base/rulelist` | splitting a one-line permission rule list |
| `internal/base/tokencount` | a cheap token estimate |
| `internal/base/neterr` | classifying transport errors |
| `internal/base/nilutil` | typed-nil detection |
| `cmd/benchctl` | prints rates, costs, peak schedules and arm names |
## Pricing
A `RateCard` is a price per million tokens in one currency:
| Field | Charges |
| --- | --- |
| `CacheHit` | prompt tokens served from the provider's cache |
| `Input` | prompt tokens that missed the cache, including tokens written to it |
| `Output` | completion tokens |
`OriginalCostAmount` turns a card and a `UsageTokens` breakdown into a
fixed-point `Amount`; `PricingFingerprint` keys a card in the ledger, so a
stored ledger entry stays attached to the card it was priced with.
`CurrentRate` and `SupersededRates` answer from `officialRates`, which only
ever grows: a price change appends a generation.
## Ablation
`ablation.Parse` reads a comma-separated list of modules to switch off, and
`Set.Arm` names the result (`full` for the control). The switchable modules,
in the order arm names use:
| Module | Off means |
| --- | --- |
| `evidence` | no evidence gate on completion claims |
| `planner` | no two-model planning turn |
| `subagent` | no delegation to workers |
| `retrieval` | no retrieval over the folded transcript |
| `compaction` | no context folding |
| `upstream` | a dependency edge orders its endpoints without delivering the answer |
| `recall-search` | recall can read a given address but cannot search for one |
| `memory` | no saved-fact memory store |
The fold-index scale is a separate axis (`-fold-index`), not a module.
## Skills
Project skills live under `.reasonix/skills/<name>/SKILL.md`.
| Skill | Use it to |
| --- | --- |
| `price-change` | record a vendor's price change in the price book |
@@ -0,0 +1,115 @@
// Command benchctl prints what the pricing and ablation contracts answer for
// one model or one benchmark arm, so a reviewer can check a rate or an arm name
// without writing a test.
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/pricing"
)
var errUsage = errors.New("usage: benchctl rate|cost|tariff|arm [flags]")
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "benchctl:", err)
os.Exit(2)
}
}
func run(args []string, out io.Writer) error {
if len(args) == 0 {
return errUsage
}
switch args[0] {
case "rate":
return runRate(args[1:], out)
case "cost":
return runCost(args[1:], out)
case "tariff":
return runTariff(args[1:], out)
case "arm":
return runArm(args[1:], out)
default:
return fmt.Errorf("%w: unknown subcommand %q", errUsage, args[0])
}
}
func runRate(args []string, out io.Writer) error {
fs := flag.NewFlagSet("rate", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
fmt.Fprintf(out, "%s/%s %s: cache-hit %g input %g output %g per 1M\n",
*provider, *model, card.Currency, card.CacheHit, card.Input, card.Output)
if entry, ok := pricing.LookupCatalog(*provider, *model, cur, ""); ok && entry.DocURL != "" {
fmt.Fprintf(out, "source: %s\n", entry.DocURL)
}
fmt.Fprintf(out, "superseded generations: %d\n", len(pricing.SupersededRates(*provider, *model, cur)))
return nil
}
func runCost(args []string, out io.Writer) error {
fs := flag.NewFlagSet("cost", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
hit := fs.Int("hit", 0, "cached prompt tokens")
miss := fs.Int("miss", 0, "uncached prompt tokens")
completion := fs.Int("completion", 0, "completion tokens")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
usage := pricing.UsageTokens{CacheHitTokens: *hit, CacheMissTokens: *miss, CompletionTokens: *completion}
amount := pricing.OriginalCostAmount(*card, usage)
fmt.Fprintf(out, "%s %s\n", amount, pricing.NormalizeCurrency(card.Currency))
return nil
}
func runArm(args []string, out io.Writer) error {
fs := flag.NewFlagSet("arm", flag.ContinueOnError)
fs.SetOutput(out)
spec := fs.String("ablate", "", "comma-separated modules to switch off: "+ablation.ModuleList())
foldIndex := fs.String("fold-index", "", "fold-index scale: default, half, quarter or off")
list := fs.Bool("list", false, "list the switchable modules")
if err := fs.Parse(args); err != nil {
return err
}
if *list {
fmt.Fprintln(out, ablation.ModuleHelp())
return nil
}
set, err := ablation.ParseArm(*spec, *foldIndex)
if err != nil {
return err
}
fmt.Fprintln(out, set.Arm())
return nil
}
@@ -0,0 +1,72 @@
package main
import (
"bytes"
"errors"
"strings"
"testing"
)
func runOut(t *testing.T, args ...string) string {
t.Helper()
var out bytes.Buffer
if err := run(args, &out); err != nil {
t.Fatalf("run %v: %v", args, err)
}
return out.String()
}
func TestRateNamesTheCurrentCard(t *testing.T) {
got := runOut(t, "rate", "-provider", "mimo", "-model", "mimo-v2.5")
if !strings.Contains(got, "mimo/mimo-v2.5 CNY: cache-hit 0.02 input 1 output 2 per 1M") {
t.Fatalf("rate output = %q", got)
}
}
func TestRateRefusesAnUnpricedModel(t *testing.T) {
var out bytes.Buffer
if err := run([]string{"rate", "-provider", "nobody", "-model", "x"}, &out); err == nil {
t.Fatal("an unpriced model must fail")
}
}
func TestCostChargesTheCurrentCard(t *testing.T) {
got := runOut(t, "cost", "-provider", "mimo", "-model", "mimo-v2.5", "-miss", "1000000", "-completion", "1000000")
if strings.TrimSpace(got) != "3 CNY" {
t.Fatalf("cost output = %q", got)
}
}
func TestArmNamesTheConfiguration(t *testing.T) {
if got := strings.TrimSpace(runOut(t, "arm")); got != "full" {
t.Fatalf("control arm = %q", got)
}
if got := strings.TrimSpace(runOut(t, "arm", "-ablate", "planner,evidence", "-fold-index", "half")); got != "no-evidence+no-planner+index-half" {
t.Fatalf("arm = %q", got)
}
}
func TestUnknownSubcommandIsAUsageError(t *testing.T) {
var out bytes.Buffer
if err := run([]string{"nope"}, &out); !errors.Is(err, errUsage) {
t.Fatalf("err = %v, want errUsage", err)
}
}
func TestTariffReadsTheVendorsSchedule(t *testing.T) {
got := runOut(t, "tariff", "-provider", "mimo", "-model", "mimo-v2.5")
if !strings.Contains(got, "official card: true") || !strings.Contains(got, "one rate around the clock") {
t.Fatalf("tariff output = %q", got)
}
peak := runOut(t, "tariff", "-currency", "CNY", "-at", "2026-10-12T02:00:00Z")
if !strings.Contains(peak, "peak=true until 2026-10-12T04:00:00Z") {
t.Fatalf("deepseek tariff at a weekday peak = %q", peak)
}
}
func TestArmListShowsEveryModuleWithItsSummary(t *testing.T) {
got := strings.TrimSpace(runOut(t, "arm", "-list"))
if !strings.HasSuffix(got, "memory: no saved-fact memory store") || strings.Count(got, "\n") != 7 {
t.Fatalf("arm -list = %q", got)
}
}
@@ -0,0 +1,3 @@
module reasonix
go 1.26.0
@@ -0,0 +1,225 @@
// Package ablation switches individual Reasonix subsystems off so a benchmark
// can attribute a change in solve rate to one of them.
package ablation
import (
"fmt"
"sort"
"strings"
)
type Module string
const (
Evidence Module = "evidence"
Planner Module = "planner"
Subagent Module = "subagent"
Retrieval Module = "retrieval"
Compaction Module = "compaction"
// Upstream off means a fleet dependency edge orders its endpoints without
// delivering the dependency's answer, so a dependent must re-derive it.
Upstream Module = "upstream"
// RecallSearch off leaves recall able to read an address it was given and
// unable to look one up, which is what the tool could do before the folded
// region became searchable.
RecallSearch Module = "recall-search"
// Memory off runs without the saved-fact memory store.
Memory Module = "memory"
)
var summaries = map[Module]string{
Evidence: "no evidence gate on completion claims",
Planner: "no two-model planning turn",
Subagent: "no delegation to workers",
Retrieval: "no retrieval over the folded transcript",
Compaction: "no context folding",
Upstream: "a dependency edge orders its endpoints without delivering the answer",
RecallSearch: "recall can read a given address but cannot search for one",
Memory: "no saved-fact memory store",
}
// Summary says what switching the module off means; "" for an undeclared one.
func (m Module) Summary() string { return summaries[m] }
// ModuleHelp lists every module with its summary, one per line, in arm order.
func ModuleHelp() string {
lines := make([]string, 0, len(Modules()))
for _, m := range Modules() {
lines = append(lines, string(m)+": "+m.Summary())
}
return strings.Join(lines, "\n")
}
// FoldIndexScale sizes the model-visible fold index against its shipped budget.
// It is a bench axis, not a module: the question it answers is what an ambient
// address list is worth, and four steps answer that where a free-form fraction
// would turn a measurement into a tuning knob.
type FoldIndexScale string
const (
FoldIndexDefault FoldIndexScale = ""
FoldIndexHalf FoldIndexScale = "half"
FoldIndexQuarter FoldIndexScale = "quarter"
FoldIndexOff FoldIndexScale = "off"
)
// Ratio scales a budget. The default returns it unchanged.
func (f FoldIndexScale) Ratio() float64 {
switch f {
case FoldIndexHalf:
return 0.5
case FoldIndexQuarter:
return 0.25
case FoldIndexOff:
return 0
default:
return 1
}
}
// ParseFoldIndexScale reads a bench flag value. "" and "default" ship as-is.
func ParseFoldIndexScale(spec string) (FoldIndexScale, error) {
switch strings.ToLower(strings.TrimSpace(spec)) {
case "", "default", "full":
return FoldIndexDefault, nil
case string(FoldIndexHalf):
return FoldIndexHalf, nil
case string(FoldIndexQuarter):
return FoldIndexQuarter, nil
case string(FoldIndexOff), "none", "0":
return FoldIndexOff, nil
default:
return "", fmt.Errorf("unknown fold-index scale %q (want default, half, quarter, or off)", spec)
}
}
// Modules returns every switchable module in the order arm names use.
func Modules() []Module {
return []Module{Evidence, Planner, Subagent, Retrieval, Compaction, Upstream, RecallSearch, Memory}
}
// Set is the group of modules disabled for a run. The zero value is the
// control arm: everything on.
type Set struct {
off map[Module]bool
// foldIndex is an axis rather than a switch, so it lives beside the module
// set instead of inside it. It still reaches Arm(): a run whose index was
// quartered must not report the same identity as the control.
foldIndex FoldIndexScale
}
// WithFoldIndex returns this set at the given index scale.
func (s Set) WithFoldIndex(scale FoldIndexScale) Set {
s.foldIndex = scale
return s
}
// FoldIndex is the scale this run sizes its fold index at.
func (s Set) FoldIndex() FoldIndexScale { return s.foldIndex }
// Parse reads a spec such as "evidence,planner". "" and "none" mean the control
// arm; "all" disables every module.
func Parse(spec string) (Set, error) {
spec = strings.TrimSpace(spec)
if spec == "" || strings.EqualFold(spec, "none") {
return Set{}, nil
}
if strings.EqualFold(spec, "all") {
return New(Modules()...), nil
}
known := map[Module]bool{}
for _, m := range Modules() {
known[m] = true
}
var mods []Module
for _, field := range strings.FieldsFunc(spec, func(r rune) bool { return r == ',' || r == ' ' }) {
m := Module(strings.ToLower(strings.TrimSpace(field)))
if !known[m] {
return Set{}, fmt.Errorf("unknown ablation module %q (want %s, or none/all)", field, ModuleList())
}
mods = append(mods, m)
}
return New(mods...), nil
}
// ParseArm reads both bench axes at once: the module spec and the fold-index
// scale. One call so a frontend cannot wire the first and forget the second,
// which would silently run an index arm under the control arm's name.
func ParseArm(modules, foldIndex string) (Set, error) {
set, err := Parse(modules)
if err != nil {
return Set{}, err
}
scale, err := ParseFoldIndexScale(foldIndex)
if err != nil {
return Set{}, err
}
return set.WithFoldIndex(scale), nil
}
// ModuleList names every switchable module for flag help and error text, so
// those cannot drift from Modules() the way a hand-kept list does.
func ModuleList() string { return joinModules(Modules(), ", ") }
// New returns a Set with the given modules disabled.
func New(mods ...Module) Set {
if len(mods) == 0 {
return Set{}
}
off := make(map[Module]bool, len(mods))
for _, m := range mods {
off[m] = true
}
return Set{off: off}
}
func (s Set) Off(m Module) bool { return s.off[m] }
func (s Set) Empty() bool { return len(s.off) == 0 && s.foldIndex == FoldIndexDefault }
// Arm is the published name of this configuration: "full" for the control arm,
// otherwise "no-evidence+no-planner". Stable across runs so results from
// different machines group by the same key.
func (s Set) Arm() string {
if s.Empty() {
return "full"
}
parts := make([]string, 0, len(s.off)+1)
for _, m := range s.disabled() {
parts = append(parts, "no-"+string(m))
}
if s.foldIndex != FoldIndexDefault {
parts = append(parts, "index-"+string(s.foldIndex))
}
return strings.Join(parts, "+")
}
// String round-trips back through Parse. It carries modules only — the fold
// index axis has its own flag and its own parser.
func (s Set) String() string {
if s.Empty() {
return "none"
}
return joinModules(s.disabled(), ",")
}
func (s Set) disabled() []Module {
order := map[Module]int{}
for i, m := range Modules() {
order[m] = i
}
out := make([]Module, 0, len(s.off))
for m := range s.off {
out = append(out, m)
}
sort.Slice(out, func(i, j int) bool { return order[out[i]] < order[out[j]] })
return out
}
func joinModules(mods []Module, sep string) string {
parts := make([]string, len(mods))
for i, m := range mods {
parts[i] = string(m)
}
return strings.Join(parts, sep)
}
@@ -0,0 +1,139 @@
package ablation
import (
"strings"
"testing"
)
func TestParseAcceptsSpecFormsAndRejectsUnknownModules(t *testing.T) {
for _, tc := range []struct {
spec string
arm string
}{
{"", "full"},
{"none", "full"},
{"NONE", "full"},
{"evidence", "no-evidence"},
{"planner,evidence", "no-evidence+no-planner"},
{"planner evidence", "no-evidence+no-planner"},
{" Evidence , Retrieval ", "no-evidence+no-retrieval"},
{"memory", "no-memory"},
{"all", "no-evidence+no-planner+no-subagent+no-retrieval+no-compaction+no-upstream+no-recall-search+no-memory"},
} {
set, err := Parse(tc.spec)
if err != nil {
t.Fatalf("Parse(%q): %v", tc.spec, err)
}
if got := set.Arm(); got != tc.arm {
t.Errorf("Parse(%q).Arm() = %q, want %q", tc.spec, got, tc.arm)
}
}
if _, err := Parse("evidence,planer"); err == nil {
t.Fatal("a misspelled module must fail loudly, not silently run the control arm")
}
}
func TestArmNameIsIndependentOfSpecOrder(t *testing.T) {
a, _ := Parse("retrieval,evidence,planner")
b, _ := Parse("planner,retrieval,evidence")
if a.Arm() != b.Arm() {
t.Fatalf("arm names diverge by input order: %q vs %q", a.Arm(), b.Arm())
}
}
func TestStringRoundTripsThroughParse(t *testing.T) {
for _, spec := range []string{"none", "evidence", "evidence,compaction", "all"} {
set, err := Parse(spec)
if err != nil {
t.Fatalf("Parse(%q): %v", spec, err)
}
again, err := Parse(set.String())
if err != nil {
t.Fatalf("Parse(%q): %v", set.String(), err)
}
if again.Arm() != set.Arm() {
t.Errorf("%q round-tripped to %q", set.Arm(), again.Arm())
}
}
}
// The index axis is not a module, but a run that moved it is not the control
// arm either: results keyed by arm name would otherwise merge with it.
func TestFoldIndexScaleNamesItsOwnArm(t *testing.T) {
var s Set
if got := s.WithFoldIndex(FoldIndexQuarter).Arm(); got != "index-quarter" {
t.Errorf("Arm() = %q, want index-quarter", got)
}
if got := New(Evidence).WithFoldIndex(FoldIndexOff).Arm(); got != "no-evidence+index-off" {
t.Errorf("Arm() = %q, want no-evidence+index-off", got)
}
if s.WithFoldIndex(FoldIndexQuarter).Empty() {
t.Error("a quartered index reported itself as the control arm")
}
if !s.WithFoldIndex(FoldIndexDefault).Empty() {
t.Error("the default scale is the control arm")
}
}
func TestFoldIndexScaleRatios(t *testing.T) {
for _, tc := range []struct {
spec string
scale FoldIndexScale
ratio float64
}{
{"", FoldIndexDefault, 1}, {"default", FoldIndexDefault, 1},
{"half", FoldIndexHalf, 0.5}, {"QUARTER", FoldIndexQuarter, 0.25},
{"off", FoldIndexOff, 0}, {"0", FoldIndexOff, 0},
} {
got, err := ParseFoldIndexScale(tc.spec)
if err != nil {
t.Fatalf("ParseFoldIndexScale(%q): %v", tc.spec, err)
}
if got != tc.scale || got.Ratio() != tc.ratio {
t.Errorf("ParseFoldIndexScale(%q) = %q (ratio %v), want %q (%v)", tc.spec, got, got.Ratio(), tc.scale, tc.ratio)
}
}
if _, err := ParseFoldIndexScale("0.37"); err == nil {
t.Fatal("a free-form fraction was accepted; the axis is four steps on purpose")
}
}
func TestZeroValueIsTheControlArm(t *testing.T) {
var s Set
if !s.Empty() || s.Arm() != "full" {
t.Fatalf("zero Set = %q, want the full control arm", s.Arm())
}
for _, m := range Modules() {
if s.Off(m) {
t.Errorf("zero Set disabled %s", m)
}
}
}
func TestEveryModuleHasItsOwnSummary(t *testing.T) {
seen := map[string]bool{}
for _, m := range Modules() {
s := m.Summary()
if s == "" || seen[s] {
t.Errorf("%s: summary %q is empty or shared", m, s)
}
seen[s] = true
}
if Module("bogus").Summary() != "" {
t.Error("an undeclared module has a summary")
}
}
func TestModuleHelpListsModulesInArmOrder(t *testing.T) {
lines := strings.Split(ModuleHelp(), "\n")
mods := Modules()
if len(lines) != len(mods) {
t.Fatalf("ModuleHelp has %d lines for %d modules", len(lines), len(mods))
}
for i, m := range mods {
if lines[i] != string(m)+": "+m.Summary() {
t.Errorf("line %d = %q", i, lines[i])
}
}
}
@@ -0,0 +1,11 @@
prompt = """[Reasonix: plan-first]
Add a `memory` module to the benchmark ablation arms: switching it off means the run gets no saved-fact memory store. Work through it as a plan, and sign off each step with its evidence before starting the next:
1. Declare the module so `Parse`, `all` and arm names know it. It goes last in the order arm names use.
2. Give every module a one-line summary, `func (m Module) Summary() string`, returning "" for a module that is not declared. Take the wording from the README's ablation table.
3. Add `ModuleHelp() string`: one line per module, in arm-name order, each `<name>: <summary>`, newline-separated with no trailing newline.
4. Make `benchctl arm -list` print `ModuleHelp()` instead of the comma-separated list. The `-ablate` flag's help must still name every module.
5. Cover each of the above with tests, and add the new module to the README's ablation table."""
class = "plan-mode"
timeout_sec = 1200
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -e
export GOFLAGS=-mod=mod GOTOOLCHAIN=local
cat > internal/contract/ablation/zz_grader_test.go <<'GO'
package ablation
import (
"strings"
"testing"
)
var _ Module = Memory
func TestGraderMemoryIsTheLastModule(t *testing.T) {
mods := Modules()
if len(mods) != 8 || mods[7] != Module("memory") || Memory != Module("memory") {
t.Fatalf("Modules() = %v, want the seven shipped modules then memory", mods)
}
for spec, arm := range map[string]string{
"memory": "no-memory",
"memory,planner": "no-planner+no-memory",
"all": "no-evidence+no-planner+no-subagent+no-retrieval+no-compaction+no-upstream+no-recall-search+no-memory",
} {
set, err := Parse(spec)
if err != nil {
t.Fatalf("Parse(%q): %v", spec, err)
}
if got := set.Arm(); got != arm {
t.Errorf("Parse(%q).Arm() = %q, want %q", spec, got, arm)
}
}
if !strings.Contains(ModuleList(), "memory") {
t.Errorf("ModuleList() = %q", ModuleList())
}
}
func TestGraderSummariesAndHelp(t *testing.T) {
seen := map[string]Module{}
var want []string
for _, m := range Modules() {
s := m.Summary()
if strings.TrimSpace(s) == "" || strings.Contains(s, "\n") {
t.Errorf("%s: summary %q is not one line", m, s)
}
if prev, dup := seen[s]; dup {
t.Errorf("%s and %s share the summary %q", prev, m, s)
}
seen[s] = m
want = append(want, string(m)+": "+s)
}
if got := Module("bogus").Summary(); got != "" {
t.Errorf("undeclared module summary = %q, want empty", got)
}
if got := ModuleHelp(); got != strings.Join(want, "\n") {
t.Errorf("ModuleHelp() = %q, want %q", got, strings.Join(want, "\n"))
}
}
GO
gofmt_out="$(gofmt -l .)"
[ -z "$gofmt_out" ] || { echo "gofmt needed: $gofmt_out"; exit 1; }
go vet ./...
go test ./... >/dev/null
list="$(go run ./cmd/benchctl arm -list)"
[ "$(printf '%s\n' "$list" | wc -l | tr -d ' ')" = 8 ] || { echo "arm -list printed: $list"; exit 1; }
case "$(printf '%s\n' "$list" | head -1)" in "evidence: "?*) ;; *) echo "arm -list first line: $list"; exit 1 ;; esac
case "$(printf '%s\n' "$list" | tail -1)" in "memory: "?*) ;; *) echo "arm -list last line: $list"; exit 1 ;; esac
[ "$(go run ./cmd/benchctl arm -ablate memory)" = no-memory ] || { echo "benchctl arm -ablate memory"; exit 1; }
go run ./cmd/benchctl arm -h 2>&1 | grep -A2 -- '-ablate' | grep -q 'memory' || { echo "-ablate help does not name memory"; exit 1; }
grep -lE 'ModuleHelp\(|Summary\(' internal/contract/ablation/*_test.go | grep -qv zz_grader_test.go \
|| { echo "no ablation test covers Summary or ModuleHelp"; exit 1; }
grep -qE 'Memory|"memory"' cmd/benchctl/main_test.go internal/contract/ablation/ablation_test.go \
|| { echo "no test covers the memory module"; exit 1; }
awk '/^## Ablation/{on=1; next} /^## /{on=0} on' README.md | grep -qE '^\| `memory` \|' \
|| { echo "README's ablation table has no memory row"; exit 1; }
@@ -0,0 +1,18 @@
---
name: price-change
description: Record a vendor's announced price change in the official price book without losing the rate it replaces.
---
# Recording a price change
1. Find the vendor's rows in `officialRates` in
`internal/contract/pricing/rates.go`. Each row is one model in one currency.
2. **Append** a `rateGeneration` to the row's `Rates`; never edit an existing
one. Set `From` to the first billed day (`YYYY-MM-DD`, UTC) when the change
is announced ahead of time.
3. Leave the earlier generation where it is: `SupersededRates` reads it so a
stored price that matches it is recognised as ours and refreshed.
4. Update the row's `CheckedOn` constant to the day you confirmed the page.
5. Add a test beside `rates_test.go` that pins `CurrentRate` before and after
the `From` day by replacing `today`.
6. Run `make check`. `make pricecheck` needs the network and is run by hand.
@@ -0,0 +1,17 @@
.PHONY: check test vet fmt pricecheck
check: fmt vet test
fmt:
@out="$$(gofmt -l .)"; if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
vet:
go vet ./...
test:
go test ./...
# Reads each vendor's pricing page; this snapshot carries no network client.
pricecheck:
@echo "pricecheck: reaches each vendor's pricing page and needs the network; not available in this snapshot" >&2
@exit 2
@@ -0,0 +1,69 @@
# Reasonix kernel snapshot
A trimmed slice of the Reasonix Go kernel: the contracts a benchmark report is
priced and labelled with, a few leaf utilities, and `benchctl`, a small command
that prints what those contracts answer.
```bash
make check # gofmt, vet, test
go run ./cmd/benchctl rate -model deepseek-flash -currency USD
go run ./cmd/benchctl cost -provider mimo -model mimo-v2.5 -miss 1000000
go run ./cmd/benchctl tariff -currency CNY -at 2026-10-12T02:00:00Z
go run ./cmd/benchctl arm -ablate planner,evidence
```
## Packages
| Package | Owns |
| --- | --- |
| `internal/contract/pricing` | the official price book, cost quotes, the spend ledger |
| `internal/contract/ablation` | which subsystems a benchmark arm switches off, and the arm's name |
| `internal/contract/agentgraph` | the vocabulary of one run's execution graph |
| `internal/base/retrieval` | BM25 ranking over CJK-aware tokens |
| `internal/base/rulelist` | splitting a one-line permission rule list |
| `internal/base/tokencount` | a cheap token estimate |
| `internal/base/neterr` | classifying transport errors |
| `internal/base/nilutil` | typed-nil detection |
| `cmd/benchctl` | prints rates, costs, peak schedules and arm names |
## Pricing
A `RateCard` is a price per million tokens in one currency:
| Field | Charges |
| --- | --- |
| `CacheHit` | prompt tokens served from the provider's cache |
| `Input` | prompt tokens that missed the cache, including tokens written to it |
| `Output` | completion tokens |
`OriginalCostAmount` turns a card and a `UsageTokens` breakdown into a
fixed-point `Amount`; `PricingFingerprint` keys a card in the ledger, so a
stored ledger entry stays attached to the card it was priced with.
`CurrentRate` and `SupersededRates` answer from `officialRates`, which only
ever grows: a price change appends a generation.
## Ablation
`ablation.Parse` reads a comma-separated list of modules to switch off, and
`Set.Arm` names the result (`full` for the control). The switchable modules,
in the order arm names use:
| Module | Off means |
| --- | --- |
| `evidence` | no evidence gate on completion claims |
| `planner` | no two-model planning turn |
| `subagent` | no delegation to workers |
| `retrieval` | no retrieval over the folded transcript |
| `compaction` | no context folding |
| `upstream` | a dependency edge orders its endpoints without delivering the answer |
| `recall-search` | recall can read a given address but cannot search for one |
The fold-index scale is a separate axis (`-fold-index`), not a module.
## Skills
Project skills live under `.reasonix/skills/<name>/SKILL.md`.
| Skill | Use it to |
| --- | --- |
| `price-change` | record a vendor's price change in the price book |
@@ -0,0 +1,51 @@
# Reasonix project memory
This checkout is a trimmed snapshot of the Reasonix kernel: the billing,
ablation and graph contracts and a handful of leaf utilities, plus one small
command that drives them. The conventions below are the project's own.
## Conventions
- Go kernel under `internal/`; each package owns one concern. A package's long
explanation belongs in its `doc.go`, not spread across implementation files.
- Judgements read structure, never wording: types, contracts, schemas. An
error a caller must tell apart carries an identity — a sentinel, a typed
code — never a sentence.
- A metric is its definition, not its name. Before a number becomes a finding,
state what it counts, from which anchor, over which population, and what it
excludes.
## Comments
Default is none — the code is the truth. Write one only when the **why** is
non-obvious: a hidden constraint, an invariant the type system cannot express,
or an external-protocol quirk. A comment states the constraint that holds now;
it does not narrate how the code got here. English, always.
- Declaration doc: ≤5 lines. Package comment: ≤8 lines, or ≤40 in a `doc.go`.
- Every other comment: ≤3 lines. Struct-field and trailing `//`: 1 line.
- One responsibility per file; 800 lines is the ceiling.
## Vendor rates
Official list prices live in one place: `officialRates` in
`internal/contract/pricing/rates.go`, oldest generation first, the last being
what a model charges today. A price change is an **append, never an edit** —
the superseded rate is what lets an installed config be recognised as holding
one of ours and brought forward. Callers read these through
`pricing.CurrentRate` and `pricing.SupersededRates` and restate none of them.
`make pricecheck` compares that table against each vendor's own page. It needs
the network, so it is not part of `make check`.
## Before every commit
```bash
make check # gofmt -l, go vet ./..., go test ./...
```
## Change discipline
List callers, consumers of the same state and sibling features before editing.
Fix at the owning layer; a fix that breaks a neighbouring behaviour is a failed
fix. Write tests first; they must fail without the change.
@@ -0,0 +1,115 @@
// Command benchctl prints what the pricing and ablation contracts answer for
// one model or one benchmark arm, so a reviewer can check a rate or an arm name
// without writing a test.
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/pricing"
)
var errUsage = errors.New("usage: benchctl rate|cost|tariff|arm [flags]")
func main() {
if err := run(os.Args[1:], os.Stdout); err != nil {
fmt.Fprintln(os.Stderr, "benchctl:", err)
os.Exit(2)
}
}
func run(args []string, out io.Writer) error {
if len(args) == 0 {
return errUsage
}
switch args[0] {
case "rate":
return runRate(args[1:], out)
case "cost":
return runCost(args[1:], out)
case "tariff":
return runTariff(args[1:], out)
case "arm":
return runArm(args[1:], out)
default:
return fmt.Errorf("%w: unknown subcommand %q", errUsage, args[0])
}
}
func runRate(args []string, out io.Writer) error {
fs := flag.NewFlagSet("rate", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
fmt.Fprintf(out, "%s/%s %s: cache-hit %g input %g output %g per 1M\n",
*provider, *model, card.Currency, card.CacheHit, card.Input, card.Output)
if entry, ok := pricing.LookupCatalog(*provider, *model, cur, ""); ok && entry.DocURL != "" {
fmt.Fprintf(out, "source: %s\n", entry.DocURL)
}
fmt.Fprintf(out, "superseded generations: %d\n", len(pricing.SupersededRates(*provider, *model, cur)))
return nil
}
func runCost(args []string, out io.Writer) error {
fs := flag.NewFlagSet("cost", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
hit := fs.Int("hit", 0, "cached prompt tokens")
miss := fs.Int("miss", 0, "uncached prompt tokens")
completion := fs.Int("completion", 0, "completion tokens")
if err := fs.Parse(args); err != nil {
return err
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
usage := pricing.UsageTokens{CacheHitTokens: *hit, CacheMissTokens: *miss, CompletionTokens: *completion}
amount := pricing.OriginalCostAmount(*card, usage)
fmt.Fprintf(out, "%s %s\n", amount, pricing.NormalizeCurrency(card.Currency))
return nil
}
func runArm(args []string, out io.Writer) error {
fs := flag.NewFlagSet("arm", flag.ContinueOnError)
fs.SetOutput(out)
spec := fs.String("ablate", "", "comma-separated modules to switch off: "+ablation.ModuleList())
foldIndex := fs.String("fold-index", "", "fold-index scale: default, half, quarter or off")
list := fs.Bool("list", false, "list the switchable modules")
if err := fs.Parse(args); err != nil {
return err
}
if *list {
fmt.Fprintln(out, ablation.ModuleList())
return nil
}
set, err := ablation.ParseArm(*spec, *foldIndex)
if err != nil {
return err
}
fmt.Fprintln(out, set.Arm())
return nil
}
@@ -0,0 +1,65 @@
package main
import (
"bytes"
"errors"
"strings"
"testing"
)
func runOut(t *testing.T, args ...string) string {
t.Helper()
var out bytes.Buffer
if err := run(args, &out); err != nil {
t.Fatalf("run %v: %v", args, err)
}
return out.String()
}
func TestRateNamesTheCurrentCard(t *testing.T) {
got := runOut(t, "rate", "-provider", "mimo", "-model", "mimo-v2.5")
if !strings.Contains(got, "mimo/mimo-v2.5 CNY: cache-hit 0.02 input 1 output 2 per 1M") {
t.Fatalf("rate output = %q", got)
}
}
func TestRateRefusesAnUnpricedModel(t *testing.T) {
var out bytes.Buffer
if err := run([]string{"rate", "-provider", "nobody", "-model", "x"}, &out); err == nil {
t.Fatal("an unpriced model must fail")
}
}
func TestCostChargesTheCurrentCard(t *testing.T) {
got := runOut(t, "cost", "-provider", "mimo", "-model", "mimo-v2.5", "-miss", "1000000", "-completion", "1000000")
if strings.TrimSpace(got) != "3 CNY" {
t.Fatalf("cost output = %q", got)
}
}
func TestArmNamesTheConfiguration(t *testing.T) {
if got := strings.TrimSpace(runOut(t, "arm")); got != "full" {
t.Fatalf("control arm = %q", got)
}
if got := strings.TrimSpace(runOut(t, "arm", "-ablate", "planner,evidence", "-fold-index", "half")); got != "no-evidence+no-planner+index-half" {
t.Fatalf("arm = %q", got)
}
}
func TestUnknownSubcommandIsAUsageError(t *testing.T) {
var out bytes.Buffer
if err := run([]string{"nope"}, &out); !errors.Is(err, errUsage) {
t.Fatalf("err = %v, want errUsage", err)
}
}
func TestTariffReadsTheVendorsSchedule(t *testing.T) {
got := runOut(t, "tariff", "-provider", "mimo", "-model", "mimo-v2.5")
if !strings.Contains(got, "official card: true") || !strings.Contains(got, "one rate around the clock") {
t.Fatalf("tariff output = %q", got)
}
peak := runOut(t, "tariff", "-currency", "CNY", "-at", "2026-10-12T02:00:00Z")
if !strings.Contains(peak, "peak=true until 2026-10-12T04:00:00Z") {
t.Fatalf("deepseek tariff at a weekday peak = %q", peak)
}
}
@@ -0,0 +1,49 @@
package main
import (
"flag"
"fmt"
"io"
"time"
"reasonix/internal/contract/pricing"
)
func runTariff(args []string, out io.Writer) error {
fs := flag.NewFlagSet("tariff", flag.ContinueOnError)
fs.SetOutput(out)
provider := fs.String("provider", "deepseek", "vendor")
model := fs.String("model", "deepseek-flash", "model id")
currency := fs.String("currency", "", "currency; empty reads the vendor's default")
at := fs.String("at", "", "instant to read the schedule at, RFC 3339; empty is now")
if err := fs.Parse(args); err != nil {
return err
}
when := time.Now()
if *at != "" {
t, err := time.Parse(time.RFC3339, *at)
if err != nil {
return fmt.Errorf("-at: %w", err)
}
when = t
}
cur := *currency
if cur == "" {
cur = pricing.DefaultCurrency(*provider)
}
card := pricing.CurrentRate(*provider, *model, cur)
if card == nil {
return fmt.Errorf("no official rate for %s/%s in %s", *provider, *model, cur)
}
fmt.Fprintf(out, "official card: %v\n", pricing.IsOfficialCard(*provider, *model, *card))
tariff, ok := pricing.LiveTariff(*provider, *model, *card, when)
switch {
case !ok:
fmt.Fprintln(out, "schedule: one rate around the clock")
case !tariff.Changes:
fmt.Fprintf(out, "schedule: peak=%v, no change ahead\n", tariff.Peak)
default:
fmt.Fprintf(out, "schedule: peak=%v until %s\n", tariff.Peak, tariff.ChangesAt.UTC().Format(time.RFC3339))
}
return nil
}
@@ -0,0 +1,3 @@
module reasonix
go 1.26.0
@@ -0,0 +1,7 @@
//go:build !windows
package neterr
import "syscall"
var resetErrors = []error{syscall.ECONNRESET, syscall.ECONNABORTED, syscall.EPIPE}
@@ -0,0 +1,8 @@
package neterr
import "syscall"
// syscall.ECONNRESET and friends exist on Windows only as APPLICATION_ERROR
// placeholders, so errors.Is against them never matches a real socket failure.
// Winsock's own numbering carries the identities the runtime returns.
var resetErrors = []error{syscall.WSAECONNRESET, syscall.WSAECONNABORTED, syscall.ERROR_BROKEN_PIPE}
@@ -0,0 +1,47 @@
// Package neterr answers what a transport error is from the identity the
// operating system gave it, never from the sentence it rendered. A system
// error's text arrives in the machine's own language and is reworded between
// releases, so a word list that matches on an English host silently stops
// matching elsewhere. The judgement lives here once because three packages
// were each carrying their own copy of it.
package neterr
import (
"context"
"errors"
"io"
"net"
)
// IsConnReset reports whether err is a connection-level drop (peer reset,
// truncated body, closed socket) as opposed to a protocol or caller error. A
// stream cut this way mid-body can be replayed from scratch, unlike a decode
// or 4xx error. The common trigger is a local proxy (v2rayN/sing-box)
// idle-closing a long-lived SSE connection during a reasoner's first-token gap.
func IsConnReset(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) ||
errors.Is(err, net.ErrClosed) || isAny(err, resetErrors) {
return true
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return false
}
var netErr net.Error
return errors.As(err, &netErr)
}
func isAny(err error, targets []error) bool {
for _, target := range targets {
if errors.Is(err, target) {
return true
}
}
return false
}
@@ -0,0 +1,43 @@
package neterr
import (
"context"
"errors"
"fmt"
"io"
"net"
"testing"
)
func TestIsConnReset(t *testing.T) {
if IsConnReset(nil) {
t.Error("nil is not a conn reset")
}
if IsConnReset(context.Canceled) || IsConnReset(context.DeadlineExceeded) {
t.Error("ctx cancel/deadline must not look like a recoverable reset")
}
if IsConnReset(errors.New("decode stream: invalid character")) {
t.Error("a plain protocol error must not be treated as a conn reset")
}
for _, err := range []error{
io.ErrUnexpectedEOF,
&net.OpError{Op: "read", Err: resetErrors[0]},
fmt.Errorf("read stream: %w", &net.OpError{Op: "read", Err: errors.New("wsarecv: forcibly closed")}),
} {
if !IsConnReset(err) {
t.Errorf("want conn reset for %v", err)
}
}
}
func TestIsConnResetExcludesDNS(t *testing.T) {
for _, dns := range []*net.DNSError{
{Err: "no such host", Name: "api.deepseek.com", IsNotFound: true},
{Err: "i/o timeout", Name: "api.deepseek.com", IsTimeout: true, IsTemporary: true},
} {
err := fmt.Errorf("dial: %w", &net.OpError{Op: "dial", Err: dns})
if IsConnReset(err) {
t.Errorf("DNS failure %v is not a connection reset", dns)
}
}
}
@@ -0,0 +1,17 @@
package nilutil
import "reflect"
// IsNil reports whether v is nil or an interface holding a typed nil value.
func IsNil(v any) bool {
if v == nil {
return true
}
rv := reflect.ValueOf(v)
switch rv.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return rv.IsNil()
default:
return false
}
}
Loaded 100 of 1574 files, more files were not shown because too many files have changed in this diff. Show more