@bitkyc08/opencodex 2.65.0 → 2.66.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 +14 -0
- package/gui/dist/assets/App-DyHlFeNB.js +51 -0
- package/gui/dist/assets/App-gkoJIEOV.css +1 -0
- package/gui/dist/assets/{Tray-D4bA1vff.js → Tray-CI8cVKWu.js} +1 -1
- package/gui/dist/assets/index-BiwnVAUm.js +86 -0
- package/gui/dist/assets/index-ComVsdSk.css +1 -0
- package/gui/dist/assets/{tray-data-BZXhB3qz.js → tray-data-_WyYzbMt.js} +1 -1
- package/gui/dist/index.html +2 -2
- package/package.json +1 -2
- package/src/adapters/anthropic/beta-allowlist.ts +80 -0
- package/src/adapters/anthropic/passthrough.ts +221 -0
- package/src/adapters/anthropic-image-codec.ts +56 -19
- package/src/adapters/anthropic-image-normalize.ts +20 -10
- package/src/adapters/anthropic.ts +55 -24
- package/src/adapters/coding-agent/protocol.ts +29 -19
- package/src/adapters/cursor/live-transport.ts +18 -3
- package/src/adapters/cursor/native-exec-shell.ts +18 -63
- package/src/adapters/cursor/native-exec.ts +5 -2
- package/src/adapters/cursor/native-foreground-shell.ts +44 -0
- package/src/adapters/devin/cloud-direct/chat.ts +7 -1
- package/src/adapters/exec-tool-result-normalize.ts +4 -1
- package/src/adapters/google.ts +12 -0
- package/src/adapters/ollama-native.ts +32 -1
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +131 -34
- package/src/adapters/openai-chat.ts +6 -4
- package/src/adapters/openai-responses/passthrough.ts +1 -0
- package/src/adapters/openai-responses/reasoning.ts +45 -1
- package/src/bridge/response-json.ts +10 -2
- package/src/chat/outbound.ts +104 -52
- package/src/claude/claude-code-block.ts +16 -0
- package/src/claude/context-windows.ts +52 -4
- package/src/claude/desktop-first-party.ts +25 -8
- package/src/claude/inbound.ts +22 -0
- package/src/claude/intercept/connect-proxy.ts +37 -4
- package/src/claude/intercept/proxy-auth.ts +166 -0
- package/src/claude/intercept/runtime.ts +21 -0
- package/src/claude/intercept/settings.ts +33 -12
- package/src/claude/outbound.ts +45 -27
- package/src/cli/account-extended.ts +47 -15
- package/src/cli/account-target.ts +115 -0
- package/src/cli/account.ts +20 -28
- package/src/cli/agent.ts +10 -1
- package/src/cli/api-protocols.ts +206 -0
- package/src/cli/capabilities.ts +86 -0
- package/src/cli/combo.ts +2 -2
- package/src/cli/connect.ts +127 -3
- package/src/cli/dispatch.ts +8 -0
- package/src/cli/doctor.ts +33 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/link.ts +288 -0
- package/src/cli/registry.ts +23 -0
- package/src/cli/runtime-api.ts +79 -0
- package/src/cli/system-command.ts +21 -2
- package/src/client/connect.ts +125 -23
- package/src/client/hub-relay.ts +12 -8
- package/src/client/link-join.ts +323 -0
- package/src/client/link-relay.ts +241 -0
- package/src/client/link-state.ts +110 -0
- package/src/client/link-teardown.ts +63 -0
- package/src/client/link-tunnel.ts +465 -0
- package/src/client/machine-listener.ts +14 -5
- package/src/client/runtime.ts +91 -42
- package/src/client/state.ts +4 -0
- package/src/codex/catalog/build-entries.ts +4 -0
- package/src/codex/catalog/derive-entry.ts +5 -0
- package/src/codex/catalog/parsing.ts +4 -0
- package/src/codex/catalog/retained-sync.ts +4 -1
- package/src/codex/desktop-switches.ts +88 -8
- package/src/codex/inject/plan.ts +16 -3
- package/src/codex/inject.ts +3 -0
- package/src/codex/internal/catalog-writer.ts +13 -2
- package/src/combos/failover.ts +3 -2
- package/src/combos/index.ts +12 -0
- package/src/combos/jev.ts +646 -0
- package/src/combos/request.ts +5 -0
- package/src/combos/resolve.ts +17 -15
- package/src/combos/types.ts +47 -3
- package/src/config/atomic-write.ts +3 -0
- package/src/config/live-reconcile.ts +20 -4
- package/src/config/persist-unlocked.ts +20 -7
- package/src/config/schema/config-schema.ts +16 -0
- package/src/config/schema/leaf-validators.ts +38 -1
- package/src/generated/compatibility-version.json +416 -128
- package/src/integrations/omp-yaml-source.ts +109 -4
- package/src/lab/automation/orchestrator.ts +60 -4
- package/src/lab/events/limits.ts +1 -1
- package/src/lib/bounded-body.ts +55 -38
- package/src/lib/config-ownership.ts +11 -22
- package/src/lib/local-desktop-snapshot-capability.ts +56 -0
- package/src/lib/local-management-capability.ts +25 -3
- package/src/lib/optional-shutdown-hooks.ts +17 -0
- package/src/lib/state-store-registrations.ts +2 -2
- package/src/lib/windows-elevation.ts +62 -25
- package/src/lib/windows-secret-acl.ts +114 -23
- package/src/link/admission-wait.ts +73 -0
- package/src/link/compensation.ts +100 -0
- package/src/link/fingerprint.ts +21 -0
- package/src/link/paths.ts +19 -0
- package/src/link/ports.ts +12 -0
- package/src/link/routes.ts +32 -0
- package/src/link/ssh-argv.ts +170 -0
- package/src/link/ssh-config.ts +152 -0
- package/src/link/ssh-runner.ts +161 -0
- package/src/link/status-projection.ts +96 -0
- package/src/link/store.ts +139 -0
- package/src/link/supervisor.ts +404 -0
- package/src/link/tunnel-state.ts +91 -0
- package/src/protocols/baseline.ts +88 -0
- package/src/protocols/codecs/chat.ts +17 -0
- package/src/protocols/codecs/messages.ts +18 -0
- package/src/protocols/codecs/responses.ts +17 -0
- package/src/protocols/contract.ts +185 -0
- package/src/protocols/dto.ts +267 -0
- package/src/protocols/encoders/adapter-events.ts +876 -0
- package/src/protocols/encoders/chat.ts +243 -0
- package/src/protocols/encoders/messages.ts +462 -0
- package/src/protocols/envelope.ts +43 -0
- package/src/protocols/features.ts +295 -0
- package/src/protocols/guard.ts +33 -0
- package/src/protocols/opaque-state.ts +132 -0
- package/src/protocols/path.ts +39 -0
- package/src/protocols/plan-snapshot.ts +226 -0
- package/src/protocols/plan.ts +192 -0
- package/src/protocols/provider-summary.ts +101 -0
- package/src/protocols/settings.ts +118 -0
- package/src/protocols/shadow-plan.ts +36 -0
- package/src/protocols/shadow.ts +62 -0
- package/src/protocols/trace.ts +335 -0
- package/src/providers/model-rename-migration.ts +3 -3
- package/src/providers/registry/entries-core.ts +6 -1
- package/src/providers/registry/entries-extended.ts +15 -0
- package/src/providers/registry/model-ids.ts +1 -0
- package/src/providers/registry/types.ts +6 -0
- package/src/providers/registry.ts +1 -1
- package/src/remote-control/workspace-hub.ts +5 -4
- package/src/responses/apply-patch-envelope.ts +6 -1
- package/src/responses/code-mode-shell-input.ts +4 -2
- package/src/responses/freeform-wrapper-scan.ts +51 -24
- package/src/responses/progressive-freeform-input.ts +14 -20
- package/src/responses/spill-store.ts +187 -33
- package/src/responses/state/snapshot-select.ts +49 -0
- package/src/responses/state/spill-inspect.ts +46 -0
- package/src/responses/state/spill-queue.ts +16 -0
- package/src/responses/state.ts +51 -27
- package/src/server/audio-client.ts +8 -3
- package/src/server/audio-upstream.ts +9 -4
- package/src/server/auth-cors.ts +25 -8
- package/src/server/chat-completions.ts +71 -11
- package/src/server/chat-native-eligibility.ts +74 -0
- package/src/server/chat-native.ts +139 -74
- package/src/server/claude-messages.ts +140 -22
- package/src/server/hub-usage.ts +4 -3
- package/src/server/index/link-listener.ts +195 -0
- package/src/server/index/optional-listeners.ts +115 -0
- package/src/server/index/serve-options.ts +44 -10
- package/src/server/index.ts +10 -9
- package/src/server/inference/attempt.ts +51 -0
- package/src/server/inference/client-encoder-delivery.ts +235 -0
- package/src/server/inference/client-wire-log.ts +49 -0
- package/src/server/inference/client-wire.ts +69 -0
- package/src/server/inference/context.ts +15 -0
- package/src/server/inference/final-log.ts +37 -0
- package/src/server/local-desktop-snapshot-auth.ts +57 -0
- package/src/server/management/agent-settings-routes.ts +15 -13
- package/src/server/management/api-access.ts +11 -1
- package/src/server/management/config-routes.ts +3 -6
- package/src/server/management/context.ts +25 -0
- package/src/server/management/link-routes.ts +535 -0
- package/src/server/management/logs-usage-routes.ts +31 -1
- package/src/server/management/native-integration-routes.ts +6 -11
- package/src/server/management/oauth-account-routes.ts +42 -16
- package/src/server/management/protocol-routes.ts +159 -0
- package/src/server/management/protocol-settings-patch.ts +145 -0
- package/src/server/management/provider-patch-transaction.ts +61 -0
- package/src/server/management/provider-routes.ts +39 -18
- package/src/server/management/route-registry.ts +12 -0
- package/src/server/management/sidebar-routes.ts +8 -3
- package/src/server/management/system-routes.ts +18 -1
- package/src/server/management/usage-aggregate-cache.ts +206 -6
- package/src/server/management-api.ts +26 -1
- package/src/server/management-auth.ts +19 -5
- package/src/server/messages-native-eligibility.ts +161 -0
- package/src/server/messages-native-oauth.ts +149 -0
- package/src/server/messages-native.ts +790 -0
- package/src/server/relay.ts +9 -0
- package/src/server/request-log-filter.ts +92 -0
- package/src/server/request-log.ts +31 -70
- package/src/server/responses/adapter-delivery.ts +43 -16
- package/src/server/responses/agent-task-recovery.ts +90 -22
- package/src/server/responses/codex-ws-exchange.ts +4 -0
- package/src/server/responses/codex-ws-wire.ts +10 -1
- package/src/server/responses/core-combo-native.ts +323 -0
- package/src/server/responses/core-combo.ts +224 -13
- package/src/server/responses/core-options.ts +22 -0
- package/src/server/responses/core.ts +14 -26
- package/src/server/responses/request-sidecar-auth.ts +8 -3
- package/src/server/responses/run-turn-execution.ts +257 -63
- package/src/server/responses/sidecar-execution.ts +7 -4
- package/src/server/system-env.ts +7 -0
- package/src/service/diagnostics.ts +7 -1
- package/src/service/launchd.ts +26 -37
- package/src/service/windows-ops.ts +24 -20
- package/src/types/config.ts +58 -1
- package/src/types.ts +2 -0
- package/src/update/transactional-install.d.mts +0 -1
- package/src/update/transactional-install.mjs +17 -17
- package/src/usage/jev-stats.ts +495 -0
- package/src/usage/log.ts +24 -3
- package/src/web-search/run-turn-loop.ts +568 -0
- package/gui/dist/assets/App-8NMiZxT0.css +0 -1
- package/gui/dist/assets/App-DGLte6IR.js +0 -50
- package/gui/dist/assets/index--EWgGQvZ.css +0 -1
- package/gui/dist/assets/index-Bi1K37Wl.js +0 -86
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the planner's input from live config, for a preview that sends nothing.
|
|
3
|
+
*
|
|
4
|
+
* SERVER SIDE (not a leaf): this reads the router and the ingress eligibility rules so the
|
|
5
|
+
* snapshot is the route the request would actually settle on. It must stay free of side
|
|
6
|
+
* effects, which is why it does not simply call `routeModel` for every selector:
|
|
7
|
+
*
|
|
8
|
+
* - a combo selector would go through `tryPickComboModel`, which advances round-robin state.
|
|
9
|
+
* Combos are expanded here from their configured targets, each resolved with
|
|
10
|
+
* `routeConcreteModel` (no combo or policy lookup, no pick);
|
|
11
|
+
* - a policy selector would run the evaluator, whose clock-dependent pick is not what a
|
|
12
|
+
* preview describes. Policies are expanded from their configured candidates the same way;
|
|
13
|
+
* - every other selector takes `routeModel`'s deterministic branches, which read config and
|
|
14
|
+
* the last-known model cache and never fetch, refresh or write.
|
|
15
|
+
*
|
|
16
|
+
* Messages caller-forward passthrough depends on the caller's own Anthropic credential, which
|
|
17
|
+
* a preview does not have; it is reported as `caller-credential-required`, never assumed.
|
|
18
|
+
*/
|
|
19
|
+
import { resolveInboundModel } from "../claude/inbound-model-options";
|
|
20
|
+
import { getCombo, preservesPhysicalComboProvider, resolveComboId } from "../combos";
|
|
21
|
+
import { captureRouteStaticPolicy, routeConcreteModel, routeModel, type RouteResult } from "../router";
|
|
22
|
+
import { getRoutingProfile, POLICY_NAMESPACE, resolvePolicyProfileId } from "../routing/profile";
|
|
23
|
+
import { resolveWireProtocolOverride } from "../server/adapter-resolve";
|
|
24
|
+
import { nativeChatDeclineReason } from "../server/chat-native-eligibility";
|
|
25
|
+
import { nativeMessagesDeclineReason } from "../server/messages-native-eligibility";
|
|
26
|
+
import { parseSyntheticRowId } from "../server/fast-row";
|
|
27
|
+
import type { OcxConfig } from "../types";
|
|
28
|
+
import { inboundWireForProtocol, type Protocol, type ProtocolReasonCode } from "./contract";
|
|
29
|
+
import type { ProtocolPlanV1 } from "./dto";
|
|
30
|
+
import type { ProtocolFeature } from "./features";
|
|
31
|
+
import { planProtocol, type ProtocolPlanCandidateInput, type ProtocolPlanInput } from "./plan";
|
|
32
|
+
import { protocolPolicyRevision, resolveApiSurfaceSettings, resolveProtocolSettings } from "./settings";
|
|
33
|
+
|
|
34
|
+
export interface ProtocolPlanRequest {
|
|
35
|
+
model: string;
|
|
36
|
+
inbound: Protocol;
|
|
37
|
+
features: readonly ProtocolFeature[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type SettledRouteKind = Exclude<ProtocolPlanV1["routeKind"], "unknown">;
|
|
41
|
+
|
|
42
|
+
interface SettledTargets {
|
|
43
|
+
routeKind: SettledRouteKind;
|
|
44
|
+
routes: RouteResult[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function concreteRoutes(config: OcxConfig, targets: readonly { provider: string; model: string }[]): RouteResult[] {
|
|
48
|
+
const routes: RouteResult[] = [];
|
|
49
|
+
for (const target of targets) {
|
|
50
|
+
try {
|
|
51
|
+
routes.push(routeConcreteModel(config, `${target.provider}/${target.model}`));
|
|
52
|
+
} catch {
|
|
53
|
+
// An unconfigured or disabled target is skipped at dispatch time as well.
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return routes;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The route targets a selector settles on, or `undefined` when nothing routes it. */
|
|
60
|
+
function settleTargets(config: OcxConfig, modelId: string): SettledTargets | undefined {
|
|
61
|
+
const policyId = resolvePolicyProfileId(config, modelId);
|
|
62
|
+
if (policyId !== null || modelId.startsWith(`${POLICY_NAMESPACE}/`)) {
|
|
63
|
+
const profile = policyId ? getRoutingProfile(config, policyId) : undefined;
|
|
64
|
+
return profile ? { routeKind: "policy", routes: concreteRoutes(config, profile.candidates) } : undefined;
|
|
65
|
+
}
|
|
66
|
+
if (!preservesPhysicalComboProvider(config)) {
|
|
67
|
+
const comboId = resolveComboId(config, modelId);
|
|
68
|
+
if (comboId !== null) {
|
|
69
|
+
const combo = getCombo(config, comboId);
|
|
70
|
+
return combo ? { routeKind: "combo", routes: concreteRoutes(config, combo.targets) } : undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
return { routeKind: "direct", routes: [routeModel(config, modelId)] };
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A structural stand-in for a Chat body carrying the requested features, for the
|
|
82
|
+
* eligibility rules that inspect the body. Never derived from a real request.
|
|
83
|
+
*/
|
|
84
|
+
function chatBodyForFeatures(features: ReadonlySet<ProtocolFeature>): Record<string, unknown> {
|
|
85
|
+
const content = features.has("request.images")
|
|
86
|
+
? [{ type: "image_url", image_url: { url: "data:image/png;base64,AA==" } }]
|
|
87
|
+
: "";
|
|
88
|
+
return {
|
|
89
|
+
messages: [{ role: "user", content }],
|
|
90
|
+
...(features.has("request.tools") ? { tools: [{ type: "function", function: { name: "preview" } }] } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The Messages counterpart of `chatBodyForFeatures`. */
|
|
95
|
+
function messagesBodyForFeatures(features: ReadonlySet<ProtocolFeature>): Record<string, unknown> {
|
|
96
|
+
const content = features.has("request.images")
|
|
97
|
+
? [{ type: "image", source: { type: "base64", media_type: "image/png", data: "AA==" } }]
|
|
98
|
+
: "";
|
|
99
|
+
return { messages: [{ role: "user", content }] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface SyntheticRows {
|
|
103
|
+
effortRow: boolean;
|
|
104
|
+
fastRow: boolean;
|
|
105
|
+
/** The resolved selector the bridge would parse; the effort pin reads it. */
|
|
106
|
+
routeSelector: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function candidateFor(
|
|
110
|
+
config: OcxConfig,
|
|
111
|
+
inbound: Protocol,
|
|
112
|
+
route: RouteResult,
|
|
113
|
+
routeKind: SettledRouteKind,
|
|
114
|
+
features: ReadonlySet<ProtocolFeature>,
|
|
115
|
+
rows: SyntheticRows,
|
|
116
|
+
): ProtocolPlanCandidateInput {
|
|
117
|
+
const wire = inboundWireForProtocol(inbound);
|
|
118
|
+
// The same two steps every ingress runs: recapture static policy for the original inbound,
|
|
119
|
+
// then settle the wire from it.
|
|
120
|
+
const staticPolicy = captureRouteStaticPolicy(
|
|
121
|
+
route.providerName, route.modelId, route.provider, route.staticPolicy.effectiveAlias, wire,
|
|
122
|
+
);
|
|
123
|
+
const provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, wire, staticPolicy);
|
|
124
|
+
const adapter = provider.adapter ?? "openai-responses";
|
|
125
|
+
let declineReasons: ProtocolReasonCode[] = [];
|
|
126
|
+
let nativeEligible = false;
|
|
127
|
+
// With `nativeChatCombos` on, the combo loop judges each Chat candidate as the concrete route it
|
|
128
|
+
// is (PF-07), so the preview must too; a policy still resolves one candidate on the bridge.
|
|
129
|
+
const comboChildNative = inbound === "chat" && routeKind === "combo"
|
|
130
|
+
&& resolveProtocolSettings(config).rollout.nativeChatCombos;
|
|
131
|
+
const settled: RouteResult = {
|
|
132
|
+
...route,
|
|
133
|
+
provider,
|
|
134
|
+
staticPolicy,
|
|
135
|
+
...(routeKind === "direct" || comboChildNative ? {} : { routeKind }),
|
|
136
|
+
};
|
|
137
|
+
if (inbound === "chat") {
|
|
138
|
+
const reason = rows.effortRow ? "effort-row" : nativeChatDeclineReason(settled, chatBodyForFeatures(features), config);
|
|
139
|
+
nativeEligible = reason === undefined;
|
|
140
|
+
if (reason) declineReasons = [reason];
|
|
141
|
+
} else if (inbound === "messages" && resolveProtocolSettings(config).rollout.managedMessagesNative) {
|
|
142
|
+
// The runtime rule itself. With the switch off nothing is judged, so the default preview
|
|
143
|
+
// is exactly what it was before the managed native lane existed.
|
|
144
|
+
// Pinned effort is judged from config and the route; blocked-skill elision and the web-search
|
|
145
|
+
// sidecar depend on body content no feature describes, so a preview cannot predict them.
|
|
146
|
+
// Anthropic OAuth (PF-10) is judged from config alone: the rollout switch, the provider, its
|
|
147
|
+
// host and `anthropicAccountPool.enabled`. The stored-account quorum a sender supplies is
|
|
148
|
+
// never read here, so a preview selects, resolves and refreshes no account.
|
|
149
|
+
const reason = nativeMessagesDeclineReason(settled, messagesBodyForFeatures(features), config, {
|
|
150
|
+
...rows,
|
|
151
|
+
claudeCode: config.claudeCode,
|
|
152
|
+
});
|
|
153
|
+
nativeEligible = reason === undefined;
|
|
154
|
+
if (reason) declineReasons = [reason];
|
|
155
|
+
}
|
|
156
|
+
return { provider: route.providerName, model: route.modelId, adapter, nativeEligible, declineReasons };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Whether the Messages ingress could forward this selector with a caller's own credential. */
|
|
160
|
+
function messagesPassthroughPossible(config: OcxConfig, model: string): boolean {
|
|
161
|
+
if (config.claudeCode?.nativePassthrough === false) return false;
|
|
162
|
+
if (!/^(claude|anthropic)/i.test(model)) return false;
|
|
163
|
+
try {
|
|
164
|
+
return resolveInboundModel(model, config.claudeCode) === model;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function buildProtocolPlanSnapshot(
|
|
171
|
+
config: OcxConfig,
|
|
172
|
+
request: ProtocolPlanRequest,
|
|
173
|
+
basis: ProtocolPlanV1["basis"] = "preview",
|
|
174
|
+
): ProtocolPlanInput {
|
|
175
|
+
const base = {
|
|
176
|
+
inbound: request.inbound,
|
|
177
|
+
requestedModel: request.model,
|
|
178
|
+
features: [...request.features],
|
|
179
|
+
surfaces: resolveApiSurfaceSettings(config),
|
|
180
|
+
settings: resolveProtocolSettings(config),
|
|
181
|
+
policyRevision: protocolPolicyRevision(config),
|
|
182
|
+
basis,
|
|
183
|
+
};
|
|
184
|
+
const reasonCodes: ProtocolReasonCode[] = [];
|
|
185
|
+
let routeKey = request.model;
|
|
186
|
+
let effortRow = false;
|
|
187
|
+
let fastRow = false;
|
|
188
|
+
let syntheticRow = false;
|
|
189
|
+
try {
|
|
190
|
+
const parsed = parseSyntheticRowId(request.model, config);
|
|
191
|
+
if (parsed.effortRow) {
|
|
192
|
+
routeKey = parsed.effortRow.baseId;
|
|
193
|
+
effortRow = true;
|
|
194
|
+
syntheticRow = true;
|
|
195
|
+
} else if (parsed.fastRow) {
|
|
196
|
+
routeKey = parsed.fastRow.baseId;
|
|
197
|
+
fastRow = true;
|
|
198
|
+
syntheticRow = true;
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
// An unparseable synthetic id routes as written, exactly as the ingress falls through.
|
|
202
|
+
}
|
|
203
|
+
if (request.inbound === "messages") {
|
|
204
|
+
// A synthetic row needs the proxy-owned adapter, so it never takes the passthrough.
|
|
205
|
+
if (!syntheticRow && messagesPassthroughPossible(config, request.model)) reasonCodes.push("caller-credential-required");
|
|
206
|
+
try {
|
|
207
|
+
routeKey = resolveInboundModel(routeKey, config.claudeCode);
|
|
208
|
+
} catch {
|
|
209
|
+
return { ...base, routeKind: "unknown", candidates: [], reasonCodes };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const settled = settleTargets(config, routeKey);
|
|
213
|
+
if (!settled || settled.routes.length === 0) return { ...base, routeKind: "unknown", candidates: [], reasonCodes };
|
|
214
|
+
const features = new Set(request.features);
|
|
215
|
+
return {
|
|
216
|
+
...base,
|
|
217
|
+
routeKind: settled.routeKind,
|
|
218
|
+
candidates: settled.routes.map(route => candidateFor(config, request.inbound, route, settled.routeKind, features, { effortRow, fastRow, routeSelector: routeKey })),
|
|
219
|
+
reasonCodes,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A preview plan for one selector, computed from config alone. */
|
|
224
|
+
export function previewProtocolPlan(config: OcxConfig, request: ProtocolPlanRequest): ProtocolPlanV1 {
|
|
225
|
+
return planProtocol(buildProtocolPlanSnapshot(config, request, "preview"));
|
|
226
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The protocol planner: given a snapshot of what the router settled, predict the path each
|
|
3
|
+
* candidate would take, what it does to the request's features, and whether it would be
|
|
4
|
+
* refused under the unrepresentable policy.
|
|
5
|
+
*
|
|
6
|
+
* LEAF MODULE (see `contract.ts`) and PURE. The planner never selects a provider and never
|
|
7
|
+
* reads config; `plan-snapshot.ts` builds its input on the server. Paths come from
|
|
8
|
+
* `path.ts`, the same rule the observed trace uses, so a preview and the log of the same
|
|
9
|
+
* request cannot disagree about how a lane maps to hops. Settings and surfaces are taken
|
|
10
|
+
* structurally rather than imported from `settings.ts`, which reads config types and is
|
|
11
|
+
* therefore not a leaf.
|
|
12
|
+
*/
|
|
13
|
+
import { PROTOCOL_CONTRACT_VERSION, upstreamWireForAdapter, type Protocol, type ProtocolReasonCode } from "./contract";
|
|
14
|
+
import {
|
|
15
|
+
PROTOCOL_DTO_LIMITS,
|
|
16
|
+
PROTOCOL_PLAN_SCHEMA_VERSION,
|
|
17
|
+
type ProtocolPlanCandidateV1,
|
|
18
|
+
type ProtocolPlanV1,
|
|
19
|
+
} from "./dto";
|
|
20
|
+
import { featureEffectsForPath, PROTOCOL_FEATURES, unrepresentableFeatures, type ProtocolFeature } from "./features";
|
|
21
|
+
import { deliveryModeForLane, requestPathForLane, responsePathForLane, type ProtocolLane } from "./path";
|
|
22
|
+
|
|
23
|
+
/** One settled route target, as the server resolved it. */
|
|
24
|
+
export interface ProtocolPlanCandidateInput {
|
|
25
|
+
provider: string;
|
|
26
|
+
model: string;
|
|
27
|
+
/** Final adapter id after the wire override for this inbound. */
|
|
28
|
+
adapter: string;
|
|
29
|
+
/** True when the ingress would send its own wire to this candidate. */
|
|
30
|
+
nativeEligible: boolean;
|
|
31
|
+
/** Why the ingress declined its native lane, when it did. */
|
|
32
|
+
declineReasons: readonly ProtocolReasonCode[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ProtocolPlanInput {
|
|
36
|
+
inbound: Protocol;
|
|
37
|
+
requestedModel: string;
|
|
38
|
+
routeKind: ProtocolPlanV1["routeKind"];
|
|
39
|
+
candidates: readonly ProtocolPlanCandidateInput[];
|
|
40
|
+
features: readonly ProtocolFeature[];
|
|
41
|
+
surfaces: Readonly<Record<Protocol, { readonly enabled: boolean }>>;
|
|
42
|
+
settings: { readonly unrepresentable: "legacy" | "reject" };
|
|
43
|
+
policyRevision: string;
|
|
44
|
+
basis: ProtocolPlanV1["basis"];
|
|
45
|
+
/** Plan-level reasons the snapshot observed, such as `caller-credential-required`. */
|
|
46
|
+
reasonCodes?: readonly ProtocolReasonCode[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function bounded(codes: Iterable<ProtocolReasonCode>): ProtocolReasonCode[] {
|
|
50
|
+
return [...new Set(codes)].slice(0, PROTOCOL_DTO_LIMITS.reasonCodes);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function orderedFeatures(features: Iterable<ProtocolFeature>): ProtocolFeature[] {
|
|
54
|
+
const present = new Set(features);
|
|
55
|
+
return PROTOCOL_FEATURES.filter(feature => present.has(feature));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function blockedCandidate(candidate: ProtocolPlanCandidateInput, reason: ProtocolReasonCode): ProtocolPlanCandidateV1 {
|
|
59
|
+
return {
|
|
60
|
+
provider: candidate.provider,
|
|
61
|
+
model: candidate.model,
|
|
62
|
+
adapter: candidate.adapter,
|
|
63
|
+
upstream: upstreamWireForAdapter(candidate.adapter),
|
|
64
|
+
mode: "blocked",
|
|
65
|
+
requestPath: [],
|
|
66
|
+
responsePath: [],
|
|
67
|
+
fidelity: "unknown",
|
|
68
|
+
reasonCodes: [reason],
|
|
69
|
+
featureEffects: [],
|
|
70
|
+
unknownFeatures: [],
|
|
71
|
+
eligible: false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function planCandidate(
|
|
76
|
+
input: ProtocolPlanInput,
|
|
77
|
+
candidate: ProtocolPlanCandidateInput,
|
|
78
|
+
features: readonly ProtocolFeature[],
|
|
79
|
+
): ProtocolPlanCandidateV1 {
|
|
80
|
+
const upstream = upstreamWireForAdapter(candidate.adapter);
|
|
81
|
+
// A native lane only exists toward the ingress's own wire; an inconsistent snapshot must
|
|
82
|
+
// not produce a path that claims the source body reached a different wire untouched.
|
|
83
|
+
const lane: ProtocolLane = candidate.nativeEligible && upstream === input.inbound ? "native" : "bridge";
|
|
84
|
+
const requestPath = requestPathForLane(input.inbound, lane, upstream);
|
|
85
|
+
const responsePath = responsePathForLane(input.inbound, lane, upstream);
|
|
86
|
+
const mode = deliveryModeForLane(input.inbound, lane, upstream);
|
|
87
|
+
const effects = featureEffectsForPath(input.inbound, requestPath, features);
|
|
88
|
+
const eligible = input.settings.unrepresentable !== "reject" || unrepresentableFeatures(effects.effects).length === 0;
|
|
89
|
+
|
|
90
|
+
const reasons: ProtocolReasonCode[] = [];
|
|
91
|
+
if (mode === "native") reasons.push("same-wire-native");
|
|
92
|
+
else if (mode === "translated") reasons.push(requestPath.length === 2 ? "cross-wire-codec" : "cross-wire-ir");
|
|
93
|
+
else reasons.push("not-migrated");
|
|
94
|
+
if (upstream === "other") reasons.push("upstream-other");
|
|
95
|
+
// Why the native lane was declined matters only where a native lane could exist: toward a
|
|
96
|
+
// different wire the path reason above already says it.
|
|
97
|
+
if (lane === "bridge" && input.inbound !== "responses" && upstream === input.inbound) {
|
|
98
|
+
reasons.push(...candidate.declineReasons);
|
|
99
|
+
}
|
|
100
|
+
if (!eligible) reasons.push("feature-unrepresentable");
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
provider: candidate.provider,
|
|
104
|
+
model: candidate.model,
|
|
105
|
+
adapter: candidate.adapter,
|
|
106
|
+
upstream,
|
|
107
|
+
mode,
|
|
108
|
+
requestPath,
|
|
109
|
+
responsePath,
|
|
110
|
+
fidelity: effects.fidelity,
|
|
111
|
+
reasonCodes: bounded(reasons),
|
|
112
|
+
featureEffects: effects.effects.map(effect => ({ feature: effect.feature, disposition: effect.disposition })),
|
|
113
|
+
unknownFeatures: effects.unknown,
|
|
114
|
+
eligible,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Features preserved (passthrough or translated) by every / by only some eligible candidates. */
|
|
119
|
+
function featureSplit(
|
|
120
|
+
eligible: readonly ProtocolPlanCandidateV1[],
|
|
121
|
+
features: readonly ProtocolFeature[],
|
|
122
|
+
): { guaranteed: ProtocolFeature[]; partial: ProtocolFeature[] } {
|
|
123
|
+
const guaranteed: ProtocolFeature[] = [];
|
|
124
|
+
const partial: ProtocolFeature[] = [];
|
|
125
|
+
if (eligible.length === 0) return { guaranteed, partial };
|
|
126
|
+
for (const feature of features) {
|
|
127
|
+
let preserved = 0;
|
|
128
|
+
for (const candidate of eligible) {
|
|
129
|
+
const effect = candidate.featureEffects.find(entry => entry.feature === feature);
|
|
130
|
+
if (effect && (effect.disposition === "passthrough" || effect.disposition === "translated")) preserved++;
|
|
131
|
+
}
|
|
132
|
+
if (preserved === eligible.length) guaranteed.push(feature);
|
|
133
|
+
else if (preserved > 0) partial.push(feature);
|
|
134
|
+
}
|
|
135
|
+
return { guaranteed, partial };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function planProtocol(input: ProtocolPlanInput): ProtocolPlanV1 {
|
|
139
|
+
const features = orderedFeatures(input.features);
|
|
140
|
+
const snapshotCandidates = input.candidates.slice(0, PROTOCOL_DTO_LIMITS.candidates);
|
|
141
|
+
const base = {
|
|
142
|
+
schemaVersion: PROTOCOL_PLAN_SCHEMA_VERSION,
|
|
143
|
+
basis: input.basis,
|
|
144
|
+
contractVersion: PROTOCOL_CONTRACT_VERSION,
|
|
145
|
+
policyRevision: input.policyRevision,
|
|
146
|
+
inbound: input.inbound,
|
|
147
|
+
requestedModel: input.requestedModel,
|
|
148
|
+
routeKind: input.routeKind,
|
|
149
|
+
} as const;
|
|
150
|
+
|
|
151
|
+
if (!input.surfaces[input.inbound].enabled) {
|
|
152
|
+
return {
|
|
153
|
+
...base,
|
|
154
|
+
mode: "blocked",
|
|
155
|
+
reasonCodes: ["surface-disabled"],
|
|
156
|
+
candidates: snapshotCandidates.map(candidate => blockedCandidate(candidate, "surface-disabled")),
|
|
157
|
+
guaranteedFeatures: [],
|
|
158
|
+
partialFeatures: [],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (input.routeKind === "unknown" || snapshotCandidates.length === 0) {
|
|
163
|
+
return {
|
|
164
|
+
...base,
|
|
165
|
+
mode: "blocked",
|
|
166
|
+
reasonCodes: bounded([...(input.reasonCodes ?? []), "unknown-model"]),
|
|
167
|
+
candidates: [],
|
|
168
|
+
guaranteedFeatures: [],
|
|
169
|
+
partialFeatures: [],
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const candidates = snapshotCandidates.map(candidate => planCandidate(input, candidate, features));
|
|
174
|
+
const eligible = candidates.filter(candidate => candidate.eligible);
|
|
175
|
+
const first = eligible[0];
|
|
176
|
+
const { guaranteed, partial } = featureSplit(eligible, features);
|
|
177
|
+
const routeReasons: ProtocolReasonCode[] = input.routeKind === "combo" || input.routeKind === "policy"
|
|
178
|
+
? ["combo-or-policy-route"]
|
|
179
|
+
: [];
|
|
180
|
+
return {
|
|
181
|
+
...base,
|
|
182
|
+
mode: first ? first.mode : "blocked",
|
|
183
|
+
reasonCodes: bounded([
|
|
184
|
+
...(input.reasonCodes ?? []),
|
|
185
|
+
...routeReasons,
|
|
186
|
+
...(first ? first.reasonCodes : ["feature-unrepresentable" as const]),
|
|
187
|
+
]),
|
|
188
|
+
candidates,
|
|
189
|
+
guaranteedFeatures: guaranteed,
|
|
190
|
+
partialFeatures: partial,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `provider` block of `GET /api/protocols?provider=<name>`: which upstream wire one
|
|
3
|
+
* provider receives and who decided it.
|
|
4
|
+
*
|
|
5
|
+
* SERVER SIDE (not a leaf): it reads the provider registry through
|
|
6
|
+
* `captureRouteStaticPolicy`, the same resolver every ingress uses, so the answer is the
|
|
7
|
+
* resolved static policy and never a second copy of the adapter rules. It is free of side
|
|
8
|
+
* effects: no fetch, no model-cache refresh, no config write. Only the adapter id, its
|
|
9
|
+
* provenance, the auth mode and model ids leave this module; no credential, base URL or header.
|
|
10
|
+
*
|
|
11
|
+
* Model overrides are resolved for a Responses client, the Codex default. A registry wire
|
|
12
|
+
* default declared for another client API only shows up in that API's plan preview.
|
|
13
|
+
*/
|
|
14
|
+
import { PROVIDER_REGISTRY } from "../providers/registry";
|
|
15
|
+
import type { StaticPolicySource } from "../providers/resolved-model-policy";
|
|
16
|
+
import { captureRouteStaticPolicy } from "../router";
|
|
17
|
+
import { captureWireAdapterHardPins, type OcxConfig, type OcxProviderConfig } from "../types";
|
|
18
|
+
import { upstreamWireForAdapter } from "./contract";
|
|
19
|
+
import {
|
|
20
|
+
PROTOCOL_DTO_LIMITS,
|
|
21
|
+
PROTOCOL_PROVIDER_OVERRIDE_LIMIT,
|
|
22
|
+
type ProtocolAdapterSource,
|
|
23
|
+
type ProtocolProviderModelOverrideV1,
|
|
24
|
+
type ProtocolProviderSummaryV1,
|
|
25
|
+
} from "./dto";
|
|
26
|
+
|
|
27
|
+
/** Upper bound on the model ids examined, so a provider with a huge static list stays cheap. */
|
|
28
|
+
const MODEL_SCAN_LIMIT = 1024;
|
|
29
|
+
|
|
30
|
+
/** Collapse the resolver's provenance vocabulary onto the four public decision sources. */
|
|
31
|
+
export function protocolAdapterSource(source: StaticPolicySource | undefined): ProtocolAdapterSource {
|
|
32
|
+
switch (source) {
|
|
33
|
+
case "hard-pin":
|
|
34
|
+
return "hard-pin";
|
|
35
|
+
case "operator":
|
|
36
|
+
case "operator-capability":
|
|
37
|
+
return "operator";
|
|
38
|
+
case "registry":
|
|
39
|
+
return "registry";
|
|
40
|
+
default:
|
|
41
|
+
return "provider-default";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function usableModelId(model: unknown): model is string {
|
|
46
|
+
return typeof model === "string" && model.length > 0 && model.length <= PROTOCOL_DTO_LIMITS.identifierLength
|
|
47
|
+
&& !/[\u0000-\u001f\u007f]/.test(model);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Model ids whose wire could differ from the provider's: explicit overrides, registry wire
|
|
52
|
+
* defaults, exact-id hard pins and the listed models. Prefix pins cannot be enumerated; a
|
|
53
|
+
* listed model they match is still found.
|
|
54
|
+
*/
|
|
55
|
+
function candidateModelIds(name: string, provider: Readonly<OcxProviderConfig>): string[] {
|
|
56
|
+
const registry = PROVIDER_REGISTRY.find(entry => entry.id === name);
|
|
57
|
+
const ids = new Set<string>();
|
|
58
|
+
const add = (model: unknown) => {
|
|
59
|
+
if (ids.size < MODEL_SCAN_LIMIT && usableModelId(model)) ids.add(model);
|
|
60
|
+
};
|
|
61
|
+
for (const model of Object.keys(provider.modelAdapters ?? {})) add(model);
|
|
62
|
+
for (const model of Object.keys(registry?.modelWireDefaults ?? {})) add(model);
|
|
63
|
+
for (const model of Object.keys(captureWireAdapterHardPins(name))) add(model);
|
|
64
|
+
add(provider.defaultModel);
|
|
65
|
+
for (const model of provider.models ?? []) add(model);
|
|
66
|
+
return [...ids].sort((left, right) => left.localeCompare(right));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Summarize one configured provider, or `undefined` when no provider has that name. The
|
|
71
|
+
* provider-level adapter is resolved without a model; each override is resolved for its model.
|
|
72
|
+
*/
|
|
73
|
+
export function buildProtocolProviderSummary(config: Readonly<OcxConfig>, name: string): ProtocolProviderSummaryV1 | undefined {
|
|
74
|
+
if (!Object.hasOwn(config.providers ?? {}, name)) return undefined;
|
|
75
|
+
const provider = config.providers[name];
|
|
76
|
+
if (!provider) return undefined;
|
|
77
|
+
const base = captureRouteStaticPolicy(name, "", provider);
|
|
78
|
+
const adapter = base.provider.adapter;
|
|
79
|
+
const overrides: ProtocolProviderModelOverrideV1[] = [];
|
|
80
|
+
let truncated = false;
|
|
81
|
+
for (const model of candidateModelIds(name, provider)) {
|
|
82
|
+
const policy = captureRouteStaticPolicy(name, model, provider);
|
|
83
|
+
const source = protocolAdapterSource(policy.provenance.model.adapter);
|
|
84
|
+
if (source === "provider-default" && policy.model.adapter === adapter) continue;
|
|
85
|
+
if (overrides.length >= PROTOCOL_PROVIDER_OVERRIDE_LIMIT) {
|
|
86
|
+
truncated = true;
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
overrides.push({ model, adapter: policy.model.adapter, source });
|
|
90
|
+
}
|
|
91
|
+
const authMode = base.provider.authMode;
|
|
92
|
+
return {
|
|
93
|
+
name,
|
|
94
|
+
adapter,
|
|
95
|
+
adapterSource: protocolAdapterSource(base.provenance.provider.adapter),
|
|
96
|
+
authMode: typeof authMode === "string" && authMode.length > 0 ? authMode : null,
|
|
97
|
+
upstream: upstreamWireForAdapter(adapter),
|
|
98
|
+
modelOverrides: overrides,
|
|
99
|
+
...(truncated ? { modelOverridesTruncated: true as const } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single reader of `apiSurfaces` and `protocols` config.
|
|
3
|
+
*
|
|
4
|
+
* Nothing else reads those keys directly: endpoint admission, `count_tokens`, the endpoint
|
|
5
|
+
* metadata DTO and the dashboard all resolve through here, so they cannot disagree about
|
|
6
|
+
* whether an API is open. Type-only config import keeps this module free of runtime edges.
|
|
7
|
+
*/
|
|
8
|
+
import type { OcxConfig } from "../types";
|
|
9
|
+
import type { Protocol } from "./contract";
|
|
10
|
+
|
|
11
|
+
export type ApiSurfaceSource =
|
|
12
|
+
/** Responses and Chat Completions: always served. */
|
|
13
|
+
| "fixed"
|
|
14
|
+
/** An explicit boolean in `apiSurfaces`. */
|
|
15
|
+
| "api-surfaces"
|
|
16
|
+
/** No explicit value; inherited from `claudeCode.enabled`. */
|
|
17
|
+
| "claude-code-legacy"
|
|
18
|
+
/** A present but malformed value; the surface is closed. */
|
|
19
|
+
| "invalid";
|
|
20
|
+
|
|
21
|
+
export interface ApiSurfaceSetting {
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
source: ApiSurfaceSource;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ApiSurfaceSettings = Readonly<Record<Protocol, Readonly<ApiSurfaceSetting>>>;
|
|
27
|
+
|
|
28
|
+
type Rec = Record<string, unknown>;
|
|
29
|
+
function isRec(value: unknown): value is Rec {
|
|
30
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveMessagesSurface(config: Pick<OcxConfig, "apiSurfaces" | "claudeCode">): ApiSurfaceSetting {
|
|
34
|
+
const raw: unknown = config.apiSurfaces;
|
|
35
|
+
if (raw !== undefined) {
|
|
36
|
+
if (!isRec(raw)) return { enabled: false, source: "invalid" };
|
|
37
|
+
const messages = raw.messages;
|
|
38
|
+
if (messages !== undefined) {
|
|
39
|
+
if (!isRec(messages)) return { enabled: false, source: "invalid" };
|
|
40
|
+
if (Object.hasOwn(messages, "enabled")) {
|
|
41
|
+
return typeof messages.enabled === "boolean"
|
|
42
|
+
? { enabled: messages.enabled, source: "api-surfaces" }
|
|
43
|
+
: { enabled: false, source: "invalid" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { enabled: config.claudeCode?.enabled !== false, source: "claude-code-legacy" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function resolveApiSurfaceSettings(config: Pick<OcxConfig, "apiSurfaces" | "claudeCode">): ApiSurfaceSettings {
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
responses: Object.freeze({ enabled: true, source: "fixed" as const }),
|
|
53
|
+
chat: Object.freeze({ enabled: true, source: "fixed" as const }),
|
|
54
|
+
messages: Object.freeze(resolveMessagesSurface(config)),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type UnrepresentablePolicy = "legacy" | "reject";
|
|
59
|
+
|
|
60
|
+
export interface ProtocolRolloutSettings {
|
|
61
|
+
nativeChatCombos: boolean;
|
|
62
|
+
managedMessagesNative: boolean;
|
|
63
|
+
managedMessagesNativeOAuth: boolean;
|
|
64
|
+
directEncoders: boolean;
|
|
65
|
+
shadowPlan: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ProtocolSettings {
|
|
69
|
+
unrepresentable: UnrepresentablePolicy;
|
|
70
|
+
rollout: Readonly<ProtocolRolloutSettings>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Resolve protocol policy with conservative defaults for every absent or malformed field. */
|
|
74
|
+
export function resolveProtocolSettings(config: Pick<OcxConfig, "protocols">): Readonly<ProtocolSettings> {
|
|
75
|
+
const raw: unknown = config.protocols;
|
|
76
|
+
const protocols = isRec(raw) ? raw : {};
|
|
77
|
+
const rollout = isRec(protocols.rollout) ? protocols.rollout : {};
|
|
78
|
+
const on = (key: keyof ProtocolRolloutSettings): boolean => rollout[key] === true;
|
|
79
|
+
const managedMessagesNative = on("managedMessagesNative");
|
|
80
|
+
return Object.freeze({
|
|
81
|
+
unrepresentable: protocols.unrepresentable === "reject" ? "reject" : "legacy",
|
|
82
|
+
rollout: Object.freeze({
|
|
83
|
+
nativeChatCombos: on("nativeChatCombos"),
|
|
84
|
+
managedMessagesNative,
|
|
85
|
+
// OAuth extension is meaningless without the key-auth path it extends.
|
|
86
|
+
managedMessagesNativeOAuth: managedMessagesNative && on("managedMessagesNativeOAuth"),
|
|
87
|
+
directEncoders: on("directEncoders"),
|
|
88
|
+
shadowPlan: on("shadowPlan"),
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Short, stable digest of every config input a protocol plan reads. Plans carry it so the
|
|
95
|
+
* dashboard can tell a preview computed under an older policy from a current one. Not a
|
|
96
|
+
* security boundary; FNV-1a over a canonical JSON projection.
|
|
97
|
+
*/
|
|
98
|
+
export function protocolPolicyRevision(config: Pick<OcxConfig, "apiSurfaces" | "claudeCode" | "protocols">): string {
|
|
99
|
+
const surfaces = resolveApiSurfaceSettings(config);
|
|
100
|
+
const settings = resolveProtocolSettings(config);
|
|
101
|
+
const canonical = JSON.stringify({
|
|
102
|
+
messages: [surfaces.messages.enabled, surfaces.messages.source],
|
|
103
|
+
unrepresentable: settings.unrepresentable,
|
|
104
|
+
rollout: [
|
|
105
|
+
settings.rollout.nativeChatCombos,
|
|
106
|
+
settings.rollout.managedMessagesNative,
|
|
107
|
+
settings.rollout.managedMessagesNativeOAuth,
|
|
108
|
+
settings.rollout.directEncoders,
|
|
109
|
+
settings.rollout.shadowPlan,
|
|
110
|
+
],
|
|
111
|
+
});
|
|
112
|
+
let hash = 0x811c9dc5;
|
|
113
|
+
for (let i = 0; i < canonical.length; i++) {
|
|
114
|
+
hash ^= canonical.charCodeAt(i);
|
|
115
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
116
|
+
}
|
|
117
|
+
return `p1-${hash.toString(16).padStart(8, "0")}`;
|
|
118
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shadow plan recording (PF-12, `protocols.rollout.shadowPlan`, default off).
|
|
3
|
+
*
|
|
4
|
+
* SERVER SIDE (not a leaf): it reads config through `plan-snapshot.ts`. An ingress calls
|
|
5
|
+
* `recordProtocolShadowPlan` right after its entry mark. With the switch off it returns before
|
|
6
|
+
* reading anything else. With it on it builds the same side-effect-free snapshot the dashboard
|
|
7
|
+
* preview uses, with basis `dispatch`, from the selector the client sent and the features the
|
|
8
|
+
* entry mark already collected, and stores it beside the marks. `trace.ts` runs the pure planner
|
|
9
|
+
* on it at finalize and compares.
|
|
10
|
+
*
|
|
11
|
+
* Nothing here sends, fetches, advances combo state or writes: the snapshot expands combos and
|
|
12
|
+
* policies from config and uses `routeModel`'s deterministic branches only. The stored input is
|
|
13
|
+
* fixed vocabulary plus the provider and model names the server already exposes. Any throw is
|
|
14
|
+
* swallowed: a shadow plan is diagnostics and never fails or changes the request it describes.
|
|
15
|
+
*/
|
|
16
|
+
import type { OcxConfig } from "../types";
|
|
17
|
+
import type { Protocol } from "./contract";
|
|
18
|
+
import { buildProtocolPlanSnapshot } from "./plan-snapshot";
|
|
19
|
+
import { resolveProtocolSettings } from "./settings";
|
|
20
|
+
import { markProtocolShadowPlanInput, protocolMarkFeatures } from "./trace";
|
|
21
|
+
|
|
22
|
+
export function recordProtocolShadowPlan(
|
|
23
|
+
logCtx: object,
|
|
24
|
+
config: OcxConfig,
|
|
25
|
+
request: { inbound: Protocol; model: unknown },
|
|
26
|
+
): void {
|
|
27
|
+
try {
|
|
28
|
+
if (!resolveProtocolSettings(config).rollout.shadowPlan) return;
|
|
29
|
+
if (typeof request.model !== "string" || request.model.length === 0) return;
|
|
30
|
+
const features = protocolMarkFeatures(logCtx) ?? [];
|
|
31
|
+
const input = buildProtocolPlanSnapshot(config, { model: request.model, inbound: request.inbound, features }, "dispatch");
|
|
32
|
+
markProtocolShadowPlanInput(logCtx, input);
|
|
33
|
+
} catch {
|
|
34
|
+
/* a shadow plan must never fail the request it describes */
|
|
35
|
+
}
|
|
36
|
+
}
|