@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
|
@@ -4,6 +4,7 @@ import type { OcxConfig, OcxProviderConfig } from "../../types";
|
|
|
4
4
|
import { PROVIDER_REGISTRY } from "../../providers/registry";
|
|
5
5
|
import { fastPolicyForModel, serviceTierSupportFromPolicy } from "../../providers/service-tier";
|
|
6
6
|
import { resolveProviderAuthTransport } from "../../providers/fastwire";
|
|
7
|
+
import { defaultAnthropicCacheRetention } from "../../adapters/anthropic/cache-retention";
|
|
7
8
|
import { localFingerprint } from "../../lab/digest";
|
|
8
9
|
import type { LabBehaviorSource, LabBehaviorValues } from "../../lab/live/types";
|
|
9
10
|
|
|
@@ -237,7 +238,9 @@ export function resolveProductionBehaviorValues(
|
|
|
237
238
|
}),
|
|
238
239
|
"tools.builtinNameEscaping": behaviorRow("provider_config", effective.escapeBuiltinToolNames === true),
|
|
239
240
|
"cache.forwarding": behaviorRow("provider_config", effective.promptCacheKey === true),
|
|
240
|
-
|
|
241
|
+
// The wire TTL, not just the setting: an omitted value means 1h on first-party Anthropic.
|
|
242
|
+
"cache.retention": behaviorRow("global_config", config.cacheRetention
|
|
243
|
+
?? (adapter === "anthropic" ? defaultAnthropicCacheRetention(effective) : "short")),
|
|
241
244
|
"anthropic.eofPolicy": behaviorRow("provider_config", effective.anthropicEofTolerance === true ? "tolerant" : "strict"),
|
|
242
245
|
"openai-chat.eofPolicy": behaviorRow("provider_config", effective.openaiChatEofTolerance === true ? "tolerant" : "strict"),
|
|
243
246
|
"google.mode": behaviorRow("provider_config", effective.googleMode ?? null),
|
package/src/server/auth-cors.ts
CHANGED
|
@@ -40,6 +40,7 @@ import { DECLARABLE_HOSTED_TOOL_TYPES } from "../responses/hosted-tool-policy";
|
|
|
40
40
|
import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
|
|
41
41
|
import { providerConfigSeed } from "../providers/derive";
|
|
42
42
|
import { anthropicOAuthInstanceConfigError } from "../config/schema/anthropic-account-pool";
|
|
43
|
+
import { accountAutoPausePolicySchema } from "../config/schema/account-auto-pause";
|
|
43
44
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
44
45
|
import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
|
|
45
46
|
import { modelAutoCompactTokenLimitsConfigError } from "../providers/auto-compact-budget";
|
|
@@ -740,6 +741,9 @@ export function providerManagementConfigError(
|
|
|
740
741
|
return "provider must be a plain object";
|
|
741
742
|
}
|
|
742
743
|
const raw = provider as Record<string, unknown>;
|
|
744
|
+
if (raw.accountAutoPause !== undefined && !accountAutoPausePolicySchema.safeParse(raw.accountAutoPause).success) {
|
|
745
|
+
return "accountAutoPause must be an object with optional boolean enabled and integer consecutiveFailures (1-100)";
|
|
746
|
+
}
|
|
743
747
|
const contextTiersError = contextTierRecordConfigError(raw.modelContextTiers);
|
|
744
748
|
if (contextTiersError) return contextTiersError;
|
|
745
749
|
const capabilitiesError = modelCapabilitiesConfigError(raw.modelCapabilities);
|
|
@@ -796,6 +800,8 @@ export function providerManagementConfigError(
|
|
|
796
800
|
// sends recover, not what the canonical forward seed is. Validated below
|
|
797
801
|
// (retryOnResetPolicyConfigError).
|
|
798
802
|
delete canonicalCandidate.retryOnReset;
|
|
803
|
+
// Validated account eligibility policy is independent of the canonical transport.
|
|
804
|
+
delete canonicalCandidate.accountAutoPause;
|
|
799
805
|
// Context windows are the same kind of user-owned overlay as requestPacing: the operator
|
|
800
806
|
// narrowing what their own native rows advertise. They can only ever LOWER the measured
|
|
801
807
|
// window (see nativeOpenAiContextWindow), so admitting them cannot widen what the proxy
|
|
@@ -1087,6 +1093,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
|
|
|
1087
1093
|
apiKeyPool: "redacted",
|
|
1088
1094
|
// Ordering preference only; it names no key material, so an editor may read and set it.
|
|
1089
1095
|
apiKeyPoolStrategy: "editor",
|
|
1096
|
+
accountAutoPause: "editor",
|
|
1090
1097
|
apiKeySelectionRevision: "runtime",
|
|
1091
1098
|
_apiKeyAttempt: "runtime",
|
|
1092
1099
|
defaultModel: "editor",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OcxConfig, StorageCleanupPolicy } from "../types";
|
|
2
2
|
import { registerCodexLowQuotaProtection, type LowQuotaRegistration } from "../codex/low-quota-protection";
|
|
3
|
+
import { resolveLowQuotaDesktopSink } from "../codex/low-quota-desktop-notifier";
|
|
3
4
|
import type { LowQuotaEvent } from "../codex/low-quota-events";
|
|
4
5
|
import { startStateStoreSweeper } from "../lib/state-store-sweeper";
|
|
5
6
|
import {
|
|
@@ -209,7 +210,7 @@ export function acquireServerBackgroundLifecycle(
|
|
|
209
210
|
fanoutRevoked: false,
|
|
210
211
|
applyPolicy,
|
|
211
212
|
resources: acquireServerResourceOwner(),
|
|
212
|
-
lowQuota: registerCodexLowQuotaProtection(config),
|
|
213
|
+
lowQuota: registerCodexLowQuotaProtection(config, { desktopSink: () => resolveLowQuotaDesktopSink() }),
|
|
213
214
|
};
|
|
214
215
|
try {
|
|
215
216
|
if (!processLoops) {
|
|
@@ -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
|
import { observeAdvisorNativeReasoning } from "./inference/advisor-diagnostics";
|
|
@@ -64,11 +65,10 @@ import { RequestPacingQueueOverloadError } from "../providers/request-pacing";
|
|
|
64
65
|
import { stampApiKeyAccountLabel } from "../providers/label";
|
|
65
66
|
import { distributedKeyPhysicalFetch, distributedKeyResponseOwner } from "../providers/api-key-response-ownership";
|
|
66
67
|
import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../providers/api-key-selection";
|
|
67
|
-
import { enrichOpenCodeZenFreeTierMessage } from "../providers/opencode-zen-rate-limit";
|
|
68
68
|
import type { OcxProviderTransport } from "../providers/xai-transport";
|
|
69
69
|
import type { RouteResult } from "../router";
|
|
70
70
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
71
|
-
import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
|
|
71
|
+
import { fetchWithHeaderTimeout, physicalSendObserverInit, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
|
|
72
72
|
import { isLocalUpstream } from "../lib/local-upstream";
|
|
73
73
|
import { linkAbortSignal } from "./responses";
|
|
74
74
|
import {
|
|
@@ -449,7 +449,7 @@ export async function runNativeChatAttempt(
|
|
|
449
449
|
let responseOwnsDistributedKey = true;
|
|
450
450
|
const send = async (
|
|
451
451
|
request: AdapterRequest,
|
|
452
|
-
recovery?: "rate-limit-429" | "key-
|
|
452
|
+
recovery?: "rate-limit-429" | "key-401" | "key-429" | UpstreamSendRecovery,
|
|
453
453
|
singleShot = false,
|
|
454
454
|
): Promise<Response> => {
|
|
455
455
|
try {
|
|
@@ -516,6 +516,7 @@ export async function runNativeChatAttempt(
|
|
|
516
516
|
const spendReport = createPhysicalSendReporter(physicalBudget, () => ({
|
|
517
517
|
poolId: logCtx.spendPoolId ?? route.providerName, identityId: logCtx.accountLogLabel,
|
|
518
518
|
}), execution.comboDispatchPermit);
|
|
519
|
+
const observeAutoPause = captureAccountAutoPauseAttempt(route.providerName, activeProvider, { config, oauth: undefined }, headers);
|
|
519
520
|
let dispatched: Response;
|
|
520
521
|
try {
|
|
521
522
|
// Capture this request's spend policy at its first physical boundary, including
|
|
@@ -538,6 +539,7 @@ export async function runNativeChatAttempt(
|
|
|
538
539
|
request.url,
|
|
539
540
|
applyUpstreamRecoveryInit({
|
|
540
541
|
...init, method: request.method, headers, body: request.body,
|
|
542
|
+
...physicalSendObserverInit(observeAutoPause, request.url),
|
|
541
543
|
}, transportRecovery),
|
|
542
544
|
// Reselection can replace the provider transport and the wire shape, so the
|
|
543
545
|
// egress route is bound to the provider this send actually uses. Omitting it
|
|
@@ -552,7 +554,7 @@ export async function runNativeChatAttempt(
|
|
|
552
554
|
responseOwnsDistributedKey = !activeProvider._apiKeyAttempt?.distributed
|
|
553
555
|
|| distributedKeyResponseOwner(dispatched, config, route.providerName) !== null;
|
|
554
556
|
if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal);
|
|
555
|
-
return dispatched;
|
|
557
|
+
return observeAutoPause ? observeAutoPause(dispatched) : dispatched;
|
|
556
558
|
},
|
|
557
559
|
}),
|
|
558
560
|
);
|
|
@@ -585,6 +587,23 @@ export async function runNativeChatAttempt(
|
|
|
585
587
|
response = await send(activeRequest);
|
|
586
588
|
const retryPolicy = rateLimitRetryPolicyFor(activeProvider);
|
|
587
589
|
let retries = 0;
|
|
590
|
+
let autoPauseMoves = 0;
|
|
591
|
+
for (;;) {
|
|
592
|
+
const priorResponse = response;
|
|
593
|
+
for (; wasAccountAutoPaused(response) && config.providers[route.providerName]?.apiKey && autoPauseMoves < 3 && transientSendAvailable(); autoPauseMoves++) {
|
|
594
|
+
const fresh = resolveCurrentProviderApiKeyTransport(config, route.providerName, { ...activeProvider, _apiKeyAttempt: undefined });
|
|
595
|
+
const next = fresh && (selectProactiveApiKeyTransport(config, route.providerName, fresh) ?? fresh);
|
|
596
|
+
if (!next) break;
|
|
597
|
+
try { void response.body?.cancel().catch(() => {}); } catch { /* closed */ }
|
|
598
|
+
activeProvider = next;
|
|
599
|
+
stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
|
|
600
|
+
activeAdapter = createOpenAIChatAdapter(activeProvider);
|
|
601
|
+
releaseRetainedRequest();
|
|
602
|
+
activeRequest = buildActiveRequest();
|
|
603
|
+
retainRequest(activeRequest);
|
|
604
|
+
response = await send(activeRequest, "key-401");
|
|
605
|
+
}
|
|
606
|
+
|
|
588
607
|
while (
|
|
589
608
|
response.status === 429
|
|
590
609
|
// A 429 this proxy synthesized for a refused reset replay is not a provider rate
|
|
@@ -632,6 +651,10 @@ export async function runNativeChatAttempt(
|
|
|
632
651
|
retainRequest(activeRequest);
|
|
633
652
|
response = await send(activeRequest, failureStatus === 401 ? "key-401" : "key-429");
|
|
634
653
|
}
|
|
654
|
+
if (response !== priorResponse && wasAccountAutoPaused(response)) continue;
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
657
|
+
|
|
635
658
|
} catch (error) {
|
|
636
659
|
releaseRetainedRequest();
|
|
637
660
|
cleanupAbort();
|
|
@@ -694,20 +717,12 @@ export async function runNativeChatAttempt(
|
|
|
694
717
|
&& (isCyberPolicyCode(upstreamCode) || isCyberPolicyMessage(upstreamMessage))
|
|
695
718
|
? upstreamMessage
|
|
696
719
|
: detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`;
|
|
697
|
-
// Zen's keyless free tier refuses the request outright rather than rate-limiting it, and
|
|
698
|
-
// the raw `MissingSessionID` tells a user nothing about why or what to do (#4121).
|
|
699
|
-
const clientMessage = enrichOpenCodeZenFreeTierMessage(message, {
|
|
700
|
-
providerName: route.providerName,
|
|
701
|
-
baseUrl: route.provider.baseUrl,
|
|
702
|
-
adapter: route.provider.adapter,
|
|
703
|
-
upstreamErrorType: upstreamType,
|
|
704
|
-
});
|
|
705
720
|
const classified = classifyError(
|
|
706
721
|
response.status,
|
|
707
722
|
upstreamType ?? (response.status === 401 ? "authentication_error"
|
|
708
723
|
: response.status === 429 ? "rate_limit_error"
|
|
709
724
|
: response.status >= 500 ? "server_error" : "invalid_request_error"),
|
|
710
|
-
|
|
725
|
+
message,
|
|
711
726
|
);
|
|
712
727
|
// The verdict is read once, from the response that carries it and from the code a
|
|
713
728
|
// re-wrapped body kept -- never from the status, which a real rate limit shares.
|
|
@@ -1110,7 +1110,8 @@ async function handleClaudeMessagesWithBudget(
|
|
|
1110
1110
|
const finalizeNativeLog = createFinalRequestLog(logIds, logCtx).finish;
|
|
1111
1111
|
const upstream = await handleResponses(internalReq, {
|
|
1112
1112
|
...buildClaudeReplayConfig(config),
|
|
1113
|
-
// Operator config wins; otherwise the caller's
|
|
1113
|
+
// Operator config wins; otherwise the caller's explicit TTLs (all 1h, or a pinned 5m) carry
|
|
1114
|
+
// over, and TTL-less breakpoints take the destination default.
|
|
1114
1115
|
cacheRetention: config.cacheRetention ?? callerCacheRetention(anthropicBody),
|
|
1115
1116
|
}, logCtx, {
|
|
1116
1117
|
// Routing keeps Claude-only sidecar overrides; admission policy must follow the live owner.
|
|
@@ -17,6 +17,7 @@ import { createLinkSupervisor, type LinkSupervisor } from "../../link/supervisor
|
|
|
17
17
|
import { linkManagementRouteAllowed } from "../../link/management-routes";
|
|
18
18
|
import { saveConfigPreservingClaudeCode } from "../../config/live-reconcile";
|
|
19
19
|
import { reconcileLiveStateStores } from "../../lib/state-store-registrations";
|
|
20
|
+
import { startJevQuotaActivation } from "../../combos/jev-quota-activation";
|
|
20
21
|
export { LINK_INGRESS_HOSTNAME } from "./link-listener";
|
|
21
22
|
import type { ServerIngress } from "./serve-options";
|
|
22
23
|
|
|
@@ -51,6 +52,7 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
|
|
|
51
52
|
const claudeIntercept: ClaudeInterceptLifecycle<T> = createClaudeInterceptLifecycle<T>();
|
|
52
53
|
const linkListener: LinkListenerLifecycle<T> = createLinkListenerLifecycle<T>(linkDeps);
|
|
53
54
|
let activeConfig: OcxConfig | undefined;
|
|
55
|
+
let stopJevQuotaActivation: (() => void) | undefined;
|
|
54
56
|
const supervisor = createLinkSupervisor({
|
|
55
57
|
apiKeys: () => activeConfig?.apiKeys ?? [],
|
|
56
58
|
revokeApiKey: id => {
|
|
@@ -114,6 +116,8 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
|
|
|
114
116
|
maxRequestBodySize: ctx.maxRequestBodySize,
|
|
115
117
|
dispatch: ctx.dispatch,
|
|
116
118
|
});
|
|
119
|
+
// Last, after every listener started: JEV advisory quota follows the live config while serving.
|
|
120
|
+
stopJevQuotaActivation = startJevQuotaActivation(ctx.config);
|
|
117
121
|
},
|
|
118
122
|
ensureStarted: () => linkListener.ensureStarted(),
|
|
119
123
|
close: () => linkListener.close(),
|
|
@@ -132,6 +136,8 @@ export function createOptionalListenerSet<T>(linkDeps: LinkListenerDeps = {}): O
|
|
|
132
136
|
try { await claudeIntercept.stop(); } catch (error) { failure ??= error; }
|
|
133
137
|
unregisterSupervisorAdmission?.();
|
|
134
138
|
unregisterSupervisorAdmission = undefined;
|
|
139
|
+
stopJevQuotaActivation?.();
|
|
140
|
+
stopJevQuotaActivation = undefined;
|
|
135
141
|
if (failure) throw failure;
|
|
136
142
|
},
|
|
137
143
|
};
|
|
@@ -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
|