From e4ba4944a8dc9643aa12192f8fef5049ab5638e1 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 12 Sep 2026 12:24:26 +0700
Subject: [PATCH 1/8] port: sync ClawRouter v0.12.278 core and harden Go
validation
---
.github/dependabot.yml | 14 +-
.github/workflows/ci.yml | 40 +-
.github/workflows/integration.yml | 29 +-
README.md | 4 +-
UPSTREAM_SYNC.md | 59 ++-
cache/cache.go | 29 +-
cache/requestkey_test.go | 78 +++
dedup/dedup.go | 31 +-
dedup/requestkey_test.go | 85 ++++
docs/configuration.md | 23 +
go.mod | 2 +
internal/requestkey/normalize.go | 76 +++
internal/requestkey/normalize_test.go | 112 +++++
logger/logger.go | 2 +
models/catalog_test.go | 149 ++++++
models/models.go | 670 ++++++++++++++++----------
proxy/prose.go | 65 +++
proxy/proxy.go | 306 ++++++++----
proxy/request.go | 116 +++++
proxy/spending.go | 177 +++++++
proxy/spending_test.go | 43 ++
proxy/upstream_sync_test.go | 505 +++++++++++++++++++
retry/retry.go | 20 +-
retry/retry_test.go | 96 ++++
router/config.go | 196 +++++---
spendcontrol/spendcontrol.go | 442 ++++++++++-------
spendcontrol/spendcontrol_test.go | 410 ++++++++++++++++
stats/stats.go | 155 ++++--
stats/stats_test.go | 60 +++
29 files changed, 3253 insertions(+), 741 deletions(-)
create mode 100644 cache/requestkey_test.go
create mode 100644 dedup/requestkey_test.go
create mode 100644 internal/requestkey/normalize.go
create mode 100644 internal/requestkey/normalize_test.go
create mode 100644 models/catalog_test.go
create mode 100644 proxy/prose.go
create mode 100644 proxy/request.go
create mode 100644 proxy/spending.go
create mode 100644 proxy/spending_test.go
create mode 100644 proxy/upstream_sync_test.go
create mode 100644 retry/retry_test.go
create mode 100644 spendcontrol/spendcontrol_test.go
create mode 100644 stats/stats_test.go
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 7eb16a9..5e1e461 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -1,14 +1,14 @@
version: 2
updates:
- - package-ecosystem: npm
+ - package-ecosystem: gomod
+ directory: "/"
+ schedule:
+ interval: weekly
+ day: monday
+ open-pull-requests-limit: 5
+ - package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
day: monday
open-pull-requests-limit: 5
- groups:
- dev-dependencies:
- dependency-type: development
- typescript-eslint:
- patterns:
- - "@typescript-eslint/*"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3acda55..44fd318 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -7,6 +7,9 @@ on:
branches: [main]
workflow_dispatch:
+permissions:
+ contents: read
+
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
@@ -17,38 +20,23 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
-
- - uses: actions/setup-node@v4.3.0
+ - uses: actions/setup-go@v5
with:
- node-version: "22"
- cache: npm
-
- - run: npm ci
-
- - name: Check formatting
- run: npx prettier --check .
-
- - name: Lint
- run: npx eslint src/
-
- - name: Typecheck
- run: npm run typecheck
+ go-version-file: go.mod
+ cache: true
+ - name: Vet
+ run: go vet ./...
build-and-test:
name: Build & Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
-
- - uses: actions/setup-node@v4.3.0
+ - uses: actions/setup-go@v5
with:
- node-version: "22"
- cache: npm
-
- - run: npm ci
-
+ go-version-file: go.mod
+ cache: true
- name: Build
- run: npm run build
-
- - name: Integration tests (lifecycle)
- run: npx vitest run --config vitest.integration.config.ts test/integration/lifecycle.test.ts
+ run: go build ./...
+ - name: Test with race detector
+ run: go test -race -count=1 ./...
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index 53b766d..b2de60d 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -7,6 +7,9 @@ on:
branches: [main]
workflow_dispatch:
+permissions:
+ contents: read
+
concurrency:
group: security-scanner-${{ github.ref }}
cancel-in-progress: true
@@ -14,25 +17,13 @@ concurrency:
jobs:
scanner:
runs-on: ubuntu-latest
-
steps:
- uses: actions/checkout@v4
-
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- - name: Build integration image
- uses: docker/build-push-action@v6
+ - uses: actions/setup-go@v5
with:
- context: .
- file: test/integration/Dockerfile
- load: true
- tags: clawrouter-integration:latest
- cache-from: type=gha
- cache-to: type=gha,mode=max
-
- - name: Run security scanner
- run: >
- docker compose -f test/integration/docker-compose.yml run --rm
- integration npx vitest run --config vitest.integration.config.ts
- test/integration/security-scanner.test.ts
+ go-version-file: go.mod
+ cache: true
+ - name: Install Go vulnerability scanner
+ run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
+ - name: Scan reachable vulnerabilities
+ run: govulncheck ./...
diff --git a/README.md b/README.md
index 142aa09..5fb56e4 100644
--- a/README.md
+++ b/README.md
@@ -125,9 +125,9 @@ go test ./router/ -bench=. -benchmem
## Upstream Sync
-This is a Go port of [BlockRunAI/ClawRouter](https://github.com/BlockRunAI/ClawRouter). Routing logic is synced periodically from upstream releases. Payment, plugin lifecycle, and CLI-specific features are excluded. See [UPSTREAM_SYNC.md](UPSTREAM_SYNC.md) for details.
+This is a Go port of [BlockRunAI/ClawRouter](https://github.com/BlockRunAI/ClawRouter). Applicable routing, proxy and spending-control changes are ported from upstream source snapshots. OpenClaw/desktop lifecycle and provider-specific account services are excluded; x402 signing remains unimplemented. See [UPSTREAM_SYNC.md](UPSTREAM_SYNC.md) for details.
-**Current sync**: v0.12.146
+**Current sync**: v0.12.278 source snapshot (`05de1e0`, 2026-09-12). See the tracker for adapted and deferred features.
## License
diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md
index a07e455..5da0b3c 100644
--- a/UPSTREAM_SYNC.md
+++ b/UPSTREAM_SYNC.md
@@ -2,7 +2,7 @@
**Upstream**: [BlockRunAI/ClawRouter](https://github.com/BlockRunAI/ClawRouter) (TypeScript)
**This repo**: [DOS/DOSRouter](https://github.com/DOS/DOSRouter) (Go port)
-**Last synced**: v0.12.245 (2026-08-16, core ported & aligned)
+**Last synced**: v0.12.278 source snapshot (`05de1e0`, 2026-09-12; Go-applicable core ported, exclusions below)
## Sync Workflow
@@ -30,6 +30,63 @@ These upstream areas are excluded (TS/npm-specific):
## Sync Log
+### 2026-09-12 - Sync v0.12.245 to v0.12.278 source snapshot
+
+Compared `v0.12.245...05de1e0` from BlockRunAI/ClawRouter. The newest
+published Git tag at sync time is `v0.12.277`; upstream `package.json` and
+CHANGELOG identify the main snapshot as `0.12.278`. This is a source parity
+update, not a claim that every BlockRun product feature is implemented.
+
+**Ported or adapted:**
+
+| Upstream area | Go adaptation |
+| --- | --- |
+| v0.12.248 assistant/tool prose | Preserve assistant prose with native calls and text-recovered calls; strip tagged thinking, including split SSE tags. `DOSROUTER_TOOL_CALL_PROSE=off` restores legacy suppression. Recover syntax only when tools are supplied. |
+| v0.12.252 tool-pair safety | Preserve `tool_calls`, `tool_call_id`, names and all provider extension fields during request rewriting. Avoid compressing protocol-bearing or multimodal messages. DOSRouter has no upstream-style message truncation path. |
+| v0.12.254-256 cancellation/cache | Keep Go request contexts through chat/image requests, stop fallback after disconnect, reject incomplete bodies, normalize injected first-text timestamps without merging distinct user data. |
+| v0.12.257-278 models/routing | Align chat catalog metadata and all four profile chains with router-core `5ee7c23c993013a8052588191569db5cf7fb793c`; retain DOS aliases and exact explicit pins. Retire dead free defaults, fix capability claims and prices. |
+| v0.12.263/269/274 spend safety | Atomic in-flight reservations for direct and routed chat, including fallback attempts; pending spend counts in rolling/session caps. Persist snapshots serially with atomic file replacement. Invalid state/cost fails closed. Each Server uses one controller; embedded callers may inject a shared controller explicitly. |
+| v0.12.267 ambiguous sends | Do not repeat a chat send or switch models after an ambiguous transport failure. Explicit retryable HTTP rejection statuses retain backoff/retry. DOSRouter does not yet sign x402 payments. |
+| v0.12.271-275 accounting/health | Prefer settled gateway cost headers, otherwise actual token usage, then explicitly labelled estimates. Capture gateway request IDs in usage logs; report the configured gateway origin in health. Image cost reads headers/body. Reject unknown-priced images when amount limits are configured. |
+| v0.12.272 credential transport | Refuse upstream redirects, avoid shared internal caching across caller-supplied bearer credentials, and mark authenticated responses `no-store`. |
+| Stats day windows | Go already defaulted nonpositive windows safely; cap aggregate reporting to 30 days and test using isolated log directories. |
+| Validation adaptation | Replace stale TypeScript/npm CI and missing Docker scanner targets with Go build, vet, race tests and govulncheck. Preserve job/workflow names and automatic CodeQL; prefer patched Go 1.26.6 via the toolchain directive. Dependabot follows Go modules and Actions. |
+
+**Catalog notes:** 114 upstream chat rows plus nine compatibility records, with
+250 chat aliases. The free default is `free/nemotron-3.5-lightning`. Model
+metadata reflects upstream source, not independently probed DOS providers.
+Gemini 3.6/3.8 Flash's $0.75/$3.75 promotional rates end on 2027-01-01,
+when upstream documents $1.50/$7.50; automated repricing is not implemented.
+
+**Already satisfied:** `/v1/models` lists active catalog entries; chat and image
+requests derive their context from the client; full health performs no balance
+RPC; nonpositive stats/log windows have safe defaults.
+
+**Excluded or deferred:**
+
+- OpenClaw plugin identity/migrations, desktop releases, npm dependencies,
+ Solana defaults/RPC/signing, and the added-then-removed TWZRD integration are
+ outside this standalone Go runtime.
+- BlockRun login/account-credit/status/reconcile APIs, account service proxying,
+ vendor-specific paid endpoints, image/video aliases and async polling require
+ separate product/provider contracts. The existing image endpoint remains a
+ passthrough. Missing media cost must not be interpreted as proof of a free call.
+- x402 counterparty policy and signing hooks remain deferred: `payment.submitPayment`
+ still returns `Success:false`; no signing, live charge or facilitator rollout
+ occurred. Earlier tracker wording about a full payment port overstated support.
+- Spend limits use estimates before dispatch, then gateway/token evidence when
+ available. They are not a provider-enforced USD guarantee. Unconfirmed sends
+ conservatively consume their estimate. Reservations and session counters are
+ process-local and reset at restart; file storage is not a multi-process ledger.
+ Cross-server sharing requires an explicitly shared controller.
+- Streaming textual tool-call synthesis is not implemented; native streaming
+ tool calls and prose are preserved. No live provider request was used to
+ verify catalog availability or pricing.
+
+Validation is recorded in the sync PR: Go unit/integration tests, race tests,
+build, vet, vulnerability scan and configured automated reviews. No deployment
+workflow exists in this repository, and this sync does not deploy DOS-AI.
+
### 2026-08-16 - Sync to v0.12.245 (flagship models, tool-call recovery, proxy hardening)
Diffed `v0.12.199...v0.12.245` (46 tags).
diff --git a/cache/cache.go b/cache/cache.go
index 19d1a58..d94553d 100644
--- a/cache/cache.go
+++ b/cache/cache.go
@@ -10,10 +10,10 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
- "regexp"
- "sort"
"sync"
"time"
+
+ "github.com/DOS/DOSRouter/internal/requestkey"
)
const (
@@ -33,9 +33,6 @@ var skipFields = map[string]bool{
"request_id": true,
}
-// timestampRe matches log-style timestamps like "[Mon 2024-01-15 09:30 UTC]".
-var timestampRe = regexp.MustCompile(`^\[\w{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+\w+\]\s*`)
-
// Entry is a cached response.
type Entry struct {
Body []byte
@@ -285,7 +282,7 @@ func CacheKey(body []byte) (string, error) {
delete(raw, f)
}
- canonical := canonicalize(raw)
+ canonical := canonicalize(requestkey.Normalize(raw))
encoded, err := json.Marshal(canonical)
if err != nil {
return "", fmt.Errorf("cache: marshal error: %w", err)
@@ -294,21 +291,16 @@ func CacheKey(body []byte) (string, error) {
return hex.EncodeToString(h[:]), nil
}
-// canonicalize recursively sorts object keys and strips timestamp prefixes
-// from string values, producing a deterministic structure for hashing.
+// canonicalize preserves JSON value types while copying nested containers.
+// json.Marshal sorts object keys when encoding the canonical request.
func canonicalize(v interface{}) interface{} {
switch val := v.(type) {
case map[string]interface{}:
- keys := make([]string, 0, len(val))
- for k := range val {
- keys = append(keys, k)
- }
- sort.Strings(keys)
- pairs := make([][2]interface{}, 0, len(keys))
- for _, k := range keys {
- pairs = append(pairs, [2]interface{}{k, canonicalize(val[k])})
+ out := make(map[string]interface{}, len(val))
+ for key, item := range val {
+ out[key] = canonicalize(item)
}
- return pairs
+ return out
case []interface{}:
out := make([]interface{}, len(val))
@@ -317,9 +309,6 @@ func canonicalize(v interface{}) interface{} {
}
return out
- case string:
- return timestampRe.ReplaceAllString(val, "")
-
default:
return val
}
diff --git a/cache/requestkey_test.go b/cache/requestkey_test.go
new file mode 100644
index 0000000..46f5d2e
--- /dev/null
+++ b/cache/requestkey_test.go
@@ -0,0 +1,78 @@
+package cache
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+const multimodalRequest = `{"model":"test/model","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.test/a.png","detail":"high"}},{"type":"text","text":"[Sat 2026-09-12 09:00 ICT] Describe this image"},{"type":"text","text":"[Sat 2026-09-12 10:00 ICT] Event"}]},{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"record_event","arguments":"[Sat 2026-09-12 11:00 ICT] Event"}}]}]}`
+
+func TestCacheReusesMultimodalResponseAcrossInjectedTimestamps(t *testing.T) {
+ first := []byte(multimodalRequest)
+ original := bytes.Clone(first)
+ second := []byte(strings.Replace(multimodalRequest, "09:00", "09:30", 1))
+ c := New()
+ c.Set(first, Entry{Body: []byte("cached answer"), StatusCode: 200})
+ got, ok := c.Get(second, false)
+ if !ok || string(got.Body) != "cached answer" {
+ t.Fatalf("timestamp-only change missed cached response: hit=%v, body=%q", ok, got.Body)
+ }
+ if !bytes.Equal(first, original) {
+ t.Error("cache key normalization mutated request bytes")
+ }
+}
+
+func TestCacheKeyPreservesRequestSemantics(t *testing.T) {
+ tests := []struct{ name, before, after string }{
+ {"later text timestamp", "10:00", "10:30"},
+ {"tool arguments", "11:00", "11:30"},
+ {"image URL", "a.png", "b.png"},
+ {"image detail", `"detail":"high"`, `"detail":"low"`},
+ {"model", "test/model", "test/other-model"},
+ }
+ base, err := CacheKey([]byte(multimodalRequest))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ changed := strings.Replace(multimodalRequest, tt.before, tt.after, 1)
+ key, err := CacheKey([]byte(changed))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if key == base {
+ t.Error("different response-affecting content shared a cache key")
+ }
+ })
+ }
+}
+
+func TestCacheKeyDistinguishesObjectsFromArrays(t *testing.T) {
+ object, err := CacheKey([]byte(`{"model":"test/model","payload":{"a":1}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ array, err := CacheKey([]byte(`{"model":"test/model","payload":[["a",1]]}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if object == array {
+ t.Fatal("object and array payloads shared a cache key")
+ }
+}
+
+func TestCacheKeyIgnoresObjectKeyOrder(t *testing.T) {
+ first, err := CacheKey([]byte(`{"model":"test/model","payload":{"a":1,"b":[2,3]}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := CacheKey([]byte(`{"payload":{"b":[2,3],"a":1},"model":"test/model"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first != second {
+ t.Fatal("equivalent JSON objects had different cache keys")
+ }
+}
diff --git a/dedup/dedup.go b/dedup/dedup.go
index 50ee2eb..9583a24 100644
--- a/dedup/dedup.go
+++ b/dedup/dedup.go
@@ -8,10 +8,10 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
- "regexp"
- "sort"
"sync"
"time"
+
+ "github.com/DOS/DOSRouter/internal/requestkey"
)
const (
@@ -21,9 +21,6 @@ const (
DefaultMaxBodySize = 1 << 20
)
-// timestampRe matches log-style timestamps like "[Mon 2024-01-15 09:30 UTC]".
-var timestampRe = regexp.MustCompile(`^\[\w{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+\w+\]\s*`)
-
// Response holds a cached upstream response.
type Response struct {
StatusCode int
@@ -167,7 +164,7 @@ func HashBody(body []byte) (string, error) {
if err := json.Unmarshal(body, &raw); err != nil {
return "", fmt.Errorf("dedup: invalid JSON body: %w", err)
}
- canonical := canonicalize(raw)
+ canonical := canonicalize(requestkey.Normalize(raw))
encoded, err := json.Marshal(canonical)
if err != nil {
return "", fmt.Errorf("dedup: marshal error: %w", err)
@@ -176,23 +173,16 @@ func HashBody(body []byte) (string, error) {
return hex.EncodeToString(h[:]), nil
}
-// canonicalize recursively sorts object keys and strips timestamp prefixes
-// from string values.
+// canonicalize preserves JSON value types while copying nested containers.
+// json.Marshal sorts object keys when encoding the canonical request.
func canonicalize(v interface{}) interface{} {
switch val := v.(type) {
case map[string]interface{}:
- keys := make([]string, 0, len(val))
- for k := range val {
- keys = append(keys, k)
- }
- sort.Strings(keys)
- // Use an ordered representation: []interface{} of [key, value] pairs.
- // This ensures json.Marshal produces a deterministic byte sequence.
- pairs := make([][2]interface{}, 0, len(keys))
- for _, k := range keys {
- pairs = append(pairs, [2]interface{}{k, canonicalize(val[k])})
+ out := make(map[string]interface{}, len(val))
+ for key, item := range val {
+ out[key] = canonicalize(item)
}
- return pairs
+ return out
case []interface{}:
out := make([]interface{}, len(val))
@@ -201,9 +191,6 @@ func canonicalize(v interface{}) interface{} {
}
return out
- case string:
- return timestampRe.ReplaceAllString(val, "")
-
default:
return val
}
diff --git a/dedup/requestkey_test.go b/dedup/requestkey_test.go
new file mode 100644
index 0000000..15c8130
--- /dev/null
+++ b/dedup/requestkey_test.go
@@ -0,0 +1,85 @@
+package dedup
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+const multimodalRequest = `{"model":"test/model","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.test/a.png","detail":"high"}},{"type":"text","text":"[Sat 2026-09-12 09:00 ICT] Describe this image"},{"type":"text","text":"[Sat 2026-09-12 10:00 ICT] Event"}]},{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"record_event","arguments":"[Sat 2026-09-12 11:00 ICT] Event"}}]}]}`
+
+func TestDedupReusesMultimodalResponseAcrossInjectedTimestamps(t *testing.T) {
+ first := []byte(multimodalRequest)
+ original := bytes.Clone(first)
+ second := []byte(strings.Replace(multimodalRequest, "09:00", "09:30", 1))
+ d := New()
+ calls := 0
+ call := func() (Response, error) {
+ calls++
+ return Response{Body: []byte("upstream answer"), StatusCode: 200}, nil
+ }
+ if _, hit, err := d.Do(first, call); err != nil || hit {
+ t.Fatalf("first request: hit=%v err=%v", hit, err)
+ }
+ got, hit, err := d.Do(second, call)
+ if err != nil || !hit || calls != 1 || string(got.Body) != "upstream answer" {
+ t.Fatalf("timestamp-only retry: hit=%v calls=%d body=%q err=%v", hit, calls, got.Body, err)
+ }
+ if !bytes.Equal(first, original) {
+ t.Error("dedup normalization mutated request bytes")
+ }
+}
+
+func TestHashBodyPreservesRequestSemantics(t *testing.T) {
+ tests := []struct{ name, before, after string }{
+ {"later text timestamp", "10:00", "10:30"},
+ {"tool arguments", "11:00", "11:30"},
+ {"image URL", "a.png", "b.png"},
+ {"image detail", `"detail":"high"`, `"detail":"low"`},
+ {"model", "test/model", "test/other-model"},
+ }
+ base, err := HashBody([]byte(multimodalRequest))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ changed := strings.Replace(multimodalRequest, tt.before, tt.after, 1)
+ key, err := HashBody([]byte(changed))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if key == base {
+ t.Error("different response-affecting content shared a dedup key")
+ }
+ })
+ }
+}
+
+func TestHashBodyDistinguishesObjectsFromArrays(t *testing.T) {
+ object, err := HashBody([]byte(`{"model":"test/model","payload":{"a":1}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ array, err := HashBody([]byte(`{"model":"test/model","payload":[["a",1]]}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if object == array {
+ t.Fatal("object and array payloads shared a dedup key")
+ }
+}
+
+func TestHashBodyIgnoresObjectKeyOrder(t *testing.T) {
+ first, err := HashBody([]byte(`{"model":"test/model","payload":{"a":1,"b":[2,3]}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := HashBody([]byte(`{"payload":{"b":[2,3],"a":1},"model":"test/model"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first != second {
+ t.Fatal("equivalent JSON objects had different dedup keys")
+ }
+}
diff --git a/docs/configuration.md b/docs/configuration.md
index 8c64c98..2636abf 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -238,3 +238,26 @@ go build ./...
go test ./router/ -v
go test ./router/ -bench=. -benchmem
```
+
+
+## Tool-call prose and spending controls
+
+Assistant prose is preserved alongside tool calls. Set
+`DOSROUTER_TOOL_CALL_PROSE=off` to restore legacy prose suppression. Tagged
+thinking is stripped from content, including tags split across streaming chunks.
+Request extensions and tool-call/result IDs survive model rewriting.
+
+The proxy loads amount limits from `~/.openclaw/DOS/spending.json`. Invalid
+state refuses paid dispatch rather than silently clearing limits. Direct and
+routed chat requests reserve estimated cost before dispatch, then record
+settled gateway headers or token-based cost when available. Concurrent pending
+reservations count against session, hourly and daily limits. Explicit upstream
+rejections release a reservation; ambiguous transport failures conservatively
+consume its estimate and are not retried. Unknown-priced models and image
+requests are refused when limits are configured. This is a local estimate-based
+control, not a provider-side USD guarantee or multi-process ledger.
+
+Embedded servers can supply `proxy.Config.SpendControl` to share a controller
+explicitly and `UsageLogger` to direct usage records to their own sink. Defaults
+remain file-backed. No BlockRun account credentials or x402 signer are enabled
+by the sync.
diff --git a/go.mod b/go.mod
index 12eedce..48e19f8 100644
--- a/go.mod
+++ b/go.mod
@@ -2,6 +2,8 @@ module github.com/DOS/DOSRouter
go 1.25.0
+toolchain go1.26.6
+
require (
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/sys v0.43.0 // indirect
diff --git a/internal/requestkey/normalize.go b/internal/requestkey/normalize.go
new file mode 100644
index 0000000..dce178f
--- /dev/null
+++ b/internal/requestkey/normalize.go
@@ -0,0 +1,76 @@
+// Package requestkey normalizes only timestamps injected into message content.
+package requestkey
+
+import (
+ "maps"
+ "regexp"
+)
+
+var timestamp = regexp.MustCompile(`^\[\w{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+\w+\]\s*`)
+
+// Normalize removes an injected prefix from root request messages' string
+// content or first text block. Tool results, metadata, tool arguments and later
+// text blocks are preserved, and the input request is never mutated.
+func Normalize(value any) any {
+ request, ok := value.(map[string]any)
+ if !ok {
+ return value
+ }
+ messages, ok := request["messages"].([]any)
+ if !ok {
+ return value
+ }
+
+ out := maps.Clone(request)
+ normalized := make([]any, len(messages))
+ for i, item := range messages {
+ normalized[i] = item
+ message, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ role, _ := message["role"].(string)
+ if role == "tool" || role == "function" {
+ continue
+ }
+ messageContent, exists := message["content"]
+ if !exists {
+ continue
+ }
+ messageCopy := maps.Clone(message)
+ messageCopy["content"] = content(messageContent)
+ normalized[i] = messageCopy
+ }
+ out["messages"] = normalized
+ return out
+}
+
+func content(value any) any {
+ if text, ok := value.(string); ok {
+ return timestamp.ReplaceAllString(text, "")
+ }
+ blocks, ok := value.([]any)
+ if !ok {
+ return value
+ }
+ out := make([]any, len(blocks))
+ copy(out, blocks)
+ for i, block := range blocks {
+ obj, ok := block.(map[string]any)
+ if !ok || obj["type"] != "text" {
+ continue
+ }
+ text, ok := obj["text"].(string)
+ if !ok {
+ continue
+ }
+ copy := make(map[string]any, len(obj))
+ for key, value := range obj {
+ copy[key] = value
+ }
+ copy["text"] = timestamp.ReplaceAllString(text, "")
+ out[i] = copy
+ break
+ }
+ return out
+}
diff --git a/internal/requestkey/normalize_test.go b/internal/requestkey/normalize_test.go
new file mode 100644
index 0000000..ccf6abe
--- /dev/null
+++ b/internal/requestkey/normalize_test.go
@@ -0,0 +1,112 @@
+package requestkey
+
+import (
+ "encoding/json"
+ "reflect"
+ "testing"
+)
+
+func TestNormalizeInjectedMessageTimestamps(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ want string
+ }{
+ {
+ name: "string message",
+ input: `{"messages":[{"role":"user","content":"[Sat 2026-09-12 09:00 ICT] Hello"}]}`,
+ want: `{"messages":[{"role":"user","content":"Hello"}]}`,
+ },
+ {
+ name: "first text after image",
+ input: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.test/image.png","detail":"high"}},{"type":"text","text":"[Sat 2026-09-12 09:00 ICT] Describe this image"},{"type":"text","text":"[Sat 2026-09-12 10:00 ICT] The event happened then"}]}]}`,
+ want: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.test/image.png","detail":"high"}},{"type":"text","text":"Describe this image"},{"type":"text","text":"[Sat 2026-09-12 10:00 ICT] The event happened then"}]}]}`,
+ },
+ {
+ name: "later text is never stripped",
+ input: `{"messages":[{"content":[{"type":"text","text":"Keep the quoted timestamp"},{"type":"text","text":"[Sat 2026-09-12 10:00 ICT] Event"}]}]}`,
+ want: `{"messages":[{"content":[{"type":"text","text":"Keep the quoted timestamp"},{"type":"text","text":"[Sat 2026-09-12 10:00 ICT] Event"}]}]}`,
+ },
+ {
+ name: "tool arguments are preserved",
+ input: `{"messages":[{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"record_event","arguments":"[Sat 2026-09-12 10:00 ICT] Event"}}]}]}`,
+ want: `{"messages":[{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"record_event","arguments":"[Sat 2026-09-12 10:00 ICT] Event"}}]}]}`,
+ },
+ {
+ name: "embedded timestamp is preserved",
+ input: `{"messages":[{"content":"Quote [Sat 2026-09-12 10:00 ICT] Event"}]}`,
+ want: `{"messages":[{"content":"Quote [Sat 2026-09-12 10:00 ICT] Event"}]}`,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var input, original, want any
+ for _, value := range []struct {
+ text string
+ target *any
+ }{
+ {tt.input, &input}, {tt.input, &original}, {tt.want, &want},
+ } {
+ if err := json.Unmarshal([]byte(value.text), value.target); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got := Normalize(input)
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("Normalize() = %#v, want %#v", got, want)
+ }
+ if !reflect.DeepEqual(input, original) {
+ t.Error("Normalize mutated its input")
+ }
+ })
+ }
+}
+
+func TestNormalizePreservesNonMessageContent(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ }{
+ {"root content", `{"content":"[Sat 2026-09-12 09:00 ICT] Root data"}`},
+ {"metadata content", `{"metadata":{"content":"[Sat 2026-09-12 09:00 ICT] Event"},"messages":[{"role":"user","content":"Hello"}]}`},
+ {"nested message history", `{"metadata":{"messages":[{"role":"user","content":"[Sat 2026-09-12 09:00 ICT] Historical event"}]},"messages":[]}`},
+ {"message metadata", `{"messages":[{"role":"user","content":"Hello","metadata":{"content":"[Sat 2026-09-12 09:00 ICT] Event"}}]}`},
+ {"object tool arguments", `{"messages":[{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"record_event","arguments":{"content":"[Sat 2026-09-12 09:00 ICT] Event"}}}]}]}`},
+ {"tool result string", `{"messages":[{"role":"tool","tool_call_id":"call_1","content":"[Sat 2026-09-12 09:00 ICT] Observed event"}]}`},
+ {"tool result text block", `{"messages":[{"role":"tool","tool_call_id":"call_1","content":[{"type":"text","text":"[Sat 2026-09-12 09:00 ICT] Observed event"}]}]}`},
+ {"legacy function result", `{"messages":[{"role":"function","name":"record_event","content":"[Sat 2026-09-12 09:00 ICT] Observed event"}]}`},
+ {"non-request array", `[{"role":"user","content":"[Sat 2026-09-12 09:00 ICT] Data"}]`},
+ {"empty text block array", `{"messages":[{"role":"user","content":[]}]}`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var input, original any
+ if err := json.Unmarshal([]byte(tt.input), &input); err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal([]byte(tt.input), &original); err != nil {
+ t.Fatal(err)
+ }
+ if got := Normalize(input); !reflect.DeepEqual(got, original) {
+ t.Errorf("Normalize changed content outside injected message prefixes: got %#v, want %#v", got, original)
+ }
+ if !reflect.DeepEqual(input, original) {
+ t.Error("Normalize mutated its input")
+ }
+ })
+ }
+}
+
+func TestNormalizePreservesSupportedMessageRoles(t *testing.T) {
+ var input any
+ if err := json.Unmarshal([]byte(`{"messages":[{"role":"system","content":"[Sat 2026-09-12 09:00 ICT] System"},{"role":"assistant","content":"[Sat 2026-09-12 09:00 ICT] Assistant"},{"role":"user","content":"[Sat 2026-09-12 09:00 ICT] User"}]}`), &input); err != nil {
+ t.Fatal(err)
+ }
+ var want any
+ if err := json.Unmarshal([]byte(`{"messages":[{"role":"system","content":"System"},{"role":"assistant","content":"Assistant"},{"role":"user","content":"User"}]}`), &want); err != nil {
+ t.Fatal(err)
+ }
+ if got := Normalize(input); !reflect.DeepEqual(got, want) {
+ t.Errorf("injected timestamps on supported roles were not stripped: got %#v, want %#v", got, want)
+ }
+}
diff --git a/logger/logger.go b/logger/logger.go
index 64c4541..19a975c 100644
--- a/logger/logger.go
+++ b/logger/logger.go
@@ -28,6 +28,8 @@ func ensureDir() {
// UsageEntry represents a single usage log record.
type UsageEntry struct {
+ RequestID string `json:"requestId,omitempty"`
+ CostSource string `json:"costSource,omitempty"`
Timestamp string `json:"timestamp"`
Model string `json:"model"`
Tier string `json:"tier"`
diff --git a/models/catalog_test.go b/models/catalog_test.go
new file mode 100644
index 0000000..60c5ff4
--- /dev/null
+++ b/models/catalog_test.go
@@ -0,0 +1,149 @@
+package models
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/DOS/DOSRouter/router"
+)
+
+// Catalog prices and limits feed budget admission before a request is sent.
+// Cover new targets and material corrections so omitted or stale paid entries
+// cannot bypass cost checks or advertise invalid context/output limits.
+func TestSyncedCatalogEconomicsAndLimits(t *testing.T) {
+ cases := []struct {
+ id string
+ input, output float64
+ context, max int
+ }{
+ {"openai/gpt-5.6-sol", 4, 20, 1050000, 128000},
+ {"openai/gpt-5.6-terra", 2, 12, 1050000, 128000},
+ {"openai/gpt-5.6-luna", 0.2, 1.2, 1050000, 128000},
+ {"openai/chat-latest", 5, 30, 128000, 128000},
+ {"anthropic/claude-fable-5", 10, 50, 1000000, 128000},
+ {"anthropic/claude-sonnet-4.6", 3, 15, 1000000, 128000},
+ {"anthropic/claude-sonnet-5", 3, 15, 1000000, 128000},
+ {"moonshot/kimi-k3", 3, 15, 1048576, 65536},
+ {"google/gemini-3.8-flash", 0.75, 3.75, 1048576, 65536},
+ {"google/gemini-3.6-flash", 0.75, 3.75, 1048576, 65536},
+ {"deepseek/deepseek-v4-pro", 1.32, 3.96, 1048576, 65536},
+ {"deepseek/deepseek-v4-flash-vision-exp", 0.44, 1.32, 1048576, 65536},
+ {"qwen/qwen3.8-flash", 0.15, 0.47, 1000000, 131072},
+ {"xiaomi/mimo-v2.5", 0.14, 0.28, 1048576, 131072},
+ {"zai/glm-5.3", 1.4, 4.4, 1000000, 131072},
+ {"zai/glm-5.3-flash", 0.15, 0.5, 1000000, 131072},
+ }
+ for _, tc := range cases {
+ t.Run(tc.id, func(t *testing.T) {
+ m := GetModel(tc.id)
+ if m == nil {
+ t.Fatal("model missing from budget metadata")
+ }
+ if m.InputPrice != tc.input || m.OutputPrice != tc.output {
+ t.Errorf("prices = %g/%g, want %g/%g", m.InputPrice, m.OutputPrice, tc.input, tc.output)
+ }
+ if m.ContextWindow != tc.context || m.MaxOutput != tc.max {
+ t.Errorf("context/output = %d/%d, want %d/%d", m.ContextWindow, m.MaxOutput, tc.context, tc.max)
+ }
+ })
+ }
+}
+
+func TestCatalogIdentityAndRetirementInvariants(t *testing.T) {
+ seen := make(map[string]bool)
+ for _, m := range Models {
+ if seen[m.ID] {
+ t.Errorf("duplicate model ID %q", m.ID)
+ }
+ seen[m.ID] = true
+ if m.ContextWindow <= 0 || m.MaxOutput <= 0 {
+ t.Errorf("model %q lacks positive limits", m.ID)
+ }
+ if !m.Deprecated && strings.Contains(m.ID, "/") && ResolveModelAlias(m.ID) != m.ID {
+ t.Errorf("active model %q is shadowed by an alias", m.ID)
+ }
+ if m.Deprecated {
+ fallback := GetModel(m.FallbackModel)
+ if fallback == nil || fallback.Deprecated {
+ t.Errorf("retired model %q has missing/retired fallback %q", m.ID, m.FallbackModel)
+ }
+ if strings.HasPrefix(m.ID, "free/") && !strings.HasPrefix(m.FallbackModel, "free/") {
+ t.Errorf("retired free model %q redirects to a paid model", m.ID)
+ }
+ }
+ if strings.HasPrefix(m.ID, "free/") {
+ if m.InputPrice != 0 || m.OutputPrice != 0 || GetActivePromoPrice(m.ID) != nil {
+ t.Errorf("free model %q has a charge", m.ID)
+ }
+ if m.Vision || m.ToolCalling {
+ t.Errorf("free model %q advertises unsupported image/tool eligibility", m.ID)
+ }
+ }
+ }
+ // ResolveModelAlias is single-pass: every target needs immediate metadata.
+ for alias, target := range ModelAliases {
+ if GetModel(target) == nil {
+ t.Errorf("alias %q points at uncatalogued target %q", alias, target)
+ }
+ }
+}
+
+func TestDefaultRoutingTargetsHaveActiveBudgetMetadata(t *testing.T) {
+ cfg := router.DefaultRoutingConfig()
+ profiles := map[string]map[router.Tier]router.TierConfig{
+ "auto": cfg.Tiers, "eco": cfg.EcoTiers,
+ "premium": cfg.PremiumTiers, "agentic": cfg.AgenticTiers,
+ }
+ for profile, tiers := range profiles {
+ for tier, chain := range tiers {
+ for _, id := range append([]string{chain.Primary}, chain.Fallback...) {
+ m := GetModel(id)
+ if m == nil || m.Deprecated {
+ t.Errorf("%s/%s routes to missing/retired model %q", profile, tier, id)
+ continue
+ }
+ if !strings.HasPrefix(id, "free/") && (m.InputPrice <= 0 || m.OutputPrice <= 0) {
+ t.Errorf("%s/%s paid target %q lacks budget pricing", profile, tier, id)
+ }
+ if strings.Contains(id, "gpt-oss-") {
+ t.Errorf("%s/%s selects withheld GPT-OSS model %q", profile, tier, id)
+ }
+ }
+ }
+ }
+ if free := ResolveModelAlias("free"); free != cfg.EcoTiers[router.TierSimple].Primary {
+ t.Errorf("free alias %q differs from eco SIMPLE primary %q", free, cfg.EcoTiers[router.TierSimple].Primary)
+ }
+}
+
+func TestSyncedVisionAndExplicitAliases(t *testing.T) {
+ for _, id := range []string{
+ "google/gemini-2.5-flash-lite", "google/gemini-3.8-flash",
+ "qwen/qwen3.8-flash", "deepseek/deepseek-v4-flash-vision-exp",
+ "xiaomi/mimo-v2.5", "zai/glm-5.3-flash",
+ } {
+ if !SupportsVision(id) {
+ t.Errorf("verified vision model %q is excluded from image routing", id)
+ }
+ }
+ for alias, want := range map[string]string{
+ "anthropic/claude-opus-4.5": "anthropic/claude-opus-4.5",
+ "openai/o1": "openai/o1",
+ "opus-4.7": "anthropic/claude-opus-4.7",
+ "gpt-5.6-sol-pro": "openai/gpt-5.6-sol-pro",
+ "kimi-k2.5": "moonshot/kimi-k2.5",
+ "glm-5.2": "zai/glm-5.2",
+ "glm": "zai/glm-5.3",
+ "mimo-v2.5": "xiaomi/mimo-v2.5",
+ "mimo": "xiaomi/mimo-v2.5-pro",
+ "chat-latest": "openai/chat-latest",
+ "chatgpt-instant": "openai/chat-latest",
+ "gpt-120b": "free/gpt-oss-120b",
+ "coder-free": "free/north-mini-code",
+ "deepseek-v4-pro": "deepseek/deepseek-v4-pro",
+ } {
+ if got := ResolveModelAlias(alias); got != want {
+ t.Errorf("ResolveModelAlias(%q) = %q, want %q", alias, got, want)
+ }
+ }
+}
diff --git a/models/models.go b/models/models.go
index 94a1351..30d75d5 100644
--- a/models/models.go
+++ b/models/models.go
@@ -36,254 +36,430 @@ type PromoDef struct {
// ModelAliases maps shorthand names to full model IDs.
var ModelAliases = map[string]string{
- // Claude
- "claude": "anthropic/claude-sonnet-5",
- "sonnet": "anthropic/claude-sonnet-5",
- "sonnet-5": "anthropic/claude-sonnet-5",
- "sonnet-4": "anthropic/claude-sonnet-4.6",
- "fable": "anthropic/claude-fable-5",
- "claude-fable": "anthropic/claude-fable-5",
- // Opus: Opus 5 is current flagship (upstream v0.12.233). Bare and forward aliases resolve to Opus 5;
- // explicit version pins stay on their version.
- "opus": "anthropic/claude-opus-5",
- "opus-5": "anthropic/claude-opus-5",
- "opus-5.0": "anthropic/claude-opus-5",
- "opus-4": "anthropic/claude-opus-4.8",
- "opus-4.8": "anthropic/claude-opus-4.8",
- "opus-4-8": "anthropic/claude-opus-4.8",
- "opus-4.7": "anthropic/claude-opus-4.7",
- "opus-4-7": "anthropic/claude-opus-4.7",
- "opus-4.6": "anthropic/claude-opus-4.6",
- "opus-4-6": "anthropic/claude-opus-4.6",
- "haiku": "anthropic/claude-haiku-4.5",
- "anthropic/sonnet": "anthropic/claude-sonnet-5",
- "anthropic/opus": "anthropic/claude-opus-5",
- "anthropic/claude-opus-5": "anthropic/claude-opus-5",
- "anthropic/haiku": "anthropic/claude-haiku-4.5",
- "anthropic/claude": "anthropic/claude-sonnet-5",
- "anthropic/claude-opus-4": "anthropic/claude-opus-4.8",
- "anthropic/claude-opus-4-8": "anthropic/claude-opus-4.8",
- "anthropic/claude-opus-4-7": "anthropic/claude-opus-4.7",
- "anthropic/claude-opus-4-6": "anthropic/claude-opus-4.6",
- "anthropic/claude-opus-4.5": "anthropic/claude-opus-4.8",
-
- // OpenAI — GPT-5.6 Terra is the current default flagship (upstream v0.12.219).
- "gpt": "openai/gpt-4o",
- "gpt4": "openai/gpt-4o",
- "gpt5": "openai/gpt-5.6-terra",
- "gpt-5.6": "openai/gpt-5.6-terra",
- "gpt-5.6-terra": "openai/gpt-5.6-terra",
- "gpt-5.6-sol": "openai/gpt-5.6-sol",
- "gpt-5.6-luna": "openai/gpt-5.6-luna",
- "gpt-5.5": "openai/gpt-5.5",
- "gpt-5.5-pro": "openai/gpt-5.5-pro",
- "chat-latest": "openai/chatgpt-instant",
- "chatgpt-instant": "openai/chatgpt-instant",
- "mini": "openai/gpt-4o-mini",
- "nano": "openai/gpt-5.4-nano",
- "gpt-5.4-mini": "openai/gpt-5.4-mini",
- "openai-codex/gpt-5.4-mini": "openai/gpt-5.4-mini",
- "codex": "openai/gpt-5.3-codex",
- "o1": "openai/o3",
- "o3": "openai/o3",
-
- // DeepSeek
- "deepseek": "deepseek/deepseek-chat",
- "deepseek-chat": "deepseek/deepseek-chat",
- "reasoner": "deepseek/deepseek-reasoner",
- "deepseek-v4-pro": "deepseek/deepseek-v4-pro",
-
- // Kimi / Moonshot — K2.7 bare flagship, K3 available (upstream v0.12.229/230)
- "kimi": "moonshot/kimi-k2.7",
- "moonshot": "moonshot/kimi-k2.7",
- "kimi-k3": "moonshot/kimi-k3",
- "kimi-k2.7": "moonshot/kimi-k2.7",
- "kimi-k2": "moonshot/kimi-k2.6",
- "kimi-k2.6": "moonshot/kimi-k2.6",
- "kimi-k2.5": "moonshot/kimi-k2.5",
- "nvidia/kimi-k2.5": "moonshot/kimi-k2.5",
-
- // Google
- "gemini": "google/gemini-2.5-pro",
- "flash": "google/gemini-2.5-flash",
- "gemini-3.5-flash": "google/gemini-3.5-flash",
-
- // xAI — Grok 4.5 flagship (upstream v0.12.225)
- "grok": "xai/grok-4.5",
- "grok-4.5": "xai/grok-4.5",
- "grok-4.3": "xai/grok-4.3",
- "grok-fast": "xai/grok-4-fast-reasoning",
- "grok-4.20": "xai/grok-4.20-reasoning",
- "grok-4-20": "xai/grok-4.20-reasoning",
-
- // MiniMax — M3 flagship (upstream v0.12.200)
- "minimax": "minimax/minimax-m3",
- "minimax-m3": "minimax/minimax-m3",
-
- // Qwen — Qwen 3.7 Max (upstream v0.12.231)
- "qwen3.7-max": "qwen/qwen3.7-max",
- "qwen-max": "qwen/qwen3.7-max",
-
- // Free models — realigned with BlockRun server up to v0.12.245
- "nvidia": "free/gpt-oss-120b",
- "free": "free/gpt-oss-120b",
- "qwen-coder": "free/llama-4-maverick",
- "qwen-coder-free": "free/llama-4-maverick",
- "qwen-thinking": "free/qwen3-next-80b-a3b-thinking",
- "qwen3-next": "free/qwen3-next-80b-a3b-thinking",
- "mistral-small": "free/llama-4-maverick",
- "mistral-free": "free/llama-4-maverick",
- // DeepSeek free redirects (V4 Flash EOL -> llama-4-maverick upstream v0.12.245)
- "deepseek-free": "free/llama-4-maverick",
- "deepseek-v4-flash": "free/llama-4-maverick",
- "v4-flash": "free/llama-4-maverick",
- "free/deepseek-v3.2": "free/llama-4-maverick",
- "free/deepseek-v4-pro": "free/llama-4-maverick",
- "free/deepseek-v4-flash": "free/llama-4-maverick",
- "nvidia/deepseek-v3.2": "free/llama-4-maverick",
- "nvidia/deepseek-v4-pro": "free/llama-4-maverick",
- "nvidia/deepseek-v4-flash": "free/llama-4-maverick",
- "glm-free": "free/glm-4.7",
- "llama-free": "free/llama-4-maverick",
- "maverick": "free/llama-4-maverick",
- // Retired free IDs -> successors
- "nemotron": "free/qwen3-next-80b-a3b-thinking",
- "nemotron-ultra": "free/qwen3-next-80b-a3b-thinking",
- "nemotron-253b": "free/qwen3-next-80b-a3b-thinking",
- "nemotron-super": "free/qwen3-next-80b-a3b-thinking",
- "nemotron-49b": "free/qwen3-next-80b-a3b-thinking",
- "nemotron-120b": "free/qwen3-next-80b-a3b-thinking",
- "devstral": "free/llama-4-maverick",
- "devstral-2": "free/llama-4-maverick",
- "free/nemotron-ultra-253b": "free/qwen3-next-80b-a3b-thinking",
- "free/nemotron-3-super-120b": "free/qwen3-next-80b-a3b-thinking",
- "free/nemotron-super-49b": "free/qwen3-next-80b-a3b-thinking",
- "free/mistral-large-3-675b": "free/llama-4-maverick",
- "free/mistral-small-4-119b": "free/llama-4-maverick",
- "free/devstral-2-123b": "free/llama-4-maverick",
- "free/qwen3-coder-480b": "free/llama-4-maverick",
- "free/seed-oss-36b": "free/gpt-oss-120b",
- "nvidia/nemotron-ultra-253b": "free/qwen3-next-80b-a3b-thinking",
- "nvidia/nemotron-3-super-120b": "free/qwen3-next-80b-a3b-thinking",
- "nvidia/nemotron-super-49b": "free/qwen3-next-80b-a3b-thinking",
- "nvidia/mistral-large-3-675b": "free/llama-4-maverick",
- "nvidia/devstral-2-123b": "free/llama-4-maverick",
-
- // Z.AI — GLM-5.2 is the flagship (upstream v0.12.211)
- "glm": "zai/glm-5.2",
- "glm-5.2": "zai/glm-5.2",
- "glm-5.1": "zai/glm-5.1",
- "glm-5": "zai/glm-5",
-
- // Routing profiles
- "auto-router": "auto",
- "router": "auto",
+ // Upstream chat aliases plus DOS-compatible shorthand targets.
+ // Bare Claude/Sonnet, o1, Gemini, Flash and paid DeepSeek Pro retain DOS behavior.
+ "anthropic/claude": "anthropic/claude-sonnet-5",
+ "anthropic/claude-fable-5.0": "anthropic/claude-fable-5",
+ "anthropic/claude-haiku-4": "anthropic/claude-haiku-4.5",
+ "anthropic/claude-haiku-4-5": "anthropic/claude-haiku-4.5",
+ "anthropic/claude-opus-4": "anthropic/claude-opus-4.8",
+ "anthropic/claude-opus-4-5": "anthropic/claude-opus-4.5",
+ "anthropic/claude-opus-4-6": "anthropic/claude-opus-4.6",
+ "anthropic/claude-opus-4-7": "anthropic/claude-opus-4.7",
+ "anthropic/claude-opus-4-8": "anthropic/claude-opus-4.8",
+ "anthropic/claude-opus-5": "anthropic/claude-opus-5",
+ "anthropic/claude-opus-5-0": "anthropic/claude-opus-5",
+ "anthropic/claude-opus-5.0": "anthropic/claude-opus-5",
+ "anthropic/claude-sonnet-4": "anthropic/claude-sonnet-4.6",
+ "anthropic/claude-sonnet-4-5": "anthropic/claude-sonnet-4.5",
+ "anthropic/claude-sonnet-4-6": "anthropic/claude-sonnet-4.6",
+ "anthropic/fable": "anthropic/claude-fable-5",
+ "anthropic/haiku": "anthropic/claude-haiku-4.5",
+ "anthropic/opus": "anthropic/claude-opus-5",
+ "anthropic/sonnet": "anthropic/claude-sonnet-5",
+ "auto-router": "auto",
+ "br-sonnet": "anthropic/claude-sonnet-4.6",
+ "chat-latest": "openai/chat-latest",
+ "chatgpt": "openai/chat-latest",
+ "chatgpt-instant": "openai/chat-latest",
+ "claude": "anthropic/claude-sonnet-5",
+ "claude-fable": "anthropic/claude-fable-5",
+ "coder-free": "free/north-mini-code",
+ "codex": "openai/gpt-5.3-codex",
+ "cohere/north-mini-code": "free/north-mini-code",
+ "deepseek": "deepseek/deepseek-chat",
+ "deepseek-chat": "deepseek/deepseek-chat",
+ "deepseek-free": "free/nemotron-3.5-lightning",
+ "deepseek-v4-flash": "free/nemotron-3.5-lightning",
+ "deepseek-v4-pro": "deepseek/deepseek-v4-pro",
+ "deepseek-vision": "deepseek/deepseek-v4-flash-vision-exp",
+ "devstral": "free/nemotron-3.5-lightning",
+ "devstral-2": "free/nemotron-3.5-lightning",
+ "fable": "anthropic/claude-fable-5",
+ "fable-5": "anthropic/claude-fable-5",
+ "fable-5.0": "anthropic/claude-fable-5",
+ "flash": "google/gemini-2.5-flash",
+ "free": "free/nemotron-3.5-lightning",
+ "free/deepseek-v3.2": "free/nemotron-3.5-lightning",
+ "free/deepseek-v4-flash": "free/nemotron-3.5-lightning",
+ "free/deepseek-v4-pro": "free/nemotron-3.5-lightning",
+ "free/devstral-2-123b": "free/nemotron-3.5-lightning",
+ "free/mistral-large-3-675b": "free/nemotron-3.5-lightning",
+ "free/mistral-small-4-119b": "free/nemotron-3.5-lightning",
+ "free/nemotron-3-super-120b": "free/nemotron-3.5-lightning",
+ "free/nemotron-super-49b": "free/nemotron-3.5-lightning",
+ "free/nemotron-ultra-253b": "free/nemotron-3.5-lightning",
+ "free/qwen3-coder-480b": "free/nemotron-3.5-lightning",
+ "free/seed-oss-36b": "free/nemotron-3.5-lightning",
+ "gemini": "google/gemini-2.5-pro",
+ "gemini-2.5-flash-lite": "google/gemini-2.5-flash-lite",
+ "gemini-3-pro": "google/gemini-3.1-pro",
+ "gemini-3-pro-preview": "google/gemini-3.1-pro",
+ "gemini-3.1-flash-lite": "google/gemini-3.1-flash-lite",
+ "gemini-3.1-pro": "google/gemini-3.1-pro",
+ "gemini-3.1-pro-preview": "google/gemini-3.1-pro",
+ "gemini-3.5-flash": "google/gemini-3.5-flash",
+ "gemini-3.5-flash-lite": "google/gemini-3.5-flash-lite",
+ "gemini-3.6": "google/gemini-3.6-flash",
+ "gemini-3.6-flash": "google/gemini-3.6-flash",
+ "gemini-pro": "google/gemini-3.1-pro",
+ "glm": "zai/glm-5.3",
+ "glm-5": "zai/glm-5",
+ "glm-5-3": "zai/glm-5.3",
+ "glm-5-3-flash": "zai/glm-5.3-flash",
+ "glm-5-turbo": "zai/glm-5-turbo",
+ "glm-5.1": "zai/glm-5.1",
+ "glm-5.2": "zai/glm-5.2",
+ "glm-5.3": "zai/glm-5.3",
+ "glm-5.3-flash": "zai/glm-5.3-flash",
+ "glm-flash": "zai/glm-5.3-flash",
+ "glm-free": "free/nemotron-3.5-lightning",
+ "google/gemini-3-pro-preview": "google/gemini-3.1-pro",
+ "google/gemini-3.1-pro-preview": "google/gemini-3.1-pro",
+ "gpt": "openai/gpt-4o",
+ "gpt-120b": "free/gpt-oss-120b",
+ "gpt-20b": "free/gpt-oss-20b",
+ "gpt-5-nano": "openai/gpt-5.4-nano",
+ "gpt-5.4": "openai/gpt-5.4",
+ "gpt-5.4-mini": "openai/gpt-5.4-mini",
+ "gpt-5.4-nano": "openai/gpt-5.4-nano",
+ "gpt-5.4-pro": "openai/gpt-5.4-pro",
+ "gpt-5.5": "openai/gpt-5.5",
+ "gpt-5.5-pro": "openai/gpt-5.5-pro",
+ "gpt-5.6": "openai/gpt-5.6-terra",
+ "gpt-5.6-luna": "openai/gpt-5.6-luna",
+ "gpt-5.6-luna-pro": "openai/gpt-5.6-luna-pro",
+ "gpt-5.6-sol": "openai/gpt-5.6-sol",
+ "gpt-5.6-sol-pro": "openai/gpt-5.6-sol-pro",
+ "gpt-5.6-terra": "openai/gpt-5.6-terra",
+ "gpt-5.6-terra-pro": "openai/gpt-5.6-terra-pro",
+ "gpt4": "openai/gpt-4o",
+ "gpt5": "openai/gpt-5.6-terra",
+ "grok": "xai/grok-4.5",
+ "grok-4-20": "xai/grok-4.20-reasoning",
+ "grok-4-5": "xai/grok-4.5",
+ "grok-4.20": "xai/grok-4.20-reasoning",
+ "grok-4.3": "xai/grok-4.3",
+ "grok-4.5": "xai/grok-4.5",
+ "grok-build": "xai/grok-build-0.1",
+ "grok-code": "xai/grok-build-0.1",
+ "grok-code-fast-1": "deepseek/deepseek-chat",
+ "grok-fast": "xai/grok-4-fast-reasoning",
+ "haiku": "anthropic/claude-haiku-4.5",
+ "hunyuan": "tencent/hy3",
+ "hy3": "tencent/hy3",
+ "kimi": "moonshot/kimi-k2.7",
+ "kimi-k2": "moonshot/kimi-k2.6",
+ "kimi-k2.5": "moonshot/kimi-k2.5",
+ "kimi-k2.6": "moonshot/kimi-k2.6",
+ "kimi-k2.7": "moonshot/kimi-k2.7",
+ "kimi-k3": "moonshot/kimi-k3",
+ "laguna": "free/laguna-xs-2.1",
+ "laguna-xs": "free/laguna-xs-2.1",
+ "lightning": "free/nemotron-3.5-lightning",
+ "llama-3.2-vision": "free/llama-3.2-11b-vision",
+ "llama-free": "free/llama-3.2-11b-vision",
+ "llama-vision": "free/llama-3.2-11b-vision",
+ "luna-pro": "openai/gpt-5.6-luna-pro",
+ "maverick": "free/llama-4-maverick",
+ "mimo": "xiaomi/mimo-v2.5-pro",
+ "mimo-v2.5": "xiaomi/mimo-v2.5",
+ "mimo-v2.5-pro": "xiaomi/mimo-v2.5-pro",
+ "mimo-vision": "xiaomi/mimo-v2.5",
+ "mini": "openai/gpt-4o-mini",
+ "minimax": "minimax/minimax-m3",
+ "minimax-m2.5": "minimax/minimax-m2.5",
+ "minimax-m2.7": "minimax/minimax-m2.7",
+ "minimax-m3": "minimax/minimax-m3",
+ "mistral-free": "free/nemotron-3.5-lightning",
+ "mistral-large": "free/mistral-large-3-675b",
+ "mistral-large-3-675b": "free/mistral-large-3-675b",
+ "mistral-nemotron": "free/mistral-nemotron",
+ "mistral-small": "free/nemotron-3.5-lightning",
+ "moonshot": "moonshot/kimi-k2.7",
+ "nano": "openai/gpt-5.4-nano",
+ "nano-30b": "free/nemotron-3-nano-30b",
+ "nano-omni": "free/nemotron-3-nano-omni-30b-a3b-reasoning",
+ "nano-vl": "free/nemotron-3-nano-omni-30b-a3b-reasoning",
+ "nemotron": "free/nemotron-3.5-lightning",
+ "nemotron-120b": "free/nemotron-3.5-lightning",
+ "nemotron-253b": "free/nemotron-3.5-lightning",
+ "nemotron-3.5-lightning": "free/nemotron-3.5-lightning",
+ "nemotron-49b": "free/nemotron-3.5-lightning",
+ "nemotron-lightning": "free/nemotron-3.5-lightning",
+ "nemotron-nano": "free/nemotron-3-nano-30b",
+ "nemotron-nano-30b": "free/nemotron-3-nano-30b",
+ "nemotron-nano-9b": "free/nemotron-3-nano-30b",
+ "nemotron-nano-vl": "free/nemotron-3-nano-omni-30b-a3b-reasoning",
+ "nemotron-omni": "free/nemotron-3-nano-omni-30b-a3b-reasoning",
+ "nemotron-super": "free/nemotron-3.5-lightning",
+ "nemotron-ultra": "free/nemotron-3.5-lightning",
+ "nemotron-ultra-550b": "free/nemotron-3-ultra-550b",
+ "north-mini": "free/north-mini-code",
+ "north-mini-code": "free/north-mini-code",
+ "nvidia": "free/nemotron-3.5-lightning",
+ "nvidia/deepseek-v3.2": "free/nemotron-3.5-lightning",
+ "nvidia/deepseek-v4-flash": "free/nemotron-3.5-lightning",
+ "nvidia/deepseek-v4-pro": "free/nemotron-3.5-lightning",
+ "nvidia/devstral-2-123b": "free/nemotron-3.5-lightning",
+ "nvidia/glm-4.7": "free/glm-4.7",
+ "nvidia/gpt-oss-120b": "free/gpt-oss-120b",
+ "nvidia/gpt-oss-20b": "free/gpt-oss-20b",
+ "nvidia/kimi-k2.5": "moonshot/kimi-k2.5",
+ "nvidia/llama-3.2-11b-vision": "free/llama-3.2-11b-vision",
+ "nvidia/llama-4-maverick": "free/llama-4-maverick",
+ "nvidia/mistral-large-3-675b": "free/nemotron-3.5-lightning",
+ "nvidia/mistral-nemotron": "free/mistral-nemotron",
+ "nvidia/nemotron-3-nano-30b": "free/nemotron-3-nano-30b",
+ "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning": "free/nemotron-3-nano-omni-30b-a3b-reasoning",
+ "nvidia/nemotron-3-super-120b": "free/nemotron-3.5-lightning",
+ "nvidia/nemotron-3-ultra-550b": "free/nemotron-3-ultra-550b",
+ "nvidia/nemotron-3.5-lightning": "free/nemotron-3.5-lightning",
+ "nvidia/nemotron-nano-12b-v2-vl": "free/nemotron-nano-12b-v2-vl",
+ "nvidia/nemotron-nano-9b-v2": "free/nemotron-nano-9b-v2",
+ "nvidia/nemotron-super-49b": "free/nemotron-3.5-lightning",
+ "nvidia/nemotron-ultra-253b": "free/nemotron-3.5-lightning",
+ "nvidia/qwen3-coder-480b": "free/qwen3-coder-480b",
+ "nvidia/qwen3-next-80b-a3b-instruct": "free/qwen3-next-80b-a3b-instruct",
+ "nvidia/qwen3-next-80b-a3b-thinking": "free/qwen3-next-80b-a3b-instruct",
+ "nvidia/qwen3.5-122b-a10b": "free/qwen3.5-122b-a10b",
+ "nvidia/seed-oss-36b": "free/seed-oss-36b",
+ "nvidia/step-3.7-flash": "free/step-3.7-flash",
+ "o1": "openai/o3",
+ "o1-mini": "openai/o4-mini",
+ "o3": "openai/o3",
+ "openai-codex/gpt-5.4-mini": "openai/gpt-5.4-mini",
+ "openai/chatgpt-instant": "openai/chat-latest",
+ "openai/gpt-5.6": "openai/gpt-5.6-terra",
+ "opus": "anthropic/claude-opus-5",
+ "opus-4": "anthropic/claude-opus-4.8",
+ "opus-4-6": "anthropic/claude-opus-4.6",
+ "opus-4-7": "anthropic/claude-opus-4.7",
+ "opus-4-8": "anthropic/claude-opus-4.8",
+ "opus-4.6": "anthropic/claude-opus-4.6",
+ "opus-4.7": "anthropic/claude-opus-4.7",
+ "opus-4.8": "anthropic/claude-opus-4.8",
+ "opus-5": "anthropic/claude-opus-5",
+ "opus-5-0": "anthropic/claude-opus-5",
+ "opus-5.0": "anthropic/claude-opus-5",
+ "poolside/laguna-xs-2.1": "free/laguna-xs-2.1",
+ "qwen-3.7-flash": "qwen/qwen3.7-flash",
+ "qwen-3.7-max": "qwen/qwen3.7-max",
+ "qwen-3.7-plus": "qwen/qwen3.7-plus",
+ "qwen-coder": "free/nemotron-3.5-lightning",
+ "qwen-coder-free": "free/nemotron-3.5-lightning",
+ "qwen-max": "qwen/qwen3.7-max",
+ "qwen-thinking": "free/nemotron-3.5-lightning",
+ "qwen-vision": "qwen/qwen3.8-flash",
+ "qwen/qwen3-coder-480b-a35b-instruct": "free/qwen3-coder-480b",
+ "qwen3-122b": "free/qwen3.5-122b-a10b",
+ "qwen3-7-max": "qwen/qwen3.7-max",
+ "qwen3-8-flash": "qwen/qwen3.8-flash",
+ "qwen3-next": "free/nemotron-3.5-lightning",
+ "qwen3-next-80b": "free/qwen3-next-80b-a3b-instruct",
+ "qwen3.5-122b": "free/qwen3.5-122b-a10b",
+ "qwen3.7-flash": "qwen/qwen3.7-flash",
+ "qwen3.7-max": "qwen/qwen3.7-max",
+ "qwen3.7-plus": "qwen/qwen3.7-plus",
+ "qwen3.8-flash": "qwen/qwen3.8-flash",
+ "reasoner": "deepseek/deepseek-reasoner",
+ "router": "auto",
+ "seed-oss": "free/seed-oss-36b",
+ "seed-oss-36b": "free/seed-oss-36b",
+ "sol-pro": "openai/gpt-5.6-sol-pro",
+ "sonnet": "anthropic/claude-sonnet-5",
+ "sonnet-4": "anthropic/claude-sonnet-4.6",
+ "sonnet-4-5": "anthropic/claude-sonnet-4.5",
+ "sonnet-4-6": "anthropic/claude-sonnet-4.6",
+ "sonnet-4.5": "anthropic/claude-sonnet-4.5",
+ "sonnet-4.6": "anthropic/claude-sonnet-4.6",
+ "sonnet-5": "anthropic/claude-sonnet-5",
+ "sonnet-5-0": "anthropic/claude-sonnet-5",
+ "sonnet-5.0": "anthropic/claude-sonnet-5",
+ "step-3.7-flash": "free/step-3.7-flash",
+ "step-flash": "free/step-3.7-flash",
+ "tencent": "tencent/hy3",
+ "terra-pro": "openai/gpt-5.6-terra-pro",
+ "ultra-550b": "free/nemotron-3-ultra-550b",
+ "v4-flash": "free/nemotron-3.5-lightning",
+ "v4-flash-vision": "deepseek/deepseek-v4-flash-vision-exp",
+ "v4-pro": "free/nemotron-3.5-lightning",
+ "vision-free": "free/nemotron-3-nano-omni-30b-a3b-reasoning",
+ "xai/grok-3-fast": "xai/grok-4-fast-reasoning",
+ "xai/grok-code-fast-1": "deepseek/deepseek-chat",
+ "xiaomi": "xiaomi/mimo-v2.5-pro",
}
// Models is the full catalog of supported models.
var Models = []ModelDef{
- // Smart routing meta-models
- {ID: "auto", Name: "Auto (Smart Router - Balanced)", ContextWindow: 1_050_000, MaxOutput: 128_000},
- {ID: "eco", Name: "Eco (Smart Router - Cost Optimized)", ContextWindow: 1_050_000, MaxOutput: 128_000},
- {ID: "premium", Name: "Premium (Smart Router - Best Quality)", ContextWindow: 2_000_000, MaxOutput: 200_000},
- {ID: "free", Name: "Free - Nemotron Ultra 253B", ContextWindow: 131_072, MaxOutput: 16_384, Reasoning: true},
-
- // OpenAI — GPT-5.6 Terra is current flagship (upstream v0.12.219).
- {ID: "openai/gpt-5.6-terra", Name: "GPT-5.6 Terra", Version: "5.6", InputPrice: 5.0, OutputPrice: 30.0, ContextWindow: 1_050_000, MaxOutput: 128_000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "openai/gpt-5.6-sol", Name: "GPT-5.6 Sol", Version: "5.6", InputPrice: 5.0, OutputPrice: 30.0, ContextWindow: 1_050_000, MaxOutput: 128_000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "openai/gpt-5.6-luna", Name: "GPT-5.6 Luna", Version: "5.6", InputPrice: 5.0, OutputPrice: 30.0, ContextWindow: 1_050_000, MaxOutput: 128_000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "openai/gpt-5.5-pro", Name: "GPT-5.5 Pro", Version: "5.5", InputPrice: 21.0, OutputPrice: 168.0, ContextWindow: 1_050_000, MaxOutput: 128_000, Reasoning: true, ToolCalling: true},
- {ID: "openai/gpt-5.5", Name: "GPT-5.5", Version: "5.5", InputPrice: 5.0, OutputPrice: 30.0, ContextWindow: 1_050_000, MaxOutput: 128_000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "openai/gpt-5.4", Name: "GPT-5.4", Version: "5.4", InputPrice: 2.5, OutputPrice: 10.0, ContextWindow: 1_050_000, MaxOutput: 128_000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "openai/gpt-5.4-mini", Name: "GPT-5.4 Mini", Version: "5.4", InputPrice: 0.75, OutputPrice: 4.5, ContextWindow: 400_000, MaxOutput: 128_000, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "openai/gpt-5.4-nano", Name: "GPT-5.4 Nano", Version: "5.4", InputPrice: 0.20, OutputPrice: 1.25, ContextWindow: 1_050_000, MaxOutput: 128_000, ToolCalling: true},
- {ID: "openai/gpt-5.4-pro", Name: "GPT-5.4 Pro", Version: "5.4", InputPrice: 21.0, OutputPrice: 168.0, ContextWindow: 1_050_000, MaxOutput: 128_000, Reasoning: true, ToolCalling: true},
- {ID: "openai/gpt-5.3-codex", Name: "GPT-5.3 Codex", Version: "5.3", InputPrice: 1.75, OutputPrice: 14.0, ContextWindow: 400_000, MaxOutput: 128_000, Reasoning: true, Agentic: true, ToolCalling: true},
- {ID: "openai/chatgpt-instant", Name: "ChatGPT Instant", InputPrice: 0.50, OutputPrice: 2.00, ContextWindow: 128_000, MaxOutput: 16_384, ToolCalling: true},
- {ID: "openai/gpt-4o", Name: "GPT-4o", InputPrice: 2.5, OutputPrice: 10.0, ContextWindow: 128_000, MaxOutput: 16_384, Vision: true, ToolCalling: true},
- {ID: "openai/gpt-4o-mini", Name: "GPT-4o Mini", InputPrice: 0.15, OutputPrice: 0.6, ContextWindow: 128_000, MaxOutput: 16_384, ToolCalling: true},
- {ID: "openai/o3", Name: "o3", InputPrice: 2.0, OutputPrice: 8.0, ContextWindow: 200_000, MaxOutput: 100_000, Reasoning: true, ToolCalling: true},
- {ID: "openai/o4-mini", Name: "o4-mini", InputPrice: 1.10, OutputPrice: 4.40, ContextWindow: 200_000, MaxOutput: 100_000, Reasoning: true, ToolCalling: true},
-
- // Anthropic — Opus 5 is the current flagship (upstream v0.12.233).
- {ID: "anthropic/claude-opus-5", Name: "Claude Opus 5", Version: "5.0", InputPrice: 5.0, OutputPrice: 25.0, ContextWindow: 1_000_000, MaxOutput: 128_000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "anthropic/claude-opus-4.8", Name: "Claude Opus 4.8", Version: "4.8", InputPrice: 5.0, OutputPrice: 25.0, ContextWindow: 1_000_000, MaxOutput: 128_000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "anthropic/claude-opus-4.7", Name: "Claude Opus 4.7", Version: "4.7", InputPrice: 5.0, OutputPrice: 25.0, ContextWindow: 1_000_000, MaxOutput: 128_000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "anthropic/claude-opus-4.6", Name: "Claude Opus 4.6", Version: "4.6", InputPrice: 5.0, OutputPrice: 25.0, ContextWindow: 1_000_000, MaxOutput: 128_000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "anthropic/claude-sonnet-5", Name: "Claude Sonnet 5", Version: "5.0", InputPrice: 3.0, OutputPrice: 15.0, ContextWindow: 200_000, MaxOutput: 64_000, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "anthropic/claude-fable-5", Name: "Claude Fable 5", Version: "5.0", InputPrice: 3.0, OutputPrice: 15.0, ContextWindow: 200_000, MaxOutput: 64_000, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "anthropic/claude-sonnet-4.6", Name: "Claude Sonnet 4.6", Version: "4.6", InputPrice: 3.0, OutputPrice: 15.0, ContextWindow: 200_000, MaxOutput: 64_000, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "anthropic/claude-haiku-4.5", Name: "Claude Haiku 4.5", Version: "4.5", InputPrice: 0.80, OutputPrice: 4.0, ContextWindow: 200_000, MaxOutput: 8192, ToolCalling: true},
-
- // Google
- {ID: "google/gemini-3.5-flash", Name: "Gemini 3.5 Flash", Version: "3.5", InputPrice: 0.15, OutputPrice: 0.60, ContextWindow: 1_050_000, MaxOutput: 65_536, Vision: true, ToolCalling: true},
- {ID: "google/gemini-3.1-pro", Name: "Gemini 3.1 Pro", Version: "3.1", InputPrice: 1.25, OutputPrice: 10.0, ContextWindow: 2_000_000, MaxOutput: 65_536, Vision: true, ToolCalling: true},
- {ID: "google/gemini-3.1-flash-lite", Name: "Gemini 3.1 Flash Lite", Version: "3.1", InputPrice: 0.25, OutputPrice: 1.50, ContextWindow: 1_050_000, MaxOutput: 65_536, ToolCalling: true},
- {ID: "google/gemini-3-pro-preview", Name: "Gemini 3 Pro Preview", Version: "3.0", InputPrice: 1.25, OutputPrice: 10.0, ContextWindow: 1_050_000, MaxOutput: 65_536, Vision: true, ToolCalling: true, Deprecated: true, FallbackModel: "google/gemini-3.1-pro"},
- {ID: "google/gemini-3-flash-preview", Name: "Gemini 3 Flash Preview", Version: "3.0", InputPrice: 0.15, OutputPrice: 0.60, ContextWindow: 1_050_000, MaxOutput: 65_536, ToolCalling: true},
- {ID: "google/gemini-2.5-pro", Name: "Gemini 2.5 Pro", Version: "2.5", InputPrice: 1.25, OutputPrice: 10.0, ContextWindow: 1_050_000, MaxOutput: 65_536, Reasoning: true, Vision: true, ToolCalling: true},
- {ID: "google/gemini-2.5-flash", Name: "Gemini 2.5 Flash", Version: "2.5", InputPrice: 0.15, OutputPrice: 0.60, ContextWindow: 1_050_000, MaxOutput: 65_536, Vision: true, ToolCalling: true},
- {ID: "google/gemini-2.5-flash-lite", Name: "Gemini 2.5 Flash Lite", Version: "2.5", InputPrice: 0.10, OutputPrice: 0.40, ContextWindow: 1_050_000, MaxOutput: 65_536, ToolCalling: true},
-
- // DeepSeek
- {ID: "deepseek/deepseek-v4-pro", Name: "DeepSeek V4 Pro", Version: "v4-pro", InputPrice: 0.55, OutputPrice: 2.19, ContextWindow: 1_000_000, MaxOutput: 65_536, Reasoning: true, ToolCalling: true},
- {ID: "deepseek/deepseek-chat", Name: "DeepSeek V3", InputPrice: 0.27, OutputPrice: 1.10, ContextWindow: 128_000, MaxOutput: 16_384, ToolCalling: true},
- {ID: "deepseek/deepseek-reasoner", Name: "DeepSeek R1", InputPrice: 0.55, OutputPrice: 2.19, ContextWindow: 128_000, MaxOutput: 16_384, Reasoning: true},
-
- // Kimi K3 & K2.7 - Moonshot flagship (upstream v0.12.229/230)
- {ID: "moonshot/kimi-k3", Name: "Kimi K3", Version: "k3", InputPrice: 1.20, OutputPrice: 5.0, ContextWindow: 262_144, MaxOutput: 65_536, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "moonshot/kimi-k2.7", Name: "Kimi K2.7", Version: "k2.7", InputPrice: 0.95, OutputPrice: 4.0, ContextWindow: 262_144, MaxOutput: 65_536, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "moonshot/kimi-k2.6", Name: "Kimi K2.6", Version: "k2.6", InputPrice: 0.95, OutputPrice: 4.0, ContextWindow: 262_144, MaxOutput: 65_536, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "moonshot/kimi-k2.5", Name: "Kimi K2.5", Version: "k2.5", InputPrice: 0.60, OutputPrice: 3.0, ContextWindow: 262_144, MaxOutput: 16_384, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
- {ID: "nvidia/kimi-k2.5", Name: "Kimi K2.5 (NVIDIA, retired)", Version: "k2.5", InputPrice: 0.60, OutputPrice: 3.0, ContextWindow: 262_144, MaxOutput: 8192, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true, Deprecated: true, FallbackModel: "moonshot/kimi-k2.5"},
-
- // Qwen
- {ID: "qwen/qwen3.7-max", Name: "Qwen 3.7 Max", Version: "3.7", InputPrice: 1.50, OutputPrice: 6.0, ContextWindow: 1_000_000, MaxOutput: 65_536, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
-
- // xAI — Grok 4.5 & 4.3 (upstream v0.12.201, v0.12.225)
- {ID: "xai/grok-4.5", Name: "Grok 4.5", Version: "4.5", InputPrice: 2.0, OutputPrice: 6.0, ContextWindow: 2_000_000, MaxOutput: 16_384, Reasoning: true, ToolCalling: true},
- {ID: "xai/grok-4.3", Name: "Grok 4.3", Version: "4.3", InputPrice: 2.0, OutputPrice: 6.0, ContextWindow: 2_000_000, MaxOutput: 16_384, Reasoning: true, ToolCalling: true},
- {ID: "xai/grok-4-0709", Name: "Grok 4", InputPrice: 3.0, OutputPrice: 15.0, ContextWindow: 200_000, MaxOutput: 100_000, Vision: true, ToolCalling: true},
- {ID: "xai/grok-4-fast-reasoning", Name: "Grok 4 Fast Reasoning", InputPrice: 0.20, OutputPrice: 0.50, ContextWindow: 131_072, MaxOutput: 32_768, Reasoning: true, ToolCalling: true},
- {ID: "xai/grok-4-1-fast-reasoning", Name: "Grok 4.1 Fast Reasoning", InputPrice: 0.20, OutputPrice: 0.50, ContextWindow: 131_072, MaxOutput: 32_768, Reasoning: true, ToolCalling: true},
- {ID: "xai/grok-4-fast-non-reasoning", Name: "Grok 4 Fast", InputPrice: 0.20, OutputPrice: 0.50, ContextWindow: 131_072, MaxOutput: 32_768, ToolCalling: true},
- {ID: "xai/grok-4-1-fast-non-reasoning", Name: "Grok 4.1 Fast", InputPrice: 0.20, OutputPrice: 0.50, ContextWindow: 131_072, MaxOutput: 32_768, ToolCalling: true},
- {ID: "xai/grok-3", Name: "Grok 3", InputPrice: 3.0, OutputPrice: 15.0, ContextWindow: 131_072, MaxOutput: 32_768, ToolCalling: true},
- {ID: "xai/grok-3-mini", Name: "Grok 3 Mini", InputPrice: 0.30, OutputPrice: 0.50, ContextWindow: 131_072, MaxOutput: 32_768, Reasoning: true, ToolCalling: true},
-
- // Grok 4.20 family (upstream v0.12.155): 2M context, multi-agent variant.
- {ID: "xai/grok-4.20-reasoning", Name: "Grok 4.20 Reasoning", Version: "4.20", InputPrice: 2.0, OutputPrice: 6.0, ContextWindow: 2_000_000, MaxOutput: 16_384, Reasoning: true, ToolCalling: true},
- {ID: "xai/grok-4.20-non-reasoning", Name: "Grok 4.20", Version: "4.20", InputPrice: 2.0, OutputPrice: 6.0, ContextWindow: 2_000_000, MaxOutput: 16_384, ToolCalling: true},
- {ID: "xai/grok-4.20-multi-agent", Name: "Grok 4.20 Multi-Agent", Version: "4.20", InputPrice: 2.0, OutputPrice: 6.0, ContextWindow: 2_000_000, MaxOutput: 16_384, Reasoning: true, ToolCalling: true},
-
- // Free (NVIDIA)
- {ID: "free/gpt-oss-120b", Name: "GPT-OSS 120B (Free)", ContextWindow: 131_072, MaxOutput: 16_384},
- {ID: "free/gpt-oss-20b", Name: "GPT-OSS 20B (Free)", ContextWindow: 131_072, MaxOutput: 16_384},
- {ID: "free/qwen3-next-80b-a3b-thinking", Name: "Qwen3 Next 80B A3B Thinking (Free)", ContextWindow: 131_072, MaxOutput: 16_384, Reasoning: true},
- {ID: "free/mistral-small-4-119b", Name: "Mistral Small 4 119B (retired)", ContextWindow: 131_072, MaxOutput: 16_384, Deprecated: true, FallbackModel: "free/llama-4-maverick"},
- {ID: "free/deepseek-v4-flash", Name: "DeepSeek V4 Flash (retired)", Version: "v4-flash", ContextWindow: 1_000_000, MaxOutput: 16_384, Reasoning: true, Deprecated: true, FallbackModel: "free/llama-4-maverick"},
- {ID: "free/deepseek-v3.2", Name: "DeepSeek V3.2 (retired)", ContextWindow: 131_072, MaxOutput: 16_384, Deprecated: true, FallbackModel: "free/llama-4-maverick"},
- {ID: "free/deepseek-v4-pro", Name: "DeepSeek V4 Pro (retired)", ContextWindow: 1_000_000, MaxOutput: 16_384, Reasoning: true, Deprecated: true, FallbackModel: "free/llama-4-maverick"},
- {ID: "free/qwen3-coder-480b", Name: "Qwen3 Coder 480B (retired)", ContextWindow: 131_072, MaxOutput: 16_384, Deprecated: true, FallbackModel: "free/llama-4-maverick"},
- {ID: "free/glm-4.7", Name: "GLM 4.7 (Free)", ContextWindow: 131_072, MaxOutput: 16_384},
- {ID: "free/llama-4-maverick", Name: "Llama 4 Maverick (Free)", ContextWindow: 131_072, MaxOutput: 16_384},
- {ID: "free/seed-oss-36b", Name: "Seed OSS 36B (retired)", ContextWindow: 131_072, MaxOutput: 16_384, Deprecated: true, FallbackModel: "free/gpt-oss-120b"},
- // Retired free models (upstream v0.12.160/v0.12.245): kept for catalog back-compat, routed via FallbackModel.
- {ID: "free/nemotron-ultra-253b", Name: "Nemotron Ultra 253B (retired)", ContextWindow: 131_072, MaxOutput: 16_384, Reasoning: true, Deprecated: true, FallbackModel: "free/qwen3-next-80b-a3b-thinking"},
- {ID: "free/nemotron-super-49b", Name: "Nemotron Super 49B (retired)", ContextWindow: 131_072, MaxOutput: 16_384, Deprecated: true, FallbackModel: "free/qwen3-next-80b-a3b-thinking"},
- {ID: "free/nemotron-3-super-120b", Name: "Nemotron 3 Super 120B (retired)", ContextWindow: 131_072, MaxOutput: 16_384, Deprecated: true, FallbackModel: "free/qwen3-next-80b-a3b-thinking"},
- {ID: "free/mistral-large-3-675b", Name: "Mistral Large 3 675B (retired)", ContextWindow: 131_072, MaxOutput: 16_384, Deprecated: true, FallbackModel: "free/llama-4-maverick"},
- {ID: "free/devstral-2-123b", Name: "Devstral 2 123B (retired)", ContextWindow: 131_072, MaxOutput: 16_384, Deprecated: true, FallbackModel: "free/llama-4-maverick"},
-
- // Z.AI — GLM-5.2 is current flagship (upstream v0.12.211)
- {ID: "zai/glm-5.2", Name: "GLM-5.2", Version: "5.2", InputPrice: 1.6, OutputPrice: 5.0, ContextWindow: 200_000, MaxOutput: 128_000, ToolCalling: true},
- {ID: "zai/glm-5.1", Name: "GLM-5.1", Version: "5.1", InputPrice: 1.4, OutputPrice: 4.4, ContextWindow: 200_000, MaxOutput: 128_000, ToolCalling: true},
- {ID: "zai/glm-5", Name: "GLM-5", InputPrice: 0.60, OutputPrice: 1.92, ContextWindow: 131_072, MaxOutput: 16_384, ToolCalling: true},
- {ID: "zai/glm-5-turbo", Name: "GLM-5 Turbo", InputPrice: 1.20, OutputPrice: 4.00, ContextWindow: 131_072, MaxOutput: 16_384, ToolCalling: true},
-
- // MiniMax — M3 is current flagship with Vision (upstream v0.12.200, v0.12.236)
- {ID: "minimax/minimax-m3", Name: "MiniMax M3", Version: "m3", InputPrice: 0.50, OutputPrice: 2.00, ContextWindow: 1_050_000, MaxOutput: 128_000, Vision: true, ToolCalling: true},
- {ID: "minimax/minimax-m2.7", Name: "MiniMax M2.7", InputPrice: 1.0, OutputPrice: 5.0, ContextWindow: 1_050_000, MaxOutput: 128_000, ToolCalling: true},
+ // Catalog metadata: ClawRouter 05de1e0 (v0.12.278 source).
+ // Retired free IDs keep compatibility metadata and redirect to live successors.
+ // Free models deliberately do not claim working vision or structured tools.
+
+ // Routing profiles
+ {ID: "auto", Name: "Auto (Smart Router - Balanced)", InputPrice: 0, OutputPrice: 0, ContextWindow: 1050000, MaxOutput: 128000},
+ {ID: "free", Name: "Free - Nemotron 3.5 Lightning", InputPrice: 0, OutputPrice: 0, ContextWindow: 1000000, MaxOutput: 16384, Reasoning: true},
+ {ID: "eco", Name: "Eco (Smart Router - Cost Optimized)", InputPrice: 0, OutputPrice: 0, ContextWindow: 1050000, MaxOutput: 128000},
+ {ID: "premium", Name: "Premium (Smart Router - Best Quality)", InputPrice: 0, OutputPrice: 0, ContextWindow: 2000000, MaxOutput: 200000},
+
+ // openai
+ {ID: "openai/gpt-5.2", Name: "GPT-5.2", Version: "5.2", InputPrice: 1.75, OutputPrice: 14.0, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-5-mini", Name: "GPT-5 Mini", Version: "5.0", InputPrice: 0.25, OutputPrice: 2.0, ContextWindow: 200000, MaxOutput: 128000, ToolCalling: true},
+ {ID: "openai/gpt-5-nano", Name: "GPT-5 Nano", Version: "5.0", InputPrice: 0.05, OutputPrice: 0.4, ContextWindow: 128000, MaxOutput: 128000, ToolCalling: true, Deprecated: true, FallbackModel: "openai/gpt-5.4-nano"},
+ {ID: "openai/gpt-5.2-pro", Name: "GPT-5.2 Pro", Version: "5.2", InputPrice: 21.0, OutputPrice: 168.0, ContextWindow: 400000, MaxOutput: 128000, Reasoning: true, Vision: true, ToolCalling: true},
+ {ID: "openai/gpt-5.6-sol", Name: "GPT-5.6 Sol", Version: "5.6", InputPrice: 4.0, OutputPrice: 20.0, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-5.6-terra", Name: "GPT-5.6 Terra", Version: "5.6", InputPrice: 2.0, OutputPrice: 12.0, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-5.6-luna", Name: "GPT-5.6 Luna", Version: "5.6", InputPrice: 0.2, OutputPrice: 1.2, ContextWindow: 1050000, MaxOutput: 128000, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-5.6-sol-pro", Name: "GPT-5.6 Sol Pro", Version: "5.6", InputPrice: 4.0, OutputPrice: 20.0, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-5.6-terra-pro", Name: "GPT-5.6 Terra Pro", Version: "5.6", InputPrice: 2.0, OutputPrice: 12.0, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-5.6-luna-pro", Name: "GPT-5.6 Luna Pro", Version: "5.6", InputPrice: 0.2, OutputPrice: 1.2, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-5.5", Name: "GPT-5.5", Version: "5.5", InputPrice: 5.0, OutputPrice: 30.0, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-5.5-pro", Name: "GPT-5.5 Pro", Version: "5.5", InputPrice: 30.0, OutputPrice: 180.0, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, Vision: true, ToolCalling: true},
+ {ID: "openai/chat-latest", Name: "ChatGPT Instant (GPT-5.5)", Version: "5.5", InputPrice: 5.0, OutputPrice: 30.0, ContextWindow: 128000, MaxOutput: 128000, Vision: true, ToolCalling: true},
+ {ID: "openai/gpt-5.4", Name: "GPT-5.4", Version: "5.4", InputPrice: 2.5, OutputPrice: 15.0, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-5.4-mini", Name: "GPT-5.4 Mini", Version: "5.4", InputPrice: 0.75, OutputPrice: 4.5, ContextWindow: 400000, MaxOutput: 128000, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-5.4-pro", Name: "GPT-5.4 Pro", Version: "5.4", InputPrice: 30.0, OutputPrice: 180.0, ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, Vision: true, ToolCalling: true},
+ {ID: "openai/gpt-5.4-nano", Name: "GPT-5.4 Nano", Version: "5.4", InputPrice: 0.2, OutputPrice: 1.25, ContextWindow: 1050000, MaxOutput: 128000, ToolCalling: true},
+ {ID: "openai/gpt-5.3", Name: "GPT-5.3", Version: "5.3", InputPrice: 1.75, OutputPrice: 14.0, ContextWindow: 128000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-5.3-codex", Name: "GPT-5.3 Codex", Version: "5.3", InputPrice: 1.75, OutputPrice: 14.0, ContextWindow: 400000, MaxOutput: 128000, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-4.1", Name: "GPT-4.1", Version: "4.1", InputPrice: 2.0, OutputPrice: 8.0, ContextWindow: 128000, MaxOutput: 32768, Vision: true, ToolCalling: true},
+ {ID: "openai/gpt-4.1-mini", Name: "GPT-4.1 Mini", Version: "4.1", InputPrice: 0.4, OutputPrice: 1.6, ContextWindow: 128000, MaxOutput: 32768, ToolCalling: true},
+ {ID: "openai/gpt-4.1-nano", Name: "GPT-4.1 Nano", Version: "4.1", InputPrice: 0.1, OutputPrice: 0.4, ContextWindow: 128000, MaxOutput: 32768, ToolCalling: true},
+ {ID: "openai/gpt-4o", Name: "GPT-4o", Version: "4o", InputPrice: 2.5, OutputPrice: 10.0, ContextWindow: 128000, MaxOutput: 16384, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "openai/gpt-4o-mini", Name: "GPT-4o Mini", Version: "4o-mini", InputPrice: 0.15, OutputPrice: 0.6, ContextWindow: 128000, MaxOutput: 16384, ToolCalling: true},
+ {ID: "openai/o1", Name: "o1", Version: "1", InputPrice: 15.0, OutputPrice: 60.0, ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, ToolCalling: true},
+ {ID: "openai/o1-mini", Name: "o1-mini", Version: "1-mini", InputPrice: 1.1, OutputPrice: 4.4, ContextWindow: 128000, MaxOutput: 65536, Reasoning: true, ToolCalling: true, Deprecated: true, FallbackModel: "openai/o4-mini"},
+ {ID: "openai/o3", Name: "o3", Version: "3", InputPrice: 2.0, OutputPrice: 8.0, ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, ToolCalling: true},
+ {ID: "openai/o3-mini", Name: "o3-mini", Version: "3-mini", InputPrice: 1.1, OutputPrice: 4.4, ContextWindow: 128000, MaxOutput: 100000, Reasoning: true, ToolCalling: true},
+ {ID: "openai/o4-mini", Name: "o4-mini", Version: "4-mini", InputPrice: 1.1, OutputPrice: 4.4, ContextWindow: 128000, MaxOutput: 100000, Reasoning: true, ToolCalling: true},
+
+ // anthropic
+ {ID: "anthropic/claude-haiku-4.5", Name: "Claude Haiku 4.5", Version: "4.5", InputPrice: 1.0, OutputPrice: 5.0, ContextWindow: 200000, MaxOutput: 64000, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "anthropic/claude-sonnet-4.5", Name: "Claude Sonnet 4.5", Version: "4.5", InputPrice: 3.0, OutputPrice: 15.0, ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "anthropic/claude-sonnet-4.6", Name: "Claude Sonnet 4.6", Version: "4.6", InputPrice: 3.0, OutputPrice: 15.0, ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "anthropic/claude-sonnet-5", Name: "Claude Sonnet 5", Version: "5", InputPrice: 3.0, OutputPrice: 15.0, ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "anthropic/claude-opus-4.5", Name: "Claude Opus 4.5", Version: "4.5", InputPrice: 5.0, OutputPrice: 25.0, ContextWindow: 200000, MaxOutput: 64000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "anthropic/claude-opus-4.6", Name: "Claude Opus 4.6", Version: "4.6", InputPrice: 5.0, OutputPrice: 25.0, ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "anthropic/claude-fable-5", Name: "Claude Fable 5", Version: "5", InputPrice: 10.0, OutputPrice: 50.0, ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "anthropic/claude-opus-4.7", Name: "Claude Opus 4.7", Version: "4.7", InputPrice: 5.0, OutputPrice: 25.0, ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "anthropic/claude-opus-4.8", Name: "Claude Opus 4.8", Version: "4.8", InputPrice: 5.0, OutputPrice: 25.0, ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "anthropic/claude-opus-5", Name: "Claude Opus 5", Version: "5", InputPrice: 5.0, OutputPrice: 25.0, ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+
+ // google
+ {ID: "google/gemini-3.1-pro", Name: "Gemini 3.1 Pro", Version: "3.1", InputPrice: 2.0, OutputPrice: 12.0, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, Vision: true, ToolCalling: true},
+ {ID: "google/gemini-3-pro-preview", Name: "Gemini 3 Pro Preview", Version: "3.0", InputPrice: 2.0, OutputPrice: 12.0, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, Vision: true, ToolCalling: true, Deprecated: true, FallbackModel: "google/gemini-3.1-pro"},
+ // Promotional $0.75/$3.75 until 2027-01-01; upstream list rate then returns to $1.50/$7.50.
+ {ID: "google/gemini-3.8-flash", Name: "Gemini 3.8 Flash", Version: "3.8", InputPrice: 0.75, OutputPrice: 3.75, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, Vision: true, ToolCalling: true},
+ // Promotional $0.75/$3.75 until 2027-01-01; upstream list rate then returns to $1.50/$7.50.
+ {ID: "google/gemini-3.6-flash", Name: "Gemini 3.6 Flash", Version: "3.6", InputPrice: 0.75, OutputPrice: 3.75, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, Vision: true, ToolCalling: true},
+ {ID: "google/gemini-3.5-flash", Name: "Gemini 3.5 Flash", Version: "3.5", InputPrice: 1.5, OutputPrice: 9.0, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, Vision: true, ToolCalling: true},
+ {ID: "google/gemini-3.5-flash-lite", Name: "Gemini 3.5 Flash Lite", Version: "3.5", InputPrice: 0.3, OutputPrice: 2.5, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, ToolCalling: true},
+ {ID: "google/gemini-3-flash-preview", Name: "Gemini 3 Flash Preview", Version: "3.0", InputPrice: 0.5, OutputPrice: 3.0, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, Vision: true},
+ {ID: "google/gemini-2.5-pro", Name: "Gemini 2.5 Pro", Version: "2.5", InputPrice: 1.25, OutputPrice: 10.0, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, Vision: true, ToolCalling: true},
+ {ID: "google/gemini-2.5-flash", Name: "Gemini 2.5 Flash", Version: "2.5", InputPrice: 0.3, OutputPrice: 2.5, ContextWindow: 1048576, MaxOutput: 65536, Vision: true, ToolCalling: true},
+ {ID: "google/gemini-2.5-flash-lite", Name: "Gemini 2.5 Flash Lite", Version: "2.5", InputPrice: 0.1, OutputPrice: 0.4, ContextWindow: 1048576, MaxOutput: 65536, Vision: true, ToolCalling: true},
+ {ID: "google/gemini-3.1-flash-lite", Name: "Gemini 3.1 Flash Lite", Version: "3.1", InputPrice: 0.25, OutputPrice: 1.5, ContextWindow: 1048576, MaxOutput: 65536, ToolCalling: true},
+
+ // deepseek
+ {ID: "deepseek/deepseek-chat", Name: "DeepSeek V4 Flash Chat", Version: "4-flash", InputPrice: 0.14, OutputPrice: 0.28, ContextWindow: 1048576, MaxOutput: 65536, ToolCalling: true},
+ {ID: "deepseek/deepseek-reasoner", Name: "DeepSeek V4 Flash Reasoner", Version: "4-flash", InputPrice: 0.14, OutputPrice: 0.28, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, ToolCalling: true},
+ {ID: "deepseek/deepseek-v4-pro", Name: "DeepSeek V4 Pro", Version: "4-pro", InputPrice: 1.32, OutputPrice: 3.96, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, Agentic: true, ToolCalling: true},
+
+ // moonshot
+ {ID: "moonshot/kimi-k3", Name: "Kimi K3", Version: "k3", InputPrice: 3.0, OutputPrice: 15.0, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "moonshot/kimi-k2.7", Name: "Kimi K2.7", Version: "k2.7", InputPrice: 0.95, OutputPrice: 4.0, ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+
+ // qwen
+ {ID: "qwen/qwen3.7-max", Name: "Qwen3.7 Max", Version: "3.7-max", InputPrice: 1.475, OutputPrice: 4.425, ContextWindow: 1000000, MaxOutput: 65536, Reasoning: true, Agentic: true, ToolCalling: true},
+ {ID: "qwen/qwen3.7-plus", Name: "Qwen3.7 Plus", Version: "3.7-plus", InputPrice: 0.32, OutputPrice: 1.28, ContextWindow: 1000000, MaxOutput: 131072, Reasoning: true, Agentic: true, ToolCalling: true},
+ {ID: "qwen/qwen3.7-flash", Name: "Qwen3.7 Flash", Version: "3.7-flash", InputPrice: 0.03, OutputPrice: 0.13, ContextWindow: 1000000, MaxOutput: 65536, Reasoning: true, ToolCalling: true},
+
+ // moonshot
+ {ID: "moonshot/kimi-k2.6", Name: "Kimi K2.6", Version: "k2.6", InputPrice: 0.95, OutputPrice: 4.0, ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "moonshot/kimi-k2.5", Name: "Kimi K2.5", Version: "k2.5", InputPrice: 0.6, OutputPrice: 3.0, ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+
+ // nvidia
+ {ID: "nvidia/kimi-k2.5", Name: "Kimi K2.5 (NVIDIA, retired)", Version: "k2.5", InputPrice: 0.6, OutputPrice: 3.0, ContextWindow: 262144, MaxOutput: 16384, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true, Deprecated: true, FallbackModel: "moonshot/kimi-k2.5"},
+
+ // xai
+ {ID: "xai/grok-3", Name: "Grok 3", Version: "3", InputPrice: 3.0, OutputPrice: 15.0, ContextWindow: 131072, MaxOutput: 16384, Reasoning: true, ToolCalling: true},
+ {ID: "xai/grok-3-mini", Name: "Grok 3 Mini", Version: "3-mini", InputPrice: 0.3, OutputPrice: 0.5, ContextWindow: 131072, MaxOutput: 16384, ToolCalling: true},
+ {ID: "xai/grok-4-fast-reasoning", Name: "Grok 4 Fast Reasoning", Version: "4", InputPrice: 0.2, OutputPrice: 0.5, ContextWindow: 2000000, MaxOutput: 16384, Reasoning: true, ToolCalling: true},
+ {ID: "xai/grok-4-fast-non-reasoning", Name: "Grok 4 Fast", Version: "4", InputPrice: 0.2, OutputPrice: 0.5, ContextWindow: 2000000, MaxOutput: 16384, ToolCalling: true},
+ {ID: "xai/grok-4-1-fast-reasoning", Name: "Grok 4.1 Fast Reasoning", Version: "4.1", InputPrice: 0.2, OutputPrice: 0.5, ContextWindow: 2000000, MaxOutput: 16384, Reasoning: true, ToolCalling: true},
+ {ID: "xai/grok-4-1-fast-non-reasoning", Name: "Grok 4.1 Fast", Version: "4.1", InputPrice: 0.2, OutputPrice: 0.5, ContextWindow: 2000000, MaxOutput: 16384, ToolCalling: true},
+ {ID: "xai/grok-4-0709", Name: "Grok 4 (0709)", Version: "4-0709", InputPrice: 3.0, OutputPrice: 15.0, ContextWindow: 256000, MaxOutput: 16384, Reasoning: true, ToolCalling: true},
+ {ID: "xai/grok-2-vision", Name: "Grok 2 Vision", Version: "2", InputPrice: 2.0, OutputPrice: 10.0, ContextWindow: 32768, MaxOutput: 16384, Vision: true, ToolCalling: true},
+ {ID: "xai/grok-4.20-reasoning", Name: "Grok 4.20 Reasoning", Version: "4.20", InputPrice: 2.0, OutputPrice: 6.0, ContextWindow: 2000000, MaxOutput: 16384, Reasoning: true, ToolCalling: true},
+ {ID: "xai/grok-4.20-non-reasoning", Name: "Grok 4.20", Version: "4.20", InputPrice: 2.0, OutputPrice: 6.0, ContextWindow: 2000000, MaxOutput: 16384, ToolCalling: true},
+ {ID: "xai/grok-4.20-multi-agent", Name: "Grok 4.20 Multi-Agent", Version: "4.20", InputPrice: 2.0, OutputPrice: 6.0, ContextWindow: 2000000, MaxOutput: 16384, Reasoning: true, ToolCalling: true},
+ {ID: "xai/grok-4.5", Name: "Grok 4.5", Version: "4.5", InputPrice: 2.0, OutputPrice: 6.0, ContextWindow: 500000, MaxOutput: 16384, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "xai/grok-4.3", Name: "Grok 4.3", Version: "4.3", InputPrice: 1.25, OutputPrice: 2.5, ContextWindow: 1000000, MaxOutput: 16384, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "xai/grok-build-0.1", Name: "Grok Build 0.1", Version: "0.1", InputPrice: 1.0, OutputPrice: 2.0, ContextWindow: 256000, MaxOutput: 16384, Agentic: true, ToolCalling: true},
+
+ // tencent
+ {ID: "tencent/hy3", Name: "Tencent Hy3", Version: "hy3", InputPrice: 0.132, OutputPrice: 0.528, ContextWindow: 262144, MaxOutput: 128000, Reasoning: true, ToolCalling: true},
+
+ // xiaomi
+ {ID: "xiaomi/mimo-v2.5-pro", Name: "Xiaomi MiMo-V2.5 Pro", Version: "v2.5-pro", InputPrice: 0.435, OutputPrice: 0.87, ContextWindow: 1048576, MaxOutput: 131072, Reasoning: true, ToolCalling: true},
+
+ // minimax
+ {ID: "minimax/minimax-m3", Name: "MiniMax M3", Version: "m3", InputPrice: 0.3, OutputPrice: 1.2, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, Vision: true, Agentic: true, ToolCalling: true},
+ {ID: "minimax/minimax-m2.7", Name: "MiniMax M2.7", Version: "m2.7", InputPrice: 0.3, OutputPrice: 1.2, ContextWindow: 204800, MaxOutput: 16384, Reasoning: true, Agentic: true, ToolCalling: true},
+ {ID: "minimax/minimax-m2.5", Name: "MiniMax M2.5", Version: "m2.5", InputPrice: 0.3, OutputPrice: 1.2, ContextWindow: 204800, MaxOutput: 16384, Reasoning: true, Agentic: true, ToolCalling: true, Deprecated: true, FallbackModel: "minimax/minimax-m2.7"},
+
+ // free
+ {ID: "free/gpt-oss-120b", Name: "[Free] GPT-OSS 120B", Version: "120b", InputPrice: 0, OutputPrice: 0, ContextWindow: 128000, MaxOutput: 16384},
+ {ID: "free/gpt-oss-20b", Name: "[Free] GPT-OSS 20B", Version: "20b", InputPrice: 0, OutputPrice: 0, ContextWindow: 128000, MaxOutput: 16384},
+ {ID: "free/deepseek-v4-flash", Name: "[Free] DeepSeek V4 Flash", Version: "v4-flash", InputPrice: 0, OutputPrice: 0, ContextWindow: 1000000, MaxOutput: 16384, Reasoning: true, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/qwen3-coder-480b", Name: "[Free] Qwen3 Coder 480B", Version: "480b", InputPrice: 0, OutputPrice: 0, ContextWindow: 131072, MaxOutput: 16384, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/glm-4.7", Name: "[Free] GLM-4.7", Version: "4.7", InputPrice: 0, OutputPrice: 0, ContextWindow: 131072, MaxOutput: 16384, Reasoning: true, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/llama-4-maverick", Name: "[Free] Llama 4 Maverick", Version: "4-maverick", InputPrice: 0, OutputPrice: 0, ContextWindow: 131072, MaxOutput: 16384, Reasoning: true, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/nemotron-3-nano-omni-30b-a3b-reasoning", Name: "[Free] Nemotron 3 Nano Omni", Version: "30b-a3b-omni-reasoning", InputPrice: 0, OutputPrice: 0, ContextWindow: 256000, MaxOutput: 16384, Reasoning: true},
+ {ID: "free/mistral-large-3-675b", Name: "[Free] Mistral Large 3 675B", Version: "3-675b", InputPrice: 0, OutputPrice: 0, ContextWindow: 131072, MaxOutput: 16384, Reasoning: true, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/qwen3.5-122b-a10b", Name: "[Free] Qwen3.5 122B", Version: "3.5-122b", InputPrice: 0, OutputPrice: 0, ContextWindow: 131072, MaxOutput: 16384, Reasoning: true, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/qwen3-next-80b-a3b-instruct", Name: "[Free] Qwen3-Next 80B Instruct", Version: "next-80b-a3b", InputPrice: 0, OutputPrice: 0, ContextWindow: 262144, MaxOutput: 16384, Reasoning: true, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/seed-oss-36b", Name: "[Free] Seed-OSS 36B", Version: "oss-36b", InputPrice: 0, OutputPrice: 0, ContextWindow: 131072, MaxOutput: 16384, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/mistral-nemotron", Name: "[Free] Mistral Nemotron", Version: "nemotron", InputPrice: 0, OutputPrice: 0, ContextWindow: 131072, MaxOutput: 16384, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/step-3.7-flash", Name: "[Free] StepFun Step 3.7 Flash", Version: "3.7-flash", InputPrice: 0, OutputPrice: 0, ContextWindow: 131072, MaxOutput: 16384, Reasoning: true, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/nemotron-nano-9b-v2", Name: "[Free] Nemotron Nano 9B v2", Version: "nano-9b-v2", InputPrice: 0, OutputPrice: 0, ContextWindow: 131072, MaxOutput: 16384, Reasoning: true, Deprecated: true, FallbackModel: "free/nemotron-3-nano-30b"},
+ {ID: "free/nemotron-nano-12b-v2-vl", Name: "[Free] Nemotron Nano 12B v2 VL", Version: "nano-12b-v2-vl", InputPrice: 0, OutputPrice: 0, ContextWindow: 131072, MaxOutput: 16384, Reasoning: true, Deprecated: true, FallbackModel: "free/nemotron-3-nano-omni-30b-a3b-reasoning"},
+ {ID: "free/nemotron-3.5-lightning", Name: "[Free] Nemotron 3.5 Lightning", Version: "3.5-lightning", InputPrice: 0, OutputPrice: 0, ContextWindow: 1000000, MaxOutput: 16384, Reasoning: true},
+ {ID: "free/nemotron-3-nano-30b", Name: "[Free] Nemotron 3 Nano 30B", Version: "3-nano-30b", InputPrice: 0, OutputPrice: 0, ContextWindow: 131072, MaxOutput: 16384, Reasoning: true},
+ {ID: "free/nemotron-3-ultra-550b", Name: "[Free] Nemotron 3 Ultra 550B", Version: "3-ultra-550b", InputPrice: 0, OutputPrice: 0, ContextWindow: 1000000, MaxOutput: 16384, Reasoning: true},
+ {ID: "free/llama-3.2-11b-vision", Name: "[Free] Llama 3.2 11B Vision", Version: "3.2-11b-vision", InputPrice: 0, OutputPrice: 0, ContextWindow: 128000, MaxOutput: 16384},
+ {ID: "free/north-mini-code", Name: "[Free] Cohere North Mini Code", Version: "north-mini-code", InputPrice: 0, OutputPrice: 0, ContextWindow: 256000, MaxOutput: 16384, Reasoning: true},
+ {ID: "free/laguna-xs-2.1", Name: "[Free] Poolside Laguna XS 2.1", Version: "xs-2.1", InputPrice: 0, OutputPrice: 0, ContextWindow: 131072, MaxOutput: 16384},
+
+ // qwen
+ {ID: "qwen/qwen3.8-flash", Name: "Qwen3.8 Flash", Version: "3.8-flash", InputPrice: 0.15, OutputPrice: 0.47, ContextWindow: 1000000, MaxOutput: 131072, Reasoning: true, Vision: true, ToolCalling: true},
+
+ // deepseek
+ {ID: "deepseek/deepseek-v4-flash-vision-exp", Name: "DeepSeek V4 Flash Vision", Version: "v4-flash-vision-exp", InputPrice: 0.44, OutputPrice: 1.32, ContextWindow: 1048576, MaxOutput: 65536, Reasoning: true, Vision: true, ToolCalling: true},
+
+ // xiaomi
+ {ID: "xiaomi/mimo-v2.5", Name: "Xiaomi MiMo V2.5", Version: "2.5", InputPrice: 0.14, OutputPrice: 0.28, ContextWindow: 1048576, MaxOutput: 131072, Reasoning: true, Vision: true, ToolCalling: true},
+
+ // zai
+ {ID: "zai/glm-5.3", Name: "GLM-5.3", Version: "5.3", InputPrice: 1.4, OutputPrice: 4.4, ContextWindow: 1000000, MaxOutput: 131072, Reasoning: true, ToolCalling: true},
+ {ID: "zai/glm-5.3-flash", Name: "GLM-5.3 Flash", Version: "5.3-flash", InputPrice: 0.15, OutputPrice: 0.5, ContextWindow: 1000000, MaxOutput: 131072, Reasoning: true, Vision: true, ToolCalling: true},
+ {ID: "zai/glm-5.2", Name: "GLM-5.2", Version: "5.2", InputPrice: 1.4, OutputPrice: 4.4, ContextWindow: 1000000, MaxOutput: 131072, Reasoning: true, ToolCalling: true},
+ {ID: "zai/glm-5.1", Name: "GLM-5.1", Version: "5.1", InputPrice: 1.4, OutputPrice: 4.4, ContextWindow: 200000, MaxOutput: 128000, Reasoning: true, ToolCalling: true, Promo: &PromoDef{FlatPrice: 0.001, StartDate: "2026-04-01", EndDate: "2026-06-05"}},
+ {ID: "zai/glm-5", Name: "GLM-5", Version: "5", InputPrice: 1.0, OutputPrice: 3.2, ContextWindow: 200000, MaxOutput: 128000, Reasoning: true, ToolCalling: true},
+ {ID: "zai/glm-5-turbo", Name: "GLM-5 Turbo", Version: "5-turbo", InputPrice: 1.2, OutputPrice: 4.0, ContextWindow: 200000, MaxOutput: 128000, Reasoning: true, ToolCalling: true},
+
+ // openai
+ {ID: "openai/chatgpt-instant", Name: "ChatGPT Instant (GPT-5.5)", Version: "5.5", InputPrice: 5.0, OutputPrice: 30.0, ContextWindow: 128000, MaxOutput: 128000, Vision: true, ToolCalling: true, Deprecated: true, FallbackModel: "openai/chat-latest"},
+
+ // free
+ {ID: "free/qwen3-next-80b-a3b-thinking", Name: "qwen3-next-80b-a3b-thinking (retired)", ContextWindow: 131072, MaxOutput: 16384, InputPrice: 0, OutputPrice: 0, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/mistral-small-4-119b", Name: "mistral-small-4-119b (retired)", ContextWindow: 131072, MaxOutput: 16384, InputPrice: 0, OutputPrice: 0, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/deepseek-v3.2", Name: "deepseek-v3.2 (retired)", ContextWindow: 131072, MaxOutput: 16384, InputPrice: 0, OutputPrice: 0, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/deepseek-v4-pro", Name: "deepseek-v4-pro (retired)", ContextWindow: 131072, MaxOutput: 16384, InputPrice: 0, OutputPrice: 0, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/nemotron-ultra-253b", Name: "nemotron-ultra-253b (retired)", ContextWindow: 131072, MaxOutput: 16384, InputPrice: 0, OutputPrice: 0, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/nemotron-super-49b", Name: "nemotron-super-49b (retired)", ContextWindow: 131072, MaxOutput: 16384, InputPrice: 0, OutputPrice: 0, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/nemotron-3-super-120b", Name: "nemotron-3-super-120b (retired)", ContextWindow: 131072, MaxOutput: 16384, InputPrice: 0, OutputPrice: 0, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
+ {ID: "free/devstral-2-123b", Name: "devstral-2-123b (retired)", ContextWindow: 131072, MaxOutput: 16384, InputPrice: 0, OutputPrice: 0, Deprecated: true, FallbackModel: "free/nemotron-3.5-lightning"},
}
// modelIndex is built on init for fast lookups.
@@ -316,6 +492,12 @@ func ResolveModelAlias(model string) string {
return withoutPrefix
}
+ // A concrete catalog ID wins over shorthand matching after prefix removal.
+ // For example, the DOS o1 shorthand targets o3, but openai/o1 is an exact pin.
+ if _, ok := modelIndex[normalized]; ok {
+ return normalized
+ }
+
// Strip "openai/" prefix for virtual profiles
if strings.HasPrefix(normalized, "openai/") {
withoutPrefix := normalized[len("openai/"):]
diff --git a/proxy/prose.go b/proxy/prose.go
new file mode 100644
index 0000000..a857ebe
--- /dev/null
+++ b/proxy/prose.go
@@ -0,0 +1,65 @@
+package proxy
+
+import (
+ "regexp"
+ "strings"
+)
+
+var thinkingTag = regexp.MustCompile(`(?i)^<\s*(/?)\s*(think(?:ing)?|thought|antthinking|antml:thinking)\b[^>]*>$`)
+
+// proseFilter tracks split thinking tags across SSE chunks. Tagged reasoning
+// stays private while ordinary assistant prose and tool_calls remain visible.
+type proseFilter struct {
+ pending string
+ hidden bool
+}
+
+func (f *proseFilter) filter(text string, final bool) string {
+ f.pending += text
+ var out strings.Builder
+ for len(f.pending) > 0 {
+ start := strings.IndexByte(f.pending, '<')
+ if start < 0 {
+ if !f.hidden {
+ out.WriteString(f.pending)
+ }
+ f.pending = ""
+ break
+ }
+ if !f.hidden {
+ out.WriteString(f.pending[:start])
+ }
+ f.pending = f.pending[start:]
+ end := strings.IndexByte(f.pending, '>')
+ if end < 0 {
+ if final {
+ if !f.hidden {
+ out.WriteString(f.pending)
+ }
+ f.pending = ""
+ }
+ break
+ }
+ tag := f.pending[:end+1]
+ f.pending = f.pending[end+1:]
+ if match := thinkingTag.FindStringSubmatch(tag); match != nil {
+ f.hidden = match[1] != "/"
+ } else if strings.HasPrefix(tag, "<|") || strings.HasPrefix(tag, "<|") {
+ lower := strings.ToLower(tag)
+ if strings.Contains(lower, "begin") {
+ f.hidden = true
+ }
+ if strings.Contains(lower, "end") {
+ f.hidden = false
+ }
+ } else if !f.hidden {
+ out.WriteString(tag)
+ }
+ }
+ return out.String()
+}
+
+func stripThinking(content string) string {
+ var filter proseFilter
+ return filter.filter(content, true)
+}
diff --git a/proxy/proxy.go b/proxy/proxy.go
index 19ebfe8..132075c 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -61,6 +61,10 @@ type Config struct {
UpstreamAPIKey string
// Routing config override (nil = use default)
RoutingConfig *router.RoutingConfig
+ // SpendControl overrides the file-backed controller (useful for embedded servers).
+ SpendControl *spendcontrol.SpendControl
+ // UsageLogger overrides usage logging.
+ UsageLogger func(logger.UsageEntry)
}
// Server is the OpenAI-compatible proxy with smart routing.
@@ -76,6 +80,7 @@ type Server struct {
sessions *session.Store
journal *journal.SessionJournal
spendControl *spendcontrol.SpendControl
+ spendError error
// Deferred startup for OpenClaw plugin config (upstream v0.12.142)
// When OpenClaw calls Register() twice, the first call has empty pluginConfig.
@@ -92,21 +97,26 @@ func New(cfg Config) *Server {
rc = *cfg.RoutingConfig
}
+ sc := cfg.SpendControl
+ var spendErr error
+ if sc == nil {
+ sc, spendErr = spendcontrol.New(spendcontrol.NewFileStorage())
+ }
return &Server{
config: cfg,
routingConfig: rc,
modelPricing: models.BuildPricingMap(),
httpClient: &http.Client{
- Timeout: 5 * time.Minute,
- Transport: &http.Transport{
- Proxy: http.ProxyFromEnvironment,
- },
+ Timeout: 5 * time.Minute,
+ Transport: http.DefaultTransport.(*http.Transport).Clone(),
+ CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
dedup: dedup.New(),
cache: cache.New(),
sessions: session.NewStore(session.DefaultConfig()),
journal: journal.New(journal.DefaultConfig()),
- spendControl: mustSpendControl(),
+ spendControl: sc,
+ spendError: spendErr,
}
}
@@ -186,18 +196,20 @@ func (s *Server) ListenAndServe() error {
// chatRequest is the OpenAI-compatible request format.
type chatRequest struct {
- Model string `json:"model"`
- Messages []chatMessage `json:"messages"`
- MaxTokens int `json:"max_tokens,omitempty"`
- Temperature *float64 `json:"temperature,omitempty"`
- Stream bool `json:"stream,omitempty"`
- Tools json.RawMessage `json:"tools,omitempty"`
+ Model string `json:"model"`
+ Messages []chatMessage `json:"messages"`
+ MaxTokens int `json:"max_tokens,omitempty"`
+ Temperature *float64 `json:"temperature,omitempty"`
+ Stream bool `json:"stream,omitempty"`
+ Tools json.RawMessage `json:"tools,omitempty"`
+ Extra map[string]json.RawMessage `json:"-"`
}
type chatMessage struct {
- Role string `json:"role"`
- Content json.RawMessage `json:"content"`
- ReasoningContent *string `json:"reasoning_content,omitempty"`
+ Role string `json:"role"`
+ Content json.RawMessage `json:"content"`
+ ReasoningContent *string `json:"reasoning_content,omitempty"`
+ Extra map[string]json.RawMessage `json:"-"`
}
func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
@@ -214,6 +226,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
defer r.Body.Close()
+ cacheBody := append([]byte(nil), body...)
var req chatRequest
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
@@ -240,8 +253,10 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
}
+ // Caller-specific credentials must never share an internal response cache.
+ cacheAllowed := s.config.UpstreamAPIKey != "" || r.Header.Get("Authorization") == ""
// --- Response cache check (non-streaming only) ---
- if !req.Stream {
+ if !req.Stream && cacheAllowed {
if entry, ok := s.cache.Get(body, false); ok {
w.Header().Set("X-DOSRouter-Cache", "hit")
for k, vs := range entry.Header {
@@ -249,6 +264,9 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
w.Header().Add(k, v)
}
}
+ if s.config.UpstreamAPIKey != "" || r.Header.Get("Authorization") != "" {
+ w.Header().Set("Cache-Control", "no-store")
+ }
w.WriteHeader(entry.StatusCode)
w.Write(entry.Body)
return
@@ -311,22 +329,6 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
decision = &d
resolvedModel = d.Model
- // --- Spend control check ---
- if decision.CostEstimate > 0 {
- check := s.spendControl.Check(decision.CostEstimate)
- if !check.Allowed {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(http.StatusTooManyRequests)
- json.NewEncoder(w).Encode(map[string]interface{}{
- "error": check.Reason,
- "blockedBy": check.BlockedBy,
- "remaining": check.Remaining,
- "resetIn": check.ResetIn,
- })
- return
- }
- }
-
// Pin to session (smart-routed, not user-explicit)
if sessionID != "" {
s.sessions.SetSession(sessionID, resolvedModel, d.Tier, false)
@@ -351,12 +353,13 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
// --- Context compression (if enabled) ---
compMsgs := toNormalizedMessages(req.Messages)
- if compression.ShouldCompress(compMsgs) {
+ if canCompressMessages(req.Messages) && compression.ShouldCompress(compMsgs) {
result := compression.CompressContext(compMsgs, compression.DefaultCompressionConfig())
if result.Stats.Ratio < 0.95 && result.Stats.Ratio > 0 {
// Re-marshal with compressed messages
compReq := req
compReq.Messages = fromNormalizedMessages(result.Messages)
+ req.Messages = compReq.Messages
if b, err := json.Marshal(compReq); err == nil {
body = b // Use compressed body for upstream
}
@@ -394,7 +397,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-DOSRouter-Confidence", fmt.Sprintf("%.2f", decision.Confidence))
w.Header().Set("X-DOSRouter-Savings", fmt.Sprintf("%.0f%%", decision.Savings*100))
w.Header().Set("X-DOSRouter-Profile", decision.Profile)
- w.Header().Set("X-DOSRouter-Reasoning", decision.Reasoning)
+ w.Header().Set("X-DOSRouter-Reasoning", sanitizeHeaderValue(decision.Reasoning))
if decision.CostEstimate > 0 {
w.Header().Set("X-DOSRouter-Cost", fmt.Sprintf("%.6f", decision.CostEstimate))
}
@@ -434,14 +437,30 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
var attempts []attemptResult
var resp *http.Response
+ var spend *requestSpend
+ defer func() {
+ if spend != nil {
+ spend.finish(nil)
+ }
+ }()
// cancelResp cancels the context of the SUCCESSFUL attempt; it is deferred
// after the loop so the chosen response body stays streamable until the
// handler returns, then its resources are released.
var cancelResp context.CancelFunc
for _, tryModel := range fallbackChain {
+ if r.Context().Err() != nil {
+ return
+ }
+ if req.MaxTokens <= 0 && req.Extra["max_completion_tokens"] == nil && s.spendControl != nil && len(s.spendControl.GetLimits()) > 0 {
+ req.MaxTokens = 4096
+ }
req.Model = tryModel
tryBody, _ := json.Marshal(req)
+ currentSpend, ok := s.reserveChat(w, req, tryBody, tryModel)
+ if !ok {
+ return
+ }
// Per-model timeout (upstream v0.12.182): reasoning models get 3min for
// cold-start first-token (DeepSeek V4 Pro / Claude opus thinking / GPT-5
// reasoning_effort=high can take 60-120s); non-reasoning get 60s. On
@@ -456,15 +475,26 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
// disconnect still cancels the in-flight attempt.
attemptCtx, cancelAttempt := context.WithCancel(r.Context())
timer := time.AfterFunc(perModelTimeout(tryModel), cancelAttempt)
- tryResp, tryErr := retry.Do(attemptCtx, makeReqFor(tryBody), retry.WithClient(s.httpClient))
+ tryResp, tryErr := retry.Do(attemptCtx, makeReqFor(tryBody), retry.WithClient(s.httpClient), retry.WithNetworkRetries(false))
+ if tryResp != nil && tryResp.StatusCode >= 300 {
+ tryErr = nil
+ }
if tryErr != nil {
+ currentSpend.finish(nil) // A lost response may already have incurred a charge.
timer.Stop()
cancelAttempt()
+ if tryResp != nil {
+ tryResp.Body.Close()
+ }
+ if r.Context().Err() != nil {
+ return
+ }
attempts = append(attempts, attemptResult{model: tryModel, reason: tryErr.Error()})
- continue
+ break
}
// Provider returned an error status (4xx/5xx except 429 which retry handles)
- if tryResp.StatusCode >= 400 {
+ if tryResp.StatusCode >= 300 {
+ currentSpend.release()
errBody, _ := io.ReadAll(tryResp.Body)
tryResp.Body.Close()
timer.Stop()
@@ -488,6 +518,8 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
timer.Stop()
cancelResp = cancelAttempt
resp = tryResp
+ spend = currentSpend
+ spend.header = resp.Header
resolvedModel = tryModel
if tryModel != fallbackChain[0] {
w.Header().Set("X-DOSRouter-Fallback", tryModel)
@@ -520,7 +552,9 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
defer resp.Body.Close()
- latencyMs := time.Since(startTime).Milliseconds()
+ if id := gatewayRequestID(resp.Header); id != "" {
+ w.Header().Set("X-DOSRouter-Request-Id", id)
+ }
// Stream response back
if req.Stream {
@@ -531,14 +565,19 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
- io.Copy(w, resp.Body)
- s.logRequest(resolvedModel, decision, startTime, "success")
+ _, copyErr := io.Copy(w, resp.Body)
+ status := "success"
+ if copyErr != nil {
+ status = "interrupted"
+ }
+ s.logRequest(resolvedModel, decision, startTime, status)
return
}
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
var streamInputTok, streamOutputTok int
+ proseFilters := make(map[int]*proseFilter)
for scanner.Scan() {
line := scanner.Text()
// Parse and rewrite streaming chunks: inject model name + track usage
@@ -547,6 +586,9 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if json.Unmarshal([]byte(line[6:]), &chunk) == nil {
// Track usage tokens
if u, ok := chunk["usage"].(map[string]interface{}); ok {
+ _, hasInput := u["prompt_tokens"]
+ _, hasOutput := u["completion_tokens"]
+ spend.usageKnown = hasInput && hasOutput
if pt, ok := u["prompt_tokens"].(float64); ok {
streamInputTok = int(pt)
}
@@ -554,10 +596,8 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
streamOutputTok = int(ct)
}
}
- // Strip tool-call planning prose from streamed delta content
- // (upstream v0.12.165/166/169): blank delta.content when the
- // chunk carries tool_calls or finish_reason=tool_calls, so
- // planning prose is not forwarded to chat channels.
+ // Preserve tool-call prose by default (upstream v0.12.248).
+ // The operator can opt back into suppression.
mutated := false
if choices, ok := chunk["choices"].([]interface{}); ok {
for _, c := range choices {
@@ -565,7 +605,22 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if !ok {
continue
}
- if choiceEndsWithToolCalls(choice) {
+ if delta, ok := choice["delta"].(map[string]interface{}); ok {
+ index, _ := choice["index"].(float64)
+ f := proseFilters[int(index)]
+ if f == nil {
+ f = &proseFilter{}
+ proseFilters[int(index)] = f
+ }
+ content, _ := delta["content"].(string)
+ finish, _ := choice["finish_reason"].(string)
+ cleaned := f.filter(content, finish != "")
+ if cleaned != content {
+ delta["content"] = cleaned
+ mutated = true
+ }
+ }
+ if choiceEndsWithToolCalls(choice) && !forwardToolCallProse() {
if delta, ok := choice["delta"].(map[string]interface{}); ok {
if s, _ := delta["content"].(string); s != "" {
delta["content"] = ""
@@ -609,17 +664,31 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
},
}
if b, err := json.Marshal(usageChunk); err == nil {
- fmt.Fprintf(w, "data: %s\n\n", b)
+ if _, err := fmt.Fprintf(w, "data: %s\n\n", b); err != nil {
+ return
+ }
flusher.Flush()
}
}
}
}
- fmt.Fprintf(w, "%s\n", line)
+ if _, err := fmt.Fprintf(w, "%s\n", line); err != nil {
+ return
+ }
flusher.Flush()
}
+ if scanner.Err() != nil || r.Context().Err() != nil {
+ return
+ }
+ spend.input, spend.output = streamInputTok, streamOutputTok
} else {
- respBody, _ := io.ReadAll(resp.Body)
+ respBody, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ if r.Context().Err() == nil {
+ http.Error(w, "Incomplete upstream response", http.StatusBadGateway)
+ }
+ return
+ }
// --- Empty turn fallback detection ---
// If the response has empty content, no tool_calls, and finish_reason "stop",
@@ -634,7 +703,6 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
if nextModel != "" {
log.Printf("degraded response: empty turn from %s, falling back to %s", resolvedModel, nextModel)
- resolvedModel = nextModel
req.Model = nextModel
fbBody, _ := json.Marshal(req)
@@ -643,36 +711,57 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
s.sessions.SetSession(sessionID, nextModel, decision.Tier, userExplicit)
}
- fbResp, fbErr := retry.Do(r.Context(), makeReqFor(fbBody), retry.WithClient(s.httpClient))
- if fbErr == nil {
- defer fbResp.Body.Close()
- respBody, _ = io.ReadAll(fbResp.Body)
- resp = fbResp
- w.Header().Set("X-DOSRouter-Fallback", nextModel)
- w.Header().Set("X-DOSRouter-Model", nextModel)
+ spend.finish(respBody)
+ spend = nil
+ nextSpend, allowed := s.reserveChat(w, req, fbBody, nextModel)
+ if !allowed {
+ return
+ }
+ fbResp, fbErr := retry.Do(r.Context(), makeReqFor(fbBody), retry.WithClient(s.httpClient), retry.WithNetworkRetries(false))
+ if fbResp != nil && fbResp.StatusCode >= 300 {
+ fbErr = nil
+ }
+ if fbErr != nil {
+ nextSpend.finish(nil)
+ if fbResp != nil {
+ fbResp.Body.Close()
+ }
+ if r.Context().Err() == nil {
+ http.Error(w, "Fallback request failed", http.StatusBadGateway)
+ }
+ return
}
+ defer fbResp.Body.Close()
+ if fbResp.StatusCode >= 300 {
+ nextSpend.release()
+ http.Error(w, "Fallback request rejected", http.StatusBadGateway)
+ return
+ }
+ spend = nextSpend
+ spend.header = fbResp.Header
+ respBody, readErr = io.ReadAll(fbResp.Body)
+ if readErr != nil {
+ http.Error(w, "Incomplete fallback response", http.StatusBadGateway)
+ return
+ }
+ resp = fbResp
+ resolvedModel = nextModel
+ w.Header().Set("X-DOSRouter-Fallback", nextModel)
+ w.Header().Set("X-DOSRouter-Model", nextModel)
+
}
}
- // Cache the response
- s.cache.Set(body, cache.Entry{
- Body: respBody,
- StatusCode: resp.StatusCode,
- Header: resp.Header,
- })
-
+ spend.readUsage(respBody)
// Inject usage.cost into non-streaming response (upstream v0.12.146)
- if resp.StatusCode == http.StatusOK && decision != nil {
+ if resp.StatusCode == http.StatusOK {
var parsed map[string]interface{}
if json.Unmarshal(respBody, &parsed) == nil {
// Overwrite model with actual resolved model
- parsed["model"] = resolvedModel
- // Strip tool-call planning prose from content (upstream
- // v0.12.165/166): some providers (notably Kimi) emit planning text
- // in message.content alongside tool_calls, or flag the turn via
- // finish_reason=tool_calls. Tool execution only needs tool_calls;
- // forwarding the prose pollutes chat channels. Blank the content
- // when the turn ends in tool calls.
+ if decision != nil {
+ parsed["model"] = resolvedModel
+ }
+ // Preserve assistant prose alongside native tool calls (v0.12.248).
if choices, ok := parsed["choices"].([]interface{}); ok {
for _, c := range choices {
choice, ok := c.(map[string]interface{})
@@ -685,7 +774,9 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
tc, _ := msg["tool_calls"].([]interface{})
if len(tc) == 0 {
contentStr, _ := msg["content"].(string)
- if recovered := recoverStructuredToolCalls(contentStr, ""); len(recovered) > 0 {
+ contentStr = stripThinking(contentStr)
+ if recovered, cleaned := recoverToolCallsWithProse(contentStr); len(recovered) > 0 && len(req.Tools) > 0 {
+ msg["content"] = cleaned
recList := make([]interface{}, len(recovered))
for idx, r := range recovered {
recList[idx] = r
@@ -696,7 +787,12 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
}
- if choiceEndsWithToolCalls(choice) {
+ if msg, ok := choice["message"].(map[string]interface{}); ok {
+ if content, ok := msg["content"].(string); ok {
+ msg["content"] = stripThinking(content)
+ }
+ }
+ if choiceEndsWithToolCalls(choice) && !forwardToolCallProse() {
if msg, ok := choice["message"].(map[string]interface{}); ok {
if s, _ := msg["content"].(string); s != "" {
msg["content"] = ""
@@ -706,7 +802,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
}
// Inject cost breakdown if usage tokens available
- if usage, ok := parsed["usage"].(map[string]interface{}); ok {
+ if usage, ok := parsed["usage"].(map[string]interface{}); ok && decision != nil {
inputTok, _ := usage["prompt_tokens"].(float64)
outputTok, _ := usage["completion_tokens"].(float64)
cb := buildCostBreakdown(resolvedModel, string(decision.Tier), decision.Profile, s.modelPricing, int(inputTok), int(outputTok))
@@ -720,12 +816,18 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
}
+ if cacheAllowed && resp.StatusCode == http.StatusOK && !isEmptyTurn(respBody) {
+ s.cache.Set(cacheBody, cache.Entry{Body: respBody, StatusCode: resp.StatusCode, Header: resp.Header.Clone()})
+ }
// Copy headers with sanitization (upstream v0.12.208)
for k, v := range resp.Header {
for _, vv := range v {
w.Header().Add(k, sanitizeHeaderValue(vv))
}
}
+ if authHeader != "" {
+ w.Header().Set("Cache-Control", "no-store")
+ }
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(respBody)))
w.WriteHeader(resp.StatusCode)
w.Write(respBody)
@@ -748,17 +850,12 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
}
- // Record spend
- if decision != nil && decision.CostEstimate > 0 {
- _ = s.spendControl.Record(decision.CostEstimate, resolvedModel, "chat")
- if sessionID != "" {
- s.sessions.AddSessionCost(sessionID, int64(decision.CostEstimate*1_000_000))
- }
+ spend.finish(nil)
+ if sessionID != "" {
+ s.sessions.AddSessionCost(sessionID, int64(spend.cost*1_000_000))
}
+ s.logSettledRequest(resolvedModel, decision, startTime, spend)
- // Log usage
- _ = latencyMs
- s.logRequest(resolvedModel, decision, startTime, "success")
}
func (s *Server) logRequest(model string, decision *router.RoutingDecision, startTime time.Time, status string) {
@@ -772,7 +869,7 @@ func (s *Server) logRequest(model string, decision *router.RoutingDecision, star
baselineCost = decision.BaselineCost
savings = decision.Savings
}
- logger.LogUsage(logger.UsageEntry{
+ s.writeUsage(logger.UsageEntry{
Timestamp: time.Now().UTC().Format(time.RFC3339),
Model: model,
Tier: tier,
@@ -784,15 +881,6 @@ func (s *Server) logRequest(model string, decision *router.RoutingDecision, star
})
}
-func mustSpendControl() *spendcontrol.SpendControl {
- sc, err := spendcontrol.New(spendcontrol.NewFileStorage())
- if err != nil {
- // Non-fatal: start with empty state
- sc, _ = spendcontrol.New(nil)
- }
- return sc
-}
-
func flattenHeaders(h http.Header) map[string]string {
flat := make(map[string]string, len(h))
for k, v := range h {
@@ -859,6 +947,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp := map[string]interface{}{
"status": "ok",
"version": Version,
+ "gateway": gatewayOrigin(s.config.UpstreamBase),
}
// Full health includes session/journal stats
if r.URL.Query().Get("full") == "true" {
@@ -867,7 +956,13 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
resp["sessions"] = sessStats.Count
resp["journalSessions"] = jStats.Sessions
resp["journalEntries"] = jStats.TotalEntries
- resp["spendControl"] = s.spendControl.GetStatus()
+ if s.spendControl != nil {
+ resp["spendControl"] = s.spendControl.GetStatus()
+ }
+ if s.spendError != nil {
+ resp["status"] = "degraded"
+ resp["spendControlError"] = "spending state unavailable"
+ }
}
json.NewEncoder(w).Encode(resp)
}
@@ -937,6 +1032,14 @@ func (s *Server) handleImageGen(w http.ResponseWriter, r *http.Request) {
return
}
+ if s.spendError != nil || s.spendControl == nil {
+ http.Error(w, "Spending state unavailable", http.StatusServiceUnavailable)
+ return
+ }
+ if len(s.spendControl.GetLimits()) > 0 {
+ http.Error(w, "Image cost cannot be reserved under configured spend limits", http.StatusTooManyRequests)
+ return
+ }
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body", http.StatusBadRequest)
@@ -973,7 +1076,13 @@ func (s *Server) handleImageGen(w http.ResponseWriter, r *http.Request) {
}
defer resp.Body.Close()
- respBody, _ := io.ReadAll(resp.Body)
+ respBody, readErr := io.ReadAll(resp.Body)
+ if readErr != nil {
+ if r.Context().Err() == nil {
+ http.Error(w, "Incomplete upstream response", http.StatusBadGateway)
+ }
+ return
+ }
for k, v := range resp.Header {
for _, vv := range v {
@@ -981,13 +1090,18 @@ func (s *Server) handleImageGen(w http.ResponseWriter, r *http.Request) {
}
}
w.Header().Set("X-DOSRouter-Model", req.Model)
+ if upstreamReq.Header.Get("Authorization") != "" {
+ w.Header().Set("Cache-Control", "no-store")
+ }
w.WriteHeader(resp.StatusCode)
w.Write(respBody)
- logger.LogUsage(logger.UsageEntry{
+ s.writeUsage(logger.UsageEntry{
Timestamp: time.Now().UTC().Format(time.RFC3339),
Model: req.Model,
Tier: "IMAGE",
+ Cost: mediaCost(resp.Header, respBody),
+ RequestID: gatewayRequestID(resp.Header),
Status: fmt.Sprintf("%d", resp.StatusCode),
})
}
@@ -995,7 +1109,7 @@ func (s *Server) handleImageGen(w http.ResponseWriter, r *http.Request) {
// choiceEndsWithToolCalls reports whether a parsed choice object (streaming or
// non-streaming) represents a tool-call turn: either finish_reason is
// "tool_calls", or a non-empty tool_calls array is present on message or delta.
-// Used to suppress planning prose in content (upstream v0.12.165/166/169).
+// Used for the optional legacy prose suppression setting.
func choiceEndsWithToolCalls(choice map[string]interface{}) bool {
if fr, _ := choice["finish_reason"].(string); fr == "tool_calls" {
return true
diff --git a/proxy/request.go b/proxy/request.go
new file mode 100644
index 0000000..dc37752
--- /dev/null
+++ b/proxy/request.go
@@ -0,0 +1,116 @@
+package proxy
+
+import (
+ "encoding/json"
+ "net/url"
+ "os"
+ "strings"
+
+ "github.com/DOS/DOSRouter/logger"
+)
+
+// The typed view supports routing while Extra preserves the wire protocol.
+func (r *chatRequest) UnmarshalJSON(data []byte) error {
+ type plain chatRequest
+ var v plain
+ if err := json.Unmarshal(data, &v); err != nil {
+ return err
+ }
+ if err := json.Unmarshal(data, &v.Extra); err != nil {
+ return err
+ }
+ *r = chatRequest(v)
+ return nil
+}
+
+func (r chatRequest) MarshalJSON() ([]byte, error) {
+ type plain chatRequest
+ return mergeJSON(r.Extra, plain(r))
+}
+
+func (m *chatMessage) UnmarshalJSON(data []byte) error {
+ type plain chatMessage
+ var v plain
+ if err := json.Unmarshal(data, &v); err != nil {
+ return err
+ }
+ if err := json.Unmarshal(data, &v.Extra); err != nil {
+ return err
+ }
+ *m = chatMessage(v)
+ return nil
+}
+
+func (m chatMessage) MarshalJSON() ([]byte, error) {
+ type plain chatMessage
+ return mergeJSON(m.Extra, plain(m))
+}
+
+func mergeJSON(extra map[string]json.RawMessage, typed any) ([]byte, error) {
+ fields := make(map[string]json.RawMessage, len(extra))
+ for k, v := range extra {
+ fields[k] = v
+ }
+ data, err := json.Marshal(typed)
+ if err != nil {
+ return nil, err
+ }
+ if err := json.Unmarshal(data, &fields); err != nil {
+ return nil, err
+ }
+ return json.Marshal(fields)
+}
+
+// Compression cannot remove or stringify protocol-bearing messages.
+func canCompressMessages(messages []chatMessage) bool {
+ for _, m := range messages {
+ if m.Role == "tool" || len(m.Content) == 0 || m.Content[0] != '"' {
+ return false
+ }
+ for key := range m.Extra {
+ if key != "role" && key != "content" {
+ return false
+ }
+ }
+ if m.ReasoningContent != nil {
+ return false
+ }
+ }
+ return true
+}
+
+func forwardToolCallProse() bool {
+ return !strings.EqualFold(strings.TrimSpace(os.Getenv("DOSROUTER_TOOL_CALL_PROSE")), "off")
+}
+
+// Remove only syntax which actually recovered into a tool call.
+func recoverToolCallsWithProse(content string) ([]map[string]interface{}, string) {
+ var calls []map[string]interface{}
+ clean := func(match string) string {
+ found := recoverStructuredToolCalls(match, "")
+ if len(found) == 0 {
+ return match
+ }
+ calls = append(calls, found...)
+ return ""
+ }
+ cleaned := jsonCodeBlockRegex.ReplaceAllStringFunc(content, clean)
+ cleaned = callToolRegex.ReplaceAllStringFunc(cleaned, clean)
+ return calls, strings.TrimSpace(cleaned)
+}
+
+func gatewayOrigin(base string) string {
+ u, err := url.Parse(base)
+ if err != nil || u.Host == "" {
+ return ""
+ }
+ return u.Scheme + "://" + u.Host
+}
+
+func (s *Server) writeUsage(entry logger.UsageEntry) {
+ if s.config.UsageLogger != nil {
+ s.config.UsageLogger(entry)
+ return
+ }
+ logger.LogUsage(entry)
+}
diff --git a/proxy/spending.go b/proxy/spending.go
new file mode 100644
index 0000000..d1b041d
--- /dev/null
+++ b/proxy/spending.go
@@ -0,0 +1,177 @@
+package proxy
+
+import (
+ "encoding/json"
+ "math"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/DOS/DOSRouter/logger"
+ "github.com/DOS/DOSRouter/router"
+)
+
+type requestSpend struct {
+ usageKnown bool
+ server *Server
+ id uint64
+ model string
+ estimate, cost float64
+ input, output int
+ header http.Header
+ finished bool
+ source string
+}
+
+func (s *Server) reserveChat(w http.ResponseWriter, req chatRequest, body []byte, model string) (*requestSpend, bool) {
+ if s.spendError != nil || s.spendControl == nil {
+ http.Error(w, "Spending state unavailable", http.StatusServiceUnavailable)
+ return nil, false
+ }
+ price, known := s.modelPricing[model]
+ limited := len(s.spendControl.GetLimits()) > 0
+ if !known && limited {
+ http.Error(w, "Unknown model cost under configured spend limits", http.StatusTooManyRequests)
+ return nil, false
+ }
+ if raw, ok := req.Extra["max_tokens"]; ok {
+ var value int
+ if json.Unmarshal(raw, &value) != nil || value <= 0 {
+ http.Error(w, "Invalid max_tokens", http.StatusBadRequest)
+ return nil, false
+ }
+ }
+ if raw, ok := req.Extra["max_completion_tokens"]; ok {
+ var value int
+ if json.Unmarshal(raw, &value) != nil || value <= 0 {
+ http.Error(w, "Invalid max_completion_tokens", http.StatusBadRequest)
+ return nil, false
+ }
+ }
+ output := req.MaxTokens
+ if raw, ok := req.Extra["max_completion_tokens"]; ok {
+ if json.Unmarshal(raw, &output) != nil {
+ output = -1
+ }
+ }
+ if output <= 0 {
+ output = 4096
+ }
+ n := 1
+ if raw, ok := req.Extra["n"]; ok {
+ if json.Unmarshal(raw, &n) != nil || n < 1 {
+ http.Error(w, "Invalid completion count", http.StatusBadRequest)
+ return nil, false
+ }
+ }
+ // Reserve a conservative text-token estimate including history and tools.
+ estimate := (float64(len(body))*price.InputPrice + float64(output)*float64(n)*price.OutputPrice) / 1_000_000
+ if price.FlatPrice != nil {
+ estimate = *price.FlatPrice * float64(n)
+ }
+ id, check := s.spendControl.Reserve(estimate)
+ if !check.Allowed {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusTooManyRequests)
+ json.NewEncoder(w).Encode(map[string]any{"error": check.Reason, "blockedBy": check.BlockedBy, "remaining": check.Remaining})
+ return nil, false
+ }
+ return &requestSpend{server: s, id: id, model: model, estimate: estimate, cost: estimate, source: "estimate"}, true
+}
+
+func (sp *requestSpend) readUsage(body []byte) {
+ var parsed struct {
+ Usage *struct {
+ Input *int `json:"prompt_tokens"`
+ Output *int `json:"completion_tokens"`
+ } `json:"usage"`
+ }
+ if json.Unmarshal(body, &parsed) == nil && parsed.Usage != nil && parsed.Usage.Input != nil && parsed.Usage.Output != nil {
+ sp.input, sp.output = *parsed.Usage.Input, *parsed.Usage.Output
+ sp.usageKnown = true
+ }
+}
+
+func (sp *requestSpend) finish(body []byte) {
+ if sp == nil || sp.finished {
+ return
+ }
+ sp.finished = true
+ if len(body) > 0 {
+ sp.readUsage(body)
+ }
+ if cost, ok := settledCost(sp.header); ok {
+ sp.cost, sp.source = cost, "gateway"
+ } else if (sp.usageKnown || sp.input > 0 || sp.output > 0) && sp.input >= 0 && sp.output >= 0 {
+ if p, ok := sp.server.modelPricing[sp.model]; ok {
+ sp.cost = (float64(sp.input)*p.InputPrice + float64(sp.output)*p.OutputPrice) / 1_000_000
+ if p.FlatPrice != nil {
+ sp.cost = *p.FlatPrice
+ }
+ sp.source = "tokens"
+ }
+ }
+ // A persistence error keeps the controller fail-closed; never retry a paid call.
+ _ = sp.server.spendControl.Commit(sp.id, sp.cost, sp.model, "chat")
+}
+
+func (sp *requestSpend) release() {
+ if sp == nil || sp.finished {
+ return
+ }
+ sp.finished = true
+ sp.server.spendControl.Release(sp.id)
+}
+
+func settledCost(h http.Header) (float64, bool) {
+ for _, name := range []string{"X-DOS-Cost-USD", "X-Blockrun-Cost-USD"} {
+ raw := strings.TrimSpace(h.Get(name))
+ if raw == "" {
+ continue
+ }
+ cost, err := strconv.ParseFloat(raw, 64)
+ if err == nil && cost >= 0 && !math.IsNaN(cost) && !math.IsInf(cost, 0) {
+ return cost, true
+ }
+ }
+ return 0, false
+}
+
+func gatewayRequestID(h http.Header) string {
+ for _, name := range []string{"X-DOS-Request-Id", "X-Blockrun-Request-Id", "X-Request-Id", "Request-Id"} {
+ if id := strings.TrimSpace(h.Get(name)); id != "" {
+ return sanitizeHeaderValue(id)
+ }
+ }
+ return ""
+}
+
+func mediaCost(h http.Header, body []byte) float64 {
+ if cost, ok := settledCost(h); ok {
+ return cost
+ }
+ var data struct {
+ Price struct {
+ Amount json.RawMessage `json:"amount"`
+ } `json:"price"`
+ }
+ if json.Unmarshal(body, &data) == nil {
+ raw := strings.Trim(string(data.Price.Amount), `"`)
+ cost, err := strconv.ParseFloat(raw, 64)
+ if err == nil && cost >= 0 && !math.IsNaN(cost) && !math.IsInf(cost, 0) {
+ return cost
+ }
+ }
+ return 0
+}
+
+func (s *Server) logSettledRequest(model string, decision *router.RoutingDecision, start time.Time, spend *requestSpend) {
+ entry := logger.UsageEntry{Timestamp: time.Now().UTC().Format(time.RFC3339), Model: model, Tier: "DIRECT", Cost: spend.cost, CostSource: spend.source, RequestID: gatewayRequestID(spend.header), InputTokens: spend.input, OutputTokens: spend.output, Status: "success", LatencyMs: time.Since(start).Milliseconds()}
+ if decision != nil {
+ entry.Tier = string(decision.Tier)
+ entry.BaselineCost = decision.BaselineCost
+ entry.Savings = decision.Savings
+ }
+ s.writeUsage(entry)
+}
diff --git a/proxy/spending_test.go b/proxy/spending_test.go
new file mode 100644
index 0000000..6f68028
--- /dev/null
+++ b/proxy/spending_test.go
@@ -0,0 +1,43 @@
+package proxy
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/DOS/DOSRouter/logger"
+)
+
+func TestZeroTokenUsageIsNotAnEstimate(t *testing.T) {
+ var entry logger.UsageEntry
+ srv, sc := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ io.WriteString(w, `{"choices":[{"message":{"content":"No charge"},"finish_reason":"stop"}],"usage":{"prompt_tokens":0,"completion_tokens":0}}`)
+ }, func(cfg *Config) { cfg.UsageLogger = func(e logger.UsageEntry) { entry = e } })
+ got := syncTestChat(t, srv, `{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"zero"}]}`)
+ if got.Code != 200 || entry.Cost != 0 || entry.CostSource != "tokens" {
+ t.Fatalf("status=%d log=%+v", got.Code, entry)
+ }
+ if h := sc.GetHistory(); len(h) != 1 || h[0].Amount != 0 {
+ t.Fatalf("history=%+v", h)
+ }
+}
+
+func TestCallerCredentialsDoNotShareResponseCache(t *testing.T) {
+ var calls atomic.Int32
+ srv, _ := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) { calls.Add(1); syncTestOK(w) }, nil)
+ for _, credential := range []string{"Bearer test-a", "Bearer test-b"} {
+ r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"same"}]}`))
+ r.Header.Set("Authorization", credential)
+ w := httptest.NewRecorder()
+ srv.handleChatCompletions(w, r)
+ if w.Code != 200 || w.Header().Get("X-DOSRouter-Cache") == "hit" || w.Header().Get("Cache-Control") != "no-store" {
+ t.Fatalf("status=%d headers=%v", w.Code, w.Header())
+ }
+ }
+ if calls.Load() != 2 {
+ t.Fatalf("upstream calls=%d", calls.Load())
+ }
+}
diff --git a/proxy/upstream_sync_test.go b/proxy/upstream_sync_test.go
new file mode 100644
index 0000000..f7c3594
--- /dev/null
+++ b/proxy/upstream_sync_test.go
@@ -0,0 +1,505 @@
+package proxy
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/DOS/DOSRouter/logger"
+ "github.com/DOS/DOSRouter/router"
+ "github.com/DOS/DOSRouter/spendcontrol"
+)
+
+func syncTestServer(t *testing.T, upstream http.HandlerFunc, configure func(*Config)) (*Server, *spendcontrol.SpendControl) {
+ t.Helper()
+ remote := httptest.NewServer(upstream)
+ t.Cleanup(remote.Close)
+ sc, err := spendcontrol.New(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg := Config{UpstreamBase: remote.URL, SpendControl: sc, UsageLogger: func(logger.UsageEntry) {}}
+ if configure != nil {
+ configure(&cfg)
+ }
+ srv := New(cfg)
+ t.Cleanup(srv.Close)
+ return srv, sc
+}
+
+func syncTestJSON(t *testing.T, value any) string {
+ t.Helper()
+ b, err := json.Marshal(value)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(b)
+}
+
+func syncTestChat(t *testing.T, srv *Server, payload string) *httptest.ResponseRecorder {
+ t.Helper()
+ w := httptest.NewRecorder()
+ srv.handleChatCompletions(w, httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(payload)))
+ return w
+}
+
+func syncTestOK(w http.ResponseWriter) {
+ w.Header().Set("Content-Type", "application/json")
+ io.WriteString(w, `{"model":"openai/gpt-4o-mini","choices":[{"index":0,"message":{"role":"assistant","content":"Done."},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}`)
+}
+
+func syncTestTools() []any {
+ return []any{map[string]any{"type": "function", "function": map[string]any{"name": "read_file", "parameters": map[string]any{"type": "object", "properties": map[string]any{"path": map[string]any{"type": "string"}}}}}}
+}
+
+func syncTestToolCall() []any {
+ return []any{map[string]any{"id": "call_test", "type": "function", "function": map[string]any{"name": "read_file", "arguments": `{"path":"README.md"}`}}}
+}
+
+func TestUpstreamSyncPreservesExtendedToolConversation(t *testing.T) {
+ original := map[string]any{
+ "model": "gpt5", "stream": false, "max_completion_tokens": 8192,
+ "reasoning_effort": "high", "response_format": map[string]any{"type": "json_schema", "json_schema": map[string]any{"name": "answer", "strict": true, "schema": map[string]any{"type": "object"}}},
+ "parallel_tool_calls": false, "metadata": map[string]any{"trace": "test-only"}, "tools": syncTestTools(),
+ "messages": []any{
+ map[string]any{"role": "user", "name": "operator", "content": strings.Repeat("The exact input must remain unchanged. ", 500)},
+ map[string]any{"role": "assistant", "content": nil, "tool_calls": syncTestToolCall(), "reasoning_content": "existing reasoning", "provider_extension": map[string]any{"retain": true}},
+ map[string]any{"role": "tool", "tool_call_id": "call_test", "name": "read_file", "content": strings.Repeat("Repeated but semantically meaningful tool output. ", 300)},
+ },
+ }
+ received := make(chan map[string]any, 1)
+ srv, _ := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ var body map[string]any
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ t.Error(err)
+ }
+ received <- body
+ syncTestOK(w)
+ }, nil)
+ result := syncTestChat(t, srv, syncTestJSON(t, original))
+ if result.Code != 200 {
+ t.Fatalf("status = %d: %s", result.Code, result.Body.String())
+ }
+ got := <-received
+ // Compare normalized JSON values, including numeric and null fields.
+ var want map[string]any
+ json.Unmarshal([]byte(syncTestJSON(t, original)), &want)
+ want["model"] = "openai/gpt-5.6-terra"
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("forwarded request lost or mutated tool/extension fields\ngot: %#v\nwant: %#v", got, want)
+ }
+}
+
+func TestUpstreamSyncNonstreamToolProse(t *testing.T) {
+ for _, kind := range []string{"native", "recovered"} {
+ for _, optout := range []bool{false, true} {
+ t.Run(fmt.Sprintf("%s/off=%t", kind, optout), func(t *testing.T) {
+ setting := ""
+ if optout {
+ setting = "off"
+ }
+ t.Setenv("DOSROUTER_TOOL_CALL_PROSE", setting)
+ content := "private deliberationI will read the file."
+ message := map[string]any{"role": "assistant", "content": content}
+ finish := "tool_calls"
+ if kind == "native" {
+ message["tool_calls"] = syncTestToolCall()
+ } else {
+ message["content"] = content + "\n```json\n{\"name\":\"read_file\",\"arguments\":{\"path\":\"README.md\"}}\n```"
+ finish = "stop"
+ }
+ response := syncTestJSON(t, map[string]any{"choices": []any{map[string]any{"index": 0, "message": message, "finish_reason": finish}}})
+ srv, _ := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, response) }, nil)
+ result := syncTestChat(t, srv, syncTestJSON(t, map[string]any{"model": "openai/gpt-4o-mini", "messages": []any{map[string]any{"role": "user", "content": "Read the file"}}, "tools": syncTestTools()}))
+ if result.Code != 200 {
+ t.Fatalf("status = %d: %s", result.Code, result.Body.String())
+ }
+ var got struct {
+ Choices []struct {
+ Finish string `json:"finish_reason"`
+ Message struct {
+ Content string `json:"content"`
+ ToolCalls []any `json:"tool_calls"`
+ } `json:"message"`
+ } `json:"choices"`
+ }
+ if err := json.Unmarshal(result.Body.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Choices) != 1 {
+ t.Fatalf("choices = %d", len(got.Choices))
+ }
+ choice := got.Choices[0]
+ want := "I will read the file."
+ if optout {
+ want = ""
+ }
+ if choice.Message.Content != want || choice.Finish != "tool_calls" || len(choice.Message.ToolCalls) != 1 {
+ t.Errorf("tool result = %+v, want prose %q with one structured call", choice, want)
+ }
+ })
+ }
+ }
+}
+
+func TestUpstreamSyncDoesNotRecoverPrivateThinkingToolSyntax(t *testing.T) {
+ content := "call:read_file({\"path\":\"PRIVATE\"})I can explain the file."
+ srv, _ := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"choices": []any{map[string]any{"index": 0, "message": map[string]any{"role": "assistant", "content": content}, "finish_reason": "stop"}}})
+ }, nil)
+ result := syncTestChat(t, srv, syncTestJSON(t, map[string]any{"model": "openai/gpt-4o-mini", "messages": []any{map[string]any{"role": "user", "content": "Explain the file"}}, "tools": syncTestTools()}))
+ if result.Code != 200 {
+ t.Fatalf("status = %d", result.Code)
+ }
+ var body map[string]any
+ json.Unmarshal(result.Body.Bytes(), &body)
+ choice := body["choices"].([]any)[0].(map[string]any)
+ message := choice["message"].(map[string]any)
+ if tc, ok := message["tool_calls"].([]any); ok && len(tc) > 0 {
+ t.Errorf("private thinking became an executable tool call: %v", tc)
+ }
+ if message["content"] != "I can explain the file." || choice["finish_reason"] != "stop" {
+ t.Errorf("visible result = %v", choice)
+ }
+}
+
+func TestUpstreamSyncStreamingToolProseAndSplitThinking(t *testing.T) {
+ for _, optout := range []bool{false, true} {
+ t.Run(fmt.Sprintf("off=%t", optout), func(t *testing.T) {
+ setting := ""
+ if optout {
+ setting = "off"
+ }
+ t.Setenv("DOSROUTER_TOOL_CALL_PROSE", setting)
+ srv, _ := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ chunks := []map[string]any{
+ {"choices": []any{map[string]any{"index": 0, "delta": map[string]any{"content": "private reasoningI will read the file.", "tool_calls": syncTestToolCall()}, "finish_reason": "tool_calls"}}},
+ }
+ for _, chunk := range chunks {
+ b, _ := json.Marshal(chunk)
+ fmt.Fprintf(w, "data: %s\n\n", b)
+ w.(http.Flusher).Flush()
+ }
+ io.WriteString(w, "data: [DONE]\n\n")
+ }, nil)
+ result := syncTestChat(t, srv, syncTestJSON(t, map[string]any{"model": "openai/gpt-4o-mini", "stream": true, "messages": []any{map[string]any{"role": "user", "content": "Read the file"}}, "tools": syncTestTools()}))
+ if result.Code != 200 {
+ t.Fatalf("status = %d", result.Code)
+ }
+ var content strings.Builder
+ toolCount := 0
+ scanner := bufio.NewScanner(strings.NewReader(result.Body.String()))
+ for scanner.Scan() {
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data: ") || line == "data: [DONE]" {
+ continue
+ }
+ var chunk struct {
+ Choices []struct {
+ Delta struct {
+ Content string `json:"content"`
+ Tools []any `json:"tool_calls"`
+ } `json:"delta"`
+ } `json:"choices"`
+ }
+ if err := json.Unmarshal([]byte(line[6:]), &chunk); err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range chunk.Choices {
+ content.WriteString(c.Delta.Content)
+ toolCount += len(c.Delta.Tools)
+ }
+ }
+ want := "I will read the file."
+ if optout {
+ want = ""
+ }
+ if content.String() != want || toolCount != 1 {
+ t.Errorf("stream prose/tools = %q/%d, want %q/1", content.String(), toolCount, want)
+ }
+ if strings.Contains(result.Body.String(), "private reasoning") {
+ t.Error("thinking leaked through SSE")
+ }
+ })
+ }
+}
+
+func TestUpstreamSyncCancellationReachesChatAndImage(t *testing.T) {
+ for _, mode := range []string{"direct", "smart", "image"} {
+ t.Run(mode, func(t *testing.T) {
+ reached := make(chan struct{}, 1)
+ cancelled := make(chan struct{}, 1)
+ release := make(chan struct{})
+ var unblock sync.Once
+ defer unblock.Do(func() { close(release) })
+ var attempts atomic.Int32
+ srv, _ := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ attempts.Add(1)
+ io.Copy(io.Discard, r.Body)
+ select {
+ case reached <- struct{}{}:
+ default:
+ }
+ select {
+ case <-r.Context().Done():
+ select {
+ case cancelled <- struct{}{}:
+ default:
+ }
+ case <-release:
+ }
+ }, nil)
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ model := "openai/gpt-4o-mini"
+ if mode == "smart" {
+ model = "auto"
+ }
+ payload := syncTestJSON(t, map[string]any{"model": model, "messages": []any{map[string]any{"role": "user", "content": "hello"}}})
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(payload)).WithContext(ctx)
+ finished := make(chan struct{})
+ go func() {
+ defer close(finished)
+ w := httptest.NewRecorder()
+ if mode == "image" {
+ srv.handleImageGen(w, req)
+ } else {
+ srv.handleChatCompletions(w, req)
+ }
+ }()
+ select {
+ case <-reached:
+ case <-time.After(3 * time.Second):
+ cancel()
+ unblock.Do(func() { close(release) })
+ t.Fatal("request did not reach test upstream")
+ }
+ cancel()
+ select {
+ case <-cancelled:
+ case <-time.After(3 * time.Second):
+ unblock.Do(func() { close(release) })
+ t.Error("upstream request did not observe cancellation")
+ }
+ select {
+ case <-finished:
+ case <-time.After(3 * time.Second):
+ t.Error("proxy handler did not stop")
+ }
+ if attempts.Load() != 1 {
+ t.Errorf("cancelled request made %d upstream attempts", attempts.Load())
+ }
+ })
+ }
+}
+
+func TestUpstreamSyncBudgetBlocksBeforeForwarding(t *testing.T) {
+ for _, model := range []string{"openai/gpt-4o-mini", "auto", "vendor/unknown"} {
+ t.Run(model, func(t *testing.T) {
+ var attempts atomic.Int32
+ srv, sc := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) { attempts.Add(1); syncTestOK(w) }, nil)
+ if err := sc.SetLimit(spendcontrol.WindowPerRequest, 0.000001); err != nil {
+ t.Fatal(err)
+ }
+ result := syncTestChat(t, srv, syncTestJSON(t, map[string]any{"model": model, "max_completion_tokens": 10000, "messages": []any{map[string]any{"role": "user", "content": "hello"}}}))
+ if result.Code != 429 || attempts.Load() != 0 {
+ t.Errorf("status/attempts = %d/%d, want 429/0", result.Code, attempts.Load())
+ }
+ if len(sc.GetHistory()) != 0 {
+ t.Error("blocked request created a charge")
+ }
+ })
+ }
+}
+
+func TestUpstreamSyncConcurrentReservationAndGatewaySettlement(t *testing.T) {
+ for _, model := range []string{"openai/gpt-4o-mini", "auto"} {
+ t.Run(model, func(t *testing.T) {
+ reached := make(chan struct{}, 1)
+ release := make(chan struct{})
+ var unblock sync.Once
+ defer unblock.Do(func() { close(release) })
+ var attempts atomic.Int32
+ logged := make(chan logger.UsageEntry, 3)
+ // Use identical bounded model chains so this test isolates admission
+ // across direct and smart routing instead of scorer calibration.
+ rc := router.DefaultRoutingConfig()
+ for _, tiers := range []map[router.Tier]router.TierConfig{rc.Tiers, rc.EcoTiers, rc.PremiumTiers, rc.AgenticTiers} {
+ for tier := range tiers {
+ tiers[tier] = router.TierConfig{Primary: "openai/gpt-4o-mini"}
+ }
+ }
+ srv, sc := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ if attempts.Add(1) == 1 {
+ reached <- struct{}{}
+ <-release
+ }
+ w.Header().Set("X-Blockrun-Cost-USD", "0.002")
+ w.Header().Set("X-Blockrun-Request-Id", "request-test-only")
+ syncTestOK(w)
+ }, func(cfg *Config) {
+ cfg.RoutingConfig = &rc
+ cfg.UsageLogger = func(e logger.UsageEntry) { logged <- e }
+ })
+ if err := sc.SetLimit(spendcontrol.WindowSession, 0.01); err != nil {
+ t.Fatal(err)
+ }
+ payload := func(prompt string) string {
+ return syncTestJSON(t, map[string]any{"model": model, "max_completion_tokens": 10000, "messages": []any{map[string]any{"role": "user", "content": prompt}}})
+ }
+ firstDone := make(chan *httptest.ResponseRecorder, 1)
+ firstPayload := payload("hello first")
+ go func() { firstDone <- syncTestChat(t, srv, firstPayload) }()
+ select {
+ case <-reached:
+ case <-time.After(3 * time.Second):
+ unblock.Do(func() { close(release) })
+ t.Fatal("first request did not reach upstream")
+ }
+ second := syncTestChat(t, srv, payload("hello second"))
+ if second.Code != 429 || attempts.Load() != 1 {
+ t.Errorf("concurrent status/attempts = %d/%d, want 429/1", second.Code, attempts.Load())
+ }
+ unblock.Do(func() { close(release) })
+ var first *httptest.ResponseRecorder
+ select {
+ case first = <-firstDone:
+ case <-time.After(3 * time.Second):
+ t.Fatal("first request did not settle")
+ }
+ if first.Code != 200 {
+ t.Fatalf("first status = %d: %s", first.Code, first.Body.String())
+ }
+ if got := first.Header().Get("X-DOSRouter-Request-Id"); got != "request-test-only" {
+ t.Errorf("gateway request ID = %q", got)
+ }
+ history := sc.GetHistory()
+ if len(history) != 1 || math.Abs(history[0].Amount-0.002) > 1e-12 {
+ t.Fatalf("settlement history = %+v", history)
+ }
+ entry := <-logged
+ if entry.Cost != 0.002 || entry.CostSource != "gateway" || entry.RequestID != "request-test-only" || entry.InputTokens != 10 || entry.OutputTokens != 5 {
+ t.Errorf("usage log = %+v", entry)
+ }
+ third := syncTestChat(t, srv, payload("hello third"))
+ if third.Code != 200 || attempts.Load() != 2 {
+ t.Errorf("after-settlement status/attempts = %d/%d, want 200/2", third.Code, attempts.Load())
+ }
+ })
+ }
+}
+
+func TestUpstreamSyncRejectedRequestReleasesReservation(t *testing.T) {
+ var attempts atomic.Int32
+ srv, sc := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ if attempts.Add(1) == 1 {
+ http.Error(w, "invalid request", 400)
+ return
+ }
+ syncTestOK(w)
+ }, nil)
+ if err := sc.SetLimit(spendcontrol.WindowSession, 0.007); err != nil {
+ t.Fatal(err)
+ }
+ payload := `{"model":"openai/gpt-4o-mini","max_completion_tokens":10000,"messages":[{"role":"user","content":"hello"}]}`
+ rejected := syncTestChat(t, srv, payload)
+ if rejected.Code != 502 {
+ t.Errorf("rejected proxy status = %d", rejected.Code)
+ }
+ if len(sc.GetHistory()) != 0 {
+ t.Error("explicit rejection charged the session")
+ }
+ accepted := syncTestChat(t, srv, payload)
+ if accepted.Code != 200 || attempts.Load() != 2 {
+ t.Errorf("released reservation status/attempts = %d/%d", accepted.Code, attempts.Load())
+ }
+}
+
+func TestUpstreamSyncTokenSettlementAndCacheDoNotDoubleCharge(t *testing.T) {
+ var attempts atomic.Int32
+ var logs []logger.UsageEntry
+ srv, sc := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ attempts.Add(1)
+ // Invalid cost headers must not replace reliable token accounting.
+ w.Header().Set("X-DOS-Cost-USD", "NaN")
+ syncTestOK(w)
+ }, func(cfg *Config) { cfg.UsageLogger = func(e logger.UsageEntry) { logs = append(logs, e) } })
+ if err := sc.SetLimit(spendcontrol.WindowSession, 1); err != nil {
+ t.Fatal(err)
+ }
+ const payload = `{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"hello cached"}]}`
+ first := syncTestChat(t, srv, payload)
+ cached := syncTestChat(t, srv, payload)
+ if first.Code != 200 || cached.Code != 200 || cached.Header().Get("X-DOSRouter-Cache") != "hit" || attempts.Load() != 1 {
+ t.Errorf("first/cache/attempts = %d/%d/%d, hit = %q", first.Code, cached.Code, attempts.Load(), cached.Header().Get("X-DOSRouter-Cache"))
+ }
+ history := sc.GetHistory()
+ want := (10*0.15 + 5*0.6) / 1_000_000
+ if len(history) != 1 || math.Abs(history[0].Amount-want) > 1e-12 {
+ t.Errorf("token settlement = %+v, want one charge %g", history, want)
+ }
+ if len(logs) != 1 || logs[0].CostSource != "tokens" || math.Abs(logs[0].Cost-want) > 1e-12 {
+ t.Errorf("usage log = %+v", logs)
+ }
+}
+
+func TestUpstreamSyncSettledCostValidation(t *testing.T) {
+ cases := []struct {
+ name, dos, blockrun string
+ want float64
+ valid bool
+ }{
+ {"DOS priority", "0.25", "0.9", 0.25, true},
+ {"zero is authoritative", "0", "0.9", 0, true},
+ {"BlockRun fallback", "", "0.4", 0.4, true},
+ {"invalid primary valid fallback", "NaN", "0.4", 0.4, true},
+ {"negative rejected", "-0.1", "", 0, false},
+ {"infinity rejected", "+Inf", "", 0, false},
+ {"non-numeric rejected", "invalid", "", 0, false},
+ {"absent", "", "", 0, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ h := make(http.Header)
+ h.Set("X-DOS-Cost-USD", tc.dos)
+ h.Set("X-Blockrun-Cost-USD", tc.blockrun)
+ cost, valid := settledCost(h)
+ if cost != tc.want || valid != tc.valid {
+ t.Errorf("settledCost = %g/%t, want %g/%t", cost, valid, tc.want, tc.valid)
+ }
+ })
+ }
+}
+
+func TestUpstreamSyncUnknownModelWithoutLimitsRemainsForwardable(t *testing.T) {
+ forwarded := make(chan string, 1)
+ srv, _ := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Model string `json:"model"`
+ }
+ json.NewDecoder(r.Body).Decode(&body)
+ forwarded <- body.Model
+ syncTestOK(w)
+ }, nil)
+ result := syncTestChat(t, srv, `{"model":"vendor/new-model","messages":[{"role":"user","content":"hello new model"}]}`)
+ if result.Code != 200 {
+ t.Fatalf("status = %d", result.Code)
+ }
+ if got := <-forwarded; got != "vendor/new-model" {
+ t.Errorf("forwarded model = %q", got)
+ }
+}
diff --git a/retry/retry.go b/retry/retry.go
index 7b624d8..2ba55f5 100644
--- a/retry/retry.go
+++ b/retry/retry.go
@@ -25,7 +25,7 @@ const (
// retryableStatusCodes are HTTP status codes that trigger a retry.
var retryableStatusCodes = map[int]bool{
- http.StatusTooManyRequests: true, // 429
+ http.StatusTooManyRequests: true, // 429
http.StatusBadGateway: true, // 502
http.StatusServiceUnavailable: true, // 503
http.StatusGatewayTimeout: true, // 504
@@ -38,7 +38,8 @@ type Config struct {
// MaxRetries is the maximum number of retry attempts (default 2).
MaxRetries int
// Client is the HTTP client to use. If nil, http.DefaultClient is used.
- Client *http.Client
+ Client *http.Client
+ RetryNetworkErrors bool
}
// Option configures retry behavior.
@@ -59,10 +60,16 @@ func WithClient(cl *http.Client) Option {
return func(c *Config) { c.Client = cl }
}
+// WithNetworkRetries controls retries when no response proves rejection.
+func WithNetworkRetries(enabled bool) Option {
+ return func(c *Config) { c.RetryNetworkErrors = enabled }
+}
+
func defaultConfig() Config {
return Config{
- BaseDelay: DefaultBaseDelay,
- MaxRetries: DefaultMaxRetries,
+ BaseDelay: DefaultBaseDelay,
+ MaxRetries: DefaultMaxRetries,
+ RetryNetworkErrors: true,
}
}
@@ -87,6 +94,9 @@ func Do(ctx context.Context, buildReq func() (*http.Request, error), opts ...Opt
var lastResp *http.Response
for attempt := 0; attempt <= cfg.MaxRetries; attempt++ {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
req, err := buildReq()
if err != nil {
return nil, fmt.Errorf("retry: build request: %w", err)
@@ -95,7 +105,7 @@ func Do(ctx context.Context, buildReq func() (*http.Request, error), opts ...Opt
resp, err := client.Do(req)
if err != nil {
- if !IsRetryableError(err) {
+ if !cfg.RetryNetworkErrors || !IsRetryableError(err) {
return nil, err
}
lastErr = err
diff --git a/retry/retry_test.go b/retry/retry_test.go
new file mode 100644
index 0000000..bea9354
--- /dev/null
+++ b/retry/retry_test.go
@@ -0,0 +1,96 @@
+package retry
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+type retryTestTransport func(*http.Request) (*http.Response, error)
+
+func (f retryTestTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
+
+func TestNetworkRetryOptOutDoesNotReplayAmbiguousPost(t *testing.T) {
+ for _, disabled := range []bool{true, false} {
+ t.Run(map[bool]string{true: "disabled", false: "legacy-enabled"}[disabled], func(t *testing.T) {
+ var attempts atomic.Int32
+ client := &http.Client{Transport: retryTestTransport(func(r *http.Request) (*http.Response, error) {
+ attempts.Add(1)
+ _, _ = io.ReadAll(r.Body)
+ return nil, errors.New("connection reset after upstream accepted request")
+ })}
+ _, err := Do(context.Background(), func() (*http.Request, error) {
+ return http.NewRequest(http.MethodPost, "http://upstream.invalid/chat", strings.NewReader(`{"model":"paid"}`))
+ }, WithClient(client), WithNetworkRetries(!disabled), WithBaseDelay(time.Microsecond))
+ if err == nil {
+ t.Fatal("expected transport failure")
+ }
+ want := int32(3)
+ if disabled {
+ want = 1
+ }
+ if got := attempts.Load(); got != want {
+ t.Errorf("attempts = %d, want %d", got, want)
+ }
+ })
+ }
+}
+
+func TestNetworkRetryOptOutRetainsRejectedStatusRetries(t *testing.T) {
+ for _, status := range []int{429, 502, 503, 504} {
+ t.Run(http.StatusText(status), func(t *testing.T) {
+ var attempts atomic.Int32
+ const payload = `{"model":"paid","messages":[]}`
+ client := &http.Client{Transport: retryTestTransport(func(r *http.Request) (*http.Response, error) {
+ got, err := io.ReadAll(r.Body)
+ if err != nil || string(got) != payload {
+ t.Errorf("retry body = %q, err = %v", got, err)
+ }
+ code := status
+ if attempts.Add(1) == 2 {
+ code = http.StatusOK
+ }
+ return &http.Response{StatusCode: code, Header: make(http.Header), Body: io.NopCloser(strings.NewReader("response"))}, nil
+ })}
+ resp, err := Do(context.Background(), func() (*http.Request, error) {
+ return http.NewRequest(http.MethodPost, "http://upstream.invalid/chat", strings.NewReader(payload))
+ }, WithClient(client), WithNetworkRetries(false), WithBaseDelay(time.Microsecond))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK || attempts.Load() != 2 {
+ t.Errorf("status/attempts = %d/%d", resp.StatusCode, attempts.Load())
+ }
+ })
+ }
+}
+
+func TestCancellationStopsStatusBackoff(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ var attempts atomic.Int32
+ client := &http.Client{Transport: retryTestTransport(func(r *http.Request) (*http.Response, error) {
+ attempts.Add(1)
+ cancel()
+ return &http.Response{StatusCode: 429, Header: http.Header{"Retry-After": []string{"90"}}, Body: io.NopCloser(strings.NewReader("rejected"))}, nil
+ })}
+ start := time.Now()
+ _, err := Do(ctx, func() (*http.Request, error) {
+ return http.NewRequest(http.MethodPost, "http://upstream.invalid/chat", nil)
+ }, WithClient(client), WithNetworkRetries(false))
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("error = %v, want context cancellation", err)
+ }
+ if attempts.Load() != 1 {
+ t.Errorf("cancelled request retried %d times", attempts.Load())
+ }
+ if time.Since(start) > time.Second {
+ t.Error("cancellation waited for Retry-After")
+ }
+}
diff --git a/router/config.go b/router/config.go
index 63c0f27..85dcf8d 100644
--- a/router/config.go
+++ b/router/config.go
@@ -17,195 +17,231 @@ func DefaultRoutingConfig() RoutingConfig {
Scoring: defaultScoringConfig(),
- // Auto (balanced) tier configs
+ // Tier chains from router-core 5ee7c23c993013a8052588191569db5cf7fb793c.
+ // NVIDIA free IDs use the DOS catalog namespace.
Tiers: map[Tier]TierConfig{
TierSimple: {
- Primary: "google/gemini-3.5-flash",
+ Primary: "google/gemini-2.5-flash",
Fallback: []string{
- "google/gemini-2.5-flash",
+ "google/gemini-3-flash-preview",
+ "google/gemini-3.5-flash-lite",
"deepseek/deepseek-chat",
- "openai/gpt-5.4-nano",
- "moonshot/kimi-k2.5",
"google/gemini-3.1-flash-lite",
+ "openai/gpt-5.6-luna",
+ "openai/gpt-5.4-nano",
"google/gemini-2.5-flash-lite",
- "free/gpt-oss-120b",
- "free/llama-4-maverick",
+ "free/nemotron-3.5-lightning",
},
},
TierMedium: {
- Primary: "moonshot/kimi-k2.7",
+ Primary: "google/gemini-3.5-flash",
Fallback: []string{
- "moonshot/kimi-k2.6",
- "moonshot/kimi-k2.5",
- "google/gemini-3.5-flash",
+ "google/gemini-3.6-flash",
+ "zai/glm-5.3-flash",
+ "openai/gpt-5.6-terra",
+ "google/gemini-3-flash-preview",
"deepseek/deepseek-chat",
- "openai/chatgpt-instant",
"google/gemini-2.5-flash",
+ "minimax/minimax-m3",
"google/gemini-3.1-flash-lite",
- "xai/grok-4-1-fast-non-reasoning",
+ "openai/gpt-5.6-luna",
+ "google/gemini-2.5-flash-lite",
},
},
TierComplex: {
- Primary: "openai/gpt-5.6-terra",
+ Primary: "google/gemini-3.1-pro",
Fallback: []string{
+ "google/gemini-3.6-flash",
+ "google/gemini-3.5-flash",
"anthropic/claude-sonnet-5",
- "google/gemini-3.1-pro",
"xai/grok-4.5",
- "openai/gpt-5.5",
- "anthropic/claude-opus-5",
+ "google/gemini-2.5-pro",
"anthropic/claude-sonnet-4.6",
+ "openai/gpt-5.6-terra",
+ "openai/gpt-5.5",
+ "openai/gpt-5.4",
+ "zai/glm-5.3",
+ "moonshot/kimi-k3",
+ "deepseek/deepseek-v4-pro",
"deepseek/deepseek-chat",
- "qwen/qwen3.7-max",
+ "google/gemini-2.5-flash",
},
},
TierReasoning: {
- Primary: "xai/grok-4-1-fast-reasoning",
+ Primary: "deepseek/deepseek-reasoner",
Fallback: []string{
- "deepseek/deepseek-reasoner",
- "openai/gpt-5.6-sol",
+ "deepseek/deepseek-v4-pro",
+ "xai/grok-4.3",
+ "qwen/qwen3.7-plus",
+ "google/gemini-3.5-flash",
"openai/o4-mini",
"openai/o3",
},
},
},
- // Eco tier configs - absolute cheapest
EcoTiers: map[Tier]TierConfig{
TierSimple: {
- Primary: "free/gpt-oss-120b",
+ Primary: "free/nemotron-3.5-lightning",
Fallback: []string{
- "free/gpt-oss-20b",
- "google/gemini-3.5-flash",
- "google/gemini-3.1-flash-lite",
+ "free/nemotron-3-nano-30b",
+ "google/gemini-2.5-flash-lite",
+ "zai/glm-5.3-flash",
+ "openai/gpt-5.6-luna",
"openai/gpt-5.4-nano",
- "free/llama-4-maverick",
+ "google/gemini-3.1-flash-lite",
},
},
TierMedium: {
- Primary: "google/gemini-3.1-flash-lite",
+ Primary: "zai/glm-5.3-flash",
Fallback: []string{
+ "deepseek/deepseek-chat",
+ "google/gemini-3.1-flash-lite",
+ "openai/gpt-5.6-luna",
"openai/gpt-5.4-nano",
- "google/gemini-3.5-flash",
"google/gemini-2.5-flash-lite",
- "free/llama-4-maverick",
+ "google/gemini-2.5-flash",
},
},
TierComplex: {
- Primary: "google/gemini-3.1-flash-lite",
+ Primary: "zai/glm-5.3-flash",
Fallback: []string{
- "google/gemini-3.5-flash",
- "google/gemini-2.5-flash",
"deepseek/deepseek-chat",
- "free/llama-4-maverick",
+ "minimax/minimax-m3",
+ "deepseek/deepseek-v4-pro",
+ "google/gemini-3.1-flash-lite",
+ "google/gemini-2.5-flash",
},
},
TierReasoning: {
- Primary: "xai/grok-4-1-fast-reasoning",
+ Primary: "deepseek/deepseek-reasoner",
Fallback: []string{
- "xai/grok-4-fast-reasoning",
- "deepseek/deepseek-reasoner",
+ "deepseek/deepseek-v4-pro",
+ "qwen/qwen3.7-plus",
+ "minimax/minimax-m3",
+ "zai/glm-5.3-flash",
},
},
},
- // Premium tier configs - best quality
PremiumTiers: map[Tier]TierConfig{
TierSimple: {
- Primary: "moonshot/kimi-k2.7",
+ Primary: "google/gemini-3.5-flash",
Fallback: []string{
- "moonshot/kimi-k2.6",
+ "google/gemini-3.6-flash",
"anthropic/claude-haiku-4.5",
- "google/gemini-3.5-flash",
+ "zai/glm-5.3",
+ "google/gemini-2.5-flash",
+ "google/gemini-3.5-flash-lite",
"deepseek/deepseek-chat",
},
},
TierMedium: {
- Primary: "anthropic/claude-sonnet-5",
+ Primary: "openai/gpt-5.3-codex",
Fallback: []string{
- "openai/gpt-5.3-codex",
+ "anthropic/claude-sonnet-5",
"moonshot/kimi-k3",
- "moonshot/kimi-k2.7",
- "zai/glm-5.2",
+ "zai/glm-5.3",
+ "google/gemini-3.6-flash",
+ "google/gemini-3.5-flash",
+ "google/gemini-2.5-pro",
"xai/grok-4.5",
+ "anthropic/claude-sonnet-4.6",
+ "openai/gpt-5.6-terra",
},
},
TierComplex: {
- Primary: "anthropic/claude-opus-5",
+ Primary: "anthropic/claude-fable-5",
Fallback: []string{
+ "anthropic/claude-opus-5",
"anthropic/claude-opus-4.8",
+ "anthropic/claude-opus-4.7",
"anthropic/claude-sonnet-5",
- "openai/gpt-5.6-terra",
- "openai/gpt-5.5-pro",
+ "anthropic/claude-sonnet-4.6",
"xai/grok-4.5",
- "qwen/qwen3.7-max",
"moonshot/kimi-k3",
+ "openai/gpt-5.6-terra",
+ "openai/gpt-5.5",
+ "openai/gpt-5.4",
+ "openai/gpt-5.3-codex",
+ "zai/glm-5.3",
"deepseek/deepseek-v4-pro",
+ "deepseek/deepseek-chat",
+ "free/nemotron-3.5-lightning",
},
},
TierReasoning: {
- Primary: "anthropic/claude-opus-5",
+ Primary: "anthropic/claude-sonnet-5",
Fallback: []string{
- "openai/gpt-5.6-sol",
- "anthropic/claude-sonnet-5",
+ "anthropic/claude-sonnet-4.6",
+ "anthropic/claude-opus-5",
"anthropic/claude-opus-4.8",
+ "anthropic/claude-opus-4.7",
+ "xai/grok-4.5",
+ "deepseek/deepseek-v4-pro",
+ "xai/grok-4.3",
+ "openai/o4-mini",
"openai/o3",
- "deepseek/deepseek-reasoner",
},
},
},
- // Agentic tier configs - multi-step autonomous tasks
AgenticTiers: map[Tier]TierConfig{
TierSimple: {
Primary: "openai/gpt-4o-mini",
Fallback: []string{
- "moonshot/kimi-k2.7",
+ "openai/gpt-5.6-luna",
+ "zai/glm-5.3-flash",
"anthropic/claude-haiku-4.5",
- "xai/grok-4-1-fast-non-reasoning",
+ "google/gemini-2.5-flash",
},
},
TierMedium: {
- Primary: "moonshot/kimi-k3",
+ Primary: "openai/gpt-5-mini",
Fallback: []string{
- "moonshot/kimi-k2.7",
- "anthropic/claude-sonnet-5",
- "xai/grok-4-1-fast-non-reasoning",
+ "google/gemini-3.5-flash",
+ "zai/glm-5.3-flash",
+ "openai/gpt-5.6-terra",
"openai/gpt-4o-mini",
+ "anthropic/claude-haiku-4.5",
+ "deepseek/deepseek-chat",
+ "moonshot/kimi-k3",
},
},
TierComplex: {
- Primary: "anthropic/claude-opus-5",
+ Primary: "anthropic/claude-sonnet-5",
Fallback: []string{
- "anthropic/claude-sonnet-5",
- "openai/gpt-5.6-terra",
+ "anthropic/claude-sonnet-4.6",
+ "anthropic/claude-opus-5",
"anthropic/claude-opus-4.8",
- "qwen/qwen3.7-max",
- "moonshot/kimi-k3",
+ "anthropic/claude-opus-4.7",
"xai/grok-4.5",
+ "moonshot/kimi-k3",
+ "openai/gpt-5.6-terra",
+ "openai/gpt-5.5",
+ "openai/gpt-5.4",
+ "openai/gpt-5.3-codex",
+ "zai/glm-5.3",
+ "deepseek/deepseek-v4-pro",
+ "deepseek/deepseek-chat",
+ "free/nemotron-3.5-lightning",
},
},
TierReasoning: {
- Primary: "anthropic/claude-opus-5",
+ Primary: "anthropic/claude-sonnet-5",
Fallback: []string{
- "anthropic/claude-sonnet-5",
- "openai/gpt-5.6-sol",
+ "anthropic/claude-sonnet-4.6",
+ "anthropic/claude-opus-5",
"anthropic/claude-opus-4.8",
- "openai/o3",
+ "anthropic/claude-opus-4.7",
+ "xai/grok-4.5",
+ "deepseek/deepseek-v4-pro",
+ "deepseek/deepseek-reasoner",
},
},
},
- Promotions: []Promotion{
- {
- Name: "GLM-5.1 Launch Promo ($0.001 flat)",
- StartDate: "2026-04-01",
- EndDate: "2026-04-15",
- TierOverrides: map[Tier]PartialTierConfig{
- TierSimple: {Primary: "zai/glm-5.1"},
- },
- Profiles: []string{"auto"},
- },
- },
+ Promotions: []Promotion{},
Overrides: OverridesConfig{
MaxTokensForceComplex: 100_000,
diff --git a/spendcontrol/spendcontrol.go b/spendcontrol/spendcontrol.go
index f340d6d..c007b0e 100644
--- a/spendcontrol/spendcontrol.go
+++ b/spendcontrol/spendcontrol.go
@@ -5,6 +5,7 @@ package spendcontrol
import (
"encoding/json"
"fmt"
+ "math"
"os"
"path/filepath"
"sync"
@@ -21,7 +22,6 @@ const (
WindowSession SpendWindow = "session"
)
-// windowDuration returns the rolling duration for time-based windows.
func windowDuration(w SpendWindow) time.Duration {
switch w {
case WindowHourly:
@@ -33,6 +33,14 @@ func windowDuration(w SpendWindow) time.Duration {
}
}
+func validWindow(w SpendWindow) bool {
+ return w == WindowPerRequest || w == WindowHourly || w == WindowDaily || w == WindowSession
+}
+
+func validAmount(amount float64) bool {
+ return amount >= 0 && !math.IsNaN(amount) && !math.IsInf(amount, 0)
+}
+
// SpendLimits maps each window to its maximum USD amount.
type SpendLimits map[SpendWindow]float64
@@ -61,14 +69,17 @@ type SpendControl struct {
sessionSpent float64
sessionCalls int
storage SpendControlStorage
+ reservations map[uint64]float64
+ nextID uint64
+ storageErr error
}
-// New creates a SpendControl with the given storage backend.
-// It loads persisted state on creation.
+// New loads persisted limits and history. Session totals start at zero.
func New(storage SpendControlStorage) (*SpendControl, error) {
sc := &SpendControl{
- limits: make(SpendLimits),
- storage: storage,
+ limits: make(SpendLimits),
+ storage: storage,
+ reservations: make(map[uint64]float64),
}
if err := sc.load(); err != nil {
return nil, fmt.Errorf("spendcontrol: load: %w", err)
@@ -76,188 +87,195 @@ func New(storage SpendControlStorage) (*SpendControl, error) {
return sc, nil
}
-// SetLimit sets the maximum USD for the given window.
+// SetLimit sets a finite, nonnegative maximum USD amount for a known window.
func (sc *SpendControl) SetLimit(window SpendWindow, amount float64) error {
+ if !validWindow(window) || !validAmount(amount) {
+ return fmt.Errorf("spendcontrol: invalid spending limit")
+ }
sc.mu.Lock()
+ defer sc.mu.Unlock()
sc.limits[window] = amount
- sc.mu.Unlock()
- return sc.save()
+ return sc.saveLocked()
}
// ClearLimit removes the limit for the given window.
func (sc *SpendControl) ClearLimit(window SpendWindow) error {
+ if !validWindow(window) {
+ return fmt.Errorf("spendcontrol: invalid spending window")
+ }
sc.mu.Lock()
+ defer sc.mu.Unlock()
delete(sc.limits, window)
- sc.mu.Unlock()
- return sc.save()
+ return sc.saveLocked()
}
// GetLimits returns a copy of the current limits.
func (sc *SpendControl) GetLimits() SpendLimits {
sc.mu.Lock()
defer sc.mu.Unlock()
- out := make(SpendLimits, len(sc.limits))
- for k, v := range sc.limits {
- out[k] = v
- }
- return out
+ return cloneState(persistedState{Limits: sc.limits}).Limits
}
-// Check evaluates whether a request costing estimatedCost USD is allowed.
+// Check evaluates a cost against committed spend and all pending reservations.
+// Use Reserve before dispatching a request to make admission atomic.
func (sc *SpendControl) Check(estimatedCost float64) CheckResult {
sc.mu.Lock()
defer sc.mu.Unlock()
+ return sc.checkLocked(estimatedCost, time.Now())
+}
- now := time.Now()
-
- // Per-request check
- if limit, ok := sc.limits[WindowPerRequest]; ok {
- if estimatedCost > limit {
- return CheckResult{
- Allowed: false,
- BlockedBy: WindowPerRequest,
- Remaining: limit,
- Reason: fmt.Sprintf("request cost $%.4f exceeds per-request limit $%.4f", estimatedCost, limit),
- }
- }
+// Reserve atomically checks and holds a request's estimated maximum cost.
+// Pending reservations count in every cumulative window until Commit or Release,
+// even if a request remains in flight longer than a rolling window.
+func (sc *SpendControl) Reserve(cost float64) (uint64, CheckResult) {
+ sc.mu.Lock()
+ defer sc.mu.Unlock()
+ result := sc.checkLocked(cost, time.Now())
+ if !result.Allowed {
+ return 0, result
}
-
- // Session check
- if limit, ok := sc.limits[WindowSession]; ok {
- remaining := limit - sc.sessionSpent
- if estimatedCost > remaining {
- return CheckResult{
- Allowed: false,
- BlockedBy: WindowSession,
- Remaining: remaining,
- Reason: fmt.Sprintf("session spend $%.4f + $%.4f would exceed limit $%.4f", sc.sessionSpent, estimatedCost, limit),
- }
- }
+ if sc.nextID == ^uint64(0) {
+ return 0, CheckResult{Reason: "spend reservation identifiers exhausted"}
}
+ sc.nextID++
+ sc.reservations[sc.nextID] = cost
+ return sc.nextID, result
+}
- // Rolling window checks (hourly, daily)
- for _, w := range []SpendWindow{WindowHourly, WindowDaily} {
+func (sc *SpendControl) checkLocked(cost float64, now time.Time) CheckResult {
+ if !validAmount(cost) {
+ return CheckResult{Reason: "request cost must be finite and nonnegative"}
+ }
+ if sc.storageErr != nil {
+ return CheckResult{Reason: "spending state could not be persisted"}
+ }
+ pending := sc.pendingLocked()
+ if !validAmount(sc.sessionSpent + pending + cost) {
+ return CheckResult{Reason: "spending total exceeds supported range"}
+ }
+ remaining := math.Inf(1)
+ for _, w := range []SpendWindow{WindowPerRequest, WindowSession, WindowHourly, WindowDaily} {
limit, ok := sc.limits[w]
if !ok {
continue
}
- d := windowDuration(w)
- cutoff := now.Add(-d)
- var spent float64
- for _, r := range sc.history {
- if r.Timestamp.After(cutoff) {
- spent += r.Amount
- }
- }
- remaining := limit - spent
- if estimatedCost > remaining {
- resetIn := sc.oldestInWindow(cutoff).Add(d).Sub(now)
- return CheckResult{
- Allowed: false,
+ spent := sc.spentLocked(w, now, pending)
+ available := math.Max(0, limit-spent)
+ if !validAmount(spent) || cost > limit-spent {
+ result := CheckResult{
BlockedBy: w,
- Remaining: remaining,
- Reason: fmt.Sprintf("%s spend $%.4f + $%.4f would exceed limit $%.4f", w, spent, estimatedCost, limit),
- ResetIn: formatDuration(resetIn),
+ Remaining: available,
+ Reason: fmt.Sprintf("%s spend $%.4f + $%.4f would exceed limit $%.4f", w, spent, cost, limit),
}
- }
- }
-
- // Compute smallest remaining across all active limits
- remaining := -1.0
- for w, limit := range sc.limits {
- var r float64
- switch w {
- case WindowPerRequest:
- r = limit
- case WindowSession:
- r = limit - sc.sessionSpent
- default:
- d := windowDuration(w)
- cutoff := now.Add(-d)
- var spent float64
- for _, rec := range sc.history {
- if rec.Timestamp.After(cutoff) {
- spent += rec.Amount
+ if d := windowDuration(w); d > 0 {
+ if oldest, ok := sc.oldestInWindow(now.Add(-d)); ok {
+ result.ResetIn = formatDuration(oldest.Add(d).Sub(now))
}
}
- r = limit - spent
- }
- if remaining < 0 || r < remaining {
- remaining = r
+ return result
}
+ remaining = math.Min(remaining, available)
}
- if remaining < 0 {
+ if math.IsInf(remaining, 1) {
remaining = 0
}
-
return CheckResult{Allowed: true, Remaining: remaining}
}
-// Record logs a completed spend and updates the session totals.
+// Commit replaces a reservation with actual spend exactly once. Actual spend is
+// recorded even when it exceeds the estimate; subsequent admission sees it.
+// Invalid amounts retain the reservation. A persistence failure retains the
+// recorded spend in memory and blocks admission until a later successful save.
+func (sc *SpendControl) Commit(id uint64, amount float64, model, action string) error {
+ sc.mu.Lock()
+ defer sc.mu.Unlock()
+ if _, ok := sc.reservations[id]; !ok {
+ return fmt.Errorf("spendcontrol: unknown reservation")
+ }
+ if !validAmount(amount) || !validAmount(sc.sessionSpent+amount) {
+ return fmt.Errorf("spendcontrol: invalid spending amount")
+ }
+ delete(sc.reservations, id)
+ sc.recordLocked(amount, model, action)
+ return sc.saveLocked()
+}
+
+// Release cancels a reservation when it is known that no spend occurred.
+// Releasing an already settled or unknown reservation has no effect.
+func (sc *SpendControl) Release(id uint64) {
+ sc.mu.Lock()
+ defer sc.mu.Unlock()
+ delete(sc.reservations, id)
+}
+
+// Record logs completed spend without an admission check. New request paths
+// should use Reserve and Commit to avoid check-then-record concurrency races.
func (sc *SpendControl) Record(amount float64, model, action string) error {
sc.mu.Lock()
+ defer sc.mu.Unlock()
+ if !validAmount(amount) || !validAmount(sc.sessionSpent+amount) {
+ return fmt.Errorf("spendcontrol: invalid spending amount")
+ }
+ sc.recordLocked(amount, model, action)
+ return sc.saveLocked()
+}
+
+func (sc *SpendControl) recordLocked(amount float64, model, action string) {
sc.history = append(sc.history, SpendRecord{
- Timestamp: time.Now(),
- Amount: amount,
- Model: model,
- Action: action,
+ Timestamp: time.Now(), Amount: amount, Model: model, Action: action,
})
sc.sessionSpent += amount
sc.sessionCalls++
- sc.mu.Unlock()
- return sc.save()
}
-// GetSpending returns total spent in each active window.
+func (sc *SpendControl) pendingLocked() float64 {
+ var pending float64
+ for _, amount := range sc.reservations {
+ pending += amount
+ }
+ return pending
+}
+
+func (sc *SpendControl) spentLocked(window SpendWindow, now time.Time, pending float64) float64 {
+ switch window {
+ case WindowPerRequest:
+ return 0
+ case WindowSession:
+ return sc.sessionSpent + pending
+ default:
+ spent := pending
+ cutoff := now.Add(-windowDuration(window))
+ for _, record := range sc.history {
+ if record.Timestamp.After(cutoff) {
+ spent += record.Amount
+ }
+ }
+ return spent
+ }
+}
+
+// GetSpending returns committed and reserved spend in each active window.
func (sc *SpendControl) GetSpending() map[SpendWindow]float64 {
sc.mu.Lock()
defer sc.mu.Unlock()
- now := time.Now()
+ now, pending := time.Now(), sc.pendingLocked()
out := make(map[SpendWindow]float64)
- for w := range sc.limits {
- switch w {
- case WindowPerRequest:
- // not cumulative
- case WindowSession:
- out[w] = sc.sessionSpent
- default:
- d := windowDuration(w)
- cutoff := now.Add(-d)
- var spent float64
- for _, r := range sc.history {
- if r.Timestamp.After(cutoff) {
- spent += r.Amount
- }
- }
- out[w] = spent
+ for window := range sc.limits {
+ if window != WindowPerRequest {
+ out[window] = sc.spentLocked(window, now, pending)
}
}
return out
}
-// GetRemaining returns remaining budget in each active window.
+// GetRemaining returns budget available after committed and reserved spend.
func (sc *SpendControl) GetRemaining() map[SpendWindow]float64 {
sc.mu.Lock()
defer sc.mu.Unlock()
- now := time.Now()
+ now, pending := time.Now(), sc.pendingLocked()
out := make(map[SpendWindow]float64)
- for w, limit := range sc.limits {
- switch w {
- case WindowPerRequest:
- out[w] = limit
- case WindowSession:
- out[w] = limit - sc.sessionSpent
- default:
- d := windowDuration(w)
- cutoff := now.Add(-d)
- var spent float64
- for _, r := range sc.history {
- if r.Timestamp.After(cutoff) {
- spent += r.Amount
- }
- }
- out[w] = limit - spent
- }
+ for window, limit := range sc.limits {
+ out[window] = limit - sc.spentLocked(window, now, pending)
}
return out
}
@@ -269,24 +287,20 @@ type StatusEntry struct {
Remaining float64 `json:"remaining"`
}
-// GetStatus returns a combined view of limits, spending, and remaining.
+// GetStatus returns one consistent snapshot of limits, spend, and remaining.
func (sc *SpendControl) GetStatus() map[SpendWindow]StatusEntry {
- spending := sc.GetSpending()
- remaining := sc.GetRemaining()
- limits := sc.GetLimits()
-
- out := make(map[SpendWindow]StatusEntry, len(limits))
- for w, limit := range limits {
- out[w] = StatusEntry{
- Limit: limit,
- Spent: spending[w],
- Remaining: remaining[w],
- }
+ sc.mu.Lock()
+ defer sc.mu.Unlock()
+ now, pending := time.Now(), sc.pendingLocked()
+ out := make(map[SpendWindow]StatusEntry, len(sc.limits))
+ for window, limit := range sc.limits {
+ spent := sc.spentLocked(window, now, pending)
+ out[window] = StatusEntry{Limit: limit, Spent: spent, Remaining: limit - spent}
}
return out
}
-// GetHistory returns a copy of all spending records.
+// GetHistory returns a copy of all completed spending records.
func (sc *SpendControl) GetHistory() []SpendRecord {
sc.mu.Lock()
defer sc.mu.Unlock()
@@ -295,47 +309,58 @@ func (sc *SpendControl) GetHistory() []SpendRecord {
return out
}
-// Cleanup prunes records older than 24 hours.
+// Cleanup prunes completed records older than 24 hours. Pending reservations and
+// the current session's totals remain intact.
func (sc *SpendControl) Cleanup() error {
sc.mu.Lock()
+ defer sc.mu.Unlock()
cutoff := time.Now().Add(-24 * time.Hour)
- kept := sc.history[:0]
- for _, r := range sc.history {
- if r.Timestamp.After(cutoff) {
- kept = append(kept, r)
+ kept := make([]SpendRecord, 0, len(sc.history))
+ for _, record := range sc.history {
+ if record.Timestamp.After(cutoff) {
+ kept = append(kept, record)
}
}
sc.history = kept
- sc.mu.Unlock()
- return sc.save()
+ return sc.saveLocked()
}
-// oldestInWindow returns the oldest record timestamp within the window,
-// or now if none found. Caller must hold mu.
-func (sc *SpendControl) oldestInWindow(cutoff time.Time) time.Time {
- oldest := time.Now()
- for _, r := range sc.history {
- if r.Timestamp.After(cutoff) && r.Timestamp.Before(oldest) {
- oldest = r.Timestamp
+func (sc *SpendControl) oldestInWindow(cutoff time.Time) (time.Time, bool) {
+ var oldest time.Time
+ for _, record := range sc.history {
+ if record.Timestamp.After(cutoff) && (oldest.IsZero() || record.Timestamp.Before(oldest)) {
+ oldest = record.Timestamp
}
}
- return oldest
+ return oldest, !oldest.IsZero()
}
-// persistedState is the JSON shape for file storage.
+// persistedState intentionally excludes process-local sessions and reservations.
type persistedState struct {
Limits SpendLimits `json:"limits"`
History []SpendRecord `json:"history"`
}
-func (sc *SpendControl) save() error {
+func cloneState(state persistedState) persistedState {
+ cp := persistedState{Limits: make(SpendLimits, len(state.Limits))}
+ for window, amount := range state.Limits {
+ cp.Limits[window] = amount
+ }
+ cp.History = append([]SpendRecord(nil), state.History...)
+ return cp
+}
+
+// saveLocked serializes saves with mutations, preventing an older snapshot from
+// overwriting a newer one. Storage receives a detached snapshot.
+func (sc *SpendControl) saveLocked() error {
if sc.storage == nil {
return nil
}
- sc.mu.Lock()
- state := persistedState{Limits: sc.limits, History: sc.history}
- sc.mu.Unlock()
- return sc.storage.Save(state)
+ sc.storageErr = sc.storage.Save(cloneState(persistedState{Limits: sc.limits, History: sc.history}))
+ if sc.storageErr != nil {
+ return fmt.Errorf("spendcontrol: save: %w", sc.storageErr)
+ }
+ return nil
}
func (sc *SpendControl) load() error {
@@ -343,23 +368,31 @@ func (sc *SpendControl) load() error {
return nil
}
state, err := sc.storage.Load()
- if err != nil {
+ if err != nil || state == nil {
return err
}
- if state != nil {
- if state.Limits != nil {
- sc.limits = state.Limits
+ for window, limit := range state.Limits {
+ if !validWindow(window) || !validAmount(limit) {
+ return fmt.Errorf("invalid persisted spending limit")
+ }
+ }
+ var total float64
+ for _, record := range state.History {
+ if !validAmount(record.Amount) || record.Timestamp.IsZero() {
+ return fmt.Errorf("invalid persisted spending record")
}
- if state.History != nil {
- sc.history = state.History
+ total += record.Amount
+ if !validAmount(total) {
+ return fmt.Errorf("persisted spending total exceeds supported range")
}
}
+ cp := cloneState(*state)
+ sc.limits, sc.history = cp.Limits, cp.History
return nil
}
-// ---------- Storage interface ----------
-
-// SpendControlStorage persists spending state.
+// SpendControlStorage persists spending state. Implementations must not call back
+// into the controller while saving; Save executes inside the controller's lock.
type SpendControlStorage interface {
Save(state persistedState) error
Load() (*persistedState, error)
@@ -381,16 +414,32 @@ func NewFileStorage() *FileSpendControlStorage {
return &FileSpendControlStorage{Path: DefaultFilePath()}
}
+// Save writes a complete temporary file before replacing the previous state.
func (fs *FileSpendControlStorage) Save(state persistedState) error {
+ data, err := json.MarshalIndent(state, "", " ")
+ if err != nil {
+ return err
+ }
dir := filepath.Dir(fs.Path)
- if err := os.MkdirAll(dir, 0o755); err != nil {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
- data, err := json.MarshalIndent(state, "", " ")
+ file, err := os.CreateTemp(dir, ".spending-*.tmp")
if err != nil {
return err
}
- return os.WriteFile(fs.Path, data, 0o644)
+ defer os.Remove(file.Name())
+ if _, err = file.Write(data); err == nil {
+ err = file.Sync()
+ }
+ closeErr := file.Close()
+ if err != nil {
+ return err
+ }
+ if closeErr != nil {
+ return closeErr
+ }
+ return os.Rename(file.Name(), fs.Path)
}
func (fs *FileSpendControlStorage) Load() (*persistedState, error) {
@@ -401,14 +450,43 @@ func (fs *FileSpendControlStorage) Load() (*persistedState, error) {
}
return nil, err
}
- var state persistedState
- if err := json.Unmarshal(data, &state); err != nil {
+ // Pointer amounts distinguish an explicit zero from a missing or null
+ // value, which could otherwise silently erase previously recorded spend.
+ var input struct {
+ Limits map[SpendWindow]*float64 `json:"limits"`
+ History []struct {
+ Timestamp time.Time `json:"timestamp"`
+ Amount *float64 `json:"amount"`
+ Model string `json:"model"`
+ Action string `json:"action"`
+ } `json:"history"`
+ }
+ if err := json.Unmarshal(data, &input); err != nil {
return nil, err
}
+ if input.Limits == nil {
+ return nil, fmt.Errorf("missing or null persisted spending limits")
+ }
+ state := persistedState{Limits: make(SpendLimits, len(input.Limits))}
+ for window, amount := range input.Limits {
+ if amount == nil {
+ return nil, fmt.Errorf("null persisted spending limit")
+ }
+ state.Limits[window] = *amount
+ }
+ for _, record := range input.History {
+ if record.Amount == nil {
+ return nil, fmt.Errorf("missing or null persisted spending amount")
+ }
+ state.History = append(state.History, SpendRecord{
+ Timestamp: record.Timestamp, Amount: *record.Amount,
+ Model: record.Model, Action: record.Action,
+ })
+ }
return &state, nil
}
-// InMemorySpendControlStorage keeps state in memory only.
+// InMemorySpendControlStorage keeps detached state in memory only.
type InMemorySpendControlStorage struct {
mu sync.Mutex
state *persistedState
@@ -417,7 +495,7 @@ type InMemorySpendControlStorage struct {
func (m *InMemorySpendControlStorage) Save(state persistedState) error {
m.mu.Lock()
defer m.mu.Unlock()
- cp := state
+ cp := cloneState(state)
m.state = &cp
return nil
}
@@ -425,12 +503,13 @@ func (m *InMemorySpendControlStorage) Save(state persistedState) error {
func (m *InMemorySpendControlStorage) Load() (*persistedState, error) {
m.mu.Lock()
defer m.mu.Unlock()
- return m.state, nil
+ if m.state == nil {
+ return nil, nil
+ }
+ cp := cloneState(*m.state)
+ return &cp, nil
}
-// ---------- Helpers ----------
-
-// formatDuration produces a human-readable duration string (e.g. "1h 23m 45s").
func formatDuration(d time.Duration) string {
if d <= 0 {
return "0s"
@@ -438,7 +517,6 @@ func formatDuration(d time.Duration) string {
h := int(d.Hours())
m := int(d.Minutes()) % 60
s := int(d.Seconds()) % 60
-
switch {
case h > 0 && m > 0:
return fmt.Sprintf("%dh %dm %ds", h, m, s)
diff --git a/spendcontrol/spendcontrol_test.go b/spendcontrol/spendcontrol_test.go
new file mode 100644
index 0000000..fada5d4
--- /dev/null
+++ b/spendcontrol/spendcontrol_test.go
@@ -0,0 +1,410 @@
+package spendcontrol
+
+import (
+ "errors"
+ "math"
+ "os"
+ "path/filepath"
+ "sync"
+ "testing"
+ "time"
+)
+
+func newTestControl(t *testing.T, storage SpendControlStorage) *SpendControl {
+ t.Helper()
+ sc, err := New(storage)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return sc
+}
+
+func TestConcurrentReservationsCannotOversubscribe(t *testing.T) {
+ for _, window := range []SpendWindow{WindowSession, WindowHourly, WindowDaily} {
+ t.Run(string(window), func(t *testing.T) {
+ sc := newTestControl(t, nil)
+ if err := sc.SetLimit(window, 1); err != nil {
+ t.Fatal(err)
+ }
+ var wg sync.WaitGroup
+ ids := make(chan uint64, 100)
+ start := make(chan struct{})
+ for i := 0; i < 100; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ if id, result := sc.Reserve(0.25); result.Allowed {
+ ids <- id
+ }
+ }()
+ }
+ close(start)
+ wg.Wait()
+ close(ids)
+ seen := make(map[uint64]bool)
+ for id := range ids {
+ if id == 0 || seen[id] {
+ t.Fatalf("invalid or reused reservation: %d", id)
+ }
+ seen[id] = true
+ }
+ if len(seen) != 4 {
+ t.Fatalf("admitted %d requests, want 4", len(seen))
+ }
+ if result := sc.Check(0.25); result.Allowed || result.BlockedBy != window {
+ t.Fatalf("pending spend did not block: %+v", result)
+ }
+ status := sc.GetStatus()[window]
+ if status.Spent != 1 || status.Remaining != 0 {
+ t.Fatalf("inconsistent pending status: %+v", status)
+ }
+ })
+ }
+}
+
+func TestReservationsSettleExactlyOnce(t *testing.T) {
+ sc := newTestControl(t, nil)
+ if err := sc.SetLimit(WindowDaily, 1); err != nil {
+ t.Fatal(err)
+ }
+ first, firstResult := sc.Reserve(0.75)
+ second, secondResult := sc.Reserve(0.25)
+ if !firstResult.Allowed || !secondResult.Allowed {
+ t.Fatal("initial reservations denied")
+ }
+ if err := sc.Commit(first, 0.5, "test-model", "chat"); err != nil {
+ t.Fatal(err)
+ }
+ sc.Release(second)
+ sc.Release(second)
+ sc.Release(first)
+ if err := sc.Commit(first, 0.5, "test-model", "chat"); err == nil {
+ t.Fatal("duplicate commit accepted")
+ }
+ if err := sc.Commit(second, 0.25, "test-model", "chat"); err == nil {
+ t.Fatal("released reservation committed")
+ }
+ if got := sc.GetSpending()[WindowDaily]; got != 0.5 {
+ t.Fatalf("spent = %v, want 0.5", got)
+ }
+ if got := sc.GetRemaining()[WindowDaily]; got != 0.5 {
+ t.Fatalf("remaining = %v, want 0.5", got)
+ }
+ history := sc.GetHistory()
+ if len(history) != 1 || history[0].Amount != 0.5 || history[0].Model != "test-model" || history[0].Action != "chat" {
+ t.Fatalf("unexpected committed history: %+v", history)
+ }
+}
+
+func TestPendingReservationsNeverAgeOut(t *testing.T) {
+ for _, window := range []SpendWindow{WindowHourly, WindowDaily} {
+ t.Run(string(window), func(t *testing.T) {
+ sc := newTestControl(t, nil)
+ if err := sc.SetLimit(window, 1); err != nil {
+ t.Fatal(err)
+ }
+ id, result := sc.Reserve(0.75)
+ if !result.Allowed {
+ t.Fatal(result.Reason)
+ }
+ if err := sc.Record(0.25, "test", "chat"); err != nil {
+ t.Fatal(err)
+ }
+ sc.mu.Lock()
+ future := time.Now().Add(2 * windowDuration(window))
+ blocked := sc.checkLocked(0.5, future)
+ allowed := sc.checkLocked(0.25, future)
+ sc.history[0].Timestamp = time.Now().Add(-48 * time.Hour)
+ sc.mu.Unlock()
+ if blocked.Allowed || !allowed.Allowed {
+ t.Fatalf("pending spend aged out or history did not expire: blocked=%+v allowed=%+v", blocked, allowed)
+ }
+ if err := sc.Cleanup(); err != nil {
+ t.Fatal(err)
+ }
+ if result := sc.Check(0.5); result.Allowed {
+ t.Fatal("cleanup removed pending reservation")
+ }
+ sc.Release(id)
+ if result := sc.Check(1); !result.Allowed {
+ t.Fatalf("released reservation still blocks: %+v", result)
+ }
+ })
+ }
+}
+
+func TestInvalidAmountsFailClosed(t *testing.T) {
+ sc := newTestControl(t, nil)
+ if err := sc.SetLimit(WindowSession, 1); err != nil {
+ t.Fatal(err)
+ }
+ id, result := sc.Reserve(0.75)
+ if !result.Allowed {
+ t.Fatal(result.Reason)
+ }
+ for _, amount := range []float64{-1, math.NaN(), math.Inf(1), math.Inf(-1)} {
+ if sc.Check(amount).Allowed {
+ t.Fatalf("invalid check accepted: %v", amount)
+ }
+ if nextID, result := sc.Reserve(amount); result.Allowed || nextID != 0 {
+ t.Fatalf("invalid reservation accepted: %v", amount)
+ }
+ if err := sc.Commit(id, amount, "", ""); err == nil {
+ t.Fatalf("invalid commit accepted: %v", amount)
+ }
+ if err := sc.Record(amount, "", ""); err == nil {
+ t.Fatalf("invalid record accepted: %v", amount)
+ }
+ if err := sc.SetLimit(WindowSession, amount); err == nil {
+ t.Fatalf("invalid limit accepted: %v", amount)
+ }
+ }
+ if err := sc.SetLimit(SpendWindow("typo"), 1); err == nil {
+ t.Fatal("unknown spending window accepted")
+ }
+ if len(sc.GetHistory()) != 0 || sc.GetLimits()[WindowSession] != 1 || sc.GetSpending()[WindowSession] != 0.75 {
+ t.Fatal("invalid input mutated valid state")
+ }
+ if err := sc.Commit(id, 0.5, "", ""); err != nil {
+ t.Fatalf("invalid commit destroyed the reservation: %v", err)
+ }
+}
+
+func TestActualSpendAboveEstimateIsRetained(t *testing.T) {
+ sc := newTestControl(t, nil)
+ if err := sc.SetLimit(WindowDaily, 1); err != nil {
+ t.Fatal(err)
+ }
+ id, result := sc.Reserve(0.5)
+ if !result.Allowed {
+ t.Fatal(result.Reason)
+ }
+ if err := sc.Commit(id, 1.5, "", ""); err != nil {
+ t.Fatal(err)
+ }
+ if sc.Check(0).Allowed || sc.GetSpending()[WindowDaily] != 1.5 {
+ t.Fatal("actual overspend was dropped")
+ }
+}
+
+type failingStorage struct {
+ state *persistedState
+ fail bool
+}
+
+func (s *failingStorage) Save(state persistedState) error {
+ if s.fail {
+ return errors.New("injected storage failure")
+ }
+ cp := cloneState(state)
+ s.state = &cp
+ return nil
+}
+
+func (s *failingStorage) Load() (*persistedState, error) { return s.state, nil }
+
+func TestPersistenceFailureRetainsSpendAndBlocksAdmission(t *testing.T) {
+ storage := &failingStorage{}
+ sc := newTestControl(t, storage)
+ if err := sc.SetLimit(WindowDaily, 1); err != nil {
+ t.Fatal(err)
+ }
+ id, _ := sc.Reserve(0.75)
+ storage.fail = true
+ if err := sc.Commit(id, 0.5, "test", "chat"); err == nil {
+ t.Fatal("commit did not report persistence failure")
+ }
+ if got := sc.GetSpending()[WindowDaily]; got != 0.5 {
+ t.Fatalf("lost completed spend on failed save: %v", got)
+ }
+ if sc.Check(0).Allowed {
+ t.Fatal("admission remained open after persistence failure")
+ }
+ if id, result := sc.Reserve(0); id != 0 || result.Allowed {
+ t.Fatal("reservation accepted after persistence failure")
+ }
+ storage.fail = false
+ if err := sc.Cleanup(); err != nil {
+ t.Fatal(err)
+ }
+ if !sc.Check(0.5).Allowed || sc.Check(0.75).Allowed {
+ t.Fatal("successful save did not recover the correct remaining budget")
+ }
+ if len(storage.state.History) != 1 || storage.state.History[0].Amount != 0.5 {
+ t.Fatal("recovered storage lost committed spend")
+ }
+}
+
+func TestMemoryStorageDoesNotShareSnapshots(t *testing.T) {
+ storage := &InMemorySpendControlStorage{}
+ original := persistedState{
+ Limits: SpendLimits{WindowDaily: 1},
+ History: []SpendRecord{{Timestamp: time.Now(), Amount: 0.25}},
+ }
+ if err := storage.Save(original); err != nil {
+ t.Fatal(err)
+ }
+ original.Limits[WindowDaily] = 100
+ original.History[0].Amount = 100
+ loaded, err := storage.Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loaded.Limits[WindowDaily] != 1 || loaded.History[0].Amount != 0.25 {
+ t.Fatal("saved state aliases caller memory")
+ }
+ loaded.Limits[WindowDaily] = 200
+ loaded.History[0].Amount = 200
+ again, err := storage.Load()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if again.Limits[WindowDaily] != 1 || again.History[0].Amount != 0.25 {
+ t.Fatal("loaded state aliases stored memory")
+ }
+}
+
+// Retaining every passed snapshot detects sharing with later controller writes.
+type snapshotStorage struct {
+ snapshots []persistedState
+}
+
+func (s *snapshotStorage) Save(state persistedState) error {
+ s.snapshots = append(s.snapshots, state)
+ return nil
+}
+func (s *snapshotStorage) Load() (*persistedState, error) { return nil, nil }
+
+func TestConcurrentMutationsPersistDetachedOrderedSnapshots(t *testing.T) {
+ storage := &snapshotStorage{}
+ sc := newTestControl(t, storage)
+ if err := sc.SetLimit(WindowDaily, 1); err != nil {
+ t.Fatal(err)
+ }
+ var wg sync.WaitGroup
+ for i := 0; i < 40; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ if err := sc.Record(0.25, "test", "chat"); err != nil {
+ t.Error(err)
+ }
+ status := sc.GetStatus()[WindowDaily]
+ if status.Limit-status.Spent != status.Remaining {
+ t.Errorf("inconsistent status: %+v", status)
+ }
+ }()
+ }
+ wg.Wait()
+ if err := sc.SetLimit(WindowDaily, 20); err != nil {
+ t.Fatal(err)
+ }
+ if len(storage.snapshots) != 42 || storage.snapshots[0].Limits[WindowDaily] != 1 {
+ t.Fatal("limit snapshots were lost or mutated")
+ }
+ for index := 1; index <= 40; index++ {
+ if len(storage.snapshots[index].History) != index {
+ t.Fatalf("out-of-order snapshot %d has %d records", index, len(storage.snapshots[index].History))
+ }
+ }
+ sc.mu.Lock()
+ sc.history[0].Amount = 100
+ sc.mu.Unlock()
+ if storage.snapshots[1].History[0].Amount != 0.25 {
+ t.Fatal("history snapshots alias controller memory")
+ }
+}
+
+func TestRestartResetsSessionButPreservesRollingSpend(t *testing.T) {
+ storage := &InMemorySpendControlStorage{}
+ first := newTestControl(t, storage)
+ for _, window := range []SpendWindow{WindowSession, WindowHourly, WindowDaily} {
+ if err := first.SetLimit(window, 1); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := first.Record(0.75, "test", "chat"); err != nil {
+ t.Fatal(err)
+ }
+ second := newTestControl(t, storage)
+ spending := second.GetSpending()
+ if spending[WindowSession] != 0 || spending[WindowHourly] != 0.75 || spending[WindowDaily] != 0.75 {
+ t.Fatalf("unexpected restart totals: %+v", spending)
+ }
+}
+
+func TestFileStorageRejectsCorruptionAndPreservesLastGoodWrite(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "spending.json")
+ storage := &FileSpendControlStorage{Path: path}
+ sc := newTestControl(t, storage)
+ if err := sc.SetLimit(WindowDaily, 1); err != nil {
+ t.Fatal(err)
+ }
+ if err := sc.Record(0.25, "test", "chat"); err != nil {
+ t.Fatal(err)
+ }
+ before, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := storage.Save(persistedState{Limits: SpendLimits{WindowDaily: math.NaN()}}); err == nil {
+ t.Fatal("invalid JSON amount saved")
+ }
+ after, err := os.ReadFile(path)
+ if err != nil || string(before) != string(after) {
+ t.Fatal("failed save changed the previous state")
+ }
+ if restarted := newTestControl(t, storage); restarted.GetSpending()[WindowDaily] != 0.25 {
+ t.Fatal("file round trip lost spending")
+ }
+ for _, invalid := range []string{
+ `{`,
+ `null`,
+ `{}`,
+ `{"limits":{"daily":-1},"history":[]}`,
+ `{"limits":{"typo":1},"history":[]}`,
+ `{"limits":null,"history":[]}`,
+ `{"limits":{"daily":null},"history":[]}`,
+ `{"limits":{"daily":1},"history":[{"amount":0.5}]}`,
+ `{"limits":{"daily":1},"history":[{"timestamp":"2026-01-01T00:00:00Z","amount":null}]}`,
+ `{"limits":{"daily":1},"history":[{"timestamp":"2026-01-01T00:00:00Z","amount":-1}]}`,
+ } {
+ if err := os.WriteFile(path, []byte(invalid), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := New(storage); err == nil {
+ t.Errorf("corrupt persisted state accepted: %s", invalid)
+ }
+ }
+}
+
+func TestPerRequestLimitDoesNotCombineSeparateReservations(t *testing.T) {
+ sc := newTestControl(t, nil)
+ if err := sc.SetLimit(WindowPerRequest, 0.5); err != nil {
+ t.Fatal(err)
+ }
+ for i := 0; i < 2; i++ {
+ if _, result := sc.Reserve(0.5); !result.Allowed {
+ t.Fatalf("per-request cap incorrectly accumulated reservations: %+v", result)
+ }
+ }
+ if _, result := sc.Reserve(0.75); result.Allowed || result.BlockedBy != WindowPerRequest {
+ t.Fatalf("per-request limit not enforced: %+v", result)
+ }
+}
+
+func TestReservationTotalOverflowFailsClosed(t *testing.T) {
+ sc := newTestControl(t, nil)
+ id, result := sc.Reserve(math.MaxFloat64)
+ if !result.Allowed {
+ t.Fatal(result.Reason)
+ }
+ if _, result := sc.Reserve(math.MaxFloat64); result.Allowed {
+ t.Fatal("overflowing reservation total accepted")
+ }
+ sc.Release(id)
+ if _, result := sc.Reserve(0); !result.Allowed {
+ t.Fatal("zero-cost reservation should remain valid")
+ }
+}
diff --git a/stats/stats.go b/stats/stats.go
index e18d807..b880934 100644
--- a/stats/stats.go
+++ b/stats/stats.go
@@ -72,26 +72,45 @@ type logEntry struct {
func parseLogFile(filePath string) []logEntry {
data, err := os.ReadFile(filePath)
- if err != nil { return nil }
+ if err != nil {
+ return nil
+ }
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
entries := make([]logEntry, 0, len(lines))
for _, line := range lines {
- if line == "" { continue }
+ if line == "" {
+ continue
+ }
var e logEntry
- if err := json.Unmarshal([]byte(line), &e); err != nil { continue }
- if e.Timestamp == "" { e.Timestamp = time.Now().Format(time.RFC3339) }
- if e.Model == "" { e.Model = "unknown" }
- if e.Tier == "" { e.Tier = "UNKNOWN" }
- if e.BaselineCost == 0 { e.BaselineCost = e.Cost }
+ if err := json.Unmarshal([]byte(line), &e); err != nil {
+ continue
+ }
+ if e.Timestamp == "" {
+ e.Timestamp = time.Now().Format(time.RFC3339)
+ }
+ if e.Model == "" {
+ e.Model = "unknown"
+ }
+ if e.Tier == "" {
+ e.Tier = "UNKNOWN"
+ }
+ if e.BaselineCost == 0 {
+ e.BaselineCost = e.Cost
+ }
entries = append(entries, e)
}
return entries
}
func getLogFiles() []string {
- dir := logger.LogDir()
+ return getLogFilesAt(logger.LogDir())
+}
+
+func getLogFilesAt(dir string) []string {
dirEntries, err := os.ReadDir(dir)
- if err != nil { return nil }
+ if err != nil {
+ return nil
+ }
var files []string
for _, de := range dirEntries {
name := de.Name()
@@ -109,14 +128,22 @@ func aggregateDay(date string, entries []logEntry) DayStats {
var totalLatency int64
var totalCost, totalBaselineCost float64
for _, e := range entries {
- ts := byTier[e.Tier]; ts.Count++; ts.Cost += e.Cost; byTier[e.Tier] = ts
- ms := byModel[e.Model]; ms.Count++; ms.Cost += e.Cost; byModel[e.Model] = ms
+ ts := byTier[e.Tier]
+ ts.Count++
+ ts.Cost += e.Cost
+ byTier[e.Tier] = ts
+ ms := byModel[e.Model]
+ ms.Count++
+ ms.Cost += e.Cost
+ byModel[e.Model] = ms
totalLatency += e.LatencyMs
totalCost += e.Cost
totalBaselineCost += e.BaselineCost
}
avgLat := 0.0
- if len(entries) > 0 { avgLat = float64(totalLatency) / float64(len(entries)) }
+ if len(entries) > 0 {
+ avgLat = float64(totalLatency) / float64(len(entries))
+ }
return DayStats{
Date: date, TotalRequests: len(entries), TotalCost: totalCost,
TotalBaselineCost: totalBaselineCost, TotalSavings: totalBaselineCost - totalCost,
@@ -126,10 +153,20 @@ func aggregateDay(date string, entries []logEntry) DayStats {
// GetStats reads log files and returns aggregated statistics for the given number of days.
func GetStats(days int) AggregatedStats {
- if days <= 0 { days = 7 }
- logFiles := getLogFiles()
- if len(logFiles) > days { logFiles = logFiles[:days] }
- dir := logger.LogDir()
+ return getStats(days, logger.LogDir())
+}
+
+func getStats(days int, dir string) AggregatedStats {
+ if days <= 0 {
+ days = 7
+ }
+ if days > 30 {
+ days = 30
+ }
+ logFiles := getLogFilesAt(dir)
+ if len(logFiles) > days {
+ logFiles = logFiles[:days]
+ }
var dailyBreakdown []DayStats
allByTier := make(map[string]TierStats)
allByModel := make(map[string]ModelStats)
@@ -138,7 +175,9 @@ func GetStats(days int) AggregatedStats {
for _, file := range logFiles {
date := strings.TrimSuffix(strings.TrimPrefix(file, "usage-"), ".jsonl")
entries := parseLogFile(filepath.Join(dir, file))
- if len(entries) == 0 { continue }
+ if len(entries) == 0 {
+ continue
+ }
day := aggregateDay(date, entries)
dailyBreakdown = append(dailyBreakdown, day)
totalRequests += day.TotalRequests
@@ -146,23 +185,35 @@ func GetStats(days int) AggregatedStats {
totalBaselineCost += day.TotalBaselineCost
totalLatency += day.AvgLatencyMs * float64(day.TotalRequests)
for tier, ts := range day.ByTier {
- a := allByTier[tier]; a.Count += ts.Count; a.Cost += ts.Cost; allByTier[tier] = a
+ a := allByTier[tier]
+ a.Count += ts.Count
+ a.Cost += ts.Cost
+ allByTier[tier] = a
}
for model, ms := range day.ByModel {
- a := allByModel[model]; a.Count += ms.Count; a.Cost += ms.Cost; allByModel[model] = a
+ a := allByModel[model]
+ a.Count += ms.Count
+ a.Cost += ms.Cost
+ allByModel[model] = a
}
}
for k, v := range allByTier {
- if totalRequests > 0 { v.Percentage = float64(v.Count) / float64(totalRequests) * 100 }
+ if totalRequests > 0 {
+ v.Percentage = float64(v.Count) / float64(totalRequests) * 100
+ }
allByTier[k] = v
}
for k, v := range allByModel {
- if totalRequests > 0 { v.Percentage = float64(v.Count) / float64(totalRequests) * 100 }
+ if totalRequests > 0 {
+ v.Percentage = float64(v.Count) / float64(totalRequests) * 100
+ }
allByModel[k] = v
}
totalSavings := totalBaselineCost - totalCost
savingsPct := 0.0
- if totalBaselineCost > 0 { savingsPct = totalSavings / totalBaselineCost * 100 }
+ if totalBaselineCost > 0 {
+ savingsPct = totalSavings / totalBaselineCost * 100
+ }
avgLatency, avgCost := 0.0, 0.0
if totalRequests > 0 {
avgLatency = totalLatency / float64(totalRequests)
@@ -170,14 +221,18 @@ func GetStats(days int) AggregatedStats {
}
var entriesWithBaseline int
for _, day := range dailyBreakdown {
- if day.TotalBaselineCost != day.TotalCost { entriesWithBaseline += day.TotalRequests }
+ if day.TotalBaselineCost != day.TotalCost {
+ entriesWithBaseline += day.TotalRequests
+ }
}
// Reverse so oldest first.
for i, j := 0, len(dailyBreakdown)-1; i < j; i, j = i+1, j-1 {
dailyBreakdown[i], dailyBreakdown[j] = dailyBreakdown[j], dailyBreakdown[i]
}
period := "today"
- if days != 1 { period = fmt.Sprintf("last %d days", days) }
+ if days != 1 {
+ period = fmt.Sprintf("last %d days", days)
+ }
return AggregatedStats{
Period: period, TotalRequests: totalRequests, TotalCost: totalCost,
TotalBaselineCost: totalBaselineCost, TotalSavings: totalSavings,
@@ -218,7 +273,9 @@ func FormatStatsASCII(s AggregatedStats) string {
for _, model := range sortedKeysByCount(s.ByModel) {
ms := s.ByModel[model]
name := model
- if len(name) > 25 { name = name[:22] + "..." }
+ if len(name) > 25 {
+ name = name[:22] + "..."
+ }
b.WriteString(fmt.Sprintf("| %-25s %4d $%7.4f %5.1f%%|\n", name, ms.Count, ms.Cost, ms.Percentage))
}
b.WriteString(topBot + "\n")
@@ -228,18 +285,28 @@ func FormatStatsASCII(s AggregatedStats) string {
// FormatRecentLogs renders individual log entries as a per-request table.
func FormatRecentLogs(days, limit int) string {
- if days <= 0 { days = 1 }
- if limit <= 0 { limit = 20 }
+ if days <= 0 {
+ days = 1
+ }
+ if limit <= 0 {
+ limit = 20
+ }
logFiles := getLogFiles()
- if len(logFiles) > days { logFiles = logFiles[:days] }
+ if len(logFiles) > days {
+ logFiles = logFiles[:days]
+ }
dir := logger.LogDir()
var all []logEntry
for _, file := range logFiles {
all = append(all, parseLogFile(filepath.Join(dir, file))...)
}
sort.Slice(all, func(i, j int) bool { return all[i].Timestamp > all[j].Timestamp })
- if len(all) > limit { all = all[:limit] }
- if len(all) == 0 { return "No recent logs found.\n" }
+ if len(all) > limit {
+ all = all[:limit]
+ }
+ if len(all) == 0 {
+ return "No recent logs found.\n"
+ }
var b strings.Builder
b.WriteString(fmt.Sprintf("Recent Requests (last %d):\n", len(all)))
b.WriteString("+-----+----------------------+----------+----------+--------+\n")
@@ -247,7 +314,9 @@ func FormatRecentLogs(days, limit int) string {
b.WriteString("+-----+----------------------+----------+----------+--------+\n")
for i, e := range all {
model := e.Model
- if len(model) > 20 { model = model[:17] + "..." }
+ if len(model) > 20 {
+ model = model[:17] + "..."
+ }
b.WriteString(fmt.Sprintf("| %3d | %-20s | %-8s | $%7.5f | %6d |\n", i+1, model, e.Tier, e.Cost, e.LatencyMs))
}
b.WriteString("+-----+----------------------+----------+----------+--------+\n")
@@ -259,7 +328,9 @@ func ClearStats() error {
dir := logger.LogDir()
entries, err := os.ReadDir(dir)
if err != nil {
- if os.IsNotExist(err) { return nil }
+ if os.IsNotExist(err) {
+ return nil
+ }
return err
}
for _, de := range entries {
@@ -272,27 +343,37 @@ func ClearStats() error {
}
func pad(n int) string {
- if n <= 0 { return "" }
+ if n <= 0 {
+ return ""
+ }
return strings.Repeat(" ", n)
}
func makeBar(pct float64, maxWidth int) string {
filled := int(math.Round(pct / 100.0 * float64(maxWidth)))
- if filled < 0 { filled = 0 }
- if filled > maxWidth { filled = maxWidth }
+ if filled < 0 {
+ filled = 0
+ }
+ if filled > maxWidth {
+ filled = maxWidth
+ }
return strings.Repeat("#", filled) + strings.Repeat(".", maxWidth-filled)
}
func sortedKeys(m map[string]TierStats) []string {
keys := make([]string, 0, len(m))
- for k := range m { keys = append(keys, k) }
+ for k := range m {
+ keys = append(keys, k)
+ }
sort.Strings(keys)
return keys
}
func sortedKeysByCount(m map[string]ModelStats) []string {
keys := make([]string, 0, len(m))
- for k := range m { keys = append(keys, k) }
+ for k := range m {
+ keys = append(keys, k)
+ }
sort.Slice(keys, func(i, j int) bool { return m[keys[i]].Count > m[keys[j]].Count })
return keys
}
diff --git a/stats/stats_test.go b/stats/stats_test.go
new file mode 100644
index 0000000..f1a20b6
--- /dev/null
+++ b/stats/stats_test.go
@@ -0,0 +1,60 @@
+package stats
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestGetStatsBoundsEmptyPeriods(t *testing.T) {
+ dir := t.TempDir()
+ tests := []struct {
+ name string
+ days int
+ period string
+ }{
+ {"negative defaults to week", -5, "last 7 days"},
+ {"zero defaults to week", 0, "last 7 days"},
+ {"one day", 1, "today"},
+ {"month", 30, "last 30 days"},
+ {"over a month", 31, "last 30 days"},
+ {"maximum integer", int(^uint(0) >> 1), "last 30 days"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := getStats(tt.days, dir)
+ if got.Period != tt.period {
+ t.Errorf("Period = %q, want %q", got.Period, tt.period)
+ }
+ if got.TotalRequests != 0 || got.TotalCost != 0 || len(got.DailyBreakdown) != 0 {
+ t.Fatalf("empty log directory produced usage: %+v", got)
+ }
+ })
+ }
+}
+
+func TestGetStatsReadsAtMostThirtyNewestLogFiles(t *testing.T) {
+ dir := t.TempDir()
+ first := time.Date(2026, time.August, 1, 0, 0, 0, 0, time.UTC)
+ for i := 0; i < 35; i++ {
+ date := first.AddDate(0, 0, i).Format("2006-01-02")
+ entry := `{"timestamp":"` + date + `T12:00:00Z","model":"test/model","tier":"SIMPLE","cost":0.25,"baselineCost":1,"latencyMs":100}` + "\n"
+ if err := os.WriteFile(filepath.Join(dir, "usage-"+date+".jsonl"), []byte(entry), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := os.WriteFile(filepath.Join(dir, "unrelated.jsonl"), []byte("invalid log"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ got := getStats(365, dir)
+ if got.Period != "last 30 days" || got.TotalRequests != 30 || len(got.DailyBreakdown) != 30 {
+ t.Fatalf("GetStats exceeded the 30-file window: period=%q requests=%d days=%d", got.Period, got.TotalRequests, len(got.DailyBreakdown))
+ }
+ if got.DailyBreakdown[0].Date != first.AddDate(0, 0, 5).Format("2006-01-02") || got.DailyBreakdown[29].Date != first.AddDate(0, 0, 34).Format("2006-01-02") {
+ t.Errorf("unexpected oldest/newest dates: %q / %q", got.DailyBreakdown[0].Date, got.DailyBreakdown[29].Date)
+ }
+ if got.TotalCost != 7.5 || got.TotalSavings != 22.5 || got.AvgLatencyMs != 100 {
+ t.Errorf("aggregates included data outside the selected window: cost=%v savings=%v latency=%v", got.TotalCost, got.TotalSavings, got.AvgLatencyMs)
+ }
+}
From 77f41cd4a3a23570982f98146d2e7d16a5f29b95 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 12 Sep 2026 12:27:08 +0700
Subject: [PATCH 2/8] fix: retain unique IDs and tool declarations during
recovery
---
proxy/proxy.go | 4 ++--
proxy/request.go | 30 ++++++++++++++++++++++++++++--
proxy/spending_test.go | 12 ++++++++++++
3 files changed, 42 insertions(+), 4 deletions(-)
diff --git a/proxy/proxy.go b/proxy/proxy.go
index 132075c..fe8fcd7 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -772,10 +772,10 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
// If model output formatted tool calls in plain text content, recover them.
if msg, ok := choice["message"].(map[string]interface{}); ok {
tc, _ := msg["tool_calls"].([]interface{})
- if len(tc) == 0 {
+ if len(tc) == 0 && strings.TrimSpace(string(req.Extra["tool_choice"])) != `"none"` {
contentStr, _ := msg["content"].(string)
contentStr = stripThinking(contentStr)
- if recovered, cleaned := recoverToolCallsWithProse(contentStr); len(recovered) > 0 && len(req.Tools) > 0 {
+ if recovered, cleaned := recoverToolCallsWithProse(contentStr, req.Tools); len(recovered) > 0 && len(req.Tools) > 0 {
msg["content"] = cleaned
recList := make([]interface{}, len(recovered))
for idx, r := range recovered {
diff --git a/proxy/request.go b/proxy/request.go
index dc37752..9ffc937 100644
--- a/proxy/request.go
+++ b/proxy/request.go
@@ -2,6 +2,7 @@ package proxy
import (
"encoding/json"
+ "fmt"
"net/url"
"os"
"strings"
@@ -84,14 +85,39 @@ func forwardToolCallProse() bool {
}
// Remove only syntax which actually recovered into a tool call.
-func recoverToolCallsWithProse(content string) ([]map[string]interface{}, string) {
+func recoverToolCallsWithProse(content string, tools json.RawMessage) ([]map[string]interface{}, string) {
+ var definitions []struct {
+ Type string `json:"type"`
+ Function struct {
+ Name string `json:"name"`
+ } `json:"function"`
+ }
+ if json.Unmarshal(tools, &definitions) != nil {
+ return nil, content
+ }
+ allowed := make(map[string]bool)
+ for _, def := range definitions {
+ if def.Type == "function" && def.Function.Name != "" {
+ allowed[def.Function.Name] = true
+ }
+ }
var calls []map[string]interface{}
clean := func(match string) string {
found := recoverStructuredToolCalls(match, "")
if len(found) == 0 {
return match
}
- calls = append(calls, found...)
+ for _, call := range found {
+ function, _ := call["function"].(map[string]interface{})
+ name, _ := function["name"].(string)
+ if !allowed[name] {
+ return match
+ }
+ }
+ for _, call := range found {
+ call["id"] = fmt.Sprintf("call_recovered_%d", len(calls))
+ calls = append(calls, call)
+ }
return ""
}
cleaned := jsonCodeBlockRegex.ReplaceAllStringFunc(content, clean)
diff --git a/proxy/spending_test.go b/proxy/spending_test.go
index 6f68028..6452ec2 100644
--- a/proxy/spending_test.go
+++ b/proxy/spending_test.go
@@ -41,3 +41,15 @@ func TestCallerCredentialsDoNotShareResponseCache(t *testing.T) {
t.Fatalf("upstream calls=%d", calls.Load())
}
}
+
+func TestRecoveredCallsHaveUniqueIDsAndDeclaredNames(t *testing.T) {
+ tools := []byte(`[{"type":"function","function":{"name":"read_file"}}]`)
+ input := "First. call:read_file({}) Then. call:read_file({}) call:undeclared({})"
+ calls, prose := recoverToolCallsWithProse(input, tools)
+ if len(calls) != 2 || calls[0]["id"] == calls[1]["id"] {
+ t.Fatalf("calls=%v", calls)
+ }
+ if !strings.Contains(prose, "call:undeclared({})") || strings.Contains(prose, "call:read_file") {
+ t.Fatalf("prose=%q", prose)
+ }
+}
From d6e633b28ba029520fafa44a2964493fee485a7e Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 12 Sep 2026 12:29:40 +0700
Subject: [PATCH 3/8] fix: address review of optional tools and gateway origin
---
internal/requestkey/normalize.go | 8 ++++----
proxy/proxy.go | 4 ++--
proxy/request.go | 7 ++++++-
proxy/spending_test.go | 20 ++++++++++++++++++++
4 files changed, 32 insertions(+), 7 deletions(-)
diff --git a/internal/requestkey/normalize.go b/internal/requestkey/normalize.go
index dce178f..fbc4728 100644
--- a/internal/requestkey/normalize.go
+++ b/internal/requestkey/normalize.go
@@ -64,12 +64,12 @@ func content(value any) any {
if !ok {
continue
}
- copy := make(map[string]any, len(obj))
+ blockCopy := make(map[string]any, len(obj))
for key, value := range obj {
- copy[key] = value
+ blockCopy[key] = value
}
- copy["text"] = timestamp.ReplaceAllString(text, "")
- out[i] = copy
+ blockCopy["text"] = timestamp.ReplaceAllString(text, "")
+ out[i] = blockCopy
break
}
return out
diff --git a/proxy/proxy.go b/proxy/proxy.go
index fe8fcd7..3682593 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -320,7 +320,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
Config: s.routingConfig,
ModelPricing: s.modelPricing,
RoutingProfile: routingProfile,
- HasTools: len(req.Tools) > 0,
+ HasTools: requestHasTools(req.Tools),
})
if err != nil {
http.Error(w, "Routing error: "+err.Error(), http.StatusInternalServerError)
@@ -775,7 +775,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if len(tc) == 0 && strings.TrimSpace(string(req.Extra["tool_choice"])) != `"none"` {
contentStr, _ := msg["content"].(string)
contentStr = stripThinking(contentStr)
- if recovered, cleaned := recoverToolCallsWithProse(contentStr, req.Tools); len(recovered) > 0 && len(req.Tools) > 0 {
+ if recovered, cleaned := recoverToolCallsWithProse(contentStr, req.Tools); len(recovered) > 0 && requestHasTools(req.Tools) {
msg["content"] = cleaned
recList := make([]interface{}, len(recovered))
for idx, r := range recovered {
diff --git a/proxy/request.go b/proxy/request.go
index 9ffc937..67bf0a2 100644
--- a/proxy/request.go
+++ b/proxy/request.go
@@ -127,7 +127,7 @@ func recoverToolCallsWithProse(content string, tools json.RawMessage) ([]map[str
func gatewayOrigin(base string) string {
u, err := url.Parse(base)
- if err != nil || u.Host == "" {
+ if err != nil || u.Host == "" || u.Scheme == "" {
return ""
}
return u.Scheme + "://" + u.Host
@@ -140,3 +140,8 @@ func (s *Server) writeUsage(entry logger.UsageEntry) {
}
logger.LogUsage(entry)
}
+
+func requestHasTools(raw json.RawMessage) bool {
+ var tools []json.RawMessage
+ return json.Unmarshal(raw, &tools) == nil && len(tools) > 0
+}
diff --git a/proxy/spending_test.go b/proxy/spending_test.go
index 6452ec2..a34f471 100644
--- a/proxy/spending_test.go
+++ b/proxy/spending_test.go
@@ -53,3 +53,23 @@ func TestRecoveredCallsHaveUniqueIDsAndDeclaredNames(t *testing.T) {
t.Fatalf("prose=%q", prose)
}
}
+
+func TestToolAndGatewayEdgeCases(t *testing.T) {
+ for _, raw := range []string{"null", " null ", "[]", "", `{"name":"not-an-array"}`} {
+ if requestHasTools([]byte(raw)) {
+ t.Errorf("has tools for %q", raw)
+ }
+ calls, _ := recoverToolCallsWithProse("call:read_file({})", []byte(raw))
+ if len(calls) != 0 {
+ t.Errorf("recovered undeclared calls for %q", raw)
+ }
+ }
+ for _, input := range []string{"//localhost:8080", "localhost", ""} {
+ if origin := gatewayOrigin(input); origin != "" {
+ t.Errorf("origin(%q)=%q", input, origin)
+ }
+ }
+ if got := gatewayOrigin("https://example.com/v1?not=public"); got != "https://example.com" {
+ t.Fatalf("origin=%q", got)
+ }
+}
From c6c9902095cd484164b512a2b4ac72134770fb11 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 12 Sep 2026 12:31:18 +0700
Subject: [PATCH 4/8] fix: preserve wrapped default HTTP transports
---
proxy/proxy.go | 6 +++++-
proxy/spending_test.go | 13 +++++++++++++
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/proxy/proxy.go b/proxy/proxy.go
index 3682593..1b55b63 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -97,6 +97,10 @@ func New(cfg Config) *Server {
rc = *cfg.RoutingConfig
}
+ transport := http.DefaultTransport
+ if standard, ok := transport.(*http.Transport); ok {
+ transport = standard.Clone()
+ }
sc := cfg.SpendControl
var spendErr error
if sc == nil {
@@ -108,7 +112,7 @@ func New(cfg Config) *Server {
modelPricing: models.BuildPricingMap(),
httpClient: &http.Client{
Timeout: 5 * time.Minute,
- Transport: http.DefaultTransport.(*http.Transport).Clone(),
+ Transport: transport,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
},
dedup: dedup.New(),
diff --git a/proxy/spending_test.go b/proxy/spending_test.go
index a34f471..7ec2927 100644
--- a/proxy/spending_test.go
+++ b/proxy/spending_test.go
@@ -73,3 +73,16 @@ func TestToolAndGatewayEdgeCases(t *testing.T) {
t.Fatalf("origin=%q", got)
}
}
+
+type testWrappedTransport struct{ http.RoundTripper }
+
+func TestNewAcceptsWrappedDefaultTransport(t *testing.T) {
+ original := http.DefaultTransport
+ wrapper := &testWrappedTransport{original}
+ http.DefaultTransport = wrapper
+ t.Cleanup(func() { http.DefaultTransport = original })
+ srv, _ := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) { syncTestOK(w) }, nil)
+ if srv.httpClient.Transport != wrapper {
+ t.Fatal("wrapped transport was not preserved")
+ }
+}
From 6dd5f97cae19cf456b5031568b8e50ef3f3557e1 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 12 Sep 2026 12:43:39 +0700
Subject: [PATCH 5/8] fix: address upstream sync accounting and cache review
---
.github/workflows/ci.yml | 4 +-
.github/workflows/integration.yml | 2 +-
UPSTREAM_SYNC.md | 8 +-
cache/cache.go | 10 +--
cache/requestkey_test.go | 45 +++++++++-
dedup/dedup.go | 8 +-
dedup/requestkey_test.go | 45 +++++++++-
docs/configuration.md | 5 +-
internal/requestkey/normalize.go | 76 ----------------
internal/requestkey/normalize_test.go | 112 -----------------------
models/catalog_test.go | 62 +++++++++++++
models/models.go | 24 ++---
proxy/prose.go | 13 ++-
proxy/proxy.go | 37 +++++---
proxy/request.go | 9 ++
proxy/spending.go | 5 +-
proxy/spending_review_test.go | 124 ++++++++++++++++++++++++++
proxy/spending_test.go | 63 +++++++++++++
spendcontrol/spendcontrol.go | 2 +-
19 files changed, 407 insertions(+), 247 deletions(-)
delete mode 100644 internal/requestkey/normalize.go
delete mode 100644 internal/requestkey/normalize_test.go
create mode 100644 proxy/spending_review_test.go
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 44fd318..9c1c927 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- - uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4.2.2
- - uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index b2de60d..5c21d92 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- - uses: actions/setup-go@v5
+ - uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
diff --git a/UPSTREAM_SYNC.md b/UPSTREAM_SYNC.md
index 5da0b3c..5846b59 100644
--- a/UPSTREAM_SYNC.md
+++ b/UPSTREAM_SYNC.md
@@ -43,12 +43,12 @@ update, not a claim that every BlockRun product feature is implemented.
| --- | --- |
| v0.12.248 assistant/tool prose | Preserve assistant prose with native calls and text-recovered calls; strip tagged thinking, including split SSE tags. `DOSROUTER_TOOL_CALL_PROSE=off` restores legacy suppression. Recover syntax only when tools are supplied. |
| v0.12.252 tool-pair safety | Preserve `tool_calls`, `tool_call_id`, names and all provider extension fields during request rewriting. Avoid compressing protocol-bearing or multimodal messages. DOSRouter has no upstream-style message truncation path. |
-| v0.12.254-256 cancellation/cache | Keep Go request contexts through chat/image requests, stop fallback after disconnect, reject incomplete bodies, normalize injected first-text timestamps without merging distinct user data. |
+| v0.12.254-256 cancellation/cache | Keep Go request contexts through chat/image requests, stop fallback after disconnect, reject incomplete bodies, preserve caller-controlled timestamp content in cache keys; normalize JSON object key order without conflating arrays. |
| v0.12.257-278 models/routing | Align chat catalog metadata and all four profile chains with router-core `5ee7c23c993013a8052588191569db5cf7fb793c`; retain DOS aliases and exact explicit pins. Retire dead free defaults, fix capability claims and prices. |
| v0.12.263/269/274 spend safety | Atomic in-flight reservations for direct and routed chat, including fallback attempts; pending spend counts in rolling/session caps. Persist snapshots serially with atomic file replacement. Invalid state/cost fails closed. Each Server uses one controller; embedded callers may inject a shared controller explicitly. |
-| v0.12.267 ambiguous sends | Do not repeat a chat send or switch models after an ambiguous transport failure. Explicit retryable HTTP rejection statuses retain backoff/retry. DOSRouter does not yet sign x402 payments. |
+| v0.12.267 ambiguous sends | Do not repeat a chat send or switch models after an ambiguous transport failure. Each reservation authorizes one HTTP send. Status retries are disabled; model fallback obtains a separate reservation and ambiguous server errors retain their estimate. DOSRouter does not yet sign x402 payments. |
| v0.12.271-275 accounting/health | Prefer settled gateway cost headers, otherwise actual token usage, then explicitly labelled estimates. Capture gateway request IDs in usage logs; report the configured gateway origin in health. Image cost reads headers/body. Reject unknown-priced images when amount limits are configured. |
-| v0.12.272 credential transport | Refuse upstream redirects, avoid shared internal caching across caller-supplied bearer credentials, and mark authenticated responses `no-store`. |
+| v0.12.272 credential transport | Refuse upstream redirects, avoid shared internal caching across caller-supplied bearer credentials (including a configured upstream key), and mark authenticated responses `no-store`. |
| Stats day windows | Go already defaulted nonpositive windows safely; cap aggregate reporting to 30 days and test using isolated log directories. |
| Validation adaptation | Replace stale TypeScript/npm CI and missing Docker scanner targets with Go build, vet, race tests and govulncheck. Preserve job/workflow names and automatic CodeQL; prefer patched Go 1.26.6 via the toolchain directive. Dependabot follows Go modules and Actions. |
@@ -58,6 +58,8 @@ metadata reflects upstream source, not independently probed DOS providers.
Gemini 3.6/3.8 Flash's $0.75/$3.75 promotional rates end on 2027-01-01,
when upstream documents $1.50/$7.50; automated repricing is not implemented.
+**Intentional divergence:** The upstream timestamp-stripping optimization is not enabled: a standalone server cannot distinguish injected prefixes from client-authored content. Cache/dedup keys preserve both string and first text-block timestamps until trusted injection provenance exists.
+
**Already satisfied:** `/v1/models` lists active catalog entries; chat and image
requests derive their context from the client; full health performs no balance
RPC; nonpositive stats/log windows have safe defaults.
diff --git a/cache/cache.go b/cache/cache.go
index d94553d..97ac39f 100644
--- a/cache/cache.go
+++ b/cache/cache.go
@@ -1,7 +1,7 @@
// Package cache provides a TTL + LRU response cache for LLM completions.
// Cache keys are derived from canonicalized request JSON, skipping
-// non-deterministic fields (stream, user, request_id) and stripping
-// timestamp prefixes from message content.
+// non-deterministic fields (stream, user, request_id) while preserving
+// all message content, including client-supplied timestamps.
package cache
import (
@@ -12,8 +12,6 @@ import (
"fmt"
"sync"
"time"
-
- "github.com/DOS/DOSRouter/internal/requestkey"
)
const (
@@ -270,7 +268,7 @@ func (c *Cache) removeLocked(elem *list.Element) {
}
// CacheKey returns a hex-encoded SHA-256 hash of the canonicalized request
-// JSON, omitting non-deterministic fields and stripping timestamps.
+// JSON, omitting non-deterministic fields while preserving all content.
func CacheKey(body []byte) (string, error) {
var raw map[string]interface{}
if err := json.Unmarshal(body, &raw); err != nil {
@@ -282,7 +280,7 @@ func CacheKey(body []byte) (string, error) {
delete(raw, f)
}
- canonical := canonicalize(requestkey.Normalize(raw))
+ canonical := canonicalize(raw)
encoded, err := json.Marshal(canonical)
if err != nil {
return "", fmt.Errorf("cache: marshal error: %w", err)
diff --git a/cache/requestkey_test.go b/cache/requestkey_test.go
index 46f5d2e..a99ba5e 100644
--- a/cache/requestkey_test.go
+++ b/cache/requestkey_test.go
@@ -8,23 +8,24 @@ import (
const multimodalRequest = `{"model":"test/model","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.test/a.png","detail":"high"}},{"type":"text","text":"[Sat 2026-09-12 09:00 ICT] Describe this image"},{"type":"text","text":"[Sat 2026-09-12 10:00 ICT] Event"}]},{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"record_event","arguments":"[Sat 2026-09-12 11:00 ICT] Event"}}]}]}`
-func TestCacheReusesMultimodalResponseAcrossInjectedTimestamps(t *testing.T) {
+func TestCacheSeparatesMultimodalResponsesByClientTimestamp(t *testing.T) {
first := []byte(multimodalRequest)
original := bytes.Clone(first)
second := []byte(strings.Replace(multimodalRequest, "09:00", "09:30", 1))
c := New()
c.Set(first, Entry{Body: []byte("cached answer"), StatusCode: 200})
got, ok := c.Get(second, false)
- if !ok || string(got.Body) != "cached answer" {
- t.Fatalf("timestamp-only change missed cached response: hit=%v, body=%q", ok, got.Body)
+ if ok {
+ t.Fatalf("different client timestamp reused cached response: hit=%v, body=%q", ok, got.Body)
}
if !bytes.Equal(first, original) {
- t.Error("cache key normalization mutated request bytes")
+ t.Error("cache key generation mutated request bytes")
}
}
func TestCacheKeyPreservesRequestSemantics(t *testing.T) {
tests := []struct{ name, before, after string }{
+ {"first text timestamp", "09:00", "09:30"},
{"later text timestamp", "10:00", "10:30"},
{"tool arguments", "11:00", "11:30"},
{"image URL", "a.png", "b.png"},
@@ -76,3 +77,39 @@ func TestCacheKeyIgnoresObjectKeyOrder(t *testing.T) {
t.Fatal("equivalent JSON objects had different cache keys")
}
}
+
+func TestCacheKeyPreservesTimestampContent(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ }{
+ {"user string", `{"messages":[{"role":"user","content":"[Sat 2026-09-12 09:00 ICT] Event"}]}`},
+ {"system string", `{"messages":[{"role":"system","content":"[Sat 2026-09-12 09:00 ICT] Event"}]}`},
+ {"assistant string", `{"messages":[{"role":"assistant","content":"[Sat 2026-09-12 09:00 ICT] Event"}]}`},
+ {"first text block", `{"messages":[{"role":"user","content":[{"type":"text","text":"[Sat 2026-09-12 09:00 ICT] Event"}]}]}`},
+ {"tool result", `{"messages":[{"role":"tool","tool_call_id":"call_1","content":"[Sat 2026-09-12 09:00 ICT] Event"}]}`},
+ {"function result", `{"messages":[{"role":"function","name":"record_event","content":"[Sat 2026-09-12 09:00 ICT] Event"}]}`},
+ {"metadata", `{"metadata":{"content":"[Sat 2026-09-12 09:00 ICT] Event"},"messages":[]}`},
+ {"object tool arguments", `{"messages":[{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"record_event","arguments":{"content":"[Sat 2026-09-12 09:00 ICT] Event"}}}]}]}`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ original, err := CacheKey([]byte(tt.body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, changed := range []string{
+ strings.ReplaceAll(tt.body, "09:00", "09:30"),
+ strings.ReplaceAll(tt.body, "[Sat 2026-09-12 09:00 ICT] ", ""),
+ } {
+ key, err := CacheKey([]byte(changed))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if original == key {
+ t.Error("different client timestamp content shared a cache key")
+ }
+ }
+ })
+ }
+}
diff --git a/dedup/dedup.go b/dedup/dedup.go
index 9583a24..55bc1be 100644
--- a/dedup/dedup.go
+++ b/dedup/dedup.go
@@ -10,8 +10,6 @@ import (
"fmt"
"sync"
"time"
-
- "github.com/DOS/DOSRouter/internal/requestkey"
)
const (
@@ -157,14 +155,14 @@ func (d *Deduplicator) Len() int {
}
// HashBody returns a hex-encoded SHA-256 hash of the canonicalized JSON body.
-// Keys are sorted recursively and timestamp prefixes are stripped from string
-// values.
+// Object keys are sorted recursively while preserving all content and JSON
+// value types, including client-supplied timestamps.
func HashBody(body []byte) (string, error) {
var raw interface{}
if err := json.Unmarshal(body, &raw); err != nil {
return "", fmt.Errorf("dedup: invalid JSON body: %w", err)
}
- canonical := canonicalize(requestkey.Normalize(raw))
+ canonical := canonicalize(raw)
encoded, err := json.Marshal(canonical)
if err != nil {
return "", fmt.Errorf("dedup: marshal error: %w", err)
diff --git a/dedup/requestkey_test.go b/dedup/requestkey_test.go
index 15c8130..ce4d36c 100644
--- a/dedup/requestkey_test.go
+++ b/dedup/requestkey_test.go
@@ -8,7 +8,7 @@ import (
const multimodalRequest = `{"model":"test/model","messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.test/a.png","detail":"high"}},{"type":"text","text":"[Sat 2026-09-12 09:00 ICT] Describe this image"},{"type":"text","text":"[Sat 2026-09-12 10:00 ICT] Event"}]},{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"record_event","arguments":"[Sat 2026-09-12 11:00 ICT] Event"}}]}]}`
-func TestDedupReusesMultimodalResponseAcrossInjectedTimestamps(t *testing.T) {
+func TestDedupSeparatesMultimodalResponsesByClientTimestamp(t *testing.T) {
first := []byte(multimodalRequest)
original := bytes.Clone(first)
second := []byte(strings.Replace(multimodalRequest, "09:00", "09:30", 1))
@@ -22,16 +22,17 @@ func TestDedupReusesMultimodalResponseAcrossInjectedTimestamps(t *testing.T) {
t.Fatalf("first request: hit=%v err=%v", hit, err)
}
got, hit, err := d.Do(second, call)
- if err != nil || !hit || calls != 1 || string(got.Body) != "upstream answer" {
- t.Fatalf("timestamp-only retry: hit=%v calls=%d body=%q err=%v", hit, calls, got.Body, err)
+ if err != nil || hit || calls != 2 || string(got.Body) != "upstream answer" {
+ t.Fatalf("different client timestamp: hit=%v calls=%d body=%q err=%v", hit, calls, got.Body, err)
}
if !bytes.Equal(first, original) {
- t.Error("dedup normalization mutated request bytes")
+ t.Error("dedup key generation mutated request bytes")
}
}
func TestHashBodyPreservesRequestSemantics(t *testing.T) {
tests := []struct{ name, before, after string }{
+ {"first text timestamp", "09:00", "09:30"},
{"later text timestamp", "10:00", "10:30"},
{"tool arguments", "11:00", "11:30"},
{"image URL", "a.png", "b.png"},
@@ -83,3 +84,39 @@ func TestHashBodyIgnoresObjectKeyOrder(t *testing.T) {
t.Fatal("equivalent JSON objects had different dedup keys")
}
}
+
+func TestHashBodyPreservesTimestampContent(t *testing.T) {
+ tests := []struct {
+ name string
+ body string
+ }{
+ {"user string", `{"messages":[{"role":"user","content":"[Sat 2026-09-12 09:00 ICT] Event"}]}`},
+ {"system string", `{"messages":[{"role":"system","content":"[Sat 2026-09-12 09:00 ICT] Event"}]}`},
+ {"assistant string", `{"messages":[{"role":"assistant","content":"[Sat 2026-09-12 09:00 ICT] Event"}]}`},
+ {"first text block", `{"messages":[{"role":"user","content":[{"type":"text","text":"[Sat 2026-09-12 09:00 ICT] Event"}]}]}`},
+ {"tool result", `{"messages":[{"role":"tool","tool_call_id":"call_1","content":"[Sat 2026-09-12 09:00 ICT] Event"}]}`},
+ {"function result", `{"messages":[{"role":"function","name":"record_event","content":"[Sat 2026-09-12 09:00 ICT] Event"}]}`},
+ {"metadata", `{"metadata":{"content":"[Sat 2026-09-12 09:00 ICT] Event"},"messages":[]}`},
+ {"object tool arguments", `{"messages":[{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"record_event","arguments":{"content":"[Sat 2026-09-12 09:00 ICT] Event"}}}]}]}`},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ original, err := HashBody([]byte(tt.body))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, changed := range []string{
+ strings.ReplaceAll(tt.body, "09:00", "09:30"),
+ strings.ReplaceAll(tt.body, "[Sat 2026-09-12 09:00 ICT] ", ""),
+ } {
+ key, err := HashBody([]byte(changed))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if original == key {
+ t.Error("different client timestamp content shared a dedup key")
+ }
+ }
+ })
+ }
+}
diff --git a/docs/configuration.md b/docs/configuration.md
index 2636abf..ff7b6e3 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -252,8 +252,9 @@ state refuses paid dispatch rather than silently clearing limits. Direct and
routed chat requests reserve estimated cost before dispatch, then record
settled gateway headers or token-based cost when available. Concurrent pending
reservations count against session, hourly and daily limits. Explicit upstream
-rejections release a reservation; ambiguous transport failures conservatively
-consume its estimate and are not retried. Unknown-priced models and image
+client rejections release a reservation unless a settled charge is reported;
+server/transport failures conservatively consume their estimate. Each
+reservation permits one HTTP send; model fallbacks reserve separately. Unknown-priced models and image
requests are refused when limits are configured. This is a local estimate-based
control, not a provider-side USD guarantee or multi-process ledger.
diff --git a/internal/requestkey/normalize.go b/internal/requestkey/normalize.go
deleted file mode 100644
index fbc4728..0000000
--- a/internal/requestkey/normalize.go
+++ /dev/null
@@ -1,76 +0,0 @@
-// Package requestkey normalizes only timestamps injected into message content.
-package requestkey
-
-import (
- "maps"
- "regexp"
-)
-
-var timestamp = regexp.MustCompile(`^\[\w{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+\w+\]\s*`)
-
-// Normalize removes an injected prefix from root request messages' string
-// content or first text block. Tool results, metadata, tool arguments and later
-// text blocks are preserved, and the input request is never mutated.
-func Normalize(value any) any {
- request, ok := value.(map[string]any)
- if !ok {
- return value
- }
- messages, ok := request["messages"].([]any)
- if !ok {
- return value
- }
-
- out := maps.Clone(request)
- normalized := make([]any, len(messages))
- for i, item := range messages {
- normalized[i] = item
- message, ok := item.(map[string]any)
- if !ok {
- continue
- }
- role, _ := message["role"].(string)
- if role == "tool" || role == "function" {
- continue
- }
- messageContent, exists := message["content"]
- if !exists {
- continue
- }
- messageCopy := maps.Clone(message)
- messageCopy["content"] = content(messageContent)
- normalized[i] = messageCopy
- }
- out["messages"] = normalized
- return out
-}
-
-func content(value any) any {
- if text, ok := value.(string); ok {
- return timestamp.ReplaceAllString(text, "")
- }
- blocks, ok := value.([]any)
- if !ok {
- return value
- }
- out := make([]any, len(blocks))
- copy(out, blocks)
- for i, block := range blocks {
- obj, ok := block.(map[string]any)
- if !ok || obj["type"] != "text" {
- continue
- }
- text, ok := obj["text"].(string)
- if !ok {
- continue
- }
- blockCopy := make(map[string]any, len(obj))
- for key, value := range obj {
- blockCopy[key] = value
- }
- blockCopy["text"] = timestamp.ReplaceAllString(text, "")
- out[i] = blockCopy
- break
- }
- return out
-}
diff --git a/internal/requestkey/normalize_test.go b/internal/requestkey/normalize_test.go
deleted file mode 100644
index ccf6abe..0000000
--- a/internal/requestkey/normalize_test.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package requestkey
-
-import (
- "encoding/json"
- "reflect"
- "testing"
-)
-
-func TestNormalizeInjectedMessageTimestamps(t *testing.T) {
- tests := []struct {
- name string
- input string
- want string
- }{
- {
- name: "string message",
- input: `{"messages":[{"role":"user","content":"[Sat 2026-09-12 09:00 ICT] Hello"}]}`,
- want: `{"messages":[{"role":"user","content":"Hello"}]}`,
- },
- {
- name: "first text after image",
- input: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.test/image.png","detail":"high"}},{"type":"text","text":"[Sat 2026-09-12 09:00 ICT] Describe this image"},{"type":"text","text":"[Sat 2026-09-12 10:00 ICT] The event happened then"}]}]}`,
- want: `{"messages":[{"role":"user","content":[{"type":"image_url","image_url":{"url":"https://example.test/image.png","detail":"high"}},{"type":"text","text":"Describe this image"},{"type":"text","text":"[Sat 2026-09-12 10:00 ICT] The event happened then"}]}]}`,
- },
- {
- name: "later text is never stripped",
- input: `{"messages":[{"content":[{"type":"text","text":"Keep the quoted timestamp"},{"type":"text","text":"[Sat 2026-09-12 10:00 ICT] Event"}]}]}`,
- want: `{"messages":[{"content":[{"type":"text","text":"Keep the quoted timestamp"},{"type":"text","text":"[Sat 2026-09-12 10:00 ICT] Event"}]}]}`,
- },
- {
- name: "tool arguments are preserved",
- input: `{"messages":[{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"record_event","arguments":"[Sat 2026-09-12 10:00 ICT] Event"}}]}]}`,
- want: `{"messages":[{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"record_event","arguments":"[Sat 2026-09-12 10:00 ICT] Event"}}]}]}`,
- },
- {
- name: "embedded timestamp is preserved",
- input: `{"messages":[{"content":"Quote [Sat 2026-09-12 10:00 ICT] Event"}]}`,
- want: `{"messages":[{"content":"Quote [Sat 2026-09-12 10:00 ICT] Event"}]}`,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- var input, original, want any
- for _, value := range []struct {
- text string
- target *any
- }{
- {tt.input, &input}, {tt.input, &original}, {tt.want, &want},
- } {
- if err := json.Unmarshal([]byte(value.text), value.target); err != nil {
- t.Fatal(err)
- }
- }
- got := Normalize(input)
- if !reflect.DeepEqual(got, want) {
- t.Errorf("Normalize() = %#v, want %#v", got, want)
- }
- if !reflect.DeepEqual(input, original) {
- t.Error("Normalize mutated its input")
- }
- })
- }
-}
-
-func TestNormalizePreservesNonMessageContent(t *testing.T) {
- tests := []struct {
- name string
- input string
- }{
- {"root content", `{"content":"[Sat 2026-09-12 09:00 ICT] Root data"}`},
- {"metadata content", `{"metadata":{"content":"[Sat 2026-09-12 09:00 ICT] Event"},"messages":[{"role":"user","content":"Hello"}]}`},
- {"nested message history", `{"metadata":{"messages":[{"role":"user","content":"[Sat 2026-09-12 09:00 ICT] Historical event"}]},"messages":[]}`},
- {"message metadata", `{"messages":[{"role":"user","content":"Hello","metadata":{"content":"[Sat 2026-09-12 09:00 ICT] Event"}}]}`},
- {"object tool arguments", `{"messages":[{"role":"assistant","content":null,"tool_calls":[{"type":"function","function":{"name":"record_event","arguments":{"content":"[Sat 2026-09-12 09:00 ICT] Event"}}}]}]}`},
- {"tool result string", `{"messages":[{"role":"tool","tool_call_id":"call_1","content":"[Sat 2026-09-12 09:00 ICT] Observed event"}]}`},
- {"tool result text block", `{"messages":[{"role":"tool","tool_call_id":"call_1","content":[{"type":"text","text":"[Sat 2026-09-12 09:00 ICT] Observed event"}]}]}`},
- {"legacy function result", `{"messages":[{"role":"function","name":"record_event","content":"[Sat 2026-09-12 09:00 ICT] Observed event"}]}`},
- {"non-request array", `[{"role":"user","content":"[Sat 2026-09-12 09:00 ICT] Data"}]`},
- {"empty text block array", `{"messages":[{"role":"user","content":[]}]}`},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- var input, original any
- if err := json.Unmarshal([]byte(tt.input), &input); err != nil {
- t.Fatal(err)
- }
- if err := json.Unmarshal([]byte(tt.input), &original); err != nil {
- t.Fatal(err)
- }
- if got := Normalize(input); !reflect.DeepEqual(got, original) {
- t.Errorf("Normalize changed content outside injected message prefixes: got %#v, want %#v", got, original)
- }
- if !reflect.DeepEqual(input, original) {
- t.Error("Normalize mutated its input")
- }
- })
- }
-}
-
-func TestNormalizePreservesSupportedMessageRoles(t *testing.T) {
- var input any
- if err := json.Unmarshal([]byte(`{"messages":[{"role":"system","content":"[Sat 2026-09-12 09:00 ICT] System"},{"role":"assistant","content":"[Sat 2026-09-12 09:00 ICT] Assistant"},{"role":"user","content":"[Sat 2026-09-12 09:00 ICT] User"}]}`), &input); err != nil {
- t.Fatal(err)
- }
- var want any
- if err := json.Unmarshal([]byte(`{"messages":[{"role":"system","content":"System"},{"role":"assistant","content":"Assistant"},{"role":"user","content":"User"}]}`), &want); err != nil {
- t.Fatal(err)
- }
- if got := Normalize(input); !reflect.DeepEqual(got, want) {
- t.Errorf("injected timestamps on supported roles were not stripped: got %#v, want %#v", got, want)
- }
-}
diff --git a/models/catalog_test.go b/models/catalog_test.go
index 60c5ff4..0bdaf2e 100644
--- a/models/catalog_test.go
+++ b/models/catalog_test.go
@@ -147,3 +147,65 @@ func TestSyncedVisionAndExplicitAliases(t *testing.T) {
}
}
}
+
+func TestRetiredFreeShorthandsResolveActiveSuccessor(t *testing.T) {
+ aliases := []string{
+ "maverick", "mistral-large", "mistral-large-3-675b", "mistral-nemotron",
+ "qwen3-122b", "qwen3-next-80b", "qwen3.5-122b",
+ "seed-oss", "seed-oss-36b", "step-flash", "step-3.7-flash",
+ }
+ for _, alias := range aliases {
+ for _, prefix := range []string{"", "dosrouter/", "blockrun/", "openai/"} {
+ t.Run(prefix+alias, func(t *testing.T) {
+ resolved := ResolveModelAlias(prefix + alias)
+ if resolved != "free/nemotron-3.5-lightning" {
+ t.Fatalf("resolved = %q, want active free successor", resolved)
+ }
+ model := GetModel(resolved)
+ if model == nil || model.Deprecated || model.InputPrice != 0 || model.OutputPrice != 0 {
+ t.Errorf("free shorthand %q resolved to unavailable or paid metadata: %+v", alias, model)
+ }
+ })
+ }
+ }
+ // The proxy resolves aliases once and does not interpret FallbackModel.
+ // A future free shorthand must therefore target an active row immediately.
+ for alias, target := range ModelAliases {
+ if strings.Contains(alias, "/") || !strings.HasPrefix(target, "free/") {
+ continue
+ }
+ model := GetModel(target)
+ if model == nil || model.Deprecated {
+ t.Errorf("free shorthand %q still targets missing/retired model %q", alias, target)
+ }
+ }
+}
+
+func TestRetiredAliasFixPreservesQualifiedAndPaidVersionPins(t *testing.T) {
+ pins := map[string]string{
+ "free/step-3.7-flash": "free/step-3.7-flash",
+ "free/mistral-nemotron": "free/mistral-nemotron",
+ "free/qwen3.5-122b-a10b": "free/qwen3.5-122b-a10b",
+ "nvidia/glm-4.7": "free/glm-4.7",
+ "nvidia/llama-4-maverick": "free/llama-4-maverick",
+ "nvidia/mistral-nemotron": "free/mistral-nemotron",
+ "nvidia/nemotron-nano-12b-v2-vl": "free/nemotron-nano-12b-v2-vl",
+ "nvidia/nemotron-nano-9b-v2": "free/nemotron-nano-9b-v2",
+ "nvidia/qwen3-coder-480b": "free/qwen3-coder-480b",
+ "nvidia/qwen3-next-80b-a3b-instruct": "free/qwen3-next-80b-a3b-instruct",
+ "nvidia/qwen3-next-80b-a3b-thinking": "free/qwen3-next-80b-a3b-instruct",
+ "nvidia/qwen3.5-122b-a10b": "free/qwen3.5-122b-a10b",
+ "nvidia/seed-oss-36b": "free/seed-oss-36b",
+ "nvidia/step-3.7-flash": "free/step-3.7-flash",
+ "qwen/qwen3-coder-480b-a35b-instruct": "free/qwen3-coder-480b",
+ "minimax-m2.5": "minimax/minimax-m2.5",
+ "minimax/minimax-m2.5": "minimax/minimax-m2.5",
+ }
+ for pin, want := range pins {
+ for _, prefix := range []string{"", "dosrouter/", "blockrun/"} {
+ if got := ResolveModelAlias(prefix + pin); got != want {
+ t.Errorf("ResolveModelAlias(%q) = %q, want preserved pin %q", prefix+pin, got, want)
+ }
+ }
+ }
+}
diff --git a/models/models.go b/models/models.go
index 30d75d5..926b218 100644
--- a/models/models.go
+++ b/models/models.go
@@ -38,6 +38,8 @@ type PromoDef struct {
var ModelAliases = map[string]string{
// Upstream chat aliases plus DOS-compatible shorthand targets.
// Bare Claude/Sonnet, o1, Gemini, Flash and paid DeepSeek Pro retain DOS behavior.
+ // Retired free shorthands resolve directly to an active free successor.
+ // Provider-qualified compatibility pins retain their existing targets.
"anthropic/claude": "anthropic/claude-sonnet-5",
"anthropic/claude-fable-5.0": "anthropic/claude-fable-5",
"anthropic/claude-haiku-4": "anthropic/claude-haiku-4.5",
@@ -161,7 +163,7 @@ var ModelAliases = map[string]string{
"llama-free": "free/llama-3.2-11b-vision",
"llama-vision": "free/llama-3.2-11b-vision",
"luna-pro": "openai/gpt-5.6-luna-pro",
- "maverick": "free/llama-4-maverick",
+ "maverick": "free/nemotron-3.5-lightning",
"mimo": "xiaomi/mimo-v2.5-pro",
"mimo-v2.5": "xiaomi/mimo-v2.5",
"mimo-v2.5-pro": "xiaomi/mimo-v2.5-pro",
@@ -172,9 +174,9 @@ var ModelAliases = map[string]string{
"minimax-m2.7": "minimax/minimax-m2.7",
"minimax-m3": "minimax/minimax-m3",
"mistral-free": "free/nemotron-3.5-lightning",
- "mistral-large": "free/mistral-large-3-675b",
- "mistral-large-3-675b": "free/mistral-large-3-675b",
- "mistral-nemotron": "free/mistral-nemotron",
+ "mistral-large": "free/nemotron-3.5-lightning",
+ "mistral-large-3-675b": "free/nemotron-3.5-lightning",
+ "mistral-nemotron": "free/nemotron-3.5-lightning",
"mistral-small": "free/nemotron-3.5-lightning",
"moonshot": "moonshot/kimi-k2.7",
"nano": "openai/gpt-5.4-nano",
@@ -252,20 +254,20 @@ var ModelAliases = map[string]string{
"qwen-thinking": "free/nemotron-3.5-lightning",
"qwen-vision": "qwen/qwen3.8-flash",
"qwen/qwen3-coder-480b-a35b-instruct": "free/qwen3-coder-480b",
- "qwen3-122b": "free/qwen3.5-122b-a10b",
+ "qwen3-122b": "free/nemotron-3.5-lightning",
"qwen3-7-max": "qwen/qwen3.7-max",
"qwen3-8-flash": "qwen/qwen3.8-flash",
"qwen3-next": "free/nemotron-3.5-lightning",
- "qwen3-next-80b": "free/qwen3-next-80b-a3b-instruct",
- "qwen3.5-122b": "free/qwen3.5-122b-a10b",
+ "qwen3-next-80b": "free/nemotron-3.5-lightning",
+ "qwen3.5-122b": "free/nemotron-3.5-lightning",
"qwen3.7-flash": "qwen/qwen3.7-flash",
"qwen3.7-max": "qwen/qwen3.7-max",
"qwen3.7-plus": "qwen/qwen3.7-plus",
"qwen3.8-flash": "qwen/qwen3.8-flash",
"reasoner": "deepseek/deepseek-reasoner",
"router": "auto",
- "seed-oss": "free/seed-oss-36b",
- "seed-oss-36b": "free/seed-oss-36b",
+ "seed-oss": "free/nemotron-3.5-lightning",
+ "seed-oss-36b": "free/nemotron-3.5-lightning",
"sol-pro": "openai/gpt-5.6-sol-pro",
"sonnet": "anthropic/claude-sonnet-5",
"sonnet-4": "anthropic/claude-sonnet-4.6",
@@ -276,8 +278,8 @@ var ModelAliases = map[string]string{
"sonnet-5": "anthropic/claude-sonnet-5",
"sonnet-5-0": "anthropic/claude-sonnet-5",
"sonnet-5.0": "anthropic/claude-sonnet-5",
- "step-3.7-flash": "free/step-3.7-flash",
- "step-flash": "free/step-3.7-flash",
+ "step-3.7-flash": "free/nemotron-3.5-lightning",
+ "step-flash": "free/nemotron-3.5-lightning",
"tencent": "tencent/hy3",
"terra-pro": "openai/gpt-5.6-terra-pro",
"ultra-550b": "free/nemotron-3-ultra-550b",
diff --git a/proxy/prose.go b/proxy/prose.go
index a857ebe..3f61b6b 100644
--- a/proxy/prose.go
+++ b/proxy/prose.go
@@ -45,12 +45,17 @@ func (f *proseFilter) filter(text string, final bool) string {
if match := thinkingTag.FindStringSubmatch(tag); match != nil {
f.hidden = match[1] != "/"
} else if strings.HasPrefix(tag, "<|") || strings.HasPrefix(tag, "<|") {
- lower := strings.ToLower(tag)
- if strings.Contains(lower, "begin") {
+ token := strings.ToLower(strings.Trim(tag, "<>||"))
+ token = strings.ReplaceAll(token, "▁", "_")
+ switch token {
+ case "begin_of_thinking", "thinking":
f.hidden = true
- }
- if strings.Contains(lower, "end") {
+ case "end_of_thinking":
f.hidden = false
+ default:
+ if !f.hidden {
+ out.WriteString(tag)
+ }
}
} else if !f.hidden {
out.WriteString(tag)
diff --git a/proxy/proxy.go b/proxy/proxy.go
index 1b55b63..bbc1fae 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -258,7 +258,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
// Caller-specific credentials must never share an internal response cache.
- cacheAllowed := s.config.UpstreamAPIKey != "" || r.Header.Get("Authorization") == ""
+ cacheAllowed := r.Header.Get("Authorization") == ""
// --- Response cache check (non-streaming only) ---
if !req.Stream && cacheAllowed {
if entry, ok := s.cache.Get(body, false); ok {
@@ -465,6 +465,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ spend = currentSpend
// Per-model timeout (upstream v0.12.182): reasoning models get 3min for
// cold-start first-token (DeepSeek V4 Pro / Claude opus thinking / GPT-5
// reasoning_effort=high can take 60-120s); non-reasoning get 60s. On
@@ -479,7 +480,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
// disconnect still cancels the in-flight attempt.
attemptCtx, cancelAttempt := context.WithCancel(r.Context())
timer := time.AfterFunc(perModelTimeout(tryModel), cancelAttempt)
- tryResp, tryErr := retry.Do(attemptCtx, makeReqFor(tryBody), retry.WithClient(s.httpClient), retry.WithNetworkRetries(false))
+ tryResp, tryErr := retry.Do(attemptCtx, makeReqFor(tryBody), retry.WithClient(s.httpClient), retry.WithNetworkRetries(false), retry.WithMaxRetries(0))
if tryResp != nil && tryResp.StatusCode >= 300 {
tryErr = nil
}
@@ -496,9 +497,14 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
attempts = append(attempts, attemptResult{model: tryModel, reason: tryErr.Error()})
break
}
- // Provider returned an error status (4xx/5xx except 429 which retry handles)
+ // Settle this attempt before considering a separately reserved fallback.
if tryResp.StatusCode >= 300 {
- currentSpend.release()
+ currentSpend.header = tryResp.Header
+ if _, settled := settledCost(tryResp.Header); settled || tryResp.StatusCode >= 500 {
+ currentSpend.finish(nil)
+ } else {
+ currentSpend.release()
+ }
errBody, _ := io.ReadAll(tryResp.Body)
tryResp.Body.Close()
timer.Stop()
@@ -590,14 +596,11 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if json.Unmarshal([]byte(line[6:]), &chunk) == nil {
// Track usage tokens
if u, ok := chunk["usage"].(map[string]interface{}); ok {
- _, hasInput := u["prompt_tokens"]
- _, hasOutput := u["completion_tokens"]
- spend.usageKnown = hasInput && hasOutput
- if pt, ok := u["prompt_tokens"].(float64); ok {
- streamInputTok = int(pt)
- }
- if ct, ok := u["completion_tokens"].(float64); ok {
- streamOutputTok = int(ct)
+ pt, inputOK := validTokenCount(u["prompt_tokens"])
+ ct, outputOK := validTokenCount(u["completion_tokens"])
+ if inputOK && outputOK {
+ spend.usageKnown = true
+ streamInputTok, streamOutputTok = pt, ct
}
}
// Preserve tool-call prose by default (upstream v0.12.248).
@@ -721,7 +724,8 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if !allowed {
return
}
- fbResp, fbErr := retry.Do(r.Context(), makeReqFor(fbBody), retry.WithClient(s.httpClient), retry.WithNetworkRetries(false))
+ spend = nextSpend
+ fbResp, fbErr := retry.Do(r.Context(), makeReqFor(fbBody), retry.WithClient(s.httpClient), retry.WithNetworkRetries(false), retry.WithMaxRetries(0))
if fbResp != nil && fbResp.StatusCode >= 300 {
fbErr = nil
}
@@ -737,7 +741,12 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
defer fbResp.Body.Close()
if fbResp.StatusCode >= 300 {
- nextSpend.release()
+ nextSpend.header = fbResp.Header
+ if _, settled := settledCost(fbResp.Header); settled || fbResp.StatusCode >= 500 {
+ nextSpend.finish(nil)
+ } else {
+ nextSpend.release()
+ }
http.Error(w, "Fallback request rejected", http.StatusBadGateway)
return
}
diff --git a/proxy/request.go b/proxy/request.go
index 67bf0a2..88ce3c8 100644
--- a/proxy/request.go
+++ b/proxy/request.go
@@ -3,6 +3,7 @@ package proxy
import (
"encoding/json"
"fmt"
+ "math"
"net/url"
"os"
"strings"
@@ -145,3 +146,11 @@ func requestHasTools(raw json.RawMessage) bool {
var tools []json.RawMessage
return json.Unmarshal(raw, &tools) == nil && len(tools) > 0
}
+
+func validTokenCount(value any) (int, bool) {
+ number, ok := value.(float64)
+ if !ok || number < 0 || math.IsNaN(number) || math.IsInf(number, 0) || math.Trunc(number) != number || number >= float64(int(^uint(0)>>1)) {
+ return 0, false
+ }
+ return int(number), true
+}
diff --git a/proxy/spending.go b/proxy/spending.go
index d1b041d..fbf597b 100644
--- a/proxy/spending.go
+++ b/proxy/spending.go
@@ -19,6 +19,7 @@ type requestSpend struct {
model string
estimate, cost float64
input, output int
+ completions int
header http.Header
finished bool
source string
@@ -77,7 +78,7 @@ func (s *Server) reserveChat(w http.ResponseWriter, req chatRequest, body []byte
json.NewEncoder(w).Encode(map[string]any{"error": check.Reason, "blockedBy": check.BlockedBy, "remaining": check.Remaining})
return nil, false
}
- return &requestSpend{server: s, id: id, model: model, estimate: estimate, cost: estimate, source: "estimate"}, true
+ return &requestSpend{server: s, id: id, model: model, estimate: estimate, cost: estimate, source: "estimate", completions: n}, true
}
func (sp *requestSpend) readUsage(body []byte) {
@@ -107,7 +108,7 @@ func (sp *requestSpend) finish(body []byte) {
if p, ok := sp.server.modelPricing[sp.model]; ok {
sp.cost = (float64(sp.input)*p.InputPrice + float64(sp.output)*p.OutputPrice) / 1_000_000
if p.FlatPrice != nil {
- sp.cost = *p.FlatPrice
+ sp.cost = *p.FlatPrice * float64(sp.completions)
}
sp.source = "tokens"
}
diff --git a/proxy/spending_review_test.go b/proxy/spending_review_test.go
new file mode 100644
index 0000000..f2a6473
--- /dev/null
+++ b/proxy/spending_review_test.go
@@ -0,0 +1,124 @@
+package proxy
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "github.com/DOS/DOSRouter/router"
+ "github.com/DOS/DOSRouter/spendcontrol"
+)
+
+func TestFlatPriceSettlementPreservesCompletionCount(t *testing.T) {
+ const flatPrice = 0.125
+ for _, tc := range []struct {
+ name, n, usage, gateway string
+ reserved, settled float64
+ }{
+ {"default completion", "", `{"usage":{"prompt_tokens":10,"completion_tokens":5}}`, "", flatPrice, flatPrice},
+ {"multiple completions", `,"n":3`, `{"usage":{"prompt_tokens":10,"completion_tokens":15}}`, "", 3 * flatPrice, 3 * flatPrice},
+ {"zero tokens", `,"n":3`, `{"usage":{"prompt_tokens":0,"completion_tokens":0}}`, "", 3 * flatPrice, 3 * flatPrice},
+ {"missing usage", `,"n":3`, `{}`, "", 3 * flatPrice, 3 * flatPrice},
+ {"gateway total is authoritative", `,"n":3`, `{"usage":{"prompt_tokens":10,"completion_tokens":15}}`, "0.25", 3 * flatPrice, 0.25},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ srv, sc := syncTestServer(t, func(http.ResponseWriter, *http.Request) {
+ t.Error("unit settlement test unexpectedly dispatched upstream")
+ }, nil)
+ flat := flatPrice
+ const model = "openai/gpt-4o-mini"
+ srv.modelPricing[model] = router.ModelPricing{FlatPrice: &flat}
+ if err := sc.SetLimit(spendcontrol.WindowSession, 1); err != nil {
+ t.Fatal(err)
+ }
+ payload := []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"test"}]` + tc.n + `}`)
+ var req chatRequest
+ if err := json.Unmarshal(payload, &req); err != nil {
+ t.Fatal(err)
+ }
+ spend, allowed := srv.reserveChat(httptest.NewRecorder(), req, payload, model)
+ if !allowed || spend == nil {
+ t.Fatal("flat-price reservation rejected")
+ }
+ if got := sc.GetSpending()[spendcontrol.WindowSession]; got != tc.reserved {
+ t.Fatalf("reserved=%v, want %v", got, tc.reserved)
+ }
+ spend.header = make(http.Header)
+ spend.header.Set("X-DOS-Cost-USD", tc.gateway)
+ spend.finish([]byte(tc.usage))
+ spend.finish(nil)
+ history := sc.GetHistory()
+ if len(history) != 1 || history[0].Amount != tc.settled {
+ t.Fatalf("settlement=%+v, want one charge of %v", history, tc.settled)
+ }
+ if got := sc.GetSpending()[spendcontrol.WindowSession]; got != tc.settled {
+ t.Fatalf("settlement left a pending reservation: total=%v, want %v", got, tc.settled)
+ }
+ })
+ }
+}
+
+func TestTruncatedEmptyTurnFallbackSettlesReservation(t *testing.T) {
+ var attempts atomic.Int32
+ const firstModel = "openai/gpt-4o-mini"
+ const nextModel = "openai/gpt-4o"
+ srv, sc := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Model string `json:"model"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ t.Error(err)
+ }
+ switch attempts.Add(1) {
+ case 1:
+ if req.Model != firstModel {
+ t.Errorf("first model=%q, want %q", req.Model, firstModel)
+ }
+ w.Header().Set("X-DOS-Cost-USD", "0.125")
+ io.WriteString(w, `{"choices":[{"message":{"content":""},"finish_reason":"stop"}]}`)
+ case 2:
+ if req.Model != nextModel {
+ t.Errorf("fallback model=%q, want %q", req.Model, nextModel)
+ }
+ w.Header().Set("X-DOS-Cost-USD", "0.25")
+ w.Header().Set("Content-Length", "4096")
+ io.WriteString(w, `{"choices":[`)
+ default:
+ t.Error("truncated fallback was retried")
+ http.Error(w, "unexpected retry", http.StatusBadRequest)
+ }
+ }, func(cfg *Config) {
+ routing := router.DefaultRoutingConfig()
+ routing.Promotions = nil
+ for _, tiers := range []map[router.Tier]router.TierConfig{routing.Tiers, routing.AgenticTiers, routing.EcoTiers, routing.PremiumTiers} {
+ for _, tier := range []router.Tier{router.TierSimple, router.TierMedium, router.TierComplex, router.TierReasoning} {
+ if tiers != nil {
+ tiers[tier] = router.TierConfig{Primary: firstModel, Fallback: []string{nextModel}}
+ }
+ }
+ }
+ cfg.RoutingConfig = &routing
+ })
+ if err := sc.SetLimit(spendcontrol.WindowSession, 1); err != nil {
+ t.Fatal(err)
+ }
+ result := syncTestChat(t, srv, `{"model":"auto","messages":[{"role":"user","content":"hello"}]}`)
+ if result.Code != http.StatusBadGateway || !strings.Contains(result.Body.String(), "Incomplete fallback response") {
+ t.Fatalf("status=%d, body=%q", result.Code, result.Body.String())
+ }
+ if attempts.Load() != 2 {
+ t.Fatalf("upstream attempts=%d, want 2", attempts.Load())
+ }
+ history := sc.GetHistory()
+ if len(history) != 2 || history[0].Model != firstModel || history[0].Amount != 0.125 || history[1].Model != nextModel || history[1].Amount != 0.25 {
+ t.Fatalf("incomplete fallback was not settled exactly once: %+v", history)
+ }
+ status := sc.GetStatus()[spendcontrol.WindowSession]
+ if status.Spent != 0.375 || status.Remaining != 0.625 {
+ t.Fatalf("fallback left a pending reservation: %+v", status)
+ }
+}
diff --git a/proxy/spending_test.go b/proxy/spending_test.go
index 7ec2927..809ff3f 100644
--- a/proxy/spending_test.go
+++ b/proxy/spending_test.go
@@ -86,3 +86,66 @@ func TestNewAcceptsWrappedDefaultTransport(t *testing.T) {
t.Fatal("wrapped transport was not preserved")
}
}
+
+func TestConfiguredGatewayDoesNotShareCallerCache(t *testing.T) {
+ var calls atomic.Int32
+ srv, _ := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) { calls.Add(1); syncTestOK(w) }, func(cfg *Config) { cfg.UpstreamAPIKey = "test-gateway-key" })
+ for _, credential := range []string{"Bearer test-a", "Bearer test-b"} {
+ r := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"same gateway"}]}`))
+ r.Header.Set("Authorization", credential)
+ w := httptest.NewRecorder()
+ srv.handleChatCompletions(w, r)
+ if w.Code != 200 || w.Header().Get("X-DOSRouter-Cache") == "hit" {
+ t.Fatalf("status=%d headers=%v", w.Code, w.Header())
+ }
+ }
+ if calls.Load() != 2 {
+ t.Fatalf("calls=%d", calls.Load())
+ }
+}
+
+func TestNonThinkingControlTokensKeepVisibleProse(t *testing.T) {
+ for _, text := range []string{"<|begin_of_text|>Visible answer", "<|end_of_turn|>Still visible"} {
+ if got := stripThinking(text); got != text {
+ t.Errorf("stripThinking(%q)=%q", text, got)
+ }
+ }
+ if got := stripThinking("<|begin▁of▁thinking|>private<|end▁of▁thinking|>Visible"); got != "Visible" {
+ t.Errorf("thinking=%q", got)
+ }
+}
+
+func TestMalformedStreamingUsageRetainsEstimate(t *testing.T) {
+ for _, usage := range []string{`{"prompt_tokens":null,"completion_tokens":null}`, `{"prompt_tokens":"10","completion_tokens":5}`, `{"prompt_tokens":1.5,"completion_tokens":0}`, `{"prompt_tokens":-1,"completion_tokens":0}`} {
+ t.Run(usage, func(t *testing.T) {
+ var entry logger.UsageEntry
+ srv, _ := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ io.WriteString(w, "data: {\"choices\":[],\"usage\":"+usage+"}\n\ndata: [DONE]\n\n")
+ }, func(cfg *Config) { cfg.UsageLogger = func(e logger.UsageEntry) { entry = e } })
+ w := syncTestChat(t, srv, `{"model":"openai/gpt-4o-mini","stream":true,"messages":[{"role":"user","content":"stream"}]}`)
+ if w.Code != 200 || entry.CostSource != "estimate" || entry.Cost <= 0 {
+ t.Fatalf("status=%d entry=%+v", w.Code, entry)
+ }
+ })
+ }
+}
+
+func TestPaidStatusResponseDoesNotRepeatSameReservation(t *testing.T) {
+ for _, status := range []int{429, 502, 503, 504} {
+ var calls atomic.Int32
+ srv, sc := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ calls.Add(1)
+ w.Header().Set("X-Blockrun-Cost-USD", "0.002")
+ http.Error(w, "failed after processing", status)
+ }, nil)
+ w := syncTestChat(t, srv, `{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"no replay"}]}`)
+ if w.Code != 502 || calls.Load() != 1 {
+ t.Fatalf("upstream=%d status=%d calls=%d", status, w.Code, calls.Load())
+ }
+ history := sc.GetHistory()
+ if len(history) != 1 || history[0].Amount != 0.002 {
+ t.Fatalf("settled error history=%v", history)
+ }
+ }
+}
diff --git a/spendcontrol/spendcontrol.go b/spendcontrol/spendcontrol.go
index c007b0e..68dd9d7 100644
--- a/spendcontrol/spendcontrol.go
+++ b/spendcontrol/spendcontrol.go
@@ -428,7 +428,7 @@ func (fs *FileSpendControlStorage) Save(state persistedState) error {
if err != nil {
return err
}
- defer os.Remove(file.Name())
+ defer func() { _ = os.Remove(file.Name()) }()
if _, err = file.Write(data); err == nil {
err = file.Sync()
}
From 1210aad87835c59ca906dc463415d9317537889f Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 12 Sep 2026 13:40:44 +0700
Subject: [PATCH 6/8] fix: retain observed usage when streaming is interrupted
---
proxy/proxy.go | 1 +
proxy/spending_review_test.go | 37 +++++++++++++++++++++++++++++++++++
2 files changed, 38 insertions(+)
diff --git a/proxy/proxy.go b/proxy/proxy.go
index bbc1fae..f55912c 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -599,6 +599,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
pt, inputOK := validTokenCount(u["prompt_tokens"])
ct, outputOK := validTokenCount(u["completion_tokens"])
if inputOK && outputOK {
+ spend.input, spend.output = pt, ct
spend.usageKnown = true
streamInputTok, streamOutputTok = pt, ct
}
diff --git a/proxy/spending_review_test.go b/proxy/spending_review_test.go
index f2a6473..046fced 100644
--- a/proxy/spending_review_test.go
+++ b/proxy/spending_review_test.go
@@ -122,3 +122,40 @@ func TestTruncatedEmptyTurnFallbackSettlesReservation(t *testing.T) {
t.Fatalf("fallback left a pending reservation: %+v", status)
}
}
+
+type closedStreamWriter struct{ *httptest.ResponseRecorder }
+
+func (w *closedStreamWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
+
+func TestInterruptedStreamRetainsObservedUsage(t *testing.T) {
+ for _, failure := range []string{"client write", "upstream read"} {
+ t.Run(failure, func(t *testing.T) {
+ srv, sc := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ if failure == "upstream read" {
+ w.Header().Set("Content-Length", "4096")
+ }
+ io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":500}}\n\n")
+ }, nil)
+ if err := sc.SetLimit(spendcontrol.WindowSession, 1); err != nil {
+ t.Fatal(err)
+ }
+ const model = "openai/gpt-4o-mini"
+ srv.modelPricing[model] = router.ModelPricing{InputPrice: 1, OutputPrice: 2}
+ request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"`+model+`","stream":true,"messages":[{"role":"user","content":"stream"}]}`))
+ var writer http.ResponseWriter = httptest.NewRecorder()
+ if failure == "client write" {
+ writer = &closedStreamWriter{httptest.NewRecorder()}
+ }
+ srv.handleChatCompletions(writer, request)
+ history := sc.GetHistory()
+ const expectedCost = 0.002
+ if len(history) != 1 || history[0].Amount != expectedCost {
+ t.Fatalf("observed usage lost on interruption: %+v, want one charge %v", history, expectedCost)
+ }
+ if spent := sc.GetSpending()[spendcontrol.WindowSession]; spent != expectedCost {
+ t.Fatalf("pending reservation remained: %v", spent)
+ }
+ })
+ }
+}
From 24bd53fd9e48e014088d0bcbcf45004fb7b13b36 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 12 Sep 2026 13:43:30 +0700
Subject: [PATCH 7/8] fix: stream ordinary comparison prose without buffering
---
proxy/prose.go | 27 +++++++++++++++++++++++++++
proxy/spending_test.go | 19 +++++++++++++++++++
2 files changed, 46 insertions(+)
diff --git a/proxy/prose.go b/proxy/prose.go
index 3f61b6b..1e283e1 100644
--- a/proxy/prose.go
+++ b/proxy/prose.go
@@ -5,6 +5,8 @@ import (
"strings"
)
+var thinkingTagPrefix = regexp.MustCompile(`(?i)^<\s*/?\s*(think(?:ing)?|thought|antthinking|antml:thinking)\b`)
+
var thinkingTag = regexp.MustCompile(`(?i)^<\s*(/?)\s*(think(?:ing)?|thought|antthinking|antml:thinking)\b[^>]*>$`)
// proseFilter tracks split thinking tags across SSE chunks. Tagged reasoning
@@ -32,6 +34,13 @@ func (f *proseFilter) filter(text string, final bool) string {
f.pending = f.pending[start:]
end := strings.IndexByte(f.pending, '>')
if end < 0 {
+ if !couldBeThinkingTag(f.pending) {
+ if !f.hidden {
+ out.WriteByte('<')
+ }
+ f.pending = f.pending[1:]
+ continue
+ }
if final {
if !f.hidden {
out.WriteString(f.pending)
@@ -68,3 +77,21 @@ func stripThinking(content string) string {
var filter proseFilter
return filter.filter(content, true)
}
+
+// couldBeThinkingTag buffers only prefixes that can still form a private tag.
+// Ordinary comparisons must flow immediately without waiting for a closing >.
+func couldBeThinkingTag(pending string) bool {
+ if thinkingTagPrefix.MatchString(pending) {
+ return true
+ }
+ candidate := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(pending, "<")))
+ candidate = strings.TrimSpace(strings.TrimPrefix(candidate, "/"))
+ candidate = strings.ReplaceAll(candidate, "|", "|")
+ candidate = strings.ReplaceAll(candidate, "▁", "_")
+ for _, name := range []string{"think", "thinking", "thought", "antthinking", "antml:thinking", "|begin_of_thinking|", "|thinking|", "|end_of_thinking|"} {
+ if strings.HasPrefix(name, candidate) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/proxy/spending_test.go b/proxy/spending_test.go
index 809ff3f..461ea19 100644
--- a/proxy/spending_test.go
+++ b/proxy/spending_test.go
@@ -149,3 +149,22 @@ func TestPaidStatusResponseDoesNotRepeatSameReservation(t *testing.T) {
}
}
}
+
+func TestStreamingComparisonDoesNotBufferOrdinaryProse(t *testing.T) {
+ var filter proseFilter
+ for _, chunk := range []string{"x < y", " and another ", "condition holds."} {
+ if got := filter.filter(chunk, false); got != chunk {
+ t.Fatalf("chunk=%q, got=%q before stream completion", chunk, got)
+ }
+ }
+ var split proseFilter
+ if got := split.filter("Visible privateFinal", false); got != "Final" {
+ t.Fatalf("closing tag output=%q", got)
+ }
+}
From 69ab4be661fc6c09daa5dead0d924bffedea19f4 Mon Sep 17 00:00:00 2001
From: JOY <5027251+JOY@users.noreply.github.com>
Date: Sat, 12 Sep 2026 14:19:52 +0700
Subject: [PATCH 8/8] fix: finalize request accounting and preserve numeric
cache inputs
---
cache/cache.go | 8 ++-
cache/requestkey_test.go | 38 +++++++++++++++
dedup/dedup.go | 8 ++-
dedup/requestkey_test.go | 38 +++++++++++++++
docs/configuration.md | 9 ++--
proxy/proxy.go | 47 +++++++++++-------
proxy/request.go | 19 +++++++-
proxy/request_review_test.go | 92 +++++++++++++++++++++++++++++++++++
proxy/spending.go | 10 ++--
proxy/spending_review_test.go | 24 ++++++++-
10 files changed, 265 insertions(+), 28 deletions(-)
create mode 100644 proxy/request_review_test.go
diff --git a/cache/cache.go b/cache/cache.go
index 97ac39f..ad6087c 100644
--- a/cache/cache.go
+++ b/cache/cache.go
@@ -5,6 +5,7 @@
package cache
import (
+ "bytes"
"container/list"
"crypto/sha256"
"encoding/hex"
@@ -270,8 +271,13 @@ func (c *Cache) removeLocked(elem *list.Element) {
// CacheKey returns a hex-encoded SHA-256 hash of the canonicalized request
// JSON, omitting non-deterministic fields while preserving all content.
func CacheKey(body []byte) (string, error) {
+ if !json.Valid(body) {
+ return "", fmt.Errorf("cache: invalid JSON body")
+ }
var raw map[string]interface{}
- if err := json.Unmarshal(body, &raw); err != nil {
+ decoder := json.NewDecoder(bytes.NewReader(body))
+ decoder.UseNumber()
+ if err := decoder.Decode(&raw); err != nil {
return "", fmt.Errorf("cache: invalid JSON body: %w", err)
}
diff --git a/cache/requestkey_test.go b/cache/requestkey_test.go
index a99ba5e..f72f188 100644
--- a/cache/requestkey_test.go
+++ b/cache/requestkey_test.go
@@ -113,3 +113,41 @@ func TestCacheKeyPreservesTimestampContent(t *testing.T) {
})
}
}
+
+func TestCacheKeyPreservesNumericLiterals(t *testing.T) {
+ for _, tt := range []struct {
+ name, first, second string
+ }{
+ {"adjacent integers beyond float64 precision", "9007199254740992", "9007199254740993"},
+ {"integer and decimal representation", "1", "1.0"},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ first, err := CacheKey([]byte(`{"payload":{"number":` + tt.first + `}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := CacheKey([]byte(`{"payload":{"number":` + tt.second + `}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first == second {
+ t.Fatalf("distinct numeric literals %s and %s shared a key", tt.first, tt.second)
+ }
+ })
+ }
+}
+
+func TestCacheKeyRejectsTrailingJSONValues(t *testing.T) {
+ for _, body := range []string{
+ `{"model":"test/model"} {"model":"second"}`,
+ `{"model":"test/model"} 1`,
+ `{"model":"test/model"} garbage`,
+ } {
+ if key, err := CacheKey([]byte(body)); err == nil || key != "" {
+ t.Errorf("trailing JSON data accepted: key=%q err=%v", key, err)
+ }
+ }
+ if _, err := CacheKey([]byte("{\"model\":\"test/model\"} \n\t")); err != nil {
+ t.Errorf("valid trailing whitespace rejected: %v", err)
+ }
+}
diff --git a/dedup/dedup.go b/dedup/dedup.go
index 55bc1be..75afce7 100644
--- a/dedup/dedup.go
+++ b/dedup/dedup.go
@@ -4,6 +4,7 @@
package dedup
import (
+ "bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -158,8 +159,13 @@ func (d *Deduplicator) Len() int {
// Object keys are sorted recursively while preserving all content and JSON
// value types, including client-supplied timestamps.
func HashBody(body []byte) (string, error) {
+ if !json.Valid(body) {
+ return "", fmt.Errorf("dedup: invalid JSON body")
+ }
var raw interface{}
- if err := json.Unmarshal(body, &raw); err != nil {
+ decoder := json.NewDecoder(bytes.NewReader(body))
+ decoder.UseNumber()
+ if err := decoder.Decode(&raw); err != nil {
return "", fmt.Errorf("dedup: invalid JSON body: %w", err)
}
canonical := canonicalize(raw)
diff --git a/dedup/requestkey_test.go b/dedup/requestkey_test.go
index ce4d36c..e906446 100644
--- a/dedup/requestkey_test.go
+++ b/dedup/requestkey_test.go
@@ -120,3 +120,41 @@ func TestHashBodyPreservesTimestampContent(t *testing.T) {
})
}
}
+
+func TestHashBodyPreservesNumericLiterals(t *testing.T) {
+ for _, tt := range []struct {
+ name, first, second string
+ }{
+ {"adjacent integers beyond float64 precision", "9007199254740992", "9007199254740993"},
+ {"integer and decimal representation", "1", "1.0"},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ first, err := HashBody([]byte(`{"payload":{"number":` + tt.first + `}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := HashBody([]byte(`{"payload":{"number":` + tt.second + `}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first == second {
+ t.Fatalf("distinct numeric literals %s and %s shared a key", tt.first, tt.second)
+ }
+ })
+ }
+}
+
+func TestHashBodyRejectsTrailingJSONValues(t *testing.T) {
+ for _, body := range []string{
+ `{"model":"test/model"} {"model":"second"}`,
+ `{"model":"test/model"} 1`,
+ `{"model":"test/model"} garbage`,
+ } {
+ if key, err := HashBody([]byte(body)); err == nil || key != "" {
+ t.Errorf("trailing JSON data accepted: key=%q err=%v", key, err)
+ }
+ }
+ if _, err := HashBody([]byte("{\"model\":\"test/model\"} \n\t")); err != nil {
+ t.Errorf("valid trailing whitespace rejected: %v", err)
+ }
+}
diff --git a/docs/configuration.md b/docs/configuration.md
index ff7b6e3..ee27118 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -252,9 +252,12 @@ state refuses paid dispatch rather than silently clearing limits. Direct and
routed chat requests reserve estimated cost before dispatch, then record
settled gateway headers or token-based cost when available. Concurrent pending
reservations count against session, hourly and daily limits. Explicit upstream
-client rejections release a reservation unless a settled charge is reported;
-server/transport failures conservatively consume their estimate. Each
-reservation permits one HTTP send; model fallbacks reserve separately. Unknown-priced models and image
+client rejections release a reservation unless a settled charge is reported.
+A valid settled gateway cost header takes priority even on server errors;
+server errors without one conservatively consume their estimate. Transport
+failures with no response consume the estimate and are not retried. Each
+reservation permits one HTTP send; model fallbacks reserve separately.
+Unknown-priced models and image
requests are refused when limits are configured. This is a local estimate-based
control, not a provider-side USD guarantee or multi-process ledger.
diff --git a/proxy/proxy.go b/proxy/proxy.go
index f55912c..564eec2 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -268,7 +268,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
w.Header().Add(k, v)
}
}
- if s.config.UpstreamAPIKey != "" || r.Header.Get("Authorization") != "" {
+ if s.config.UpstreamAPIKey != "" {
w.Header().Set("Cache-Control", "no-store")
}
w.WriteHeader(entry.StatusCode)
@@ -442,11 +442,20 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
var attempts []attemptResult
var resp *http.Response
var spend *requestSpend
- defer func() {
- if spend != nil {
- spend.finish(nil)
+ accounted := make(map[*requestSpend]bool)
+ finalStatus := "interrupted"
+ finalizeSpend := func(current *requestSpend, status string) {
+ if current == nil || accounted[current] {
+ return
+ }
+ current.finish(nil)
+ accounted[current] = true
+ if sessionID != "" {
+ s.sessions.AddSessionCost(sessionID, int64(current.cost*1_000_000))
}
- }()
+ s.logSettledRequest(current.model, decision, startTime, current, status)
+ }
+ defer func() { finalizeSpend(spend, finalStatus) }()
// cancelResp cancels the context of the SUCCESSFUL attempt; it is deferred
// after the loop so the chosen response body stays streamable until the
// handler returns, then its resources are released.
@@ -469,7 +478,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
// Per-model timeout (upstream v0.12.182): reasoning models get 3min for
// cold-start first-token (DeepSeek V4 Pro / Claude opus thinking / GPT-5
// reasoning_effort=high can take 60-120s); non-reasoning get 60s. On
- // timeout the loop falls through to the next model rather than failing.
+ // timeout the request ends without another ambiguous paid send.
//
// Implemented as cancel-context + AfterFunc (the Go equivalent of
// setTimeout/clearTimeout): the timer fires cancel() only if the attempt
@@ -485,7 +494,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
tryErr = nil
}
if tryErr != nil {
- currentSpend.finish(nil) // A lost response may already have incurred a charge.
+ finalizeSpend(currentSpend, "error") // A lost response may already have incurred a charge.
timer.Stop()
cancelAttempt()
if tryResp != nil {
@@ -501,9 +510,10 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if tryResp.StatusCode >= 300 {
currentSpend.header = tryResp.Header
if _, settled := settledCost(tryResp.Header); settled || tryResp.StatusCode >= 500 {
- currentSpend.finish(nil)
+ finalizeSpend(currentSpend, "error")
} else {
currentSpend.release()
+ accounted[currentSpend] = true
}
errBody, _ := io.ReadAll(tryResp.Body)
tryResp.Body.Close()
@@ -557,7 +567,6 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
"models": len(attempts),
},
})
- s.logRequest(resolvedModel, decision, startTime, "error")
return
}
defer resp.Body.Close()
@@ -580,7 +589,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if copyErr != nil {
status = "interrupted"
}
- s.logRequest(resolvedModel, decision, startTime, status)
+ finalStatus = status
return
}
@@ -719,7 +728,8 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
s.sessions.SetSession(sessionID, nextModel, decision.Tier, userExplicit)
}
- spend.finish(respBody)
+ spend.readUsage(respBody)
+ finalizeSpend(spend, "empty")
spend = nil
nextSpend, allowed := s.reserveChat(w, req, fbBody, nextModel)
if !allowed {
@@ -731,7 +741,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
fbErr = nil
}
if fbErr != nil {
- nextSpend.finish(nil)
+ finalizeSpend(nextSpend, "error")
if fbResp != nil {
fbResp.Body.Close()
}
@@ -744,15 +754,20 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
if fbResp.StatusCode >= 300 {
nextSpend.header = fbResp.Header
if _, settled := settledCost(fbResp.Header); settled || fbResp.StatusCode >= 500 {
- nextSpend.finish(nil)
+ finalizeSpend(nextSpend, "error")
} else {
nextSpend.release()
+ accounted[nextSpend] = true
}
http.Error(w, "Fallback request rejected", http.StatusBadGateway)
return
}
spend = nextSpend
spend.header = fbResp.Header
+ w.Header().Del("X-DOSRouter-Request-Id")
+ if id := gatewayRequestID(fbResp.Header); id != "" {
+ w.Header().Set("X-DOSRouter-Request-Id", id)
+ }
respBody, readErr = io.ReadAll(fbResp.Body)
if readErr != nil {
http.Error(w, "Incomplete fallback response", http.StatusBadGateway)
@@ -864,11 +879,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) {
}
}
- spend.finish(nil)
- if sessionID != "" {
- s.sessions.AddSessionCost(sessionID, int64(spend.cost*1_000_000))
- }
- s.logSettledRequest(resolvedModel, decision, startTime, spend)
+ finalStatus = "success"
}
diff --git a/proxy/request.go b/proxy/request.go
index 88ce3c8..dcc0abc 100644
--- a/proxy/request.go
+++ b/proxy/request.go
@@ -142,9 +142,26 @@ func (s *Server) writeUsage(entry logger.UsageEntry) {
logger.LogUsage(entry)
}
+// requestHasTools recognizes the function-tool contract used by routing and
+// recovery. Unknown provider extensions are forwarded without inferring tool
+// capability from an arbitrary nonempty array.
func requestHasTools(raw json.RawMessage) bool {
var tools []json.RawMessage
- return json.Unmarshal(raw, &tools) == nil && len(tools) > 0
+ if json.Unmarshal(raw, &tools) != nil {
+ return false
+ }
+ for _, rawTool := range tools {
+ var tool struct {
+ Type string `json:"type"`
+ Function struct {
+ Name string `json:"name"`
+ } `json:"function"`
+ }
+ if json.Unmarshal(rawTool, &tool) == nil && tool.Type == "function" && strings.TrimSpace(tool.Function.Name) != "" {
+ return true
+ }
+ }
+ return false
}
func validTokenCount(value any) (int, bool) {
diff --git a/proxy/request_review_test.go b/proxy/request_review_test.go
new file mode 100644
index 0000000..6bd9509
--- /dev/null
+++ b/proxy/request_review_test.go
@@ -0,0 +1,92 @@
+package proxy
+
+import (
+ "bytes"
+ "encoding/json"
+ "log"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/DOS/DOSRouter/spendcontrol"
+)
+
+func TestRequestHasToolsRequiresDeclaredFunction(t *testing.T) {
+ for _, tc := range []struct {
+ name, raw string
+ want bool
+ }{
+ {"missing", "", false},
+ {"null", `null`, false},
+ {"empty array", `[]`, false},
+ {"null entry", `[null]`, false},
+ {"empty object", `[{}]`, false},
+ {"non-object entry", `["function"]`, false},
+ {"missing type", `[{"function":{"name":"read_file"}}]`, false},
+ {"missing function", `[{"type":"function"}]`, false},
+ {"missing name", `[{"type":"function","function":{}}]`, false},
+ {"null name", `[{"type":"function","function":{"name":null}}]`, false},
+ {"wrong name type", `[{"type":"function","function":{"name":7}}]`, false},
+ {"empty name", `[{"type":"function","function":{"name":""}}]`, false},
+ {"whitespace name", `[{"type":"function","function":{"name":" \t "}}]`, false},
+ {"unknown provider extension", `[{"type":"provider_specific","name":"extension"}]`, false},
+ {"declared function", `[{"type":"function","function":{"name":"read_file"}}]`, true},
+ {"valid declaration among invalid entries", `[null,{},false,{"type":"function","function":{"name":"read_file"}}]`, true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := requestHasTools(json.RawMessage(tc.raw)); got != tc.want {
+ t.Fatalf("requestHasTools(%s)=%v, want %v", tc.raw, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestSpendCommitFailureLogsWithoutBackendDetails(t *testing.T) {
+ storage := &spendcontrol.FileSpendControlStorage{Path: filepath.Join(t.TempDir(), "spending.json")}
+ sc, err := spendcontrol.New(storage)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := sc.SetLimit(spendcontrol.WindowSession, 1); err != nil {
+ t.Fatal(err)
+ }
+ srv := New(Config{SpendControl: sc})
+ t.Cleanup(srv.Close)
+ payload := []byte(`{"model":"openai/gpt-4o-mini","messages":[{"role":"user","content":"test"}]}`)
+ var request chatRequest
+ if err := json.Unmarshal(payload, &request); err != nil {
+ t.Fatal(err)
+ }
+ spend, allowed := srv.reserveChat(httptest.NewRecorder(), request, payload, request.Model)
+ if !allowed {
+ t.Fatal("reservation denied before storage failure")
+ }
+
+ // Replacing a directory with the state file fails deterministically on all
+ // platforms, without depending on elevated users respecting file modes.
+ const privateDetail = "private-backend-detail-sentinel"
+ storage.Path = filepath.Join(t.TempDir(), privateDetail)
+ if err := os.Mkdir(storage.Path, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ previous := log.Writer()
+ var captured bytes.Buffer
+ log.SetOutput(&captured)
+ t.Cleanup(func() { log.SetOutput(previous) })
+ spend.finish([]byte(`{"usage":{"prompt_tokens":10,"completion_tokens":5}}`))
+ spend.finish(nil)
+ if count := strings.Count(captured.String(), "spend settlement failed"); count != 1 {
+ t.Fatalf("expected one observable settlement failure, got %d", count)
+ }
+ if strings.Contains(captured.String(), privateDetail) || strings.Contains(captured.String(), storage.Path) {
+ t.Fatal("backend details leaked into logs")
+ }
+ if len(sc.GetHistory()) != 1 {
+ t.Fatal("settlement was lost or repeated after storage failure")
+ }
+ if sc.Check(0).Allowed {
+ t.Fatal("persistence failure did not keep admission closed")
+ }
+}
diff --git a/proxy/spending.go b/proxy/spending.go
index fbf597b..d560080 100644
--- a/proxy/spending.go
+++ b/proxy/spending.go
@@ -2,6 +2,7 @@ package proxy
import (
"encoding/json"
+ "log"
"math"
"net/http"
"strconv"
@@ -114,7 +115,10 @@ func (sp *requestSpend) finish(body []byte) {
}
}
// A persistence error keeps the controller fail-closed; never retry a paid call.
- _ = sp.server.spendControl.Commit(sp.id, sp.cost, sp.model, "chat")
+ if err := sp.server.spendControl.Commit(sp.id, sp.cost, sp.model, "chat"); err != nil {
+ // Backend errors may contain sensitive paths or data; keep the event generic.
+ log.Print("DOSRouter: spend settlement failed; accounting state requires attention")
+ }
}
func (sp *requestSpend) release() {
@@ -167,8 +171,8 @@ func mediaCost(h http.Header, body []byte) float64 {
return 0
}
-func (s *Server) logSettledRequest(model string, decision *router.RoutingDecision, start time.Time, spend *requestSpend) {
- entry := logger.UsageEntry{Timestamp: time.Now().UTC().Format(time.RFC3339), Model: model, Tier: "DIRECT", Cost: spend.cost, CostSource: spend.source, RequestID: gatewayRequestID(spend.header), InputTokens: spend.input, OutputTokens: spend.output, Status: "success", LatencyMs: time.Since(start).Milliseconds()}
+func (s *Server) logSettledRequest(model string, decision *router.RoutingDecision, start time.Time, spend *requestSpend, status string) {
+ entry := logger.UsageEntry{Timestamp: time.Now().UTC().Format(time.RFC3339), Model: model, Tier: "DIRECT", Cost: spend.cost, CostSource: spend.source, RequestID: gatewayRequestID(spend.header), InputTokens: spend.input, OutputTokens: spend.output, Status: status, LatencyMs: time.Since(start).Milliseconds()}
if decision != nil {
entry.Tier = string(decision.Tier)
entry.BaselineCost = decision.BaselineCost
diff --git a/proxy/spending_review_test.go b/proxy/spending_review_test.go
index 046fced..34b0a90 100644
--- a/proxy/spending_review_test.go
+++ b/proxy/spending_review_test.go
@@ -9,7 +9,9 @@ import (
"sync/atomic"
"testing"
+ "github.com/DOS/DOSRouter/logger"
"github.com/DOS/DOSRouter/router"
+ "github.com/DOS/DOSRouter/session"
"github.com/DOS/DOSRouter/spendcontrol"
)
@@ -64,6 +66,7 @@ func TestFlatPriceSettlementPreservesCompletionCount(t *testing.T) {
func TestTruncatedEmptyTurnFallbackSettlesReservation(t *testing.T) {
var attempts atomic.Int32
+ var logs []logger.UsageEntry
const firstModel = "openai/gpt-4o-mini"
const nextModel = "openai/gpt-4o"
srv, sc := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
@@ -79,12 +82,14 @@ func TestTruncatedEmptyTurnFallbackSettlesReservation(t *testing.T) {
t.Errorf("first model=%q, want %q", req.Model, firstModel)
}
w.Header().Set("X-DOS-Cost-USD", "0.125")
+ w.Header().Set("X-DOS-Request-Id", "initial-attempt")
io.WriteString(w, `{"choices":[{"message":{"content":""},"finish_reason":"stop"}]}`)
case 2:
if req.Model != nextModel {
t.Errorf("fallback model=%q, want %q", req.Model, nextModel)
}
w.Header().Set("X-DOS-Cost-USD", "0.25")
+ w.Header().Set("X-DOS-Request-Id", "fallback-attempt")
w.Header().Set("Content-Length", "4096")
io.WriteString(w, `{"choices":[`)
default:
@@ -102,6 +107,7 @@ func TestTruncatedEmptyTurnFallbackSettlesReservation(t *testing.T) {
}
}
cfg.RoutingConfig = &routing
+ cfg.UsageLogger = func(e logger.UsageEntry) { logs = append(logs, e) }
})
if err := sc.SetLimit(spendcontrol.WindowSession, 1); err != nil {
t.Fatal(err)
@@ -110,6 +116,12 @@ func TestTruncatedEmptyTurnFallbackSettlesReservation(t *testing.T) {
if result.Code != http.StatusBadGateway || !strings.Contains(result.Body.String(), "Incomplete fallback response") {
t.Fatalf("status=%d, body=%q", result.Code, result.Body.String())
}
+ if result.Header().Get("X-DOSRouter-Request-Id") != "fallback-attempt" {
+ t.Fatalf("fallback request ID=%q", result.Header().Get("X-DOSRouter-Request-Id"))
+ }
+ if len(logs) != 2 || logs[0].Cost != 0.125 || logs[1].Cost != 0.25 || logs[0].RequestID != "initial-attempt" || logs[1].RequestID != "fallback-attempt" {
+ t.Fatalf("fallback usage logs=%+v", logs)
+ }
if attempts.Load() != 2 {
t.Fatalf("upstream attempts=%d, want 2", attempts.Load())
}
@@ -130,19 +142,23 @@ func (w *closedStreamWriter) Write([]byte) (int, error) { return 0, io.ErrClosed
func TestInterruptedStreamRetainsObservedUsage(t *testing.T) {
for _, failure := range []string{"client write", "upstream read"} {
t.Run(failure, func(t *testing.T) {
+ var logs []logger.UsageEntry
srv, sc := syncTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
if failure == "upstream read" {
w.Header().Set("Content-Length", "4096")
}
io.WriteString(w, "data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000,\"completion_tokens\":500}}\n\n")
- }, nil)
+ }, func(cfg *Config) { cfg.UsageLogger = func(e logger.UsageEntry) { logs = append(logs, e) } })
+ srv.sessions.Close()
+ srv.sessions = session.NewStore(session.Config{Enabled: true, TimeoutMs: 60000})
if err := sc.SetLimit(spendcontrol.WindowSession, 1); err != nil {
t.Fatal(err)
}
const model = "openai/gpt-4o-mini"
srv.modelPricing[model] = router.ModelPricing{InputPrice: 1, OutputPrice: 2}
request := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"`+model+`","stream":true,"messages":[{"role":"user","content":"stream"}]}`))
+ request.Header.Set("x-session-id", "interrupted-session")
var writer http.ResponseWriter = httptest.NewRecorder()
if failure == "client write" {
writer = &closedStreamWriter{httptest.NewRecorder()}
@@ -153,6 +169,12 @@ func TestInterruptedStreamRetainsObservedUsage(t *testing.T) {
if len(history) != 1 || history[0].Amount != expectedCost {
t.Fatalf("observed usage lost on interruption: %+v, want one charge %v", history, expectedCost)
}
+ if len(logs) != 1 || logs[0].Cost != expectedCost || logs[0].Status != "interrupted" {
+ t.Fatalf("interrupted usage log=%+v", logs)
+ }
+ if cost := srv.sessions.GetSessionCostUSD("interrupted-session"); cost != expectedCost {
+ t.Fatalf("session cost=%v, want %v", cost, expectedCost)
+ }
if spent := sc.GetSpending()[spendcontrol.WindowSession]; spent != expectedCost {
t.Fatalf("pending reservation remained: %v", spent)
}