Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
307683d
fix(server): proxied usage limits keep a safe reset time
Bil0000 Nov 6, 2026
0c94651
fix(web): a direct login's failed reading no longer shows an unrelate…
Bil0000 Nov 6, 2026
a4073d1
fix(server): an empty Codex TZ reads reset times as UTC
Bil0000 Nov 6, 2026
ef61c02
fix(server): hub limits follow the accounts that serve the thread's m…
Bil0000 Nov 7, 2026
8b2304f
fix(web): the hub pool card keeps workspaces apart and shows every ro…
Bil0000 Nov 7, 2026
18f8c43
fix(shared): hub routing counts accounts with unread model lists and …
Bil0000 Nov 7, 2026
8a76c9a
fix(shared): only a custom-endpoint instance follows the hub's model …
Bil0000 Nov 7, 2026
e225159
fix(server): a hub reset follows the failing run's model, not a later…
Bil0000 Nov 7, 2026
f60fc3c
fix(web): the thread usage card shows the hub account that served the…
Bil0000 Nov 7, 2026
d18bde0
fix(server): a hub account with no routed models serves none
Bil0000 Nov 7, 2026
c09e21d
Merge remote-tracking branch 'origin/main' into t3/fix-account-limits…
Bil0000 Nov 7, 2026
8ad336b
docs: drop the hub account note from usage limits
Bil0000 Nov 7, 2026
886fa33
Merge remote-tracking branch 'origin/main' into t3/fix-account-limits…
Bil0000 Nov 7, 2026
11659f4
Merge remote-tracking branch 'github/main' into t3/fix-account-limits…
maria-rcks Nov 10, 2026
f16f5d7
Merge origin/main into t3/fix-account-limits-workspace-cards
juliusmarminge Nov 11, 2026
4c07d99
test(web): drop thread usage cases covered elsewhere
juliusmarminge Nov 11, 2026
15d591b
fix(server): a Codex custom model provider follows the hub's model ro…
juliusmarminge Nov 11, 2026
15f7ca6
Merge origin/main into t3/fix-account-limits-workspace-cards
Bil0000 Nov 12, 2026
6f72d9c
fix(server): a hub's limits apply only to instances sent to its address
Bil0000 Nov 12, 2026
efb91aa
fix(server): hub routing and resets ignore quota that cannot serve th…
Bil0000 Nov 12, 2026
df19659
Merge remote-tracking branch 'origin/main' into rv/30778
Bil0000 Nov 13, 2026
c909f45
fix(server): Codex custom providers keep their reported auth type
Bil0000 Nov 13, 2026
2a228db
fix(server): hub limits show the routed pool, not a guessed last-serv…
Bil0000 Nov 13, 2026
f37fb7d
fix(server): usage-limit failures read the hub's last reading instead…
Bil0000 Nov 13, 2026
2ffd864
test: keep only the regression cases for proxied usage limits
Bil0000 Nov 13, 2026
84e1e4c
fix(shared): a hub account with no models stays out of an alias's fal…
Bil0000 Nov 13, 2026
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
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
props.serverConfig?.providers ?? [],
props.serverConfig?.usageLimitSources ?? [],
Date.now(),
currentModelSelection.model,
);
onShowUsageLimits(report);
if (!report) {
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,7 @@ function ThreadDetailBody(
props.serverConfig?.providers ?? [],
props.serverConfig?.usageLimitSources ?? [],
usageLimitsPanel.now,
props.selectedThread.modelSelection.model,
)
: null;
const showUsageLimits = (report: UsageLimitsReport | null) =>
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6679,6 +6679,20 @@ describe("CodexAdapterV2 post-settle continuation", () => {
expectedClass: "usage_limit",
},
{ name: "retry", code: "usageLimitExceeded", notification: true, expectedClass: "usage_limit" },
// regression: #26574 a proxy strips the rate-limit snapshot, so the reset is only in the text
{
name: "message-reset",
code: "usageLimitExceeded",
message: "You’ve hit your usage limit. Try again at May 19th, 2033 7:20 AM.",
notification: false,
expectedClass: "usage_limit",
expectedResetAt: DateTime.formatIso(
DateTime.makeZonedUnsafe("2033-05-19T07:21:00", {
timeZone: DateTime.zoneMakeLocal(),
adjustForTimeZone: true,
}),
),
},
{
name: "exhausted-429",
code: "responseTooManyFailedAttempts",
Expand Down Expand Up @@ -7003,6 +7017,8 @@ describe("CodexAdapterV2 post-settle continuation", () => {
assert.equal(terminal.failure.resetAt, resetAt);
if (scenario.name === "newer-reset-during-read")
assert.equal(terminal.failure.resetAt, "2033-05-20T11:06:40.000Z");
if ("expectedResetAt" in scenario)
assert.equal(terminal.failure.resetAt, scenario.expectedResetAt);
if (scenario.name === "matching-details")
assert.equal(terminal.failure.message, "Detailed provider allowance explanation.");
if ("resetAfter" in scenario) {
Expand Down
28 changes: 24 additions & 4 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import {
codexRateLimitsToUpdate,
mergeCodexRateLimits,
codexMessageResetAt,
codexUsageLimitResetAt,
type CodexRateLimitSnapshot,
} from "../../provider/codexUsageLimits.ts";
Expand Down Expand Up @@ -1213,11 +1214,14 @@ function withCodexManagedFailure(
function failureResetAt(
failure: OrchestrationV2ProviderFailure,
snapshot: CodexRateLimitSnapshot | undefined,
now: DateTime.Utc,
codexTimeZone: string | undefined,
) {
// Sharing caps have no reset timestamp in the ordinary Codex allowance snapshot.
return classifyCodexManagedError(failure.code)?.usageLimit
? null
: codexUsageLimitResetAt(snapshot);
: (codexUsageLimitResetAt(snapshot) ??
codexMessageResetAt(failure.message, now, codexTimeZone));
}

interface ActiveCodexTurnContext {
Expand Down Expand Up @@ -1880,6 +1884,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
settings: resolvedRuntime?.config ?? adapterOptions.settings,
environment: resolvedRuntime?.environment ?? adapterOptions.environment,
});
const codexTimeZone = (resolvedRuntime?.environment ?? adapterOptions.environment).TZ;
const additionalContextByThread = yield* Ref.make(
new Map<string, NonNullable<CodexTurnStartParams["additionalContext"]>>(),
);
Expand Down Expand Up @@ -4810,7 +4815,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
);
const snapshot = yield* Ref.get(rateLimitSnapshot);
for (const item of (yield* Ref.get(limitedTurnItems)).values()) {
const resetAt = failureResetAt(item.failure, snapshot);
const resetAt = failureResetAt(
item.failure,
snapshot,
yield* DateTime.now,
codexTimeZone,
);
if (resetAt === null || item.failure.resetAt != null) continue;
const updated = {
...item,
Expand Down Expand Up @@ -6236,7 +6246,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
failure.class === "usage_limit"
? {
...failure,
resetAt: failureResetAt(failure, yield* Ref.get(rateLimitSnapshot)),
resetAt: failureResetAt(
failure,
yield* Ref.get(rateLimitSnapshot),
yield* DateTime.now,
codexTimeZone,
),
}
: failure,
...(input.providerRetry === undefined
Expand Down Expand Up @@ -6276,7 +6291,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
...event.failure,
resetAt:
event.failure.resetAt ??
failureResetAt(event.failure, yield* Ref.get(rateLimitSnapshot)),
failureResetAt(
event.failure,
yield* Ref.get(rateLimitSnapshot),
yield* DateTime.now,
codexTimeZone,
),
},
}
: event;
Expand Down
88 changes: 82 additions & 6 deletions apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
RunAttemptId,
RunId,
RuntimeRequestId,
type ServerProvider,
TurnItemId,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
Expand All @@ -45,6 +46,7 @@ import * as EventSink from "./EventSink.ts";
import * as EventStore from "./EventStore.ts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
import * as ProjectionStore from "./ProjectionStore.ts";
import { UsageLimitResetFallback } from "../usage/UsageLimitSources.ts";
import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts";
import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
Expand Down Expand Up @@ -261,14 +263,17 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit
},
snapshot: {
refreshUsage: Deferred.succeed(refreshed, undefined).pipe(Effect.asVoid),
getSnapshot: Effect.die("unused"),
getSnapshot: Effect.sync(() => own as ServerProvider),
refresh: Effect.die("unused"),
resolveMaintenance: () => Effect.die("unused"),
applyUsageLimits: () => Effect.die("unused"),
streamChanges: Stream.empty,
},
};
let own: Partial<ServerProvider> | undefined;
const lookups: ProviderInstanceId[] = [];
const hubLookups: string[] = [];
const hubResetAt = "2033-05-19T07:20:00.000Z";
const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
getInstance: (id) =>
Effect.sync(() => {
Expand All @@ -282,7 +287,36 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit
const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const threadEvent = yield* threadCreatedEvent(now);
yield* eventSink.write({ events: [threadEvent] });
// The run started on an earlier model than the thread's current selection.
const runId = RunId.make("run:usage-limit");
yield* eventSink.write({
events: [
threadEvent,
{
id: yield* idAllocator.allocate.event({ threadId: threadEvent.threadId }),
type: "run.created",
threadId: threadEvent.threadId,
occurredAt: now,
payload: {
id: runId,
threadId: threadEvent.threadId,
ordinal: 1,
providerInstanceId: modelSelection.instanceId,
modelSelection: { ...modelSelection, model: "gpt-run-model" },
providerThreadId: null,
userMessageId: MessageId.make("message:usage-limit"),
rootNodeId: null,
activeAttemptId: null,
status: "running",
requestedAt: now,
startedAt: now,
completedAt: null,
checkpointId: null,
contextHandoffId: null,
},
},
],
});
const input = {
providerSessionId: yield* idAllocator.allocate.providerSession({
providerInstanceId: instance.instanceId,
Expand All @@ -291,9 +325,33 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit
providerInstanceId: instance.instanceId,
threadId: threadEvent.threadId,
};
for (const failureClass of ["validation_error", "usage_limit"] as const) {
const unread = { checkedAt: now.toString(), windows: [] };
const cases = [
{ failureClass: "validation_error", provider: undefined, resetAt: undefined },
{
failureClass: "usage_limit",
provider: {
auth: { status: "authenticated", endpoint: "http://hub:8317" },
usageLimits: { ...unread, unavailable: { reason: "unsupported" } },
},
resetAt: hubResetAt,
runId,
},
// regression: #30778 an instance with no hub endpoint gets no hub reset
{
failureClass: "usage_limit",
provider: {
auth: { status: "authenticated" },
usageLimits: { ...unread, unavailable: { reason: "unsupported" } },
},
resetAt: undefined,
},
] as const;
for (const [index, { failureClass, provider, resetAt, ...run }] of cases.entries()) {
own = provider;
const stored = yield* ingestor.ingestNormalized({
...input,
...run,
event: {
type: "turn.terminal",
driver: instance.driverKind,
Expand All @@ -303,7 +361,7 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit
}),
providerTurnId: idAllocator.derive.providerTurn({
driver: instance.driverKind,
nativeTurnId: failureClass,
nativeTurnId: String(index),
}),
runOrdinal: 1,
failureItemOrdinal: 1,
Expand All @@ -317,10 +375,28 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit
});
assert.equal(stored.length, 1);
if (failureClass === "validation_error") assert.deepEqual(lookups, []);
// regression: #26574 a proxied CLI drops the reset, so the hub's pool refill fills it
const item = stored[0]?.event;
assert.equal(
item?.type === "turn-item.updated" && item.payload.type === "error"
? item.payload.failure.resetAt
: undefined,
resetAt,
);
}
assert.deepEqual(lookups, [instance.instanceId]);
assert.deepEqual(lookups, Array(4).fill(instance.instanceId));
assert.deepEqual(hubLookups, [`${instance.driverKind}:http://hub:8317:gpt-run-model`]);
yield* Deferred.await(refreshed);
}).pipe(Effect.provide(makeLayerTest(layerTestEventSink, instances)));
}).pipe(
Effect.provide(makeLayerTest(layerTestEventSink, instances)),
Effect.provideService(UsageLimitResetFallback, {
resetAt: (driver, endpoint, model) =>
Effect.sync(() => {
hubLookups.push(`${driver}:${endpoint}:${model}`);
return hubResetAt;
}),
}),
);
}),
);

Expand Down
35 changes: 34 additions & 1 deletion apps/server/src/orchestration-v2/ProviderEventIngestor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from "../telemetry/ProviderUsageAnalytics.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
import { UsageLimitResetFallback } from "../usage/UsageLimitSources.ts";
import * as EventSink from "./EventSink.ts";
import * as ProjectionStore from "./ProjectionStore.ts";
import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
Expand Down Expand Up @@ -254,6 +255,7 @@ export const layer: Layer.Layer<
const analytics = yield* ProviderTurnAnalytics;
const agentScope = yield* AgentScope;
const instances = yield* ProviderInstanceRegistry.ProviderInstanceRegistry;
const usageLimitResets = yield* UsageLimitResetFallback;
const scope = yield* Effect.scope;
const completedTurnAnalytics = new Set<string>();

Expand Down Expand Up @@ -656,9 +658,40 @@ export const layer: Layer.Layer<
return dismissed;
}
const occurredAt = yield* DateTime.now;
const failure = (yield* agentScope.oomKilled(input.threadId))
const reported = (yield* agentScope.oomKilled(input.threadId))
? { ...input.event.failure, message: "Killed: out of memory.", code: "oom_kill" }
: input.event.failure;
// A proxy's CLI can drop the reset time; its hub still knows when the pool refills.
const provider =
reported.class === "usage_limit" && reported.resetAt == null
? yield* Effect.flatMap(
instances.getInstance(input.providerInstanceId),
(instance) => instance?.snapshot.getSnapshot ?? Effect.undefined,
)
: undefined;
// Only an instance sent to a hub's address is served by that hub.
const endpoint = provider?.auth.endpoint;
// The failing run's model, which may differ from a selection changed mid-turn.
const model =
endpoint !== undefined
? yield* Effect.gen(function* () {
const { runs } =
input.runId === undefined
? { runs: [] }
: yield* projections.getThreadRecords(input.threadId, ["runs"], {
runIds: [input.runId],
});
const selection =
runs[0]?.modelSelection ??
(yield* projections.getThread(input.threadId)).modelSelection;
return selection.model;
}).pipe(Effect.orElseSucceed(() => null))
: null;
const hubResetAt =
endpoint !== undefined
? yield* usageLimitResets.resetAt(input.event.driver, endpoint, model)
: null;
const failure = hubResetAt === null ? reported : { ...reported, resetAt: hubResetAt };
return [
...dismissed,
yield* makeDomainEvent(input, {
Expand Down
5 changes: 4 additions & 1 deletion apps/server/src/provider/ClaudeProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
(yield* readClaudeSettingsEnv(claudeSettings, cwd, resolvedEnvironment)).ANTHROPIC_BASE_URL ??
resolvedEnvironment.ANTHROPIC_BASE_URL;
const customApiEndpoint = customClaudeApiEndpointHost(baseUrl) !== undefined;
const endpoint = customApiEndpoint ? URL.parse(baseUrl?.trim() ?? "")?.origin : undefined;
if (!customApiEndpoint && claudeAuthStatus(capabilities) === "unauthenticated") {
const path = yield* Path.Path;
const configDir = (yield* makeClaudeEnvironment(claudeSettings, resolvedEnvironment))
Expand Down Expand Up @@ -804,7 +805,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
// A proxy's ANTHROPIC_AUTH_TOKEN reads as an API key; the endpoint is what the token is for.
const authMetadata =
apiProviderAuthMetadata(capabilities.apiProvider) ??
(customApiEndpoint ? { type: "customEndpoint", label: "Custom API endpoint" } : undefined) ??
(customApiEndpoint
? { type: "customEndpoint", label: "Custom API endpoint", ...(endpoint ? { endpoint } : {}) }
: undefined) ??
claudeAuthMetadata({
subscriptionType: capabilities.subscriptionType,
authMethod: capabilities.tokenSource,
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/provider/CodexProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest";

import {
applyPreferredCodexDefaultModel,
codexCustomEndpoint,
codexRoutesSpendNativeQuota,
mapCodexModelCapabilities,
} from "./CodexProvider.ts";
Expand Down Expand Up @@ -192,4 +193,12 @@ it("reports native quota only for an OpenAI login on a native route", () => {
name,
);
}
assert.strictEqual(codexCustomEndpoint(hub), "https://hub.example.com");
assert.strictEqual(
codexCustomEndpoint({ openai_base_url: "http://127.0.0.1:8317/v1" }),
"http://127.0.0.1:8317",
);
for (const native of [{}, { openai_base_url: "https://chatgpt.com/x" }, null]) {
assert.strictEqual(codexCustomEndpoint(native), undefined);
}
});
Loading
Loading