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
17 changes: 16 additions & 1 deletion apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4438,7 +4438,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
)
.map((message) => message.id),
);
if (queuedRuns.some((run) => !automaticMessageIds.has(run.userMessageId))) {
const userRuns = queuedRuns.filter((run) => !automaticMessageIds.has(run.userMessageId));
// User work waiting behind a usage limit cannot start until the reset or the user acts.
const latest = latestExecutedRun(projection.runs);
const limitRecords =
userRuns.length === 0 || latest?.status !== "failed"
? null
: yield* loadProjectionForCommand(command, ["turnItems", "providerSessions"], {
turnItemTypes: ["error"],
turnItemRunIds: [latest.id],
});
const heldBehindLimit =
limitRecords !== null &&
userRuns.every((run) =>
usageLimitHoldsQueue({ ...limitRecords, runs: projection.runs }, run.providerInstanceId),
);
if (userRuns.length > 0 && !heldBehindLimit) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
commandType: command.type,
Expand Down
32 changes: 28 additions & 4 deletions apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ import * as ProviderEventIngestor from "./ProviderEventIngestor.ts";
import * as ProviderUsageAnalytics from "../telemetry/ProviderUsageAnalytics.ts";
import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
import * as ProviderInstanceRegistry from "../provider/ProviderInstanceRegistry.ts";
import { UsageLimitResetFallback } from "../usage/UsageLimitSources.ts";
import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
import type { ProviderInstance } from "@t3tools/provider-core/server/driver";
import type { ServerProvider } from "@t3tools/contracts";
import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
import {
makeProviderEventRoutingState,
Expand Down Expand Up @@ -230,7 +232,7 @@ function threadCreatedEvent(

const layer = it.layer(layerTest);

it.effect("refreshes the failing instance snapshot after a committed usage_limit terminal", () =>
it.effect("takes a hub's reset for an unreset usage_limit, then refreshes the snapshot", () =>
Effect.gen(function* () {
const refreshed = yield* Deferred.make<void>();
const adapter = yield* ProviderAdapter.ProviderAdapterV2.pipe(
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.succeed({
auth: { status: "authenticated", endpoint: "http://hub.test:8317" },
} as ServerProvider),
refresh: Effect.die("unused"),
resolveMaintenance: () => Effect.die("unused"),
applyUsageLimits: () => Effect.die("unused"),
streamChanges: Stream.empty,
},
};
const lookups: ProviderInstanceId[] = [];
const hubLookups: Array<[string, string, string | null]> = [];
const instances = Layer.mock(ProviderInstanceRegistry.ProviderInstanceRegistry)({
getInstance: (id) =>
Effect.sync(() => {
Expand Down Expand Up @@ -317,10 +322,29 @@ it.effect("refreshes the failing instance snapshot after a committed usage_limit
});
assert.equal(stored.length, 1);
if (failureClass === "validation_error") assert.deepEqual(lookups, []);
else {
const event = stored[0]?.event;
assert.equal(
event?.type === "turn-item.updated" && event.payload.type === "error"
? event.payload.failure.resetAt
: undefined,
"2026-11-13T18:00:00.000Z",
);
}
}
assert.deepEqual(lookups, [instance.instanceId]);
assert.deepEqual(lookups, [instance.instanceId, instance.instanceId]);
assert.deepEqual(hubLookups, [["cursor", "http://hub.test:8317", "gpt-5.4"]]);
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 "2026-11-13T18:00:00.000Z";
}),
}),
);
}),
);

