Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions devlog/_plan/260907_lane_d/020_receipt.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,8 @@ and ordinary validation error remain
editable. Screenshot changed disabled input/reset with retry available.

Verification: NOT RUN locally by user instruction; focused tests execute in final top-head Cross-platform CI.

Implementation: unknown outcome guards input/reset handlers and submit, and focuses Retry
when saving fails without a receipt. Saved:true remains editable. Transport/body failure
matrix attempts a replacement intent and asserts no second PUT before read-only retry.
Astra Herschel plan verdict PASS. Screenshots and product execution await top CI artifact.
14 changes: 14 additions & 0 deletions devlog/_plan/260907_lane_d/030_account_prices.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,17 @@ account rename/removal invalidation. Account aliases never become identity autho
Audit determines precise supported historical labels from actual producer evidence.

Verification: NOT RUN locally by user instruction; focused tests execute in final top-head Cross-platform CI.

Astra Ohm audit corrections: config-only identity mapping supports selectable Codex
accounts, effective codexAccountLogLabel, exact ID compatibility aliases, and built-in
main/__main__. Generic OAuth stores are separate and excluded; no free-form inference.
Use exact configured provider before canonical account identity, exact override first.
Apply same namespace for context/priority/lower-bound modifiers, preserving attribution.
Include sorted mapping in version signature, but aliases/plan/reordering stay no-ops.

