Skip to content

feat: session stickiness, channel editing, group/allowance deletion, Codex images, OpenCode Go usage and proxy IP reports - #160

Merged
murongg merged 10 commits into
murongg:mainfrom
Alumos:contrib/upstream-features
Nov 11, 2026
Merged

murongg merged 10 commits into
murongg:mainfrom
Alumos:contrib/upstream-features

Conversation

@Alumos

@Alumos Alumos commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Summary

This PR bundles several features developed while running SubLane in production. They are in separate commits so they can be reviewed (or cherry-picked) independently; I am happy to split them into smaller PRs if you prefer.

  1. Optional session stickiness per resource group (5e66974). Off by default. When enabled, requests without a gateway session derive a conversation key from client conversation headers, Codex turn metadata or Claude Code session metadata, falling back to a hash of the system prompt and first user message (following CLIProxyAPI's session-affinity order). Derived keys create one-hour preferences: the preferred resource ranks first only after every existing 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 (Codex parent thread, Claude parent session, X-Parent-Session-Id) prefer their parent's resource without changing its binding. Explicit sessions keep the existing strict rules. Motivation: prompt-cache hit rates dropped because sessionless traffic and subagents rotated across accounts.
  2. Edit API channels in place (5e66974). Name, platform, protocol, base URL and an optional key can be changed; a blank key keeps the stored secret. The channel ID, group assignments, limits and history are kept; endpoint/protocol/key changes reset verification and endpoint/protocol changes invalidate the catalog. This relaxes the current "changing the endpoint requires a separate channel" rule — happy to discuss if that rule is intentional.
  3. Delete resource groups (5e66974). Allowed once no active key or allowance uses the group. The group is retired (soft-deleted) so request history and usage aggregates keep their workspace scope; grants, resources, model policy and bindings are removed in one audited transaction and the name is released.
  4. Codex image models (5e66974). POST /v1/images/generations and /v1/images/edits (JSON or multipart) route to Codex subscriptions via the ChatGPT backend's direct Images endpoints. Codex catalogs that advertise GPT models include the built-in gpt-image-* models; image models are rejected on text endpoints and vice versa; streaming partial images is not supported. Pricing now reads output_cost_per_image_token for image_generation models, and CC Switch picker suggestions omit image models.
  5. OpenCode Go usage (5e66974). OpenCode Go channels show the rolling 5-hour, weekly and monthly windows from the official key-authenticated GET https://opencode.ai/zen/go/v1/usage, reusing the subscription quota cache. Informational only; it does not gate scheduling.
  6. Delete resource allowances (2186c36). allocation.delete was already an audit action but had no implementation. Deletion is refused while requests hold reservations; it removes the allowance, revisions, price snapshots, key bindings and ledger in one audited transaction. Request history and usage statistics are kept; bound keys keep their group without a budget limit.
  7. Network proxy cards with IP purity (81ff0de, 790ebe6, 8cb3146). Proxies render as compact cards in a responsive grid. Each card merges the proxy check and an IP report so exit IP, location and network appear once. The report looks up the last checked public exit IP on ip.net.coffee (the lookup behind its scoring page) and shows the trust score with the same five tiers, network type and risk flags. Results are cached per IP for 6 hours, forced refreshes are paced to one per 30 s per IP, unchecked proxies make no lookup, and the page states that the exit IP is sent to that service. This adds a third-party data flow, so please treat it as optional.
  8. Flaky dialog test fix (92ba4d9). Radix FocusScope restores focus from a zero-delay timer after a dialog unmounts; when it fired after a test's jsdom window was torn down, Vitest reported an intermittent unhandled dispatchEvent ... is not of type 'Event' error and failed the frontend job although every test passed. The shared setup now lets that timer run during cleanup.

PRODUCT.md and all eleven UI dictionaries are updated. User-facing guides in sublane-website are not updated yet.

Validation

  • The full CI workflow from this repository (generate-check, lint, Go race tests for every package, frontend tests, production build, key migration test, and native amd64/arm64 container smoke tests) passed on byte-identical application code in the fork: https://github.com/Alumos/SubLane/actions/runs/42815887797. This branch differs from that code only by leaving this repository's workflows untouched.

  • New tests: session stickiness (rotation baseline, derived stickiness, parent preference, failover, hint parsing), channel edit over HTTP, group deletion guards, allowance deletion with in-flight guard, Codex image generation/edits through a fake upstream with history accounting, OpenCode Go usage parsing, image-token pricing, IP report parsing and cache bounds, proxy card de-duplication.

  • Browser checks on a local instance: channel editing, group stickiness toggle and deletion, allowance deletion, OpenCode Go usage (verified against the live endpoint with a real key), proxy cards against the live ip.net.coffee API — desktop and mobile widths, light and dark themes, English and Chinese.

  • The same changes have run in a fork's CI and release workflow (amd64/arm64 images built and smoke-tested).

  • Verified manually by the PR author on a production deployment: image generation against a live ChatGPT subscription, session stickiness with live Codex and Claude Code clients, and keyboard navigation of the new controls.

Compatibility and migration

  • 030_group_lifecycle.sql (additive): adds account_groups.session_sticky (default 0) and account_groups.deleted_at.
  • 031_image_requests.sql: rebuilds request_records to allow the images operation, copying every row and recreating its indexes.
  • The Codex catalog source moves to codex:v4 so stored catalogs are rediscovered once.
  • No configuration changes. Existing behavior is unchanged unless a group enables stickiness.

Checklist

  • The change is focused on one problem and follows CONTRIBUTING.md. — Not met: this intentionally bundles several features in separate commits; I can split it on request.
  • Behavior changes have regression tests using synthetic data only.
  • Validation results and any remaining gaps are documented above.
  • Relevant documentation and English/Chinese UI text are updated. (PRODUCT.md and all dictionaries; sublane-website guides pending.)
  • UI changes are checked for keyboard access, narrow screens, light/dark themes, and English/Chinese.
  • No credentials, account identifiers, private request/response content, or local planning documents are included.

🤖 Generated with Claude Code

Alumos and others added 6 commits November 10, 2026 20:32
…es and OpenCode Go usage

- Resource groups gain an opt-in session stickiness setting. Sessionless
  requests derive a conversation key (client headers, Codex turn metadata,
  Claude Code session metadata, then a system + first user message hash,
  following CLIProxyAPI's order) and subagents prefer their parent's
  resource. Derived keys are one-hour preferences that fail over instead of
  pinning to unavailable resources.
- API channels can be edited in place (name, platform, protocol, base URL,
  optional key) while keeping their ID, group assignments and history.
- Resource groups can be deleted once no active key or allowance uses them.
  Deletion retires the row so request history and usage keep their scope.
- New /v1/images/generations and /v1/images/edits (JSON or multipart) route
  to Codex subscriptions through the ChatGPT direct Images endpoints. Codex
  catalogs with GPT models include the built-in gpt-image-* models, request
  history records an images operation, and pricing reads image token output
  prices. CC Switch picker suggestions omit image models.
- OpenCode Go channels show rolling, weekly and monthly usage from the
  official key-authenticated usage endpoint.

Migrations 030 and 031 are additive and preserve existing data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Administrators can delete a resource allowance once no admitted request
still holds one of its reservations. The allowance, its revisions, price
snapshots, key bindings and ledger are removed in one audited transaction;
request history and usage statistics are kept. Keys bound to it keep their
resource group without a budget limit, and the group is no longer locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Network proxies are shown as cards with the exit IP, location, latency,
check time and bound resources beside an IP purity panel. The panel looks
up the last checked public exit IP on card.net.coffee and shows the trust
score, network type, risk flags, ASN, ISP, location and verdict. Lookups
are cached per IP for six hours and forced refreshes are paced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Query the ip.net.coffee lookup behind its IP scoring page and show the same
five purity tiers (pristine, clean, good, neutral, suspicious) so a card and
the full report agree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Merge each proxy's check result and IP report into one compact card so the
exit IP, location and network appear exactly once, and lay the cards out in
an auto-fill grid that adapts its column count to the page width. The data
source note moves to a single page-level hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Radix FocusScope restores focus from a zero-delay timer after a dialog
unmounts. When that timer fired after a test's jsdom window was torn down,
Vitest reported an intermittent unhandled "dispatchEvent ... is not of type
'Event'" error and failed the frontend job even though every test passed.
Let the timer run during each test's cleanup instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@murongg murongg left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The channel edit and session stickiness paths need changes before merge. A URL-only edit can forward an existing channel key to a different host, and a derived session can move an existing explicit session to another account. I reproduced both with synthetic upstreams and left the related findings inline.

The affected Go package tests, 544 frontend tests, frontend type checking and lint, and make generate-check passed. The targeted reproductions exposed cases the existing tests miss.

}
key := input.APIKey
if key == "" {
key = previous.AccessToken

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] A workspace admin can change base_url, leave the key blank, and trigger catalog discovery at the new host with the original stored credential. A synthetic upstream received the original bearer token after this edit. Please keep the existing separate-channel rule for endpoint/protocol changes so credentials and existing conversation bindings stay scoped to their original upstream.

return "", digest, err
}
if existing != nil {
stickyAccount, replacement = existing.AccountID, existing

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] An X-Session-Id that matches an existing explicit Session_id reads the same binding. When the original account is disabled, the derived request selects a fallback and writes it back through ReplaceAccountAffinity. After re-enabling the original account, an explicit continuation still goes to the fallback. Please give derived preferences their own namespace and keep parent lookups read-only.

