@bitkyc08/opencodex 2.88.0 → 2.90.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-DgxkE6Md.js +58 -0
- package/gui/dist/assets/App-dnkJ85te.css +1 -0
- package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
- package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
- package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
- package/gui/dist/index.html +1 -1
- package/package.json +2 -2
- 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/base.ts +4 -0
- package/src/adapters/claude-cli/adapter.ts +13 -7
- package/src/adapters/claude-cli/usage-admission.ts +14 -2
- package/src/adapters/coding-agent/protocol.ts +6 -0
- package/src/adapters/coding-agent/turn.ts +14 -4
- package/src/adapters/cursor/protobuf-request.ts +3 -0
- package/src/adapters/google-http.ts +3 -2
- package/src/adapters/google-progress-history.ts +19 -1
- package/src/adapters/google-vertex-endpoint.ts +67 -0
- package/src/adapters/google.ts +62 -37
- package/src/adapters/image.ts +5 -3
- package/src/adapters/input-media-guard.ts +5 -2
- package/src/adapters/kiro-refusal.ts +41 -1
- package/src/adapters/ollama-native.ts +1 -0
- package/src/adapters/openai-chat/messages.ts +34 -4
- package/src/adapters/openai-chat/passthrough.ts +37 -4
- package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
- package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
- package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
- package/src/adapters/openai-chat.ts +7 -6
- package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
- package/src/adapters/openai-responses/opengateway-input.ts +139 -0
- package/src/adapters/openai-responses/passthrough.ts +22 -13
- package/src/adapters/openai-responses/reasoning.ts +68 -1
- package/src/adapters/openai-responses/transport.ts +13 -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/desktop-3p-library.ts +43 -3
- package/src/claude/desktop-3p.ts +20 -10
- package/src/claude/desktop-picker-profile.ts +18 -9
- package/src/claude/desktop-remote-store-io.ts +2 -2
- 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/ensure-desktop-cold-start.ts +2 -2
- 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-lifecycle.ts +19 -6
- package/src/codex/account-pause.ts +6 -1
- package/src/codex/account-runtime-state.ts +5 -0
- package/src/codex/account-store.ts +55 -9
- package/src/codex/account-usability.ts +4 -1
- package/src/codex/auth-api/account-list.ts +13 -5
- package/src/codex/auth-api/account-pause-group.ts +2 -22
- package/src/codex/auth-api/login-collision.ts +59 -0
- package/src/codex/auth-api/login-flow.ts +12 -7
- package/src/codex/auth-api/main-account-probe.ts +3 -2
- package/src/codex/auth-api/pool-mode-gate.ts +81 -13
- package/src/codex/auth-collision.ts +31 -4
- package/src/codex/auth-context.ts +57 -10
- 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/cli-update-plan.ts +18 -3
- package/src/codex/collision-identity.ts +78 -0
- 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-hard-lock.ts +14 -0
- 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/routing/cooldown-math.ts +5 -0
- package/src/codex/routing/failure-window.ts +61 -9
- package/src/codex/routing.ts +4 -3
- package/src/codex/shim-diagnostics.ts +2 -1
- package/src/codex/shim-fingerprint.ts +11 -2
- package/src/codex/shim-templates.ts +16 -5
- package/src/codex/shim-windows-refresh.ts +224 -0
- package/src/codex/shim.ts +45 -21
- package/src/combos/failover.ts +10 -2
- 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/provider-validation.ts +7 -2
- 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 +476 -216
- package/src/generated/model-metadata.ts +1 -1
- package/src/images/loop.ts +34 -11
- 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 +82 -0
- package/src/lib/decision-quota-sink.ts +76 -0
- package/src/lib/provider-admission-wait.ts +14 -0
- package/src/lib/service-secrets.ts +37 -2
- package/src/lib/state-store-registrations.ts +25 -0
- package/src/lib/translator-budget.ts +1 -1
- package/src/lib/upstream-retry.ts +15 -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-import/google-antigravity-adapter.ts +38 -2
- package/src/oauth/account-import/parser.ts +15 -2
- package/src/oauth/account-import/types.ts +7 -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/chatgpt-shared.ts +10 -1
- package/src/oauth/dsh.ts +45 -2
- package/src/oauth/index.ts +5 -35
- package/src/oauth/kiro.ts +44 -1
- package/src/oauth/login-flow-state.ts +11 -2
- 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 +25 -5
- package/src/providers/initial-model-selection.ts +2 -2
- package/src/providers/key-failover.ts +21 -13
- package/src/providers/key-store.ts +8 -3
- package/src/providers/mistral-transport.ts +207 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/openai-sidecar.ts +5 -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 +20 -7
- 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 +45 -8
- package/src/providers/quota/failure-codes.ts +18 -0
- package/src/providers/quota/report-cache.ts +14 -0
- package/src/providers/quota/vendor-probes-oauth.ts +15 -6
- 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 +4 -1
- package/src/providers/quota-types.ts +6 -1
- package/src/providers/quota.ts +8 -3
- package/src/providers/registry/entries-extended.ts +14 -53
- package/src/providers/registry/model-seeds.ts +1 -1
- package/src/providers/registry.ts +1 -2
- package/src/providers/routed-config.ts +1 -1
- package/src/providers/stale-vision-classification-migration.ts +1 -1
- package/src/providers/xai-transport.ts +2 -1
- package/src/responses/audio.ts +46 -0
- package/src/responses/input-media.ts +37 -15
- package/src/responses/parser-content.ts +22 -27
- package/src/responses/parser.ts +18 -10
- package/src/responses/reasoning-retention.ts +218 -11
- package/src/responses/schema.ts +3 -4
- package/src/responses/web-search-replay.ts +147 -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 +36 -20
- package/src/server/claude-messages.ts +2 -1
- package/src/server/index/optional-listeners.ts +6 -0
- package/src/server/index/serve-options.ts +8 -2
- package/src/server/index/websocket-handler.ts +10 -0
- package/src/server/lifecycle.ts +17 -1
- package/src/server/management/combo-routes.ts +17 -2
- package/src/server/management/oauth-account-routes.ts +12 -2
- 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 +29 -4
- package/src/server/messages-shared.ts +4 -1
- package/src/server/request-log.ts +23 -9
- package/src/server/responses/account-auto-pause-recovery.ts +26 -0
- package/src/server/responses/adapter-continuation.ts +2 -2
- package/src/server/responses/adapter-dispatch.ts +79 -30
- package/src/server/responses/codex-ws-exchange.ts +3 -0
- package/src/server/responses/codex-ws-metadata.ts +44 -0
- package/src/server/responses/codex-ws-session.ts +21 -1
- package/src/server/responses/combo-session-recall.ts +25 -0
- package/src/server/responses/combo-stream-preflight.ts +10 -3
- package/src/server/responses/combo-tool-loop-pin.ts +70 -0
- package/src/server/responses/compact.ts +10 -5
- package/src/server/responses/context-overflow.ts +141 -2
- package/src/server/responses/core-auth.ts +20 -0
- package/src/server/responses/core-codex-account.ts +28 -15
- package/src/server/responses/core-combo.ts +34 -5
- package/src/server/responses/core-opaque-recovery.ts +20 -11
- package/src/server/responses/core.ts +12 -9
- package/src/server/responses/deepseek-image-files.ts +121 -0
- package/src/server/responses/empty-completion-guard.ts +10 -0
- package/src/server/responses/fetch-helpers.ts +65 -14
- package/src/server/responses/input-admission.ts +6 -2
- package/src/server/responses/jev-model-invoke.ts +13 -14
- package/src/server/responses/main-hard-lock-routing.ts +33 -8
- package/src/server/responses/native-steering-log.ts +2 -2
- package/src/server/responses/opengateway-completion-guard.ts +232 -0
- package/src/server/responses/passthrough-completion.ts +41 -0
- package/src/server/responses/passthrough-delivery.ts +5 -2
- package/src/server/responses/passthrough-dispatch.ts +42 -45
- package/src/server/responses/passthrough-key-rotation.ts +40 -0
- package/src/server/responses/passthrough-observations.ts +38 -0
- package/src/server/responses/reasoning-archive-error.ts +21 -0
- package/src/server/responses/request-compression.ts +98 -0
- package/src/server/responses/request-prepare.ts +11 -1
- package/src/server/responses/request-transport.ts +92 -5
- 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/service/orchestration.ts +17 -0
- package/src/types/account-auto-pause.ts +15 -0
- package/src/types/config.ts +23 -1
- package/src/types/provider.ts +11 -5
- package/src/types/request.ts +36 -3
- 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 +7 -0
- package/src/web-search/loop.ts +39 -13
- 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
- package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
|
@@ -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
|
};
|
|
@@ -206,6 +206,7 @@ import type { WorkflowRefusalLog } from "../workflow-refusal";
|
|
|
206
206
|
import { readyProtocolMetadata } from "../../remote/protocol";
|
|
207
207
|
import { modelCapabilityFields } from "../models-capabilities";
|
|
208
208
|
import { createWebsocketHandler } from "./websocket-handler";
|
|
209
|
+
import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
|
|
209
210
|
import { withGrokSessionIdentity } from "../../grok/session-identity";
|
|
210
211
|
import { withCallerSessionIdentity } from "../caller-session-identity";
|
|
211
212
|
|
|
@@ -1347,8 +1348,9 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1347
1348
|
response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission, {
|
|
1348
1349
|
onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer),
|
|
1349
1350
|
});
|
|
1350
|
-
} catch {
|
|
1351
|
-
response =
|
|
1351
|
+
} catch (error) {
|
|
1352
|
+
response = reasoningArchiveErrorResponse(error)
|
|
1353
|
+
?? formatErrorResponse(500, "server_error", "Unexpected compact request failure");
|
|
1352
1354
|
}
|
|
1353
1355
|
addFinalRequestLog(requestId, start, logCtx, response.status,
|
|
1354
1356
|
response.status === 499 ? { closeReason: "client_cancel" } : undefined);
|
|
@@ -1519,6 +1521,10 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1519
1521
|
},
|
|
1520
1522
|
});
|
|
1521
1523
|
} catch (error) {
|
|
1524
|
+
const archiveError = reasoningArchiveErrorResponse(error);
|
|
1525
|
+
if (archiveError) return withRequestLogId(
|
|
1526
|
+
withCors(responseWithDeferredRequestLog(archiveError, requestId, start, logCtx), req, policy), requestId,
|
|
1527
|
+
);
|
|
1522
1528
|
// A bounded upstream body can fail before handleResponses returns a client response.
|
|
1523
1529
|
// Finalize before rethrow so accounting observes the physical send while the caller
|
|
1524
1530
|
// retains the existing reset/rejection instead of receiving a synthesized response.
|
|
@@ -2,6 +2,7 @@ import { nativeSteeringUnavailableReason, nativeResponseControlMode, type Native
|
|
|
2
2
|
import { NativeInjectionChannel } from "../responses/native-injection";
|
|
3
3
|
import { NativeSteeringChannel, NativeSteeringError } from "../responses/native-steering";
|
|
4
4
|
import { createNativeSteeringLogObserver } from "../responses/native-steering-log";
|
|
5
|
+
import { reasoningArchiveErrorResponse } from "../responses/reasoning-archive-error";
|
|
5
6
|
import type { Server, ServerWebSocket } from "bun";
|
|
6
7
|
import {
|
|
7
8
|
LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
|
|
@@ -396,6 +397,15 @@ export function createWebsocketHandler(
|
|
|
396
397
|
}));
|
|
397
398
|
return;
|
|
398
399
|
}
|
|
400
|
+
// A local archive refusal happens before any send. Carry the same sanitized 503
|
|
401
|
+
// body and stable code as HTTP so no filesystem path reaches the client frame.
|
|
402
|
+
const archiveError = reasoningArchiveErrorResponse(err);
|
|
403
|
+
if (archiveError) {
|
|
404
|
+
finalizeLog(archiveError.status);
|
|
405
|
+
const body = await archiveError.json() as { error: Record<string, unknown> };
|
|
406
|
+
sendJsonFrame(ws, buildWsErrorFrame(archiveError.status, body.error));
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
399
409
|
finalizeLog(502);
|
|
400
410
|
sendJsonFrame(ws, buildWsErrorFrame(502, {
|
|
401
411
|
type: "proxy_error",
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -476,13 +476,25 @@ export function trackStreamLifetime(
|
|
|
476
476
|
registerTurn(ac, lease);
|
|
477
477
|
const reader = body.getReader();
|
|
478
478
|
let closed = false;
|
|
479
|
+
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
480
|
+
// A drain aborts the turn's controller; many callers pass a fresh one nothing else listens to,
|
|
481
|
+
// so the tracked stream itself has to stop.
|
|
482
|
+
const onAbort = () => {
|
|
483
|
+
if (closed) return;
|
|
484
|
+
// Stop the stream before the caller's onDone runs, so a throwing callback cannot leave it open.
|
|
485
|
+
reader.cancel(ac.signal.reason).catch(() => {});
|
|
486
|
+
try { streamController?.error(ac.signal.reason); } catch { /* already closed */ }
|
|
487
|
+
finish();
|
|
488
|
+
};
|
|
479
489
|
const finish = () => {
|
|
480
490
|
if (closed) return;
|
|
481
491
|
closed = true;
|
|
492
|
+
ac.signal.removeEventListener("abort", onAbort);
|
|
482
493
|
unregisterTurn(ac);
|
|
483
494
|
onDone?.();
|
|
484
495
|
};
|
|
485
|
-
|
|
496
|
+
const tracked = new ReadableStream<Uint8Array>({
|
|
497
|
+
start(controller) { streamController = controller; },
|
|
486
498
|
async pull(controller) {
|
|
487
499
|
try {
|
|
488
500
|
const { done, value } = await reader.read();
|
|
@@ -499,6 +511,10 @@ export function trackStreamLifetime(
|
|
|
499
511
|
reader.cancel(reason).catch(() => {});
|
|
500
512
|
},
|
|
501
513
|
});
|
|
514
|
+
// After registerTurn, so the lease's own abort listener (release the turn) runs first. A
|
|
515
|
+
// controller registerTurn already aborted (a settled lease) keeps its previous behaviour.
|
|
516
|
+
ac.signal.addEventListener("abort", onAbort, { once: true });
|
|
517
|
+
return tracked;
|
|
502
518
|
}
|
|
503
519
|
|
|
504
520
|
export async function drainAndShutdown(
|
|
@@ -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);
|
|
@@ -374,7 +374,8 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
374
374
|
// Without a flow id this cancels the provider's newest in-flight login, which is what the
|
|
375
375
|
// single-user GUI has always meant by "Cancel". A host running concurrent code logins for
|
|
376
376
|
// several users passes the flowId it was given at /api/oauth/login.
|
|
377
|
-
const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined
|
|
377
|
+
const cancelled = cancelLoginFlow(provider, typeof body.flowId === "string" ? body.flowId : undefined,
|
|
378
|
+
{ dismissSettledFailure: true });
|
|
378
379
|
return jsonResponse({ ok: true, cancelled });
|
|
379
380
|
}
|
|
380
381
|
|
|
@@ -510,7 +511,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
510
511
|
reauthReason: summary.needsReauth === true ? "refresh_failed" : undefined,
|
|
511
512
|
});
|
|
512
513
|
return { ...summary, ...oauthAccountHealthFields(provider, summary.id, health), quotaMode,
|
|
513
|
-
...(supportsPause ? { paused: full?.paused === true } : {}),
|
|
514
|
+
...(supportsPause ? { paused: full?.paused === true, ...(full?.pauseDetails ? { pauseDetails: full.pauseDetails } : {}) } : {}),
|
|
514
515
|
...(isAnthropicOAuthInstance(provider) && supportsPause ? { autoSwitchThresholdOverride: full?.autoSwitchThresholdOverride ?? null,
|
|
515
516
|
effectiveAutoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider as AnthropicInstanceId, config, full),
|
|
516
517
|
autoSwitchThreshold: effectiveAnthropicAccountThresholdForInstance(provider as AnthropicInstanceId, config) } : {}),
|
|
@@ -1116,6 +1117,15 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
|
|
|
1116
1117
|
clearProviderQuotaCache();
|
|
1117
1118
|
return jsonResponse({ ...result, name, store: providerKeyStoreKind(config.providers[name]) });
|
|
1118
1119
|
}
|
|
1120
|
+
if (url.pathname === "/api/providers/keys/pause" && req.method === "PUT") {
|
|
1121
|
+
const body = await readManagementJsonBodyOr(req, {});
|
|
1122
|
+
if (!isPlainRecord(body)) return jsonResponse({ error: "body must be an object" }, 400);
|
|
1123
|
+
if (typeof body.name !== "string" || typeof body.id !== "string" || typeof body.paused !== "boolean") return jsonResponse({ error: "name, id and paused required" }, 400);
|
|
1124
|
+
if (!isValidProviderName(body.name) || !hasOwnProvider(config.providers, body.name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
1125
|
+
const { setProviderApiKeyPaused } = await import("../../providers/api-key-pause");
|
|
1126
|
+
return setProviderApiKeyPaused(config, body.name, body.id, body.paused)
|
|
1127
|
+
? jsonResponse({ ok: true }) : jsonResponse({ error: "key not found" }, 404);
|
|
1128
|
+
}
|
|
1119
1129
|
if (url.pathname === "/api/providers/keys/active" && req.method === "PUT") {
|
|
1120
1130
|
const body = await readManagementJsonBodyOr(req, {}) as { name?: string; id?: string };
|
|
1121
1131
|
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 },
|