Expand Down
34 changes: 33 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,39 @@ 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 proxied CLI can drop the reset time; the hub still knows when its pool refills.
const endpoint =
reported.class === "usage_limit" && reported.resetAt == null
? (yield* Effect.flatMap(
instances.getInstance(input.providerInstanceId),
(instance) => instance?.snapshot.getSnapshot ?? Effect.undefined,
))?.auth.endpoint
: undefined;
const hubResetAt =
endpoint === undefined
? null
: yield* Effect.gen(function* () {
// The failing run's model, which may differ from a selection changed mid-turn.
const { runs } =
input.runId === undefined
? { runs: [] }
: yield* projections.getThreadRecords(input.threadId, ["runs"], {
runIds: [input.runId],
});
return (
runs[0]?.modelSelection ??
(yield* projections.getThread(input.threadId)).modelSelection
).model;
}).pipe(
Effect.orElseSucceed(() => null),
Effect.flatMap((model) =>
usageLimitResets.resetAt(input.event.driver, endpoint, model),
),
);
const failure = hubResetAt === null ? reported : { ...reported, resetAt: hubResetAt };
return [
...dismissed,
yield* makeDomainEvent(input, {
Expand Down
62 changes: 55 additions & 7 deletions apps/server/src/orchestration-v2/runtimeLayer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9276,8 +9276,8 @@ it.layer(layerSharedApplicationDataPlaneTest)("pending provider interruption", (
});

it.layer(layerTest)("snooze queued runs", (it) => {
it.effect.each(["notification", "delegated-completion", "user"] as const)(
"only user messages block snooze with a held %s run",
it.effect.each(["notification", "delegated-completion", "user", "limited-user"] as const)(
"only user messages block snooze with a queued %s run, unless a usage limit holds them",
(kind) =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
Expand Down Expand Up @@ -9346,8 +9346,8 @@ it.layer(layerTest)("snooze queued runs", (it) => {
attachments: [],
modelSelection,
dispatchMode: { type: "queue_after_active" },
createdBy: kind === "user" ? "user" : "agent",
creationSource: kind === "user" ? "web" : "server",
createdBy: kind === "user" || kind === "limited-user" ? "user" : "agent",
creationSource: kind === "user" || kind === "limited-user" ? "web" : "server",
...(kind === "notification"
? {
notification: {
Expand All @@ -9366,6 +9366,43 @@ it.layer(layerTest)("snooze queued runs", (it) => {
}
: {}),
});
if (kind === "limited-user") {
yield* eventSink.write({
events: [
{
id: EventId.make(`limited:${threadId}`),
type: "turn-item.updated",
threadId,
occurredAt: now,
payload: {
id: TurnItemId.make(`limited:${threadId}`),
type: "error",
threadId,
runId: activeRun.id,
nodeId: activeRun.rootNodeId,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
parentItemId: null,
ordinal: 2,
status: "failed",
title: "Usage limit reached",
startedAt: now,
completedAt: now,
updatedAt: now,
// A proxied provider can report no reset time.
failure: {
class: "usage_limit",
message: "Claude API rate limit reached. Try again later.",
code: "api_error_429",
retryable: true,
resetAt: null,
},
},
},
],
});
}
const beforeRecovery = yield* orchestrator.getThreadProjection(threadId);
yield* eventSink.commitCommand({
commandId: CommandId.make(`recovery:${threadId}`),
Expand All @@ -9380,13 +9417,24 @@ it.layer(layerTest)("snooze queued runs", (it) => {
occurredAt: now,
payload:
run.status === "queued"
? { ...run, queueHeld: true }
: { ...run, status: "cancelled" as const, completedAt: now },
? // A usage limit stops the queue from starting without marking it held.
kind === "limited-user"
? run
: { ...run, queueHeld: true }
: {
...run,
status: kind === "limited-user" ? ("failed" as const) : ("cancelled" as const),
completedAt: now,
},
})),
effects: [],
});
const beforeSnooze = yield* orchestrator.getThreadProjection(threadId);
assert.isTrue(beforeSnooze.runs.some((run) => run.status === "queued" && run.queueHeld));
assert.isTrue(
beforeSnooze.runs.some(
(run) => run.status === "queued" && (kind === "limited-user" || run.queueHeld),
),
);
const snooze = orchestrator.dispatch({
type: "thread.snooze",
commandId: CommandId.make(`snooze:${threadId}`),
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,
);
}
// The custom endpoint is what a usage-limit hub is matched against.
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",
);
assert.strictEqual(codexCustomEndpoint({ openai_base_url: "https://chatgpt.com/x" }), undefined);
assert.strictEqual(codexCustomEndpoint({}), undefined);
});
33 changes: 25 additions & 8 deletions apps/server/src/provider/CodexProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,17 +94,21 @@ const CodexCyberAccessConfig = Schema.Struct({
});
const decodeCodexCyberAccessConfig = Schema.decodeUnknownOption(CodexCyberAccessConfig);

function routesToNativeOpenai(config: unknown): boolean {
// Null is Codex's auth-dependent OpenAI default; undefined is a route that cannot be read.
function codexRouteBaseUrl(config: unknown): string | null | undefined {
const decoded = decodeCodexRoutingConfig(config);
if (Option.isNone(decoded)) return false;
if (Option.isNone(decoded)) return undefined;
const routing = decoded.value;
const providerId = routing.model_provider ?? "openai";
if (providerId === "openai") return routing.openai_base_url || null;
const provider = routing.model_providers?.[providerId];
if (providerId !== "openai" && !provider) return false;
const baseUrl =
providerId === "openai" ? routing.openai_base_url || undefined : provider?.base_url;
// A provider without an override uses Codex's auth-dependent OpenAI default.
if (baseUrl == null) return true;
return provider ? (provider.base_url ?? null) : undefined;
}

function routesToNativeOpenai(config: unknown): boolean {
const baseUrl = codexRouteBaseUrl(config);
if (baseUrl === undefined) return false;
if (baseUrl === null) return true;
if (!URL.canParse(baseUrl)) return false;
const url = new URL(baseUrl);
return (
Expand All @@ -113,6 +117,15 @@ function routesToNativeOpenai(config: unknown): boolean {
);
}

/** The origin of the custom endpoint Codex sends model requests to, if it uses one. */
export function codexCustomEndpoint(config: unknown): string | undefined {
const baseUrl = codexRouteBaseUrl(config);
const url = baseUrl ? URL.parse(baseUrl) : null;
return url && /^https?:$/.test(url.protocol) && !routesToNativeOpenai(config)
? url.origin
: undefined;
}

// A stored login does not mean this provider's traffic spends that login's quota.
export const codexRoutesSpendNativeQuota = (
account: CodexSchema.V2GetAccountResponse,
Expand Down Expand Up @@ -140,6 +153,7 @@ const CODEX_PRESENTATION = {

export interface CodexAppServerProviderSnapshot {
readonly account: CodexSchema.V2GetAccountResponse;
readonly endpoint?: string | undefined;
readonly nativeUsage?: boolean;
readonly rateLimits?: CodexRateLimitsProbe;
readonly version: string | undefined;
Expand Down Expand Up @@ -757,6 +771,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun
const cyberAccess = Option.getOrUndefined(decodeCodexCyberAccessConfig(config));
return {
account: accountResponse,
endpoint: config ? codexCustomEndpoint(config) : undefined,
nativeUsage,
...(rateLimits ? { rateLimits } : {}),
version,
Expand Down Expand Up @@ -1075,7 +1090,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu
installed: true,
version: snapshot.version ?? null,
status: snapshot.models === undefined ? "warning" : accountStatus.status,
auth: accountStatus.auth,
auth: snapshot.endpoint
? { ...accountStatus.auth, endpoint: snapshot.endpoint }
: accountStatus.auth,
...(accountStatus.message
? { message: accountStatus.message }
: snapshot.models === undefined
Expand Down
14 changes: 13 additions & 1 deletion apps/server/src/provider/claudeUsageLimits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,20 @@ export const makeClaudeScopedLimitNames = Ref.make<ClaudeScopedLimitNames>({
overageIncluded: undefined,
});

const SCOPED_WINDOW_PREFIX = "seven_day_";
const scopeToken = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "_");

function scopedWindowId(displayName: string): string {
return `seven_day_${displayName.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`;
return `${SCOPED_WINDOW_PREFIX}${scopeToken(displayName)}`;
}

/** A model-scoped weekly allowance blocks only its own model; an unknown model is not blocked. */
export function windowLimitsModel(
window: Pick<ServerProviderUsageWindow, "id">,
model: string | null,
): boolean {
if (!window.id.startsWith(SCOPED_WINDOW_PREFIX)) return true;
return model !== null && scopeToken(model).includes(window.id.slice(SCOPED_WINDOW_PREFIX.length));
}

function scopedWindow(
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,7 @@ const layerScheduledTaskWebhookOrigin = Layer.effect(
);

const layerOrchestrationV2Runtime = RuntimeLayer.layerProduction.pipe(
Layer.provide(UsageLimitSources.layerResetFallback),
Layer.provide(layerScheduledTaskWebhookOrigin),
Layer.provide(ProviderUsageAnalytics.layer),
Layer.provide(layerCheckpointStore),
Expand Down
Loading
Loading