Files
yetone 71d2da1f42 catalog, usage, gateway, gui: a price can have a long-context tier and a 1-hour cache write, and the price boxes take decimals (PAMI on Discord)
OpenAI bills gpt-6-astra, gpt-6.1-sol and gpt-6-luna over 272K input at 2× input and cache and 1.5× output, for the whole request. A price now has tiers: `magpie model price <m> 10,50,1,12.5 --tier 272k 20,75,2,25` (--tier can repeat). A tier applies when input + cache reads + cache writes is over its size, so 272,000 is not over and 272,001 is. The built-in prices take tiers only where models.dev lists one (cost.tiers of type "context"), and it does list one for those three. The ledger, each request and OTLP are costed at the tier the call reached. Session and day totals sum many calls, so they stay at the base price.

Anthropic bills a cache write kept for an hour at 2× input and one kept for 5 minutes at 1.25×. Its usage reports which is which in cache_creation.ephemeral_5m/1h_input_tokens. The gateway (API and Claude subscription) and Claude Code's transcripts now keep that split. A price's fifth part is the 1-hour write. When it isn't given, it is 2× input: that is Anthropic's rule, and only Anthropic reports 1-hour writes. Claude list prices carry the 2× too. cache_write_tokens is still the total, and the usage CSV adds cache_write_5m_tokens and cache_write_1h_tokens at the end. The app shows the split on a request's Cache and on the usage tooltips. Old prices and old records, which have no 1-hour part, read as before. The sessions cache (18) and the call cache (calls-v8) are re-read once.

In the GUI, the price boxes were number inputs and dropped what they didn't parse ("0,25"). They are now decimal text boxes that take both "0.25" and "0,25". A model's price row has a 1-hour box (empty means 2× input) and a quiet Long-context price row. That row shows the list's tier greyed in, or sits behind a link when the list has none. A bad size is refused by name. en/zh/ja/de.

Tests:
- New: TestPriceTiers, TestCostSplit, TestPriceJSON, TestModelPriceTiers, TestModelPriceTierCmd, TestAnthropicCacheWriteSplit and TestClaudeCacheWriteSplit. The ledger CSV test expects the new columns.
- model-price-tiers.test.cjs is new; model-price-edit and provider-price-rate are updated. All of them fail against the old assets and pass in WebKit. Chromium couldn't be launched here.
- go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
2026-10-05 21:56:59 +08:00

281 lines
7.3 KiB
Go

