Repository navigation
feat: session stickiness, channel editing, group/allowance deletion, Codex images, OpenCode Go usage and proxy IP reports - #160
Conversation
…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
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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 |
There was a problem hiding this comment.
[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.
| } 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) |
There was a problem hiding this comment.
[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.
| } | ||
| result := make(map[string]Price, len(document)) | ||
| for model, value := range document { | ||
| if value.Output == nil && value.Mode == "image_generation" { |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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 }) |
There was a problem hiding this comment.
[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.
| return entry.report, nil | ||
| } | ||
| c.mu.Unlock() | ||
| report, err := c.lookup(ctx, ip) |
There was a problem hiding this comment.
[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.
| } | ||
| 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)}) |
There was a problem hiding this comment.
[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>
|
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 ( P1 — derived session rewrites an explicit binding ( P2 — preferences consume the shared 4,096 rows. Migration P2 — image pricing. For P2 — OpenCode Go usage with a relay URL. The usage lookup now requires the official P2 — automatic IP disclosure on page load. 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 P2 — retired group names collide. Migration Validation: |
murongg
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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 { |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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.
| now := c.now() | ||
| entry := c.entries[ip] | ||
| if entry == nil { | ||
| c.evictLocked() |
There was a problem hiding this comment.
[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>
|
Thanks again — all four follow-ups reproduced as described and are fixed in 5d137a8: P1 — stickiness bypasses an existing Claude binding ( P2 — key replacement keeps the old catalog ( P2 — manual settlement ignores the image split. 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 Validation: |
murongg
left a comment
There was a problem hiding this comment.
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.
| return "", digest, err | ||
| } | ||
| derived := false | ||
| if hints, ok := stickyFrom(ctx); ok && policy.Routing.SessionSticky { |
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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>
|
Thanks — both reproduced as described and are fixed in 0b530bd: P1 — opt-out moves a normalized Claude continuation ( P2 — manual retry ignores the image split ( Validation: |
- 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>
|
Follow-up cleanup in cd52371 (no behavior change): the resource-group and allowance delete dialogs now share one |
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.
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.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.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.5e66974).POST /v1/images/generationsand/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-ingpt-image-*models; image models are rejected on text endpoints and vice versa; streaming partial images is not supported. Pricing now readsoutput_cost_per_image_tokenforimage_generationmodels, and CC Switch picker suggestions omit image models.5e66974). OpenCode Go channels show the rolling 5-hour, weekly and monthly windows from the official key-authenticatedGET https://opencode.ai/zen/go/v1/usage, reusing the subscription quota cache. Informational only; it does not gate scheduling.2186c36).allocation.deletewas 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.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 onip.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.92ba4d9). RadixFocusScoperestores 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 unhandleddispatchEvent ... 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.mdand 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): addsaccount_groups.session_sticky(default 0) andaccount_groups.deleted_at.031_image_requests.sql: rebuildsrequest_recordsto allow theimagesoperation, copying every row and recreating its indexes.codex:v4so stored catalogs are rediscovered once.Checklist
🤖 Generated with Claude Code