@bitkyc08/opencodex 2.88.0 → 2.89.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 +11 -1
- package/bin/ocx.mjs +15 -4
- package/gui/dist/assets/App-BnRSujOq.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-7z1COnmz.js} +1 -1
- package/gui/dist/assets/{index-B5bsvxor.js → index-uchD-ngf.js} +13 -13
- package/gui/dist/assets/{quota-summary-D8y2wTsB.js → quota-summary-BY_10IEz.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/anthropic/cache-retention.ts +117 -0
- package/src/adapters/anthropic/passthrough.ts +7 -4
- package/src/adapters/anthropic.ts +10 -3
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +35 -29
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/passthrough.ts +17 -12
- package/src/adapters/openai-responses/reasoning.ts +68 -1
- package/src/adapters/registry.ts +1 -1
- package/src/adapters/upstream-http-error.ts +3 -2
- package/src/adapters/zed.ts +7 -4
- package/src/bridge/internal.ts +11 -1
- package/src/bridge/response-json.ts +2 -2
- package/src/bridge/sse.ts +10 -4
- package/src/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +5 -0
- package/src/cli/account-api.ts +13 -1
- package/src/cli/account-extended.ts +17 -5
- package/src/cli/account-key-quota.ts +4 -1
- package/src/cli/account.ts +1 -1
- package/src/cli/capabilities-agents-routing.ts +45 -8
- package/src/cli/capabilities-base.ts +5 -5
- package/src/cli/capabilities-provider-models.ts +5 -4
- package/src/cli/combo-result.ts +8 -2
- package/src/cli/combo.ts +17 -0
- package/src/cli/help.ts +1 -1
- package/src/cli/message-command.ts +4 -1
- package/src/cli/message-remote-args.ts +69 -0
- package/src/cli/message-remote-command.ts +77 -0
- package/src/cli/provider-runtime.ts +69 -1
- package/src/cli/registry.ts +3 -3
- package/src/codex/account-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +30 -3
- package/src/codex/auth-api/account-list.ts +3 -0
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-context.ts +9 -4
- package/src/codex/catalog/aggregation.ts +15 -0
- package/src/codex/catalog/auto-review.ts +2 -1
- package/src/codex/catalog/build-entries.ts +17 -7
- package/src/codex/catalog/derive-entry.ts +3 -1
- package/src/codex/catalog/metadata.ts +2 -0
- package/src/codex/catalog/model-visibility.ts +0 -2
- package/src/codex/catalog/ownership.ts +71 -0
- package/src/codex/catalog/parsing.ts +12 -3
- package/src/codex/catalog/retained-sync.ts +2 -1
- package/src/codex/catalog/routed-removal.ts +4 -4
- package/src/codex/convergence.ts +2 -1
- package/src/codex/low-quota-desktop-notifier.ts +125 -0
- package/src/codex/low-quota-protection.ts +57 -31
- package/src/codex/main-account-cache.ts +77 -3
- package/src/codex/main-account.ts +6 -2
- package/src/codex/native-residue.ts +2 -3
- package/src/codex/quota.ts +26 -2
- package/src/combos/failover.ts +2 -1
- package/src/combos/jev-model-backend.ts +30 -7
- package/src/combos/jev-model-request.ts +20 -0
- package/src/combos/jev-quota-activation.ts +153 -0
- package/src/combos/jev-quota-config.ts +62 -0
- package/src/combos/jev-quota-route.ts +26 -0
- package/src/combos/jev-quota.ts +88 -0
- package/src/combos/jev.ts +25 -12
- package/src/combos/types.ts +27 -0
- package/src/compatibility/index.ts +3 -0
- package/src/compatibility/openai-responses.ts +8 -1
- package/src/compatibility/opengateway-responses.ts +61 -0
- package/src/config/account-auto-pause-revisions.ts +136 -0
- package/src/config/diagnostics.ts +13 -0
- package/src/config/live-reconcile.ts +9 -0
- package/src/config/persist-unlocked.ts +10 -1
- package/src/config/removed-opencode-free.ts +135 -0
- package/src/config/schema/account-auto-pause.ts +14 -0
- package/src/config/schema/config-schema.ts +15 -0
- package/src/config/schema/leaf-validators.ts +7 -0
- package/src/config.ts +10 -3
- package/src/generated/compatibility-version.json +344 -144
- package/src/images/loop.ts +32 -9
- package/src/lib/account-auto-pause-response.ts +16 -0
- package/src/lib/bun-runtime.ts +18 -1
- package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
- package/src/lib/bun-transpiler-cache-scope.mjs +68 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/state-store-registrations.ts +25 -0
- package/src/link/ssh-argv.ts +3 -0
- package/src/messaging/envelope.ts +6 -3
- package/src/messaging/remote-auth.ts +83 -0
- package/src/messaging/remote-bridge.ts +270 -0
- package/src/messaging/remote-contract.ts +108 -0
- package/src/messaging/remote-enrollment-recovery.ts +59 -0
- package/src/messaging/remote-enrollment.ts +244 -0
- package/src/messaging/remote-envelope.ts +40 -0
- package/src/messaging/remote-files.ts +117 -0
- package/src/messaging/remote-owner.ts +169 -0
- package/src/messaging/remote-ports.ts +56 -0
- package/src/messaging/remote-process.ts +131 -0
- package/src/messaging/remote-replies.ts +98 -0
- package/src/messaging/remote-rpc-admission.ts +32 -0
- package/src/messaging/remote-send.ts +72 -0
- package/src/messaging/remote-store.ts +111 -0
- package/src/messaging/remote-tunnels.ts +65 -0
- package/src/messaging/rpc.ts +13 -1
- package/src/oauth/account-auto-pause-policy.ts +66 -0
- package/src/oauth/account-auto-pause.ts +171 -0
- package/src/oauth/account-quota-rank.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +6 -0
- package/src/oauth/anthropic-model-family.ts +8 -0
- package/src/oauth/anthropic-model-quota.ts +2 -6
- package/src/oauth/api-key-pool-preserve.ts +37 -0
- package/src/oauth/index.ts +5 -35
- package/src/oauth/store.ts +35 -8
- package/src/oauth/types.ts +2 -0
- package/src/plugins/upstream-hooks.ts +14 -1
- package/src/providers/api-key-pause.ts +32 -0
- package/src/providers/api-key-resolve.ts +10 -4
- package/src/providers/api-key-selection.ts +6 -1
- package/src/providers/api-keys.ts +8 -4
- package/src/providers/claude-cli-usage.ts +5 -0
- package/src/providers/key-failover.ts +11 -11
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/opencode-zen-rate-limit.ts +6 -72
- package/src/providers/quota/account-cache.ts +15 -1
- package/src/providers/quota/anthropic-account-quota.ts +2 -1
- package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
- package/src/providers/quota/anthropic-decision-windows.ts +47 -0
- package/src/providers/quota/antigravity.ts +39 -6
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +3 -3
- package/src/providers/quota-decision-publication.ts +77 -0
- package/src/providers/quota-decision-snapshot.ts +145 -0
- package/src/providers/quota-routing-cache.ts +3 -0
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +3 -2
- package/src/providers/registry/entries-extended.ts +8 -52
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/responses/parser.ts +18 -10
- package/src/responses/web-search-replay.ts +138 -0
- package/src/routing/compatibility/behavior.ts +4 -1
- package/src/server/auth-cors.ts +7 -0
- package/src/server/background-lifecycle.ts +2 -1
- package/src/server/chat-native.ts +28 -13
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +10 -1
- package/src/server/management/provider-completion-test.ts +289 -0
- package/src/server/management/provider-routes.ts +9 -1
- package/src/server/management/route-registry.ts +2 -0
- package/src/server/messages-native.ts +18 -4
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +7 -2
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-dispatch.ts +77 -28
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +3 -1
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +8 -5
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-codex-account.ts +7 -1
- package/src/server/responses/core-combo.ts +31 -5
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +35 -4
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +30 -33
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-transport.ts +32 -3
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +16 -3
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/startup-action-control.ts +2 -2
- package/src/server/startup-health-cache.ts +2 -2
- package/src/server/windows-tray-control.ts +2 -2
- package/src/service/definition.ts +9 -1
- package/src/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/provider.ts +9 -5
- package/src/types/request.ts +20 -2
- package/src/types.ts +1 -0
- package/src/usage/cost.ts +17 -1
- package/src/usage/expected-prices.ts +3 -1
- package/src/usage/jev-stats.ts +4 -0
- package/src/usage/log.ts +2 -0
- package/src/usage/summary.ts +7 -0
- package/src/web-search/loop.ts +37 -11
- package/src/web-search/progress-stream.ts +4 -1
- package/src/web-search/run-turn-loop.ts +9 -3
- package/src/web-search/sidecar-search-replay.ts +96 -0
- package/gui/dist/assets/App-CHC89EXQ.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST } from "./openai-responses";
|
|
2
|
+
import { OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST } from "./opengateway-responses";
|
|
2
3
|
import type { CompatibilityManifestV1 } from "./manifest";
|
|
3
4
|
|
|
4
5
|
export {
|
|
@@ -16,9 +17,11 @@ export type {
|
|
|
16
17
|
CompatibilitySubjectV1,
|
|
17
18
|
} from "./manifest";
|
|
18
19
|
export { OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST } from "./openai-responses";
|
|
20
|
+
export { OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST } from "./opengateway-responses";
|
|
19
21
|
|
|
20
22
|
export const COMPATIBILITY_MANIFESTS: readonly CompatibilityManifestV1[] = Object.freeze([
|
|
21
23
|
OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST,
|
|
24
|
+
OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST,
|
|
22
25
|
]);
|
|
23
26
|
|
|
24
27
|
export function getCompatibilityManifest(id: string): CompatibilityManifestV1 | undefined {
|
|
@@ -9,7 +9,7 @@ const FIXTURE_ID = "openai-codex-forward-gpt56-sol-v1";
|
|
|
9
9
|
export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManifest({
|
|
10
10
|
schemaVersion: 1,
|
|
11
11
|
id: "openai.codex-forward.gpt-5-6-sol.responses",
|
|
12
|
-
version: "1.
|
|
12
|
+
version: "1.4.0",
|
|
13
13
|
subject: {
|
|
14
14
|
providerId: "openai",
|
|
15
15
|
baseUrl: "https://chatgpt.com/backend-api/codex",
|
|
@@ -117,5 +117,12 @@ export const OPENAI_CODEX_FORWARD_GPT56_SOL_MANIFEST = defineCompatibilityManife
|
|
|
117
117
|
limitation: "The field is removed only for the canonical forward destination; public and custom Responses providers keep it.",
|
|
118
118
|
evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["prompt-cache-options-removed"] }],
|
|
119
119
|
},
|
|
120
|
+
{
|
|
121
|
+
id: "custom-tool-grammar",
|
|
122
|
+
feature: "request.custom_tools.grammar",
|
|
123
|
+
disposition: "passthrough",
|
|
124
|
+
summary: "A custom tool's grammar format, including its definition, reaches the backend unchanged.",
|
|
125
|
+
evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-grammar-preserved"] }],
|
|
126
|
+
},
|
|
120
127
|
],
|
|
121
128
|
} as const);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { defineCompatibilityManifest } from "./manifest";
|
|
2
|
+
|
|
3
|
+
const FIXTURE_ID = "opengateway-key-glm53-responses-v1";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A routed Responses subject whose destination rejects native custom tools, so OpenCodex lowers
|
|
7
|
+
* them to functions before dispatch. The claims describe that local wire transformation only;
|
|
8
|
+
* they do not certify how the upstream model treats the lowered declaration.
|
|
9
|
+
*/
|
|
10
|
+
export const OPENGATEWAY_KEY_GLM53_RESPONSES_MANIFEST = defineCompatibilityManifest({
|
|
11
|
+
schemaVersion: 1,
|
|
12
|
+
id: "opengateway.key.glm-5-3-flash-ultrafast.responses",
|
|
13
|
+
version: "1.0.0",
|
|
14
|
+
subject: {
|
|
15
|
+
providerId: "opengateway",
|
|
16
|
+
baseUrl: "https://apis.opengateway.ai/v1",
|
|
17
|
+
adapterId: "openai-responses",
|
|
18
|
+
authMode: "key",
|
|
19
|
+
inboundProtocol: "responses",
|
|
20
|
+
upstreamProtocol: "openai-responses",
|
|
21
|
+
modelIds: ["z-ai/glm-5.3-flash-ultrafast"],
|
|
22
|
+
},
|
|
23
|
+
claims: [
|
|
24
|
+
{
|
|
25
|
+
id: "custom-tools",
|
|
26
|
+
feature: "request.custom_tools",
|
|
27
|
+
disposition: "translated",
|
|
28
|
+
summary: "Custom tool declarations are sent as functions that take one raw string input.",
|
|
29
|
+
limitation: "The destination rejects native custom tools, so each declaration is lowered to a function with a single required string parameter named input.",
|
|
30
|
+
evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-lowered-to-function"] }],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "custom-tool-grammar",
|
|
34
|
+
feature: "request.custom_tools.grammar",
|
|
35
|
+
disposition: "degraded",
|
|
36
|
+
summary: "The tool and its description survive, but its grammar does not reach the destination.",
|
|
37
|
+
limitation: "The format, including any grammar definition, is removed during lowering, so the upstream model is not constrained by it; only the string input type remains.",
|
|
38
|
+
evidence: [{
|
|
39
|
+
kind: "fixture",
|
|
40
|
+
id: FIXTURE_ID,
|
|
41
|
+
assertionIds: ["custom-tool-grammar-removed", "custom-tool-lowered-to-function"],
|
|
42
|
+
}],
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: "custom-tool-call-history",
|
|
46
|
+
feature: "continuation.custom_tool_call",
|
|
47
|
+
disposition: "translated",
|
|
48
|
+
summary: "Replayed custom tool calls are sent as function calls with the same call_id and name.",
|
|
49
|
+
limitation: "The raw input is wrapped as the JSON arguments object {\"input\": <raw input>}.",
|
|
50
|
+
evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-call-lowered"] }],
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: "custom-tool-output-history",
|
|
54
|
+
feature: "continuation.custom_tool_call_output",
|
|
55
|
+
disposition: "translated",
|
|
56
|
+
summary: "Replayed custom tool outputs are sent as function call outputs with the same call_id and output.",
|
|
57
|
+
limitation: "Only the item type changes from custom_tool_call_output to function_call_output.",
|
|
58
|
+
evidence: [{ kind: "fixture", id: FIXTURE_ID, assertionIds: ["custom-tool-output-lowered"] }],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
} as const);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import type { OcxConfig } from "../types";
|
|
5
|
+
import { getConfigPath } from "./paths";
|
|
6
|
+
|
|
7
|
+
type PolicyConfig = { providers?: unknown; accountAutoPausePolicyRevisions?: unknown };
|
|
8
|
+
// fingerprint follows the live config; diskFingerprint follows the last observed disk state, so a
|
|
9
|
+
// detached load compares disk with disk and still fences an observed external policy change.
|
|
10
|
+
type Observed = { diskRevision: string; localGen: number; unknown: boolean; fingerprint: string; diskFingerprint: string };
|
|
11
|
+
export type AccountAutoPauseFence = { configPath: string; diskRevision: string; localGen: number; threshold: number; route: string };
|
|
12
|
+
const observed = new Map<string, Map<string, Observed>>();
|
|
13
|
+
// One process-wide allocator for every new entry and every advance: a deleted and recreated name
|
|
14
|
+
// can never reproduce an old {diskRevision, localGen} pair, even when both read "legacy".
|
|
15
|
+
let nextGeneration = 0;
|
|
16
|
+
const allocateGeneration = (): number => ++nextGeneration;
|
|
17
|
+
function hasProvider(config: PolicyConfig | undefined, name: string): boolean {
|
|
18
|
+
const providers = record(config?.providers);
|
|
19
|
+
return !!providers && Object.hasOwn(providers, name);
|
|
20
|
+
}
|
|
21
|
+
const UUID = z.uuid();
|
|
22
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
23
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
24
|
+
}
|
|
25
|
+
function revisions(config: PolicyConfig): Record<string, string> | undefined | null {
|
|
26
|
+
const value = config.accountAutoPausePolicyRevisions;
|
|
27
|
+
if (value === undefined) return undefined;
|
|
28
|
+
const map = record(value);
|
|
29
|
+
return map && Object.values(map).every(v => typeof v === "string" && UUID.safeParse(v).success) ? map as Record<string, string> : null;
|
|
30
|
+
}
|
|
31
|
+
function revisionFor(map: Record<string, string> | undefined | null, name: string): string | undefined {
|
|
32
|
+
return map && Object.hasOwn(map, name) ? map[name] : undefined;
|
|
33
|
+
}
|
|
34
|
+
function provider(config: PolicyConfig, name: string): Record<string, unknown> | undefined {
|
|
35
|
+
const providers = record(config.providers);
|
|
36
|
+
return providers && Object.hasOwn(providers, name) ? record(providers[name]) : undefined;
|
|
37
|
+
}
|
|
38
|
+
function route(row: Record<string, unknown> | undefined): string {
|
|
39
|
+
return JSON.stringify([row?.baseUrl, row?.adapter, row?.authMode]);
|
|
40
|
+
}
|
|
41
|
+
export function accountAutoPauseFingerprint(row: unknown): string {
|
|
42
|
+
const p = record(row), policy = record(p?.accountAutoPause);
|
|
43
|
+
return JSON.stringify([!!p, policy?.enabled === true, policy?.consecutiveFailures ?? 2, p?.disabled === true, route(p)]);
|
|
44
|
+
}
|
|
45
|
+
export function snapshotAutoPauseFingerprints(config: PolicyConfig): Map<string, string> {
|
|
46
|
+
return new Map(Object.entries(record(config.providers) ?? {}).map(([name, row]) => [name, accountAutoPauseFingerprint(row)]));
|
|
47
|
+
}
|
|
48
|
+
/** Caller revisions never authorize a write. The literal pre-write file owns the epoch. */
|
|
49
|
+
export function normalizeAccountAutoPauseRevisions(before: PolicyConfig | undefined, persisted: OcxConfig): void {
|
|
50
|
+
const old = revisions(before ?? {});
|
|
51
|
+
const names = new Set(Object.keys(old ?? {}));
|
|
52
|
+
for (const config of [before, persisted]) {
|
|
53
|
+
for (const [name, row] of Object.entries(record(config?.providers) ?? {})) {
|
|
54
|
+
if (record(row)?.accountAutoPause !== undefined) names.add(name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
// Records follow the providers that exist after disk-only preservation; a deleted provider's
|
|
58
|
+
// record is reclaimed and a recreated one is minted a fresh UUID.
|
|
59
|
+
const entries = [...names].filter(name => hasProvider(persisted, name)).map(name => [name, revisionFor(old, name)
|
|
60
|
+
&& accountAutoPauseFingerprint(provider(before ?? {}, name)) === accountAutoPauseFingerprint(provider(persisted, name))
|
|
61
|
+
? revisionFor(old, name)! : randomUUID()]);
|
|
62
|
+
if (entries.length) persisted.accountAutoPausePolicyRevisions = Object.fromEntries(entries);
|
|
63
|
+
else delete persisted.accountAutoPausePolicyRevisions;
|
|
64
|
+
}
|
|
65
|
+
/** Adopt disk epochs even when an external ABA left the effective policy unchanged. */
|
|
66
|
+
export function observeAccountAutoPauseRevisions(config: PolicyConfig, disk: PolicyConfig = config, previous?: Map<string, string>): void {
|
|
67
|
+
const path = getConfigPath();
|
|
68
|
+
let entries = observed.get(path);
|
|
69
|
+
if (!entries) { entries = new Map(); observed.set(path, entries); }
|
|
70
|
+
const map = revisions(disk);
|
|
71
|
+
const names = new Set([...entries.keys(), ...Object.keys(record(config.providers) ?? {}), ...Object.keys(map ?? {})]);
|
|
72
|
+
for (const name of names) {
|
|
73
|
+
if (!hasProvider(config, name) && !hasProvider(disk, name)) { entries.delete(name); continue; }
|
|
74
|
+
const fingerprint = accountAutoPauseFingerprint(provider(config, name));
|
|
75
|
+
const old = entries.get(name);
|
|
76
|
+
const prior = previous?.get(name) ?? old?.fingerprint ?? fingerprint;
|
|
77
|
+
entries.set(name, { fingerprint, diskRevision: revisionFor(map, name) ?? "legacy", unknown: map === null,
|
|
78
|
+
diskFingerprint: accountAutoPauseFingerprint(provider(disk, name)),
|
|
79
|
+
localGen: !old || prior !== fingerprint ? allocateGeneration() : old.localGen });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* A loaded snapshot adopts the disk epoch and compares its policy with the previously observed
|
|
84
|
+
* disk policy. Many callers read detached snapshots; comparing one with the live fingerprint would
|
|
85
|
+
* advance localGen on every live/snapshot difference. A disk-to-disk change still advances it.
|
|
86
|
+
*/
|
|
87
|
+
function adoptDiskAutoPauseRevisions(config: PolicyConfig): void {
|
|
88
|
+
const path = getConfigPath();
|
|
89
|
+
let entries = observed.get(path);
|
|
90
|
+
if (!entries) { entries = new Map(); observed.set(path, entries); }
|
|
91
|
+
const map = revisions(config);
|
|
92
|
+
for (const name of new Set([...entries.keys(), ...Object.keys(record(config.providers) ?? {}), ...Object.keys(map ?? {})])) {
|
|
93
|
+
if (!hasProvider(config, name)) { entries.delete(name); continue; }
|
|
94
|
+
const old = entries.get(name);
|
|
95
|
+
const diskRevision = revisionFor(map, name) ?? "legacy";
|
|
96
|
+
const diskFingerprint = accountAutoPauseFingerprint(provider(config, name));
|
|
97
|
+
entries.set(name, old
|
|
98
|
+
? { ...old, diskRevision, unknown: map === null, diskFingerprint, localGen: old.diskFingerprint !== diskFingerprint ? allocateGeneration() : old.localGen }
|
|
99
|
+
: { fingerprint: diskFingerprint, diskFingerprint, diskRevision, unknown: map === null, localGen: allocateGeneration() });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Test inspection only: observed records for the current config path. */
|
|
103
|
+
export function accountAutoPauseObservedSizeForTests(): number {
|
|
104
|
+
return observed.get(getConfigPath())?.size ?? 0;
|
|
105
|
+
}
|
|
106
|
+
/** Initial load is an observation, never a persistence operation. */
|
|
107
|
+
export function observeLoadedAutoPauseConfig(config: OcxConfig): OcxConfig {
|
|
108
|
+
adoptDiskAutoPauseRevisions(config);
|
|
109
|
+
return config;
|
|
110
|
+
}
|
|
111
|
+
export function captureAccountAutoPauseFence(config: OcxConfig, name: string): AccountAutoPauseFence | undefined {
|
|
112
|
+
// Only a config path no load or save has observed may be seeded from the caller's object. Once a
|
|
113
|
+
// path is observed, a missing name means an authoritative observation removed it, and a stale
|
|
114
|
+
// live snapshot must not re-enroll it; adoption by load, reload or save is required.
|
|
115
|
+
if (!observed.has(getConfigPath())) observeAccountAutoPauseRevisions(config);
|
|
116
|
+
const state = observed.get(getConfigPath())?.get(name);
|
|
117
|
+
const p = provider(config, name), policy = record(p?.accountAutoPause);
|
|
118
|
+
const threshold = policy?.consecutiveFailures ?? 2;
|
|
119
|
+
if (!state || state.unknown || !p || p.disabled === true || policy?.enabled !== true || typeof threshold !== "number" || !Number.isInteger(threshold) || threshold < 1 || threshold > 100) return;
|
|
120
|
+
return { configPath: getConfigPath(), diskRevision: state.diskRevision, localGen: state.localGen, threshold, route: route(p) };
|
|
121
|
+
}
|
|
122
|
+
export function accountAutoPauseFenceIsLocal(name: string, fence: AccountAutoPauseFence): boolean {
|
|
123
|
+
const state = observed.get(fence.configPath)?.get(name);
|
|
124
|
+
return getConfigPath() === fence.configPath && !!state && !state.unknown
|
|
125
|
+
&& state.diskRevision === fence.diskRevision && state.localGen === fence.localGen;
|
|
126
|
+
}
|
|
127
|
+
/** Must run inside the config mutation lock at the final persistence boundary. */
|
|
128
|
+
export function accountAutoPauseFenceMatchesDisk(name: string, fence: AccountAutoPauseFence, fresh?: PolicyConfig): boolean {
|
|
129
|
+
if (!accountAutoPauseFenceIsLocal(name, fence)) return false;
|
|
130
|
+
try {
|
|
131
|
+
const disk = fresh ?? JSON.parse(readFileSync(fence.configPath, "utf8").replace(/^\uFEFF/, "")) as PolicyConfig;
|
|
132
|
+
const map = revisions(disk), p = provider(disk, name), policy = record(p?.accountAutoPause);
|
|
133
|
+
return map !== null && (revisionFor(map, name) ?? "legacy") === fence.diskRevision && !!p && p.disabled !== true
|
|
134
|
+
&& policy?.enabled === true && (policy.consecutiveFailures ?? 2) === fence.threshold && route(p) === fence.route;
|
|
135
|
+
} catch { return false; }
|
|
136
|
+
}
|
|
@@ -21,6 +21,7 @@ import { isMissingPathError } from "./atomic-write";
|
|
|
21
21
|
import { getConfigDir, getConfigPath } from "./paths";
|
|
22
22
|
import { getDefaultConfig } from "./proxy-env";
|
|
23
23
|
import { salvageConfigCandidate } from "./salvage";
|
|
24
|
+
import { sanitizeRemovedOpenCodeFreeForLoad } from "./removed-opencode-free";
|
|
24
25
|
import { sanitizeOpenCodeOverridesForLoad } from "./opencode-wire-upgrade";
|
|
25
26
|
import {
|
|
26
27
|
sanitizeReasoningPinsForLoad,
|
|
@@ -267,6 +268,16 @@ function upstreamHostCircuitThresholdError(value: unknown): string | null {
|
|
|
267
268
|
return `schema_invalid: upstreamHostCircuitThreshold: must be an integer from 0 to ${UPSTREAM_HOST_CIRCUIT_MAX_THRESHOLD}`;
|
|
268
269
|
}
|
|
269
270
|
|
|
271
|
+
// The schema degrades an unknown codec to identity so a hand edit cannot break startup; a live write
|
|
272
|
+
// or import must instead be refused, or it would report success and then drop the value (#7247).
|
|
273
|
+
function upstreamRequestCompressionError(value: unknown): string | null {
|
|
274
|
+
const raw = rawConfigRecord(value);
|
|
275
|
+
if (!raw || !Object.hasOwn(raw, "upstreamRequestCompression")) return null;
|
|
276
|
+
const mode = raw.upstreamRequestCompression;
|
|
277
|
+
if (mode === undefined || mode === "off" || mode === "gzip" || mode === "zstd") return null;
|
|
278
|
+
return "schema_invalid: upstreamRequestCompression: must be \"off\", \"gzip\" or \"zstd\"";
|
|
279
|
+
}
|
|
280
|
+
|
|
270
281
|
function plaintextV2AgentMessagesError(value: unknown): string | null {
|
|
271
282
|
return malformedPlaintextV2AgentMessagesWarning(value)
|
|
272
283
|
? "schema_invalid: plaintextV2AgentMessages: must be a boolean or omitted"
|
|
@@ -726,6 +737,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
|
|
|
726
737
|
?? claudeSubagentEffortError(value)
|
|
727
738
|
?? appOwnedMemoryBudgetError(value)
|
|
728
739
|
?? upstreamHostCircuitThresholdError(value)
|
|
740
|
+
?? upstreamRequestCompressionError(value)
|
|
729
741
|
?? plaintextV2AgentMessagesError(value)
|
|
730
742
|
?? agentTaskRecoveryError(value)
|
|
731
743
|
?? quotaResetNotifyError(value)
|
|
@@ -766,6 +778,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx
|
|
|
766
778
|
export function configDiagnosticsFromRaw(raw: string): ConfigDiagnostics {
|
|
767
779
|
try {
|
|
768
780
|
const parsed = JSON.parse(raw.replace(/^\uFEFF/, ""));
|
|
781
|
+
sanitizeRemovedOpenCodeFreeForLoad(parsed);
|
|
769
782
|
sanitizeOpenCodeOverridesForLoad(parsed);
|
|
770
783
|
sanitizeReasoningPinsForLoad(parsed);
|
|
771
784
|
// Same degradation as loadConfig: a hand-edited invalid retryOn429 must not trip the
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { observeAccountAutoPauseRevisions, snapshotAutoPauseFingerprints } from "./account-auto-pause-revisions";
|
|
1
2
|
import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types";
|
|
2
3
|
import { configReasoningPinsConfigError } from "./provider-validation";
|
|
3
4
|
import { adoptCustomModelCatalogMigration, projectCustomModelCatalogMigration } from "../codex/custom-model-catalog-migration";
|
|
@@ -18,6 +19,7 @@ import { withConfigMutationLockSync, bumpGenerationForCooperatingConfigWrite } f
|
|
|
18
19
|
import { ConfigWritePublishedError, persistConfigUnlocked, readRawConfigJson } from "./persist-unlocked";
|
|
19
20
|
import { configDiagnosticsFromRaw, readConfigDiagnostics, validateConfigCandidate } from "./diagnostics";
|
|
20
21
|
import { normalizePersistedClaudeCode } from "./load-degrade";
|
|
22
|
+
import { sanitizeRemovedOpenCodeFreeForLoad } from "./removed-opencode-free";
|
|
21
23
|
import { reconcileApiKeys } from "./api-key-reconcile";
|
|
22
24
|
import { captureLiveTopologyBaseline, LIVE_TOPOLOGY_KEYS, projectLiveTopology } from "./live-topology";
|
|
23
25
|
|
|
@@ -161,7 +163,9 @@ export function adoptPersistedProviderIntoLiveConfig(
|
|
|
161
163
|
provider: OcxProviderConfig,
|
|
162
164
|
persistedConfig?: OcxConfig,
|
|
163
165
|
): void {
|
|
166
|
+
const pauseBefore = snapshotAutoPauseFingerprints(config);
|
|
164
167
|
config.providers[name] = structuredClone(provider);
|
|
168
|
+
observeAccountAutoPauseRevisions(config, persistedConfig ?? readRawConfigJson() ?? { accountAutoPausePolicyRevisions: null }, pauseBefore);
|
|
165
169
|
const baseline = liveConfigBaseline.get(config);
|
|
166
170
|
if (baseline) baseline.providers[name] = structuredClone(provider);
|
|
167
171
|
if (persistedConfig) refreshPreservedProviderOwner(config, persistedConfig);
|
|
@@ -436,6 +440,7 @@ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline
|
|
|
436
440
|
if (diagnostics.source === "fallback") {
|
|
437
441
|
throw new Error(`OAuth config reconciliation failed: ${diagnostics.error ?? "invalid config file"}`);
|
|
438
442
|
}
|
|
443
|
+
const pauseBefore = snapshotAutoPauseFingerprints(config);
|
|
439
444
|
const persisted = diagnostics.config;
|
|
440
445
|
const claudeGuardArmed = claudeCodeBaseline.has(config);
|
|
441
446
|
const pendingLiveClaudeMutation = claudeGuardArmed
|
|
@@ -460,6 +465,7 @@ export function reconcileLiveConfigFromDisk(config: OcxConfig, persistedBaseline
|
|
|
460
465
|
else config.claudeCode = structuredClone(persisted.claudeCode);
|
|
461
466
|
claudeCodeBaseline.set(config, structuredClone(config.claudeCode));
|
|
462
467
|
}
|
|
468
|
+
observeAccountAutoPauseRevisions(config, persisted, pauseBefore);
|
|
463
469
|
// The reconciliation may have adopted a providers.<name>.modelCosts edit made
|
|
464
470
|
// by a cooperating process while the OAuth login was pending; keep the overlay
|
|
465
471
|
// registry (and the usage-cache overlay version) in sync with the live config.
|
|
@@ -526,6 +532,8 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
|
|
|
526
532
|
// One authoritative pre-write read feeds both the live-config reconciliation and
|
|
527
533
|
// custom-model deletion migration. A second read could observe different bytes.
|
|
528
534
|
const onDisk = readRawConfigJson();
|
|
535
|
+
sanitizeRemovedOpenCodeFreeForLoad(onDisk);
|
|
536
|
+
const pauseBefore = snapshotAutoPauseFingerprints(config);
|
|
529
537
|
const baseline = liveConfigBaseline.get(config);
|
|
530
538
|
let diskDeletedKeys = new Set<string>();
|
|
531
539
|
let persistedSnapshot: OcxConfig | undefined;
|
|
@@ -576,6 +584,7 @@ export function saveConfigPreservingClaudeCode(config: OcxConfig): void {
|
|
|
576
584
|
}
|
|
577
585
|
}
|
|
578
586
|
}
|
|
587
|
+
observeAccountAutoPauseRevisions(config, onDisk ?? config, pauseBefore);
|
|
579
588
|
applyConfigObjectChildDeletions(config, childDeletions);
|
|
580
589
|
// Recursive three-way merging preserves sibling rows; explicit PUT/reset
|
|
581
590
|
// replaces only its model after merging, including absent policy fields.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeAccountAutoPauseRevisions, observeAccountAutoPauseRevisions, snapshotAutoPauseFingerprints } from "./account-auto-pause-revisions";
|
|
1
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
3
|
import { configReasoningPinsConfigError } from "./provider-validation";
|
|
3
4
|
import type { OcxConfig } from "../types";
|
|
@@ -78,6 +79,8 @@ export function persistConfigUnlocked(config: OcxConfig): boolean {
|
|
|
78
79
|
const persisted = withPreservedDiskOnlyProviders(config);
|
|
79
80
|
if (provenanceProjection.configRebaseProvenance === undefined) delete persisted.configRebaseProvenance;
|
|
80
81
|
else persisted.configRebaseProvenance = provenanceProjection.configRebaseProvenance;
|
|
82
|
+
normalizeAccountAutoPauseRevisions(rawBeforeWrite, persisted);
|
|
83
|
+
const beforeFingerprints = snapshotAutoPauseFingerprints(rawBeforeWrite ?? {});
|
|
81
84
|
const bytes = JSON.stringify(persisted, null, 2) + "\n";
|
|
82
85
|
let unchanged = false;
|
|
83
86
|
try {
|
|
@@ -90,6 +93,7 @@ export function persistConfigUnlocked(config: OcxConfig): boolean {
|
|
|
90
93
|
// the same bytes (e.g. before a proxy notification), and Logs/Usage must
|
|
91
94
|
// adopt the overlay without waiting for a changed save or restart.
|
|
92
95
|
let published = unchanged;
|
|
96
|
+
let observedPublication = false;
|
|
93
97
|
try {
|
|
94
98
|
if (!unchanged) {
|
|
95
99
|
atomicWriteFile(configPath, bytes, undefined, { afterRename: () => { published = true; } });
|
|
@@ -97,10 +101,15 @@ export function persistConfigUnlocked(config: OcxConfig): boolean {
|
|
|
97
101
|
}
|
|
98
102
|
// Publication, not successful cache refresh, is the rollback boundary. A
|
|
99
103
|
// byte-identical save already has the requested state on disk as well.
|
|
104
|
+
observeAccountAutoPauseRevisions(persisted, persisted, beforeFingerprints);
|
|
105
|
+
observedPublication = true;
|
|
100
106
|
refreshConfigDerivedRegistries(persisted);
|
|
101
107
|
return !unchanged;
|
|
102
108
|
} catch (error) {
|
|
103
|
-
if (published)
|
|
109
|
+
if (published) {
|
|
110
|
+
if (!observedPublication) observeAccountAutoPauseRevisions(persisted, persisted, beforeFingerprints);
|
|
111
|
+
throw new ConfigWritePublishedError(error);
|
|
112
|
+
}
|
|
104
113
|
throw error;
|
|
105
114
|
}
|
|
106
115
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { getDefaultConfig } from "./proxy-env";
|
|
2
|
+
import { DESKTOP_FAMILIES } from "../claude/desktop-profile";
|
|
3
|
+
|
|
4
|
+
const REMOVED_PROVIDER = "opencode-free";
|
|
5
|
+
let warned = false;
|
|
6
|
+
|
|
7
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
8
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
9
|
+
? value as Record<string, unknown> : undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Load-only projection, like the OpenCode wire upgrades: never rewrites config.json. */
|
|
13
|
+
export function sanitizeRemovedOpenCodeFreeForLoad(parsed: unknown): void {
|
|
14
|
+
const config = record(parsed);
|
|
15
|
+
if (!config) return;
|
|
16
|
+
const providers = record(config.providers);
|
|
17
|
+
const retiredProviders = new Set([REMOVED_PROVIDER]);
|
|
18
|
+
const retiredRoutes = new Set<string>();
|
|
19
|
+
let changed = false;
|
|
20
|
+
if (providers) {
|
|
21
|
+
for (const [name, value] of Object.entries(providers)) {
|
|
22
|
+
const provider = record(value);
|
|
23
|
+
if (name !== REMOVED_PROVIDER && ![provider?.adapter, provider?.type, provider?.id, provider?.preset, record(provider?.preset)?.id, provider?.presetId, provider?.providerType].includes(REMOVED_PROVIDER)) continue;
|
|
24
|
+
retiredProviders.add(name);
|
|
25
|
+
if (typeof provider?.alias === "string") retiredProviders.add(provider.alias.trim());
|
|
26
|
+
delete providers[name];
|
|
27
|
+
changed = true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const retired = (value: unknown): boolean => typeof value === "string"
|
|
31
|
+
&& (retiredProviders.has(value.trim()) || retiredProviders.has(value.trim().split("/")[0]!) || retiredRoutes.has(value.trim()));
|
|
32
|
+
|
|
33
|
+
// Prune references in arrays/maps as well as structured provider/model targets. A decision
|
|
34
|
+
// model is required operator intent: disable that combo instead of choosing another service.
|
|
35
|
+
function prune(value: unknown, preserveContainer = false): unknown {
|
|
36
|
+
if (retired(value)) { changed = true; return undefined; }
|
|
37
|
+
if (Array.isArray(value)) return value.map(item => prune(item)).filter(item => item !== undefined);
|
|
38
|
+
const row = record(value);
|
|
39
|
+
if (!row) return value;
|
|
40
|
+
if (!preserveContainer && [row.provider, row.model, row.decisionModel, row.decisionProvider].some(retired)) {
|
|
41
|
+
changed = true;
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
const hadTargets = Array.isArray(row.targets) && row.targets.length > 0;
|
|
45
|
+
const hadCandidates = Array.isArray(row.candidates) && row.candidates.length > 0;
|
|
46
|
+
const hadSourceModels = Array.isArray(row.sourceModels) && row.sourceModels.length > 0;
|
|
47
|
+
for (const [key, child] of Object.entries(row)) {
|
|
48
|
+
// Descriptions and credential/transport data are not model references.
|
|
49
|
+
if (["note", "description", "displayName", "label", "modelProfile", "headers", "apiKey", "apiKeys", "baseUrl", "modelDisplayNames"].includes(key)) continue;
|
|
50
|
+
const next = retired(key) ? undefined : prune(child);
|
|
51
|
+
if (next === undefined) { delete row[key]; changed = true; }
|
|
52
|
+
else row[key] = next;
|
|
53
|
+
}
|
|
54
|
+
// Empty combos/profiles cannot pass schema validation or execute a request.
|
|
55
|
+
if ((hadTargets && Array.isArray(row.targets) && row.targets.length === 0)
|
|
56
|
+
|| (hadCandidates && Array.isArray(row.candidates) && row.candidates.length === 0)
|
|
57
|
+
|| (hadSourceModels && Array.isArray(row.sourceModels) && row.sourceModels.length === 0)) return undefined;
|
|
58
|
+
return row;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Register removed public aliases before pruning remaining references, including a
|
|
62
|
+
// combo whose decision model points to another combo disabled later in the map.
|
|
63
|
+
let removedRoute: boolean;
|
|
64
|
+
do {
|
|
65
|
+
removedRoute = false;
|
|
66
|
+
for (const [section, namespace] of [["combos", "combo"], ["routingProfiles", "policy"]] as const) {
|
|
67
|
+
const routes = record(config[section]);
|
|
68
|
+
if (!routes) continue;
|
|
69
|
+
for (const [id, value] of Object.entries(routes)) {
|
|
70
|
+
const alias = record(value)?.alias;
|
|
71
|
+
if (prune(value) !== undefined) continue;
|
|
72
|
+
delete routes[id];
|
|
73
|
+
retiredRoutes.add(`${namespace}/${id}`);
|
|
74
|
+
if (typeof alias === "string") retiredRoutes.add(alias.trim());
|
|
75
|
+
changed = removedRoute = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} while (removedRoute);
|
|
79
|
+
// A retired default falls back to the standard built-in OAuth route, without moving a
|
|
80
|
+
// user's choice to a different billable provider. Preserve any configured OpenAI row.
|
|
81
|
+
if (retired(config.defaultProvider)) {
|
|
82
|
+
const defaults = getDefaultConfig();
|
|
83
|
+
config.providers = { ...defaults.providers, ...providers };
|
|
84
|
+
config.defaultProvider = defaults.defaultProvider;
|
|
85
|
+
changed = true;
|
|
86
|
+
}
|
|
87
|
+
const depletedFallbacks = new Set(Object.entries(record(config.subagentModelFallbackByModel) ?? {})
|
|
88
|
+
.filter(([, chain]) => Array.isArray(chain) && chain.some(retired)).map(([model]) => model));
|
|
89
|
+
const desktopProfile = record(record(config.claudeCode)?.desktopProfile);
|
|
90
|
+
const desktopAssignments = record(desktopProfile?.assignments);
|
|
91
|
+
const desktopDefaults = record(desktopProfile?.defaults);
|
|
92
|
+
const retiredDesktopRoute = Object.keys(desktopAssignments ?? {}).some(retired)
|
|
93
|
+
|| Object.values(desktopDefaults ?? {}).some(retired);
|
|
94
|
+
if (retired(config.injectionModel)) {
|
|
95
|
+
delete config.syncCodexSubagentDefaults;
|
|
96
|
+
delete config.injectionEffort;
|
|
97
|
+
}
|
|
98
|
+
for (const [key, value] of Object.entries(config)) {
|
|
99
|
+
if (key === "providers" || !/model|provider|combo|routing|claudeCode|fallback|redirect|compaction|autoReview|advisor|tokenGuardian|images|codexRoleTiers|sidecar|shadowCallIntercept/i.test(key)) continue;
|
|
100
|
+
const next = prune(value, key === "claudeCode");
|
|
101
|
+
if (next === undefined) delete config[key];
|
|
102
|
+
else config[key] = next;
|
|
103
|
+
}
|
|
104
|
+
// Desktop defaults require a member of their family, or null for an empty family.
|
|
105
|
+
// Repair only profiles affected by this migration; unrelated invalid input still degrades.
|
|
106
|
+
if (retiredDesktopRoute && desktopAssignments && desktopDefaults) {
|
|
107
|
+
for (const family of DESKTOP_FAMILIES) {
|
|
108
|
+
const current = desktopDefaults[family];
|
|
109
|
+
if (typeof current === "string" && record(desktopAssignments[current])?.family === family) continue;
|
|
110
|
+
desktopDefaults[family] = Object.keys(desktopAssignments)
|
|
111
|
+
.filter(route => record(desktopAssignments[route])?.family === family).sort()[0] ?? null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (providers) {
|
|
115
|
+
for (const provider of Object.values(providers)) {
|
|
116
|
+
const row = record(provider);
|
|
117
|
+
if (!row) continue;
|
|
118
|
+
for (const key of ["defaultModel", "autoReviewModel", "autoReviewModelOverrides"]) {
|
|
119
|
+
const next = prune(row[key], key === "autoReviewModelOverrides");
|
|
120
|
+
if (next === undefined) delete row[key];
|
|
121
|
+
else row[key] = next;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const fallbacks = record(config.subagentModelFallbackByModel);
|
|
126
|
+
if (fallbacks) {
|
|
127
|
+
for (const [model, chain] of Object.entries(fallbacks)) {
|
|
128
|
+
if (depletedFallbacks.has(model) && Array.isArray(chain) && chain.length === 0) delete fallbacks[model];
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (changed && !warned) {
|
|
132
|
+
warned = true;
|
|
133
|
+
console.warn("opencode-free support was removed because every free model now returns 403 permission_denied under OpenCode's upstream free-tier gate (#7255); the entry and dependent references are disabled. Use opencode-go, or another provider such as opencode-zen or openrouter.");
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as z from "zod/v4";
|
|
2
|
+
import { ACCOUNT_PAUSE_REASONS } from "../../types/account-auto-pause";
|
|
3
|
+
|
|
4
|
+
export const accountAutoPausePolicySchema = z.object({
|
|
5
|
+
enabled: z.boolean().optional(),
|
|
6
|
+
consecutiveFailures: z.number().int().min(1).max(100).optional(),
|
|
7
|
+
}).strict();
|
|
8
|
+
export const accountPauseDetailsSchema = z.object({
|
|
9
|
+
reason: z.enum(ACCOUNT_PAUSE_REASONS),
|
|
10
|
+
pausedAt: z.number().int().nonnegative(),
|
|
11
|
+
}).strict();
|
|
12
|
+
export const accountPauseStateSchema = z.record(z.string(), z.object({
|
|
13
|
+
revision: z.string(), details: accountPauseDetailsSchema.optional(),
|
|
14
|
+
}).strict());
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { MAIN_ACCOUNT_HARD_LOCK_MIN_PERCENT } from "../../codex/quota-types";
|
|
2
2
|
import { validRemainingQuotaAccountIds } from "../../codex/account-use-remaining";
|
|
3
3
|
import * as z from "zod/v4";
|
|
4
|
+
import { accountPauseStateSchema } from "./account-auto-pause";
|
|
4
5
|
import { anthropicAccountPoolSchema } from "./anthropic-account-pool";
|
|
5
6
|
import { compactionRecoverySchema } from "./compaction-recovery";
|
|
6
7
|
import { nativeReasoningRetentionSchema } from "./native-reasoning-retention";
|
|
@@ -163,6 +164,9 @@ export const configSchema = z.object({
|
|
|
163
164
|
.min(0)
|
|
164
165
|
.optional()
|
|
165
166
|
.catch(undefined),
|
|
167
|
+
// Opt-in outbound request compression (#7247). An unknown codec degrades to identity, matching
|
|
168
|
+
// the guards above: a hand-edit typo must not change what the proxy sends or refuse traffic.
|
|
169
|
+
upstreamRequestCompression: z.enum(["off", "gzip", "zstd"]).optional().catch(undefined),
|
|
166
170
|
// Opt-in inbound body ceiling (#3573). An invalid hand edit degrades to the 256 MiB default
|
|
167
171
|
// rather than failing the parse, matching the outbound guard above: a malformed number must
|
|
168
172
|
// not change what the proxy admits. The hard ceiling is NOT enforced here — because of that
|
|
@@ -303,6 +307,17 @@ export const configSchema = z.object({
|
|
|
303
307
|
// and the default label is emitted, rather than failing the parse or writing a config Codex
|
|
304
308
|
// would refuse to load — the provider id routing depends on is never derived from it.
|
|
305
309
|
codexProviderDisplayName: z.string().trim().min(1).max(128).optional().catch(undefined),
|
|
310
|
+
// Preserve an invalid marker: silently treating corrupt epochs as legacy would grant stale attempts authority.
|
|
311
|
+
accountAutoPausePolicyRevisions: z.record(z.string(), z.uuid()).nullable().optional().catch(null),
|
|
312
|
+
// Details are display-only; keep each usable resume revision and the separate paused-id authority.
|
|
313
|
+
codexAccountPauseState: z.preprocess(value => {
|
|
314
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
315
|
+
return Object.fromEntries(Object.entries(value).flatMap(([id, row]) => {
|
|
316
|
+
if (!row || typeof row !== "object" || Array.isArray(row) || typeof row.revision !== "string") return [];
|
|
317
|
+
const parsed = accountPauseStateSchema.safeParse({ [id]: row });
|
|
318
|
+
return [[id, parsed.success ? parsed.data[id] : { revision: row.revision }]];
|
|
319
|
+
}));
|
|
320
|
+
}, accountPauseStateSchema.optional()),
|
|
306
321
|
pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
|
|
307
322
|
// A malformed allow-list degrades to "no account spends credits" rather than failing the parse,
|
|
308
323
|
// so a hand-edited typo cannot trip the backup-and-defaults repair path; the write path rejects
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as z from "zod/v4";
|
|
2
|
+
import { accountAutoPausePolicySchema, accountPauseDetailsSchema } from "./account-auto-pause";
|
|
2
3
|
import { anthropicAccountPoolSchema } from "./anthropic-account-pool";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
import { isValidProviderName } from "../provider-name";
|
|
@@ -303,6 +304,8 @@ const providerNoProxySchema = z.unknown().superRefine((value, ctx) => {
|
|
|
303
304
|
* fields pass through (preserved for runtime extensions).
|
|
304
305
|
*/
|
|
305
306
|
export const providerConfigSchema = z.object({
|
|
307
|
+
accountAutoPause: accountAutoPausePolicySchema.optional(),
|
|
308
|
+
apiKeyPool: z.array(z.object({ id: z.string(), key: z.string(), label: z.string().optional(), addedAt: z.number().optional(), disabled: z.boolean().optional(), pauseRevision: z.string().optional(), pauseDetails: accountPauseDetailsSchema.optional() }).passthrough()).optional(),
|
|
306
309
|
anthropicAccountPool: anthropicAccountPoolSchema,
|
|
307
310
|
// Raw diagnostics/writes reject malformed provenance; tolerant loads preserve the custom row.
|
|
308
311
|
anthropicOAuthInstance: z.literal("anthropic2").optional().catch(undefined),
|
|
@@ -1020,12 +1023,16 @@ export const codexPoolSchema = z.object({
|
|
|
1020
1023
|
actions: z.object({
|
|
1021
1024
|
pause: z.boolean(),
|
|
1022
1025
|
notify: z.boolean(),
|
|
1026
|
+
desktop: z.boolean().optional(),
|
|
1023
1027
|
}).strict(),
|
|
1024
1028
|
windows: z.object({
|
|
1025
1029
|
short: z.boolean(),
|
|
1026
1030
|
weekly: z.boolean(),
|
|
1027
1031
|
}).strict(),
|
|
1028
1032
|
}).strict().superRefine((policy, ctx) => {
|
|
1033
|
+
if (policy.actions.desktop === true && !policy.actions.notify) {
|
|
1034
|
+
ctx.addIssue({ code: "custom", path: ["actions", "desktop"], message: "desktop low-quota notification requires notify" });
|
|
1035
|
+
}
|
|
1029
1036
|
if (!policy.enabled) return;
|
|
1030
1037
|
if (!policy.actions.pause && !policy.actions.notify) {
|
|
1031
1038
|
ctx.addIssue({ code: "custom", path: ["actions"], message: "enabled low-quota protection needs an action" });
|