diff --git a/PRODUCT.md b/PRODUCT.md index 8a3f9a19..7780ac67 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -105,15 +105,15 @@ 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 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. +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; a conversation already bound under its raw `metadata.user_id` key before opt-in keeps that binding, and only unbound conversations adopt the normalized key. Explicit sessions keep the existing strict binding rules. -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. +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; a key change resets verification and invalidates the model catalog in the same transaction. 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. 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. +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, rate-limited responses back off for five minutes, and a new IP is refused while all 256 cache slots hold in-flight lookups. 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. 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. +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; manual settlement of a pending request accepts the same optional image input count. CC Switch picker suggestions omit image models. Live ChatGPT image acceptance remains unverified. 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. diff --git a/internal/accounts/apikey.go b/internal/accounts/apikey.go index 17202088..c4ce694d 100644 --- a/internal/accounts/apikey.go +++ b/internal/accounts/apikey.go @@ -172,6 +172,10 @@ func (s *Service) UpdateAPIChannel(ctx context.Context, id string, input APIChan if err := s.persist(ctx, q, id, credential, status); err != nil { return Account{}, err } + // A new key may grant a different model set; discovery must not reuse the old snapshot. + if err := q.InvalidateAccountCatalog(ctx, id); err != nil { + return Account{}, err + } } now := s.now().Unix() if name != row.Name { diff --git a/internal/allocations/allocations_test.go b/internal/allocations/allocations_test.go index 74ab3d22..a417bd52 100644 --- a/internal/allocations/allocations_test.go +++ b/internal/allocations/allocations_test.go @@ -8,6 +8,7 @@ import ( "fmt" "path/filepath" "slices" + "strconv" "strings" "testing" "time" @@ -1793,3 +1794,47 @@ func TestCostSettlesImageInputByModality(t *testing.T) { t.Fatal("overlapping categories accepted", err) } } + +func TestManualSettlementChargesImageInputAtImageRate(t *testing.T) { + s, conn, user, account := fixture(t) + ctx := context.Background() + now := time.Date(2030, 1, 2, 12, 0, 0, 0, time.UTC) + s.now = func() time.Time { return now } + config := `{"mode":"amount","period":"day","members":[{"user_id":` + strconv.FormatInt(user, 10) + `,"limit":1000000000}],"rates":[{"model":"gpt-image-2","input":5000000,"cached":1250000,"output":30000000,"image_input":8000000}]}` + if _, err := conn.Exec(`INSERT INTO allocation_schemes(id,name,group_id,enabled,created_at) VALUES(9,'Synthetic image allowance',2,1,?)`, now.Unix()); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(`INSERT INTO allocation_revisions(id,scheme_id,effective_at,accounting_anchor_at,config) VALUES(90,9,?,?,?)`, now.Unix()-60, now.Unix()-60, config); err != nil { + t.Fatal(err) + } + q := db.New(conn) + r := Request{ID: "synthetic-image-edit", SchemeID: 9, UserID: user, GroupID: 2, AccountID: account, Model: "gpt-image-2", StartedAt: now.Unix()} + if err := Begin(ctx, q, r, now.Unix(), time.UTC); err != nil { + t.Fatal(err) + } + if err := q.RecoverAllocationEntries(ctx, 1); err != nil { + t.Fatal(err) + } + if err := s.Settle(ctx, 9, r.ID, Completion{Input: 1_000_000, ImageInput: 400_000, Cached: 100_000, Output: 1_000_000}); err != nil { + t.Fatal(err) + } + var cost int64 + if err := conn.QueryRow(`SELECT cost FROM allocation_entries WHERE request_id=?`, r.ID).Scan(&cost); err != nil { + t.Fatal(err) + } + // 500k text * $5 + 400k image * $8 + 100k cached * $1.25 + 1M image output * $30, in micro-USD. + if cost != 35_825_000 { + t.Fatalf("manual settlement ignored the image input split: %d", cost) + } + overlap := r + overlap.ID = "synthetic-image-overlap" + if err := Begin(ctx, q, overlap, now.Unix(), time.UTC); err != nil { + t.Fatal(err) + } + if err := q.RecoverAllocationEntries(ctx, 1); err != nil { + t.Fatal(err) + } + if err := s.Settle(ctx, 9, overlap.ID, Completion{Input: 100, ImageInput: 60, Cached: 50}); !errors.Is(err, ErrInput) { + t.Fatal("overlapping image and cached input accepted", err) + } +} diff --git a/internal/gateway/selection.go b/internal/gateway/selection.go index 46a3939c..8a8b40c2 100644 --- a/internal/gateway/selection.go +++ b/internal/gateway/selection.go @@ -90,7 +90,21 @@ func (s *Service) selectAllocationAccount(ctx context.Context, userID, groupID i } derived := false if hints, ok := stickyFrom(ctx); ok && policy.Routing.SessionSticky { - if hints.Session != "" && (session == "" || hints.Replace) { + normalize := hints.Session != "" && (session == "" || hints.Replace) + if normalize && session != "" { + // A conversation bound under its raw metadata key before opt-in keeps that binding: + // provider-held state must continue on its original account. + rawScope := provider + if rawScope == "" { + rawScope = "auto" + } + bound, err := sessionBinding(ctx, q, db.ListSessionAffinitiesParams{GroupID: groupID, UserID: userID, SessionHash: digest[:], Now: s.now().Unix()}, rawScope) + if err != nil && !errors.Is(err, ErrAffinityUnavailable) { + return "", digest, err + } + normalize = bound == nil && err == nil + } + if normalize { session, derived = hints.Session, hints.Derived digest = sessionDigest(userID, groupID, session) if derived { diff --git a/internal/gateway/sticky_test.go b/internal/gateway/sticky_test.go index 4523ae07..234237d0 100644 --- a/internal/gateway/sticky_test.go +++ b/internal/gateway/sticky_test.go @@ -12,6 +12,15 @@ import ( func stickyGateway(t *testing.T, sticky bool) (*Service, string, string) { t.Helper() s, parent, other := reviewGateway(t) + return stickyGatewayFrom(t, s, sticky, parent, other) +} + +func stickyGatewayFrom(t *testing.T, s *Service, sticky bool, ids ...string) (*Service, string, string) { + t.Helper() + parent, other := "", "" + if len(ids) == 2 { + parent, other = ids[0], ids[1] + } detail, err := groups.New(s.db).Get(context.Background(), 1) if err != nil { t.Fatal(err) @@ -192,3 +201,38 @@ func TestDerivedPreferencesNeverBlockExplicitSessions(t *testing.T) { t.Fatal("derived request was rejected") } } + +func TestStickinessHonorsBindingsMadeBeforeOptIn(t *testing.T) { + s, _, _ := stickyGateway(t, false) + ctx := context.Background() + raw := `{"device_id":"synthetic","session_id":"synthetic-claude-session"}` + original, _, err := s.selectAccount(ctx, 1, 1, raw, "", "synthetic-model", Responses) + if err != nil { + t.Fatal(err) + } + s, _, _ = stickyGatewayFrom(t, s, true) + if _, err := s.accounts.SetEnabled(ctx, original, false); err != nil { + t.Fatal(err) + } + hints := withStickyHints(ctx, stickyHints{Session: "claude:synthetic-claude-session", Replace: true}) + // The raw metadata binding stays authoritative; provider state must not move to a fallback. + got, _, err := s.selectAllocationAccount(hints, 1, 1, raw, "", "synthetic-model", Responses, 0, pinnedAffinity, "") + if err == nil || got != original { + t.Fatalf("bound conversation moved after opt-in: %q %v", got, err) + } + if _, err := s.accounts.SetEnabled(ctx, original, true); err != nil { + t.Fatal(err) + } + // Unbound conversations adopt the normalized key, shared by the whole agent tree. + fresh := withStickyHints(ctx, stickyHints{Session: "claude:synthetic-new-session", Replace: true}) + first, _, err := s.selectAllocationAccount(fresh, 1, 1, `{"session_id":"synthetic-new-session","agent_id":"a"}`, "", "synthetic-model", Responses, 0, pinnedAffinity, "") + if err != nil { + t.Fatal(err) + } + for _, agent := range []string{"b", "c", "d"} { + got, _, err := s.selectAllocationAccount(fresh, 1, 1, `{"session_id":"synthetic-new-session","agent_id":"`+agent+`"}`, "", "synthetic-model", Responses, 0, pinnedAffinity, "") + if err != nil || got != first { + t.Fatal("agent tree did not share the normalized binding", agent, got, err) + } + } +} diff --git a/internal/server/allocations.go b/internal/server/allocations.go index 1357c7e6..1e8defc4 100644 --- a/internal/server/allocations.go +++ b/internal/server/allocations.go @@ -216,15 +216,21 @@ func (h *allocationHTTP) settle(w http.ResponseWriter, r *http.Request) { Input *int64 `json:"input"` Output *int64 `json:"output"` Cached *int64 `json:"cached"` + // ImageInput is the optional image share of Input for image model requests. + ImageInput *int64 `json:"image_input"` } if !decodeJSON(w, r, &in) { return } - if in.Input == nil || in.Output == nil || in.Cached == nil { + if in.Input == nil || in.Output == nil || in.Cached == nil || in.ImageInput != nil && *in.ImageInput < 0 { allocationError(w, allocations.ErrInput) return } - err = h.gateway.Allocations().Settle(r.Context(), id, in.RequestID, allocations.Completion{Input: *in.Input, Output: *in.Output, Cached: *in.Cached}) + var imageInput int64 + if in.ImageInput != nil { + imageInput = *in.ImageInput + } + err = h.gateway.Allocations().Settle(r.Context(), id, in.RequestID, allocations.Completion{Input: *in.Input, ImageInput: imageInput, Output: *in.Output, Cached: *in.Cached}) if err != nil { allocationError(w, err) return diff --git a/internal/server/channels_test.go b/internal/server/channels_test.go index 68507abc..1b3c8e4d 100644 --- a/internal/server/channels_test.go +++ b/internal/server/channels_test.go @@ -2,10 +2,12 @@ package server import ( "encoding/json" + "github.com/murongg/SubLane/internal/upstream" "io" "net/http" "strings" "testing" + "time" "github.com/murongg/SubLane/internal/groups" ) @@ -205,10 +207,23 @@ func TestChannelSettingsCanBeEditedInPlace(t *testing.T) { t.Fatal("edit removed group membership", value) } } + if err := f.accounts.SaveCatalog(t.Context(), channel.ID, 0, []string{"synthetic-old-model"}, time.Now().Unix(), upstream.CatalogSource("openai")); err != nil { + t.Fatal(err) + } + var revision int64 + if err := f.connection.QueryRow(`SELECT models_revision FROM accounts WHERE id=?`, channel.ID).Scan(&revision); err != nil { + t.Fatal(err) + } 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()) } + // A replacement key must not reuse the previous key's model snapshot. + var cleared bool + var next int64 + if err := f.connection.QueryRow(`SELECT models_snapshot IS NULL, models_revision FROM accounts WHERE id=?`, channel.ID).Scan(&cleared, &next); err != nil || !cleared || next <= revision { + t.Fatal("key replacement kept the old catalog", cleared, next, revision, err) + } 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) } diff --git a/internal/server/ipreport.go b/internal/server/ipreport.go index a3dfe0ac..e1663f7f 100644 --- a/internal/server/ipreport.go +++ b/internal/server/ipreport.go @@ -62,7 +62,11 @@ func (c *ipReportCache) fetch(ctx context.Context, ip string) (upstream.IPReport now := c.now() entry := c.entries[ip] if entry == nil { - c.evictLocked() + if !c.makeRoomLocked() { + // Every slot holds an in-flight lookup; admitting another IP would exceed the bound. + c.mu.Unlock() + return upstream.IPReport{}, ipReportCooldown, errIPReportPaced + } entry = &ipReportEntry{} c.entries[ip] = entry } @@ -111,10 +115,11 @@ func (c *ipReportCache) fetch(ctx context.Context, ip string) (upstream.IPReport return report, 0, nil } +// makeRoomLocked reports whether a new entry fits, evicting the oldest idle entry when full. // Called with the lock held; in-flight entries are never evicted. -func (c *ipReportCache) evictLocked() { +func (c *ipReportCache) makeRoomLocked() bool { if len(c.entries) < ipReportEntries { - return + return true } oldest := "" for key, value := range c.entries { @@ -125,9 +130,11 @@ func (c *ipReportCache) evictLocked() { oldest = key } } - if oldest != "" { - delete(c.entries, oldest) + if oldest == "" { + return false } + delete(c.entries, oldest) + return true } func (h *proxyHTTP) exitIP(w http.ResponseWriter, r *http.Request) (string, bool) { diff --git a/internal/server/ipreport_test.go b/internal/server/ipreport_test.go index d213655d..2ee27bfa 100644 --- a/internal/server/ipreport_test.go +++ b/internal/server/ipreport_test.go @@ -97,3 +97,36 @@ func TestIPReportCacheNeverDisclosesOnReadAndPacesLookups(t *testing.T) { t.Fatal("cache is unbounded", len(cache.entries)) } } + +func TestIPReportCacheRejectsNewIPsWhenEverySlotIsInFlight(t *testing.T) { + release := make(chan struct{}) + var calls atomic.Int32 + cache := &ipReportCache{entries: map[string]*ipReportEntry{}, now: time.Now, lookup: func(_ context.Context, ip string) (upstream.IPReport, error) { + calls.Add(1) + <-release + return upstream.IPReport{IP: ip}, nil + }} + var group sync.WaitGroup + for i := range ipReportEntries { + group.Add(1) + go func() { + defer group.Done() + _, _, _ = cache.fetch(context.Background(), fmt.Sprintf("192.0.2.%d", i)) + }() + } + deadline := time.Now().Add(5 * time.Second) + for int(calls.Load()) < ipReportEntries && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if _, _, err := cache.fetch(context.Background(), "198.51.100.200"); !errors.Is(err, errIPReportPaced) { + t.Fatal("a new IP was admitted while every slot was in flight", err) + } + cache.mu.Lock() + size := len(cache.entries) + cache.mu.Unlock() + close(release) + group.Wait() + if size > ipReportEntries || int(calls.Load()) != ipReportEntries { + t.Fatal("cache exceeded its bound", size, calls.Load()) + } +} diff --git a/web/src/components/AllocationSettlement.test.tsx b/web/src/components/AllocationSettlement.test.tsx new file mode 100644 index 00000000..3425ddd9 --- /dev/null +++ b/web/src/components/AllocationSettlement.test.tsx @@ -0,0 +1,70 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { expect, it, vi } from 'vitest' +import { AllocationSettlement } from './AllocationSettlement' + +const entry = { + request_id: 'synthetic-image-edit', + user_id: 2, + mode: 'amount' as const, + model: 'gpt-image-2', + state: 'pending', + input: 0, + output: 0, + cached: 0, +} + +function mount(onSaved = vi.fn().mockResolvedValue(undefined)) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + render( + + + , + ) + return onSaved +} + +it('settles image input separately and rejects overlapping token categories', async () => { + const bodies: unknown[] = [] + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((url: string, init?: RequestInit) => { + if (url === '/api/allocations/9/settle') { + bodies.push(JSON.parse(String(init?.body))) + return Promise.resolve(new Response(null, { status: 204 })) + } + return Promise.reject(new Error('Unexpected request')) + }), + ) + const onSaved = mount() + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: 'Correct usage' })) + const fill = async (label: string, value: string) => { + const field = screen.getByLabelText(label) + await user.clear(field) + await user.type(field, value) + } + await fill('Input tokens · M', '1') + await fill('Output tokens · M', '1') + await fill('Cached input · M', '0.1') + // Image and cached input together cannot exceed the total input. + await fill('Image input · M', '0.95') + await user.click(screen.getByRole('button', { name: 'Confirm settlement' })) + expect(await screen.findByRole('alert')).toBeTruthy() + expect(bodies).toHaveLength(0) + await fill('Image input · M', '0.4') + await user.click(screen.getByRole('button', { name: 'Confirm settlement' })) + await waitFor(() => expect(onSaved).toHaveBeenCalled()) + expect(bodies).toEqual([ + { + request_id: 'synthetic-image-edit', + input: 1_000_000, + output: 1_000_000, + cached: 100_000, + image_input: 400_000, + }, + ]) +}) diff --git a/web/src/components/AllocationSettlement.tsx b/web/src/components/AllocationSettlement.tsx index eb109ce3..617477cb 100644 --- a/web/src/components/AllocationSettlement.tsx +++ b/web/src/components/AllocationSettlement.tsx @@ -63,11 +63,18 @@ export function AllocationSettlement({ String(form.get('cached') ?? ''), 'tokens', ) ?? -1 + // An empty image share means the request had no image input. + const imageText = String(form.get('image_input') ?? '').trim() + const imageInput = + imageText === '' + ? 0 + : (parseAllocationValue(imageText, 'tokens') ?? -1) const bad = input < entry.input || output < entry.output || cached < 0 || - cached > input + imageInput < 0 || + cached + imageInput > input setInvalid(bad) if (!bad) mutation.mutate({ @@ -75,12 +82,13 @@ export function AllocationSettlement({ input, output, cached, + image_input: imageInput, }) }} >
{(['input', 'output', 'cached'] as const).map((key) => (
@@ -104,6 +112,27 @@ export function AllocationSettlement({ />
))} +
+ + +

+ {t('allocationImageInputHint')} +

+
{(invalid || mutation.isError) && (

diff --git a/web/src/lib/allocations.ts b/web/src/lib/allocations.ts index d4991c8a..7a15803c 100644 --- a/web/src/lib/allocations.ts +++ b/web/src/lib/allocations.ts @@ -162,6 +162,8 @@ export function settleAllocation( input: number output: number cached: number + // Image share of input for image model requests; settled at the image input rate. + image_input?: number }, ) { return request(`/api/allocations/${id}/settle`, z.undefined(), { diff --git a/web/src/locales/ar.json b/web/src/locales/ar.json index 0fcca394..4760e882 100644 --- a/web/src/locales/ar.json +++ b/web/src/locales/ar.json @@ -1615,5 +1615,7 @@ "ipReportPageHint": "لا يتم الاستعلام عن نقاء IP إلا عند طلبك؛ وعندها تتلقى ip.net.coffee عنوان IP للخروج هذا وتُخزَّن النتيجة مؤقتًا لست ساعات.", "ipReportLookup": "فحص نقاء IP", "ipReportLookupNamed": "فحص نقاء IP لـ {{name}}", - "ipReportLookupHint": "يؤدي الفحص إلى إرسال عنوان IP للخروج هذا إلى ip.net.coffee." + "ipReportLookupHint": "يؤدي الفحص إلى إرسال عنوان IP للخروج هذا إلى ip.net.coffee.", + "allocationImageInputM": "إدخال الصور · M", + "allocationImageInputHint": "لطلبات نماذج الصور: حصة الصور من رموز الإدخال وتُحتسب بسعر إدخال الصور. اتركه فارغًا في غير ذلك." } diff --git a/web/src/locales/de.json b/web/src/locales/de.json index f50e0287..0353be17 100644 --- a/web/src/locales/de.json +++ b/web/src/locales/de.json @@ -1595,5 +1595,7 @@ "ipReportPageHint": "Die IP-Reinheit wird nur auf Anfrage abgefragt; dabei erhält ip.net.coffee diese Ausgangs-IP, und das Ergebnis wird sechs Stunden zwischengespeichert.", "ipReportLookup": "IP-Reinheit prüfen", "ipReportLookupNamed": "IP-Reinheit für {{name}} prüfen", - "ipReportLookupHint": "Beim Prüfen wird diese Ausgangs-IP an ip.net.coffee gesendet." + "ipReportLookupHint": "Beim Prüfen wird diese Ausgangs-IP an ip.net.coffee gesendet.", + "allocationImageInputM": "Bildeingabe · M", + "allocationImageInputHint": "Für Anfragen an Bildmodelle: der Bildanteil der Eingabetokens, abgerechnet zum Bildeingabepreis. Sonst leer lassen." } diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 9210402b..b953129e 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -1992,4 +1992,7 @@ export const en = { ipReportLookupHint: 'Checking sends this exit IP to ip.net.coffee.', ipReportPageHint: 'IP purity is looked up only when you request it; ip.net.coffee then receives that exit IP, and results are cached for six hours.', + allocationImageInputM: 'Image input · M', + allocationImageInputHint: + 'For image model requests: the image share of input tokens, charged at the image input rate. Leave empty otherwise.', } satisfies Record diff --git a/web/src/locales/es.json b/web/src/locales/es.json index 3e0dce3a..1a717228 100644 --- a/web/src/locales/es.json +++ b/web/src/locales/es.json @@ -1600,5 +1600,7 @@ "ipReportPageHint": "La pureza de IP solo se consulta cuando la solicitas; entonces ip.net.coffee recibe esa IP de salida y el resultado se guarda seis horas.", "ipReportLookup": "Comprobar pureza de IP", "ipReportLookupNamed": "Comprobar la pureza de IP de {{name}}", - "ipReportLookupHint": "Al comprobarla, esta IP de salida se envía a ip.net.coffee." + "ipReportLookupHint": "Al comprobarla, esta IP de salida se envía a ip.net.coffee.", + "allocationImageInputM": "Entrada de imagen · M", + "allocationImageInputHint": "Para solicitudes de modelos de imagen: la parte de imagen de los tokens de entrada, cobrada a la tarifa de entrada de imagen. Déjalo vacío en otro caso." } diff --git a/web/src/locales/fr.json b/web/src/locales/fr.json index 95e3692e..2c08d708 100644 --- a/web/src/locales/fr.json +++ b/web/src/locales/fr.json @@ -1600,5 +1600,7 @@ "ipReportPageHint": "La pureté IP n’est consultée que sur demande ; ip.net.coffee reçoit alors cette IP de sortie et le résultat est conservé six heures.", "ipReportLookup": "Vérifier la pureté IP", "ipReportLookupNamed": "Vérifier la pureté IP de {{name}}", - "ipReportLookupHint": "La vérification envoie cette IP de sortie à ip.net.coffee." + "ipReportLookupHint": "La vérification envoie cette IP de sortie à ip.net.coffee.", + "allocationImageInputM": "Entrée image · M", + "allocationImageInputHint": "Pour les requêtes de modèles d’image : la part image des jetons d’entrée, facturée au tarif d’entrée image. Sinon, laissez vide." } diff --git a/web/src/locales/ja.json b/web/src/locales/ja.json index 5b12baed..d2336978 100644 --- a/web/src/locales/ja.json +++ b/web/src/locales/ja.json @@ -1595,5 +1595,7 @@ "ipReportPageHint": "IP 純度は手動で確認したときだけ取得し、その出口 IP を ip.net.coffee に送信します。結果は 6 時間キャッシュされます。", "ipReportLookup": "IP 純度を確認", "ipReportLookupNamed": "{{name}} の IP 純度を確認", - "ipReportLookupHint": "確認すると、この出口 IP が ip.net.coffee に送信されます。" + "ipReportLookupHint": "確認すると、この出口 IP が ip.net.coffee に送信されます。", + "allocationImageInputM": "うち画像入力 · M", + "allocationImageInputHint": "画像モデルのリクエスト用:入力トークンのうち画像の分で、画像入力料金で課金されます。それ以外は空欄のままにしてください。" } diff --git a/web/src/locales/ko.json b/web/src/locales/ko.json index 3c7cd485..db8cd86e 100644 --- a/web/src/locales/ko.json +++ b/web/src/locales/ko.json @@ -1595,5 +1595,7 @@ "ipReportPageHint": "IP 청정도는 직접 요청할 때만 조회하며, 이때 출구 IP가 ip.net.coffee로 전송되고 결과는 6시간 동안 캐시됩니다.", "ipReportLookup": "IP 청정도 확인", "ipReportLookupNamed": "{{name}}의 IP 청정도 확인", - "ipReportLookupHint": "확인하면 이 출구 IP가 ip.net.coffee로 전송됩니다." + "ipReportLookupHint": "확인하면 이 출구 IP가 ip.net.coffee로 전송됩니다.", + "allocationImageInputM": "이미지 입력 · M", + "allocationImageInputHint": "이미지 모델 요청 전용: 입력 토큰 중 이미지 부분이며 이미지 입력 요금으로 청구됩니다. 그 외에는 비워 두세요." } diff --git a/web/src/locales/pt.json b/web/src/locales/pt.json index 7548c0b2..8a8556dc 100644 --- a/web/src/locales/pt.json +++ b/web/src/locales/pt.json @@ -1600,5 +1600,7 @@ "ipReportPageHint": "A pureza do IP só é consultada quando você solicita; então ip.net.coffee recebe esse IP de saída e o resultado fica em cache por seis horas.", "ipReportLookup": "Verificar pureza do IP", "ipReportLookupNamed": "Verificar a pureza do IP de {{name}}", - "ipReportLookupHint": "A verificação envia este IP de saída para ip.net.coffee." + "ipReportLookupHint": "A verificação envia este IP de saída para ip.net.coffee.", + "allocationImageInputM": "Entrada de imagem · M", + "allocationImageInputHint": "Para solicitações de modelos de imagem: a parte de imagem dos tokens de entrada, cobrada pela tarifa de entrada de imagem. Caso contrário, deixe vazio." } diff --git a/web/src/locales/ru.json b/web/src/locales/ru.json index bc941126..85a7dcba 100644 --- a/web/src/locales/ru.json +++ b/web/src/locales/ru.json @@ -1605,5 +1605,7 @@ "ipReportPageHint": "Чистота IP запрашивается только по вашему запросу; тогда ip.net.coffee получает этот выходной IP, а результат кэшируется на шесть часов.", "ipReportLookup": "Проверить чистоту IP", "ipReportLookupNamed": "Проверить чистоту IP для {{name}}", - "ipReportLookupHint": "При проверке этот выходной IP отправляется на ip.net.coffee." + "ipReportLookupHint": "При проверке этот выходной IP отправляется на ip.net.coffee.", + "allocationImageInputM": "Ввод изображений · M", + "allocationImageInputHint": "Для запросов к моделям изображений: доля изображений во входных токенах по тарифу ввода изображений. Иначе оставьте пустым." } diff --git a/web/src/locales/zh-TW.json b/web/src/locales/zh-TW.json index cb37f3a9..c433ff02 100644 --- a/web/src/locales/zh-TW.json +++ b/web/src/locales/zh-TW.json @@ -1595,5 +1595,7 @@ "ipReportPageHint": "IP 純淨度只在你手動查詢時取得,屆時會把該出口 IP 傳送給 ip.net.coffee,結果快取 6 小時。", "ipReportLookup": "查詢 IP 純淨度", "ipReportLookupNamed": "查詢 {{name}} 的 IP 純淨度", - "ipReportLookupHint": "查詢時會把該出口 IP 傳送給 ip.net.coffee。" + "ipReportLookupHint": "查詢時會把該出口 IP 傳送給 ip.net.coffee。", + "allocationImageInputM": "其中圖片輸入 · M", + "allocationImageInputHint": "僅用於生圖模型請求:輸入 Token 中屬於圖片的部分,按圖片輸入價計費。其他請求留空即可。" } diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 3574a4dd..bdaeada1 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -1822,4 +1822,7 @@ export const zh: Record = { ipReportLookupHint: '查询时会把该出口 IP 发送给 ip.net.coffee。', ipReportPageHint: 'IP 纯净度只在你手动查询时获取,届时会把该出口 IP 发送给 ip.net.coffee,结果缓存 6 小时。', + allocationImageInputM: '其中图片输入 · M', + allocationImageInputHint: + '仅用于生图模型请求:输入 Token 中属于图片的部分,按图片输入价计费。其他请求留空即可。', }