Comment thread internal/gateway/selection.go Outdated
} 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)
err = rememberAffinity(ctx, q, userID, groupID, digest, scope, id, bindingModel, now, expires, replacement != nil && !derived)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Derived preferences consume the same global 4,096 rows as explicit sessions. With one slot left, a derived request can claim it and the next new explicit session returns 429 gateway_busy, including in another workspace. Please give preferences a separate capacity or let explicit sessions evict them first.

Comment thread internal/pricing/service.go Outdated
}
result := make(map[string]Price, len(document))
for model, value := range document {
if value.Output == nil && value.Mode == "image_generation" {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The default price feed provides both output fields for gpt-image-2. This branch selects the $10/M text rate and the output portion of each image request debits the allowance at one third of the $30/M image output rate. Edits also need separate text and image input counts from input_tokens_details because the input rates differ. Please use the image output rate for image tokens and settle edit inputs by modality, or keep USD allowance support for Images disabled until both can be accounted for.

if err != nil {
return Usage{}, ErrInput
}
request.Header.Set("Authorization", "Bearer "+credential.AccessToken)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Channels can use the opencode-go preset with a custom relay URL. The usage poll sends the relay's key to opencode.ai because the dispatch checks the platform label alone. I reproduced this with a fake transport. Please limit the official usage lookup to channels configured for the official origin.

const locale = i18n.resolvedLanguage ?? 'en'
const ready = proxy.reachable && proxy.exit_ip !== ''
const options = ipReportOptions(proxy.id, proxy.exit_ip)
const query = useQuery({ ...options, enabled: ready })

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Mounting the page sends the saved exit IPs of reachable proxies to the reputation service. The page notice appears as the automatic requests start. Please make the lookup an explicit action or require a workspace opt-in before the first disclosure.

Comment thread internal/server/ipreport.go Outdated
return entry.report, nil
}
c.mu.Unlock()
report, err := c.lookup(ctx, ip)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The cooldown is recorded only after a successful lookup. Two simultaneous requests for the same IP can both call upstream, and a 429 leaves callers free to retry immediately. Please coalesce requests per IP and start the cooldown when the upstream attempt begins.

Comment thread internal/groups/groups.go Outdated
}
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)})

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] If group 42 is named Synthetic group and a second group is named Synthetic group (#42), deleting group 42 fails with the SQLite name uniqueness constraint. Both names are valid. Please scope name uniqueness to active groups so retired groups can retain their original labels without blocking deletion or name reuse.

…ports

- Keep the separate-channel rule: base URL, platform and protocol cannot
  change on edit, so a stored key never reaches another upstream.
- Hash derived stickiness preferences into their own namespace so they never
  read or rewrite explicit bindings; parent lookups stay read-only.
- Mark derived affinity rows, cap them at a quarter of the table and let
  explicit bindings evict them first.
- Charge image model output at the image rate and settle input by modality.
- Read OpenCode Go usage only for channels on the official origin.
- Make IP reputation lookups an explicit action, coalesce them per IP and
  start the cooldown when an attempt begins.
- Make group names unique among active groups only so retired groups keep
  their names and never block deletion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alumos

Alumos commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review and the reproductions — all eight findings were real. They are addressed in b361911, each with a regression test that follows your reproduction:

P1 — channel edit forwards the stored key (apikey.go). Restored the separate-channel rule: base URL, platform and protocol must match the stored channel, otherwise the update returns 409 before anything is persisted or discovered. Only the name and (optionally) the key can change. The edit dialog locks those fields again. TestChannelSettingsCanBeEditedInPlace covers a URL-only change with a blank key, a protocol change, and a URL change with a new key.

P1 — derived session rewrites an explicit binding (selection.go). Derived preferences now hash into their own namespace (derived:<user>:<group>:<session>; explicit inputs always start with a numeric user ID), so they can neither read nor ReplaceAccountAffinity an explicit binding. Parent lookups are read-only and check the explicit binding first, then a derived parent preference. TestDerivedPreferencesNeverRewriteExplicitBindings reproduces the disable → fallback → re-enable sequence.

P2 — preferences consume the shared 4,096 rows. Migration 032_affinity_preferences.sql marks derived rows. Preferences may hold at most a quarter of the table and are skipped (never rejected) when either limit is reached; an explicit binding that finds the table full evicts the derived preference closest to expiry instead of returning gateway_busy. TestDerivedPreferencesNeverBlockExplicitSessions fills the table to one free slot.

P2 — image pricing. For image_generation models the price now always uses output_cost_per_image_token for output and keeps input_cost_per_image_token as a separate rate. Allowance settlement splits input by modality using input_tokens_details.image_tokens (CostWithImages); an inconsistent split falls back to the text input rate rather than failing settlement. Saved revisions without an image rate keep their previous behavior.

P2 — OpenCode Go usage with a relay URL. The usage lookup now requires the official https://opencode.ai origin as well as the preset (scheme, host and port are checked); relay channels return provider_usage_unsupported without any request, and the UI hides the panel for them.

P2 — automatic IP disclosure on page load. GET /ip-report now only returns a report the server already holds and never contacts the service. Each card shows an explicit Check IP purity action that states the exit IP will be sent to ip.net.coffee; only that action (or a refresh of an existing report) discloses the IP.

P2 — lookup pacing. Lookups are coalesced per IP (one in-flight upstream request shared by concurrent callers), the 30-second cooldown starts when an attempt begins (so failures are paced too), and an upstream 429 backs off for five minutes, returning 429 with Retry-After meanwhile. Covered under -race by TestIPReportCacheNeverDisclosesOnReadAndPacesLookups.

P2 — retired group names collide. Migration 033_group_active_names.sql rebuilds account_groups (same rebuild pattern as 027) and replaces UNIQUE(tenant_id,name) with a unique index over active groups only. Deletion no longer renames the group, so retired groups keep their original labels and names can be reused. TestDeletedGroupNamesNeverCollide uses your Synthetic group / Synthetic group (#id) case. I also applied 032 and 033 to a copy of a populated database: the service started, row counts were unchanged, and foreign_key_check/integrity_check were clean.

Validation: make check passes on this branch (Node 24.22.2, pnpm 9.12.2): generate-check, gofmt/vet, lint, format, go test -race ./... (all 27 packages), 68 frontend test files / 544 tests, and the production build.

@murongg murongg left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the follow-up. The endpoint restriction and the separate namespace for derived bindings address the original cases. One session continuity issue still blocks merge: enabling stickiness can bypass a Claude conversation’s existing binding and send its continuation to a different account.

I’ve left four follow-up comments. The affected Go package tests, all 544 frontend tests, frontend type checking, lint, formatting, and make generate-check passed locally. Targeted synthetic tests reproduced the Claude routing, channel catalog, and IP report capacity issues. I traced the manual image settlement issue through the API and settlement code.

}
if claudeSession != "" && (explicit == "" || fromMetadata) {
// Claude subagents vary metadata.user_id; the shared session keeps the whole agent tree together.
hints.Session, hints.Replace = "claude:"+claudeSession, fromMetadata

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Enabling stickiness replaces the existing metadata.user_id session key before the gateway looks up its binding. In a synthetic routing test, a conversation bound before opt-in went to the fallback account after opt-in and after the original account was disabled. That breaks the strict continuation rule for provider-held state. Please look up and honor the existing binding before using the normalized key for an unbound request.

}
defer tx.Rollback()
q := s.queries.WithTx(tx)
if connectionChanged {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] The edit endpoint saves the replacement key without invalidating the model catalog. The handler then calls AccountCatalog with force=false, which reuses a fresh snapshot. A synthetic HTTP edit confirmed that the old model set and catalog revision survive key replacement. Please invalidate the catalog in the same transaction as the credential write, as the existing key-rotation path does.

return err
}
cost, err = Cost(rate, c.Input, c.Output, c.Cached)
cost, err = CostWithImages(rate, c.Input, c.ImageInput, c.Output, c.Cached)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Automatic settlement now passes the image input count, but the manual settlement endpoint only accepts total input, output, and cached tokens. If an image edit lands in the pending ledger and an administrator later has the token breakdown, the correction still charges all uncached input at the text rate. Please carry the image input count through the manual API and UI into this same settlement path.

