@bitkyc08/opencodex 2.91.0 → 2.92.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/bin/ocx.mjs +37 -3
- package/gui/dist/assets/{App-nZ34cR5G.js → App-sUf_8afJ.js} +18 -18
- package/gui/dist/assets/{Tray-BuLy7hL1.js → Tray-BSsxznDm.js} +1 -1
- package/gui/dist/assets/index-BlFE_VB_.js +94 -0
- package/gui/dist/assets/quota-summary-BRlbXgw0.js +1 -0
- package/gui/dist/index.html +1 -1
- package/gui/dist/provider-icons/workbuddy-color.svg +29 -0
- package/package.json +4 -3
- package/src/adapters/google-vertex-endpoint.ts +9 -1
- package/src/adapters/google-vertex-key-project.ts +223 -0
- package/src/adapters/google.ts +13 -3
- package/src/adapters/openai-chat/tool-name-registry.ts +3 -1
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/request-strips.ts +60 -0
- package/src/adapters/registry.ts +5 -0
- package/src/adapters/workbuddy-schema.ts +61 -0
- package/src/adapters/workbuddy.ts +68 -0
- package/src/bridge/response-json.ts +1 -1
- package/src/bridge/sse.ts +1 -1
- package/src/chat/outbound.ts +11 -4
- package/src/cli/capabilities-agents-routing.ts +3 -3
- package/src/cli/codex-cli-update.ts +8 -0
- package/src/cli/combo-result.ts +6 -0
- package/src/cli/combo-stats.ts +3 -0
- package/src/cli/combo.ts +66 -42
- package/src/cli/launcher-context.ts +49 -2
- package/src/cli/link.ts +45 -32
- package/src/cli/provider.ts +2 -1
- package/src/cli/usage-compare.ts +68 -12
- package/src/client/link-ingress.ts +1 -1
- package/src/client/link-readiness.ts +97 -0
- package/src/client/link-relay-transport.ts +79 -11
- package/src/client/link-relay.ts +74 -15
- package/src/client/link-tunnel.ts +45 -18
- package/src/client/machine-listener.ts +20 -2
- package/src/client/runtime.ts +20 -2
- package/src/codex/account-store.ts +17 -3
- package/src/codex/app-server-restart-service.ts +6 -5
- package/src/codex/catalog/effort.ts +9 -2
- package/src/codex/catalog/gather-capture.ts +4 -2
- package/src/codex/catalog/provider-models.ts +14 -8
- package/src/codex/chatgpt-refresh-failure.ts +6 -2
- package/src/codex/cli-install-provenance.ts +418 -0
- package/src/codex/cli-installation-targets.ts +5 -1
- package/src/codex/cli-update-plan.ts +43 -16
- package/src/codex/desktop-app/picker-freshness.ts +10 -8
- package/src/codex/desktop-compatibility/usage-activation.ts +2 -1
- package/src/codex/desktop-compatibility/usage-controller.ts +7 -1
- package/src/codex/pool-refresh-backoff.ts +3 -1
- package/src/codex/user-identity.ts +35 -6
- package/src/codex/windows-current-sid.ts +162 -0
- package/src/combos/jev-quota-activation.ts +5 -1
- package/src/combos/jev-quota-route.ts +1 -1
- package/src/combos/jev-quota.ts +9 -1
- package/src/combos/jev-session-pin-config.ts +51 -0
- package/src/combos/jev.ts +6 -0
- package/src/combos/types.ts +19 -0
- package/src/config/provider-validation.ts +7 -0
- package/src/config/schema/leaf-validators.ts +1 -0
- package/src/generated/compatibility-version.json +194 -122
- package/src/lib/decision-quota-sink.ts +10 -0
- package/src/lib/translator-budget.ts +4 -4
- package/src/lib/upstream-retry.ts +4 -1
- package/src/link/diagnostics.ts +193 -0
- package/src/link/relay-auth.ts +6 -0
- package/src/link/status-projection.ts +39 -0
- package/src/link/supervisor.ts +45 -12
- package/src/link/tunnel-history.ts +109 -0
- package/src/link/tunnel-state.ts +80 -17
- package/src/messaging/remote-files.ts +24 -1
- package/src/oauth/generic-account-failover.ts +91 -7
- package/src/oauth/index.ts +15 -0
- package/src/oauth/model-discovery-auth.ts +5 -0
- package/src/oauth/store.ts +4 -1
- package/src/oauth/token-guardian.ts +7 -0
- package/src/oauth/types.ts +9 -0
- package/src/oauth/workbuddy.ts +139 -0
- package/src/providers/antigravity-quota-identity.ts +37 -0
- package/src/providers/base-url-choices.ts +8 -5
- package/src/providers/derive.ts +23 -3
- package/src/providers/model-discovery.ts +2 -0
- package/src/providers/model-rename-fields.ts +1 -0
- package/src/providers/quota/account-cache.ts +3 -2
- package/src/providers/quota/antigravity.ts +25 -14
- package/src/providers/quota/report-cache.ts +13 -4
- package/src/providers/quota/vendor-probes-key.ts +60 -0
- package/src/providers/quota/workbuddy.ts +83 -0
- package/src/providers/quota-decision-antigravity.ts +64 -0
- package/src/providers/quota-decision-publication.ts +11 -0
- package/src/providers/quota-decision-snapshot.ts +21 -4
- package/src/providers/quota-types.ts +2 -0
- package/src/providers/quota.ts +3 -1
- package/src/providers/registry/entries-core.ts +9 -0
- package/src/providers/registry/entries-extended.ts +20 -7
- package/src/providers/registry/model-ids.ts +3 -0
- package/src/providers/registry/model-seeds.ts +27 -28
- package/src/providers/registry/types.ts +19 -1
- package/src/providers/resolved-model-policy.ts +4 -4
- package/src/providers/routed-config.ts +15 -0
- package/src/providers/stale-model-roster-migration.ts +83 -15
- package/src/providers/workbuddy-credit-balance.ts +81 -0
- package/src/providers/workbuddy-models.ts +37 -0
- package/src/providers/workbuddy.ts +60 -0
- package/src/responses/reasoning-replay-cache.ts +18 -14
- package/src/router.ts +2 -0
- package/src/routing/analytics.ts +2 -3
- package/src/routing/fallback-policy.ts +3 -3
- package/src/server/auth-cors.ts +1 -0
- package/src/server/chat-completions.ts +14 -1
- package/src/server/chat-native-reasoning.ts +10 -3
- package/src/server/chat-native.ts +13 -2
- package/src/server/claude-messages.ts +3 -0
- package/src/server/index/link-relay-sessions.ts +9 -5
- package/src/server/index/optional-listeners.ts +4 -0
- package/src/server/local-management-read-client.ts +3 -1
- package/src/server/management/combo-routes.ts +10 -4
- package/src/server/management/link-routes.ts +5 -2
- package/src/server/management/provider-routes.ts +1 -1
- package/src/server/port-reclaim.ts +51 -9
- package/src/server/request-log-conversation.ts +49 -0
- package/src/server/request-log.ts +2 -0
- package/src/server/responses/anthropic-thinking-recovery.ts +1 -0
- package/src/server/responses/compact.ts +10 -2
- package/src/server/responses/core-combo.ts +53 -31
- package/src/server/responses/core-lifetime.ts +2 -2
- package/src/server/responses/core-normalize.ts +6 -3
- package/src/server/responses/core-opaque-recovery.ts +1 -0
- package/src/server/responses/core-options.ts +5 -0
- package/src/server/responses/core-replay.ts +2 -1
- package/src/server/responses/jev-combo-choice.ts +39 -0
- package/src/server/responses/jev-session-pin.ts +203 -0
- package/src/server/responses/passthrough-delivery.ts +4 -0
- package/src/server/responses/request-prepare.ts +10 -5
- package/src/server/responses/request-transport.ts +25 -6
- package/src/server/responses/run-turn-execution.ts +2 -4
- package/src/server/responses/sidecar-execution.ts +2 -4
- package/src/server/responses-phase-inference.ts +267 -0
- package/src/server/sse-payload-rewrite.ts +14 -1
- package/src/service/definition.ts +20 -8
- package/src/types/config.ts +6 -0
- package/src/types/provider.ts +9 -1
- package/src/types/request.ts +1 -0
- package/src/types/wire.ts +1 -0
- package/src/update/codex-cli-update-launch-policy.d.mts +9 -0
- package/src/update/codex-cli-update-launch-policy.mjs +30 -0
- package/src/usage/jev-stats.ts +54 -14
- package/gui/dist/assets/index-DGXS51sp.js +0 -94
- package/gui/dist/assets/quota-summary-DO1Sx5TM.js +0 -1
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
const schemaMaps = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]);
|
|
2
|
+
const schemaLists = new Set(["anyOf", "oneOf", "allOf", "prefixItems"]);
|
|
3
|
+
const schemaValues = new Set(["items", "additionalProperties", "unevaluatedProperties", "contains", "not", "if", "then", "else", "propertyNames"]);
|
|
4
|
+
const jsonTypes = new Set(["string", "number", "integer", "boolean", "object", "array", "null"]);
|
|
5
|
+
|
|
6
|
+
/** Normalize schemas only; defaults, examples and property names are data, never schemas. */
|
|
7
|
+
export function workBuddySchema(value: unknown, depth = 0): Record<string, unknown> {
|
|
8
|
+
if (depth > 64) throw new Error("WorkBuddy AI tool schema exceeds maximum depth");
|
|
9
|
+
if (value === false) return { not: {} };
|
|
10
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
11
|
+
const input = value as Record<string, unknown>, out = { ...input };
|
|
12
|
+
for (const [key, child] of Object.entries(input)) {
|
|
13
|
+
if (schemaMaps.has(key) && child && typeof child === "object" && !Array.isArray(child)) {
|
|
14
|
+
out[key] = Object.fromEntries(Object.entries(child).map(([name, schema]) => [name, workBuddySchema(schema, depth + 1)]));
|
|
15
|
+
} else if (schemaLists.has(key) && Array.isArray(child)) {
|
|
16
|
+
out[key] = child.map(schema => workBuddySchema(schema, depth + 1));
|
|
17
|
+
} else if (schemaValues.has(key)) {
|
|
18
|
+
if ((key === "additionalProperties" || key === "unevaluatedProperties") && typeof child === "boolean") continue;
|
|
19
|
+
out[key] = key === "items" && Array.isArray(child)
|
|
20
|
+
? { anyOf: child.map(schema => workBuddySchema(schema, depth + 1)) } : workBuddySchema(child, depth + 1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (typeof out.pattern === "string") out.pattern = out.pattern.replace(/\\\\|\\0(?![0-9])/g, match => match === "\\0" ? "\\u0000" : match);
|
|
24
|
+
if (out.required !== undefined && !Array.isArray(out.required)) delete out.required;
|
|
25
|
+
if (typeof out.type === "string") {
|
|
26
|
+
out.type = out.type.toLowerCase();
|
|
27
|
+
if (!jsonTypes.has(out.type as string)) delete out.type;
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(out.enum)) {
|
|
30
|
+
if (!out.enum.length) delete out.enum;
|
|
31
|
+
else if (out.type === undefined) {
|
|
32
|
+
const groups = new Map<string, unknown[]>();
|
|
33
|
+
for (const item of out.enum) {
|
|
34
|
+
const type = item === null ? "null" : Array.isArray(item) ? "array" : typeof item;
|
|
35
|
+
const values = groups.get(type) ?? []; values.push(item); groups.set(type, values);
|
|
36
|
+
}
|
|
37
|
+
if (groups.size === 1) out.type = groups.keys().next().value;
|
|
38
|
+
else {
|
|
39
|
+
const union = { anyOf: [...groups].map(([type, values]) => ({ type, enum: values })) };
|
|
40
|
+
out.allOf = [...(Array.isArray(out.allOf) ? out.allOf : []), union];
|
|
41
|
+
delete out.enum;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function workBuddyToolParameters(value: unknown): Record<string, unknown> {
|
|
49
|
+
const out = workBuddySchema(value);
|
|
50
|
+
const properties: Record<string, unknown> = { ...(out.properties as Record<string, unknown> ?? {}) };
|
|
51
|
+
const required = new Set(Array.isArray(out.required) ? out.required.filter(x => typeof x === "string") : []);
|
|
52
|
+
for (const key of ["anyOf", "oneOf", "allOf"]) {
|
|
53
|
+
for (const variant of Array.isArray(out[key]) ? out[key] : []) {
|
|
54
|
+
const row = variant as Record<string, unknown>;
|
|
55
|
+
Object.assign(properties, row.properties);
|
|
56
|
+
if (key === "allOf" && Array.isArray(row.required)) for (const field of row.required) if (typeof field === "string") required.add(field);
|
|
57
|
+
}
|
|
58
|
+
delete out[key];
|
|
59
|
+
}
|
|
60
|
+
return { ...out, type: "object", properties, ...(required.size ? { required: [...required] } : {}) };
|
|
61
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHmac, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import type { ProviderAdapter } from "./base";
|
|
3
|
+
import { workBuddyToolParameters } from "./workbuddy-schema";
|
|
4
|
+
import { retainTranslatedEvent, releaseTranslatedEvent } from "../lib/translator-budget";
|
|
5
|
+
import { createOpenAIChatAdapter } from "./openai-chat";
|
|
6
|
+
import type { AdapterEvent, OcxProviderConfig } from "../types";
|
|
7
|
+
import { assertWorkBuddyDestination, WORKBUDDY_BASE_URL, workBuddyHeaders } from "../providers/workbuddy";
|
|
8
|
+
|
|
9
|
+
const sessionSalt = randomBytes(32);
|
|
10
|
+
const opaqueId = (value: unknown) => createHmac("sha256", sessionSalt).update(JSON.stringify(value)).digest("hex").slice(0, 32);
|
|
11
|
+
|
|
12
|
+
export function createWorkBuddyAdapter(provider: OcxProviderConfig): ProviderAdapter {
|
|
13
|
+
const base = createOpenAIChatAdapter({ ...provider, adapter: "openai-chat", baseUrl: WORKBUDDY_BASE_URL, headers: undefined });
|
|
14
|
+
return {
|
|
15
|
+
...base, name: "workbuddy-ai",
|
|
16
|
+
async buildRequest(parsed, incoming) {
|
|
17
|
+
assertWorkBuddyDestination(provider.baseUrl);
|
|
18
|
+
if (!provider.apiKey || !parsed._workbuddyAuthContext) throw new Error("WorkBuddy AI account unavailable; run ocx login workbuddy-ai");
|
|
19
|
+
const req = await base.buildRequest({ ...parsed, stream: true }, incoming);
|
|
20
|
+
const body = JSON.parse(req.body);
|
|
21
|
+
const messages = body.messages as Array<Record<string, unknown>>;
|
|
22
|
+
for (const message of messages) if (message.role === "developer") message.role = "system";
|
|
23
|
+
if (messages[0]?.role !== "system") messages.unshift({ role: "system", content: "You are a helpful assistant." });
|
|
24
|
+
if (body.max_completion_tokens !== undefined) { body.max_tokens = body.max_completion_tokens; delete body.max_completion_tokens; }
|
|
25
|
+
if (body.tool_choice === "none") { delete body.tools; delete body.tool_choice; }
|
|
26
|
+
else if (body.tool_choice?.type === "function") body.tool_choice = body.tool_choice.function.name;
|
|
27
|
+
for (const tool of body.tools ?? []) {
|
|
28
|
+
if (tool.type === "function") tool.function.parameters = workBuddyToolParameters(tool.function.parameters);
|
|
29
|
+
}
|
|
30
|
+
const users = messages.map((m, index) => ({ m, index })).filter(({ m }) => m.role === "user");
|
|
31
|
+
const session = parsed._codexOwnThreadId || parsed._clientThreadId
|
|
32
|
+
|| (!parsed._promptCacheKeyIsSharedCohort ? parsed.options.promptCacheKey : undefined)
|
|
33
|
+
|| incoming.headers.get("session_id") || incoming.headers.get("x-session-id");
|
|
34
|
+
const conversation = session || users[0]?.m.content;
|
|
35
|
+
const latest = users.at(-1);
|
|
36
|
+
const requestId = randomUUID().replaceAll("-", "");
|
|
37
|
+
const headers = {
|
|
38
|
+
"Content-Type": "application/json", Accept: "text/event-stream",
|
|
39
|
+
...workBuddyHeaders(provider.apiKey, parsed._workbuddyAuthContext),
|
|
40
|
+
"X-Request-ID": requestId, "X-Conversation-Message-ID": requestId,
|
|
41
|
+
"X-Conversation-Request-ID": latest ? opaqueId([conversation, latest.index, latest.m.content]) : requestId,
|
|
42
|
+
...(conversation ? { "X-Conversation-ID": opaqueId(conversation) } : {}),
|
|
43
|
+
};
|
|
44
|
+
return { ...req, url: `${WORKBUDDY_BASE_URL}/chat/completions`, headers, body: JSON.stringify(body), redirect: "error" as const };
|
|
45
|
+
},
|
|
46
|
+
async parseResponse(response, budget, tier) {
|
|
47
|
+
const events: AdapterEvent[] = [];
|
|
48
|
+
try {
|
|
49
|
+
for await (const event of base.parseStream(response, budget, tier)) {
|
|
50
|
+
retainTranslatedEvent(event, budget, events.at(-1));
|
|
51
|
+
events.push(event);
|
|
52
|
+
}
|
|
53
|
+
return events;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
for (const event of events) releaseTranslatedEvent(event, budget);
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
formatErrorBody(status, _headers, payload) {
|
|
60
|
+
let code: unknown;
|
|
61
|
+
try { code = JSON.parse(payload)?.code; } catch { /* Only structured numeric codes are public. */ }
|
|
62
|
+
if (Number(code) === 11128) return "WorkBuddy AI rejected this client channel (11128).";
|
|
63
|
+
if (Number(code) === 11140) return "WorkBuddy AI restricted this account (11140); contact WorkBuddy support.";
|
|
64
|
+
if (Number(code) === 14018) return "WorkBuddy AI credits exhausted (14018).";
|
|
65
|
+
return `WorkBuddy AI request failed (HTTP ${status}).`;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -253,7 +253,7 @@ function buildResponseJSONWithBudget(
|
|
|
253
253
|
const text = stripCitationMarkers(currentTextStr);
|
|
254
254
|
const sourceBytes = pendingWebSources.reduce((sum, source) => sum + bytesOf(JSON.stringify(source)), 0);
|
|
255
255
|
const annotations = pendingWebSources.map(s => ({
|
|
256
|
-
type: "url_citation", url: s.url,
|
|
256
|
+
type: "url_citation", url: s.url, title: s.title || s.url, start_index: 0, end_index: 0,
|
|
257
257
|
}));
|
|
258
258
|
pendingWebSources = [];
|
|
259
259
|
const item = {
|
package/src/bridge/sse.ts
CHANGED
|
@@ -488,7 +488,7 @@ export function bridgeToResponsesSSE(
|
|
|
488
488
|
const takeWebAnnotations = (): { type: string; url: string; title?: string; start_index: number; end_index: number }[] => {
|
|
489
489
|
if (pendingWebSources.length === 0) return [];
|
|
490
490
|
const anns = pendingWebSources.map(s => ({
|
|
491
|
-
type: "url_citation", url: s.url,
|
|
491
|
+
type: "url_citation", url: s.url, title: s.title || s.url, start_index: 0, end_index: 0,
|
|
492
492
|
}));
|
|
493
493
|
const annotationBytes = bytesOf(JSON.stringify(anns));
|
|
494
494
|
const reservation = budget?.reserveTransient(annotationBytes, { kind: "retained_collectors" });
|
package/src/chat/outbound.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
UNDECLARED_TOOL_CALL_ERROR_CODE,
|
|
23
23
|
} from "../lib/errors";
|
|
24
24
|
import { redactSecretString } from "../lib/redact";
|
|
25
|
+
import { truncationReasonFor } from "../responses/truncated-stop-reason";
|
|
25
26
|
import { createSseBlockBuffer, sseDataPayload } from "../server/sse-payload-rewrite";
|
|
26
27
|
import { reasoningDetailEntriesForItem } from "./reasoning-details";
|
|
27
28
|
|
|
@@ -243,8 +244,14 @@ export function chatCompletionsIncompleteOutcome(
|
|
|
243
244
|
reason: unknown,
|
|
244
245
|
message: unknown,
|
|
245
246
|
): { finishReason: "length" | "content_filter" } | { failMessage: string } {
|
|
246
|
-
|
|
247
|
-
|
|
247
|
+
// Upstream Responses endpoints do not all use OpenAI's `max_output_tokens`: Volcengine Ark
|
|
248
|
+
// (Coding Plan `/responses`) sends `incomplete_details.reason: "length"` for a hit output cap.
|
|
249
|
+
// Read the reason through the shared truncation vocabulary so such a turn ends with
|
|
250
|
+
// finish_reason "length" instead of a 502. Proxy reasons (adapter_eof, upstream_stall_timeout,
|
|
251
|
+
// missing_terminal_event) are not in that vocabulary and still fail.
|
|
252
|
+
const truncation = typeof reason === "string" ? truncationReasonFor(reason) : undefined;
|
|
253
|
+
if (truncation === "max_output_tokens") return { finishReason: "length" };
|
|
254
|
+
if (truncation === "content_filter") return { finishReason: "content_filter" };
|
|
248
255
|
const why = typeof reason === "string" ? reason : "unknown";
|
|
249
256
|
return {
|
|
250
257
|
failMessage: typeof message === "string" && message.length > 0
|
|
@@ -871,8 +878,8 @@ export function responsesJsonToChatCompletion(json: unknown, model: string, tran
|
|
|
871
878
|
const incomplete = isRec(body.incomplete_details) ? body.incomplete_details : {};
|
|
872
879
|
let incompleteFinish: "length" | "content_filter" | undefined;
|
|
873
880
|
if (body.status === "incomplete") {
|
|
874
|
-
|
|
875
|
-
|
|
881
|
+
const outcome = chatCompletionsIncompleteOutcome(incomplete.reason, undefined);
|
|
882
|
+
if ("finishReason" in outcome) incompleteFinish = outcome.finishReason;
|
|
876
883
|
else throw new ChatCompletionsStreamError("upstream response ended without a supported completion boundary", {
|
|
877
884
|
code: "upstream_incomplete", type: "upstream_error",
|
|
878
885
|
});
|
|
@@ -385,13 +385,13 @@ export const AGENT_ROUTING_CAPABILITIES: readonly Capability[] = [
|
|
|
385
385
|
},
|
|
386
386
|
{
|
|
387
387
|
command: ["combo","set"],
|
|
388
|
-
usage: "ocx combo set <id> [--targets <provider/model[:weight],...> | --targets-file <FILE|->] [--strategy <failover|round-robin|random|least-used|reset-window|jev>] [--sticky <1-100|->] [--effort <low|medium|high|xhigh|max|ultra|->] [--effort-mode <fallback|force|->] [--alias <name|->] [--native-alias [on|off]] [--allow-metered-targets <on|off>] [--display-name <label|->] [--description <text|->] [--decision-provider <provider|-> | --decision-model <route|->] [--decision-timeout <ms|->] [--decision-quota-signals <on|off|->] [--decision-quota-tiers <JSON|->] [--rename-from <id>] [--image-input <auto|disabled>] [--reasoning-effort-mode <strict|adaptive>] [--json]",
|
|
388
|
+
usage: "ocx combo set <id> [--targets <provider/model[:weight],...> | --targets-file <FILE|->] [--strategy <failover|round-robin|random|least-used|reset-window|jev>] [--sticky <1-100|->] [--effort <low|medium|high|xhigh|max|ultra|->] [--effort-mode <fallback|force|->] [--alias <name|->] [--native-alias [on|off]] [--allow-metered-targets <on|off>] [--display-name <label|->] [--description <text|->] [--decision-provider <provider|-> | --decision-model <route|->] [--decision-timeout <ms|->] [--decision-quota-signals <on|off|->] [--decision-quota-tiers <JSON|->] [--session-pin <duration|->] [--rename-from <id>] [--image-input <auto|disabled>] [--reasoning-effort-mode <strict|adaptive>] [--json]",
|
|
389
389
|
summary: "Create, update or rename a combo.",
|
|
390
390
|
routes: [{"method":"GET","path":"/api/combos"},{"method":"PUT","path":"/api/combos"}],
|
|
391
|
-
flags: [{"name":"--allow-metered-targets","value":"string","summary":"on grants operator consent to metered targets only on a loopback-only proxy; off clears it."},{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--description","value":"string","summary":"Catalog row description; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--decision-quota-signals","value":"string","summary":"JEV advisory quota on/off; - clears."},{"name":"--decision-quota-tiers","value":"string","summary":"Partial JSON thresholds; - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."}],
|
|
391
|
+
flags: [{"name":"--allow-metered-targets","value":"string","summary":"on grants operator consent to metered targets only on a loopback-only proxy; off clears it."},{"name":"--targets","value":"string","summary":"Ordered provider/model[:weight] list; required for creation."},{"name":"--strategy","value":"string","summary":"Routing strategy."},{"name":"--sticky","value":"string","summary":"Round-robin limit 1-100; - resets."},{"name":"--effort","value":"string","summary":"Default effort; - clears."},{"name":"--effort-mode","value":"string","summary":"fallback or force; - resets to fallback."},{"name":"--alias","value":"string","summary":"Alias; - clears."},{"name":"--display-name","value":"string","summary":"Label; - clears."},{"name":"--description","value":"string","summary":"Catalog row description; - clears."},{"name":"--decision-provider","value":"string","summary":"JEV provider; - clears."},{"name":"--decision-model","value":"string","summary":"JEV route; - clears; exclusive with provider."},{"name":"--decision-timeout","value":"string","summary":"JEV deadline 1000-120000 ms; - clears."},{"name":"--decision-quota-signals","value":"string","summary":"JEV advisory quota on/off; - clears."},{"name":"--decision-quota-tiers","value":"string","summary":"Partial JSON thresholds; - clears."},{"name":"--session-pin","value":"string","summary":"JEV session pin idle window 1m-24h (90s, 20m, 1h); - clears."},{"name":"--rename-from","value":"string","summary":"Existing combo ID to rename."},{"name":"--json","value":"boolean","summary":"Emit structured JSON."},{"name":"--native-alias","value":"string","summary":"Bare legacy flag means on; explicit on/off preserves presence, including false."},{"name":"--targets-file","value":"string","summary":"Bounded ordered target JSON array, exclusive with --targets; preserves effort/profile/last-resort metadata."},{"name":"--image-input","value":"string","summary":"auto or disabled; explicit auto clears the previous disabled choice."},{"name":"--reasoning-effort-mode","value":"string","summary":"strict or adaptive, distinct from --effort-mode fallback/force."}],
|
|
392
392
|
mutates: true,
|
|
393
393
|
json: "payload",
|
|
394
|
-
details: ["create and update are aliases of set. Existing fields are read before writing; --targets replaces targets.","force overrides valid client effort and may increase cost and latency.","Only the implemented target grammar is supported; full GUI target metadata and routing-profile writes are separate tasks.","New file/flag conflicts refuse before transport. Partial edits preserve target metadata; explicit false/default values override server carry. Turning a native alias off may also require clearing its incompatible alias.","Read/merge/write uses one target and remains an upsert without revision CAS. Saved-but-incomplete catalog outcomes return nonzero with their receipt."],
|
|
394
|
+
details: ["create and update are aliases of set. Existing fields are read before writing; --targets replaces targets. Without --strategy an existing combo keeps its stored strategy and JEV policy; a new combo defaults to failover.","force overrides valid client effort and may increase cost and latency.","Only the implemented target grammar is supported; full GUI target metadata and routing-profile writes are separate tasks.","New file/flag conflicts refuse before transport. Partial edits preserve target metadata; explicit false/default values override server carry. Turning a native alias off may also require clearing its incompatible alias.","Read/merge/write uses one target and remains an upsert without revision CAS. Saved-but-incomplete catalog outcomes return nonzero with their receipt."],
|
|
395
395
|
},
|
|
396
396
|
{
|
|
397
397
|
command: ["combo", "remove"],
|
|
@@ -221,11 +221,19 @@ function inspectionDeps(commandDeps: CodexCliUpdateCommandDeps): CodexCliInstall
|
|
|
221
221
|
return {
|
|
222
222
|
env: {
|
|
223
223
|
...trusted.managerRoots,
|
|
224
|
+
...trusted.transportEnv,
|
|
224
225
|
CODEX_CLI_PATH: trusted.codexCliPath ?? undefined,
|
|
225
226
|
PATH: trusted.path ?? undefined,
|
|
226
227
|
PATHEXT: trusted.pathExt ?? undefined,
|
|
228
|
+
// The lookup seeds the inspection models against the launch-time
|
|
229
|
+
// capture; ambient values are never consulted.
|
|
230
|
+
HOME: trusted.home ?? undefined,
|
|
231
|
+
NODE_PATH: trusted.nodePath ?? undefined,
|
|
227
232
|
},
|
|
228
233
|
configDir: trusted.configDir,
|
|
234
|
+
// Content observation picks the platform backing by the Node launcher's
|
|
235
|
+
// arch, which can differ from this Bun process's under Rosetta.
|
|
236
|
+
arch: trusted.arch ?? undefined,
|
|
229
237
|
// The snapshot is proof-bound to the launcher, but the report may only attest
|
|
230
238
|
// the environment candidate when the resolver picks the same canonical path —
|
|
231
239
|
// managed requires both.
|
package/src/cli/combo-result.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { normalizeJevQuotaTiers } from "../combos/jev-quota-config";
|
|
2
|
+
import { normalizeJevSessionPin } from "../combos/jev-session-pin-config";
|
|
2
3
|
import { parseComboTargets } from "./combo-input";
|
|
3
4
|
import { printCatalogResult } from "./catalog-command-result";
|
|
4
5
|
|
|
@@ -39,6 +40,11 @@ function publicCombo(value: unknown): Record<string, unknown> {
|
|
|
39
40
|
if (!tiers) invalid();
|
|
40
41
|
result[key] = tiers;
|
|
41
42
|
continue;
|
|
43
|
+
} else if (key === "sessionPin") {
|
|
44
|
+
const pin = normalizeJevSessionPin(child);
|
|
45
|
+
if (!pin) invalid();
|
|
46
|
+
result[key] = pin;
|
|
47
|
+
continue;
|
|
42
48
|
} else if (key === "nativeAlias" || key === "allowMeteredTargets" || key === "decisionQuotaSignals") {
|
|
43
49
|
if (typeof child !== "boolean") invalid();
|
|
44
50
|
} else invalid();
|
package/src/cli/combo-stats.ts
CHANGED
|
@@ -53,6 +53,8 @@ function projectStats(value: unknown, id: string, range: string) {
|
|
|
53
53
|
const rawSummary = record(dto.summary);
|
|
54
54
|
const summary = {
|
|
55
55
|
...counts(rawSummary, SUMMARY_COUNTS),
|
|
56
|
+
// Absent means zero: a proxy started before session pins (#7525) does not report the field.
|
|
57
|
+
pinnedRequests: Object.hasOwn(rawSummary, "pinnedRequests") ? number(rawSummary.pinnedRequests) : 0,
|
|
56
58
|
averageLatencyMs: nullable(rawSummary.averageLatencyMs),
|
|
57
59
|
averageConfidence: nullable(rawSummary.averageConfidence, 1),
|
|
58
60
|
averageChosenProbability: nullable(rawSummary.averageChosenProbability, 1),
|
|
@@ -111,6 +113,7 @@ export async function handleComboStatsCommand(argv: string[], deps: RuntimeApiDe
|
|
|
111
113
|
`Combo ${id} — JEV observations (${range})`,
|
|
112
114
|
...(summary.decisions === 0 ? ["No recorded JEV decisions in this window."] : []),
|
|
113
115
|
`Decisions: ${summary.decisions}; applied: ${summary.appliedDecisions}; fail-open: ${summary.failOpenDecisions}`,
|
|
116
|
+
...(summary.pinnedRequests > 0 ? [`Session-pinned requests (no decision call): ${summary.pinnedRequests}`] : []),
|
|
114
117
|
`Successful requests: ${summary.successfulRequests}; requests with model fallback: ${summary.requestsWithModelFallback}`,
|
|
115
118
|
`Model tokens: ${summary.modelTotalTokens}; measured attempts: ${summary.measuredModelAttempts}/${summary.modelAttempts}`,
|
|
116
119
|
`Decision tokens: ${summary.decisionTotalTokens}; decisions reporting usage: ${summary.decisionUsageReported}/${summary.decisions}`,
|
package/src/cli/combo.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { normalizeJevQuotaTiers } from "../combos/jev-quota-config";
|
|
2
|
+
import { parseJevSessionPinDuration } from "../combos/jev-session-pin-config";
|
|
2
3
|
import { JEV_DECISION_TIMEOUT_MAX_MS, JEV_DECISION_TIMEOUT_MIN_MS } from "../combos/types";
|
|
3
4
|
import {
|
|
4
5
|
CliUsageError,
|
|
5
6
|
printData,
|
|
6
7
|
rejectArgs,
|
|
8
|
+
RuntimeApiError,
|
|
7
9
|
runCliAction,
|
|
8
10
|
runtimeRequest,
|
|
9
11
|
runtimeBaseUrl,
|
|
@@ -29,6 +31,7 @@ const USAGE = `Usage:
|
|
|
29
31
|
[--image-input <auto|disabled>] [--reasoning-effort-mode <strict|adaptive>]
|
|
30
32
|
[--decision-provider <provider|-> | --decision-model <route|->] [--decision-timeout <ms|->]
|
|
31
33
|
[--decision-quota-signals <on|off|->] [--decision-quota-tiers <JSON|->]
|
|
34
|
+
[--session-pin <duration|->] (e.g. 20m; keeps a session on JEV's target while active)
|
|
32
35
|
(jev only; the provider must be a configured jev-decision row)
|
|
33
36
|
[--rename-from <id>] [--json]
|
|
34
37
|
ocx combo stats <id> [--range <7d|30d|all>] [--json]
|
|
@@ -39,6 +42,15 @@ const USAGE = `Usage:
|
|
|
39
42
|
ocx combo discover [--query <text>] [--json]`;
|
|
40
43
|
|
|
41
44
|
type ComboRow = Record<string, unknown> & { id?: string; model?: string; strategy?: string };
|
|
45
|
+
const STRATEGIES = ["failover", "round-robin", "random", "least-used", "reset-window", "jev"] as const;
|
|
46
|
+
const isComboStrategy = (value: string): value is typeof STRATEGIES[number] => (STRATEGIES as readonly string[]).includes(value);
|
|
47
|
+
// Combo writes print fixed messages instead of server text. When --targets kept a stored JEV
|
|
48
|
+
// strategy the user did not name, a refusal of the targets or a JEV setting says so (#7579).
|
|
49
|
+
const KEPT_JEV_REJECTED = "combo_targets_rejected_by_kept_jev";
|
|
50
|
+
const KEPT_JEV_FIELDS = new Set(["targets", "decisionProvider", "decisionModel", "decisionTimeoutMs", "decisionQuotaSignals", "decisionQuotaTiers", "sessionPin"]);
|
|
51
|
+
const SET_ERRORS = {
|
|
52
|
+
[KEPT_JEV_REJECTED]: "The selected target rejected the new targets or the kept jev settings. Without --strategy, --targets keeps the stored jev strategy with its decision method, quota policy and session pin, and the save checks the new targets together with them. Inspect the combo with ocx combo show <id> --json, then fix the targets or those settings, or pass --strategy to change the strategy.",
|
|
53
|
+
};
|
|
42
54
|
|
|
43
55
|
function parseTargets(value: string): Array<{ provider: string; model: string; weight?: number }> {
|
|
44
56
|
const targets = value.split(",").map(part => part.trim()).filter(Boolean).map(part => {
|
|
@@ -91,29 +103,17 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
|
|
|
91
103
|
if (!id) throw new CliUsageError("combo id is required", USAGE);
|
|
92
104
|
const additional = await prepareComboInput(args, deps);
|
|
93
105
|
const targetsRaw = takeOption(args, "--targets");
|
|
106
|
+
const parsedTargets = targetsRaw === undefined ? undefined : parseTargets(targetsRaw);
|
|
94
107
|
const renameFrom = takeOption(args, "--rename-from");
|
|
95
|
-
const replacingTargets =
|
|
96
|
-
deps = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
|
|
97
|
-
// A structured target file replaces the target list, not unrelated combo settings.
|
|
98
|
-
const partialCurrent = targetsRaw === undefined
|
|
99
|
-
? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", { redirect: "error" }, deps)
|
|
100
|
-
: undefined;
|
|
101
|
-
const partialExisting = partialCurrent?.combos?.find(row => row.id === (renameFrom ?? id));
|
|
102
|
-
if (!replacingTargets && !partialExisting) {
|
|
103
|
-
throw new CliUsageError("--targets is required when creating a combo", USAGE);
|
|
104
|
-
}
|
|
108
|
+
const replacingTargets = parsedTargets !== undefined || additional.targets !== undefined;
|
|
105
109
|
const strategyRaw = takeOption(args, "--strategy");
|
|
106
|
-
|
|
107
|
-
if (strategy !== "failover" && strategy !== "round-robin" && strategy !== "random" && strategy !== "least-used" && strategy !== "reset-window" && strategy !== "jev") throw new CliUsageError("--strategy must be failover, round-robin, random, least-used, reset-window, or jev", USAGE);
|
|
110
|
+
if (strategyRaw !== undefined && !isComboStrategy(strategyRaw)) throw new CliUsageError("--strategy must be failover, round-robin, random, least-used, reset-window, or jev", USAGE);
|
|
108
111
|
const stickyRaw = takeOption(args, "--sticky");
|
|
109
112
|
const stickyLimit = stickyRaw === undefined ? undefined : stickyRaw === "-" ? 1 : Number(stickyRaw);
|
|
110
113
|
if (stickyLimit !== undefined && (!Number.isInteger(stickyLimit) || stickyLimit < 1)) {
|
|
111
114
|
throw new CliUsageError("--sticky must be an integer from 1 to 100, or -", USAGE);
|
|
112
115
|
}
|
|
113
|
-
if (stickyLimit !== undefined)
|
|
114
|
-
if (stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE);
|
|
115
|
-
if (strategy !== "round-robin") throw new CliUsageError("--sticky applies only to round-robin", USAGE);
|
|
116
|
-
}
|
|
116
|
+
if (stickyLimit !== undefined && stickyLimit > 100) throw new CliUsageError("--sticky must be <= 100", USAGE);
|
|
117
117
|
const effort = takeOption(args, "--effort");
|
|
118
118
|
const effortMode = takeOption(args, "--effort-mode");
|
|
119
119
|
if (effortMode !== undefined && effortMode !== "fallback" && effortMode !== "force" && effortMode !== "-") {
|
|
@@ -128,19 +128,17 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
|
|
|
128
128
|
if (decisionModel !== undefined && decisionModel !== "-" && decisionProvider !== undefined && decisionProvider !== "-") {
|
|
129
129
|
throw new CliUsageError("--decision-model cannot be combined with --decision-provider", USAGE);
|
|
130
130
|
}
|
|
131
|
-
if (decisionModel !== undefined && decisionModel !== "-" && strategy !== "jev") {
|
|
132
|
-
throw new CliUsageError("--decision-model applies only to the jev strategy", USAGE);
|
|
133
|
-
}
|
|
134
|
-
if (decisionProvider !== undefined && decisionProvider !== "-" && strategy !== "jev") {
|
|
135
|
-
throw new CliUsageError("--decision-provider applies only to the jev strategy", USAGE);
|
|
136
|
-
}
|
|
137
131
|
const quotaSignals = takeOption(args, "--decision-quota-signals");
|
|
138
132
|
const quotaTiers = takeOption(args, "--decision-quota-tiers");
|
|
139
133
|
if (quotaSignals !== undefined && !["on", "off", "-"].includes(quotaSignals)) throw new CliUsageError("--decision-quota-signals must be on, off or -", USAGE);
|
|
140
134
|
let parsedQuotaTiers: ReturnType<typeof normalizeJevQuotaTiers> | null;
|
|
141
135
|
try { parsedQuotaTiers = quotaTiers === undefined || quotaTiers === "-" ? null : normalizeJevQuotaTiers(JSON.parse(quotaTiers)); } catch { throw new CliUsageError("invalid --decision-quota-tiers JSON", USAGE); }
|
|
142
136
|
if (quotaTiers !== undefined && quotaTiers !== "-" && !parsedQuotaTiers) throw new CliUsageError("quota tiers must be finite 0..100 and strictly ascending", USAGE);
|
|
143
|
-
|
|
137
|
+
const sessionPinRaw = takeOption(args, "--session-pin");
|
|
138
|
+
const sessionPinMs = sessionPinRaw === undefined || sessionPinRaw === "-" ? undefined : parseJevSessionPinDuration(sessionPinRaw);
|
|
139
|
+
if (sessionPinRaw !== undefined && sessionPinRaw !== "-" && sessionPinMs === undefined) {
|
|
140
|
+
throw new CliUsageError("--session-pin must be a duration from 1m to 24h (for example 90s, 20m, 1h, or milliseconds), or -", USAGE);
|
|
141
|
+
}
|
|
144
142
|
const decisionTimeout = takeOption(args, "--decision-timeout");
|
|
145
143
|
let decisionTimeoutMs: number | null | undefined;
|
|
146
144
|
if (decisionTimeout !== undefined) {
|
|
@@ -154,19 +152,38 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
|
|
|
154
152
|
USAGE,
|
|
155
153
|
);
|
|
156
154
|
}
|
|
157
|
-
if (decisionTimeoutMs !== null && strategy !== "jev") {
|
|
158
|
-
throw new CliUsageError("--decision-timeout applies only to the jev strategy", USAGE);
|
|
159
|
-
}
|
|
160
155
|
}
|
|
161
156
|
rejectArgs(args, USAGE);
|
|
157
|
+
// Flags that belong to one strategy. An explicit --strategy is checked before any request; an
|
|
158
|
+
// omitted one is the stored strategy, so it is checked once the row has been read.
|
|
159
|
+
const requireStrategyFlags = (strategy: string): void => {
|
|
160
|
+
if (stickyLimit !== undefined && strategy !== "round-robin") throw new CliUsageError("--sticky applies only to round-robin", USAGE);
|
|
161
|
+
if (strategy === "jev") return;
|
|
162
|
+
if (decisionModel !== undefined && decisionModel !== "-") throw new CliUsageError("--decision-model applies only to the jev strategy", USAGE);
|
|
163
|
+
if (decisionProvider !== undefined && decisionProvider !== "-") throw new CliUsageError("--decision-provider applies only to the jev strategy", USAGE);
|
|
164
|
+
if (quotaSignals !== undefined && quotaSignals !== "-" || quotaTiers !== undefined && quotaTiers !== "-") throw new CliUsageError("quota settings apply only to jev", USAGE);
|
|
165
|
+
if (sessionPinMs !== undefined) throw new CliUsageError("--session-pin applies only to the jev strategy", USAGE);
|
|
166
|
+
if (decisionTimeoutMs != null) throw new CliUsageError("--decision-timeout applies only to the jev strategy", USAGE);
|
|
167
|
+
};
|
|
168
|
+
if (strategyRaw !== undefined) requireStrategyFlags(strategyRaw);
|
|
169
|
+
deps = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
|
|
170
|
+
// Replacing targets keeps every other stored setting, including the strategy and its policy (#7579).
|
|
171
|
+
const current = await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", { redirect: "error" }, deps);
|
|
172
|
+
const existing = current.combos?.find(row => row.id === (renameFrom ?? id));
|
|
173
|
+
if (!replacingTargets && !existing) {
|
|
174
|
+
throw new CliUsageError("--targets is required when creating a combo", USAGE);
|
|
175
|
+
}
|
|
176
|
+
const strategy = strategyRaw ?? existing?.strategy ?? "failover";
|
|
177
|
+
if (!isComboStrategy(strategy)) throw new CliUsageError(`combo ${renameFrom ?? id} has an unsupported stored strategy; pass --strategy`, USAGE);
|
|
178
|
+
if (strategyRaw === undefined) requireStrategyFlags(strategy);
|
|
162
179
|
// Listing-only nulls must not be resent as explicit clears (alias rejects null).
|
|
163
|
-
const combo: Record<string, unknown> =
|
|
164
|
-
? Object.fromEntries(Object.entries(
|
|
165
|
-
: { strategy, stickyLimit: stickyLimit ?? 1
|
|
180
|
+
const combo: Record<string, unknown> = existing
|
|
181
|
+
? Object.fromEntries(Object.entries(existing).filter(([, value]) => value !== null))
|
|
182
|
+
: { strategy, stickyLimit: stickyLimit ?? 1 };
|
|
166
183
|
delete combo.id;
|
|
167
184
|
delete combo.model;
|
|
168
|
-
if (
|
|
169
|
-
|
|
185
|
+
if (replacingTargets) combo.targets = additional.targets ?? parsedTargets;
|
|
186
|
+
combo.strategy = strategy;
|
|
170
187
|
if (stickyLimit !== undefined) combo.stickyLimit = stickyLimit;
|
|
171
188
|
if (effort !== undefined) combo.defaultEffort = effort === "-" ? null : effort;
|
|
172
189
|
if (effortMode !== undefined) combo.defaultEffortMode = effortMode === "-" ? "fallback" : effortMode;
|
|
@@ -184,26 +201,33 @@ async function set(argv: string[], deps: RuntimeApiDeps): Promise<number> {
|
|
|
184
201
|
if (decisionTimeoutMs !== undefined) combo.decisionTimeoutMs = decisionTimeoutMs;
|
|
185
202
|
if (quotaSignals !== undefined) combo.decisionQuotaSignals = quotaSignals === "-" ? null : quotaSignals === "on";
|
|
186
203
|
if (quotaTiers !== undefined) combo.decisionQuotaTiers = parsedQuotaTiers;
|
|
204
|
+
if (sessionPinRaw !== undefined) combo.sessionPin = sessionPinMs === undefined ? null : { ttlMs: sessionPinMs };
|
|
187
205
|
if (strategy !== "jev") {
|
|
188
206
|
delete combo.decisionProvider;
|
|
189
207
|
delete combo.decisionModel;
|
|
190
208
|
delete combo.decisionTimeoutMs;
|
|
191
209
|
delete combo.decisionQuotaSignals;
|
|
192
210
|
delete combo.decisionQuotaTiers;
|
|
193
|
-
|
|
194
|
-
const current = partialCurrent ?? await runtimeRequest<{ combos?: ComboRow[] }>("/api/combos", { redirect: "error" }, deps);
|
|
195
|
-
const existing = (current.combos ?? []).find(row => row.id === (renameFrom ?? id));
|
|
196
|
-
if (additional.imageInput === undefined && existing?.imageInput === "disabled") combo.imageInput = "disabled";
|
|
197
|
-
if (effort === undefined && existing?.defaultEffort != null && combo.defaultEffort === undefined) {
|
|
198
|
-
combo.defaultEffort = existing.defaultEffort;
|
|
211
|
+
delete combo.sessionPin;
|
|
199
212
|
}
|
|
200
213
|
if (effortMode === undefined && existing?.defaultEffortMode === "force") {
|
|
201
214
|
combo.defaultEffortMode = effort === "-" ? "fallback" : "force";
|
|
202
215
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
216
|
+
let result: unknown;
|
|
217
|
+
try {
|
|
218
|
+
result = await runtimeRequest("/api/combos", {
|
|
219
|
+
method: "PUT", redirect: "error",
|
|
220
|
+
body: serializeManagementJson({ id, combo, ...(renameFrom ? { renameFrom } : {}) }),
|
|
221
|
+
}, deps);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const field = error instanceof RuntimeApiError && error.body !== null && typeof error.body === "object"
|
|
224
|
+
? (error.body as Record<string, unknown>).field : undefined;
|
|
225
|
+
if (error instanceof RuntimeApiError && error.status === 400 && typeof field === "string" && KEPT_JEV_FIELDS.has(field)
|
|
226
|
+
&& replacingTargets && strategyRaw === undefined && strategy === "jev") {
|
|
227
|
+
throw new RuntimeApiError(error.message, error.status, { code: KEPT_JEV_REJECTED });
|
|
228
|
+
}
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
207
231
|
return printComboWriteResult(result, id, wantsJson);
|
|
208
232
|
}
|
|
209
233
|
|
|
@@ -286,7 +310,7 @@ async function discover(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
286
310
|
|
|
287
311
|
export async function handleComboCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
|
|
288
312
|
const [subcommand, ...writeArgs] = argv;
|
|
289
|
-
if (subcommand === "set" || subcommand === "create" || subcommand === "update") return runCatalogAction(() => set(writeArgs, deps));
|
|
313
|
+
if (subcommand === "set" || subcommand === "create" || subcommand === "update") return runCatalogAction(() => set(writeArgs, deps), SET_ERRORS);
|
|
290
314
|
if (subcommand === "stats") {
|
|
291
315
|
const { handleComboStatsCommand } = await import("./combo-stats");
|
|
292
316
|
return handleComboStatsCommand(writeArgs, deps);
|
|
@@ -24,7 +24,11 @@ export type TrustedNodeLaunchContext = {
|
|
|
24
24
|
codexCliPath: string | null;
|
|
25
25
|
path: string | null;
|
|
26
26
|
pathExt: string | null;
|
|
27
|
+
home: string | null;
|
|
28
|
+
nodePath: string | null;
|
|
27
29
|
managerRoots: CodexCliVersionManagerRoots | null;
|
|
30
|
+
transportEnv: Readonly<Record<string, string>>;
|
|
31
|
+
arch: NodeJS.Architecture | null;
|
|
28
32
|
configDir: string;
|
|
29
33
|
}> | null;
|
|
30
34
|
};
|
|
@@ -50,6 +54,27 @@ function parseVersionManagerRoots(value: unknown): CodexCliVersionManagerRoots |
|
|
|
50
54
|
return Object.freeze(Object.fromEntries(entries)) as CodexCliVersionManagerRoots;
|
|
51
55
|
}
|
|
52
56
|
|
|
57
|
+
function parseTransportEnv(value: unknown): Readonly<Record<string, string>> | undefined {
|
|
58
|
+
// Older snapshots convey no transport settings. Never fill them from Bun's ambient env.
|
|
59
|
+
if (value === undefined) return Object.freeze({});
|
|
60
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
61
|
+
const entries = Object.entries(value);
|
|
62
|
+
if (entries.some(([name, setting]) => !CODEX_CLI_TRANSPORT_ENV_KEYS.includes(name.toLowerCase())
|
|
63
|
+
|| typeof setting !== "string" || setting.length > 32 * 1024)) return undefined;
|
|
64
|
+
return Object.freeze(Object.fromEntries(entries));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const NODE_ARCHITECTURES: ReadonlySet<string> = new Set<NodeJS.Architecture>([
|
|
68
|
+
"arm", "arm64", "ia32", "loong64", "mips", "mipsel", "ppc64", "riscv64", "s390x", "x64",
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
function parseLauncherArch(value: unknown): NodeJS.Architecture | null | undefined {
|
|
72
|
+
// Older snapshots predate the field; absent stays null (Bun's own arch is the
|
|
73
|
+
// pre-existing fallback). Any other non-architecture value is untrusted.
|
|
74
|
+
if (value === undefined || value === null) return null;
|
|
75
|
+
return typeof value === "string" && NODE_ARCHITECTURES.has(value) ? value as NodeJS.Architecture : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
53
78
|
/** Consume the internal proof before normal CLI argument parsing. */
|
|
54
79
|
export function initializeNodeLauncherContext(
|
|
55
80
|
argv: string[] = process.argv,
|
|
@@ -70,7 +95,7 @@ export function initializeNodeLauncherContext(
|
|
|
70
95
|
delete env.OCX_PRE_BUN_ANTHROPIC_ENV;
|
|
71
96
|
trustedContext = null;
|
|
72
97
|
|
|
73
|
-
if (proofArgs.length !== 1 || !raw || raw.length >
|
|
98
|
+
if (proofArgs.length !== 1 || !raw || raw.length > NODE_LAUNCH_CONTEXT_MAX_CHARS) return null;
|
|
74
99
|
const proof = proofArgs[0]!;
|
|
75
100
|
if (!isLaunchProof(proof)) return null;
|
|
76
101
|
|
|
@@ -95,6 +120,12 @@ export function initializeNodeLauncherContext(
|
|
|
95
120
|
const managerRoots = inspection && typeof inspection === "object" && !Array.isArray(inspection)
|
|
96
121
|
? parseVersionManagerRoots((inspection as Record<string, unknown>).managerRoots)
|
|
97
122
|
: null;
|
|
123
|
+
const transportEnv = inspection && typeof inspection === "object" && !Array.isArray(inspection)
|
|
124
|
+
? parseTransportEnv((inspection as Record<string, unknown>).transportEnv)
|
|
125
|
+
: undefined;
|
|
126
|
+
const arch = inspection && typeof inspection === "object" && !Array.isArray(inspection)
|
|
127
|
+
? parseLauncherArch((inspection as Record<string, unknown>).arch)
|
|
128
|
+
: undefined;
|
|
98
129
|
const codexCliInspectionEnv = inspection === null || inspection === undefined
|
|
99
130
|
? null
|
|
100
131
|
: inspection && typeof inspection === "object" && !Array.isArray(inspection)
|
|
@@ -102,15 +133,27 @@ export function initializeNodeLauncherContext(
|
|
|
102
133
|
const value = (inspection as Record<string, unknown>)[key];
|
|
103
134
|
return value === null || typeof value === "string";
|
|
104
135
|
})
|
|
136
|
+
// Older launchers predate the lookup-seed slots; absent stays null so
|
|
137
|
+
// the fallback lane simply remains unverifiable rather than untrusted.
|
|
138
|
+
&& ["home", "nodePath"].every(key => {
|
|
139
|
+
const value = (inspection as Record<string, unknown>)[key];
|
|
140
|
+
return value === undefined || value === null || typeof value === "string";
|
|
141
|
+
})
|
|
105
142
|
&& typeof (inspection as Record<string, unknown>).configDir === "string"
|
|
106
143
|
&& (inspection as Record<string, string>).configDir.length > 0
|
|
107
144
|
&& (inspection as Record<string, string>).configDir.length <= 32 * 1024
|
|
108
145
|
&& managerRoots !== undefined
|
|
146
|
+
&& transportEnv !== undefined
|
|
147
|
+
&& arch !== undefined
|
|
109
148
|
? Object.freeze({
|
|
110
149
|
codexCliPath: (inspection as Record<string, string | null>).codexCliPath ?? null,
|
|
111
150
|
path: (inspection as Record<string, string | null>).path ?? null,
|
|
112
151
|
pathExt: (inspection as Record<string, string | null>).pathExt ?? null,
|
|
152
|
+
home: (inspection as Record<string, string | null>).home ?? null,
|
|
153
|
+
nodePath: (inspection as Record<string, string | null>).nodePath ?? null,
|
|
113
154
|
managerRoots,
|
|
155
|
+
transportEnv,
|
|
156
|
+
arch,
|
|
114
157
|
configDir: (inspection as Record<string, string>).configDir,
|
|
115
158
|
})
|
|
116
159
|
: undefined;
|
|
@@ -125,4 +168,8 @@ export function initializeNodeLauncherContext(
|
|
|
125
168
|
export function trustedNodeLauncherContext(): TrustedNodeLaunchContext | null {
|
|
126
169
|
return trustedContext;
|
|
127
170
|
}
|
|
128
|
-
import {
|
|
171
|
+
import {
|
|
172
|
+
CODEX_CLI_TRANSPORT_ENV_KEYS,
|
|
173
|
+
CODEX_CLI_VERSION_MANAGER_ROOT_ENV_SLOTS,
|
|
174
|
+
NODE_LAUNCH_CONTEXT_MAX_CHARS,
|
|
175
|
+
} from "../update/codex-cli-update-launch-policy.mjs";
|