package sessions
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
func traceOCMessages(f file, budget *int64) [][]byte {
var out [][]byte
for _, b := range f.oc.messages(f.sid) {
if *budget <= 0 {
break
}
*budget -= int64(len(b))
out = append(out, b)
}
return out
}
func traceOCParts(f file, mid string, budget *int64) [][]byte {
var out [][]byte
for _, b := range f.oc.parts(mid) {
if *budget <= 0 {
break
}
*budget -= int64(len(b))
out = append(out, b)
}
return out
}
func (r *TraceReader) pollOpenCode(bodies bool, budget *int64) []TraceSpan {
// Unchanged SQLite/WAL files need no queries. Opening a new connection for
// data_version would reset its baseline, so use both files' stat identities.
dbPath := openCodeDB()
if fileExists(dbPath) {
if info, err := os.Stat(dbPath); err == nil {
if r.ocInfo != nil && !os.SameFile(r.ocInfo, info) {
for key := range r.snapshots {
if strings.HasPrefix(key, "opencode:") {
delete(r.snapshots, key)
}
}
r.ocStamp = [4]int64{}
}
r.ocInfo = info
}
var stamp [4]int64
for i, path := range []string{dbPath, dbPath + "-wal"} {
if info, err := os.Stat(path); err == nil {
stamp[i*2], stamp[i*2+1] = info.Size(), info.ModTime().UnixNano()
}
}
if r.ocStamp == stamp && !r.ocPending {
return nil
}
out, pending := r.pollOpenCodeDB(dbPath, bodies, budget)
r.ocStamp, r.ocPending = stamp, pending
return out
}
files := openCodeJSONFiles(filepath.Join(OpenCodeDir(), "storage"))
for i, f := range files {
if store, ok := f.oc.(ocFiles); ok {
for _, message := range readDirectory(filepath.Join(store.root, "message", f.sid)) {
mid := strings.TrimSuffix(message.Name(), ".json")
for _, part := range readDirectory(filepath.Join(store.root, "part", mid)) {
if info, err := part.Info(); err == nil && info.Mode().IsRegular() {
files[i].size += info.Size()
if info.ModTime().After(files[i].mod) {
files[i].mod = info.ModTime()
}
}
}
}
}
}
sort.Slice(files, func(i, j int) bool { return files[i].mod.After(files[j].mod) })
if len(files) > Limit {
files = files[:Limit]
}
seen := map[string]bool{}
var out []TraceSpan
for _, f := range files {
path := "opencode:" + f.path
seen[path] = true
if !f.main || f.mod.Before(r.Since.Add(-2*time.Second)) || *budget <= 0 {
continue
}
s := r.snapshot(path)
if s.size == f.size && s.mod.Equal(f.mod) {
continue
}
localBudget := int64(32 << 20)
docs := traceOCMessages(f, &localBudget)
// Remember oversized legacy snapshots until they change. SQLite uses
// resumable row batches instead of this snapshot path.
if localBudget <= 0 {
*budget -= 32 << 20
s.size, s.mod = f.size, f.mod
continue
}
messages := make([]ocTraceMessage, 0, len(docs))
for _, b := range docs {
var m ocTraceMessage
if json.Unmarshal(b, &m) != nil {
continue
}
if m.Role == "" {
m.Role = m.Type
}
if m.Model.ID != "" {
m.ModelID = m.Model.ID
m.ProviderID = m.Model.ProviderID
}
if _, ok := f.oc.(ocV2DB); !ok {
for _, p := range traceOCParts(f, m.ID, &localBudget) {
var part ocTracePart
if json.Unmarshal(p, &part) == nil {
m.Content = append(m.Content, part)
}
}
}
messages = append(messages, m)
}
if localBudget <= 0 {
*budget -= 32 << 20
s.size, s.mod = f.size, f.mod
continue
}
*budget -= (32 << 20) - localBudget
out = append(out, r.observations(s, traceOpenCode(f.sid, messages, bodies))...)
s.size, s.mod = f.size, f.mod
}
for path := range r.snapshots {
if strings.HasPrefix(path, "opencode:") && !seen[path] {
delete(r.snapshots, path)
}
}
return out
}
type ocTracePart struct {
ID, Type, Text, Name, Tool, CallID string
Synthetic, Ignored bool
Time struct{ Created, Ran, Completed int64 }
State struct {
Status string
Input, Output, Content, Error json.RawMessage
Time struct{ Start, End int64 }
}
}
type ocTraceMessage struct {
ID, Type, Role, ParentID, ModelID, ProviderID, Finish, Text string
Model struct{ ID, ProviderID string }
Time struct{ Created, Completed int64 }
Tokens *struct {
Input, Output, Reasoning int
Cache struct{ Read, Write int }
}
Error json.RawMessage
Content []ocTracePart
}
func traceOpenCode(sid string, messages []ocTraceMessage, bodies bool) []TraceSpan {
sort.SliceStable(messages, func(i, j int) bool {
if messages[i].Time.Created == messages[j].Time.Created {
return messages[i].ID < messages[j].ID
}
return messages[i].Time.Created < messages[j].Time.Created
})
c := &traceCursor{agent: "opencode", session: sid, turns: map[string]*traceTurn{}}
var out []TraceSpan
var current *traceTurn
roots := map[string]*traceTurn{}
for _, m := range messages {
if m.ID == "" || m.Time.Created <= 0 {
continue
}
if m.Role == "user" {
current = &traceTurn{id: m.ID, start: ms(m.Time.Created), last: ms(m.Time.Created)}
if bodies {
current.input = m.Text
for _, p := range m.Content {
if p.Type == "text" && !p.Synthetic && !p.Ignored {
current.input += p.Text
}
}
}
roots[m.ID] = current
continue
}
if m.Role != "assistant" {
continue
}
t := roots[m.ParentID]
if t == nil {
if m.ParentID != "" {
continue
}
t = current
}
if t == nil {
continue
}
end := ms(m.Time.Completed)
failed := len(m.Error) > 0 && string(m.Error) != "null"
if !end.IsZero() {
s := c.span(t.id, m.ID, "model "+m.ModelID, "generation", ms(m.Time.Created), end)
s.Model, s.Provider, s.Error = m.ModelID, m.ProviderID, failed
if u := m.Tokens; u != nil {
s.Tokens = Tokens{Input: u.Input, Output: u.Output + u.Reasoning, CacheRead: u.Cache.Read, CacheWrite: u.Cache.Write}
s.Reasoning = u.Reasoning
}
if bodies {
s.Input = t.input
b, _ := json.Marshal(m.Content)
s.Output = string(b)
t.output = s.Output
}
out = append(out, s)
if end.After(t.last) {
t.last = end
}
t.failed = t.failed || failed
}
for _, p := range m.Content {
if p.Type != "tool" {
continue
}
if p.State.Status != "completed" && p.State.Status != "error" {
continue
}
start, done := ms(p.Time.Ran), ms(p.Time.Completed)
if start.IsZero() {
start = ms(p.Time.Created)
}
if start.IsZero() {
start = ms(p.State.Time.Start)
}
if done.IsZero() {
done = ms(p.State.Time.End)
}
name := p.Name
if name == "" {
name = p.Tool
}
key := p.ID
if key == "" {
key = p.CallID
}
if key == "" {
continue
}
s := c.span(t.id, key, name, "tool", start, done)
s.Error = p.State.Status == "error"
if bodies {
s.Input = string(p.State.Input)
s.Output = string(p.State.Output)
if s.Output == "" {
s.Output = string(p.State.Content)
}
if s.Error {
s.Output = string(p.State.Error)
}
}
out = append(out, s)
if done.After(t.last) {
t.last = done
}
t.failed = t.failed || s.Error
}
}
for _, t := range roots {
out = append(out, c.root(t, t.last, t.failed))
}
return out
}