Implementation: exact selectable account IDs, effective labels and main forms are resolved
from config at overlay refresh. Only identity changes bump cache versions. Exact configured
providers and explicit user rows remain isolated; context/Fast/lower-bound use the selected
price namespace while request attribution is unchanged. Existing memo fast path is retained.
Regression fixtures cover mappings, collisions, ignored aliases/invalid rows, add/remove/
label invalidation, presentation no-ops, estimate/attempt/combo and tier parity.
20 changes: 14 additions & 6 deletions gui/src/components/ModelDisplayNameDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface ModelDisplayNameDialogProps {
saving: boolean;
requestError: string | null;
currentNamePending?: boolean;
mutationOutcomeUnknown?: boolean;
onRetry?: () => void;
onEdit?: () => void;
onSave: (displayName: string) => void;
Expand All @@ -28,6 +29,7 @@ export default function ModelDisplayNameDialog({
saving,
requestError,
currentNamePending = false,
mutationOutcomeUnknown = false,
onRetry,
onEdit,
onSave,
Expand All @@ -37,6 +39,7 @@ export default function ModelDisplayNameDialog({
const t = useT();
const dialogRef = useRef<HTMLDialogElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const submitRef = useRef<HTMLButtonElement>(null);
const wasSavingRef = useRef(saving);
const titleId = useId();
const helpId = useId();
Expand All @@ -55,8 +58,11 @@ export default function ModelDisplayNameDialog({
useEffect(() => {
const saveFailed = wasSavingRef.current && !saving && Boolean(requestError);
wasSavingRef.current = saving;
if (saveFailed) inputRef.current?.focus();
}, [requestError, saving]);
if (saveFailed) {
if (mutationOutcomeUnknown) submitRef.current?.focus();
else inputRef.current?.focus();
}
}, [requestError, saving, mutationOutcomeUnknown]);

// Parent replaces this snapshot only after a confirmed mutation, not typing or polling.
// Adjust before committing children, preserving the mounted dialog and its focus refs.
Expand Down Expand Up @@ -102,6 +108,7 @@ export default function ModelDisplayNameDialog({
event.preventDefault();
if (saving) return;
if (onRetry) { onRetry(); return; }
if (mutationOutcomeUnknown) return;
const nextValidationKey = modelDisplayNameValidationKey(draft);
setValidationKey(nextValidationKey);
if (!nextValidationKey) onSave(draft.trim());
Expand Down Expand Up @@ -137,8 +144,9 @@ export default function ModelDisplayNameDialog({
placeholder={t("models.displayNamePlaceholder")}
aria-describedby={`${helpId}${visibleError ? ` ${errorId}` : ""}`}
aria-invalid={validationError ? true : undefined}
disabled={saving}
disabled={saving || mutationOutcomeUnknown}
onChange={event => {
if (saving || mutationOutcomeUnknown) return;
onEdit?.();
setDraft(event.target.value);
setValidationKey(null);
Expand All @@ -157,15 +165,15 @@ export default function ModelDisplayNameDialog({
<button
type="button"
className="btn btn-ghost btn-sm"
disabled={saving || !model.displayNameOverride}
onClick={onReset}
disabled={saving || mutationOutcomeUnknown || !model.displayNameOverride}
onClick={() => { if (!saving && !mutationOutcomeUnknown) onReset(); }}
>
{t("models.displayNameReset")}
</button>
<button type="button" className="btn btn-sm" disabled={saving} onClick={requestClose}>
{t("common.cancel")}
</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
<button ref={submitRef} type="submit" className="btn btn-primary btn-sm" disabled={saving || (mutationOutcomeUnknown && !onRetry)}>
{saving ? t("common.saving") : onRetry ? t("common.retry") : t("common.save")}
</button>
</div>
Expand Down
1 change: 1 addition & 0 deletions gui/src/pages/Models.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2643,6 +2643,7 @@ export default function Models({ apiBase, restartEpoch = 0 }: { apiBase: string;
saving={displayNameSaving}
requestError={displayNameRequestError}
currentNamePending={displayNameCurrentPending}
mutationOutcomeUnknown={displayNameRecovery?.confirmed === false}
onRetry={displayNameRecovery ? () => void saveDisplayName(displayNameRecovery.value) : undefined}
onEdit={() => setDisplayNameRecovery(null)}
onSave={value => void saveDisplayName(value)}
Expand Down
12 changes: 10 additions & 2 deletions gui/tests/models-display-name-editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -448,8 +448,16 @@ describe("Models dashboard discovered display name integration", () => {
expect(currentNameText()).toContain("Current name unavailable until refresh");
expect(currentNameText()).not.toContain("Your name");
expect(container.textContent).toContain("The change may have been saved");
expect(dialogInput().disabled).toBe(true);
expect(dialogButton("Reset name").disabled).toBe(true);
expect(dialogButton("Retry").disabled).toBe(false);
expect(dialogButton("Cancel").disabled).toBe(false);
await act(async () => {
setInputValue(dialogInput(), "Replacement intent");
dialogButton("Reset name").dispatchEvent(new testWindow.MouseEvent("click", { bubbles: true }));
});
expect(dialogButton("Retry").disabled).toBe(false);
expect(mutationBodies).toHaveLength(1);
await act(async () => container.querySelector("dialog form")!.dispatchEvent(
new testWindow.Event("submit", { bubbles: true, cancelable: true }),
));
Expand Down Expand Up @@ -522,12 +530,12 @@ describe("Models dashboard discovered display name integration", () => {
if (stage === "reload") expect(seenSignals[1]).toBe(seenSignals[0]);
await act(async () => deadline.abort(new DOMException("Timed out", "TimeoutError")));
await flush();
expect(dialogInput().disabled).toBe(false);
expect(dialogInput().disabled).toBe(stage === "mutation");
expect(dialogButton("Cancel").disabled).toBe(false);
expect(dialogInput().value).toBe("Possibly saved");
expect(container.textContent).toContain(stage === "mutation"
? "The change may have been saved" : "The change was saved");
expect(testWindow.document.activeElement).toBe(dialogInput());
expect(testWindow.document.activeElement).toBe(stage === "mutation" ? dialogButton("Retry") : dialogInput());
stall = false;
if (descriptor) Object.defineProperty(AbortSignal, "timeout", descriptor);
await act(async () => dialogButton("Retry").click());
Expand Down
48 changes: 22 additions & 26 deletions src/usage/cost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ import {
} from "../generated/model-metadata";
import type { AttemptTierOutcome, OcxUsage } from "../types";
import { canonicalFastTierMarker } from "../providers/fastwire";
import { baseProviderLabel, canonicalUsageProviderLabel } from "../providers/label";
import { baseProviderLabel } from "../providers/label";
import type { PersistedUsageAttempt, UsageStatus } from "./log";
import { canonicalAntigravityUsageModel } from "../providers/antigravity-models";
import { activeConfiguredProviders, activeUserCostOverlays, userCostOverlayVersion } from "./user-cost-overlays";
import { activeAccountPricingProviders, activeConfiguredProviders, activeUserCostOverlays, userCostOverlayVersion } from "./user-cost-overlays";
import {
EXPECTED_PRICE_OVERLAYS,
findExpectedPriceOverlay,
Expand Down Expand Up @@ -187,21 +187,18 @@ export function resolveMatchedPrice(
userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(),
options: PriceResolutionOptions = {},
): MatchedPrice | null {
// User-configured overlays are keyed by the EXACT configured provider name.
// A provider that literally exists in config.providers keeps its own pricing
// namespace: a real custom provider can legitimately end with a label-shaped
// suffix (e.g. acme-pabcdef) and must not inherit the base provider's user
// overlay. Only NON-configured names (generated account log labels) collapse
// to their label base. chatgpt/openai-multi are the same OpenAI usage surface
// and always canonicalize to openai.
const collapsed = baseProviderLabel(provider);
if (collapsed !== provider && (canonicalUsageProviderLabel(provider) !== provider || !activeConfiguredProviders().has(provider))) {
// Literal configured providers win over account identities. Only then use
// config-owned Codex identities, followed by the existing historical suffix
// grammar. Never infer an account by stripping an arbitrary suffix.
const namespace = activeConfiguredProviders().has(provider)
? provider
: activeAccountPricingProviders().get(provider) ?? baseProviderLabel(provider);
if (namespace !== provider) {
// An exact override (including caller-supplied rows) owns its namespace.
// Unchanged names use the memoized inner lookup's existing user-first order.
const exactUserOverlay = userOverlayMatch(provider, modelId, userOverlays);
if (exactUserOverlay) return exactUserOverlay;
// Pool/account log suffixes (e.g. google-antigravity-p442fff) must collapse
// before the compiled/overlay lookup; configured providers keep their own
// namespace above.
provider = collapsed;
provider = namespace;
}
// Memoize by (provider, model): usage summaries iterate hundreds of thousands of
// rows that share a handful of provider/model keys, so resolving each time would
Expand Down Expand Up @@ -466,7 +463,7 @@ function applyContextTier(
tier?: ServiceTierInput,
): [Cost4, ContextTierName | undefined, boolean] {
if (rawInputTokens === undefined) return [cost4, undefined, false];
const rule = findContextTier(baseProviderLabel(provider), modelId);
const rule = findContextTier(provider, modelId);
if (!rule || !isLongContext(rule, rawInputTokens)) return [cost4, undefined, false];
const confirmedFast = isConfirmedFast(tier);
if (confirmedFast && rule.confirmedPriorityRelation === "exclusive") {
Expand Down Expand Up @@ -494,9 +491,8 @@ function applyPriorityMultiplier(
contextTier?: ContextTierName,
): [Cost4, number] {
if (canonicalFastTierMarker(tierScalar(serviceTier)) !== "priority") return [cost4, 1];
const base = baseProviderLabel(provider);
if (contextTier && findContextTier(base, modelId)?.confirmedPriorityRelation !== "stack") return [cost4, 1];
const rule = findPriorityPricingRule(base, modelId);
if (contextTier && findContextTier(provider, modelId)?.confirmedPriorityRelation !== "stack") return [cost4, 1];
const rule = findPriorityPricingRule(provider, modelId);
if (rule?.requiresResponseConfirmation && !isConfirmedFast(serviceTier)) return [cost4, 1];
const multiplier = rule?.multiplier ?? 1;
if (multiplier === 1) return [cost4, 1];
Expand Down Expand Up @@ -524,7 +520,7 @@ function isOpenRouterPriorityLowerBound(
provider: string,
outcome: AttemptTierOutcome | undefined,
): boolean {
return baseProviderLabel(provider) === "openrouter"
return provider === "openrouter"
&& outcome?.canonical === "priority"
&& outcome.fastOutcome === "applied"
&& (outcome.confirmation === "confirmed" || outcome.confirmation === "assumed");
Expand All @@ -550,13 +546,13 @@ export function estimateAttemptCost(
? serviceTierContextFromOutcome(attempt.tierOutcome)
: serviceTier;
const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier(
price.cost4, attempt.provider, attempt.model, attempt.usage.inputTokens, attemptServiceTier,
price.cost4, price.provider, attempt.model, attempt.usage.inputTokens, attemptServiceTier,
);
const [effectiveCost4, multiplier] = applyPriorityMultiplier(
tieredCost4, attempt.provider, attempt.model, attemptServiceTier, contextTier,
tieredCost4, price.provider, attempt.model, attemptServiceTier, contextTier,
);
const priorityLowerBound = contextPriorityLowerBound
|| isOpenRouterPriorityLowerBound(attempt.provider, attempt.tierOutcome);
|| isOpenRouterPriorityLowerBound(price.provider, attempt.tierOutcome);
return {
ordinal: attempt.ordinal,
provider: attempt.provider,
Expand Down Expand Up @@ -635,13 +631,13 @@ export function estimateRequestCost(
const price = resolveMatchedPrice(input.provider, input.model, overlays, userOverlays, input);
if (!price) return null;
const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier(
price.cost4, input.provider, input.model, input.usage.inputTokens, input.serviceTier,
price.cost4, price.provider, input.model, input.usage.inputTokens, input.serviceTier,
);
const [effectiveCost4, multiplier] = applyPriorityMultiplier(
tieredCost4, input.provider, input.model, input.serviceTier, contextTier,
tieredCost4, price.provider, input.model, input.serviceTier, contextTier,
);
const priorityLowerBound = contextPriorityLowerBound || isOpenRouterPriorityLowerBound(
input.provider,
price.provider,
typeof input.serviceTier === "object" ? input.serviceTier.tierOutcome : undefined,
);
return {
Expand Down
38 changes: 35 additions & 3 deletions src/usage/user-cost-overlays.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,23 @@
* must not churn the version (see refreshUserCostOverlays). The configured
* provider-name set is part of the change identity: adding or removing a
* provider changes which names may collapse to a label base in the resolver,
* so it bumps the version even when no overlay row changed.
* so it bumps the version even when no overlay row changed. Exact selectable
* Codex IDs and effective log labels also participate in that identity.
*
* Display-time estimation only — these rows never affect billing.
*/
import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types";
import { MAX_COST4_RATE, type ExpectedPriceOverlay } from "./expected-prices";
import { redactSecretString } from "../lib/redact";
import { isSelectableCodexPoolAccount, MAIN_CODEX_ACCOUNT_ID } from "../codex/account-id";
import { codexAccountLogLabel } from "../codex/account-label";

const EMPTY: readonly ExpectedPriceOverlay[] = [];

let active: readonly ExpectedPriceOverlay[] = EMPTY;
let activeSignature = "";
let activeConfigured = new Set<string>();
let activeAccountProviders = codexAccountProviders([]);
let version = 0;
let preservedDiskOnlyProviders: Record<string, OcxProviderConfig> | null = null;

Expand Down Expand Up @@ -54,6 +58,24 @@ function providerNames(config: OcxConfig): Set<string> {
return new Set(Object.keys(config.providers ?? {}));
}

/** Exact config-owned identities only; aliases and generic OAuth stores are not authority. */
function codexAccountProviders(accounts: OcxConfig["codexAccounts"]): Map<string, string> {
const identities = new Set(["main", MAIN_CODEX_ACCOUNT_ID]);
for (const account of accounts ?? []) {
if (!isSelectableCodexPoolAccount(account)) continue;
identities.add(account.id);
identities.add(codexAccountLogLabel(account));
}
const mapping = new Map<string, string>();
for (const identity of identities) {
mapping.set(identity, "openai");
Comment thread
lidge-jun marked this conversation as resolved.
for (const provider of ["openai", "chatgpt", "openai-multi"]) {
mapping.set(`${provider}-${identity}`, "openai");
}
}
return mapping;
}

/** Register one active live-config owner. Multiple server leases may share one config object. */
export function registerPreservedProviderOwner(config: OcxConfig): void {
const tagged = config as PreservationTaggedConfig;
Expand Down Expand Up @@ -289,12 +311,17 @@ export function refreshUserCostOverlays(config: OcxConfig): void {
// removing a provider (even one without an overlay) changes which names are
// allowed to collapse to a label base, so the resolver memo and the
// /api/usage summary cache must be invalidated on that change as well.
// Sort effective account identities so account order, aliases and plan
// metadata do not churn caches; add/remove/label changes still invalidate.
const configuredNames = Object.keys(providers ?? {}).sort();
const signature = `${JSON.stringify(configuredNames)}\u0000${JSON.stringify(rows)}`;
const accountProviders = codexAccountProviders(config.codexAccounts);
const accountEntries = [...accountProviders].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
const signature = `${JSON.stringify(configuredNames)}\u0000${JSON.stringify(rows)}\u0000${JSON.stringify(accountEntries)}`;
if (signature === activeSignature) return;
activeSignature = signature;
active = rows;
activeConfigured = new Set(configuredNames);
activeAccountProviders = accountProviders;
version++;
}

Expand All @@ -303,7 +330,7 @@ export function activeUserCostOverlays(): readonly ExpectedPriceOverlay[] {
return active;
}

/** Monotonic version bumped on every refresh; used by the estimator memo key. */
/** Monotonic version bumped on pricing-identity changes; used by the estimator memo key. */
export function userCostOverlayVersion(): number {
return version;
}
Expand All @@ -312,3 +339,8 @@ export function userCostOverlayVersion(): number {
export function activeConfiguredProviders(): ReadonlySet<string> {
return activeConfigured;
}

/** Account pricing identities built at refresh, without reading credential stores. */
export function activeAccountPricingProviders(): ReadonlyMap<string, string> {
return activeAccountProviders;
}
Loading
Loading