@bitkyc08/opencodex 2.80.0 → 2.81.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 +3 -3
- package/gui/dist/assets/App-7yhoys8a.js +54 -0
- package/gui/dist/assets/Tray-EwAOsP0S.js +1 -0
- package/gui/dist/assets/index-RsoaAxXY.js +94 -0
- package/gui/dist/assets/quota-summary-D2lN8tDm.js +1 -0
- package/gui/dist/index.html +1 -1
- package/gui/dist/provider-icons/claude-green.svg +1 -0
- package/package.json +3 -2
- package/src/adapters/anthropic-model-contract.ts +8 -2
- package/src/adapters/anthropic.ts +7 -2
- package/src/adapters/codebuddy/mcp-server.ts +2 -25
- package/src/adapters/codebuddy/tool-bridge.ts +1 -7
- package/src/adapters/coding-agent/mcp-server.ts +33 -0
- package/src/adapters/coding-agent/protocol.ts +122 -17
- package/src/adapters/coding-agent/tool-catalog.ts +75 -0
- package/src/adapters/coding-agent/turn.ts +101 -44
- package/src/adapters/cursor/catalog.ts +8 -0
- package/src/adapters/cursor/effort-map.ts +2 -0
- package/src/adapters/devin/live-models.ts +2 -0
- package/src/adapters/kiro-retry.ts +9 -4
- package/src/adapters/openai-chat/messages.ts +7 -6
- package/src/adapters/physical-send.ts +8 -5
- package/src/adapters/qoder/adapter.ts +41 -6
- package/src/adapters/qoder/scaffold-guard.ts +2 -3
- package/src/adapters/zed.ts +16 -1
- package/src/claude/inbound-cache-stabilize.ts +4 -9
- package/src/claude/inbound.ts +1 -0
- package/src/claude/intercept/model-bindings.ts +1 -0
- package/src/claude/intercept/picker-listener.ts +71 -4
- package/src/cli/account-anthropic-threshold.ts +14 -5
- package/src/cli/account-api.ts +3 -1
- package/src/cli/account-extended.ts +13 -9
- package/src/cli/account-policy-dto.ts +1 -1
- package/src/cli/account-policy.ts +13 -4
- package/src/cli/account.ts +3 -3
- package/src/cli/capabilities-accounts.ts +2 -2
- package/src/cli/capabilities-agents-routing.ts +24 -0
- package/src/cli/capabilities-integrations.ts +16 -0
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/dispatch.ts +10 -1
- package/src/cli/help.ts +4 -1
- package/src/cli/index.ts +11 -5
- package/src/cli/integration-plan-dto.ts +1 -0
- package/src/cli/integration-preview.ts +8 -1
- package/src/cli/integrations.ts +32 -3
- package/src/cli/message-args.ts +34 -0
- package/src/cli/message-command.ts +43 -0
- package/src/cli/message-runtime.ts +6 -0
- package/src/cli/observe.ts +3 -1
- package/src/cli/provider.ts +29 -6
- package/src/cli/registry.ts +24 -2
- package/src/clients/config-export/commandcode.ts +131 -0
- package/src/clients/config-export/contracts.ts +12 -0
- package/src/clients/config-export.ts +64 -1
- package/src/codex/auth-api/account-list.ts +11 -3
- package/src/codex/auth-api/main-account-probe.ts +25 -8
- package/src/codex/auth-api/pool-mode-gate.ts +33 -9
- package/src/codex/auth-api/pool-quota-probe.ts +15 -1
- package/src/codex/auth-api/routes.ts +1 -0
- package/src/codex/catalog/auto-review.ts +13 -6
- package/src/codex/catalog/build-entries.ts +15 -7
- package/src/codex/catalog/control-plane.ts +45 -0
- package/src/codex/catalog/effort.ts +11 -1
- package/src/codex/catalog/gather-capture.ts +5 -3
- package/src/codex/catalog/parsing.ts +2 -1
- package/src/codex/catalog/provider-models.ts +15 -9
- package/src/codex/control-plane-models.ts +6 -0
- package/src/combos/jev-decision-contract.ts +25 -2
- package/src/combos/jev.ts +3 -2
- package/src/combos/types.ts +1 -1
- package/src/config/diagnostics.ts +27 -16
- package/src/config/load-degrade.ts +34 -0
- package/src/config/schema/anthropic-account-pool.ts +143 -0
- package/src/config/schema/config-schema.ts +7 -1
- package/src/config/schema/leaf-validators.ts +4 -0
- package/src/config.ts +2 -0
- package/src/generated/compatibility-version.json +320 -184
- package/src/generated/model-metadata.ts +5 -4
- package/src/images/loop.ts +2 -1
- package/src/integrations/catalog-refresh.ts +1 -1
- package/src/integrations/mutation-plan.ts +7 -0
- package/src/integrations/registry.ts +8 -0
- package/src/integrations/state.ts +9 -2
- package/src/integrations/writer.ts +8 -2
- package/src/lib/lab-live-route-production.ts +9 -1
- package/src/lib/request-execution-budget.ts +236 -58
- package/src/lib/spend-pool-alias-validation.ts +79 -0
- package/src/lib/spend-pool-continuity.ts +34 -0
- package/src/lib/spend-reservation-ledger.ts +412 -50
- package/src/lib/state-store-registrations.ts +20 -3
- package/src/lib/upstream-retry.ts +17 -5
- package/src/lib/workflow-budget.ts +53 -11
- package/src/messaging/budget.ts +45 -0
- package/src/messaging/discovery.ts +57 -0
- package/src/messaging/envelope.ts +49 -0
- package/src/messaging/input.ts +37 -0
- package/src/messaging/rpc.ts +196 -0
- package/src/messaging/send.ts +58 -0
- package/src/messaging/socket.ts +72 -0
- package/src/messaging/types.ts +29 -0
- package/src/oauth/anthropic-account-refusal.ts +51 -17
- package/src/oauth/anthropic-account-threshold.ts +8 -2
- package/src/oauth/anthropic-continuity.ts +15 -1
- package/src/oauth/anthropic-model-quota.ts +120 -48
- package/src/oauth/anthropic-model-routes.ts +11 -3
- package/src/oauth/anthropic-oauth-definitions.ts +98 -0
- package/src/oauth/anthropic-pool-config.ts +30 -0
- package/src/oauth/anthropic-rate-limit-policy.ts +64 -9
- package/src/oauth/anthropic-routing.ts +1202 -866
- package/src/oauth/anthropic-send-ownership.ts +41 -0
- package/src/oauth/anthropic.ts +4 -1
- package/src/oauth/generic-account-failover.ts +3 -1
- package/src/oauth/health.ts +8 -6
- package/src/oauth/index.ts +90 -173
- package/src/oauth/kiro.ts +22 -9
- package/src/oauth/login-cli.ts +2 -0
- package/src/oauth/model-discovery-auth.ts +43 -0
- package/src/oauth/pool-kernel.ts +11 -5
- package/src/oauth/pool-settings-capability.ts +5 -3
- package/src/oauth/store-anthropic-instance.ts +87 -0
- package/src/oauth/store.ts +67 -9
- package/src/oauth/token-guardian.ts +2 -0
- package/src/oauth/types.ts +2 -0
- package/src/protocols/plan-snapshot.ts +5 -0
- package/src/protocols/settings.ts +20 -5
- package/src/providers/anthropic-instance-id.ts +47 -0
- package/src/providers/anthropic-instance.ts +48 -0
- package/src/providers/anthropic-reset-grant-ledger.ts +11 -1
- package/src/providers/derive.ts +2 -0
- package/src/providers/kiro-models.ts +3 -0
- package/src/providers/label.ts +1 -1
- package/src/providers/model-rename-fields.ts +2 -0
- package/src/providers/quota/account-cache.ts +37 -11
- package/src/providers/quota/anthropic-account-quota.ts +93 -0
- package/src/providers/quota/anthropic-cooldown-recovery.ts +222 -111
- package/src/providers/quota/report-cache.ts +24 -1
- package/src/providers/quota/vendor-probes-oauth.ts +46 -14
- package/src/providers/quota.ts +22 -32
- package/src/providers/registry/entries-core.ts +35 -30
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/model-seeds.ts +7 -4
- package/src/providers/registry/types.ts +3 -0
- package/src/providers/registry.ts +3 -3
- package/src/responses/spill-store.ts +5 -1
- package/src/responses/state/metrics.ts +87 -0
- package/src/responses/state/snapshot-codec.ts +6 -0
- package/src/responses/state/spill-queue.ts +1 -0
- package/src/responses/state/unforced-store-false.ts +55 -0
- package/src/responses/state.ts +50 -66
- package/src/router.ts +28 -4
- package/src/routing/compatibility/assemble.ts +1 -1
- package/src/routing/quota.ts +7 -3
- package/src/server/auth-cors.ts +5 -0
- package/src/server/chat-completions.ts +6 -1
- package/src/server/chat-native.ts +45 -25
- package/src/server/claude-messages.ts +16 -2
- package/src/server/gui-static.ts +38 -4
- package/src/server/index/spend-ledger-lifecycle.ts +10 -4
- package/src/server/index.ts +4 -2
- package/src/server/inference/context.ts +1 -1
- package/src/server/lifecycle.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +30 -6
- package/src/server/management/anthropic-account-threshold.ts +12 -9
- package/src/server/management/anthropic-pool-settings.ts +72 -0
- package/src/server/management/anthropic-reset-grant-routes.ts +98 -39
- package/src/server/management/config-routes.ts +27 -7
- package/src/server/management/decision-routes.ts +2 -1
- package/src/server/management/integration-routes.ts +25 -2
- package/src/server/management/oauth-account-routes.ts +79 -98
- package/src/server/management/provider-instance-ownership.ts +44 -0
- package/src/server/management/provider-routes.ts +21 -28
- package/src/server/management/route-registry.ts +3 -1
- package/src/server/management/routing-profile-routes.ts +4 -4
- package/src/server/management/system-restart.ts +44 -14
- package/src/server/management/system-routes.ts +24 -2
- package/src/server/management/vision-sidecar-options.ts +9 -6
- package/src/server/management/web-search-sidecar-options.ts +51 -2
- package/src/server/management-auth.ts +6 -0
- package/src/server/messages-native-eligibility.ts +3 -2
- package/src/server/messages-native-oauth.ts +86 -41
- package/src/server/messages-native-selector.ts +39 -0
- package/src/server/messages-native.ts +84 -25
- package/src/server/request-log.ts +5 -0
- package/src/server/responses/adapter-continuation.ts +29 -17
- package/src/server/responses/adapter-dispatch.ts +47 -17
- package/src/server/responses/antigravity-validation-refusal.ts +20 -6
- package/src/server/responses/compact.ts +25 -8
- package/src/server/responses/compaction-recovery.ts +1 -1
- package/src/server/responses/core-combo-native.ts +4 -1
- package/src/server/responses/core-combo.ts +16 -11
- package/src/server/responses/core-normalize.ts +1 -0
- package/src/server/responses/core-options.ts +3 -1
- package/src/server/responses/core-replay.ts +5 -0
- package/src/server/responses/passthrough-delivery.ts +7 -3
- package/src/server/responses/passthrough-dispatch.ts +21 -8
- package/src/server/responses/request-prepare.ts +37 -5
- package/src/server/responses/request-send-budget.ts +74 -37
- package/src/server/responses/request-spend.ts +325 -33
- package/src/server/responses/request-transport.ts +132 -52
- package/src/server/responses/run-turn-execution.ts +11 -3
- package/src/server/responses/sidecar-execution.ts +96 -14
- package/src/server/responses-undeclared-tool-guard.ts +5 -1
- package/src/server/workflow-refusal.ts +30 -0
- package/src/service/ownership-mutation-lease.d.mts +1 -1
- package/src/service/ownership-mutation-lease.mjs +5 -7
- package/src/sidecar/anthropic-binding.ts +79 -0
- package/src/sidecar/auth.ts +76 -2
- package/src/types/anthropic-account-pool.ts +33 -0
- package/src/types/config.ts +12 -28
- package/src/types/provider.ts +5 -0
- package/src/types/tools.ts +7 -0
- package/src/types.ts +4 -0
- package/src/usage/cost.ts +3 -2
- package/src/usage/expected-prices.ts +66 -14
- package/src/vision/anthropic-describe.ts +16 -3
- package/src/vision/index.ts +3 -0
- package/src/vision/plan.ts +19 -6
- package/src/web-search/alpha-search.ts +7 -1
- package/src/web-search/anthropic-executor.ts +16 -4
- package/src/web-search/index.ts +7 -3
- package/src/web-search/loop.ts +27 -5
- package/src/web-search/passthrough-bridge.ts +4 -1
- package/src/web-search/sidecar-providers.ts +4 -3
- package/gui/dist/assets/App-MbDVfUeC.js +0 -54
- package/gui/dist/assets/Tray-CzkxK82A.js +0 -1
- package/gui/dist/assets/index-UJt6npXk.js +0 -94
- package/gui/dist/assets/quota-summary-DU3sD6Z-.js +0 -1
package/src/server/gui-static.ts
CHANGED
|
@@ -20,12 +20,46 @@ const MIME_TYPES: Record<string, string> = {
|
|
|
20
20
|
*/
|
|
21
21
|
const HASHED_ASSET_PATTERN = /-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9]+$/;
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
/** `productName` in desktop/src-tauri/tauri.conf.json; Linux packages install resources under `lib/<productName>`. */
|
|
24
|
+
export const DESKTOP_PRODUCT_NAME = "OpenCodex";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Where a compiled `ocx` looks for `gui/dist`, given the directory holding the executable.
|
|
28
|
+
*
|
|
29
|
+
* Beside the binary covers a release archive and the Windows desktop install. The desktop shell
|
|
30
|
+
* ships `ocx` as a sidecar and its resources where Tauri's `resource_dir` puts them: `../Resources`
|
|
31
|
+
* inside a macOS app bundle, `../lib/<productName>` for a Linux package (`/usr/bin`, `/usr/local/bin`,
|
|
32
|
+
* an AppImage's `usr/bin`). The shell passes that directory as OPENCODEX_GUI_DIST only to the sidecar
|
|
33
|
+
* it starts itself; `ocx ensure` from the Codex shim and the login service start the same binary
|
|
34
|
+
* without the variable, so they find the dashboard here.
|
|
35
|
+
*/
|
|
36
|
+
export function standaloneGuiDistCandidates(executableDir: string, platform: NodeJS.Platform = process.platform): string[] {
|
|
37
|
+
const candidates = [join(executableDir, "gui", "dist")];
|
|
38
|
+
if (platform === "darwin") candidates.push(join(executableDir, "..", "Resources", "gui", "dist"));
|
|
39
|
+
if (platform === "linux") candidates.push(join(executableDir, "..", "lib", DESKTOP_PRODUCT_NAME, "gui", "dist"));
|
|
40
|
+
return candidates;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Overrides for tests; production callers pass nothing and get this process's own layout. */
|
|
44
|
+
export interface GuiDistLookup {
|
|
45
|
+
/** Directory holding a compiled `ocx`, or null when running from source. */
|
|
46
|
+
standaloneDir?: string | null;
|
|
47
|
+
platform?: NodeJS.Platform;
|
|
48
|
+
/** Directory of this module; a source checkout's `gui/dist` is resolved from it. */
|
|
49
|
+
moduleDir?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Lookup only: checks for `index.html` and never creates, copies or builds anything. */
|
|
53
|
+
export function findGuiDist(lookup: GuiDistLookup = {}): string | null {
|
|
54
|
+
const standaloneDir = lookup.standaloneDir === undefined
|
|
55
|
+
? (isStandaloneBinary() ? standaloneRoot() : null)
|
|
56
|
+
: lookup.standaloneDir;
|
|
57
|
+
const moduleDir = lookup.moduleDir ?? import.meta.dir;
|
|
24
58
|
const candidates = [
|
|
25
59
|
process.env.OPENCODEX_GUI_DIST,
|
|
26
|
-
...(
|
|
27
|
-
join(
|
|
28
|
-
join(
|
|
60
|
+
...(standaloneDir === null ? [] : standaloneGuiDistCandidates(standaloneDir, lookup.platform)),
|
|
61
|
+
join(moduleDir, "..", "..", "gui", "dist"),
|
|
62
|
+
join(moduleDir, "..", "..", "..", "gui", "dist"),
|
|
29
63
|
].filter((candidate): candidate is string => Boolean(candidate));
|
|
30
64
|
for (const c of candidates) {
|
|
31
65
|
if (existsSync(join(c, "index.html"))) return c;
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
import {
|
|
7
7
|
configureSharedSpendLedger,
|
|
8
8
|
spendPolicyFromConfig,
|
|
9
|
+
waitForSharedSpendReporterDrain,
|
|
9
10
|
} from "../../lib/spend-reservation-ledger";
|
|
10
11
|
import { warnIfSyncedStateDirectory } from "../../lib/synced-state-location";
|
|
11
12
|
|
|
@@ -26,7 +27,7 @@ export function waitForFailedStartRollback(error: unknown): Promise<void> {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export interface SpendLedgerServerLifecycle {
|
|
29
|
-
configure(spend: OcxSpendConfig | undefined): void;
|
|
30
|
+
configure(spend: OcxSpendConfig | undefined, poolAliases?: unknown, providerIds?: readonly string[]): void;
|
|
30
31
|
track<T extends { stop(closeActiveConnections?: boolean): void | Promise<void> }>(server: T): T;
|
|
31
32
|
release(): void;
|
|
32
33
|
releaseAfterFailedStart(): Promise<void>;
|
|
@@ -49,13 +50,18 @@ export function acquireSpendLedgerServerLifecycle(configDir: string): SpendLedge
|
|
|
49
50
|
owner.release();
|
|
50
51
|
};
|
|
51
52
|
return {
|
|
52
|
-
configure(spend): void {
|
|
53
|
-
configureSharedSpendLedger(spendPolicyFromConfig(spend));
|
|
53
|
+
configure(spend, poolAliases, providerIds): void {
|
|
54
|
+
configureSharedSpendLedger(spendPolicyFromConfig(spend, poolAliases, providerIds));
|
|
54
55
|
},
|
|
55
56
|
track<T extends { stop(closeActiveConnections?: boolean): void | Promise<void> }>(server: T): T {
|
|
56
57
|
// Capture the raw stop before startServer replaces the public method with full teardown.
|
|
57
58
|
const stop = server.stop.bind(server);
|
|
58
|
-
|
|
59
|
+
const drainedStop = async (closeActiveConnections?: boolean): Promise<void> => {
|
|
60
|
+
await stop(closeActiveConnections);
|
|
61
|
+
await waitForSharedSpendReporterDrain();
|
|
62
|
+
};
|
|
63
|
+
Object.defineProperty(server, "stop", { configurable: true, value: drainedStop });
|
|
64
|
+
failedStartStops.push(() => drainedStop(true));
|
|
59
65
|
return server;
|
|
60
66
|
},
|
|
61
67
|
release,
|
package/src/server/index.ts
CHANGED
|
@@ -302,7 +302,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
302
302
|
configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(config.appOwnedMemoryBudgetMb));
|
|
303
303
|
enforceAppOwnedMemoryBudget();
|
|
304
304
|
// Observe-only mode still journals physical sends, so every server owns before configuring.
|
|
305
|
-
spendLedgerLifecycle.configure(config.spend);
|
|
305
|
+
spendLedgerLifecycle.configure(config.spend, config.spendPoolAliases, Object.keys(config.providers));
|
|
306
306
|
// After ownership: a second server on the same home is refused above, so the process running
|
|
307
307
|
// this line is the only one appending to usage.jsonl and the only one that may compact it.
|
|
308
308
|
setUsageLedgerRetention(config.usageLedgerMaxBytes);
|
|
@@ -505,7 +505,9 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
505
505
|
const lease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers));
|
|
506
506
|
if (!lease) return serverBusyResponse(req, "active turns", policy);
|
|
507
507
|
// Root, lane, and the refusal that follows from them, all live in ./workflow-refusal.
|
|
508
|
-
|
|
508
|
+
let workflow: ReturnType<typeof admitHttpWorkflowTurn>;
|
|
509
|
+
try { workflow = admitHttpWorkflowTurn(req.headers); }
|
|
510
|
+
catch (error) { lease.release(); throw error; }
|
|
509
511
|
if (workflow && !workflow.admitted) {
|
|
510
512
|
lease.release();
|
|
511
513
|
// withCors, because without Access-Control-Allow-Origin the exposed refusal header is
|
|
@@ -34,7 +34,7 @@ export function createInferenceSendBudget(
|
|
|
34
34
|
export function expandInferenceOAuthSendBudget(budget: TransientSendBudget | undefined, accounts: number): void {
|
|
35
35
|
if (!budget) return;
|
|
36
36
|
const policy = ingressPolicies.get(budget);
|
|
37
|
-
if (!policy || budget.used !== 0) return;
|
|
37
|
+
if (!policy || budget.used !== 0 || ("physicalStarted" in budget && Number(budget.physicalStarted) > 0)) return;
|
|
38
38
|
ingressPolicies.delete(budget);
|
|
39
39
|
if (accounts < 2 || !Number.isSafeInteger(accounts)) return;
|
|
40
40
|
const sends = Math.min(accounts, GENERIC_OAUTH_MAX_ACCOUNTS_PER_REQUEST) * TRANSIENT_RETRY_MAX_ATTEMPTS;
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -479,7 +479,7 @@ export async function drainAndShutdown(
|
|
|
479
479
|
beginBackgroundShellShutdown();
|
|
480
480
|
try {
|
|
481
481
|
while (admittedTurns.size > 0 && Date.now() < deadline) {
|
|
482
|
-
await Bun.sleep(100);
|
|
482
|
+
await Bun.sleep(Math.min(100, Math.max(0, deadline - Date.now())));
|
|
483
483
|
}
|
|
484
484
|
if (admittedTurns.size > 0) {
|
|
485
485
|
console.warn(`⚠️ Aborting ${admittedTurns.size} in-flight turn(s) after ${timeoutMs}ms deadline`);
|
|
@@ -8,6 +8,8 @@ import { catalogModelSlug, filterCatalogVisibleModels, invalidateCodexModelsCach
|
|
|
8
8
|
import { mergeModelPinnedEfforts, modelPinnedEffortsConfigError } from "../../config/provider-validation";
|
|
9
9
|
import { MULTI_AGENT_SURFACE_ADVISORY_VERSION, multiAgentSurfaceAdvisory, resolveMultiAgentMode } from "../../config/multi-agent-surface";
|
|
10
10
|
import { captureConfigTopLevelRollback, parsedConfigRebaseDeletionKeys, projectConfigRebaseProvenance } from "../../config/rebase-provenance";
|
|
11
|
+
import { anthropicSidecarPatchError } from "../../config/schema/anthropic-account-pool";
|
|
12
|
+
import { isAnthropicInstanceId, type AnthropicInstanceId } from "../../providers/anthropic-instance-id";
|
|
11
13
|
import {
|
|
12
14
|
adoptPersistedClaudeCode,
|
|
13
15
|
DEFAULT_SUBAGENT_MODELS,
|
|
@@ -71,9 +73,13 @@ import {
|
|
|
71
73
|
webSearchCandidateRows,
|
|
72
74
|
webSearchModelIsRejected,
|
|
73
75
|
webSearchModelRejection,
|
|
76
|
+
sidecarAnthropicPoolOptions,
|
|
77
|
+
sidecarSettingsAfterPatch,
|
|
78
|
+
sidecarOptionsAuth,
|
|
74
79
|
type WebSearchBackend,
|
|
75
80
|
} from "./web-search-sidecar-options";
|
|
76
81
|
import { drainAndShutdown } from "../lifecycle";
|
|
82
|
+
import { inheritedAnthropicInstance } from "../../sidecar/auth";
|
|
77
83
|
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
|
|
78
84
|
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
79
85
|
import type { PersistedUsageAttempt } from "../../usage/log";
|
|
@@ -1368,6 +1374,12 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1368
1374
|
);
|
|
1369
1375
|
const webSearchOverride = config.claudeCode?.webSearchSidecar;
|
|
1370
1376
|
const visionOverride = config.claudeCode?.visionSidecar;
|
|
1377
|
+
const helperParent = config.claudeCode?.model?.includes("/") ? config.claudeCode.model.split("/")[0] : undefined;
|
|
1378
|
+
const helperVision = { ...config.visionSidecar, ...visionOverride };
|
|
1379
|
+
// Same pool the runtime picks (vision/plan.ts preferredVisionBackend): explicit, else inherited
|
|
1380
|
+
// from the Claude model's instance, so a usable inherited Pool 2 reports the Anthropic backend.
|
|
1381
|
+
const helperVisionInstance = helperVision.anthropicInstance ?? inheritedAnthropicInstance(config, helperParent);
|
|
1382
|
+
const helperVisionBackend = helperVision.backend ?? (sidecarOptionsAuth(config, helperVisionInstance).isAnthropicAuth ? "anthropic" : "openai");
|
|
1371
1383
|
const { firstPartyDesired, readFirstPartyProxyStatus } = await import("../../claude/first-party-settings");
|
|
1372
1384
|
const { observeClaudeDesktopMode } = await import("../../claude/desktop-first-party");
|
|
1373
1385
|
const { claudeInterceptEnabled, getClaudeInterceptState } = await import("../../claude/intercept/runtime");
|
|
@@ -1411,11 +1423,15 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1411
1423
|
autoCompactWindow: config.claudeCode?.autoCompactWindow ?? null,
|
|
1412
1424
|
blockedSkills: config.claudeCode?.blockedSkills ?? null,
|
|
1413
1425
|
injectAgents: config.claudeCode?.injectAgents !== false,
|
|
1426
|
+
sidecarPools: {
|
|
1427
|
+
webSearchSidecar: sidecarAnthropicPoolOptions(config, { ...config.webSearchSidecar, ...webSearchOverride }, helperParent),
|
|
1428
|
+
visionSidecar: sidecarAnthropicPoolOptions(config, { ...helperVision, backend: helperVisionBackend }, helperParent),
|
|
1429
|
+
},
|
|
1414
1430
|
...(webSearchOverride && Object.keys(webSearchOverride).length > 0
|
|
1415
|
-
? { webSearchSidecar: { backend: webSearchOverride.backend, model: webSearchOverride.model } }
|
|
1431
|
+
? { webSearchSidecar: { backend: webSearchOverride.backend, model: webSearchOverride.model, ...(webSearchOverride.anthropicInstance ? { anthropicInstance: webSearchOverride.anthropicInstance } : {}) } }
|
|
1416
1432
|
: {}),
|
|
1417
1433
|
...(visionOverride && Object.keys(visionOverride).length > 0
|
|
1418
|
-
? { visionSidecar: { backend: visionOverride.backend, model: visionOverride.model } }
|
|
1434
|
+
? { visionSidecar: { backend: visionOverride.backend, model: visionOverride.model, ...(visionOverride.anthropicInstance ? { anthropicInstance: visionOverride.anthropicInstance } : {}) } }
|
|
1419
1435
|
: {}),
|
|
1420
1436
|
fastMode: config.fastMode,
|
|
1421
1437
|
forceAvailable: [...visibleNativeSlugs(config), ...filterCatalogVisibleModels(models, config).map(catalogModelSlug)],
|
|
@@ -1563,6 +1579,8 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1563
1579
|
...(residual ? ["settings_residual"] : []),
|
|
1564
1580
|
] });
|
|
1565
1581
|
}
|
|
1582
|
+
const instanceError = anthropicSidecarPatchError(config, body, true);
|
|
1583
|
+
if (instanceError) return jsonResponse({ error: instanceError }, 400);
|
|
1566
1584
|
for (const field of ["webSearchSidecar", "visionSidecar"] as const) {
|
|
1567
1585
|
const section = body[field];
|
|
1568
1586
|
if (section === undefined || section === null) continue;
|
|
@@ -1587,7 +1605,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1587
1605
|
// gates cannot drift.
|
|
1588
1606
|
if (field === "visionSidecar" && typeof section.model === "string" && section.model !== "") {
|
|
1589
1607
|
const requested = section.model;
|
|
1590
|
-
const candidates = await visionCandidateRows(config
|
|
1608
|
+
const candidates = await visionCandidateRows({ ...config, visionSidecar: {
|
|
1609
|
+
...config.visionSidecar, ...sidecarSettingsAfterPatch(config.claudeCode?.visionSidecar, section),
|
|
1610
|
+
} });
|
|
1591
1611
|
const hint = section.backend === "anthropic" || section.backend === "openai"
|
|
1592
1612
|
|| section.backend === "routed"
|
|
1593
1613
|
? section.backend
|
|
@@ -1610,7 +1630,7 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1610
1630
|
// module as /api/sidecar-settings — a gate on one route and a stale copy on
|
|
1611
1631
|
// the other is no gate at all.
|
|
1612
1632
|
if (field === "webSearchSidecar"
|
|
1613
|
-
&& (section.model !== undefined || section.backend !== undefined)) {
|
|
1633
|
+
&& (section.model !== undefined || section.backend !== undefined || section.anthropicInstance !== undefined)) {
|
|
1614
1634
|
const stored = config.claudeCode?.webSearchSidecar;
|
|
1615
1635
|
// Validate against the SUBMITTED backend across the whole union, not
|
|
1616
1636
|
// just openai/anthropic (#2457). allowedBackends above already refused
|
|
@@ -1629,7 +1649,9 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1629
1649
|
: typeof section.model === "string"
|
|
1630
1650
|
? section.model
|
|
1631
1651
|
: stored?.model ?? config.webSearchSidecar?.model;
|
|
1632
|
-
const candidates = await webSearchCandidateRows(config
|
|
1652
|
+
const candidates = await webSearchCandidateRows({ ...config, webSearchSidecar: {
|
|
1653
|
+
...config.webSearchSidecar, ...sidecarSettingsAfterPatch(config.claudeCode?.webSearchSidecar, section),
|
|
1654
|
+
} });
|
|
1633
1655
|
if (effectiveModel && webSearchModelIsRejected(effectiveBackend, effectiveModel, candidates)) {
|
|
1634
1656
|
return jsonResponse(webSearchModelRejection(
|
|
1635
1657
|
"webSearchSidecar.model",
|
|
@@ -1650,8 +1672,10 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise
|
|
|
1650
1672
|
}
|
|
1651
1673
|
// The per-field validation above guarantees vision only ever carries the two-member
|
|
1652
1674
|
// union; the cast is the loop's shared-shape compromise, not a wider write path.
|
|
1653
|
-
const requested = section as { backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa" | null; model?: string };
|
|
1675
|
+
const requested = section as { backend?: "openai" | "anthropic" | "xai" | "gemini" | "exa" | null; model?: string; anthropicInstance?: AnthropicInstanceId | null };
|
|
1654
1676
|
const override = { ...next[field] } as NonNullable<OcxClaudeCodeConfig[typeof field]>;
|
|
1677
|
+
if (requested.anthropicInstance === null) delete override.anthropicInstance;
|
|
1678
|
+
else if (isAnthropicInstanceId(requested.anthropicInstance)) override.anthropicInstance = requested.anthropicInstance;
|
|
1655
1679
|
if (requested.backend === null) delete override.backend;
|
|
1656
1680
|
else if (requested.backend !== undefined) override.backend = requested.backend as never;
|
|
1657
1681
|
if (requested.model === "") delete override.model;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { isAnthropicOAuthInstance, configuredAnthropicInstance } from "../../providers/anthropic-instance";
|
|
1
2
|
import type { OcxConfig } from "../../types";
|
|
2
|
-
import {
|
|
3
|
+
import { effectiveAnthropicAccountThresholdForInstance, parseAnthropicAccountThreshold } from "../../oauth/anthropic-account-threshold";
|
|
3
4
|
import { OAUTH_PROVIDERS } from "../../oauth";
|
|
4
|
-
import {
|
|
5
|
+
import { setAnthropicAccountThresholdForInstance } from "../../oauth/store";
|
|
5
6
|
import { jsonResponse } from "../auth-cors";
|
|
6
7
|
import { readManagementJsonBodyOr } from "./body";
|
|
7
8
|
|
|
@@ -10,18 +11,20 @@ export async function handleAnthropicAccountThreshold(req: Request, config: OcxC
|
|
|
10
11
|
const body = await readManagementJsonBodyOr(req, {});
|
|
11
12
|
if (!body || typeof body !== "object" || Array.isArray(body)) return jsonResponse({ error: "body must be an object" }, 400);
|
|
12
13
|
const fields = body as Record<string, unknown>;
|
|
13
|
-
const
|
|
14
|
-
|
|
14
|
+
const provider = fields.provider;
|
|
15
|
+
if (typeof provider !== "string" || !isAnthropicOAuthInstance(provider)) return jsonResponse({ error: "account threshold requires Anthropic OAuth" }, 400);
|
|
16
|
+
const definition = OAUTH_PROVIDERS[provider];
|
|
17
|
+
const effectiveProvider = config.providers[provider]
|
|
15
18
|
?? definition?.resolveProviderConfig?.(config) ?? definition?.providerConfig;
|
|
16
|
-
if (
|
|
19
|
+
if (effectiveProvider?.authMode !== "oauth" || (provider === "anthropic2" && !configuredAnthropicInstance(config, provider))) {
|
|
17
20
|
return jsonResponse({ error: "account threshold requires Anthropic OAuth" }, 400);
|
|
18
21
|
}
|
|
19
22
|
if (typeof fields.accountId !== "string" || !fields.accountId.trim()) return jsonResponse({ error: "missing accountId" }, 400);
|
|
20
23
|
const threshold = parseAnthropicAccountThreshold(fields.threshold);
|
|
21
24
|
if (fields.threshold !== null && threshold === null) return jsonResponse({ error: "threshold must be an integer 0-100 or null" }, 400);
|
|
22
|
-
if (!await
|
|
23
|
-
return jsonResponse({ ok: true, provider
|
|
25
|
+
if (!await setAnthropicAccountThresholdForInstance(provider, fields.accountId, threshold)) return jsonResponse({ error: "account not found" }, 404);
|
|
26
|
+
return jsonResponse({ ok: true, provider, accountId: fields.accountId,
|
|
24
27
|
autoSwitchThresholdOverride: threshold,
|
|
25
|
-
effectiveAutoSwitchThreshold:
|
|
26
|
-
autoSwitchThreshold:
|
|
28
|
+
effectiveAutoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider, config, { autoSwitchThresholdOverride: threshold ?? undefined }),
|
|
29
|
+
autoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider, config) });
|
|
27
30
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { OcxConfig } from "../../types";
|
|
2
|
+
import type { AnthropicAccountPoolConfig } from "../../types/anthropic-account-pool";
|
|
3
|
+
import { configuredAnthropicInstance, type AnthropicInstanceId } from "../../providers/anthropic-instance";
|
|
4
|
+
import { rawAnthropicAccountPool, resolveAnthropicAccountPoolConfig } from "../../oauth/anthropic-pool-config";
|
|
5
|
+
import { readConfigFileSnapshot, validateConfigCandidate } from "../../config/diagnostics";
|
|
6
|
+
import { ConfigWritePublishedError } from "../../config/persist-unlocked";
|
|
7
|
+
import { mutatePersistedConfig } from "../../config";
|
|
8
|
+
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
9
|
+
import { jsonResponse } from "../auth-cors";
|
|
10
|
+
|
|
11
|
+
/** Required instance; never synthesizes a deleted B provider row. */
|
|
12
|
+
export function writeAnthropicPoolSettings(config: OcxConfig, instance: AnthropicInstanceId, value: AnthropicAccountPoolConfig): void {
|
|
13
|
+
if (instance === "anthropic") config.anthropicAccountPool = value;
|
|
14
|
+
else {
|
|
15
|
+
const row = config.providers[instance];
|
|
16
|
+
if (!configuredAnthropicInstance(config, instance)) throw new Error("Anthropic pool instance is no longer configured");
|
|
17
|
+
row!.anthropicAccountPool = value;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Both Anthropic writers share publication-aware recovery from the atomic mutation owner.
|
|
23
|
+
* Recovery covers only the durable write. Once the file holds the new value, a stale live row
|
|
24
|
+
* or a failed reconcile is reported as saved with the fixed bookkeeping warning, never a 500.
|
|
25
|
+
*/
|
|
26
|
+
export function persistAnthropicPoolPatch(
|
|
27
|
+
config: OcxConfig,
|
|
28
|
+
instance: AnthropicInstanceId,
|
|
29
|
+
patch: (target: OcxConfig) => { changed: boolean; value: NonNullable<OcxConfig["anthropicAccountPool"]> },
|
|
30
|
+
): { status: "saved"; warning?: "config_bookkeeping_failed" } | { status: "failed"; response: Response } {
|
|
31
|
+
const before = readConfigFileSnapshot();
|
|
32
|
+
const unknown = () => ({ status: "failed" as const, response: jsonResponse({
|
|
33
|
+
error: "Pool settings save state is unknown; reload settings before editing again",
|
|
34
|
+
code: "config_save_state_unknown",
|
|
35
|
+
}, 409) });
|
|
36
|
+
const publish = (value: AnthropicAccountPoolConfig, warned: boolean): { status: "saved"; warning?: "config_bookkeeping_failed" } => {
|
|
37
|
+
let warning = warned;
|
|
38
|
+
try { writeAnthropicPoolSettings(config, instance, value); } catch { warning = true; }
|
|
39
|
+
try { reconcileLiveStateStores(); } catch { warning = true; }
|
|
40
|
+
return warning ? { status: "saved", warning: "config_bookkeeping_failed" } : { status: "saved" };
|
|
41
|
+
};
|
|
42
|
+
let durable: AnthropicAccountPoolConfig | undefined;
|
|
43
|
+
try {
|
|
44
|
+
const saved = mutatePersistedConfig(target => {
|
|
45
|
+
if (instance === "anthropic2" && !configuredAnthropicInstance(target, instance)) {
|
|
46
|
+
throw new Error("Anthropic pool instance is no longer configured");
|
|
47
|
+
}
|
|
48
|
+
return patch(target);
|
|
49
|
+
});
|
|
50
|
+
if (saved.status === "unavailable") return unknown();
|
|
51
|
+
durable = saved.value;
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const persisted = readConfigFileSnapshot();
|
|
54
|
+
if (persisted.diagnostics.source !== "file" || persisted.raw === undefined) return unknown();
|
|
55
|
+
// File reads may salvage hand edits; recovery success requires a strict, authoritative document.
|
|
56
|
+
let validated: ReturnType<typeof validateConfigCandidate>;
|
|
57
|
+
try { validated = validateConfigCandidate(JSON.parse(persisted.raw.replace(/^\uFEFF/, ""))); }
|
|
58
|
+
catch { return unknown(); }
|
|
59
|
+
if (!validated.ok) return unknown();
|
|
60
|
+
const current = validated.config;
|
|
61
|
+
if (instance === "anthropic2" && !configuredAnthropicInstance(current, instance)) return unknown();
|
|
62
|
+
let expected: AnthropicAccountPoolConfig;
|
|
63
|
+
try { expected = patch(structuredClone(current)).value; } catch { return unknown(); }
|
|
64
|
+
const matches = JSON.stringify(rawAnthropicAccountPool(current, instance)) === JSON.stringify(expected);
|
|
65
|
+
// A failed live write or reconcile must not turn confirmed publication back into a rollback.
|
|
66
|
+
if (matches) return publish(resolveAnthropicAccountPoolConfig(current, instance), true);
|
|
67
|
+
if (error instanceof ConfigWritePublishedError || persisted.raw !== before.raw) return unknown();
|
|
68
|
+
return { status: "failed", response: jsonResponse({ error: "Pool settings could not be saved" }, 500) };
|
|
69
|
+
}
|
|
70
|
+
if (durable === undefined) return unknown();
|
|
71
|
+
return publish(durable, false);
|
|
72
|
+
}
|
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
import { jsonResponse } from "../auth-cors";
|
|
15
15
|
import type { ManagementContext } from "./context";
|
|
16
16
|
import { getValidAccessSnapshotForAccount } from "../../oauth";
|
|
17
|
-
import {
|
|
17
|
+
import { configuredAnthropicInstance, isAnthropicInstanceId, type AnthropicInstanceId } from "../../providers/anthropic-instance";
|
|
18
|
+
import { captureAnthropicPhysicalSendOwnership, anthropicPhysicalSendOwnershipIsCurrent } from "../../oauth/anthropic-send-ownership";
|
|
19
|
+
import { anthropicCooldownRecoveryFor } from "../../providers/quota/anthropic-cooldown-recovery";
|
|
20
|
+
import { captureProviderAccountQuotaEpoch } from "../../providers/quota/account-cache";
|
|
21
|
+
import { captureOAuthAccountSelection, getAccountSet, getAccountCredentialWithStatus, listAccounts } from "../../oauth/store";
|
|
18
22
|
import {
|
|
19
23
|
ANTHROPIC_RESET_GRANT_ID_RE,
|
|
20
24
|
AnthropicResetGrantError,
|
|
@@ -28,19 +32,20 @@ import {
|
|
|
28
32
|
AnthropicResetLedgerError,
|
|
29
33
|
anthropicOrgDigest,
|
|
30
34
|
anthropicResetOperationExists,
|
|
35
|
+
anthropicResetJournalPathForInstance,
|
|
31
36
|
beginAnthropicResetOperation,
|
|
32
37
|
pendingAnthropicResetOperation,
|
|
33
38
|
releaseAnthropicResetLease,
|
|
34
39
|
settleAnthropicResetOperation,
|
|
35
40
|
} from "../../providers/anthropic-reset-grant-ledger";
|
|
36
41
|
|
|
37
|
-
const PROVIDER = "anthropic";
|
|
38
42
|
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
39
43
|
|
|
40
44
|
const MESSAGES = {
|
|
41
45
|
method_not_allowed: "Method not allowed",
|
|
42
46
|
session_required: "Spending a reset requires a dashboard session.",
|
|
43
47
|
invalid_json: "Request body must be JSON.",
|
|
48
|
+
invalid_provider: "No matching builtin Anthropic OAuth provider.",
|
|
44
49
|
invalid_grant_id: "grantId is missing or malformed.",
|
|
45
50
|
invalid_operation_id: "operationId must be a UUIDv4.",
|
|
46
51
|
no_account: "No matching Anthropic OAuth account.",
|
|
@@ -60,6 +65,9 @@ const MESSAGES = {
|
|
|
60
65
|
type ErrorCode = keyof typeof MESSAGES;
|
|
61
66
|
|
|
62
67
|
export interface AnthropicResetGrantRouteDeps {
|
|
68
|
+
provider?: AnthropicInstanceId;
|
|
69
|
+
isCurrent?: () => boolean;
|
|
70
|
+
forProvider?: (provider: AnthropicInstanceId) => AnthropicResetGrantRouteDeps;
|
|
63
71
|
fetchFn?: typeof globalThis.fetch;
|
|
64
72
|
journalPath?: string;
|
|
65
73
|
now?: () => number;
|
|
@@ -68,25 +76,64 @@ export interface AnthropicResetGrantRouteDeps {
|
|
|
68
76
|
accessTokenFor: (accountId: string) => Promise<string>;
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
79
|
+
function defaultDepsFor(ctx: ManagementContext, provider: AnthropicInstanceId): AnthropicResetGrantRouteDeps | null {
|
|
80
|
+
// One source: the live management config. A keeps its legacy reach without a providers row;
|
|
81
|
+
// B requires its configured builtin row. A fresh file load here would refuse on any drift.
|
|
82
|
+
const live = ctx.config;
|
|
83
|
+
const target = live.providers?.[provider];
|
|
84
|
+
if (target?.disabled || target && target.authMode !== "oauth" || configuredAnthropicInstance(live, provider) !== provider) return null;
|
|
85
|
+
const identity = JSON.stringify(target ?? null);
|
|
86
|
+
let owner: ReturnType<typeof captureAnthropicPhysicalSendOwnership> = null;
|
|
87
|
+
const targetCurrent = () => configuredAnthropicInstance(live, provider) === provider
|
|
88
|
+
&& JSON.stringify(live.providers?.[provider] ?? null) === identity;
|
|
89
|
+
return {
|
|
90
|
+
// Only the journal path: the ledger's numeric `now` option is not the deps clock function.
|
|
91
|
+
provider, journalPath: anthropicResetJournalPathForInstance(provider),
|
|
92
|
+
listAccountIds: () => listAccounts(provider).map(account => account.id),
|
|
93
|
+
activeAccountId: () => captureOAuthAccountSelection(provider)?.accountId ?? null,
|
|
94
|
+
isCurrent: () => {
|
|
95
|
+
if (!targetCurrent() || !owner || !anthropicPhysicalSendOwnershipIsCurrent(owner)) return false;
|
|
96
|
+
const row = getAccountCredentialWithStatus(provider, owner.accountId);
|
|
97
|
+
return !!row && !row.paused && !row.needsReauth;
|
|
98
|
+
},
|
|
99
|
+
accessTokenFor: async accountId => {
|
|
100
|
+
const initial = getAccountSet(provider)?.accounts.find(row => row.id === accountId);
|
|
101
|
+
const recovery = anthropicCooldownRecoveryFor(provider);
|
|
102
|
+
const incarnation = recovery.reserveAnthropicAccountIncarnation(accountId);
|
|
103
|
+
const epoch = captureProviderAccountQuotaEpoch(provider);
|
|
104
|
+
const snapshot = await getValidAccessSnapshotForAccount(provider, accountId, { requireUsableAccount: true });
|
|
105
|
+
const current = getAccountSet(provider)?.accounts.find(row => row.id === accountId);
|
|
106
|
+
if (!initial || !current || initial.loginId !== current.loginId || initial.addedAt !== current.addedAt
|
|
107
|
+
|| recovery.anthropicAccountIncarnation(accountId) !== incarnation || captureProviderAccountQuotaEpoch(provider) !== epoch) throw new Error("reset account replaced");
|
|
108
|
+
owner = captureAnthropicPhysicalSendOwnership(snapshot);
|
|
109
|
+
if (!targetCurrent() || !owner) throw new Error("reset owner changed");
|
|
110
|
+
return snapshot.accessToken;
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function selectDeps(ctx: ManagementContext, requested: unknown, deps?: AnthropicResetGrantRouteDeps): AnthropicResetGrantRouteDeps | null {
|
|
116
|
+
const provider = requested === undefined || requested === null ? "anthropic" : requested;
|
|
117
|
+
if (!isAnthropicInstanceId(provider)) return null;
|
|
118
|
+
const selected = deps?.forProvider?.(provider) ?? deps;
|
|
119
|
+
// Legacy injected dependencies are A-only; B must explicitly bind its own journal.
|
|
120
|
+
if (selected) return (selected.provider ?? "anthropic") === provider
|
|
121
|
+
? { journalPath: anthropicResetJournalPathForInstance(provider), ...selected, provider } : null;
|
|
122
|
+
return defaultDepsFor(ctx, provider);
|
|
123
|
+
}
|
|
77
124
|
|
|
78
125
|
function fail(ctx: ManagementContext, status: number, code: ErrorCode, extra: Record<string, unknown> = {}): Response {
|
|
79
126
|
return jsonResponse({ error: { code, message: MESSAGES[code], ...extra } }, status, ctx.req, ctx.config);
|
|
80
127
|
}
|
|
81
128
|
|
|
82
|
-
function ledgerFailure(ctx: ManagementContext, error: unknown): Response {
|
|
83
|
-
if (error instanceof AnthropicResetLedgerError && error.code === "busy") return fail(ctx, 503, "ledger_busy");
|
|
84
|
-
return fail(ctx, 503, "ledger_unavailable");
|
|
129
|
+
function ledgerFailure(ctx: ManagementContext, error: unknown, extra: Record<string, unknown> = {}): Response {
|
|
130
|
+
if (error instanceof AnthropicResetLedgerError && error.code === "busy") return fail(ctx, 503, "ledger_busy", extra);
|
|
131
|
+
return fail(ctx, 503, "ledger_unavailable", extra);
|
|
85
132
|
}
|
|
86
133
|
|
|
87
|
-
function readFailure(ctx: ManagementContext, error: unknown): Response {
|
|
88
|
-
if (error instanceof AnthropicResetGrantError && error.code === "auth") return fail(ctx, 401, "auth_failed");
|
|
89
|
-
return fail(ctx, 502, "upstream_unavailable");
|
|
134
|
+
function readFailure(ctx: ManagementContext, error: unknown, extra: Record<string, unknown> = {}): Response {
|
|
135
|
+
if (error instanceof AnthropicResetGrantError && error.code === "auth") return fail(ctx, 401, "auth_failed", extra);
|
|
136
|
+
return fail(ctx, 502, "upstream_unavailable", extra);
|
|
90
137
|
}
|
|
91
138
|
|
|
92
139
|
function resolveAccountId(deps: AnthropicResetGrantRouteDeps, requested: unknown): string | null {
|
|
@@ -107,16 +154,19 @@ async function tokenFor(deps: AnthropicResetGrantRouteDeps, accountId: string):
|
|
|
107
154
|
}
|
|
108
155
|
|
|
109
156
|
async function handleRead(ctx: ManagementContext, deps: AnthropicResetGrantRouteDeps): Promise<Response> {
|
|
157
|
+
const receipt = { provider: deps.provider };
|
|
110
158
|
const accountId = resolveAccountId(deps, ctx.url.searchParams.get("accountId") ?? undefined);
|
|
111
|
-
if (!accountId) return fail(ctx, 400, "no_account");
|
|
159
|
+
if (!accountId) return fail(ctx, 400, "no_account", receipt);
|
|
112
160
|
const accessToken = await tokenFor(deps, accountId);
|
|
113
|
-
if (!accessToken) return fail(ctx, 401, "auth_failed");
|
|
161
|
+
if (!accessToken) return fail(ctx, 401, "auth_failed", receipt);
|
|
162
|
+
if (deps.isCurrent?.() === false) return fail(ctx, 401, "auth_failed", receipt);
|
|
114
163
|
let status;
|
|
115
164
|
try {
|
|
116
165
|
status = await fetchAnthropicResetGrantStatus({ accessToken, fetchFn: deps.fetchFn });
|
|
117
166
|
} catch (error) {
|
|
118
|
-
return readFailure(ctx, error);
|
|
167
|
+
return readFailure(ctx, error, receipt);
|
|
119
168
|
}
|
|
169
|
+
if (deps.isCurrent?.() === false) return fail(ctx, 401, "auth_failed", receipt);
|
|
120
170
|
let pendingOperation = null;
|
|
121
171
|
let journalAvailable = true;
|
|
122
172
|
try {
|
|
@@ -124,10 +174,10 @@ async function handleRead(ctx: ManagementContext, deps: AnthropicResetGrantRoute
|
|
|
124
174
|
} catch {
|
|
125
175
|
journalAvailable = false;
|
|
126
176
|
}
|
|
127
|
-
return jsonResponse({ accountId, ...status, pendingOperation, journalAvailable }, 200, ctx.req, ctx.config);
|
|
177
|
+
return jsonResponse({ provider: deps.provider, accountId, ...status, pendingOperation, journalAvailable }, 200, ctx.req, ctx.config);
|
|
128
178
|
}
|
|
129
179
|
|
|
130
|
-
async function handleConsume(ctx: ManagementContext,
|
|
180
|
+
async function handleConsume(ctx: ManagementContext, injected?: AnthropicResetGrantRouteDeps): Promise<Response> {
|
|
131
181
|
if (ctx.principal !== "gui-session") return fail(ctx, 403, "session_required");
|
|
132
182
|
let body: Record<string, unknown>;
|
|
133
183
|
try {
|
|
@@ -137,21 +187,27 @@ async function handleConsume(ctx: ManagementContext, deps: AnthropicResetGrantRo
|
|
|
137
187
|
} catch {
|
|
138
188
|
return fail(ctx, 400, "invalid_json");
|
|
139
189
|
}
|
|
190
|
+
const deps = selectDeps(ctx, body.provider, injected);
|
|
191
|
+
if (!deps) return fail(ctx, 400, "invalid_provider");
|
|
192
|
+
const provider = deps.provider;
|
|
193
|
+
const operationReceipt = { provider };
|
|
194
|
+
const scopedFail = (status: number, code: ErrorCode, extra: Record<string, unknown> = {}) => fail(ctx, status, code, { ...operationReceipt, ...extra });
|
|
140
195
|
const { grantId, operationId } = body;
|
|
141
|
-
if (typeof grantId !== "string" || !ANTHROPIC_RESET_GRANT_ID_RE.test(grantId)) return
|
|
142
|
-
if (typeof operationId !== "string" || !UUID_V4_RE.test(operationId)) return
|
|
143
|
-
if (typeof body.accountId !== "string") return
|
|
196
|
+
if (typeof grantId !== "string" || !ANTHROPIC_RESET_GRANT_ID_RE.test(grantId)) return scopedFail(400, "invalid_grant_id");
|
|
197
|
+
if (typeof operationId !== "string" || !UUID_V4_RE.test(operationId)) return scopedFail(400, "invalid_operation_id");
|
|
198
|
+
if (typeof body.accountId !== "string") return scopedFail(400, "no_account");
|
|
144
199
|
const accountId = resolveAccountId(deps, body.accountId);
|
|
145
|
-
if (!accountId) return
|
|
200
|
+
if (!accountId) return scopedFail(400, "no_account");
|
|
146
201
|
|
|
147
202
|
const accessToken = await tokenFor(deps, accountId);
|
|
148
|
-
if (!accessToken) return
|
|
203
|
+
if (!accessToken || deps.isCurrent?.() === false) return scopedFail(401, "auth_failed");
|
|
149
204
|
let organizationUuid: string;
|
|
150
205
|
try {
|
|
151
206
|
organizationUuid = await fetchAnthropicOrganizationUuid({ accessToken, fetchFn: deps.fetchFn });
|
|
152
207
|
} catch (error) {
|
|
153
|
-
return readFailure(ctx, error);
|
|
208
|
+
return readFailure(ctx, error, operationReceipt);
|
|
154
209
|
}
|
|
210
|
+
if (deps.isCurrent?.() === false) return scopedFail(401, "auth_failed");
|
|
155
211
|
const ledger = { journalPath: deps.journalPath, now: deps.now?.() };
|
|
156
212
|
|
|
157
213
|
// A new operation must pass the spend gate on a fresh read. A same-id retry of
|
|
@@ -161,39 +217,40 @@ async function handleConsume(ctx: ManagementContext, deps: AnthropicResetGrantRo
|
|
|
161
217
|
try {
|
|
162
218
|
known = anthropicResetOperationExists(operationId, ledger);
|
|
163
219
|
} catch (error) {
|
|
164
|
-
return ledgerFailure(ctx, error);
|
|
220
|
+
return ledgerFailure(ctx, error, operationReceipt);
|
|
165
221
|
}
|
|
166
222
|
if (!known) {
|
|
167
223
|
let status;
|
|
168
224
|
try {
|
|
169
225
|
status = await fetchAnthropicResetGrantStatus({ accessToken, fetchFn: deps.fetchFn });
|
|
170
226
|
} catch (error) {
|
|
171
|
-
return readFailure(ctx, error);
|
|
227
|
+
return readFailure(ctx, error, operationReceipt);
|
|
172
228
|
}
|
|
173
229
|
const blocker = anthropicResetGrantBlocker(status, grantId);
|
|
174
|
-
if (blocker) return
|
|
230
|
+
if (blocker) return scopedFail(409, "grant_not_usable", { reason: blocker });
|
|
175
231
|
}
|
|
176
232
|
|
|
177
233
|
let begin;
|
|
234
|
+
if (deps.isCurrent?.() === false) return scopedFail(401, "auth_failed");
|
|
178
235
|
try {
|
|
179
236
|
begin = beginAnthropicResetOperation(
|
|
180
237
|
{ operationId, accountId, grantId, orgDigest: anthropicOrgDigest(organizationUuid) },
|
|
181
238
|
{ journalPath: deps.journalPath, now: deps.now?.() },
|
|
182
239
|
);
|
|
183
240
|
} catch (error) {
|
|
184
|
-
return ledgerFailure(ctx, error);
|
|
241
|
+
return ledgerFailure(ctx, error, operationReceipt);
|
|
185
242
|
}
|
|
186
243
|
switch (begin.kind) {
|
|
187
244
|
case "replay":
|
|
188
245
|
return jsonResponse(
|
|
189
|
-
{ code: begin.code, replayed: true, resetsLeft: begin.resetsLeft, accountId, grantId, operationId },
|
|
246
|
+
{ provider, code: begin.code, replayed: true, resetsLeft: begin.resetsLeft, accountId, grantId, operationId },
|
|
190
247
|
200, ctx.req, ctx.config,
|
|
191
248
|
);
|
|
192
|
-
case "identity-mismatch": return
|
|
193
|
-
case "in-flight": return
|
|
194
|
-
case "expired": return
|
|
195
|
-
case "unresolved-prior": return
|
|
196
|
-
case "capacity": return
|
|
249
|
+
case "identity-mismatch": return scopedFail(409, "operation_identity_mismatch");
|
|
250
|
+
case "in-flight": return scopedFail(409, "in_flight");
|
|
251
|
+
case "expired": return scopedFail(409, "unknown_outcome_expired");
|
|
252
|
+
case "unresolved-prior": return scopedFail(409, "unresolved_prior_operation", { pendingOperationId: begin.operationId });
|
|
253
|
+
case "capacity": return scopedFail(503, "ledger_capacity");
|
|
197
254
|
case "execute": break;
|
|
198
255
|
}
|
|
199
256
|
|
|
@@ -206,7 +263,7 @@ async function handleConsume(ctx: ManagementContext, deps: AnthropicResetGrantRo
|
|
|
206
263
|
try {
|
|
207
264
|
releaseAnthropicResetLease(operationId, { journalPath: deps.journalPath, now: deps.now?.() });
|
|
208
265
|
} catch { /* the lease expires on its own */ }
|
|
209
|
-
return
|
|
266
|
+
return scopedFail(502, "unknown_outcome", { operationId, grantId, accountId });
|
|
210
267
|
}
|
|
211
268
|
let settled;
|
|
212
269
|
try {
|
|
@@ -216,13 +273,14 @@ async function handleConsume(ctx: ManagementContext, deps: AnthropicResetGrantRo
|
|
|
216
273
|
);
|
|
217
274
|
} catch {
|
|
218
275
|
// Fail closed: the caller must not read this as a durable result.
|
|
219
|
-
return
|
|
276
|
+
return scopedFail(500, "journal_write_failed", { operationId, grantId, accountId });
|
|
220
277
|
}
|
|
221
278
|
// Report what the journal holds. An overlapping same-id attempt may have
|
|
222
279
|
// settled first; its answer is the canonical one.
|
|
223
280
|
const replayed = settled.code !== answer.code || settled.resetsLeft !== answer.resetsLeft;
|
|
224
281
|
return jsonResponse(
|
|
225
282
|
{
|
|
283
|
+
provider,
|
|
226
284
|
code: settled.code,
|
|
227
285
|
replayed,
|
|
228
286
|
resetsLeft: settled.resetsLeft,
|
|
@@ -237,12 +295,13 @@ async function handleConsume(ctx: ManagementContext, deps: AnthropicResetGrantRo
|
|
|
237
295
|
|
|
238
296
|
export async function handleAnthropicResetGrantRoutes(
|
|
239
297
|
ctx: ManagementContext,
|
|
240
|
-
deps
|
|
298
|
+
deps?: AnthropicResetGrantRouteDeps,
|
|
241
299
|
): Promise<Response | null> {
|
|
242
300
|
const { pathname } = ctx.url;
|
|
243
301
|
if (pathname === "/api/anthropic/reset-grants") {
|
|
244
302
|
if (ctx.req.method !== "GET") return fail(ctx, 405, "method_not_allowed");
|
|
245
|
-
|
|
303
|
+
const selected = selectDeps(ctx, ctx.url.searchParams.get("provider"), deps);
|
|
304
|
+
return selected ? handleRead(ctx, selected) : fail(ctx, 400, "invalid_provider");
|
|
246
305
|
}
|
|
247
306
|
if (pathname === "/api/anthropic/reset-grants/consume") {
|
|
248
307
|
if (ctx.req.method !== "POST") return fail(ctx, 405, "method_not_allowed");
|