@bitkyc08/opencodex 2.87.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-DCd-A1az.js → Tray-7z1COnmz.js} +1 -1
- package/gui/dist/assets/index-uchD-ngf.js +94 -0
- package/gui/dist/assets/{quota-summary-ChvmKGOk.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-rejection-memo.ts +71 -0
- package/src/adapters/anthropic-replay.ts +135 -0
- package/src/adapters/anthropic-tool-catalog.ts +11 -0
- package/src/adapters/anthropic.ts +86 -48
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +53 -0
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +52 -30
- 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 +17 -3
- package/src/bridge/response-json.ts +4 -4
- package/src/bridge/sse.ts +17 -10
- package/src/claude/inbound-content-options.ts +66 -0
- package/src/claude/inbound.ts +12 -26
- package/src/claude/outbound.ts +18 -5
- 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/cross-home-owner.ts +16 -2
- package/src/cli/help.ts +1 -1
- package/src/cli/loopback-family.ts +31 -0
- 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-gather.ts +3 -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/codex/websocket-activity.ts +161 -0
- package/src/codex/websocket-registry.ts +25 -1
- 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/opencode-zen-upgrade.ts +9 -8
- 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 +411 -175
- package/src/images/loop.ts +101 -27
- 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/request-failure-model.ts +2 -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/generic-account-failover.ts +7 -4
- 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/protocols/encoders/adapter-events.ts +5 -5
- package/src/protocols/encoders/messages.ts +2 -2
- 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 +12 -73
- 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/reasoning-replay-cache.ts +50 -4
- package/src/responses/web-search-replay.ts +138 -0
- package/src/routing/analytics.ts +7 -15
- 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 +4 -2
- package/src/server/index/live-sideband.ts +3 -2
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/index/serve-options.ts +11 -5
- package/src/server/index/websocket-handler.ts +18 -4
- package/src/server/index.ts +7 -7
- package/src/server/lifecycle.ts +13 -1
- 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 +34 -7
- 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-continuation.ts +34 -18
- package/src/server/responses/adapter-delivery.ts +53 -6
- package/src/server/responses/adapter-dispatch.ts +104 -29
- package/src/server/responses/anthropic-thinking-recovery.ts +153 -0
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +6 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +8 -5
- package/src/server/responses/compaction-recovery.ts +2 -0
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-codex-account.ts +9 -3
- package/src/server/responses/core-combo.ts +34 -7
- package/src/server/responses/core-lifetime.ts +3 -2
- package/src/server/responses/core-options.ts +2 -0
- package/src/server/responses/core-replay.ts +5 -1
- 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-execution.ts +3 -2
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +1 -0
- package/src/server/responses/request-transport.ts +32 -3
- package/src/server/responses/response-effects.ts +1 -0
- package/src/server/responses/response-tool-ownership.ts +21 -0
- package/src/server/responses/run-turn-execution.ts +2 -0
- package/src/server/responses/sidecar-execution.ts +59 -10
- package/src/server/responses/sidecar-send-budget.ts +2 -2
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- 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/server/ws-bridge.ts +2 -0
- 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/opencode-zen.ts +9 -3
- package/src/types/provider.ts +9 -5
- package/src/types/request.ts +30 -4
- package/src/types/wire.ts +4 -2
- package/src/types.ts +2 -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 +16 -3
- package/src/usage/telemetry-contract.ts +1 -0
- package/src/web-search/anthropic-history.ts +59 -0
- package/src/web-search/loop.ts +212 -73
- package/src/web-search/progress-stream.ts +14 -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-ZfYbLHkB.js +0 -58
- package/gui/dist/assets/App-wT3mXqFz.css +0 -1
- package/gui/dist/assets/index-CfYDMj34.js +0 -94
package/src/server/index.ts
CHANGED
|
@@ -267,17 +267,17 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
267
267
|
startupOwnershipHomes = homes;
|
|
268
268
|
startupOwnershipStatePaths = statePaths;
|
|
269
269
|
} catch { /* inspection below stays unknown */ }
|
|
270
|
-
const startupCacheOwnership = inspectStartupOwnership(
|
|
270
|
+
const startupCacheOwnership = shouldSyncCodexOnStart(config) ? inspectStartupOwnership(
|
|
271
271
|
deps,
|
|
272
272
|
startupOwnershipHomes,
|
|
273
273
|
startupOwnershipStatePaths,
|
|
274
274
|
startupWindowsTaskListingCache,
|
|
275
|
-
);
|
|
275
|
+
) : null;
|
|
276
276
|
// Startup cache invalidation is best-effort and applies only when startup sync is enabled.
|
|
277
277
|
// Check OFF before taking K: on Windows K resolves SID + LocalAppData through PowerShell,
|
|
278
278
|
// and run 35093667426 exceeded healthy controls by 33.8 s against that 30 s child budget.
|
|
279
279
|
// The permit callback still re-reads intent under K to close a concurrent disable race.
|
|
280
|
-
if (
|
|
280
|
+
if (startupCacheOwnership?.ownership === "owned" && startupOwnershipHomes !== null) {
|
|
281
281
|
try {
|
|
282
282
|
const startupCodexHome = startupOwnershipHomes.codexHome;
|
|
283
283
|
// #1046: record whether this actually rewrote the cache. `handleStart` ORs this
|
|
@@ -549,12 +549,12 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
549
549
|
// clients; no Codex request can use this lifecycle in that state.
|
|
550
550
|
// Re-probe here instead of trusting the earlier cache decision: startup work
|
|
551
551
|
// between the two sites must not widen the service-install race.
|
|
552
|
-
const nativeOwnership = inspectStartupOwnership(
|
|
552
|
+
const nativeOwnership = shouldSyncCodexOnStart(config) ? inspectStartupOwnership(
|
|
553
553
|
deps,
|
|
554
554
|
startupOwnershipHomes,
|
|
555
555
|
startupOwnershipStatePaths,
|
|
556
|
-
);
|
|
557
|
-
const preparedNativeMainLifecycle = nativeOwnership.ownership !== "foreign"
|
|
556
|
+
) : null;
|
|
557
|
+
const preparedNativeMainLifecycle = nativeOwnership !== null && nativeOwnership.ownership !== "foreign"
|
|
558
558
|
&& startupOwnershipHomes !== null
|
|
559
559
|
? prepareNativeMainStartupLifecycle(
|
|
560
560
|
deps.nativeMainStartup,
|
|
@@ -600,7 +600,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
600
600
|
return retryPreparedNativeMainLifecycle.start();
|
|
601
601
|
},
|
|
602
602
|
};
|
|
603
|
-
const nativeMainLifecycle: NativeMainStartupLifecycle =
|
|
603
|
+
const nativeMainLifecycle: NativeMainStartupLifecycle = nativeOwnership !== null
|
|
604
604
|
? nativeOwnership.ownership === "owned"
|
|
605
605
|
? preparedNativeMainLifecycle
|
|
606
606
|
? preparedNativeMainLifecycle.start()
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -38,6 +38,7 @@ export interface ActiveTurnLease extends AdmissionLease {
|
|
|
38
38
|
attach(lease: AdmissionLease): void;
|
|
39
39
|
bindAbortSignal(signal: AbortSignal): () => void;
|
|
40
40
|
bindAbortController(ac: AbortController): void;
|
|
41
|
+
unbindAbortController(ac: AbortController): void;
|
|
41
42
|
beginCodexAccountSelection(): CodexAccountSelectionAdmission;
|
|
42
43
|
isTransferred(): boolean;
|
|
43
44
|
}
|
|
@@ -231,6 +232,17 @@ export function tryAdmitTurn(sessionLaneId?: string, clientSignal?: AbortSignal)
|
|
|
231
232
|
activeTurns.set(ac, lease);
|
|
232
233
|
lease.bindAbortSignal(ac.signal);
|
|
233
234
|
},
|
|
235
|
+
unbindAbortController(ac) {
|
|
236
|
+
if (!controllers.delete(ac)) return;
|
|
237
|
+
if (activeTurns.get(ac) === lease) activeTurns.delete(ac);
|
|
238
|
+
const listener = abortListeners.get(ac.signal);
|
|
239
|
+
if (listener) {
|
|
240
|
+
ac.signal.removeEventListener("abort", listener);
|
|
241
|
+
abortListeners.delete(ac.signal);
|
|
242
|
+
}
|
|
243
|
+
// A completed child relay cannot retire its still-running parent turn.
|
|
244
|
+
if (controllers.size === 0) lease.release();
|
|
245
|
+
},
|
|
234
246
|
beginCodexAccountSelection() {
|
|
235
247
|
const mainProfileDraining = nativeMainDrainOwners.size > 0;
|
|
236
248
|
let selectionActive = active && !mainProfileDraining;
|
|
@@ -327,7 +339,7 @@ export function unregisterTurn(ac: AbortController): void {
|
|
|
327
339
|
turnReleaseMisses += 1;
|
|
328
340
|
return;
|
|
329
341
|
}
|
|
330
|
-
lease.
|
|
342
|
+
lease.unbindAbortController(ac);
|
|
331
343
|
}
|
|
332
344
|
export function isDraining(): boolean { return shutdownDraining || temporaryDrainOwners.size > 0; }
|
|
333
345
|
export function getActiveTurnCount(): number { return turnGate.metrics().active; }
|
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
} from "../../vision/eligibility";
|
|
64
64
|
import type { OcxClaudeCodeConfig, OcxComboConfig, OcxConfig, OcxCustomModel, OcxProviderConfig, OcxComboCooldownWaitPolicy } from "../../types";
|
|
65
65
|
import { drainAndShutdown } from "../lifecycle";
|
|
66
|
-
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
66
|
+
import { notifyLiveConfigObservers, reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
67
67
|
import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from "../request-log";
|
|
68
68
|
import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost";
|
|
69
69
|
import type { PersistedUsageAttempt } from "../../usage/log";
|
|
@@ -162,6 +162,7 @@ function parseVisionSidecarTargets(
|
|
|
162
162
|
return { targets };
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/** Handle the `/api/combos` management routes (list, save, delete and related combo endpoints), validating and preserving the opt-in quota signal fields; null when no route matches. */
|
|
165
166
|
export async function handleComboRoutes(ctx: ManagementContext): Promise<Response | null> {
|
|
166
167
|
const { req, url, config, deps, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
|
|
167
168
|
|
|
@@ -222,6 +223,8 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
222
223
|
const requestedCombo: Record<string, unknown> = body.combo;
|
|
223
224
|
const effectiveCombo = {
|
|
224
225
|
...requestedCombo,
|
|
226
|
+
...(!Object.hasOwn(requestedCombo, "decisionQuotaSignals") && requestedCombo.strategy === "jev" && previous?.decisionQuotaSignals !== undefined ? { decisionQuotaSignals: previous.decisionQuotaSignals } : {}),
|
|
227
|
+
...(!Object.hasOwn(requestedCombo, "decisionQuotaTiers") && requestedCombo.strategy === "jev" && previous?.decisionQuotaTiers !== undefined ? { decisionQuotaTiers: previous.decisionQuotaTiers } : {}),
|
|
225
228
|
...(!Object.hasOwn(requestedCombo, "allowMeteredTargets") && previous?.allowMeteredTargets === true
|
|
226
229
|
? { allowMeteredTargets: true }
|
|
227
230
|
: {}),
|
|
@@ -244,6 +247,11 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
244
247
|
...(!Object.hasOwn(requestedCombo, "imageInput") && previous?.imageInput !== undefined
|
|
245
248
|
? { imageInput: previous.imageInput }
|
|
246
249
|
: {}),
|
|
250
|
+
// #7229: the dashboard does not edit the row description, so a round trip that omits it
|
|
251
|
+
// (including a rename) keeps it. An explicit empty string still clears it.
|
|
252
|
+
...(!Object.hasOwn(requestedCombo, "description") && previous?.description !== undefined
|
|
253
|
+
? { description: previous.description }
|
|
254
|
+
: {}),
|
|
247
255
|
// `lastResort` rides on each target, so a GUI that re-sends the target list without the
|
|
248
256
|
// flag would strip it. Carry it over per target, matched on provider+model.
|
|
249
257
|
...(Array.isArray(requestedCombo.targets) && Array.isArray(previous?.targets)
|
|
@@ -516,6 +524,8 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
516
524
|
if (!(error instanceof ConfigWritePublishedError)) {
|
|
517
525
|
return jsonResponse({ error: "combo could not be saved" }, 500);
|
|
518
526
|
}
|
|
527
|
+
// Published but bookkeeping failed: live observers (JEV quota activation) still follow the saved combo.
|
|
528
|
+
notifyLiveConfigObservers(config);
|
|
519
529
|
throw error;
|
|
520
530
|
}
|
|
521
531
|
reconcileLiveStateStores();
|
|
@@ -548,7 +558,12 @@ export async function handleComboRoutes(ctx: ManagementContext): Promise<Respons
|
|
|
548
558
|
const { clearComboSelectionState, clearComboTargetCooldowns } = await import("../../combos");
|
|
549
559
|
delete config.combos![id];
|
|
550
560
|
if (Object.keys(config.combos!).length === 0) deleteConfigTopLevelKey(config, "combos");
|
|
551
|
-
|
|
561
|
+
try {
|
|
562
|
+
saveConfigPreservingClaudeCode(config);
|
|
563
|
+
} catch (error) {
|
|
564
|
+
if (error instanceof ConfigWritePublishedError) notifyLiveConfigObservers(config);
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
552
567
|
reconcileLiveStateStores();
|
|
553
568
|
clearComboSelectionState(id);
|
|
554
569
|
clearComboTargetCooldowns(id);
|
|
@@ -510,7 +510,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
510
510
|
reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
|
|
511
511
|
});
|
|
512
512
|
return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode,
|
|
513
|
-
...(supportsPause ? { paused: full?.paused === true } : {}),
|
|
513
|
+
...(supportsPause ? { paused: full?.paused === true, ...(full?.pauseDetails ? { pauseDetails: full.pauseDetails } : {}) } : {}),
|
|
514
514
|
...(isAnthropicOAuthInstance(provider) && supportsPause ? { autoSwitchThresholdOverride: full?.autoSwitchThresholdOverride ?? null,
|
|
515
515
|
effectiveAutoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider as AnthropicInstanceId, config, full),
|
|
516
516
|
autoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider as AnthropicInstanceId, config) } : {}),
|
|
@@ -1116,6 +1116,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
1116
1116
|
clearProviderQuotaCache();
|
|
1117
1117
|
return jsonResponse({ ...result, name, store: providerKeyStoreKind(config.providers[name]) });
|
|
1118
1118
|
}
|
|
1119
|
+
if (url.pathname === "/api/providers/keys/pause" && req.method === "PUT") {
|
|
1120
|
+
const body = await readManagementJsonBodyOr(req, {});
|
|
1121
|
+
if (!isPlainRecord(body)) return jsonResponse({ error: "body must be an object" }, 400);
|
|
1122
|
+
if (typeof body.name !== "string" || typeof body.id !== "string" || typeof body.paused !== "boolean") return jsonResponse({ error: "name, id and paused required" }, 400);
|
|
1123
|
+
if (!isValidProviderName(body.name) || !hasOwnProvider(config.providers, body.name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
1124
|
+
const { setProviderApiKeyPaused } = await import("../../providers/api-key-pause");
|
|
1125
|
+
return setProviderApiKeyPaused(config, body.name, body.id, body.paused)
|
|
1126
|
+
? jsonResponse({ ok: true }) : jsonResponse({ error: "key not found" }, 404);
|
|
1127
|
+
}
|
|
1119
1128
|
if (url.pathname === "/api/providers/keys/active" && req.method === "PUT") {
|
|
1120
1129
|
const body = await readManagementJsonBodyOr(req, {}) as { name?: string; id?: string };
|
|
1121
1130
|
const name = (body.name ?? "").trim();
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Explicit single-model diagnostic, isolated from discovery, routing health and request history.
|
|
3
|
+
* Unsupported transports: TLS profiles, HTTP version pins, enabled pacing, custom fetch + proxy.
|
|
4
|
+
* Adding an exclusion requires a regression test. No retries, failover, or output preview.
|
|
5
|
+
*/
|
|
6
|
+
import type { OcxConfig, OcxProviderConfig } from "../../types";
|
|
7
|
+
import { routedProviderConfig } from "../../providers/routed-config";
|
|
8
|
+
import { PROVIDER_REGISTRY } from "../../providers/registry";
|
|
9
|
+
import { resolveModelAlias } from "../../providers/default-aliases";
|
|
10
|
+
import { providerApiKeySelectionIsCurrent } from "../../providers/api-key-selection";
|
|
11
|
+
import { resolveWireProtocolOverride } from "../adapter-resolve";
|
|
12
|
+
import { openAIChatTransport, stripBracketedModelSuffix } from "../../adapters/openai-chat/wire";
|
|
13
|
+
import { buildResponsesTransport } from "../../adapters/openai-responses/transport";
|
|
14
|
+
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../../providers/openrouter-routing";
|
|
15
|
+
import { resolveVercelGatewayRouting, vercelGatewayProviderPayload } from "../../providers/vercel-gateway-routing";
|
|
16
|
+
import { assertProviderDestinationAllowed, providerAllowsPrivateNetwork } from "../../lib/destination-policy";
|
|
17
|
+
import { providerOutboundPost, ProviderOutboundPolicyError, ProviderOutboundSendCancelledError,
|
|
18
|
+
type ProviderOutboundDependencies } from "../../lib/provider-outbound";
|
|
19
|
+
import { redactSecretString } from "../../lib/redact";
|
|
20
|
+
import { decodeServerSentEvents } from "../../lib/sse-decoder";
|
|
21
|
+
import { createTranslatorBudget } from "../../lib/translator-budget";
|
|
22
|
+
import { jsonResponse } from "../auth-cors";
|
|
23
|
+
import type { ManagementContext } from "./context";
|
|
24
|
+
import { managementBodyTooLargeResponse, readManagementJsonBody } from "./body";
|
|
25
|
+
|
|
26
|
+
export const COMPLETION_TEST_PROMPT = "Reply exactly OK.";
|
|
27
|
+
export const COMPLETION_TEST_OUTPUT_TOKEN_CAP = 32;
|
|
28
|
+
export const COMPLETION_TEST_TIMEOUT_MS = 20_000;
|
|
29
|
+
export const COMPLETION_TEST_MAX_BYTES = 64 * 1024;
|
|
30
|
+
|
|
31
|
+
type Wire = "openai-chat" | "openai-responses";
|
|
32
|
+
type Code = "destination_refused" | "credential_missing" | "credential_changed" | "http_status" | "redirect"
|
|
33
|
+
| "network" | "stream_error" | "premature_eof" | "no_content" | "timeout" | "aborted" | "too_large";
|
|
34
|
+
const MESSAGES: Record<Code, string> = {
|
|
35
|
+
destination_refused: "Provider destination refused by policy.",
|
|
36
|
+
credential_missing: "Provider credential is missing.",
|
|
37
|
+
credential_changed: "Provider request authority changed before send.",
|
|
38
|
+
http_status: "Upstream returned an unsuccessful HTTP status.",
|
|
39
|
+
redirect: "Upstream redirect refused.",
|
|
40
|
+
network: "Upstream network request failed.",
|
|
41
|
+
stream_error: "Upstream stream reported an error.",
|
|
42
|
+
premature_eof: "Upstream stream ended without a terminal event.",
|
|
43
|
+
no_content: "Upstream completed without a text delta.",
|
|
44
|
+
timeout: "Completion test deadline exceeded.",
|
|
45
|
+
aborted: "Completion test was cancelled.",
|
|
46
|
+
too_large: "Upstream stream exceeded the byte limit.",
|
|
47
|
+
};
|
|
48
|
+
export type ProviderCompletionTestResult = {
|
|
49
|
+
applicable: false;
|
|
50
|
+
reason: "disabled" | "forward_auth" | "oauth_auth" | "unsupported_adapter" | "unsupported_transport";
|
|
51
|
+
adapter?: string;
|
|
52
|
+
detail?: "tls_profile" | "upstream_http_version" | "request_pacing" | "custom_executor_egress";
|
|
53
|
+
} | {
|
|
54
|
+
applicable: true; ok: boolean; provider: string; requestedModel: string; wireModel: string; wire: Wire;
|
|
55
|
+
upstreamModel: string | null; modelComparison: "match" | "mismatch" | "unknown";
|
|
56
|
+
comparisonNote?: "missing_metadata" | "conflicting_metadata";
|
|
57
|
+
identityVerified: false; ttftMs: number | null; totalLatencyMs: number; outputTokenCap: 32;
|
|
58
|
+
termination: "completed" | "failed" | "timeout" | "aborted" | "too_large";
|
|
59
|
+
error: null | { code: Code; status?: number; message: string };
|
|
60
|
+
};
|
|
61
|
+
export interface ProviderCompletionTestInput { config: OcxConfig; name: string; model: string; signal?: AbortSignal }
|
|
62
|
+
export interface ProviderCompletionTestDependencies {
|
|
63
|
+
now?: () => number;
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
outboundDependencies?: ProviderOutboundDependencies;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function transportExclusion(row: OcxProviderConfig): Extract<ProviderCompletionTestResult, { applicable: false }>["detail"] {
|
|
69
|
+
if (row.tlsProfile !== undefined) return "tls_profile";
|
|
70
|
+
if (row.upstreamHttpVersion !== undefined) return "upstream_http_version";
|
|
71
|
+
if (row.requestPacing?.enabled) return "request_pacing";
|
|
72
|
+
if (typeof (row as { fetch?: unknown }).fetch === "function" && "proxy" in row) return "custom_executor_egress";
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
function nativeModel(config: OcxConfig, row: OcxProviderConfig, model: string): string {
|
|
76
|
+
const known = new Set([...(row.models ?? []), ...Object.keys(row.modelAliases ?? {})]);
|
|
77
|
+
return known.has(model) ? model : resolveModelAlias(config, row, known, model) ?? model;
|
|
78
|
+
}
|
|
79
|
+
function derive(config: OcxConfig, name: string, row: OcxProviderConfig, model: string) {
|
|
80
|
+
const resolved = routedProviderConfig(name, row);
|
|
81
|
+
const native = nativeModel(config, resolved, model);
|
|
82
|
+
const routed = resolveWireProtocolOverride(name, native, resolved);
|
|
83
|
+
const wire = routed.adapter;
|
|
84
|
+
if (wire !== "openai-chat" && wire !== "openai-responses") throw new Error("unsupported adapter");
|
|
85
|
+
const transport = wire === "openai-chat" ? openAIChatTransport(routed)
|
|
86
|
+
: buildResponsesTransport(routed, { headers: new Headers() });
|
|
87
|
+
const headers = new Headers(transport.headers);
|
|
88
|
+
headers.set("Accept", "text/event-stream");
|
|
89
|
+
const wireModel = routed.modelSuffixBracketStrip ? stripBracketedModelSuffix(native) : native;
|
|
90
|
+
const openRouterRouting = wire === "openai-chat" ? resolveOpenRouterRouting(routed, native) : undefined;
|
|
91
|
+
const vercelRouting = wire === "openai-chat" ? resolveVercelGatewayRouting(routed, native) : undefined;
|
|
92
|
+
const providerRouting = vercelRouting ? vercelGatewayProviderPayload(vercelRouting)
|
|
93
|
+
: openRouterRouting ? openRouterProviderPayload(openRouterRouting) : undefined;
|
|
94
|
+
// Normal inference admits registry-local defaults as well as the operator's explicit opt-in.
|
|
95
|
+
// Materialize that effective policy for the outbound wrapper's narrower local HTTP safeguard.
|
|
96
|
+
const allowLocalCleartextPost = providerAllowsPrivateNetwork(name, routed);
|
|
97
|
+
// Capture primitive values now: rows and their nested header objects may mutate in place.
|
|
98
|
+
const selection = routed._apiKeyAttempt;
|
|
99
|
+
const fingerprint = JSON.stringify({ wire, providerRouting, allowLocalCleartextPost, url: transport.url, wireModel, headers: [...headers].sort(([a], [b]) => a.localeCompare(b)),
|
|
100
|
+
authMode: routed.authMode, apiKey: routed.apiKey,
|
|
101
|
+
selection: selection && { entryId: selection.entryId, reference: selection.reference, revision: selection.revision },
|
|
102
|
+
allowPrivateNetwork: routed.allowPrivateNetwork, proxy: routed.proxy, noProxy: routed.noProxy, disabled: row.disabled });
|
|
103
|
+
return { routed, wire: wire as Wire, wireModel, providerRouting, allowLocalCleartextPost, headers, url: transport.url, fingerprint };
|
|
104
|
+
}
|
|
105
|
+
function maskedModel(raw: string, secrets: Array<string | undefined>): string {
|
|
106
|
+
if (secrets.some(secret => typeof secret === "string" && secret.length > 0 && raw.includes(secret))) return "[redacted]";
|
|
107
|
+
return redactSecretString(raw.replace(/[\x00-\x1f\x7f-\x9f]/g, "").slice(0, 256));
|
|
108
|
+
}
|
|
109
|
+
function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const abort = () => reject(signal.reason);
|
|
112
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
113
|
+
if (signal.aborted) abort();
|
|
114
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
class DiagnosticFailure extends Error { constructor(readonly code: Code) { super(MESSAGES[code]); } }
|
|
118
|
+
|
|
119
|
+
/** Direct diagnostic; callers supply an existing provider name and an already validated model. */
|
|
120
|
+
export async function runProviderCompletionTest(
|
|
121
|
+
{ config, name, model, signal: requestSignal }: ProviderCompletionTestInput,
|
|
122
|
+
deps: ProviderCompletionTestDependencies = {},
|
|
123
|
+
): Promise<ProviderCompletionTestResult> {
|
|
124
|
+
const row = config.providers[name];
|
|
125
|
+
if (!Object.hasOwn(config.providers, name) || !row) throw new Error("unknown provider");
|
|
126
|
+
if (row.disabled) return { applicable: false, reason: "disabled" };
|
|
127
|
+
const authKind = PROVIDER_REGISTRY.find(entry => entry.id === name)?.authKind;
|
|
128
|
+
if (row.authMode === "forward" || authKind === "forward") return { applicable: false, reason: "forward_auth" };
|
|
129
|
+
if (row.authMode === "oauth" || authKind === "oauth") return { applicable: false, reason: "oauth_auth" };
|
|
130
|
+
const detail = transportExclusion(row);
|
|
131
|
+
if (detail) return { applicable: false, reason: "unsupported_transport", detail };
|
|
132
|
+
// Metadata resolution must never dereference keychain/env/pool credentials.
|
|
133
|
+
const { apiKey: _key, apiKeyPool: _pool, _apiKeyAttempt: _attempt, ...metadata } = row;
|
|
134
|
+
let snapshot: OcxProviderConfig = metadata;
|
|
135
|
+
let destinationRefused = false;
|
|
136
|
+
try { snapshot = routedProviderConfig(name, metadata); } catch { destinationRefused = true; }
|
|
137
|
+
const native = nativeModel(config, snapshot, model);
|
|
138
|
+
const wire = resolveWireProtocolOverride(name, native, snapshot).adapter;
|
|
139
|
+
if (wire !== "openai-chat" && wire !== "openai-responses") return { applicable: false, reason: "unsupported_adapter", adapter: wire };
|
|
140
|
+
const now = deps.now ?? (() => performance.now());
|
|
141
|
+
const result: Extract<ProviderCompletionTestResult, { applicable: true }> = {
|
|
142
|
+
applicable: true, ok: false, provider: name, requestedModel: model,
|
|
143
|
+
wireModel: snapshot.modelSuffixBracketStrip ? stripBracketedModelSuffix(native) : native, wire,
|
|
144
|
+
upstreamModel: null, modelComparison: "unknown", comparisonNote: "missing_metadata", identityVerified: false,
|
|
145
|
+
ttftMs: null, totalLatencyMs: 0, outputTokenCap: COMPLETION_TEST_OUTPUT_TOKEN_CAP, termination: "failed", error: null,
|
|
146
|
+
};
|
|
147
|
+
const fail = (code: Code, status?: number) => {
|
|
148
|
+
result.error = { code, message: MESSAGES[code], ...(status !== undefined ? { status } : {}) };
|
|
149
|
+
result.termination = code === "timeout" || code === "aborted" || code === "too_large" ? code : "failed";
|
|
150
|
+
return result;
|
|
151
|
+
};
|
|
152
|
+
if (destinationRefused) return fail("destination_refused");
|
|
153
|
+
try { assertProviderDestinationAllowed(name, snapshot); } catch { return fail("destination_refused"); }
|
|
154
|
+
let resolved: OcxProviderConfig;
|
|
155
|
+
try { resolved = routedProviderConfig(name, row); } catch { return fail("credential_missing"); }
|
|
156
|
+
if (resolved.authMode === "key" && !resolved.keyOptional && !resolved.apiKey?.trim()) return fail("credential_missing");
|
|
157
|
+
let authority: ReturnType<typeof derive>;
|
|
158
|
+
try { authority = derive(config, name, row, model); } catch { return fail("network"); }
|
|
159
|
+
const secrets = [authority.routed.apiKey, ...Object.values(authority.routed.headers ?? {}),
|
|
160
|
+
...[...authority.headers].filter(([name]) => name === "authorization" || name === "x-api-key").map(([, value]) => value)];
|
|
161
|
+
result.wire = authority.wire;
|
|
162
|
+
result.wireModel = authority.wireModel;
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
const deadline = AbortSignal.timeout(deps.timeoutMs ?? COMPLETION_TEST_TIMEOUT_MS);
|
|
165
|
+
const signal = AbortSignal.any([...(requestSignal ? [requestSignal] : []), deadline, controller.signal]);
|
|
166
|
+
const started = now();
|
|
167
|
+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
|
168
|
+
let response: Response | undefined;
|
|
169
|
+
const translatorBudget = createTranslatorBudget();
|
|
170
|
+
let observed: string | undefined;
|
|
171
|
+
let conflicting = false, terminal = false, content = false, finishReason = false;
|
|
172
|
+
const observe = (value: unknown) => {
|
|
173
|
+
if (typeof value !== "string") return;
|
|
174
|
+
if (observed === undefined) observed = value;
|
|
175
|
+
else if (value !== observed) conflicting = true;
|
|
176
|
+
};
|
|
177
|
+
try {
|
|
178
|
+
signal.throwIfAborted();
|
|
179
|
+
const body = JSON.stringify(authority.wire === "openai-chat"
|
|
180
|
+
? { model: authority.wireModel, messages: [{ role: "user", content: COMPLETION_TEST_PROMPT }], stream: true, max_tokens: 32,
|
|
181
|
+
...(authority.providerRouting ? { provider: authority.providerRouting } : {}) }
|
|
182
|
+
: { model: authority.wireModel, input: COMPLETION_TEST_PROMPT, stream: true, max_output_tokens: 32, store: false });
|
|
183
|
+
const pending = providerOutboundPost(name, { ...authority.routed, allowPrivateNetwork: authority.allowLocalCleartextPost }, authority.url, { headers: authority.headers, body, signal }, {
|
|
184
|
+
...deps.outboundDependencies,
|
|
185
|
+
allowLocalCleartextPost: authority.allowLocalCleartextPost,
|
|
186
|
+
beforeSend: () => {
|
|
187
|
+
try {
|
|
188
|
+
const current = config.providers[name];
|
|
189
|
+
return current === row && !current.disabled && !transportExclusion(current)
|
|
190
|
+
&& derive(config, name, current, model).fingerprint === authority.fingerprint
|
|
191
|
+
&& (!authority.routed.apiKey || providerApiKeySelectionIsCurrent(config, name, authority.routed));
|
|
192
|
+
} catch { return false; }
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
// Even an executor ignoring AbortSignal cannot retain a late response body.
|
|
196
|
+
void pending.then(late => { if (signal.aborted) void late.body?.cancel().catch(() => {}); }, () => {});
|
|
197
|
+
response = await abortable(pending, signal);
|
|
198
|
+
if (response.status >= 300 && response.status < 400) return fail("redirect");
|
|
199
|
+
if (!response.ok) return fail("http_status", response.status);
|
|
200
|
+
if (!response.body) return fail("premature_eof");
|
|
201
|
+
reader = response.body.getReader();
|
|
202
|
+
let bytes = 0;
|
|
203
|
+
const counted = new ReadableStream<Uint8Array>({
|
|
204
|
+
async pull(target) {
|
|
205
|
+
try {
|
|
206
|
+
const next = await abortable(reader!.read(), signal);
|
|
207
|
+
if (next.done) { target.close(); return; }
|
|
208
|
+
bytes += next.value.byteLength;
|
|
209
|
+
if (bytes > COMPLETION_TEST_MAX_BYTES) throw new DiagnosticFailure("too_large");
|
|
210
|
+
target.enqueue(next.value);
|
|
211
|
+
} catch (error) { target.error(error); }
|
|
212
|
+
},
|
|
213
|
+
cancel() { void reader!.cancel().catch(() => {}); },
|
|
214
|
+
}, { highWaterMark: 0 });
|
|
215
|
+
for await (const event of decodeServerSentEvents(counted, { signal, translatorBudget })) {
|
|
216
|
+
if (authority.wire === "openai-chat" && event.data.trim() === "[DONE]") { terminal = true; break; }
|
|
217
|
+
let chunk: Record<string, any>;
|
|
218
|
+
try { chunk = JSON.parse(event.data); } catch { throw new DiagnosticFailure("stream_error"); }
|
|
219
|
+
if (!chunk || typeof chunk !== "object" || chunk.error || event.event === "error" || chunk.type === "error" || chunk.type === "response.failed") {
|
|
220
|
+
throw new DiagnosticFailure("stream_error");
|
|
221
|
+
}
|
|
222
|
+
let text = false;
|
|
223
|
+
if (authority.wire === "openai-chat") {
|
|
224
|
+
observe(chunk.model);
|
|
225
|
+
if (Array.isArray(chunk.choices)) for (const choice of chunk.choices) {
|
|
226
|
+
if (typeof choice?.delta?.content === "string" && choice.delta.content.length > 0) text = true;
|
|
227
|
+
if (choice?.finish_reason != null) finishReason = true;
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
const type = chunk.type ?? event.event;
|
|
231
|
+
if (["response.created", "response.completed", "response.incomplete"].includes(type)) observe(chunk.response?.model);
|
|
232
|
+
text = type === "response.output_text.delta" && typeof chunk.delta === "string" && chunk.delta.length > 0;
|
|
233
|
+
terminal = type === "response.completed" || type === "response.incomplete";
|
|
234
|
+
if (type === "response.failed") throw new DiagnosticFailure("stream_error");
|
|
235
|
+
}
|
|
236
|
+
if (text) { content = true; result.ttftMs ??= Math.max(0, Math.round(now() - started)); }
|
|
237
|
+
if (terminal) break;
|
|
238
|
+
}
|
|
239
|
+
signal.throwIfAborted();
|
|
240
|
+
if (!terminal && !(authority.wire === "openai-chat" && finishReason)) return fail("premature_eof");
|
|
241
|
+
if (!content) return fail("no_content");
|
|
242
|
+
result.ok = true;
|
|
243
|
+
result.termination = "completed";
|
|
244
|
+
return result;
|
|
245
|
+
} catch (error) {
|
|
246
|
+
return fail(requestSignal?.aborted ? "aborted" : deadline.aborted ? "timeout"
|
|
247
|
+
: error instanceof DiagnosticFailure ? error.code
|
|
248
|
+
: error instanceof ProviderOutboundSendCancelledError ? "credential_changed"
|
|
249
|
+
: error instanceof ProviderOutboundPolicyError ? "destination_refused" : "network");
|
|
250
|
+
} finally {
|
|
251
|
+
translatorBudget.dispose();
|
|
252
|
+
result.totalLatencyMs = Math.max(0, Math.round(now() - started));
|
|
253
|
+
if (observed !== undefined && !conflicting) {
|
|
254
|
+
result.modelComparison = observed === authority.wireModel ? "match" : "mismatch";
|
|
255
|
+
delete result.comparisonNote;
|
|
256
|
+
result.upstreamModel = maskedModel(observed, secrets);
|
|
257
|
+
} else if (conflicting) result.comparisonNote = "conflicting_metadata";
|
|
258
|
+
controller.abort();
|
|
259
|
+
// Cancellation is initiated on every exit; do not wait on a misbehaving executor.
|
|
260
|
+
if (reader) { void reader.cancel().catch(() => {}); try { reader.releaseLock(); } catch { /* read pending */ } }
|
|
261
|
+
else void response?.body?.cancel().catch(() => {});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const inFlight = new Set<string>();
|
|
266
|
+
export async function handleProviderCompletionTestRoute(
|
|
267
|
+
{ req, url, config }: Pick<ManagementContext, "req" | "url" | "config">,
|
|
268
|
+
deps: ProviderCompletionTestDependencies = {},
|
|
269
|
+
): Promise<Response> {
|
|
270
|
+
let body: unknown;
|
|
271
|
+
try { body = await readManagementJsonBody(req); } catch (error) {
|
|
272
|
+
return managementBodyTooLargeResponse(error, req, config) ?? jsonResponse({ error: "invalid completion test request" }, 400);
|
|
273
|
+
}
|
|
274
|
+
if (!body || typeof body !== "object" || Array.isArray(body)
|
|
275
|
+
|| Object.keys(body).some(key => key !== "model" && key !== "confirm")
|
|
276
|
+
|| (body as { confirm?: unknown }).confirm !== true || typeof (body as { model?: unknown }).model !== "string") {
|
|
277
|
+
return jsonResponse({ error: "invalid completion test request" }, 400);
|
|
278
|
+
}
|
|
279
|
+
const model = (body as { model: string }).model.trim();
|
|
280
|
+
if (!model || model.length > 256 || /[\x00-\x1f\x7f-\x9f]/.test((body as { model: string }).model)) {
|
|
281
|
+
return jsonResponse({ error: "invalid completion test request" }, 400);
|
|
282
|
+
}
|
|
283
|
+
const name = url.searchParams.get("name") ?? "";
|
|
284
|
+
if (!Object.hasOwn(config.providers, name) || !config.providers[name]) return jsonResponse({ error: "unknown provider" }, 404);
|
|
285
|
+
if (inFlight.has(name)) return jsonResponse({ error: "completion test already running for this provider" }, 409);
|
|
286
|
+
inFlight.add(name);
|
|
287
|
+
try { return jsonResponse(await runProviderCompletionTest({ config, name, model, signal: req.signal }, deps)); }
|
|
288
|
+
finally { inFlight.delete(name); }
|
|
289
|
+
}
|
|
@@ -764,7 +764,7 @@ function applyProviderPatchFields(
|
|
|
764
764
|
if (Object.hasOwn(rawBody, "headers")) {
|
|
765
765
|
const headersValue = rawBody.headers;
|
|
766
766
|
if (headersValue === null || (isPlainRecord(headersValue) && Object.keys(headersValue).length === 0)) {
|
|
767
|
-
// Registry-owned static metadata (e.g.
|
|
767
|
+
// Registry-owned static metadata (e.g. meta-muse's x-api-version marker)
|
|
768
768
|
// is not user-managed: restoring it keeps the upstream transport intact after a
|
|
769
769
|
// clear instead of deleting the whole block.
|
|
770
770
|
const entry = getProviderRegistryEntry(name);
|
|
@@ -1357,6 +1357,12 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1357
1357
|
if (submittedModelContextTiers && liveTiers) candidate.modelContextTiers = Object.assign(Object.create(null), liveTiers, candidate.modelContextTiers ?? {});
|
|
1358
1358
|
else if (!submittedModelContextTiers && liveTiers) candidate.modelContextTiers = { ...liveTiers };
|
|
1359
1359
|
else if (!submittedModelContextTiers) delete candidate.modelContextTiers;
|
|
1360
|
+
// Read omitted policy at publication time, after any concurrent operator edit.
|
|
1361
|
+
if (!Object.hasOwn(rawProvider, "accountAutoPause")) {
|
|
1362
|
+
const livePolicy = config.providers[name]?.accountAutoPause;
|
|
1363
|
+
if (livePolicy === undefined) delete candidate.accountAutoPause;
|
|
1364
|
+
else candidate.accountAutoPause = structuredClone(livePolicy);
|
|
1365
|
+
}
|
|
1360
1366
|
// Full forms omit the opt-in; preserve the latest value, including a concurrent clear.
|
|
1361
1367
|
if (!Object.hasOwn(rawProvider, "inferContextWindow")) {
|
|
1362
1368
|
const liveInference = config.providers[name]?.inferContextWindow;
|
|
@@ -1602,6 +1608,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
|
|
|
1602
1608
|
});
|
|
1603
1609
|
}
|
|
1604
1610
|
|
|
1611
|
+
if (url.pathname === "/api/providers/test-completion" && req.method === "POST") return (await import("./provider-completion-test")).handleProviderCompletionTestRoute(ctx);
|
|
1612
|
+
|
|
1605
1613
|
// Lightweight connectivity probe: perform the provider's live /models fetch DIRECTLY and
|
|
1606
1614
|
// report only real upstream evidence. The catalog aggregate (fetchAllModels) deliberately
|
|
1607
1615
|
// hides fetch failures behind stale/static fallbacks, so a catalog-presence check would
|
|
@@ -364,6 +364,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
364
364
|
{ method: "PUT", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: true },
|
|
365
365
|
{ method: "PUT", path: "/api/providers/keys/active", module: "server/management/oauth-account-routes", mutates: true },
|
|
366
366
|
{ method: "PUT", path: "/api/providers/keys/alias", module: "server/management/oauth-account-routes", mutates: true },
|
|
367
|
+
{ method: "PUT", path: "/api/providers/keys/pause", module: "server/management/oauth-account-routes", mutates: true },
|
|
367
368
|
// server/management/provider-routes
|
|
368
369
|
{ method: "DELETE", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
|
|
369
370
|
{ method: "GET", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: false },
|
|
@@ -373,6 +374,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
|
|
|
373
374
|
{ method: "GET", path: "/api/providers", module: "server/management/provider-routes", mutates: false },
|
|
374
375
|
{ method: "PATCH", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
|
|
375
376
|
{ method: "POST", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
|
|
377
|
+
{ method: "POST", path: "/api/providers/test-completion", module: "server/management/provider-routes", mutates: true },
|
|
376
378
|
{ method: "POST", path: "/api/providers/test", module: "server/management/provider-routes", mutates: true },
|
|
377
379
|
{ method: "PUT", path: "/api/providers", module: "server/management/provider-routes", mutates: true },
|
|
378
380
|
{ method: "PUT", path: "/api/provider-context-caps", module: "server/management/provider-routes", mutates: true },
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { captureAccountAutoPauseAttempt, wasAccountAutoPaused } from "../oauth/account-auto-pause";
|
|
1
2
|
import { createPhysicalSendReporter } from "../lib/request-execution-budget";
|
|
2
3
|
import { createInferenceSendBudget } from "./inference/context";
|
|
3
4
|
/**
|
|
@@ -44,6 +45,7 @@ import { classifyError } from "../lib/errors";
|
|
|
44
45
|
import { relaySseWithPayloadRewrite } from "./sse-payload-rewrite";
|
|
45
46
|
import { redactSecretString } from "../lib/redact";
|
|
46
47
|
import { resolveClientRetryAfter } from "../lib/retry-after";
|
|
48
|
+
import { enrichOpenCodeZenUpstreamMessage } from "../providers/opencode-zen-rate-limit";
|
|
47
49
|
import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
|
|
48
50
|
import {
|
|
49
51
|
applyReplayRefusalClientHeaders,
|
|
@@ -119,7 +121,7 @@ import {
|
|
|
119
121
|
recordKeyWireAttemptUsage,
|
|
120
122
|
type RequestLogContext,
|
|
121
123
|
} from "./request-log";
|
|
122
|
-
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
|
|
124
|
+
import { fetchWithHeaderTimeout, physicalSendObserverInit, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
|
|
123
125
|
import { anthropicBillingPhysicalFetch } from "../providers/anthropic-billing-ownership";
|
|
124
126
|
import { rotateAnthropicBillingKeyOnResponse } from "../providers/anthropic-billing-recovery";
|
|
125
127
|
import { linkAbortSignal } from "./responses/core-lifetime";
|
|
@@ -544,6 +546,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
544
546
|
pendingDistributedFailure = undefined;
|
|
545
547
|
record?.();
|
|
546
548
|
};
|
|
549
|
+
/** Send the native Messages request under the transient retry policy, re-validating the selected OAuth account or API key at the dispatch boundary. */
|
|
547
550
|
const send = async (recovery?: "rate-limit-429" | "oauth-401" | "oauth-account-403" | "key-429" | "key-401"): Promise<Response> => {
|
|
548
551
|
const remaining = remainingTransientSends();
|
|
549
552
|
if (requestTransientPolicy && remaining <= 0) {
|
|
@@ -615,6 +618,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
615
618
|
}
|
|
616
619
|
const releaseFamily = snapshot && oauthBinding ? anthropicModelQuotaFor(oauthBinding.instance).claimAnthropicFamilyRevalidation(snapshot.accountId, route.modelId) : () => {};
|
|
617
620
|
if (!releaseFamily) throw new AnthropicAccountCooldownError(1);
|
|
621
|
+
const observeAutoPause = captureAccountAutoPauseAttempt(route.providerName, activeProvider, { config, oauth: sendingBinding?.snapshot }, headers);
|
|
618
622
|
const selection = activeProvider._apiKeyAttempt;
|
|
619
623
|
let dispatched: Response;
|
|
620
624
|
const spendReport = createPhysicalSendReporter(physicalBudget, () => ({
|
|
@@ -640,7 +644,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
640
644
|
anthropicBillingPhysicalFetch((activeProvider as OcxProviderTransport).fetch ?? execute, route.providerName, activeProvider),
|
|
641
645
|
route.providerName, activeProvider, wire.url, activeProvider.apiKeyTransport === "bearer" ? "authorization" : "x-api-key"),
|
|
642
646
|
wire.url,
|
|
643
|
-
applyUpstreamRecoveryInit({ ...init, method: "POST", headers, body: wire.body }, transportRecovery),
|
|
647
|
+
applyUpstreamRecoveryInit({ ...init, method: "POST", headers, body: wire.body, ...physicalSendObserverInit(observeAutoPause, wire.url) }, transportRecovery),
|
|
644
648
|
{ providerName: route.providerName, provider: activeProvider },
|
|
645
649
|
);
|
|
646
650
|
} finally {
|
|
@@ -658,7 +662,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
658
662
|
&& credentialGeneration(current.credential) === snapshot.generation
|
|
659
663
|
&& current.credential.accountId === providerAccountUuid) {
|
|
660
664
|
bindAnthropicRefusalCredentialForSend(dispatched, sendOwner, providerAccountUuid);
|
|
661
|
-
recordAnthropicAccountQuotaFromHeadersForInstance(nativeInstance!, snapshot.accountId, dispatched.headers, writerGeneration, dispatched.status, route.modelId);
|
|
665
|
+
recordAnthropicAccountQuotaFromHeadersForInstance(nativeInstance!, snapshot.accountId, dispatched.headers, writerGeneration, dispatched.status, route.modelId, snapshot.generation);
|
|
662
666
|
}
|
|
663
667
|
} catch { /* Passive observation must not fail the response. */ }
|
|
664
668
|
}
|
|
@@ -671,7 +675,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
671
675
|
? () => { recordDistributedResponseFailure(dispatched, config, route.providerName, req.signal); }
|
|
672
676
|
: undefined;
|
|
673
677
|
if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal);
|
|
674
|
-
return dispatched;
|
|
678
|
+
return observeAutoPause ? observeAutoPause(dispatched) : dispatched;
|
|
675
679
|
},
|
|
676
680
|
}),
|
|
677
681
|
),
|
|
@@ -699,9 +703,19 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
699
703
|
let billingRecoveryActive = false;
|
|
700
704
|
const keySendAvailable = () => transientSendAvailable()
|
|
701
705
|
&& (!billingRecoveryActive || Math.max(physicalSendsUsed, physicalBudget.used) < physicalBudget.policy.maxTotalModelSends);
|
|
706
|
+
let autoPauseMoves = 0;
|
|
702
707
|
const recoveryPasses = activeProvider._apiKeyAttempt?.distributed ? activeProvider.apiKeyPool?.length ?? 1 : 1;
|
|
703
708
|
for (let pass = 0; ; pass++) {
|
|
704
709
|
const priorResponse = response;
|
|
710
|
+
for (; wasAccountAutoPaused(response) && !oauthBinding && config.providers[route.providerName]?.apiKey && autoPauseMoves < 3 && keySendAvailable(); autoPauseMoves++) {
|
|
711
|
+
const fresh = resolveCurrentProviderApiKeyTransport(config, route.providerName, { ...activeProvider, _apiKeyAttempt: undefined });
|
|
712
|
+
const next = fresh && (selectProactiveApiKeyTransport(config, route.providerName, fresh) ?? fresh);
|
|
713
|
+
if (!next) break;
|
|
714
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* closed */ }
|
|
715
|
+
rebuildFor(next);
|
|
716
|
+
response = await send("key-401");
|
|
717
|
+
}
|
|
718
|
+
|
|
705
719
|
// A credential-scoped 401 on a static key pool says nothing about the sibling keys.
|
|
706
720
|
while (response.status === 401 && distributedResponseOwnsKey && hasKeyPoolFailover(activeProvider) && keySendAvailable()) {
|
|
707
721
|
const rotated = rotateProviderTransportOn401(config, route.providerName, activeProvider, {
|
|
@@ -761,6 +775,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
761
775
|
if (response === priorResponse || isNonReplayableResponse(response) || !keySendAvailable()) break;
|
|
762
776
|
// Billing class transitions share the physical-send ceiling; distributed-only
|
|
763
777
|
// transitions additionally retain their original bounded pool-pass allowance.
|
|
778
|
+
if (wasAccountAutoPaused(response) && autoPauseMoves < 3) continue;
|
|
764
779
|
if (distributedResponseOwnsKey && (
|
|
765
780
|
(billingRecoveryActive && [401, 429].includes(response.status))
|
|
766
781
|
|| (activeProvider.adapter === "anthropic" && hasKeyPoolFailover(activeProvider) && [400, 402].includes(response.status))
|
|
@@ -845,7 +860,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
|
|
|
845
860
|
upstream.abort();
|
|
846
861
|
return fail(499, "Client cancelled request", "api_error");
|
|
847
862
|
}
|
|
848
|
-
return retainUpstreamMessagesRequestId(nativeMessagesErrorResponse(response, bodyText, finishLog), response.headers);
|
|
863
|
+
return retainUpstreamMessagesRequestId(nativeMessagesErrorResponse(response, bodyText, finishLog, route.providerName, activeProvider), response.headers);
|
|
849
864
|
}
|
|
850
865
|
|
|
851
866
|
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
@@ -999,7 +1014,7 @@ function isNativeOverflowError(error: Rec, message: string): boolean {
|
|
|
999
1014
|
* Messages path, so a client sees the same contract on either lane: transient 5xx become 529
|
|
1000
1015
|
* with a retry hint, and a proxy-side replay refusal keeps its no-retry marking.
|
|
1001
1016
|
*/
|
|
1002
|
-
function nativeMessagesErrorResponse(response: Response, bodyText: string, finishLog: FinishLog): Response {
|
|
1017
|
+
function nativeMessagesErrorResponse(response: Response, bodyText: string, finishLog: FinishLog, providerName: string, provider: OcxProviderConfig): Response {
|
|
1003
1018
|
let upstreamType: string | undefined;
|
|
1004
1019
|
let upstreamCode: string | undefined;
|
|
1005
1020
|
let upstreamMessage: string | undefined;
|
|
@@ -1013,8 +1028,20 @@ function nativeMessagesErrorResponse(response: Response, bodyText: string, finis
|
|
|
1013
1028
|
}
|
|
1014
1029
|
} catch { /* keep generic classification */ }
|
|
1015
1030
|
const detail = formatAnthropicErrorBody(response.status, response.headers, bodyText);
|
|
1016
|
-
const
|
|
1031
|
+
const rawMessage = upstreamMessage
|
|
1017
1032
|
?? (detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`);
|
|
1033
|
+
const message = enrichOpenCodeZenUpstreamMessage(rawMessage, {
|
|
1034
|
+
status: response.status,
|
|
1035
|
+
providerName,
|
|
1036
|
+
baseUrl: provider.baseUrl,
|
|
1037
|
+
adapter: provider.adapter,
|
|
1038
|
+
authMode: provider.authMode,
|
|
1039
|
+
hasApiKey: Boolean(provider.apiKey?.trim()),
|
|
1040
|
+
upstreamRetryAfter: response.headers.get("retry-after"),
|
|
1041
|
+
upstreamErrorType: upstreamType,
|
|
1042
|
+
upstreamErrorCode: upstreamCode,
|
|
1043
|
+
supportsHttpSameKeyRetry: true,
|
|
1044
|
+
});
|
|
1018
1045
|
const classified = classifyError(
|
|
1019
1046
|
response.status,
|
|
1020
1047
|
upstreamType ?? (response.status === 401 ? "authentication_error"
|