Comment thread internal/server/ipreport.go Outdated
now := c.now()
entry := c.entries[ip]
if entry == nil {
c.evictLocked()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] When all 256 entries have a lookup in flight, evictLocked cannot make space and this branch still starts another request. A synthetic test left 257 entries in the cache. Please have admission report whether it made room, and reject a new IP when every slot is occupied by an in-flight lookup.

- Keep a conversation's existing raw metadata.user_id binding when
  stickiness is enabled; only unbound conversations adopt the normalized key.
- Invalidate the model catalog in the same transaction as a channel key
  replacement.
- Accept an optional image input count in manual allowance settlement and
  its correction form, settled through the same modality-aware cost path.
- Refuse a new IP report entry when every cache slot holds an in-flight
  lookup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alumos

Alumos commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks again — all four follow-ups reproduced as described and are fixed in 5d137a8:

P1 — stickiness bypasses an existing Claude binding (sticky.go / selection.go). Before normalizing a metadata.user_id session, selection now looks up the raw key's binding in the same transaction. If the conversation is already bound (or the lookup is ambiguous), the raw key stays authoritative and the existing strict rules apply, so a disabled original account returns its error instead of moving to a fallback. Only unbound conversations adopt the normalized claude:<session_id> key. TestStickinessHonorsBindingsMadeBeforeOptIn binds before opt-in, enables stickiness, disables the original account and asserts no fallback; it also checks that a new conversation's agents share one normalized binding.

