@bitkyc08/opencodex 2.75.0 → 2.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/bin/ocx.mjs +12 -1
- package/gui/dist/assets/App-CddorHdA.js +51 -0
- package/gui/dist/assets/App-DMudtISW.css +1 -0
- package/gui/dist/assets/{Tray-CGAD2AGk.js → Tray-DjNwwrWS.js} +1 -1
- package/gui/dist/assets/index-Btze1I3T.css +1 -0
- package/gui/dist/assets/index-CvnY7E6i.js +86 -0
- package/gui/dist/assets/quota-summary-B_AWpHfP.js +1 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/opengateway.svg +12 -0
- package/package.json +1 -1
- package/src/adapters/anthropic/account-metadata.ts +17 -0
- package/src/adapters/anthropic/client-identity.ts +69 -0
- package/src/adapters/anthropic/passthrough.ts +23 -4
- package/src/adapters/anthropic.ts +18 -1
- package/src/adapters/command-code-project-context.ts +7 -11
- package/src/adapters/devin/cloud-direct/tool-schema.ts +12 -48
- package/src/adapters/kiro-retry.ts +23 -8
- package/src/adapters/openai-chat/messages.ts +1 -1
- package/src/adapters/registry.ts +8 -1
- package/src/adapters/zed.ts +377 -0
- package/src/bridge/sse.ts +4 -2
- package/src/chatgpt/app-server-shim/app-server-rewrite.ts +45 -0
- package/src/chatgpt/app-server-shim/bundle-trust.ts +77 -0
- package/src/chatgpt/app-server-shim/filter.ts +157 -0
- package/src/chatgpt/app-server-shim/gate-rewrite.ts +99 -0
- package/src/chatgpt/app-server-shim/launcher.ts +91 -0
- package/src/claude/desktop-3p-startup.ts +62 -0
- package/src/claude/desktop-3p.ts +5 -0
- package/src/claude/first-party-settings.ts +14 -0
- package/src/claude/intercept/cli-catalog.ts +150 -0
- package/src/claude/intercept/cli-picker.ts +82 -0
- package/src/claude/intercept/listener.ts +21 -6
- package/src/claude/intercept/picker-bootstrap.ts +23 -5
- package/src/claude/intercept/picker-models.ts +57 -13
- package/src/claude/intercept/runtime.ts +30 -2
- package/src/claude/intercept/settings.ts +3 -0
- package/src/cli/agent.ts +172 -0
- package/src/cli/alias.ts +2 -1
- package/src/cli/capabilities-command.ts +3 -2
- package/src/cli/capabilities.ts +87 -0
- package/src/cli/chatgpt-command.ts +140 -0
- package/src/cli/combo.ts +144 -19
- package/src/cli/config-command.ts +3 -1
- package/src/cli/dispatch.ts +10 -1
- package/src/cli/help.ts +1 -0
- package/src/cli/index.ts +5 -24
- package/src/cli/init.ts +16 -2
- package/src/cli/integrations.ts +13 -0
- package/src/cli/internal-command.ts +12 -5
- package/src/cli/observe.ts +6 -1
- package/src/cli/registry.ts +6 -0
- package/src/cli/route-policy.ts +3 -1
- package/src/cli/runtime-api.ts +5 -2
- package/src/cli/status-oauth.ts +5 -0
- package/src/cli/status.ts +8 -1
- package/src/cli/stop-approval.ts +55 -16
- package/src/cli/stop-report.ts +5 -0
- package/src/clients/lazycodex.ts +48 -0
- package/src/clients/omo-role-models.ts +111 -0
- package/src/codex/account-credit-use.ts +99 -0
- package/src/codex/account-lifecycle.ts +2 -0
- package/src/codex/agent-role-models.ts +171 -0
- package/src/codex/auth-api/account-list.ts +10 -3
- package/src/codex/auth-api/routes.ts +34 -0
- package/src/codex/auth-context.ts +52 -11
- package/src/codex/catalog/discovered-natives.ts +11 -1
- package/src/codex/catalog/gather-capture.ts +6 -1
- package/src/codex/catalog/model-hints.ts +9 -1
- package/src/codex/catalog/provider-models.ts +60 -0
- package/src/codex/catalog/routed-gather.ts +30 -5
- package/src/codex/convergence-types.ts +2 -1
- package/src/codex/main-account-external-usage.ts +62 -0
- package/src/codex/main-account-hard-lock.ts +24 -13
- package/src/codex/native-profile-processes.ts +53 -16
- package/src/codex/prompt-layers/toml-edit.ts +1 -1
- package/src/codex/quota-types.ts +3 -7
- package/src/codex/quota.ts +19 -4
- package/src/codex/role-auto-assign.ts +159 -0
- package/src/codex/role-sizing-limits.ts +2 -0
- package/src/codex/role-sizing.ts +216 -0
- package/src/codex/routing/selection.ts +36 -0
- package/src/codex/routing/thread-affinity.ts +2 -0
- package/src/codex/routing.ts +4 -4
- package/src/codex/subagent-model-fallback.ts +82 -26
- package/src/codex/subagent-selectable-models.ts +32 -0
- package/src/combos/index.ts +18 -0
- package/src/combos/jev-decision-contract.ts +20 -0
- package/src/combos/jev-dispatch.ts +18 -0
- package/src/combos/jev-model-backend.ts +127 -0
- package/src/combos/jev.ts +249 -33
- package/src/combos/types.ts +125 -2
- package/src/config/diagnostics.ts +18 -0
- package/src/config/macos-system-proxy.ts +72 -15
- package/src/config/owner-registry.ts +13 -4
- package/src/config/process-state.ts +17 -8
- package/src/config/proxy-env.ts +35 -5
- package/src/config/schema/config-schema.ts +30 -1
- package/src/config/schema/leaf-validators.ts +4 -0
- package/src/config/serving-runtimes.ts +95 -1
- package/src/generated/compatibility-version.json +336 -156
- package/src/lib/errors.ts +7 -7
- package/src/lib/jsonc.ts +13 -0
- package/src/lib/local-chat-completion.ts +144 -0
- package/src/lib/provider-outbound.ts +87 -1
- package/src/lib/provider-runtime-fetch.ts +23 -0
- package/src/lib/provider-tls-profile.ts +221 -0
- package/src/lib/request-execution-budget.ts +16 -6
- package/src/lib/spend-reservation-ledger.ts +54 -20
- package/src/lib/sse-decoder.ts +20 -17
- package/src/lib/synced-state-location.ts +96 -0
- package/src/lib/winsw.ts +14 -5
- package/src/oauth/anthropic-account-refusal.ts +53 -4
- package/src/oauth/anthropic-model-quota.ts +83 -0
- package/src/oauth/anthropic-rate-limit-policy.ts +37 -0
- package/src/oauth/anthropic-routing.ts +112 -58
- package/src/oauth/health.ts +39 -3
- package/src/oauth/index.ts +14 -0
- package/src/oauth/kiro-account-load.ts +10 -0
- package/src/oauth/zed.ts +134 -0
- package/src/providers/model-discovery.ts +19 -8
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/provider-id-rewrite.ts +9 -2
- package/src/providers/quota/account-cache.ts +29 -7
- package/src/providers/quota/anthropic-cooldown-recovery.ts +18 -0
- package/src/providers/quota/anthropic-family-headers.ts +25 -0
- package/src/providers/quota/vendor-probes-oauth.ts +5 -1
- package/src/providers/quota-types.ts +4 -0
- package/src/providers/registry/entries-core.ts +18 -0
- package/src/providers/registry/entries-extended.ts +55 -0
- package/src/providers/registry/types.ts +3 -0
- package/src/providers/xai-fast-model.ts +12 -4
- package/src/providers/zed.ts +598 -0
- package/src/responses/state/snapshot-select.ts +22 -9
- package/src/responses/state.ts +2 -3
- package/src/router.ts +9 -0
- package/src/server/anthropic-rate-limit-headers.ts +7 -0
- package/src/server/auth-cors.ts +7 -0
- package/src/server/chat-completions.ts +9 -4
- package/src/server/claude-messages.ts +59 -21
- package/src/server/index/claude-intercept-lifecycle.ts +71 -25
- package/src/server/index/optional-listeners.ts +4 -0
- package/src/server/index/spend-ledger-lifecycle.ts +4 -0
- package/src/server/management/agent-settings-routes.ts +74 -37
- package/src/server/management/claude-desktop-picker-routes.ts +7 -0
- package/src/server/management/claude-intercept-routes.ts +35 -0
- package/src/server/management/codex-agent-role-routes.ts +122 -0
- package/src/server/management/codex-role-auto-assign.ts +208 -0
- package/src/server/management/combo-routes.ts +62 -5
- package/src/server/management/config-routes.ts +35 -3
- package/src/server/management/context.ts +4 -1
- package/src/server/management/decision-discovery.ts +74 -0
- package/src/server/management/decision-model-validation.ts +80 -0
- package/src/server/management/decision-routes.ts +160 -0
- package/src/server/management/native-integration-routes.ts +16 -0
- package/src/server/management/provider-routes.ts +30 -34
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sibling-guard.ts +1 -0
- package/src/server/management/zed-provider-probe.ts +38 -0
- package/src/server/management-api.ts +12 -1
- package/src/server/messages-native-oauth.ts +7 -2
- package/src/server/messages-native.ts +7 -3
- package/src/server/relay.ts +3 -1
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/adapter-continuation.ts +3 -3
- package/src/server/responses/adapter-dispatch.ts +18 -3
- package/src/server/responses/compact.ts +14 -1
- package/src/server/responses/core-auth.ts +2 -0
- package/src/server/responses/core-combo.ts +67 -11
- package/src/server/responses/core-normalize.ts +21 -1
- package/src/server/responses/core-options.ts +11 -1
- package/src/server/responses/core.ts +5 -3
- package/src/server/responses/fetch-helpers.ts +6 -3
- package/src/server/responses/jev-model-invoke.ts +190 -0
- package/src/server/responses/passthrough-delivery.ts +16 -0
- package/src/server/responses/policy-fallback.ts +55 -3
- package/src/server/responses/policy-request-scope.ts +80 -0
- package/src/server/responses/request-prepare.ts +70 -18
- package/src/server/responses/request-transport.ts +54 -23
- package/src/server/responses/run-turn-execution.ts +1 -0
- package/src/server/responses/sidecar-execution.ts +9 -3
- package/src/server/responses-hosted-image-display.ts +225 -0
- package/src/service/managing-cli.ts +34 -4
- package/src/service/systemd-env.ts +75 -0
- package/src/service/windows-guarded-manager.ts +60 -19
- package/src/service/windows-scheduler.ts +22 -3
- package/src/service/windows-taskxml.ts +32 -10
- package/src/service-manager-probe.ts +72 -19
- package/src/types/config.ts +31 -1
- package/src/types/provider.ts +2 -0
- package/src/types/request.ts +2 -0
- package/src/update/index.ts +39 -6
- package/src/update/job.ts +63 -63
- package/src/update/npm-cache-preflight.d.mts +33 -1
- package/src/update/npm-cache-preflight.mjs +131 -18
- package/src/update/ownership-transaction.ts +19 -1
- package/src/update/restart-ownership.ts +80 -6
- package/src/update/transactional-install.d.mts +2 -0
- package/src/update/transactional-install.mjs +6 -1
- package/src/usage/jev-stats.ts +28 -1
- package/src/usage/log.ts +1 -1
- package/src/vision/anthropic-describe.ts +5 -3
- package/src/vision/index.ts +1 -0
- package/src/vision/plan.ts +2 -1
- package/src/vision/routed-describe.ts +29 -121
- package/src/web-search/alpha-search.ts +3 -3
- package/src/web-search/anthropic-executor.ts +6 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +3 -3
- package/src/web-search/passthrough-bridge.ts +2 -1
- package/src/web-search/run-turn-loop.ts +1 -1
- package/src/web-search/sidecar-providers.ts +2 -1
- package/gui/dist/assets/App-ia4U0ufY.css +0 -1
- package/gui/dist/assets/App-xm0zGNOe.js +0 -51
- package/gui/dist/assets/index-DBT3Q31Y.css +0 -1
- package/gui/dist/assets/index-D_RGAT6Q.js +0 -86
- package/gui/dist/assets/quota-summary-CUFq48t-.js +0 -1
|
@@ -81,14 +81,31 @@ export interface ListCodexClientProcessesOptions {
|
|
|
81
81
|
*/
|
|
82
82
|
export function isCodexClientProcess(command: string, args: string): boolean {
|
|
83
83
|
const comm = basename(command).toLowerCase();
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
if (DIRECT_CODEX_BASENAMES.has(comm)) return true;
|
|
85
|
+
// A full executable path is stronger evidence than ps's unquoted args.
|
|
86
|
+
// Splitting ".../Codex Framework.framework/.../browser_crashpad_handler"
|
|
87
|
+
// at its first space would otherwise manufacture an argv0 named Codex.
|
|
88
|
+
if (command.includes("/") && !CODEX_INTERPRETER_BASENAMES.has(comm)) return false;
|
|
89
|
+
const trimmedArgs = args.trim();
|
|
90
|
+
const hasExecutablePrefix = command && trimmedArgs.startsWith(command)
|
|
91
|
+
&& /^(?:\s|$)/.test(trimmedArgs.slice(command.length));
|
|
92
|
+
const remainingArgs = hasExecutablePrefix ? trimmedArgs.slice(command.length).trim() : trimmedArgs;
|
|
93
|
+
const rawTokens = remainingArgs.split(/\s+/, 2);
|
|
94
|
+
// Even with only a comm basename, this initial token pair is the split
|
|
95
|
+
// Codex.app framework path, not a CLI executable plus an argument. Only
|
|
96
|
+
// inspect the executable or immediate script, never a later argument.
|
|
97
|
+
if (rawTokens[0]?.endsWith("/Codex.app/Contents/Frameworks/Codex")
|
|
98
|
+
&& rawTokens[1]?.startsWith("Framework.framework/")) return false;
|
|
99
|
+
const quotedTokens = tokenizeCommandLine(remainingArgs);
|
|
100
|
+
// ps/procfs also leave literal apostrophes unescaped. Preserve a matching
|
|
101
|
+
// immediate raw entrypoint before interpreting shell-like quotes.
|
|
102
|
+
return [rawTokens, quotedTokens].some(tokens => {
|
|
103
|
+
const [rawArgv0 = "", rawEntrypoint = ""] = hasExecutablePrefix ? [command, ...tokens] : tokens;
|
|
104
|
+
const argv0 = basename(rawArgv0).toLowerCase();
|
|
105
|
+
const entrypoint = basename(rawEntrypoint).toLowerCase();
|
|
106
|
+
return DIRECT_CODEX_BASENAMES.has(argv0)
|
|
107
|
+
|| (CODEX_INTERPRETER_BASENAMES.has(argv0) && CODEX_ENTRYPOINT_BASENAMES.has(entrypoint));
|
|
108
|
+
});
|
|
92
109
|
}
|
|
93
110
|
|
|
94
111
|
function parseUnixPsLine(line: string): { pid: number; command: string; args: string } | null {
|
|
@@ -147,9 +164,7 @@ export function listCodexClientProcesses({
|
|
|
147
164
|
}
|
|
148
165
|
|
|
149
166
|
function snapshotIsCodexClient(snapshot: ProcessSnapshot): boolean {
|
|
150
|
-
|
|
151
|
-
const argv0 = tokenizeCommandLine(snapshot.commandLine)[0] ?? "";
|
|
152
|
-
return argv0 !== "" && isCodexClientProcess(argv0, snapshot.commandLine);
|
|
167
|
+
return isCodexClientProcess(snapshot.executable ?? "", snapshot.commandLine);
|
|
153
168
|
}
|
|
154
169
|
|
|
155
170
|
/** Async, shell-free child execution with runtime-enforced timeout and output bounds. */
|
|
@@ -192,15 +207,37 @@ async function windowsProcessCount(run: NativeProcessExecutor): Promise<number>
|
|
|
192
207
|
return count;
|
|
193
208
|
}
|
|
194
209
|
|
|
195
|
-
async function unixProcessCount(run: NativeProcessExecutor, pid: number): Promise<number> {
|
|
196
|
-
const
|
|
210
|
+
async function unixProcessCount(run: NativeProcessExecutor, pid: number, platform: NodeJS.Platform): Promise<number> {
|
|
211
|
+
const options: NativeProcessExecOptions = {
|
|
197
212
|
encoding: "utf8",
|
|
198
213
|
timeout: 5_000,
|
|
199
214
|
maxBuffer: PROCESS_LIST_MAX_BUFFER,
|
|
200
215
|
shell: false,
|
|
201
216
|
killSignal: "SIGKILL",
|
|
202
|
-
}
|
|
203
|
-
|
|
217
|
+
};
|
|
218
|
+
if (platform !== "darwin") {
|
|
219
|
+
const output = await run("ps", ["-eo", "pid=,comm=,args="], options);
|
|
220
|
+
return unixCodexClientsFromPs(output, pid).length;
|
|
221
|
+
}
|
|
222
|
+
// macOS comm is a full path and may contain spaces. Read the two fields
|
|
223
|
+
// separately, as the routing-adoption snapshot collector does, then join by PID.
|
|
224
|
+
const commands = new Map<number, string>();
|
|
225
|
+
const commOutput = await run("ps", ["-eo", "pid=,comm="], options);
|
|
226
|
+
for (const line of commOutput.split("\n")) {
|
|
227
|
+
const match = /^\s*(\d+)\s+(.+)$/.exec(line);
|
|
228
|
+
if (match) commands.set(Number(match[1]), match[2]!.trim());
|
|
229
|
+
}
|
|
230
|
+
const argsOutput = await run("ps", ["-eo", "pid=,args="], options);
|
|
231
|
+
let count = 0;
|
|
232
|
+
for (const line of argsOutput.split("\n")) {
|
|
233
|
+
const match = /^\s*(\d+)\s+(.+)$/.exec(line);
|
|
234
|
+
if (!match) continue;
|
|
235
|
+
const candidatePid = Number(match[1]);
|
|
236
|
+
const command = commands.get(candidatePid);
|
|
237
|
+
if (Number.isSafeInteger(candidatePid) && candidatePid !== pid && command
|
|
238
|
+
&& isCodexClientProcess(command, match[2]!)) count++;
|
|
239
|
+
}
|
|
240
|
+
return count;
|
|
204
241
|
}
|
|
205
242
|
|
|
206
243
|
/** Best-effort, read-only process probe. It never terminates a user process. */
|
|
@@ -212,7 +249,7 @@ export async function probeNativeCodexProcesses({
|
|
|
212
249
|
try {
|
|
213
250
|
const count = await (platform === "win32"
|
|
214
251
|
? windowsProcessCount(run)
|
|
215
|
-
: unixProcessCount(run, pid));
|
|
252
|
+
: unixProcessCount(run, pid, platform));
|
|
216
253
|
return count > 0 ? { status: "busy", count } : { status: "clear", count: 0 };
|
|
217
254
|
} catch {
|
|
218
255
|
return { status: "unknown", count: 0 };
|
|
@@ -3,7 +3,7 @@ import { encodeBasicString } from "./encoding";
|
|
|
3
3
|
import { TABLE_HEADER, ANY_DEV_INSTRUCTIONS, DEV_INSTRUCTIONS_KEY } from "./toml-read";
|
|
4
4
|
|
|
5
5
|
/** Line editing, not re-serialization: the user's comments and layout survive. */
|
|
6
|
-
function dominantEol(content: string): "\r\n" | "\n" {
|
|
6
|
+
export function dominantEol(content: string): "\r\n" | "\n" {
|
|
7
7
|
const crlf = (content.match(/\r\n/g) ?? []).length;
|
|
8
8
|
if (crlf === 0) return "\n";
|
|
9
9
|
const bareLf = (content.match(/\n/g) ?? []).length - crlf;
|
package/src/codex/quota-types.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
1
|
/** Quota wire/storage shapes. This leaf must not import credential or config owners. */
|
|
2
|
-
/**
|
|
3
|
-
* Observed usage at which new identity-matched main-account requests are refused (#5694).
|
|
4
|
-
*
|
|
5
|
-
* The policy is on by default, so this constant is what every installation without an explicit
|
|
6
|
-
* `codexMainAccountHardLock: false` admits against. 98 leaves one point of headroom under the
|
|
7
|
-
* exhausted reading Codex Desktop already treats as its own disabled send button.
|
|
8
|
-
*/
|
|
2
|
+
/** Long-window default; explicit main-account hard-lock thresholds may override it. */
|
|
9
3
|
export const MAIN_ACCOUNT_HARD_LOCK_PERCENT = 98;
|
|
4
|
+
export const MAIN_ACCOUNT_HARD_LOCK_SHORT_PERCENT = 90;
|
|
5
|
+
export const MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT = 80;
|
|
10
6
|
|
|
11
7
|
/**
|
|
12
8
|
* How recently a 100% burst reading must have been observed to exclude an account when it
|
package/src/codex/quota.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { forgetMainAccountUsage, observeMainAccountUsage, type FreshWindow } from "./main-account-external-usage";
|
|
1
2
|
import { closeSync, constants as fsConstants, existsSync, fstatSync, openSync, readSync, unlinkSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { atomicWriteFile, getConfigDir } from "../config";
|
|
@@ -10,7 +11,7 @@ import { getObservedMainQuotaIdentityKey, isMainQuotaWriterLive, type MainQuotaW
|
|
|
10
11
|
|
|
11
12
|
import { CodexQuotaHistory, QUOTA_HISTORY_LIMITS, type QuotaHistoryWindow } from "./quota-history";
|
|
12
13
|
import { isPoolQuotaWriterLive, poolQuotaHistoryIdentity } from "./account-store";
|
|
13
|
-
import { CODEX_EXHAUSTED_USAGE_PERCENT,
|
|
14
|
+
import { CODEX_EXHAUSTED_USAGE_PERCENT, MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT, resetAtToMs } from "./quota-types";
|
|
14
15
|
import type { PoolQuotaWriter, StoredAccountQuota, WhamUsageResponse, WhamUsageWindow } from "./quota-types";
|
|
15
16
|
|
|
16
17
|
export type { StoredAccountQuota, WhamUsageResponse } from "./quota-types";
|
|
@@ -260,7 +261,7 @@ function assignCarriedShort(
|
|
|
260
261
|
const existingShortPercent = existing.shortPercent;
|
|
261
262
|
const preserveBlockingEvidence = policyEvidence
|
|
262
263
|
&& finitePercent(existingShortPercent)
|
|
263
|
-
&& existingShortPercent >=
|
|
264
|
+
&& existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT
|
|
264
265
|
&& existingShortPercent <= 100;
|
|
265
266
|
if (!preserveBlockingEvidence && shortResetHasElapsed(existing.shortResetAt, now)) return;
|
|
266
267
|
if (existing.shortPercent !== undefined) next.shortPercent = existing.shortPercent;
|
|
@@ -315,6 +316,19 @@ export function setAccountQuotaFromParsed(
|
|
|
315
316
|
const next = mergeAccountQuota(quota, legacyExisting, updatedAt);
|
|
316
317
|
accountQuota.set(accountId, next);
|
|
317
318
|
if (isMain) {
|
|
319
|
+
if (mainWriter && policyQuota && snapshotHasUsage(policyQuota)) {
|
|
320
|
+
const windows: FreshWindow[] = [];
|
|
321
|
+
const add = (kind: FreshWindow["kind"], percent: number | undefined, reset: number | undefined) => {
|
|
322
|
+
if (finitePercent(percent) && percent >= 0 && percent <= 100
|
|
323
|
+
&& typeof reset === "number" && Number.isFinite(reset) && reset > 0) {
|
|
324
|
+
windows.push({ kind, percent, resetAtMs: resetAtToMs(reset) });
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
add("short", policyQuota.shortPercent, policyQuota.shortResetAt);
|
|
328
|
+
if (policyQuota.monthlyIsPrimaryWindow === true) add("long", policyQuota.monthlyPercent, policyQuota.monthlyResetAt);
|
|
329
|
+
else add("long", policyQuota.weeklyPercent, policyQuota.weeklyResetAt);
|
|
330
|
+
observeMainAccountUsage(mainWriter.identityKey, windows, updatedAt);
|
|
331
|
+
}
|
|
318
332
|
const policyExisting = mainWriter && mainPolicyQuota?.identityKey === mainWriter.identityKey
|
|
319
333
|
? mainPolicyQuota.quota
|
|
320
334
|
: undefined;
|
|
@@ -371,7 +385,7 @@ function mergeAccountQuota(
|
|
|
371
385
|
&& quota.weeklyPercent === undefined
|
|
372
386
|
&& quota.monthlyIsPrimaryWindow !== true
|
|
373
387
|
&& finitePercent(existingWeeklyPercent)
|
|
374
|
-
&& existingWeeklyPercent >=
|
|
388
|
+
&& existingWeeklyPercent >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT
|
|
375
389
|
&& existingWeeklyPercent <= 100;
|
|
376
390
|
if (snapshotHasWeekly(quota) && !preserveKnownWeekly) {
|
|
377
391
|
if (quota.weeklyPercent !== undefined) next.weeklyPercent = quota.weeklyPercent;
|
|
@@ -404,7 +418,7 @@ function mergeAccountQuota(
|
|
|
404
418
|
const preserveKnownShort = policyEvidence
|
|
405
419
|
&& quota.shortPercent === undefined
|
|
406
420
|
&& finitePercent(existingShortPercent)
|
|
407
|
-
&& existingShortPercent >=
|
|
421
|
+
&& existingShortPercent >= MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT
|
|
408
422
|
&& existingShortPercent <= 100;
|
|
409
423
|
if (snapshotHasShort(quota) && !preserveKnownShort) {
|
|
410
424
|
if (quota.shortPercent !== undefined) {
|
|
@@ -778,6 +792,7 @@ function forgetCodexQuotaBaseline(accountId?: string): void {
|
|
|
778
792
|
}
|
|
779
793
|
|
|
780
794
|
export function clearAccountQuota(accountId?: string): void {
|
|
795
|
+
if (!accountId || accountId === MAIN_CODEX_ACCOUNT_ID) forgetMainAccountUsage();
|
|
781
796
|
if (accountId) hydrateAccountQuotasFromDisk();
|
|
782
797
|
quotaHistory.clear(accountId);
|
|
783
798
|
if (accountId) {
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns a role's sized tier and effort into a concrete model and reasoning level. Deterministic:
|
|
3
|
+
* the sizing model never names a model, and nothing here asks one.
|
|
4
|
+
*
|
|
5
|
+
* Tier of a candidate model, first rule that applies:
|
|
6
|
+
* 1. codexRoleTiers in the opencodex config lists it under fast, standard or frontier.
|
|
7
|
+
* 2. It has a known price. Priced candidates are ranked by input plus output USD per 1M tokens.
|
|
8
|
+
* Three or more split evenly across the three tiers by rank, cheapest in fast. Fewer than three
|
|
9
|
+
* cannot fill every tier, so the ranking is anchored at the top: the dearest is frontier,
|
|
10
|
+
* because it is the strongest model on offer, and each cheaper one sits one tier lower. One
|
|
11
|
+
* priced model is frontier; two are standard and frontier. The tier left empty is fast, so a
|
|
12
|
+
* fast role steps up to the cheapest model and a standard role keeps it instead of jumping to
|
|
13
|
+
* the dearest, which keeps every role on the cheapest sufficient model on offer.
|
|
14
|
+
* 3. Otherwise it is unclassified and never proposed.
|
|
15
|
+
*
|
|
16
|
+
* A role gets the cheapest sufficient model: the lowest tier at or above what it was sized for,
|
|
17
|
+
* then the lowest price, then the picker's own order.
|
|
18
|
+
*
|
|
19
|
+
* Effort binds to positions on the chosen model's ladder: glance is the floor, measured is the
|
|
20
|
+
* model's default or the middle rung, exhaustive is the ceiling, and thorough is the rung above
|
|
21
|
+
* measured when that rung is not the ceiling. When no such interior rung exists, thorough
|
|
22
|
+
* collapses to the nearer of measured and the ceiling; the two are equally near, and the one
|
|
23
|
+
* above wins because thorough asks for more than the everyday setting. Nothing resolves outside
|
|
24
|
+
* the ladder.
|
|
25
|
+
*/
|
|
26
|
+
import { SIZING_TIERS, type RoleSizingOutcome, type SizingEffort, type SizingTier } from "./role-sizing";
|
|
27
|
+
import { CODEX_REASONING_LEVELS } from "../reasoning-effort";
|
|
28
|
+
|
|
29
|
+
export type RoleTierMapping = Partial<Record<SizingTier, readonly string[]>>;
|
|
30
|
+
|
|
31
|
+
export interface RoleModelCandidate {
|
|
32
|
+
readonly model: string;
|
|
33
|
+
/** Input plus output USD per 1M tokens, or null when no price is known. */
|
|
34
|
+
readonly unitPrice: number | null;
|
|
35
|
+
readonly efforts: readonly string[];
|
|
36
|
+
readonly defaultEffort?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ClassifiedCandidate extends RoleModelCandidate {
|
|
40
|
+
readonly tier: SizingTier | null;
|
|
41
|
+
readonly tierSource: "mapping" | "price" | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const LADDER_ORDER = ["none", "minimal", ...CODEX_REASONING_LEVELS.map(level => level.effort)];
|
|
45
|
+
|
|
46
|
+
export function effortLadder(efforts: readonly string[]): string[] {
|
|
47
|
+
return [...new Set(efforts)]
|
|
48
|
+
.filter(effort => LADDER_ORDER.includes(effort))
|
|
49
|
+
.sort((a, b) => LADDER_ORDER.indexOf(a) - LADDER_ORDER.indexOf(b));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function mapEffortToLevel(intent: SizingEffort, efforts: readonly string[], defaultEffort?: string): string | null {
|
|
53
|
+
const ladder = effortLadder(efforts);
|
|
54
|
+
if (ladder.length === 0) return null;
|
|
55
|
+
const top = ladder.length - 1;
|
|
56
|
+
const measured = defaultEffort !== undefined && ladder.includes(defaultEffort)
|
|
57
|
+
? ladder.indexOf(defaultEffort)
|
|
58
|
+
: Math.floor(top / 2);
|
|
59
|
+
switch (intent) {
|
|
60
|
+
case "glance": return ladder[0]!;
|
|
61
|
+
case "measured": return ladder[measured]!;
|
|
62
|
+
case "thorough": return ladder[Math.min(measured + 1, top)]!;
|
|
63
|
+
case "exhaustive": return ladder[top]!;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function classifyRoleModelCandidates(
|
|
68
|
+
candidates: readonly RoleModelCandidate[],
|
|
69
|
+
mapping: RoleTierMapping = {},
|
|
70
|
+
): ClassifiedCandidate[] {
|
|
71
|
+
const mapped = new Map<string, SizingTier>();
|
|
72
|
+
for (const tier of SIZING_TIERS) {
|
|
73
|
+
for (const model of mapping[tier] ?? []) if (!mapped.has(model)) mapped.set(model, tier);
|
|
74
|
+
}
|
|
75
|
+
const priced = candidates
|
|
76
|
+
.map((candidate, order) => ({ candidate, order }))
|
|
77
|
+
.filter(({ candidate }) => !mapped.has(candidate.model) && candidate.unitPrice !== null)
|
|
78
|
+
.sort((a, b) => a.candidate.unitPrice! - b.candidate.unitPrice! || a.order - b.order);
|
|
79
|
+
const byPrice = new Map<string, SizingTier>();
|
|
80
|
+
priced.forEach(({ candidate }, rank) => {
|
|
81
|
+
const top = SIZING_TIERS.length - 1;
|
|
82
|
+
const index = priced.length < SIZING_TIERS.length
|
|
83
|
+
? top - (priced.length - 1 - rank)
|
|
84
|
+
: Math.round((rank * top) / (priced.length - 1));
|
|
85
|
+
byPrice.set(candidate.model, SIZING_TIERS[index]!);
|
|
86
|
+
});
|
|
87
|
+
return candidates.map(candidate => {
|
|
88
|
+
const fromMapping = mapped.get(candidate.model);
|
|
89
|
+
if (fromMapping) return { ...candidate, tier: fromMapping, tierSource: "mapping" as const };
|
|
90
|
+
const fromPrice = byPrice.get(candidate.model);
|
|
91
|
+
return fromPrice
|
|
92
|
+
? { ...candidate, tier: fromPrice, tierSource: "price" as const }
|
|
93
|
+
: { ...candidate, tier: null, tierSource: null };
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function cheapestSufficientCandidate(
|
|
98
|
+
classified: readonly ClassifiedCandidate[],
|
|
99
|
+
tier: SizingTier,
|
|
100
|
+
): ClassifiedCandidate | null {
|
|
101
|
+
const need = SIZING_TIERS.indexOf(tier);
|
|
102
|
+
const ranked = classified
|
|
103
|
+
.map((candidate, order) => ({ candidate, order, rank: candidate.tier ? SIZING_TIERS.indexOf(candidate.tier) : -1 }))
|
|
104
|
+
.filter(entry => entry.rank >= need)
|
|
105
|
+
.sort((a, b) => a.rank - b.rank
|
|
106
|
+
|| (a.candidate.unitPrice ?? Infinity) - (b.candidate.unitPrice ?? Infinity)
|
|
107
|
+
|| a.order - b.order);
|
|
108
|
+
return ranked[0]?.candidate ?? null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface RoleProposalInput {
|
|
112
|
+
readonly role: string;
|
|
113
|
+
readonly model: string | null;
|
|
114
|
+
readonly effort: string | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export type RoleProposal = RoleProposalInput & (
|
|
118
|
+
| {
|
|
119
|
+
readonly status: "proposed" | "unassigned";
|
|
120
|
+
readonly tier: SizingTier;
|
|
121
|
+
readonly effortIntent: SizingEffort;
|
|
122
|
+
readonly rationale: string;
|
|
123
|
+
readonly moveUpIf: string;
|
|
124
|
+
readonly moveDownIf: string;
|
|
125
|
+
readonly proposedModel: string | null;
|
|
126
|
+
/** Only when the role already carries an effort, unless the caller asks for one always. */
|
|
127
|
+
readonly proposedEffort: string | null;
|
|
128
|
+
readonly reason: string | null;
|
|
129
|
+
}
|
|
130
|
+
| { readonly status: "unsized"; readonly reason: string }
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
export function buildRoleProposals(
|
|
134
|
+
roles: readonly RoleProposalInput[],
|
|
135
|
+
sizing: ReadonlyMap<string, RoleSizingOutcome>,
|
|
136
|
+
classified: readonly ClassifiedCandidate[],
|
|
137
|
+
options: { readonly alwaysProposeEffort?: boolean } = {},
|
|
138
|
+
): RoleProposal[] {
|
|
139
|
+
return roles.map(role => {
|
|
140
|
+
const outcome = sizing.get(role.role) ?? { unsized: "the role was not sized" };
|
|
141
|
+
if ("unsized" in outcome) return { ...role, status: "unsized", reason: outcome.unsized };
|
|
142
|
+
const { tier, effort, rationale, moveUpIf, moveDownIf } = outcome.sizing;
|
|
143
|
+
const pick = cheapestSufficientCandidate(classified, tier);
|
|
144
|
+
const sized = { ...role, tier, effortIntent: effort, rationale, moveUpIf, moveDownIf };
|
|
145
|
+
if (!pick) {
|
|
146
|
+
return {
|
|
147
|
+
...sized,
|
|
148
|
+
status: "unassigned",
|
|
149
|
+
proposedModel: null,
|
|
150
|
+
proposedEffort: null,
|
|
151
|
+
reason: `no available model is classified ${tier} or above`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const proposedEffort = role.effort === null && !options.alwaysProposeEffort
|
|
155
|
+
? null
|
|
156
|
+
: mapEffortToLevel(effort, pick.efforts, pick.defaultEffort);
|
|
157
|
+
return { ...sized, status: "proposed", proposedModel: pick.model, proposedEffort, reason: null };
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sizing rubric for Codex agent roles: which capability tier a role needs and how hard it
|
|
3
|
+
* should reason, on a neutral scale that never names a model.
|
|
4
|
+
*
|
|
5
|
+
* This is the one home of the rubric. The dashboard's auto-assign sends it as the system prompt
|
|
6
|
+
* and parses the answer here; any later surface that sizes a role imports it rather than
|
|
7
|
+
* restating it. Choosing the concrete model and the concrete effort level is not the sizing
|
|
8
|
+
* model's job: that mapping is deterministic code in "role-auto-assign.ts".
|
|
9
|
+
*
|
|
10
|
+
* The tier and effort definitions, the risk factors, and the rules are ported from the
|
|
11
|
+
* modelchk skill by LilMGenius,
|
|
12
|
+
* https://github.com/LilMGenius/paperthin/blob/main/skills/depth/modelchk/SKILL.md,
|
|
13
|
+
* adapted from sizing one task to sizing a standing role and given a strict JSON answer shape.
|
|
14
|
+
* That text is used under the MIT License:
|
|
15
|
+
*
|
|
16
|
+
* Copyright (c) 2026 LilMGenius
|
|
17
|
+
*
|
|
18
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
|
|
19
|
+
* and associated documentation files (the "Software"), to deal in the Software without
|
|
20
|
+
* restriction, including without limitation the rights to use, copy, modify, merge, publish,
|
|
21
|
+
* distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
|
|
22
|
+
* Software is furnished to do so, subject to the following conditions:
|
|
23
|
+
*
|
|
24
|
+
* The above copyright notice and this permission notice shall be included in all copies or
|
|
25
|
+
* substantial portions of the Software.
|
|
26
|
+
*
|
|
27
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING
|
|
28
|
+
* BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
29
|
+
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
|
30
|
+
* DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
31
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { ROLE_INSTRUCTIONS_EXCERPT_CHARS } from "./role-sizing-limits";
|
|
35
|
+
|
|
36
|
+
export { ROLE_INSTRUCTIONS_EXCERPT_CHARS };
|
|
37
|
+
|
|
38
|
+
/** Capability tiers, cheapest first. */
|
|
39
|
+
export const SIZING_TIERS = ["fast", "standard", "frontier"] as const;
|
|
40
|
+
export type SizingTier = typeof SIZING_TIERS[number];
|
|
41
|
+
|
|
42
|
+
/** Reasoning effort intents, least deliberation first. Positions on a ladder, never level names. */
|
|
43
|
+
export const SIZING_EFFORTS = ["glance", "measured", "thorough", "exhaustive"] as const;
|
|
44
|
+
export type SizingEffort = typeof SIZING_EFFORTS[number];
|
|
45
|
+
|
|
46
|
+
const MAX_FIELD_CHARS = 400;
|
|
47
|
+
|
|
48
|
+
export const ROLE_SIZING_SYSTEM_PROMPT = [
|
|
49
|
+
"You size Codex agent roles before anyone spends a run on them. Each role is a standing agent that",
|
|
50
|
+
"a parent hands many tasks of the kind its instructions describe. For each role, size two dials",
|
|
51
|
+
"from one assessment of risk and complexity: capability tier and reasoning effort.",
|
|
52
|
+
"",
|
|
53
|
+
"Capability tier is the cheapest sufficient class:",
|
|
54
|
+
"- fast: local, mechanical, reversible work with cheap, complete verification.",
|
|
55
|
+
"- standard: ordinary repo-grounded reasoning, multi-step drafting, normal coding, and conventional documentation or skill work.",
|
|
56
|
+
"- frontier: architecture, high ambiguity, safety/security/privacy/data-loss risk, release-critical review, cross-domain scope, or work where one wrong assumption wastes a large run.",
|
|
57
|
+
"",
|
|
58
|
+
"Reasoning effort is how hard the model should deliberate. From least to most deliberation:",
|
|
59
|
+
"- glance: minimal deliberation; take the direct path. Resolves to the model's floor.",
|
|
60
|
+
"- measured: ordinary deliberation. Resolves to the model's default, or the middle of its ladder if no default is named.",
|
|
61
|
+
"- thorough: work the alternatives and check assumptions. Resolves above the everyday setting, short of the top.",
|
|
62
|
+
"- exhaustive: maximal deliberation; exhaust the search and re-check the work. Resolves to the model's ceiling.",
|
|
63
|
+
"",
|
|
64
|
+
"The axes are independent. A bounded but fiddly role can be fast + thorough; a quick expert call can",
|
|
65
|
+
"be frontier + glance. In most work they move together, parting when a cheap task needs hard thinking",
|
|
66
|
+
"or a strong model needs only a quick call. Effort buys deliberation, never capability, and more",
|
|
67
|
+
"effort is not more correct.",
|
|
68
|
+
"",
|
|
69
|
+
"Assess risk and complexity once for both dials:",
|
|
70
|
+
"- file, module, or ownership boundary crossing;",
|
|
71
|
+
"- reversibility and blast radius;",
|
|
72
|
+
"- safety, security, privacy, publishing, or data-loss risk;",
|
|
73
|
+
"- novelty, ambiguity, and long-context synthesis load;",
|
|
74
|
+
"- need for external research, adversarial review, or careful release sequencing;",
|
|
75
|
+
"- cost of a wrong answer.",
|
|
76
|
+
"",
|
|
77
|
+
"Choose the cheapest capability tier whose ceiling covers the role's judgment and risk. Default effort",
|
|
78
|
+
"to track tier (fast->glance, standard->measured, frontier->thorough), reserving exhaustive for the",
|
|
79
|
+
"hardest, highest-stakes work. Raise effort when an otherwise cheap role needs ambiguity resolved, long",
|
|
80
|
+
"multi-step reasoning, or adversarial self-check; lower it for bounded work under a strong model.",
|
|
81
|
+
"",
|
|
82
|
+
"Rules:",
|
|
83
|
+
"- Default to the cheapest sufficient tier and the effort the work needs, not the strongest of either.",
|
|
84
|
+
"- Never raise effort to buy capability; raise the tier.",
|
|
85
|
+
"- Risk beats size: a narrow high-risk role can need frontier; a broad mechanical role can stay fast + thorough.",
|
|
86
|
+
"- Use only the neutral words above. Do not name concrete model products, vendors, versions, or vendor effort levels anywhere in the answer.",
|
|
87
|
+
"",
|
|
88
|
+
"Answer with one JSON object and nothing else, no prose and no code fence:",
|
|
89
|
+
'{"roles":{"<role name>":{"tier":"fast|standard|frontier","effort":"glance|measured|thorough|exhaustive","rationale":"<one sentence covering both dials>","move_up_if":"<signals that would justify a stronger tier or higher effort>","move_down_if":"<signals that would justify a cheaper tier or lower effort>"}}}',
|
|
90
|
+
"Include every role you were given, keyed by its exact name, with exactly those five fields.",
|
|
91
|
+
].join("\n");
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The rubric for sizing one described piece of delegated work instead of a standing role. The role
|
|
95
|
+
* rubric is kept whole so both surfaces share one scale; the addendum only changes what is sized.
|
|
96
|
+
*/
|
|
97
|
+
export const DELEGATED_WORK_SIZING_SYSTEM_PROMPT = [
|
|
98
|
+
ROLE_SIZING_SYSTEM_PROMPT,
|
|
99
|
+
"",
|
|
100
|
+
"This request sizes one-shot delegated work rather than a standing role. The single entry's instructions",
|
|
101
|
+
"describe the work a parent hands off; size that work itself, as described, rather than every task a",
|
|
102
|
+
"role with that description might later receive. Answer in the same shape, keyed by the entry's exact name.",
|
|
103
|
+
].join("\n");
|
|
104
|
+
|
|
105
|
+
export interface RoleSizing {
|
|
106
|
+
readonly tier: SizingTier;
|
|
107
|
+
readonly effort: SizingEffort;
|
|
108
|
+
readonly rationale: string;
|
|
109
|
+
readonly moveUpIf: string;
|
|
110
|
+
readonly moveDownIf: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A role's sizing, or the reason it could not be sized. No reason is ever turned into a guess. */
|
|
114
|
+
export type RoleSizingOutcome =
|
|
115
|
+
| { readonly sizing: RoleSizing }
|
|
116
|
+
| { readonly unsized: string };
|
|
117
|
+
|
|
118
|
+
export interface RoleSizingInput {
|
|
119
|
+
readonly role: string;
|
|
120
|
+
readonly instructions: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function textField(value: unknown): string | null {
|
|
124
|
+
return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* What the sizing model reads about one role: its description, then its developer instructions,
|
|
129
|
+
* cut to ROLE_INSTRUCTIONS_EXCERPT_CHARS. Null when the file is not TOML or carries neither,
|
|
130
|
+
* because a role sized from its name alone would be a guess.
|
|
131
|
+
*/
|
|
132
|
+
export function roleInstructionsExcerpt(toml: string): string | null {
|
|
133
|
+
let document: unknown;
|
|
134
|
+
try {
|
|
135
|
+
document = Bun.TOML.parse(toml.replace(/^\ufeff/, ""));
|
|
136
|
+
} catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
if (typeof document !== "object" || document === null) return null;
|
|
140
|
+
const fields = document as Record<string, unknown>;
|
|
141
|
+
const parts = [textField(fields.description), textField(fields.developer_instructions)]
|
|
142
|
+
.filter((part): part is string => part !== null);
|
|
143
|
+
if (parts.length === 0) return null;
|
|
144
|
+
return parts.join("\n\n").slice(0, ROLE_INSTRUCTIONS_EXCERPT_CHARS);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function buildRoleSizingUserMessage(inputs: readonly RoleSizingInput[]): string {
|
|
148
|
+
return "Size these Codex agent roles.\n\n" + JSON.stringify({
|
|
149
|
+
roles: inputs.map(input => ({ name: input.role, instructions: input.instructions })),
|
|
150
|
+
}, null, 2);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function oneLine(value: unknown): string | null {
|
|
154
|
+
if (typeof value !== "string") return null;
|
|
155
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
156
|
+
return collapsed === "" || collapsed.length > MAX_FIELD_CHARS ? null : collapsed;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isMember<T extends string>(set: readonly T[], value: unknown): value is T {
|
|
160
|
+
return typeof value === "string" && (set as readonly string[]).includes(value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function validateEntry(entry: unknown): RoleSizingOutcome {
|
|
164
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
165
|
+
return { unsized: "the sizing answer for this role is not an object" };
|
|
166
|
+
}
|
|
167
|
+
const fields = entry as Record<string, unknown>;
|
|
168
|
+
if (!isMember(SIZING_TIERS, fields.tier)) return { unsized: "the sizing answer has no valid tier" };
|
|
169
|
+
if (!isMember(SIZING_EFFORTS, fields.effort)) return { unsized: "the sizing answer has no valid effort" };
|
|
170
|
+
const rationale = oneLine(fields.rationale);
|
|
171
|
+
const moveUpIf = oneLine(fields.move_up_if);
|
|
172
|
+
const moveDownIf = oneLine(fields.move_down_if);
|
|
173
|
+
if (rationale === null || moveUpIf === null || moveDownIf === null) {
|
|
174
|
+
return { unsized: "the sizing answer is missing a rationale or a move trigger" };
|
|
175
|
+
}
|
|
176
|
+
return { sizing: { tier: fields.tier, effort: fields.effort, rationale, moveUpIf, moveDownIf } };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const CODE_FENCE = /^\x60{3}(?:json)?[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\x60{3}$/i;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Validate the sizing model's answer for each requested role.
|
|
183
|
+
*
|
|
184
|
+
* The answer must be one JSON object of the shape the rubric asks for. Two leniencies, both for
|
|
185
|
+
* content the mapping never reads: a single surrounding code fence, which models add out of habit,
|
|
186
|
+
* and keys beyond the five fields in a role's entry, which are ignored. Every one of the five must
|
|
187
|
+
* still be present and valid. Anything else wrong marks the affected role, or every role when the
|
|
188
|
+
* whole answer is unusable, as unsized with the reason.
|
|
189
|
+
*/
|
|
190
|
+
export function parseRoleSizingResponse(text: string, roles: readonly string[]): Map<string, RoleSizingOutcome> {
|
|
191
|
+
const out = new Map<string, RoleSizingOutcome>();
|
|
192
|
+
const everyRole = (reason: string) => {
|
|
193
|
+
for (const role of roles) out.set(role, { unsized: reason });
|
|
194
|
+
return out;
|
|
195
|
+
};
|
|
196
|
+
const trimmed = text.trim();
|
|
197
|
+
const fenced = CODE_FENCE.exec(trimmed);
|
|
198
|
+
let parsed: unknown;
|
|
199
|
+
try {
|
|
200
|
+
parsed = JSON.parse(fenced ? fenced[1]! : trimmed);
|
|
201
|
+
} catch {
|
|
202
|
+
return everyRole("the sizing model did not answer with JSON");
|
|
203
|
+
}
|
|
204
|
+
const answered = typeof parsed === "object" && parsed !== null
|
|
205
|
+
? (parsed as Record<string, unknown>).roles
|
|
206
|
+
: undefined;
|
|
207
|
+
if (typeof answered !== "object" || answered === null || Array.isArray(answered)) {
|
|
208
|
+
return everyRole("the sizing answer has no roles object");
|
|
209
|
+
}
|
|
210
|
+
for (const role of roles) {
|
|
211
|
+
out.set(role, Object.hasOwn(answered, role)
|
|
212
|
+
? validateEntry((answered as Record<string, unknown>)[role])
|
|
213
|
+
: { unsized: "the sizing model did not size this role" });
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getEffectiveCodexAutoSwitchThreshold } from "../account-auto-switch";
|
|
2
|
+
import { isCodexAccountHeldForCredits } from "../account-credit-use";
|
|
2
3
|
import { isCodexAccountPaused } from "../account-pause";
|
|
3
4
|
import { codexAccountPriorityLookup, pinnedCodexAccountId } from "../account-priority";
|
|
4
5
|
import { isSelectableCodexPoolAccount } from "../account-id";
|
|
@@ -82,6 +83,37 @@ export function isCodexAccountPlanExcluded(
|
|
|
82
83
|
return plan !== undefined && excluded.has(plan);
|
|
83
84
|
}
|
|
84
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Whether the operator turned credits off for this account and one of its usage windows is full
|
|
88
|
+
* now (#6334). Checked wherever plan exclusion is checked, for the reason given there: an account
|
|
89
|
+
* that is already active or bound is served without passing through the eligible list.
|
|
90
|
+
*/
|
|
91
|
+
export function isCodexAccountHeldAtUsageLimit(
|
|
92
|
+
config: OcxConfig,
|
|
93
|
+
accountId: string,
|
|
94
|
+
now: number,
|
|
95
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
96
|
+
): boolean {
|
|
97
|
+
return isCodexAccountHeldForCredits(
|
|
98
|
+
config,
|
|
99
|
+
accountId,
|
|
100
|
+
getAccountQuota(accountId),
|
|
101
|
+
getPoolAccountPlanForSelection(config, accountId, selectionOptions),
|
|
102
|
+
now,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The automatic-rotation policies that also bind the legacy keep-the-active-account fallback. */
|
|
107
|
+
export function isCodexAccountRotationExcluded(
|
|
108
|
+
config: OcxConfig,
|
|
109
|
+
accountId: string,
|
|
110
|
+
now: number,
|
|
111
|
+
selectionOptions?: CodexAccountUsabilityOptions,
|
|
112
|
+
): boolean {
|
|
113
|
+
return isCodexAccountPlanExcluded(config, accountId)
|
|
114
|
+
|| isCodexAccountHeldAtUsageLimit(config, accountId, now, selectionOptions);
|
|
115
|
+
}
|
|
116
|
+
|
|
85
117
|
export function isCodexAccountSelectable(
|
|
86
118
|
config: OcxConfig,
|
|
87
119
|
accountId: string,
|
|
@@ -91,6 +123,7 @@ export function isCodexAccountSelectable(
|
|
|
91
123
|
): boolean {
|
|
92
124
|
return !isCodexAccountPaused(config, accountId)
|
|
93
125
|
&& !isCodexAccountPlanExcluded(config, accountId)
|
|
126
|
+
&& !isCodexAccountHeldAtUsageLimit(config, accountId, now, selectionOptions)
|
|
94
127
|
&& getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
|
|
95
128
|
&& !isCodexQuotaAvoided(accountId, quotaScope, now)
|
|
96
129
|
&& !isCodexAccountSoftAvoided(accountId, now)
|
|
@@ -117,6 +150,7 @@ export function codexAccountBlockReason(
|
|
|
117
150
|
): CodexAffinityReason | undefined {
|
|
118
151
|
if (isCodexAccountPaused(config, accountId)) return "paused";
|
|
119
152
|
if (isCodexAccountPlanExcluded(config, accountId)) return "plan_excluded";
|
|
153
|
+
if (isCodexAccountHeldAtUsageLimit(config, accountId, now, selectionOptions)) return "credits_off";
|
|
120
154
|
if (getCodexQuotaHealthSnapshot(accountId, quotaScope, now) !== null) return "cooldown";
|
|
121
155
|
if (isCodexQuotaAvoided(accountId, quotaScope, now)) return "quota_avoided";
|
|
122
156
|
if (isCodexAccountSoftAvoided(accountId, now)) return "transient";
|
|
@@ -172,6 +206,7 @@ export function getEligiblePoolAccounts(
|
|
|
172
206
|
&& account.id !== excludeId
|
|
173
207
|
&& !isCodexAccountPaused(config, account.id)
|
|
174
208
|
&& !isCodexAccountPlanExcluded(config, account.id, excludedPlans)
|
|
209
|
+
&& !isCodexAccountHeldAtUsageLimit(config, account.id, now, selectionOptions)
|
|
175
210
|
&& !isAccountNeedsReauth(account.id)
|
|
176
211
|
&& (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now)))
|
|
177
212
|
.filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
|
|
@@ -195,6 +230,7 @@ export function getEligiblePoolAccounts(
|
|
|
195
230
|
// in between the main account returns as a first-class candidate.
|
|
196
231
|
&& !isCodexQuotaAvoided(MAIN_CODEX_ACCOUNT_ID, quotaScope, now)
|
|
197
232
|
&& !isCodexPoolRefreshCooling(MAIN_CODEX_ACCOUNT_ID, now)
|
|
233
|
+
&& !isCodexAccountHeldAtUsageLimit(config, MAIN_CODEX_ACCOUNT_ID, now, selectionOptions)
|
|
198
234
|
&& (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now))
|
|
199
235
|
&& isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
|
|
200
236
|
) {
|
|
@@ -102,6 +102,8 @@ export type CodexAffinityReason =
|
|
|
102
102
|
| "unusable"
|
|
103
103
|
| "paused"
|
|
104
104
|
| "plan_excluded"
|
|
105
|
+
/** Credits are off for the account and one of its usage windows is full (#6334). */
|
|
106
|
+
| "credits_off"
|
|
105
107
|
| "cooldown"
|
|
106
108
|
| "quota_avoided"
|
|
107
109
|
| "generation"
|