Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

275 lines
8.5 KiB
Go

package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"os"
"sync"
"time"
"github.com/zzet/gortex/internal/tokens"
)
// opus47Counter measures the input-token cost of a payload against the
// Claude Opus 4.7 tokenizer. We use three strategies, picked at flag
// time, that share this interface:
//
// - modelCounter: an in-process, per-model tiktoken estimate via
// internal/tokens.CountFor (default). Offline, deterministic, and
// tokenizer-aware — it counts with the encoding the model family
// actually uses and applies the family calibration ratio, so the
// estimate tracks each fixture instead of a single flat scalar.
// - cachedCounter: reads pre-computed exact counts from a JSON
// sidecar on disk. Falls back to the model counter when the cache
// misses, so a partial cache is still useful.
// - apiCounter: calls Anthropic's `messages/count_tokens`
// endpoint with the configured model id; populates the cache on
// success so subsequent runs are deterministic. Requires
// ANTHROPIC_API_KEY in the environment.
//
// Returning `exact=true` lets the scorecard label each row as
// estimated or exact — important for the published artifact to be
// honest about which numbers came from where.
type opus47Counter interface {
Count(caseName, channel, payload string, cl100k int) (count int, exact bool)
}
// defaultOpus47Model is the model id the offline counter resolves its
// tokenizer family from when --opus47-model is left unset.
const defaultOpus47Model = "claude-opus-4-20250514"
// --- model counter ---------------------------------------------------
// modelCounter estimates input tokens with internal/tokens.CountFor —
// the per-model, tokenizer-aware estimator. A Claude model id resolves
// to cl100k_base scaled by the empirically calibrated Claude ratio;
// because it tokenizes the actual payload, the estimate varies
// per-fixture rather than applying one uniform scalar. The cheapest
// strategy: pure in-process compute, no I/O, no network.
type modelCounter struct{ model string }
func newModelCounter(model string) modelCounter {
if model == "" {
model = defaultOpus47Model
}
return modelCounter{model: model}
}
func (c modelCounter) Count(_, _, payload string, _ int) (int, bool) {
return tokens.CountFor(c.model, payload), false
}
// --- cached counter --------------------------------------------------
// opus47CacheEntry records exact counts for one fixture's two channels.
// JSON keys mirror the encoder names so the on-disk file is easy to
// edit by hand.
type opus47CacheEntry struct {
JSON int `json:"json"`
GCX int `json:"gcx"`
}
// opus47Cache is the on-disk shape of `opus47-counts.json` — a map
// keyed by case name. Missing entries are tolerated: the cached
// counter falls through to the model counter and the API counter
// fills the gap on the next `--use-api` run.
type opus47Cache map[string]opus47CacheEntry
// loadOpus47Cache reads the cache from disk. Returns an empty cache
// (not an error) when the file is missing; surfaces real I/O errors
// so the harness fails loud on permission / disk problems.
func loadOpus47Cache(path string) (opus47Cache, error) {
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return opus47Cache{}, nil
}
if err != nil {
return nil, fmt.Errorf("read opus47 cache %s: %w", path, err)
}
if len(bytes.TrimSpace(raw)) == 0 {
return opus47Cache{}, nil
}
var c opus47Cache
if err := json.Unmarshal(raw, &c); err != nil {
return nil, fmt.Errorf("parse opus47 cache %s: %w", path, err)
}
return c, nil
}
// saveOpus47Cache writes the cache atomically (tmp+rename) so a
// crash mid-flush doesn't corrupt the file.
func saveOpus47Cache(path string, c opus47Cache) error {
tmp := path + ".tmp"
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
// cachedCounter consults the cache first, falls through to the model
// counter on miss. Concurrent reads are safe; concurrent writes (via
// the API counter) are serialized through the mutex.
type cachedCounter struct {
mu sync.RWMutex
cache opus47Cache
fallback modelCounter
}
func newCachedCounter(c opus47Cache, model string) *cachedCounter {
if c == nil {
c = opus47Cache{}
}
return &cachedCounter{cache: c, fallback: newModelCounter(model)}
}
func (c *cachedCounter) Count(caseName, channel, payload string, cl100k int) (int, bool) {
c.mu.RLock()
entry, ok := c.cache[caseName]
c.mu.RUnlock()
if ok {
switch channel {
case "json":
if entry.JSON > 0 {
return entry.JSON, true
}
case "gcx":
if entry.GCX > 0 {
return entry.GCX, true
}
}
}
return c.fallback.Count(caseName, channel, payload, cl100k)
}
func (c *cachedCounter) snapshot() opus47Cache {
c.mu.RLock()
defer c.mu.RUnlock()
out := make(opus47Cache, len(c.cache))
maps.Copy(out, c.cache)
return out
}
func (c *cachedCounter) store(caseName, channel string, count int) {
c.mu.Lock()
defer c.mu.Unlock()
entry := c.cache[caseName]
switch channel {
case "json":
entry.JSON = count
case "gcx":
entry.GCX = count
}
c.cache[caseName] = entry
}
// --- API counter -----------------------------------------------------
// apiCounter wraps a cachedCounter and falls through to Anthropic's
// `messages/count_tokens` endpoint on cache miss, then stores the
// result for future runs. Network failures degrade to the model
// counter with a warning on stderr — the harness must keep running
// when the user is offline.
type apiCounter struct {
cached *cachedCounter
client *http.Client
apiKey string
model string
apiBase string
warned sync.Once
}
// opus47APIEndpoint is the documented Anthropic counter endpoint.
const opus47APIEndpoint = "https://api.anthropic.com/v1/messages/count_tokens"
// opus47AnthropicVersion is the API header required for the
// count_tokens endpoint. Bumping requires verifying the response
// schema still has `input_tokens`.
const opus47AnthropicVersion = "2023-06-01"
func newAPICounter(cached *cachedCounter, model string) (*apiCounter, error) {
apiKey := os.Getenv("ANTHROPIC_API_KEY")
if apiKey == "" {
return nil, errors.New("--use-api requires ANTHROPIC_API_KEY in environment")
}
if model == "" {
model = defaultOpus47Model
}
return &apiCounter{
cached: cached,
client: &http.Client{Timeout: 30 * time.Second},
apiKey: apiKey,
model: model,
apiBase: opus47APIEndpoint,
}, nil
}
func (a *apiCounter) Count(caseName, channel, payload string, cl100k int) (int, bool) {
if got, ok := a.cached.Count(caseName, channel, payload, cl100k); ok {
return got, true
}
count, err := a.callAPI(payload)
if err != nil {
// Fail soft: warn once, keep ticking with the model counter.
a.warned.Do(func() {
fmt.Fprintf(os.Stderr, "wire-bench: opus47 API counter degraded to in-process model counter after first error: %v\n", err)
})
return a.cached.fallback.Count(caseName, channel, payload, cl100k)
}
a.cached.store(caseName, channel, count)
return count, true
}
// apiResponse mirrors the documented response shape; we only care
// about input_tokens but parse strictly so a schema drift surfaces.
type apiResponse struct {
InputTokens int `json:"input_tokens"`
}
// callAPI POSTs the payload as a single user message and returns the
// integer input-token count. The chat-wrapper overhead (~3-5 tokens
// for the role+system framing) is part of the answer — documenting
// that in the scorecard footnote rather than trying to subtract it
// keeps the harness honest about exactly what it measured.
func (a *apiCounter) callAPI(payload string) (int, error) {
body, _ := json.Marshal(map[string]any{
"model": a.model,
"messages": []map[string]string{
{"role": "user", "content": payload},
},
})
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, a.apiBase, bytes.NewReader(body))
if err != nil {
return 0, err
}
req.Header.Set("x-api-key", a.apiKey)
req.Header.Set("anthropic-version", opus47AnthropicVersion)
req.Header.Set("content-type", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("opus47 API %d: %s", resp.StatusCode, string(raw))
}
var r apiResponse
if err := json.Unmarshal(raw, &r); err != nil {
return 0, fmt.Errorf("opus47 API parse: %w", err)
}
if r.InputTokens <= 0 {
return 0, fmt.Errorf("opus47 API returned non-positive count: %s", string(raw))
}
return r.InputTokens, nil
}