P2 — key replacement keeps the old catalog (apikey.go). A key change now calls InvalidateAccountCatalog in the same transaction as the credential write, matching the existing rotation path. TestChannelSettingsCanBeEditedInPlace saves a snapshot, replaces the key over HTTP and asserts the snapshot is cleared and the revision increased.

P2 — manual settlement ignores the image split. POST /api/allocations/{id}/settle accepts an optional image_input and passes it to the same Finish/CostWithImages path; overlapping image and cached counts are rejected as invalid input. The correction form has an optional "Image input" field with the same client-side check. Covered by TestManualSettlementChargesImageInputAtImageRate (exact micro-USD amount and the overlap case) and a new AllocationSettlement.test.tsx.

P3 — cache admission past 256 in-flight entries. Admission now reports whether it made room; when every slot holds an in-flight lookup, a new IP is refused with 429 ip_report_rate_limited before any request starts. TestIPReportCacheRejectsNewIPsWhenEverySlotIsInFlight (run with -race) holds 256 lookups open and confirms the 257th IP is refused and the cache stays bounded.

Validation: make check passes on this branch (Node 24.22.2, pnpm 9.12.2): generate-check, gofmt/vet, lint, format, go test -race ./... (all 27 packages), 69 frontend test files / 545 tests, and the production build.

