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
275 lines
8.5 KiB
Go
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
|
|
}
|