diff --git a/PRODUCT.md b/PRODUCT.md index 7632b372..8a3f9a19 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -105,17 +105,17 @@ Gateway uploads renew a 30-second idle read deadline when bytes arrive, with a f Gateway admission allows model changes with reasoning, compaction and reconstructed WebSocket history when the current bound account remains usable and supports the requested model. WebSocket continuation identity survives normalization so rebuilt transcripts cannot authorize account migration. Complete portable history may still replace an unsupported binding; a durable rebind marker protects older model-scoped state after recorded account moves, legacy-scope adoption and restart. Additive migration 019 preserves existing bindings and treats their current account as the continuity baseline; earlier account moves cannot be reconstructed. Incompatible model continuations return HTTP 409 with `conversation_model_conflict`, distinct from an unavailable account binding, and request history and every supported language provide model-specific recovery guidance. Live provider and desktop acceptance remain separate. -Resource groups offer optional session stickiness, disabled by default. When enabled, requests that carry no gateway session derive a conversation key from client conversation headers, Codex turn metadata, Claude Code session metadata or, as a fallback, a hash of the system prompt and first user message, following CLIProxyAPI's session-affinity order. Derived keys create one-hour preferences rather than strict bindings: the preferred resource ranks first only after every policy, quota, allowance, cooldown and capacity check, unavailable resources fall back to normal scheduling, and a full affinity table skips the preference instead of rejecting traffic. Subagents identified by Codex parent threads, Claude parent sessions or `X-Parent-Session-Id` prefer their parent's resource without changing its binding. Claude Code subagents share their main session key. Explicit sessions keep the existing strict binding rules. +Resource groups offer optional session stickiness, disabled by default. When enabled, requests that carry no gateway session derive a conversation key from client conversation headers, Codex turn metadata, Claude Code session metadata or, as a fallback, a hash of the system prompt and first user message, following CLIProxyAPI's session-affinity order. Derived keys create one-hour preferences in their own namespace, so they never read or rewrite an explicit binding: the preferred resource ranks first only after every policy, quota, allowance, cooldown and capacity check, and unavailable resources fall back to normal scheduling. Preferences may hold at most a quarter of the shared affinity table, are skipped rather than rejected when it is full, and are evicted first when an explicit conversation needs a slot. Subagents identified by Codex parent threads, Claude parent sessions or `X-Parent-Session-Id` prefer their parent's resource through read-only lookups of the parent's explicit binding or preference. Claude Code subagents share their main session key. Explicit sessions keep the existing strict binding rules. -API channels can be edited in place at `/channels`: name, platform, protocol, base URL and, optionally, the API key. Leaving the key blank keeps the stored secret. Edits retain the channel ID, group assignments, limits and request history; endpoint, protocol or key changes reset verification, and endpoint or protocol changes invalidate the model catalog. Resource groups can be deleted when no active personal key or resource allowance references them. Deletion retires the group: routing, grants, resources, model policy and bindings are removed in one audited transaction, while request history and usage aggregates keep their workspace scope and the name becomes available again. +API channels can be renamed and given a replacement key in place at `/channels`; leaving the key blank keeps the stored secret. Base URL, platform and protocol remain fixed so a stored key never reaches another upstream; those changes still require a separate channel. Edits retain the channel ID, group assignments, limits and request history, and a key change resets verification. Resource groups can be deleted when no active personal key or resource allowance references them. Deletion retires the group: routing, grants, resources, model policy and bindings are removed in one audited transaction, while request history and usage aggregates keep their workspace scope. Retired groups keep their names; uniqueness applies only to active groups, so the name can be reused. -Network proxies are shown as compact cards in a responsive grid whose column count follows the page width. Each card shows its status and latency, an IP purity panel, and the exit IP, location, network, verdict and bound accounts exactly once, merging the proxy check with the IP report. The panel reads the exit IP observed by the last successful check, never the proxy endpoint, from the public ip.net.coffee lookup used by its IP scoring page and shows its trust score with the same five purity tiers, network type, risk flags, ASN, ISP, location and verdict. Only public exit IPs are sent; results are cached per IP for six hours, forced refreshes are paced to one per 30 seconds per IP, and unchecked proxies make no lookup. The report is informational and does not affect routing or proxy checks. +Network proxies are shown as compact cards in a responsive grid whose column count follows the page width. Each card shows its status and latency, an IP purity panel, and the exit IP, location, network, verdict and bound accounts exactly once, merging the proxy check with the IP report. Opening the page only reads reports the server already holds; an administrator must explicitly request a lookup, which sends the exit IP observed by the last successful check, never the proxy endpoint, to the public ip.net.coffee lookup used by its IP scoring page. The panel shows its trust score with the same five purity tiers, network type, risk flags, ASN, ISP, location and verdict. Only public exit IPs are sent; results are cached per IP for six hours, concurrent lookups for one IP share a single request, attempts are paced to one per 30 seconds per IP from when they start, and rate-limited responses back off for five minutes. The report is informational and does not affect routing or proxy checks. Workspace administrators can delete a resource allowance once no admitted request still holds one of its reservations. Deletion removes the allowance, its revisions, price snapshots, key bindings and allowance ledger in one audited transaction; request history and usage statistics are retained. Keys that were bound to it keep their resource group without a budget limit, and the group's resources are no longer locked by the allowance. -OpenAI Images requests (`POST /v1/images/generations` and `/v1/images/edits`, JSON or multipart) route only to Codex subscriptions and forward to the ChatGPT backend's direct Images endpoints. Codex catalogs include the built-in `gpt-image-*` models so they appear in model lists, group allowlists and pricing; image models are rejected on text endpoints, and text models on Images endpoints. Streaming partial images is not supported. Request history records the `images` operation with reported token usage. Pricing reads image-generation output prices per image token. CC Switch picker suggestions omit image models. Live ChatGPT image acceptance remains unverified. +OpenAI Images requests (`POST /v1/images/generations` and `/v1/images/edits`, JSON or multipart) route only to Codex subscriptions and forward to the ChatGPT backend's direct Images endpoints. Codex catalogs include the built-in `gpt-image-*` models so they appear in model lists, group allowlists and pricing; image models are rejected on text endpoints, and text models on Images endpoints. Streaming partial images is not supported. Request history records the `images` operation with reported token usage. Allowance pricing for image models charges output at the image output rate and settles input by modality, using the image input rate for reported image input tokens. CC Switch picker suggestions omit image models. Live ChatGPT image acceptance remains unverified. -OpenCode Go API channels show upstream spending windows from the official key-authenticated `GET https://opencode.ai/zen/go/v1/usage` endpoint: the rolling five-hour, weekly and monthly percentages with their reset times. Reads share the quota cache, refresh bounds and failure states of subscription usage. These observations are informational and do not gate channel scheduling. +OpenCode Go API channels whose base URL is the official `https://opencode.ai` origin show upstream spending windows from the key-authenticated `GET https://opencode.ai/zen/go/v1/usage` endpoint; relay URLs using the preset never send their key there. The windows are the rolling five-hour, weekly and monthly percentages with their reset times. Reads share the quota cache, refresh bounds and failure states of subscription usage. These observations are informational and do not gate channel scheduling. ## Brand Commitments diff --git a/internal/accounts/apikey.go b/internal/accounts/apikey.go index 3ecd67ed..17202088 100644 --- a/internal/accounts/apikey.go +++ b/internal/accounts/apikey.go @@ -105,7 +105,8 @@ func normalizeBaseURL(value string) (string, error) { return strings.TrimRight(u.String(), "/"), nil } -// APIChannelUpdate edits an API channel in place. An empty APIKey keeps the stored key. +// APIChannelUpdate renames a channel or replaces its key. An empty APIKey keeps the stored key; +// BaseURL, Platform and Protocol must match the stored channel. type APIChannelUpdate struct { Name string APIKey string @@ -114,8 +115,8 @@ type APIChannelUpdate struct { Protocol string } -// UpdateAPIChannel changes channel settings without replacing its resource ID, group membership, -// limits or conversation bindings. Endpoint and protocol changes invalidate the model catalog. +// UpdateAPIChannel changes the name or key without replacing the resource ID, group membership, +// limits or conversation bindings. func (s *Service) UpdateAPIChannel(ctx context.Context, id string, input APIChannelUpdate) (Account, error) { name, err := NormalizeName(input.Name) if err != nil { @@ -151,8 +152,12 @@ func (s *Service) UpdateAPIChannel(ctx context.Context, id string, input APIChan if credential.APIPlatform == "" && credential.APIProtocol == "" { credential.APIPlatform, credential.APIProtocol = previous.APIPlatform, previous.APIProtocol } - endpointChanged := previous.BaseURL != credential.BaseURL || previous.APIConfig() != credential.APIConfig() - connectionChanged := endpointChanged || credential.AccessToken != previous.AccessToken + // Credentials and conversation bindings stay scoped to the original upstream: a stored key must + // never be sent to another endpoint or protocol, so those changes still require a new channel. + if previous.BaseURL != credential.BaseURL || previous.APIConfig() != credential.APIConfig() { + return Account{}, ErrIdentity + } + connectionChanged := credential.AccessToken != previous.AccessToken status := row.Status if connectionChanged { status = "unverified" @@ -168,11 +173,6 @@ func (s *Service) UpdateAPIChannel(ctx context.Context, id string, input APIChan return Account{}, err } } - if endpointChanged { - if err := q.InvalidateAccountCatalog(ctx, id); err != nil { - return Account{}, err - } - } now := s.now().Unix() if name != row.Name { if err := q.RenameAccount(ctx, db.RenameAccountParams{ID: id, TenantID: s.tenantID, Name: name, UpdatedAt: now}); err != nil { diff --git a/internal/allocations/allocations.go b/internal/allocations/allocations.go index db94a3f4..5b0e9f21 100644 --- a/internal/allocations/allocations.go +++ b/internal/allocations/allocations.go @@ -61,6 +61,8 @@ type Rate struct { Input int64 `json:"input"` Cached int64 `json:"cached"` Output int64 `json:"output"` + // ImageInput charges image input tokens of image models; zero falls back to Input. + ImageInput int64 `json:"image_input,omitempty"` } type Config struct { Mode string `json:"mode"` @@ -240,7 +242,7 @@ func normalize(c *Config) error { } models[model] = true c.Rates[i].Model = model - if r.Input <= 0 || r.Cached < 0 || r.Output <= 0 || r.Input > 1_000_000_000 || r.Cached > 1_000_000_000 || r.Output > 1_000_000_000 { + if r.Input <= 0 || r.Cached < 0 || r.Output <= 0 || r.ImageInput < 0 || r.Input > 1_000_000_000 || r.Cached > 1_000_000_000 || r.Output > 1_000_000_000 || r.ImageInput > 1_000_000_000 { return ErrInput } } @@ -674,7 +676,7 @@ func (s *Service) applyPrices(ctx context.Context, q *db.Queries, groupID int64, config.Rates = make([]Rate, 0, len(ids)) for _, model := range ids { if price, ok := s.pricing.Lookup(model); ok { - config.Rates = append(config.Rates, Rate{Model: model, Input: price.Input, Cached: price.Cached, Output: price.Output}) + config.Rates = append(config.Rates, Rate{Model: model, Input: price.Input, Cached: price.Cached, Output: price.Output, ImageInput: price.ImageInput}) continue } if rate, ok := previousByModel[model]; ok { @@ -687,12 +689,21 @@ func (s *Service) applyPrices(ctx context.Context, q *db.Queries, groupID int64, return &catalog, nil } func Cost(r Rate, input, output, cached int64) (int64, error) { - if input < 0 || output < 0 || cached < 0 || cached > input || input > 1_000_000_000 || output > 1_000_000_000 { + return CostWithImages(r, input, 0, output, cached) +} + +// CostWithImages settles input by modality: imageInput tokens are part of input and use the image rate. +func CostWithImages(r Rate, input, imageInput, output, cached int64) (int64, error) { + if input < 0 || output < 0 || cached < 0 || imageInput < 0 || cached+imageInput > input || input > 1_000_000_000 || output > 1_000_000_000 { return 0, ErrInput } + imageRate := r.ImageInput + if imageRate == 0 { + imageRate = r.Input + } // Round only after summing all token categories; even sub-micro charges remain nonzero. sum := new(big.Int) - for _, v := range [][2]int64{{input - cached, r.Input}, {cached, r.Cached}, {output, r.Output}} { + for _, v := range [][2]int64{{input - cached - imageInput, r.Input}, {imageInput, imageRate}, {cached, r.Cached}, {output, r.Output}} { if v[1] < 0 { return 0, ErrInput } diff --git a/internal/allocations/allocations_test.go b/internal/allocations/allocations_test.go index 1e5fbc10..74ab3d22 100644 --- a/internal/allocations/allocations_test.go +++ b/internal/allocations/allocations_test.go @@ -1772,3 +1772,24 @@ func TestManagedAccountDeletionExplainsPoolLock(t *testing.T) { t.Fatal("missing actionable lock error", err) } } + +func TestCostSettlesImageInputByModality(t *testing.T) { + // Rates are micro-USD per million tokens: text input $5/M, image input $8/M, image output $30/M. + rate := Rate{Input: 5_000_000, Cached: 1_250_000, Output: 30_000_000, ImageInput: 8_000_000} + cost, err := CostWithImages(rate, 1_000_000, 400_000, 1_000_000, 100_000) + // 500k text * 5 + 400k image * 8 + 100k cached * 1.25 + 1M output * 30 = 2.5 + 3.2 + 0.125 + 30 USD. + if err != nil || cost != 35_825_000 { + t.Fatal(cost, err) + } + if plain, err := Cost(rate, 1_000_000, 1_000_000, 100_000); err != nil || plain != 34_625_000 { + t.Fatal("text-only cost changed", plain, err) + } + fallback := rate + fallback.ImageInput = 0 + if cost, err := CostWithImages(fallback, 1_000_000, 400_000, 0, 0); err != nil || cost != 5_000_000 { + t.Fatal("missing image rate must use the input rate", cost, err) + } + if _, err := CostWithImages(rate, 100, 60, 0, 50); !errors.Is(err, ErrInput) { + t.Fatal("overlapping categories accepted", err) + } +} diff --git a/internal/allocations/runtime.go b/internal/allocations/runtime.go index 85fef1a0..eefb1bdd 100644 --- a/internal/allocations/runtime.go +++ b/internal/allocations/runtime.go @@ -195,9 +195,11 @@ func Begin(ctx context.Context, q *db.Queries, r Request, now int64, location *t type Completion struct { Input, Output, Cached int64 - Known bool - Dispatched bool - Rejected bool + // ImageInput is the image share of Input, reported by image generation responses. + ImageInput int64 + Known bool + Dispatched bool + Rejected bool } func Finish(ctx context.Context, q *db.Queries, id string, c Completion, now int64, manual bool) error { @@ -237,7 +239,7 @@ func Finish(ctx context.Context, q *db.Queries, id string, c Completion, now int if err != nil { return err } - cost, err = Cost(rate, c.Input, c.Output, c.Cached) + cost, err = CostWithImages(rate, c.Input, c.ImageInput, c.Output, c.Cached) if err != nil { return err } diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go index dc2e63d0..22cef3dd 100644 --- a/internal/backup/backup_test.go +++ b/internal/backup/backup_test.go @@ -212,6 +212,8 @@ ALTER TABLE accounts DROP COLUMN reset_check_at; ALTER TABLE accounts DROP COLUMN usage_revision; ALTER TABLE account_affinity DROP COLUMN rebound; ALTER TABLE account_affinity DROP COLUMN model; +DROP INDEX account_groups_active_name; +ALTER TABLE account_affinity DROP COLUMN derived; ALTER TABLE account_groups DROP COLUMN session_sticky; ALTER TABLE account_groups DROP COLUMN deleted_at; ALTER TABLE account_groups DROP COLUMN routing_preference; diff --git a/internal/channels/channels.go b/internal/channels/channels.go index ced4b1cc..a227cd45 100644 --- a/internal/channels/channels.go +++ b/internal/channels/channels.go @@ -106,7 +106,7 @@ func (s *Service) Delete(ctx context.Context, id string) error { return s.resources.Delete(ctx, id) } -// Update edits name, endpoint, platform, protocol and optionally the key while keeping the channel ID. +// Update renames a channel and optionally replaces its key while keeping the channel ID. func (s *Service) Update(ctx context.Context, id string, input Input) (Channel, error) { if _, err := s.Get(ctx, id); err != nil { return Channel{}, err diff --git a/internal/gateway/affinity.go b/internal/gateway/affinity.go index 1c68eef8..561404d5 100644 --- a/internal/gateway/affinity.go +++ b/internal/gateway/affinity.go @@ -18,6 +18,12 @@ func sessionDigest(userID, groupID int64, session string) [32]byte { return sha256.Sum256([]byte(fmt.Sprintf("%d:%d:%s", userID, groupID, session))) } +// derivedDigest hashes stickiness preferences in a namespace explicit session digests cannot produce: +// explicit inputs always begin with a numeric user ID. +func derivedDigest(userID, groupID int64, session string) [32]byte { + return sha256.Sum256([]byte(fmt.Sprintf("derived:%d:%d:%s", userID, groupID, session))) +} + func sessionBinding(ctx context.Context, q *db.Queries, params db.ListSessionAffinitiesParams, scope string) (*db.ListSessionAffinitiesRow, error) { bindings, err := q.ListSessionAffinities(ctx, params) if err != nil { diff --git a/internal/gateway/allocations.go b/internal/gateway/allocations.go index 30e308b0..d480235b 100644 --- a/internal/gateway/allocations.go +++ b/internal/gateway/allocations.go @@ -29,7 +29,7 @@ func (e *observation) settleAllocation(ctx context.Context, q *db.Queries) error } known := e.record.InputTokens != nil && e.record.OutputTokens != nil && (e.usageFinal || e.record.Outcome == "success" || e.record.Outcome == "incomplete") rejected := e.record.UpstreamStatus != nil && *e.record.UpstreamStatus >= 400 - err := allocations.Finish(ctx, q, e.record.RequestID, allocations.Completion{Input: value(e.record.InputTokens), Output: value(e.record.OutputTokens), Cached: value(e.record.CachedTokens), Known: known, Dispatched: e.upstreamDispatched, Rejected: rejected}, e.service.now().UnixMilli(), false) + err := allocations.Finish(ctx, q, e.record.RequestID, allocations.Completion{Input: value(e.record.InputTokens), ImageInput: e.imageInputTokens, Output: value(e.record.OutputTokens), Cached: value(e.record.CachedTokens), Known: known, Dispatched: e.upstreamDispatched, Rejected: rejected}, e.service.now().UnixMilli(), false) if errors.Is(err, sql.ErrNoRows) { return ErrAllocationAccounting } diff --git a/internal/gateway/history.go b/internal/gateway/history.go index d02c7a07..de7429fe 100644 --- a/internal/gateway/history.go +++ b/internal/gateway/history.go @@ -90,6 +90,9 @@ func requestReasoningEffort(input map[string]json.RawMessage, kind Kind) string } type observation struct { + // imageInputTokens is the image share of input tokens reported by Images responses; it only + // affects allowance settlement and is not stored in request history. + imageInputTokens int64 route *RouteExplanation kind Kind service *Service @@ -381,6 +384,7 @@ func (e *observation) observe(raw []byte) { Output *int64 `json:"output_tokens"` Details struct { Cached *int64 `json:"cached_tokens"` + Image *int64 `json:"image_tokens"` } `json:"input_tokens_details"` } var value struct { @@ -408,4 +412,15 @@ func (e *observation) observe(raw []byte) { e.record.InputTokens = valid(tokens.Input) e.record.OutputTokens = valid(tokens.Output) e.record.CachedTokens = valid(tokens.Details.Cached) + e.imageInputTokens = 0 + // Settlement rejects overlapping categories, so an inconsistent split keeps the text input rate. + if image := valid(tokens.Details.Image); image != nil && e.record.InputTokens != nil { + cached := int64(0) + if e.record.CachedTokens != nil { + cached = *e.record.CachedTokens + } + if cached+*image <= *e.record.InputTokens { + e.imageInputTokens = *image + } + } } diff --git a/internal/gateway/selection.go b/internal/gateway/selection.go index 7e4c586b..46a3939c 100644 --- a/internal/gateway/selection.go +++ b/internal/gateway/selection.go @@ -93,6 +93,10 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i if hints.Session != "" && (session == "" || hints.Replace) { session, derived = hints.Session, hints.Derived digest = sessionDigest(userID, groupID, session) + if derived { + // Preferences live in their own namespace so they can never read or rewrite an explicit binding. + digest = derivedDigest(userID, groupID, session) + } } // Subagents prefer their parent's resource among admitted candidates, as Codex reviews already do. if parentThread == "" && hints.Parent != "" && hints.Parent != session { @@ -207,7 +211,7 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i bindingModel = existing.Model } if existing.Provider != scope { - if err := rememberAffinity(ctx, q, userID, groupID, digest, scope, id, bindingModel, now, expires, existing.Rebound); err != nil { + if err := rememberAffinity(ctx, q, userID, groupID, digest, scope, id, bindingModel, now, expires, existing.Rebound, false); err != nil { return "", digest, err } } @@ -228,13 +232,20 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i parentAccount := "" if parentThread != "" && affinity != continuationAffinity { // Resolve only in the authenticated user's pool; parent hints never contain trusted account IDs. - hash := sessionDigest(userID, groupID, parentThread) - binding, err := sessionBinding(ctx, q, db.ListSessionAffinitiesParams{GroupID: groupID, UserID: userID, SessionHash: hash[:], Now: now}, "auto") - if err != nil && !errors.Is(err, ErrAffinityUnavailable) { - return "", digest, err - } - if binding != nil { - parentAccount = binding.AccountID + // Parent lookups are read-only: an explicit parent binding wins, then a derived parent preference. + hashes := [][32]byte{sessionDigest(userID, groupID, parentThread)} + if policy.Routing.SessionSticky { + hashes = append(hashes, derivedDigest(userID, groupID, parentThread)) + } + for _, hash := range hashes { + binding, err := sessionBinding(ctx, q, db.ListSessionAffinitiesParams{GroupID: groupID, UserID: userID, SessionHash: hash[:], Now: now}, "auto") + if err != nil && !errors.Is(err, ErrAffinityUnavailable) { + return "", digest, err + } + if binding != nil { + parentAccount = binding.AccountID + break + } } } candidates := make([]string, 0, len(available)) @@ -409,7 +420,7 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i err = q.ReplaceAccountAffinity(ctx, db.ReplaceAccountAffinityParams{UserID: userID, GroupID: groupID, SessionHash: digest[:], Provider: scope, AccountID: id, Model: bindingModel, ExpiresAt: expires}) } else { // Creating an auto binding from a legacy scope can also move the account. - err = rememberAffinity(ctx, q, userID, groupID, digest, scope, id, bindingModel, now, expires, replacement != nil && !derived) + err = rememberAffinity(ctx, q, userID, groupID, digest, scope, id, bindingModel, now, expires, replacement != nil && !derived, derived) } // Derived preferences must never reject traffic that would otherwise be scheduled. if derived && errors.Is(err, ErrAffinityLimit) { @@ -436,7 +447,13 @@ func preferredProvider(kind Kind) string { } } -func rememberAffinity(ctx context.Context, q *db.Queries, userID, groupID int64, digest [32]byte, scope, id, model string, now, expires int64, rebound bool) error { +const ( + affinityLimit = 4096 + // Derived preferences may hold at most a quarter of the shared table. + derivedAffinityLimit = affinityLimit / 4 +) + +func rememberAffinity(ctx context.Context, q *db.Queries, userID, groupID int64, digest [32]byte, scope, id, model string, now, expires int64, rebound, derived bool) error { if err := q.PruneAccountAffinity(ctx, now); err != nil { return err } @@ -444,8 +461,23 @@ func rememberAffinity(ctx context.Context, q *db.Queries, userID, groupID int64, if err != nil { return err } - if count >= 4096 { - return ErrAffinityLimit + if derived { + preferences, err := q.CountDerivedAffinity(ctx) + if err != nil { + return err + } + if count >= affinityLimit || preferences >= derivedAffinityLimit { + return ErrAffinityLimit + } + } else if count >= affinityLimit { + // An explicit conversation never fails because preferences filled the table. + evicted, err := q.EvictDerivedAffinity(ctx) + if err != nil { + return err + } + if evicted == 0 { + return ErrAffinityLimit + } } - return q.CreateAccountAffinity(ctx, db.CreateAccountAffinityParams{GroupID: groupID, Provider: scope, UserID: userID, SessionHash: digest[:], AccountID: id, Model: model, ExpiresAt: expires, Rebound: rebound}) + return q.CreateAccountAffinity(ctx, db.CreateAccountAffinityParams{GroupID: groupID, Provider: scope, UserID: userID, SessionHash: digest[:], AccountID: id, Model: model, ExpiresAt: expires, Rebound: rebound, Derived: derived}) } diff --git a/internal/gateway/sticky_test.go b/internal/gateway/sticky_test.go index 089d1ae2..4523ae07 100644 --- a/internal/gateway/sticky_test.go +++ b/internal/gateway/sticky_test.go @@ -140,3 +140,55 @@ func TestStickyHintsReadClientConversationMetadata(t *testing.T) { t.Fatal("message fallback changed between turns") } } + +func TestDerivedPreferencesNeverRewriteExplicitBindings(t *testing.T) { + s, _, _ := stickyGateway(t, true) + explicit := http.Header{} + explicit.Set("Session_id", "synthetic-shared-id") + original := openSticky(t, s, explicit, stickyOpening) + derived := http.Header{} + derived.Set("X-Session-Id", "synthetic-shared-id") + if _, err := s.accounts.SetEnabled(context.Background(), original, false); err != nil { + t.Fatal(err) + } + if fallback := openSticky(t, s, derived, stickyOpening); fallback == original { + t.Fatal("derived request used the disabled account") + } + if _, err := s.accounts.SetEnabled(context.Background(), original, true); err != nil { + t.Fatal(err) + } + // The explicit continuation keeps its own binding regardless of the derived fallback. + if got := openSticky(t, s, explicit, stickyOpening); got != original { + t.Fatalf("explicit session moved from %s to %s", original, got) + } +} + +func TestDerivedPreferencesNeverBlockExplicitSessions(t *testing.T) { + s, _, _ := stickyGateway(t, true) + var existing int + if err := s.db.QueryRow("SELECT count(*) FROM account_affinity").Scan(&existing); err != nil { + t.Fatal(err) + } + // Leave exactly one free slot in the shared table. + if _, err := s.db.Exec(`WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i < ?) + INSERT INTO account_affinity(user_id,group_id,session_hash,provider,account_id,expires_at,model,rebound,derived) + SELECT 1,1,randomblob(32),'auto','synthetic-filler',unixepoch()+3600,'',0,0 FROM n`, affinityLimit-1-existing); err != nil { + t.Fatal(err) + } + openSticky(t, s, http.Header{}, stickyOpening) + var derived int + if err := s.db.QueryRow("SELECT count(*) FROM account_affinity WHERE derived=1").Scan(&derived); err != nil || derived != 1 { + t.Fatal("derived preference did not take the free slot", derived, err) + } + explicit := http.Header{} + explicit.Set("Session_id", "synthetic-new-explicit") + openSticky(t, s, explicit, stickyOpening) + var total int + if err := s.db.QueryRow("SELECT count(*), sum(derived) FROM account_affinity").Scan(&total, &derived); err != nil || total != affinityLimit || derived != 0 { + t.Fatal("explicit session did not displace the preference", total, derived, err) + } + // With the table full of explicit bindings, preferences are skipped rather than rejected. + if got := openSticky(t, s, http.Header{}, `{"model":"synthetic-model","input":[{"role":"user","content":"Another synthetic opening"}]}`); got == "" { + t.Fatal("derived request was rejected") + } +} diff --git a/internal/groups/groups.go b/internal/groups/groups.go index babd9416..82b6ae14 100644 --- a/internal/groups/groups.go +++ b/internal/groups/groups.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "errors" - "fmt" "slices" "strings" "time" @@ -290,11 +289,9 @@ func (s *Service) Delete(ctx context.Context, id int64) error { } defer tx.Rollback() q := s.queries.WithTx(tx) - row, err := q.GetTenantGroup(ctx, db.GetTenantGroupParams{ID: id, TenantID: s.tenantID}) - if errors.Is(err, sql.ErrNoRows) { + if _, err := q.GetTenantGroup(ctx, db.GetTenantGroupParams{ID: id, TenantID: s.tenantID}); errors.Is(err, sql.ErrNoRows) { return ErrNotFound - } - if err != nil { + } else if err != nil { return err } allocated, err := q.GroupHasAllocation(ctx, id) @@ -317,8 +314,7 @@ func (s *Service) Delete(ctx context.Context, id int64) error { } } now := time.Now().Unix() - // The retired name frees the unique workspace name while history keeps a recognizable label. - changed, err := q.RetireGroup(ctx, db.RetireGroupParams{ID: id, TenantID: s.tenantID, Now: &now, Name: fmt.Sprintf("%s (#%d)", row.Name, id)}) + changed, err := q.RetireGroup(ctx, db.RetireGroupParams{ID: id, TenantID: s.tenantID, Now: &now}) if err != nil { return err } diff --git a/internal/groups/groups_test.go b/internal/groups/groups_test.go index 03b19513..44cb716c 100644 --- a/internal/groups/groups_test.go +++ b/internal/groups/groups_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "path/filepath" "testing" "time" @@ -366,7 +367,7 @@ func TestDeleteRetiresGroupOnlyAfterKeysAndAllowancesAreGone(t *testing.T) { } // The row and revoked key survive so history and usage aggregates keep their workspace scope. var retained, members, resources int - if err := connection.QueryRow(`SELECT (SELECT count(*) FROM account_groups WHERE id=? AND deleted_at IS NOT NULL AND enabled=0), + if err := connection.QueryRow(`SELECT (SELECT count(*) FROM account_groups WHERE id=? AND deleted_at IS NOT NULL AND enabled=0 AND name='Retired'), (SELECT count(*) FROM group_members WHERE group_id=?), (SELECT count(*) FROM group_accounts WHERE group_id=?)`, group.ID, group.ID, group.ID).Scan(&retained, &members, &resources); err != nil { t.Fatal(err) } @@ -380,3 +381,32 @@ func TestDeleteRetiresGroupOnlyAfterKeysAndAllowancesAreGone(t *testing.T) { t.Fatalf("repeated deletion: %v", err) } } + +func TestDeletedGroupNamesNeverCollide(t *testing.T) { + _, service, _, _ := fixture(t) + ctx := context.Background() + group, err := service.Save(ctx, 0, groups.Input{Name: "Synthetic group", Enabled: true, AccountIDs: []string{}}) + if err != nil { + t.Fatal(err) + } + // Any label is valid, including one shaped like a retired-name suffix. + if _, err := service.Save(ctx, 0, groups.Input{Name: fmt.Sprintf("Synthetic group (#%d)", group.ID), Enabled: true, AccountIDs: []string{}}); err != nil { + t.Fatal(err) + } + if err := service.Delete(ctx, group.ID); err != nil { + t.Fatal("deletion collided with another group's name", err) + } + reused, err := service.Save(ctx, 0, groups.Input{Name: "synthetic GROUP", Enabled: true, AccountIDs: []string{}}) + if err != nil { + t.Fatal("released name could not be reused", err) + } + if err := service.Delete(ctx, reused.ID); err != nil { + t.Fatal("a second retired group with the same name was rejected", err) + } + if _, err := service.Save(ctx, 0, groups.Input{Name: "Synthetic group", Enabled: true, AccountIDs: []string{}}); err != nil { + t.Fatal(err) + } + if _, err := service.Save(ctx, 0, groups.Input{Name: "SYNTHETIC group", Enabled: true, AccountIDs: []string{}}); !errors.Is(err, groups.ErrDuplicate) { + t.Fatal("active names are no longer unique", err) + } +} diff --git a/internal/pricing/service.go b/internal/pricing/service.go index 21511c38..ed6c8162 100644 --- a/internal/pricing/service.go +++ b/internal/pricing/service.go @@ -24,10 +24,12 @@ const DefaultHashURL = "https://raw.githubusercontent.com/Wei-Shaw/model-price-r // Price uses micro-USD per million tokens, matching allocations.Rate. type Price struct { - Input int64 `json:"input"` - Cached int64 `json:"cached"` - Output int64 `json:"output"` - Source string `json:"source"` + Input int64 `json:"input"` + Cached int64 `json:"cached"` + Output int64 `json:"output"` + // ImageInput prices image input tokens for image generation models; zero means the text input rate. + ImageInput int64 `json:"image_input,omitempty"` + Source string `json:"source"` } type ModelPrice struct { @@ -299,7 +301,8 @@ type litePrice struct { Input *float64 `json:"input_cost_per_token"` Cached *float64 `json:"cache_read_input_token_cost"` Output *float64 `json:"output_cost_per_token"` - // Image generation models publish output prices per image token instead of per text token. + // Image generation models price image tokens separately from text tokens. + ImageInput *float64 `json:"input_cost_per_image_token"` ImageOutput *float64 `json:"output_cost_per_image_token"` Mode string `json:"mode"` } @@ -311,7 +314,8 @@ func parsePrices(raw []byte, source string) (map[string]Price, error) { } result := make(map[string]Price, len(document)) for model, value := range document { - if value.Output == nil && value.Mode == "image_generation" { + // Image models emit image tokens, so their output is always charged at the image rate. + if value.Mode == "image_generation" && value.ImageOutput != nil { value.Output = value.ImageOutput } if source == "override" { @@ -343,7 +347,13 @@ func parsePrices(raw []byte, source string) (map[string]Price, error) { if !ok1 || !ok2 || !ok3 { continue } - result[strings.ToLower(normalizeModel(model))] = Price{Input: input, Cached: cache, Output: output, Source: source} + price := Price{Input: input, Cached: cache, Output: output, Source: source} + if value.Mode == "image_generation" && value.ImageInput != nil && *value.ImageInput > 0 && !math.IsNaN(*value.ImageInput) { + if imageInput, ok := toMicroPerMillion(*value.ImageInput); ok { + price.ImageInput = imageInput + } + } + result[strings.ToLower(normalizeModel(model))] = price } return result, nil } diff --git a/internal/pricing/service_test.go b/internal/pricing/service_test.go index 8825d5a8..a7dcc8cb 100644 --- a/internal/pricing/service_test.go +++ b/internal/pricing/service_test.go @@ -208,15 +208,16 @@ func TestHashForDocument(t *testing.T) { } func TestImageGenerationPricesUseImageOutputTokens(t *testing.T) { - prices, err := parsePrices([]byte(`{"gpt-image-2.5-flare":{"mode":"image_generation","input_cost_per_token":0.000005,"cache_read_input_token_cost":0.00000125,"output_cost_per_image_token":0.00003},"image-without-mode":{"input_cost_per_token":0.000005,"output_cost_per_image_token":0.00003}}`), "remote") + // The default feed lists both text and image output rates for gpt-image models. + prices, err := parsePrices([]byte(`{"gpt-image-2":{"mode":"image_generation","input_cost_per_token":0.000005,"cache_read_input_token_cost":0.00000125,"output_cost_per_token":0.00001,"input_cost_per_image_token":0.000008,"output_cost_per_image_token":0.00003},"image-without-mode":{"input_cost_per_token":0.000005,"output_cost_per_token":0.00001,"input_cost_per_image_token":0.000008,"output_cost_per_image_token":0.00003}}`), "remote") if err != nil { t.Fatal(err) } - price, ok := prices["gpt-image-2.5-flare"] - if !ok || price.Input != 5_000_000 || price.Cached != 1_250_000 || price.Output != 30_000_000 { + price, ok := prices["gpt-image-2"] + if !ok || price.Input != 5_000_000 || price.Cached != 1_250_000 || price.Output != 30_000_000 || price.ImageInput != 8_000_000 { t.Fatalf("image price: %+v %v", price, ok) } - if _, ok := prices["image-without-mode"]; ok { - t.Fatal("image token prices were applied outside image generation models") + if other := prices["image-without-mode"]; other.Output != 10_000_000 || other.ImageInput != 0 { + t.Fatalf("image token prices were applied outside image generation models: %+v", other) } } diff --git a/internal/server/channels.go b/internal/server/channels.go index 8b399b06..e5bcabe2 100644 --- a/internal/server/channels.go +++ b/internal/server/channels.go @@ -115,20 +115,14 @@ func (h *channelHTTP) update(w http.ResponseWriter, r *http.Request) { if !decodeJSONLimit(w, r, &input, 32<<10) { return } - id := chi.URLParam(r, "id") - before, err := h.service.Get(r.Context(), id) - if err != nil { - channelError(w, err) - return - } - row, err := h.service.Update(r.Context(), id, input) + row, err := h.service.Update(r.Context(), chi.URLParam(r, "id"), input) if err != nil { channelError(w, err) return } writeJSON(w, 200, row) - // Endpoint and protocol edits invalidate discovery; refresh it like a new connection. - if h.gateway != nil && (before.BaseURL != row.BaseURL || before.Protocol != row.Protocol || before.Platform != row.Platform || input.APIKey != "") { + // A replacement key may expose a different model set; refresh discovery like a key rotation. + if h.gateway != nil && input.APIKey != "" { _, _ = h.gateway.AccountCatalog(r.Context(), row.ID, false) } } diff --git a/internal/server/channels_test.go b/internal/server/channels_test.go index e38827f8..68507abc 100644 --- a/internal/server/channels_test.go +++ b/internal/server/channels_test.go @@ -160,7 +160,7 @@ func TestChannelSettingsCanBeEditedInPlace(t *testing.T) { login := request(h, "POST", "/api/auth/login", origin, map[string]string{"username": "owner-test", "password": "owner pass 42"}, nil) owner := login.Result().Cookies()[0] created := request(h, "POST", "/api/channels", origin, map[string]string{"name": "Synthetic original", "api_key": "synthetic-original-key", "base_url": "https://one.example.test/v1", "platform": "custom", "protocol": "openai"}, owner) - var channel struct{ ID, Name, BaseURL, Platform, Protocol string } + var channel struct{ ID string } if created.Code != 201 || json.Unmarshal(created.Body.Bytes(), &channel) != nil { t.Fatal(created.Code, created.Body.String()) } @@ -168,16 +168,33 @@ func TestChannelSettingsCanBeEditedInPlace(t *testing.T) { if group.Code != 201 { t.Fatal(group.Code, group.Body.String()) } - edited := request(h, "PUT", "/api/channels/"+channel.ID, origin, map[string]string{"name": "Synthetic renamed", "base_url": "https://two.example.test/v1", "platform": "custom", "protocol": "responses"}, owner) + // A blank key must never carry the stored secret to another endpoint or protocol. + for _, moved := range []map[string]string{ + {"name": "Synthetic original", "base_url": "https://attacker.example.test/v1", "platform": "custom", "protocol": "openai"}, + {"name": "Synthetic original", "base_url": "https://one.example.test/v1", "platform": "custom", "protocol": "responses"}, + {"name": "Synthetic original", "api_key": "synthetic-new-key", "base_url": "https://attacker.example.test/v1", "platform": "custom", "protocol": "openai"}, + } { + if response := request(h, "PUT", "/api/channels/"+channel.ID, origin, moved, owner); response.Code != 409 { + t.Fatal("endpoint or protocol change accepted", moved, response.Code, response.Body.String()) + } + } + stored, err := f.accounts.Prepare(t.Context(), channel.ID, nil) + if err != nil || stored.AccessToken != "synthetic-original-key" || stored.BaseURL != "https://one.example.test/v1" || stored.APIConfig().Protocol != "openai" { + t.Fatal("rejected edit changed the channel", err) + } + edited := request(h, "PUT", "/api/channels/"+channel.ID, origin, map[string]string{"name": "Synthetic renamed", "base_url": "https://one.example.test/v1", "platform": "custom", "protocol": "openai"}, owner) var result struct { - ID, Name, Platform, Protocol string - BaseURL string `json:"base_url"` + ID, Name string + BaseURL string `json:"base_url"` } - if edited.Code != 200 || json.Unmarshal(edited.Body.Bytes(), &result) != nil { - t.Fatal(edited.Code, edited.Body.String()) + if edited.Code != 200 || json.Unmarshal(edited.Body.Bytes(), &result) != nil || result.ID != channel.ID || result.Name != "Synthetic renamed" || result.BaseURL != "https://one.example.test/v1" { + t.Fatal("rename", edited.Code, edited.Body.String()) } - if result.ID != channel.ID || result.Name != "Synthetic renamed" || result.BaseURL != "https://two.example.test/v1" || result.Protocol != "responses" { - t.Fatalf("edit result: %s", edited.Body.String()) + if strings.Contains(edited.Body.String(), "synthetic-original-key") { + t.Fatal("edit response disclosed the key") + } + if stored, err := f.accounts.Prepare(t.Context(), channel.ID, nil); err != nil || stored.AccessToken != "synthetic-original-key" { + t.Fatal("omitted key was not retained", err) } groupsList, err := f.groups.List(t.Context()) if err != nil { @@ -188,27 +205,14 @@ func TestChannelSettingsCanBeEditedInPlace(t *testing.T) { t.Fatal("edit removed group membership", value) } } - stored, err := f.accounts.Prepare(t.Context(), channel.ID, nil) - if err != nil || stored.AccessToken != "synthetic-original-key" || stored.BaseURL != "https://two.example.test/v1" { - t.Fatal("omitted key was not retained", err) - } - if strings.Contains(edited.Body.String(), "synthetic-original-key") { - t.Fatal("edit response disclosed the key") - } - rotated := request(h, "PUT", "/api/channels/"+channel.ID, origin, map[string]string{"name": "Synthetic renamed", "api_key": "synthetic-next-key", "base_url": "https://two.example.test/v1", "platform": "custom", "protocol": "responses"}, owner) + rotated := request(h, "PUT", "/api/channels/"+channel.ID, origin, map[string]string{"name": "Synthetic renamed", "api_key": "synthetic-next-key", "base_url": "https://one.example.test/v1", "platform": "custom", "protocol": "openai"}, owner) if rotated.Code != 200 { t.Fatal(rotated.Code, rotated.Body.String()) } if stored, err := f.accounts.Prepare(t.Context(), channel.ID, nil); err != nil || stored.AccessToken != "synthetic-next-key" { t.Fatal("key was not replaced", err) } - for _, invalid := range []map[string]string{ - {"name": "", "base_url": "https://two.example.test/v1"}, - {"name": "Synthetic", "base_url": "ftp://two.example.test"}, - {"name": "Synthetic", "base_url": "https://two.example.test/v1", "protocol": "unknown"}, - } { - if response := request(h, "PUT", "/api/channels/"+channel.ID, origin, invalid, owner); response.Code != 400 { - t.Fatal("invalid edit accepted", invalid, response.Code, response.Body.String()) - } + if response := request(h, "PUT", "/api/channels/"+channel.ID, origin, map[string]string{"name": "", "base_url": "https://one.example.test/v1"}, owner); response.Code != 400 { + t.Fatal("invalid name accepted", response.Code, response.Body.String()) } } diff --git a/internal/server/ipreport.go b/internal/server/ipreport.go index fa3e15a9..a3dfe0ac 100644 --- a/internal/server/ipreport.go +++ b/internal/server/ipreport.go @@ -4,6 +4,7 @@ import ( "context" "errors" "net/http" + "strconv" "sync" "time" @@ -14,106 +15,183 @@ import ( const ( ipReportTTL = 6 * time.Hour ipReportCooldown = 30 * time.Second + ipReportBackoff = 5 * time.Minute ipReportEntries = 256 ) -var errNoExitIP = errors.New("proxy_exit_ip_unknown") +var ( + errNoExitIP = errors.New("proxy_exit_ip_unknown") + errIPReportMissing = errors.New("ip_report_not_loaded") + errIPReportPaced = errors.New("ip_report_rate_limited") +) // ipReportCache keeps third-party reputation lookups bounded: one result per public exit IP, // shared by every workspace because the data describes the IP rather than any tenant. +// Reads never contact the service; only an explicit lookup discloses an exit IP. type ipReportCache struct { mu sync.Mutex - entries map[string]ipReportEntry + entries map[string]*ipReportEntry now func() time.Time lookup func(context.Context, string) (upstream.IPReport, error) } type ipReportEntry struct { - report upstream.IPReport + report *upstream.IPReport at time.Time + // nextAttempt starts counting when an attempt begins, so failures and concurrent callers are paced too. + nextAttempt time.Time + flight chan struct{} + err error } -var ipReports = &ipReportCache{entries: map[string]ipReportEntry{}, now: time.Now, lookup: upstream.LookupIPReport} +var ipReports = &ipReportCache{entries: map[string]*ipReportEntry{}, now: time.Now, lookup: upstream.LookupIPReport} + +// cached returns a fresh stored report without any third-party request. +func (c *ipReportCache) cached(ip string) (upstream.IPReport, error) { + c.mu.Lock() + defer c.mu.Unlock() + if entry := c.entries[ip]; entry != nil && entry.report != nil && c.now().Sub(entry.at) < ipReportTTL { + return *entry.report, nil + } + return upstream.IPReport{}, errIPReportMissing +} -func (c *ipReportCache) get(ctx context.Context, ip string, refresh bool) (upstream.IPReport, error) { +// fetch performs an explicit lookup. Concurrent callers for one IP share a single upstream request. +func (c *ipReportCache) fetch(ctx context.Context, ip string) (upstream.IPReport, time.Duration, error) { c.mu.Lock() - entry, ok := c.entries[ip] now := c.now() - // Forced refreshes are paced per IP so repeated clicks cannot flood the public service. - if ok && (now.Sub(entry.at) < ipReportTTL && !refresh || refresh && now.Sub(entry.at) < ipReportCooldown) { + entry := c.entries[ip] + if entry == nil { + c.evictLocked() + entry = &ipReportEntry{} + c.entries[ip] = entry + } + if flight := entry.flight; flight != nil { c.mu.Unlock() - return entry.report, nil + select { + case <-flight: + case <-ctx.Done(): + return upstream.IPReport{}, 0, ctx.Err() + } + c.mu.Lock() + defer c.mu.Unlock() + if entry.err != nil { + return upstream.IPReport{}, 0, entry.err + } + return *entry.report, 0, nil } - c.mu.Unlock() - report, err := c.lookup(ctx, ip) - if err != nil { - return upstream.IPReport{}, err + if now.Before(entry.nextAttempt) { + wait := entry.nextAttempt.Sub(now) + defer c.mu.Unlock() + if entry.report != nil { + return *entry.report, wait, nil + } + return upstream.IPReport{}, wait, errIPReportPaced } + entry.nextAttempt = now.Add(ipReportCooldown) + flight := make(chan struct{}) + entry.flight = flight + c.mu.Unlock() + // The shared lookup uses its own bounded context so one caller's cancellation cannot fail the others. + lookupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second) + report, err := c.lookup(lookupCtx, ip) + cancel() c.mu.Lock() defer c.mu.Unlock() - if len(c.entries) >= ipReportEntries { - oldest := "" - for key, value := range c.entries { - if oldest == "" || value.at.Before(c.entries[oldest].at) { - oldest = key - } - } - delete(c.entries, oldest) + entry.flight, entry.err = nil, err + close(flight) + var upstreamErr *upstream.UpstreamError + if errors.As(err, &upstreamErr) && upstreamErr.Status == http.StatusTooManyRequests { + entry.nextAttempt = c.now().Add(ipReportBackoff) } - c.entries[ip] = ipReportEntry{report: report, at: now} - return report, nil -} - -func (h *proxyHTTP) ipReport(w http.ResponseWriter, r *http.Request) { - h.readIPReport(w, r, false) + if err != nil { + return upstream.IPReport{}, 0, err + } + entry.report, entry.at = &report, c.now() + return report, 0, nil } -func (h *proxyHTTP) refreshIPReport(w http.ResponseWriter, r *http.Request) { - var input struct{} - if !decodeJSON(w, r, &input) { +// Called with the lock held; in-flight entries are never evicted. +func (c *ipReportCache) evictLocked() { + if len(c.entries) < ipReportEntries { return } - h.readIPReport(w, r, true) + oldest := "" + for key, value := range c.entries { + if value.flight != nil { + continue + } + if oldest == "" || value.nextAttempt.Before(c.entries[oldest].nextAttempt) { + oldest = key + } + } + if oldest != "" { + delete(c.entries, oldest) + } } -func (h *proxyHTTP) readIPReport(w http.ResponseWriter, r *http.Request, refresh bool) { +func (h *proxyHTTP) exitIP(w http.ResponseWriter, r *http.Request) (string, bool) { if h.service == nil { writeJSON(w, 503, map[string]string{"error": "unavailable"}) - return + return "", false } proxies, err := h.service.ListProxies(r.Context()) if err != nil { accountError(w, err) - return + return "", false } - id, ip := chi.URLParam(r, "id"), "" - found := false + id := chi.URLParam(r, "id") for _, proxy := range proxies { - if proxy.ID == id { - found, ip = true, proxy.ExitIP - break + if proxy.ID != id { + continue + } + // Reports describe the exit observed by the last successful check, never the proxy endpoint. + if proxy.ExitIP == "" { + writeJSON(w, 409, map[string]string{"error": errNoExitIP.Error()}) + return "", false } + return proxy.ExitIP, true } - if !found { - writeJSON(w, 404, map[string]string{"error": "proxy_not_found"}) + writeJSON(w, 404, map[string]string{"error": "proxy_not_found"}) + return "", false +} + +// ipReport reads the cached report only; it never sends the exit IP anywhere. +func (h *proxyHTTP) ipReport(w http.ResponseWriter, r *http.Request) { + ip, ok := h.exitIP(w, r) + if !ok { return } - // Reports describe the exit observed by the last successful check, never the proxy endpoint. - if ip == "" { - writeJSON(w, 409, map[string]string{"error": errNoExitIP.Error()}) + report, err := ipReports.cached(ip) + if err != nil { + writeJSON(w, 404, map[string]string{"error": err.Error()}) return } - ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) - defer cancel() - report, err := ipReports.get(ctx, ip, refresh) + writeJSON(w, 200, report) +} + +// refreshIPReport is the explicit administrator action that discloses the exit IP to the service. +func (h *proxyHTTP) refreshIPReport(w http.ResponseWriter, r *http.Request) { + var input struct{} + if !decodeJSON(w, r, &input) { + return + } + ip, ok := h.exitIP(w, r) + if !ok { + return + } + report, wait, err := ipReports.fetch(r.Context(), ip) if err != nil { status, code := 502, "ip_report_unavailable" var upstreamErr *upstream.UpstreamError switch { case errors.Is(err, upstream.ErrInput): status, code = 422, "ip_report_unsupported" - case errors.As(err, &upstreamErr) && upstreamErr.Status == 429: + case errors.Is(err, errIPReportPaced), errors.As(err, &upstreamErr) && upstreamErr.Status == http.StatusTooManyRequests: status, code = 429, "ip_report_rate_limited" + if wait > 0 { + w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) + } } writeJSON(w, status, map[string]string{"error": code}) return diff --git a/internal/server/ipreport_test.go b/internal/server/ipreport_test.go index d304355c..d213655d 100644 --- a/internal/server/ipreport_test.go +++ b/internal/server/ipreport_test.go @@ -2,39 +2,94 @@ package server import ( "context" + "errors" + "fmt" + "sync" + "sync/atomic" "testing" "time" "github.com/murongg/SubLane/internal/upstream" ) -func TestIPReportCacheBoundsLookups(t *testing.T) { +func TestIPReportCacheNeverDisclosesOnReadAndPacesLookups(t *testing.T) { now := time.Unix(1_900_000_000, 0) - calls := 0 - cache := &ipReportCache{entries: map[string]ipReportEntry{}, now: func() time.Time { return now }, lookup: func(_ context.Context, ip string) (upstream.IPReport, error) { - calls++ + var mu sync.Mutex + clock := func() time.Time { + mu.Lock() + defer mu.Unlock() + return now + } + advance := func(d time.Duration) { + mu.Lock() + now = now.Add(d) + mu.Unlock() + } + var calls atomic.Int32 + release := make(chan struct{}) + fail := atomic.Bool{} + cache := &ipReportCache{entries: map[string]*ipReportEntry{}, now: clock, lookup: func(_ context.Context, ip string) (upstream.IPReport, error) { + calls.Add(1) + <-release + if fail.Load() { + return upstream.IPReport{}, &upstream.UpstreamError{Status: 429} + } return upstream.IPReport{IP: ip}, nil }} ctx := context.Background() - for range 3 { - if _, err := cache.get(ctx, "203.0.113.9", false); err != nil { - t.Fatal(err) - } + if _, err := cache.cached("203.0.113.9"); !errors.Is(err, errIPReportMissing) || calls.Load() != 0 { + t.Fatal("a cache read contacted the service", err, calls.Load()) + } + // Concurrent explicit lookups for one IP share a single upstream request. + var group sync.WaitGroup + for range 4 { + group.Add(1) + go func() { + defer group.Done() + if report, _, err := cache.fetch(ctx, "203.0.113.9"); err != nil || report.IP != "203.0.113.9" { + t.Error(report, err) + } + }() + } + deadline := time.Now().Add(2 * time.Second) + for calls.Load() == 0 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + close(release) + group.Wait() + if calls.Load() != 1 { + t.Fatal("concurrent lookups were not coalesced", calls.Load()) + } + if report, err := cache.cached("203.0.113.9"); err != nil || report.IP != "203.0.113.9" { + t.Fatal("successful lookup was not cached", err) + } + if _, wait, err := cache.fetch(ctx, "203.0.113.9"); err != nil || wait <= 0 || calls.Load() != 1 { + t.Fatal("a refresh inside the cooldown reached the service", wait, err, calls.Load()) + } + // A failed attempt is paced from when it started, and 429 backs off longer. + fail.Store(true) + if _, _, err := cache.fetch(ctx, "198.51.100.7"); err == nil { + t.Fatal("upstream failure was hidden") + } + if _, wait, err := cache.fetch(ctx, "198.51.100.7"); !errors.Is(err, errIPReportPaced) || wait < ipReportCooldown || calls.Load() != 2 { + t.Fatal("failed lookup was retried immediately", wait, err, calls.Load()) } - if _, err := cache.get(ctx, "203.0.113.9", true); err != nil || calls != 1 { - t.Fatal("refresh inside the cooldown reached the service", calls, err) + advance(ipReportCooldown) + if _, _, err := cache.fetch(ctx, "198.51.100.7"); !errors.Is(err, errIPReportPaced) || calls.Load() != 2 { + t.Fatal("rate-limited lookup did not back off", err, calls.Load()) } - now = now.Add(ipReportCooldown) - if _, err := cache.get(ctx, "203.0.113.9", true); err != nil || calls != 2 { - t.Fatal("refresh after the cooldown was cached", calls, err) + fail.Store(false) + advance(ipReportBackoff) + if _, _, err := cache.fetch(ctx, "198.51.100.7"); err != nil || calls.Load() != 3 { + t.Fatal("lookup did not resume after backoff", err, calls.Load()) } - now = now.Add(ipReportTTL) - if _, err := cache.get(ctx, "203.0.113.9", false); err != nil || calls != 3 { - t.Fatal("expired report was reused", calls, err) + advance(ipReportTTL) + if _, err := cache.cached("203.0.113.9"); !errors.Is(err, errIPReportMissing) { + t.Fatal("expired report was served", err) } for i := range ipReportEntries + 5 { - now = now.Add(time.Second) - if _, err := cache.get(ctx, string(rune('a'+i%26))+time.Duration(i).String(), false); err != nil { + advance(ipReportCooldown) + if _, _, err := cache.fetch(ctx, fmt.Sprintf("192.0.2.%d", i)); err != nil { t.Fatal(err) } } diff --git a/internal/storage/db/gateway.sql.go b/internal/storage/db/gateway.sql.go index 0c17f1d5..52fc1e6c 100644 --- a/internal/storage/db/gateway.sql.go +++ b/internal/storage/db/gateway.sql.go @@ -20,8 +20,19 @@ func (q *Queries) CountAccountAffinity(ctx context.Context) (int64, error) { return count, err } +const countDerivedAffinity = `-- name: CountDerivedAffinity :one +SELECT count(*) FROM account_affinity WHERE derived = 1 +` + +func (q *Queries) CountDerivedAffinity(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countDerivedAffinity) + var count int64 + err := row.Scan(&count) + return count, err +} + const createAccountAffinity = `-- name: CreateAccountAffinity :exec -INSERT INTO account_affinity(user_id, group_id, session_hash, provider, account_id, expires_at, model, rebound) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) +INSERT INTO account_affinity(user_id, group_id, session_hash, provider, account_id, expires_at, model, rebound, derived) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) ` type CreateAccountAffinityParams struct { @@ -33,6 +44,7 @@ type CreateAccountAffinityParams struct { ExpiresAt int64 Model string Rebound bool + Derived bool } func (q *Queries) CreateAccountAffinity(ctx context.Context, arg CreateAccountAffinityParams) error { @@ -45,10 +57,26 @@ func (q *Queries) CreateAccountAffinity(ctx context.Context, arg CreateAccountAf arg.ExpiresAt, arg.Model, arg.Rebound, + arg.Derived, ) return err } +const evictDerivedAffinity = `-- name: EvictDerivedAffinity :execrows +DELETE FROM account_affinity WHERE rowid = ( + SELECT rowid FROM account_affinity WHERE derived = 1 ORDER BY expires_at, rowid LIMIT 1 +) +` + +// Explicit bindings may displace the derived preference closest to expiry. +func (q *Queries) EvictDerivedAffinity(ctx context.Context) (int64, error) { + result, err := q.db.ExecContext(ctx, evictDerivedAffinity) + if err != nil { + return 0, err + } + return result.RowsAffected() +} + const listResourceGroups = `-- name: ListResourceGroups :many SELECT ga.group_id FROM group_accounts ga JOIN account_groups g ON g.id=ga.group_id WHERE ga.account_id=?1 AND g.tenant_id=?2 diff --git a/internal/storage/db/groups.sql.go b/internal/storage/db/groups.sql.go index cd13c08e..6c36bdad 100644 --- a/internal/storage/db/groups.sql.go +++ b/internal/storage/db/groups.sql.go @@ -155,7 +155,7 @@ func (q *Queries) CreateGroup(ctx context.Context, arg CreateGroupParams) (int64 } const findGroupName = `-- name: FindGroupName :one -SELECT id FROM account_groups WHERE tenant_id=?1 AND name=?2 COLLATE NOCASE +SELECT id FROM account_groups WHERE tenant_id=?1 AND name=?2 COLLATE NOCASE AND deleted_at IS NULL ` type FindGroupNameParams struct { @@ -556,25 +556,19 @@ func (q *Queries) MemberExistsInTenant(ctx context.Context, arg MemberExistsInTe } const retireGroup = `-- name: RetireGroup :execrows -UPDATE account_groups SET enabled=0,deleted_at=?1,updated_at=?1,name=?2 -WHERE id=?3 AND tenant_id=?4 AND deleted_at IS NULL +UPDATE account_groups SET enabled=0,deleted_at=?1,updated_at=?1 +WHERE id=?2 AND tenant_id=?3 AND deleted_at IS NULL ` type RetireGroupParams struct { Now *int64 - Name string ID int64 TenantID int64 } -// Retired names free the unique workspace name while history keeps a recognizable label. +// Retired groups keep their names; uniqueness only applies to active groups. func (q *Queries) RetireGroup(ctx context.Context, arg RetireGroupParams) (int64, error) { - result, err := q.db.ExecContext(ctx, retireGroup, - arg.Now, - arg.Name, - arg.ID, - arg.TenantID, - ) + result, err := q.db.ExecContext(ctx, retireGroup, arg.Now, arg.ID, arg.TenantID) if err != nil { return 0, err } diff --git a/internal/storage/migrations/032_affinity_preferences.sql b/internal/storage/migrations/032_affinity_preferences.sql new file mode 100644 index 00000000..0ebf7059 --- /dev/null +++ b/internal/storage/migrations/032_affinity_preferences.sql @@ -0,0 +1,3 @@ +-- Derived session-stickiness preferences are tracked separately so they can be capped and evicted +-- before they ever block an explicit conversation binding. +ALTER TABLE account_affinity ADD COLUMN derived INTEGER NOT NULL DEFAULT 0 CHECK(derived IN (0,1)); diff --git a/internal/storage/migrations/033_group_active_names.sql b/internal/storage/migrations/033_group_active_names.sql new file mode 100644 index 00000000..8859788e --- /dev/null +++ b/internal/storage/migrations/033_group_active_names.sql @@ -0,0 +1,27 @@ +-- Retired groups keep their original names: uniqueness applies only to active groups, so deleting +-- a group never collides with another group's label and released names can be reused. +-- Published migrations must remain an unchanged prefix for backup validation. +PRAGMA legacy_alter_table=ON; +CREATE TABLE account_groups_next ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL COLLATE NOCASE, + enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)), + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + restricted_models INTEGER NOT NULL DEFAULT 0 CHECK(restricted_models IN (0,1)), + tenant_id INTEGER NOT NULL DEFAULT 1 REFERENCES tenants(id), + routing_preference TEXT NOT NULL DEFAULT 'protocol' CHECK(routing_preference IN ('protocol','subscription_first','api_first')), + allow_api_fallback INTEGER NOT NULL DEFAULT 1 CHECK(allow_api_fallback IN (0,1)), + session_sticky INTEGER NOT NULL DEFAULT 0 CHECK(session_sticky IN (0,1)), + deleted_at INTEGER +); +INSERT INTO account_groups_next(id,name,enabled,created_at,updated_at,restricted_models,tenant_id,routing_preference,allow_api_fallback,session_sticky,deleted_at) +SELECT id,name,enabled,created_at,updated_at,restricted_models,tenant_id,routing_preference,allow_api_fallback,session_sticky,deleted_at FROM account_groups; +DROP TABLE account_groups; +ALTER TABLE account_groups_next RENAME TO account_groups; +PRAGMA legacy_alter_table=OFF; +CREATE INDEX account_groups_tenant ON account_groups(tenant_id, id); +CREATE UNIQUE INDEX account_groups_active_name ON account_groups(tenant_id, name) WHERE deleted_at IS NULL; +CREATE TRIGGER account_groups_tenant_update BEFORE UPDATE OF tenant_id ON account_groups +WHEN NEW.tenant_id != OLD.tenant_id +BEGIN SELECT RAISE(ABORT, 'tenant_immutable'); END; diff --git a/internal/storage/queries/gateway.sql b/internal/storage/queries/gateway.sql index 373c7c46..4d3ed5a4 100644 --- a/internal/storage/queries/gateway.sql +++ b/internal/storage/queries/gateway.sql @@ -1,11 +1,20 @@ -- name: CountAccountAffinity :one SELECT count(*) FROM account_affinity; +-- name: CountDerivedAffinity :one +SELECT count(*) FROM account_affinity WHERE derived = 1; + +-- name: EvictDerivedAffinity :execrows +-- Explicit bindings may displace the derived preference closest to expiry. +DELETE FROM account_affinity WHERE rowid = ( + SELECT rowid FROM account_affinity WHERE derived = 1 ORDER BY expires_at, rowid LIMIT 1 +); + -- name: PruneAccountAffinity :exec DELETE FROM account_affinity WHERE expires_at <= sqlc.arg(now); -- name: CreateAccountAffinity :exec -INSERT INTO account_affinity(user_id, group_id, session_hash, provider, account_id, expires_at, model, rebound) VALUES (sqlc.arg(user_id), sqlc.arg(group_id), sqlc.arg(session_hash), sqlc.arg(provider), sqlc.arg(account_id), sqlc.arg(expires_at), sqlc.arg(model), sqlc.arg(rebound)); +INSERT INTO account_affinity(user_id, group_id, session_hash, provider, account_id, expires_at, model, rebound, derived) VALUES (sqlc.arg(user_id), sqlc.arg(group_id), sqlc.arg(session_hash), sqlc.arg(provider), sqlc.arg(account_id), sqlc.arg(expires_at), sqlc.arg(model), sqlc.arg(rebound), sqlc.arg(derived)); -- name: TouchAccountAffinity :exec UPDATE account_affinity SET expires_at = sqlc.arg(expires_at), model = sqlc.arg(model) diff --git a/internal/storage/queries/groups.sql b/internal/storage/queries/groups.sql index 1c38fd1f..f5923f8a 100644 --- a/internal/storage/queries/groups.sql +++ b/internal/storage/queries/groups.sql @@ -19,7 +19,7 @@ INSERT INTO account_groups(tenant_id,name,enabled,created_at,updated_at) VALUES(sqlc.arg(tenant_id),sqlc.arg(name),sqlc.arg(enabled),sqlc.arg(now),sqlc.arg(now)); -- name: FindGroupName :one -SELECT id FROM account_groups WHERE tenant_id=sqlc.arg(tenant_id) AND name=sqlc.arg(name) COLLATE NOCASE; +SELECT id FROM account_groups WHERE tenant_id=sqlc.arg(tenant_id) AND name=sqlc.arg(name) COLLATE NOCASE AND deleted_at IS NULL; -- name: UpdateGroup :exec UPDATE account_groups SET name=sqlc.arg(name),enabled=sqlc.arg(enabled),updated_at=sqlc.arg(now) @@ -118,6 +118,6 @@ DELETE FROM group_members WHERE group_id=sqlc.arg(group_id); DELETE FROM account_affinity WHERE group_id=sqlc.arg(group_id); -- name: RetireGroup :execrows --- Retired names free the unique workspace name while history keeps a recognizable label. -UPDATE account_groups SET enabled=0,deleted_at=sqlc.arg(now),updated_at=sqlc.arg(now),name=sqlc.arg(name) +-- Retired groups keep their names; uniqueness only applies to active groups. +UPDATE account_groups SET enabled=0,deleted_at=sqlc.arg(now),updated_at=sqlc.arg(now) WHERE id=sqlc.arg(id) AND tenant_id=sqlc.arg(tenant_id) AND deleted_at IS NULL; diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 87361510..adcff297 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -85,7 +85,7 @@ func apply(ctx context.Context, db *sql.DB, name string) error { defer conn.Close() // SQLite requires disabling FK actions outside the transaction for a table rebuild. // The transaction's foreign_key_check still gates publication; restore enforcement before reuse. - if name == "011_grok.sql" || name == "015_api_upstreams.sql" || name == "026_catalog_capabilities.sql" || name == "027_allocation_anchors.sql" { + if name == "011_grok.sql" || name == "015_api_upstreams.sql" || name == "026_catalog_capabilities.sql" || name == "027_allocation_anchors.sql" || name == "033_group_active_names.sql" { if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys=OFF"); err != nil { return err } diff --git a/internal/upstream/opencode_usage.go b/internal/upstream/opencode_usage.go index 26d945b9..b3b408a8 100644 --- a/internal/upstream/opencode_usage.go +++ b/internal/upstream/opencode_usage.go @@ -5,6 +5,8 @@ import ( "encoding/json" "math" "net/http" + "net/url" + "strings" "time" "github.com/murongg/SubLane/internal/accounts" @@ -13,6 +15,16 @@ import ( // OpenCodeGoUsageURL is the official JSON usage endpoint; it accepts the channel's API key. var OpenCodeGoUsageURL = "https://opencode.ai/zen/go/v1/usage" +// OfficialOpenCodeGo reports whether a channel's key belongs to the official OpenCode origin. +// The preset label alone is not enough: a custom relay key must never be sent to opencode.ai. +func OfficialOpenCodeGo(baseURL, platform string) bool { + if platform != "opencode-go" { + return false + } + u, err := url.Parse(baseURL) + return err == nil && u.Scheme == "https" && u.User == nil && strings.EqualFold(u.Host, "opencode.ai") +} + const ( openCodeGoRollingSeconds int64 = 5 * 3600 openCodeGoWeeklySeconds int64 = 7 * 24 * 3600 diff --git a/internal/upstream/opencode_usage_test.go b/internal/upstream/opencode_usage_test.go index d953bb7e..c6d25a56 100644 --- a/internal/upstream/opencode_usage_test.go +++ b/internal/upstream/opencode_usage_test.go @@ -14,7 +14,9 @@ import ( func TestOpenCodeGoUsageReadsSpendingWindows(t *testing.T) { channel := accounts.Credential{Provider: "openai", APIPlatform: "opencode-go", APIProtocol: "openai", BaseURL: "https://opencode.ai/zen/go/v1", AccessToken: "synthetic-opencode-key"} body := `{"usage":{"rolling":{"status":"ok","percent":23,"resetsAt":"2030-01-01T05:00:00.000Z"},"weekly":{"status":"ok","percent":10,"resetsAt":"2030-01-06T00:00:00.000Z"},"monthly":{"status":"ok","percent":100,"resetsAt":"2030-01-31T00:00:00.000Z"}}}` + calls := 0 client := NewWithTransport(usageTransport(func(r *http.Request) (*http.Response, error) { + calls++ if r.Method != "GET" || r.URL.String() != OpenCodeGoUsageURL || r.Header.Get("Authorization") != "Bearer synthetic-opencode-key" { t.Error("wrong OpenCode Go usage request", r.URL.String()) } @@ -60,6 +62,15 @@ func TestOpenCodeGoUsageReadsSpendingWindows(t *testing.T) { if _, err := client.Usage(context.Background(), other); !errors.Is(err, ErrUsageUnsupported) { t.Fatal("other channels must not report usage", err) } + // The preset label is not enough: a relay key must never be sent to the official endpoint. + for _, relay := range []string{"https://relay.example.test/zen/go/v1", "http://opencode.ai/zen/go/v1", "https://opencode.ai.example.test/v1", "https://opencode.ai:8443/v1"} { + configured := channel + configured.BaseURL = relay + before := calls + if _, err := client.Usage(context.Background(), configured); !errors.Is(err, ErrUsageUnsupported) || calls != before { + t.Fatal("relay channel reached the official usage endpoint", relay, err) + } + } } func TestCodexImageModelsJoinOnlyGPTCatalogs(t *testing.T) { diff --git a/internal/upstream/usage.go b/internal/upstream/usage.go index b880299f..87cf1841 100644 --- a/internal/upstream/usage.go +++ b/internal/upstream/usage.go @@ -61,7 +61,7 @@ func (c *Client) Usage(ctx context.Context, credential accounts.Credential) (Usa return c.antigravityUsage(ctx, credential) case "openai": // API channels only report quota where the platform publishes a key-authenticated usage endpoint. - if credential.APIConfig().Platform == "opencode-go" { + if OfficialOpenCodeGo(credential.BaseURL, credential.APIConfig().Platform) { return c.openCodeGoUsage(ctx, credential) } return Usage{}, ErrUsageUnsupported diff --git a/sqlc.yaml b/sqlc.yaml index 35562e9a..8594a3cd 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -14,6 +14,8 @@ sql: overrides: - column: "account_affinity.rebound" go_type: "bool" + - column: "account_affinity.derived" + go_type: "bool" - column: "users.enabled" go_type: "bool" - column: "accounts.enabled" diff --git a/web/src/components/ChannelForm.tsx b/web/src/components/ChannelForm.tsx index 8c053762..7841548f 100644 --- a/web/src/components/ChannelForm.tsx +++ b/web/src/components/ChannelForm.tsx @@ -140,7 +140,7 @@ export function ChannelForm({ )} searchLabel={t('channelPlatformSearch')} emptyLabel={t('channelPlatformNoMatch')} - disabled={save.isPending} + disabled={Boolean(channel) || save.isPending} />
- {t(channel ? 'channelEditPlatformHint' : 'channelPlatformHint')} -
+ {!channel && ( ++ {t('channelPlatformHint')} +
+ )}{t('ipReportNeedsCheck')}
- ) : query.isPending ? ( + ) : query.isPending || (refresh.isPending && !report) ? ({t('ipReportLoading')}
) : !report ? ( -- {t(ipReportErrorKey(error))} -
++ {t(ipReportErrorKey(error))} +
+ )} ++ {t('ipReportLookupHint')} +
+ +