@murongg murongg left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The four findings from the previous round passed their regression tests on this commit. I reproduced two follow-up issues with synthetic tests. The merge blocker is that turning stickiness off can still move an existing Claude continuation to another account. Manual image settlement also needs to distinguish identical retries from submissions with different image token counts.

Comment thread internal/gateway/selection.go Outdated
return "", digest, err
}
derived := false
if hints, ok := stickyFrom(ctx); ok && policy.Routing.SessionSticky {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] The opt-in fix preserves bindings made before the toggle is enabled. In the other direction, a Claude session started with stickiness enabled is bound under the normalized key only. Turning stickiness off skips that lookup, and a continuation is scheduled as a new session under its raw metadata.user_id. A synthetic test confirmed that after disabling the original account, the continuation was admitted on another account. Please preserve existing normalized bindings when the setting is turned off. The setting can still control whether new normalized bindings are created.

return err
}
cost, err = Cost(rate, c.Input, c.Output, c.Cached)
cost, err = CostWithImages(rate, c.Input, c.ImageInput, c.Output, c.Cached)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] ImageInput now affects the charge, but the manual settlement retry check earlier in Finish only compares total input, output, and cached tokens. After settling a request with 400,000 image input tokens, submitting 600,000 with the same totals returned success and kept the original debit in a synthetic test. Please include the image input count in the stored settlement details and retry comparison so an identical retry still succeeds and a different breakdown returns a conflict.

…retry

- Choose an explicit Claude session's key from whichever raw or normalized
  key already owns its binding, independent of the stickiness setting; the
  setting only decides whether an unbound conversation creates a normalized
  binding.
- Store the image input count with allowance settlements and require it to
  match for a manual retry, so a different split is a conflict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alumos

Alumos commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — both reproduced as described and are fixed in 0b530bd:

P1 — opt-out moves a normalized Claude continuation (selection.go). Key selection for explicit Claude sessions no longer depends on the setting. For every request with Claude session metadata, selection checks the raw metadata.user_id key first and then the normalized claude:<session_id> key, and keeps whichever already owns a binding (ambiguous lookups count as bound). The setting now only decides whether an unbound conversation creates a normalized binding; derived preferences and parent preferences still apply only while it is enabled. TestNormalizedBindingsSurviveOptOut binds with stickiness on, turns it off, disables the original account and asserts the continuation is not admitted elsewhere, then re-enables it and asserts it returns to the same account; it also checks that an unbound conversation creates no normalized binding while the setting is off.

P2 — manual retry ignores the image split (runtime.go). Migration 034_allocation_image_input.sql adds allocation_entries.image_input_tokens (additive, default 0), Finish stores it with every settlement, and the manual retry check compares it with the other token counts. An identical retry still succeeds; the same totals with a different image split return 409 invalid_allocation_settlement and leave the original debit and breakdown unchanged. TestManualSettlementChargesImageInputAtImageRate covers your 400,000 → 600,000 case.

Validation: make check passes on this branch (Node 24.22.2, pnpm 9.12.2): generate-check, gofmt/vet, lint, format, go test -race ./... (all 27 packages), 69 frontend test files / 545 tests, and the production build.

- Share one ConfirmDelete dialog between resource groups and allowances.
- Drop IP report fields the interface never shows (rdns, abuser level,
  threats, fetch time) and their parsing.
- Return a bare stream for Images responses, which never use translation
  state.
- Remove a redundant channel lookup before updates and locale keys orphaned
  by the new channel and proxy layouts.
- Replace the variadic stickiness test helper with setSticky.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@Alumos

Alumos commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up cleanup in cd52371 (no behavior change): the resource-group and allowance delete dialogs now share one ConfirmDelete component; IP report fields the interface never displays (rdns, abuser level, threats, fetch time) and their parsing are removed; Images responses return a bare stream since they never use translation state; a redundant channel lookup before updates and three locale keys orphaned by the new channel and proxy layouts are removed; and the stickiness test helper is simplified. make check passes (all 27 Go packages under -race, 69 frontend files / 545 tests, build).

@murongg
murongg merged commit 2fec7f7 into murongg:main Nov 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants