@bitkyc08/opencodex 2.20.0 → 2.22.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/AGENTS_INSTALL.md +32 -0
- package/README.md +1 -1
- package/gui/dist/assets/{index-DSK3S5HY.js → index-ClEcVlFO.js} +43 -17
- package/gui/dist/assets/index-DQsMZzI5.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic.ts +2 -28
- package/src/adapters/google.ts +31 -3
- package/src/adapters/openai-chat.ts +25 -8
- package/src/adapters/responses-tool-schema.ts +67 -0
- package/src/bridge.ts +15 -2
- package/src/claude/agents-inject.ts +2 -2
- package/src/claude/gateway-cache.ts +41 -4
- package/src/cli/claude.ts +1 -1
- package/src/cli/codex-log-guard-doctor.ts +103 -0
- package/src/cli/dispatch.ts +7 -1
- package/src/cli/help.ts +1 -1
- package/src/cli/models.ts +16 -6
- package/src/cli/observe.ts +38 -2
- package/src/cli/registry.ts +2 -1
- package/src/cli/v2.ts +34 -1
- package/src/codex/app-server-processes.ts +46 -26
- package/src/codex/catalog/effort.ts +49 -1
- package/src/codex/catalog/parsing.ts +64 -4
- package/src/codex/catalog/provider-fetch.ts +12 -0
- package/src/codex/catalog/sync.ts +14 -1
- package/src/codex/convergence.ts +2 -0
- package/src/codex/inject.ts +3 -3
- package/src/codex/log-guard/inspect.ts +506 -0
- package/src/codex/log-guard/lock.ts +150 -0
- package/src/codex/log-guard/maintenance.ts +403 -0
- package/src/codex/log-guard/path-safety.ts +39 -0
- package/src/codex/log-guard/policy.ts +44 -0
- package/src/codex/log-guard/processes.ts +205 -0
- package/src/codex/log-guard/protection.ts +489 -0
- package/src/codex/log-guard/sqlite-errors.ts +9 -0
- package/src/codex/paths.ts +5 -0
- package/src/codex/plugins-doctor.ts +1 -1
- package/src/codex/project-config-warnings.ts +2 -2
- package/src/generated/compatibility-version.json +93 -45
- package/src/images/loop.ts +15 -5
- package/src/providers/antigravity-models.ts +11 -1
- package/src/providers/model-discovery.ts +94 -6
- package/src/providers/quota.ts +159 -0
- package/src/providers/registry.ts +20 -1
- package/src/providers/slug-codec.ts +29 -0
- package/src/responses/custom-tool-compat.ts +4 -1
- package/src/responses/parser.ts +7 -1
- package/src/responses/provider-opaque-metadata.ts +73 -0
- package/src/responses/schema.ts +6 -0
- package/src/router.ts +12 -4
- package/src/routing/capability.ts +32 -17
- package/src/server/auth-cors.ts +42 -6
- package/src/server/index.ts +1 -0
- package/src/server/management/agent-settings-routes.ts +20 -2
- package/src/server/management/context.ts +15 -0
- package/src/server/management/model-routes.ts +12 -3
- package/src/server/management/storage-log-guard-routes.ts +186 -0
- package/src/server/management-api.ts +3 -1
- package/src/server/responses/core.ts +13 -0
- package/src/server/system-env.ts +1 -1
- package/src/types.ts +24 -2
- package/src/web-search/loop.ts +21 -5
- package/gui/dist/assets/index-DF_UFrGS.css +0 -1
|
@@ -14,6 +14,7 @@ import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalo
|
|
|
14
14
|
import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing";
|
|
15
15
|
import { canSerializeServiceTierForChatModel } from "../providers/service-tier";
|
|
16
16
|
import { openaiChatCompletionsUrl } from "./openai-chat-url";
|
|
17
|
+
import { stripResponsesOnlyEncryptedMarker } from "./responses-tool-schema";
|
|
17
18
|
import {
|
|
18
19
|
isTranslatorBudgetExceededError,
|
|
19
20
|
retainTranslatedEventBatch,
|
|
@@ -332,6 +333,16 @@ type InvalidToolCallReason =
|
|
|
332
333
|
| "tool_call_function_name_blank"
|
|
333
334
|
| "tool_call_function_arguments_invalid";
|
|
334
335
|
|
|
336
|
+
/**
|
|
337
|
+
* Streamed string fields are absent when null or undefined (#1731): OpenAI-compatible
|
|
338
|
+
* streamers repeat already-sent `id`/`name`/`arguments` as null on continuation deltas.
|
|
339
|
+
* The accumulator and this diagnostic share this predicate so they cannot disagree about
|
|
340
|
+
* which delta was the invalid one.
|
|
341
|
+
*/
|
|
342
|
+
function isInvalidStreamStringField(value: unknown): boolean {
|
|
343
|
+
return value != null && typeof value !== "string";
|
|
344
|
+
}
|
|
345
|
+
|
|
335
346
|
/**
|
|
336
347
|
* Explain only the rejected wire shape, never its values. This diagnostic exists so provider
|
|
337
348
|
* compatibility can be tightened from evidence without retaining tool arguments or credentials.
|
|
@@ -358,6 +369,10 @@ function diagnoseInvalidToolCalls(
|
|
|
358
369
|
// Blank names are caught later at flush, not here, so they are not diagnosed on this
|
|
359
370
|
// branch. Describe exactly that boundary rather than tightening compatibility in a
|
|
360
371
|
// diagnostic change.
|
|
372
|
+
// #1731: "present" means the same thing here as in the accumulator — null and undefined
|
|
373
|
+
// are both absent, because some OpenAI-compatible streamers repeat already-sent fields
|
|
374
|
+
// as null on continuation deltas. A separate predicate here would diagnose accepted
|
|
375
|
+
// padding as the failure and point compatibility work at the wrong delta.
|
|
361
376
|
const streamFunction = (rawToolCall as { function?: unknown }).function;
|
|
362
377
|
if (streamFunction !== undefined && streamFunction !== null) {
|
|
363
378
|
if (!isRecord(streamFunction)) {
|
|
@@ -367,14 +382,14 @@ function diagnoseInvalidToolCalls(
|
|
|
367
382
|
valueType: Array.isArray(streamFunction) ? "array" : typeof streamFunction,
|
|
368
383
|
};
|
|
369
384
|
}
|
|
370
|
-
if (streamFunction.name
|
|
385
|
+
if (isInvalidStreamStringField(streamFunction.name)) {
|
|
371
386
|
return { reason: "tool_call_function_name_invalid", callIndex, valueType: typeof streamFunction.name };
|
|
372
387
|
}
|
|
373
|
-
if (streamFunction.arguments
|
|
388
|
+
if (isInvalidStreamStringField(streamFunction.arguments)) {
|
|
374
389
|
return { reason: "tool_call_function_arguments_invalid", callIndex, valueType: typeof streamFunction.arguments };
|
|
375
390
|
}
|
|
376
391
|
}
|
|
377
|
-
if (rawToolCall.id
|
|
392
|
+
if (isInvalidStreamStringField(rawToolCall.id)) {
|
|
378
393
|
return { reason: "tool_call_id_invalid", callIndex, valueType: typeof rawToolCall.id };
|
|
379
394
|
}
|
|
380
395
|
continue;
|
|
@@ -1077,9 +1092,9 @@ function toolsToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig
|
|
|
1077
1092
|
if (tools.length === 0) return undefined;
|
|
1078
1093
|
const xaiTarget = isXaiSchemaTarget(provider);
|
|
1079
1094
|
const formatted = tools.flatMap(t => {
|
|
1080
|
-
const parameters = xaiTarget
|
|
1095
|
+
const parameters = stripResponsesOnlyEncryptedMarker(xaiTarget
|
|
1081
1096
|
? normalizeXaiToolParameters(t.parameters)
|
|
1082
|
-
: ensureRootObjectType(t.parameters);
|
|
1097
|
+
: ensureRootObjectType(t.parameters));
|
|
1083
1098
|
|
|
1084
1099
|
if (parameters === undefined) return [];
|
|
1085
1100
|
return [{
|
|
@@ -1488,13 +1503,15 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
|
|
|
1488
1503
|
}
|
|
1489
1504
|
const rawName = rawFunction.name;
|
|
1490
1505
|
const rawArguments = rawFunction.arguments;
|
|
1491
|
-
|
|
1492
|
-
|
|
1506
|
+
// Some OpenAI-compatible streamers repeat already-sent fields as null on
|
|
1507
|
+
// continuation deltas. Treat only null/undefined as absent; every other
|
|
1508
|
+
// non-string value still fails closed before entering the accumulator.
|
|
1509
|
+
if (isInvalidStreamStringField(rawName) || isInvalidStreamStringField(rawArguments)) {
|
|
1493
1510
|
logInvalidToolCalls("stream", rawToolCalls);
|
|
1494
1511
|
return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
|
|
1495
1512
|
}
|
|
1496
1513
|
}
|
|
1497
|
-
if (tc.id
|
|
1514
|
+
if (isInvalidStreamStringField(tc.id)) {
|
|
1498
1515
|
logInvalidToolCalls("stream", rawToolCalls);
|
|
1499
1516
|
return yield* terminateWithError(invalidToolCallsEvent(pendingUsage));
|
|
1500
1517
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Codex multi-agent v2 stamps a Responses-only `encrypted: true` marker on
|
|
2
|
+
// collaboration tool schemas (openai/codex 5f4d06ef; issue #85). It is an
|
|
3
|
+
// annotation for the ChatGPT backend only, so translated provider schemas must
|
|
4
|
+
// drop it without removing properties or definitions literally named `encrypted`.
|
|
5
|
+
const ENCRYPTED_MARKER_NAME_BAG_KEYS = new Set([
|
|
6
|
+
"properties",
|
|
7
|
+
"patternProperties",
|
|
8
|
+
"$defs",
|
|
9
|
+
"definitions",
|
|
10
|
+
"dependencies",
|
|
11
|
+
"dependentSchemas",
|
|
12
|
+
"dependentRequired",
|
|
13
|
+
]);
|
|
14
|
+
const ENCRYPTED_MARKER_LITERAL_VALUE_KEYS = new Set(["const", "default", "enum", "examples"]);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The schema is caller-supplied, so its nesting depth is attacker-influenced. Native recursion
|
|
18
|
+
* would turn a deep schema into a stack overflow that takes down the request path, so this walks
|
|
19
|
+
* an explicit stack instead: depth costs heap, which is bounded and recoverable.
|
|
20
|
+
*/
|
|
21
|
+
export function stripResponsesOnlyEncryptedMarker(node: unknown, inNameBag = false): unknown {
|
|
22
|
+
type Assign = (value: unknown) => void;
|
|
23
|
+
interface Frame { node: unknown; inNameBag: boolean; assign: Assign }
|
|
24
|
+
|
|
25
|
+
let result: unknown;
|
|
26
|
+
const stack: Frame[] = [{ node, inNameBag, assign: value => { result = value; } }];
|
|
27
|
+
|
|
28
|
+
while (stack.length > 0) {
|
|
29
|
+
const frame = stack.pop()!;
|
|
30
|
+
const current = frame.node;
|
|
31
|
+
|
|
32
|
+
if (Array.isArray(current)) {
|
|
33
|
+
const out: unknown[] = new Array(current.length);
|
|
34
|
+
frame.assign(out);
|
|
35
|
+
// Array items are schemas in their own right, never a name bag.
|
|
36
|
+
for (let i = current.length - 1; i >= 0; i--) {
|
|
37
|
+
stack.push({ node: current[i], inNameBag: false, assign: value => { out[i] = value; } });
|
|
38
|
+
}
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!current || typeof current !== "object") {
|
|
42
|
+
frame.assign(current);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A schema name may be `__proto__`; a null-prototype record keeps it as data.
|
|
47
|
+
const out: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
48
|
+
frame.assign(out);
|
|
49
|
+
|
|
50
|
+
for (const [key, value] of Object.entries(current as Record<string, unknown>)) {
|
|
51
|
+
if (frame.inNameBag) {
|
|
52
|
+
// Inside a name bag every key is a caller-chosen name, so `encrypted` here is data.
|
|
53
|
+
stack.push({ node: value, inNameBag: false, assign: v => { out[key] = v; } });
|
|
54
|
+
} else if (key !== "encrypted") {
|
|
55
|
+
if (ENCRYPTED_MARKER_LITERAL_VALUE_KEYS.has(key)) {
|
|
56
|
+
// Literal payloads are values, not schemas: an `encrypted` key inside them is data.
|
|
57
|
+
out[key] = value;
|
|
58
|
+
} else {
|
|
59
|
+
const childInNameBag = ENCRYPTED_MARKER_NAME_BAG_KEYS.has(key);
|
|
60
|
+
stack.push({ node: value, inNameBag: childInNameBag, assign: v => { out[key] = v; } });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return result;
|
|
67
|
+
}
|
package/src/bridge.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
AdapterEvent,
|
|
3
3
|
OcxMessagePhase,
|
|
4
4
|
OcxProviderContinuationState,
|
|
5
|
+
OcxProviderOpaqueToolCallMetadata,
|
|
5
6
|
OcxReasoningReplayScopeRef,
|
|
6
7
|
OcxUsage,
|
|
7
8
|
} from "./types";
|
|
@@ -10,6 +11,7 @@ import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCy
|
|
|
10
11
|
import { encodeCompactionSummary } from "./responses/compaction";
|
|
11
12
|
import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
|
|
12
13
|
import { rememberReasoningForCall } from "./responses/reasoning-replay-cache";
|
|
14
|
+
import { responsesExtraContentFromProviderMetadata } from "./responses/provider-opaque-metadata";
|
|
13
15
|
import { resolveStallTimeoutSec } from "./stall-timeout";
|
|
14
16
|
import { usageDisplayTotalTokens } from "./usage/totals";
|
|
15
17
|
import {
|
|
@@ -496,7 +498,7 @@ export function bridgeToResponsesSSE(
|
|
|
496
498
|
// synthetic compaction item's payload on done.
|
|
497
499
|
let compactionText = "";
|
|
498
500
|
let compactionTextBytes = 0;
|
|
499
|
-
let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string } | null = null;
|
|
501
|
+
let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null;
|
|
500
502
|
// Open native web-search cell (between begin and end). Holds the output index allocated on
|
|
501
503
|
// begin so the matching done reuses it; closed as `failed` if the stream terminates early.
|
|
502
504
|
let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null;
|
|
@@ -621,6 +623,9 @@ export function bridgeToResponsesSSE(
|
|
|
621
623
|
call_id: currentToolCall.callId, name: currentToolCall.name,
|
|
622
624
|
arguments: argsStr, status: "completed",
|
|
623
625
|
...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
|
|
626
|
+
// Provider-opaque metadata (issue #1735) rides the item so a client that replays
|
|
627
|
+
// this history can hand the signature back on the part it belongs to.
|
|
628
|
+
...(responsesExtraContentFromProviderMetadata(currentToolCall.providerMetadata) ?? {}),
|
|
624
629
|
};
|
|
625
630
|
emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
|
|
626
631
|
retainFinishedItem(item as OutputItem);
|
|
@@ -655,6 +660,10 @@ export function bridgeToResponsesSSE(
|
|
|
655
660
|
call_id: currentToolCall.callId, name: currentToolCall.name,
|
|
656
661
|
arguments: argsStr, status: "incomplete",
|
|
657
662
|
...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}),
|
|
663
|
+
// An incomplete call can still be persisted and replayed (max_output_tokens), so it
|
|
664
|
+
// carries the same metadata as the completed item — otherwise SSE and buffered JSON
|
|
665
|
+
// would disagree about whether the signature survives.
|
|
666
|
+
...(responsesExtraContentFromProviderMetadata(currentToolCall.providerMetadata) ?? {}),
|
|
658
667
|
};
|
|
659
668
|
emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item });
|
|
660
669
|
retainFinishedItem(item as OutputItem);
|
|
@@ -1013,7 +1022,7 @@ export function bridgeToResponsesSSE(
|
|
|
1013
1022
|
? { type: "custom_tool_call", id: itemId, call_id: event.id, name: realName, input: "", status: "in_progress" }
|
|
1014
1023
|
: { type: "function_call", id: itemId, call_id: event.id, name: realName, arguments: "", status: "in_progress", ...(ns ? { namespace: ns } : {}) };
|
|
1015
1024
|
emit("response.output_item.added", { output_index: outputIndex, item });
|
|
1016
|
-
currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch };
|
|
1025
|
+
currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch, providerMetadata: event.providerMetadata };
|
|
1017
1026
|
budget?.openCall(event.id);
|
|
1018
1027
|
break;
|
|
1019
1028
|
}
|
|
@@ -1476,6 +1485,7 @@ function buildResponseJSONWithBudget(
|
|
|
1476
1485
|
let currentToolCallId = "";
|
|
1477
1486
|
let currentToolCallName = "";
|
|
1478
1487
|
let currentToolCallArgs = "";
|
|
1488
|
+
let currentToolCallProviderMetadata: OcxProviderOpaqueToolCallMetadata | undefined;
|
|
1479
1489
|
let currentToolCallArgsBytes = 0;
|
|
1480
1490
|
// Web-search citations awaiting the next assistant message (attached as url_citation annotations).
|
|
1481
1491
|
let pendingWebSources: { url: string; title?: string }[] = [];
|
|
@@ -1584,11 +1594,13 @@ function buildResponseJSONWithBudget(
|
|
|
1584
1594
|
call_id: currentToolCallId, name: realName,
|
|
1585
1595
|
arguments: coercedArgs || "{}", status,
|
|
1586
1596
|
...(ns ? { namespace: ns } : {}),
|
|
1597
|
+
...(responsesExtraContentFromProviderMetadata(currentToolCallProviderMetadata) ?? {}),
|
|
1587
1598
|
});
|
|
1588
1599
|
}
|
|
1589
1600
|
budget?.closeCall(currentToolCallId);
|
|
1590
1601
|
currentToolCallId = "";
|
|
1591
1602
|
currentToolCallName = "";
|
|
1603
|
+
currentToolCallProviderMetadata = undefined;
|
|
1592
1604
|
currentToolCallArgs = "";
|
|
1593
1605
|
currentToolCallArgsBytes = 0;
|
|
1594
1606
|
};
|
|
@@ -1703,6 +1715,7 @@ function buildResponseJSONWithBudget(
|
|
|
1703
1715
|
currentToolCallName = e.name;
|
|
1704
1716
|
currentToolCallArgs = "";
|
|
1705
1717
|
currentToolCallArgsBytes = 0;
|
|
1718
|
+
currentToolCallProviderMetadata = e.providerMetadata;
|
|
1706
1719
|
break;
|
|
1707
1720
|
case "tool_call_delta":
|
|
1708
1721
|
{
|
|
@@ -20,7 +20,7 @@ import { claudeConfigDir } from "./gateway-cache";
|
|
|
20
20
|
import { DEFAULT_SUBAGENT_MODELS, hasOwnProvider } from "../config";
|
|
21
21
|
import { effectiveBlockedSkillNames, resolveInboundModel } from "./inbound";
|
|
22
22
|
import { knownModelIdsForProvider } from "../router";
|
|
23
|
-
import {
|
|
23
|
+
import { decodeRoutedModelIdOrThrow } from "../providers/slug-codec";
|
|
24
24
|
|
|
25
25
|
export interface ClaudeAgentDef {
|
|
26
26
|
file: string;
|
|
@@ -85,7 +85,7 @@ function entryParts(entry: string, config: OcxConfig): { alias: string; id: stri
|
|
|
85
85
|
const provider = entry.slice(0, slash);
|
|
86
86
|
const prov = hasOwnProvider(config.providers, provider) ? config.providers[provider] : undefined;
|
|
87
87
|
const id = prov
|
|
88
|
-
?
|
|
88
|
+
? decodeRoutedModelIdOrThrow(entry.slice(slash + 1), knownModelIdsForProvider(provider, prov, config))
|
|
89
89
|
: entry.slice(slash + 1);
|
|
90
90
|
return { alias: claudeCodeAlias(provider, id), id, provider };
|
|
91
91
|
}
|
|
@@ -13,12 +13,21 @@
|
|
|
13
13
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { join } from "node:path";
|
|
16
|
+
import { loadServiceTokenFromFile, serviceApiTokenFilePath } from "../lib/service-secrets";
|
|
17
|
+
import type { OcxConfig } from "../types";
|
|
16
18
|
|
|
17
19
|
export interface GatewayModelRow {
|
|
18
20
|
id: string;
|
|
19
21
|
display_name?: string;
|
|
20
22
|
}
|
|
21
23
|
|
|
24
|
+
export interface GatewayModelCacheRefreshOptions {
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
configDir?: string;
|
|
27
|
+
admissionConfig?: Pick<OcxConfig, "apiKeys">;
|
|
28
|
+
env?: NodeJS.ProcessEnv;
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
/** Claude Code config dir (CLAUDE_CONFIG_DIR override honored, like the CLI). */
|
|
23
32
|
export function claudeConfigDir(): string {
|
|
24
33
|
const custom = process.env.CLAUDE_CONFIG_DIR;
|
|
@@ -45,14 +54,42 @@ export function writeGatewayModelCache(baseUrl: string, models: readonly Gateway
|
|
|
45
54
|
}
|
|
46
55
|
}
|
|
47
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Hardened service-token file, the same precedence `ocx opencode` uses. A service
|
|
59
|
+
* install writes the admission token to disk rather than the interactive environment,
|
|
60
|
+
* so an interactive `ocx claude` with neither env token nor configured key would
|
|
61
|
+
* otherwise still get a 401 and keep a stale picker list.
|
|
62
|
+
*/
|
|
63
|
+
function serviceFileToken(env: NodeJS.ProcessEnv): string | null {
|
|
64
|
+
const lookup = env.OCX_API_TOKEN_FILE?.trim()
|
|
65
|
+
? env
|
|
66
|
+
: { ...env, OCX_API_TOKEN_FILE: serviceApiTokenFilePath() };
|
|
67
|
+
return loadServiceTokenFromFile(lookup as Record<string, string | undefined>);
|
|
68
|
+
}
|
|
69
|
+
|
|
48
70
|
/** Fetch the anthropic-flavor /v1/models from the local proxy and write the cache. */
|
|
49
|
-
export async function refreshGatewayModelCacheFromProxy(
|
|
71
|
+
export async function refreshGatewayModelCacheFromProxy(
|
|
72
|
+
port: number,
|
|
73
|
+
options: GatewayModelCacheRefreshOptions = {},
|
|
74
|
+
): Promise<string | null> {
|
|
50
75
|
try {
|
|
76
|
+
const headers = new Headers({ "anthropic-version": "2023-06-01" });
|
|
77
|
+
// A wildcard/non-loopback listener requires data-plane admission even for a
|
|
78
|
+
// request sent to its local 127.0.0.1 address. Reuse the same dedicated
|
|
79
|
+
// credential domain as /v1/models admission; never place it in Authorization,
|
|
80
|
+
// which can belong to an upstream provider on other data-plane surfaces.
|
|
81
|
+
const envToken = (options.env ?? process.env).OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
82
|
+
const configuredToken = options.admissionConfig?.apiKeys
|
|
83
|
+
?.find(entry => entry.key.trim().length > 0)
|
|
84
|
+
?.key.trim();
|
|
85
|
+
const admissionToken = envToken || serviceFileToken(options.env ?? process.env) || configuredToken;
|
|
86
|
+
if (admissionToken) headers.set("x-opencodex-api-key", admissionToken);
|
|
87
|
+
|
|
51
88
|
// ?ids=cli pins the readable claude-ocx id family deterministically (audit 051
|
|
52
89
|
// #5): the cache prewrite must not depend on UA sniffing.
|
|
53
90
|
const res = await fetch(`http://127.0.0.1:${port}/v1/models?limit=1000&ids=cli`, {
|
|
54
|
-
headers
|
|
55
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
91
|
+
headers,
|
|
92
|
+
signal: AbortSignal.timeout(options.timeoutMs ?? 3_000),
|
|
56
93
|
});
|
|
57
94
|
if (!res.ok) return null;
|
|
58
95
|
const body = await res.json() as { data?: unknown };
|
|
@@ -63,7 +100,7 @@ export async function refreshGatewayModelCacheFromProxy(port: number, timeoutMs
|
|
|
63
100
|
id: m.id as string,
|
|
64
101
|
display_name: typeof m.display_name === "string" ? m.display_name : undefined,
|
|
65
102
|
}));
|
|
66
|
-
return writeGatewayModelCache(`http://127.0.0.1:${port}`, models, configDir);
|
|
103
|
+
return writeGatewayModelCache(`http://127.0.0.1:${port}`, models, options.configDir);
|
|
67
104
|
} catch {
|
|
68
105
|
return null;
|
|
69
106
|
}
|
package/src/cli/claude.ts
CHANGED
|
@@ -317,7 +317,7 @@ export async function cmdClaude(args: string[]): Promise<number> {
|
|
|
317
317
|
// Pre-write the CLI's gateway-model cache (devlog 030): without a token the CLI
|
|
318
318
|
// never refreshes it, so the picker would keep showing yesterday's aliases.
|
|
319
319
|
try {
|
|
320
|
-
const cachePath = await refreshGatewayModelCacheFromProxy(port);
|
|
320
|
+
const cachePath = await refreshGatewayModelCacheFromProxy(port, { admissionConfig: config });
|
|
321
321
|
if (cachePath === null) {
|
|
322
322
|
console.error("⚠ Gateway model cache could not be refreshed; the model picker may be stale.");
|
|
323
323
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { inspectCodexLogs, type CodexLogGuardInspection } from "../codex/log-guard/inspect";
|
|
2
|
+
import {
|
|
3
|
+
getCodexLogGuardProtectionStatus,
|
|
4
|
+
type CodexLogGuardStatus,
|
|
5
|
+
} from "../codex/log-guard/protection";
|
|
6
|
+
|
|
7
|
+
function kib(bytes: number): string {
|
|
8
|
+
return `${(bytes / 1024).toFixed(1)} KiB`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function fileMetadataLines(report: CodexLogGuardInspection): string[] {
|
|
12
|
+
if (report.externalSqliteHome === null) return [];
|
|
13
|
+
const location = report.externalSqliteHome ? "external sqlite_home" : "CODEX_HOME sqlite_home";
|
|
14
|
+
return [
|
|
15
|
+
` ${location}; DB ${kib(report.files.databaseBytes)}, WAL ${kib(report.files.walBytes)}, SHM ${kib(report.files.shmBytes)}`,
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type DoctorReport = CodexLogGuardInspection | CodexLogGuardStatus;
|
|
20
|
+
|
|
21
|
+
function protectionLines(report: DoctorReport): string[] {
|
|
22
|
+
if (!("protection" in report)) return [];
|
|
23
|
+
const protection = report.protection;
|
|
24
|
+
switch (protection.state) {
|
|
25
|
+
case "active":
|
|
26
|
+
return [` ok protection active (${protection.desiredMode})`];
|
|
27
|
+
case "off":
|
|
28
|
+
return [" -- protection off"];
|
|
29
|
+
case "drifted":
|
|
30
|
+
return [
|
|
31
|
+
` WARN protection drifted (desired ${protection.desiredMode}; observed ${protection.observedMode})`,
|
|
32
|
+
" Action: ocx storage codex-logs repair",
|
|
33
|
+
];
|
|
34
|
+
case "unsupported":
|
|
35
|
+
return [" -- protection unavailable for this schema"];
|
|
36
|
+
case "unknown":
|
|
37
|
+
return [" WARN protection state unknown; inspect reserved Log Guard triggers before changing mode"];
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function formatCodexLogGuardDoctor(report: DoctorReport): string[] {
|
|
42
|
+
const lines = ["Codex diagnostic logs"];
|
|
43
|
+
lines.push(...protectionLines(report));
|
|
44
|
+
|
|
45
|
+
if (report.schema.state === "unavailable") {
|
|
46
|
+
lines.push(" -- inspection unavailable");
|
|
47
|
+
return lines;
|
|
48
|
+
}
|
|
49
|
+
if (report.schema.state === "missing") {
|
|
50
|
+
lines.push(" -- logs_2.sqlite is not present");
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
if (report.schema.state === "unreadable") {
|
|
54
|
+
lines.push(" -- logs_2.sqlite is unreadable; inspection metadata only");
|
|
55
|
+
lines.push(...fileMetadataLines(report));
|
|
56
|
+
lines.push(" checkpointed read-only snapshot; activity rate not measured");
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
if (report.schema.state === "unsupported") {
|
|
60
|
+
lines.push(" -- unknown schema; inspection only");
|
|
61
|
+
} else {
|
|
62
|
+
lines.push(" ok schema compatible");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
lines.push(...fileMetadataLines(report));
|
|
66
|
+
|
|
67
|
+
if (report.metrics) {
|
|
68
|
+
lines.push(
|
|
69
|
+
` ${report.metrics.totalRows} rows; TRACE ${(report.metrics.traceShare * 100).toFixed(1)}%; reclaimable ${kib(report.metrics.reclaimableBytes)}`,
|
|
70
|
+
);
|
|
71
|
+
const top = report.metrics.topTargets[0];
|
|
72
|
+
if (top) lines.push(` top target ${top.target} (${top.rows} rows)`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
lines.push(" checkpointed read-only snapshot; activity rate not measured");
|
|
76
|
+
return lines;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CodexLogGuardDoctorDeps {
|
|
80
|
+
inspect?: () => DoctorReport;
|
|
81
|
+
log?: (line: string) => void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Observe-only doctor section. Inspection failures are reported without mutating or failing doctor. */
|
|
85
|
+
export function printCodexLogGuardDoctor(deps: CodexLogGuardDoctorDeps = {}): void {
|
|
86
|
+
const inspect = deps.inspect ?? getCodexLogGuardProtectionStatus;
|
|
87
|
+
const log = deps.log ?? console.log;
|
|
88
|
+
try {
|
|
89
|
+
for (const line of formatCodexLogGuardDoctor(inspect())) log(line);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
// Production can still fall back to PR 1's simpler inspector if the enriched
|
|
92
|
+
// protection lookup fails. An injected inspector is a test/caller boundary:
|
|
93
|
+
// never escape that boundary and touch the real Codex home behind its back.
|
|
94
|
+
if (!deps.inspect) {
|
|
95
|
+
try {
|
|
96
|
+
for (const line of formatCodexLogGuardDoctor(inspectCodexLogs())) log(line);
|
|
97
|
+
return;
|
|
98
|
+
} catch { /* report the original failure below */ }
|
|
99
|
+
}
|
|
100
|
+
log("Codex diagnostic logs");
|
|
101
|
+
log(" -- inspection unavailable");
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/cli/dispatch.ts
CHANGED
|
@@ -171,8 +171,14 @@ const commandRunners: Record<string, CommandRunner> = {
|
|
|
171
171
|
return Number(process.exitCode ?? 0);
|
|
172
172
|
},
|
|
173
173
|
doctor: async deps => {
|
|
174
|
+
const doctorArgs = deps.args.slice(1);
|
|
174
175
|
const { runDoctor } = await import("./doctor");
|
|
175
|
-
await runDoctor(
|
|
176
|
+
await runDoctor(doctorArgs);
|
|
177
|
+
if (!doctorArgs.includes("--fix-codex-runtime")) {
|
|
178
|
+
console.log("");
|
|
179
|
+
const { printCodexLogGuardDoctor } = await import("./codex-log-guard-doctor");
|
|
180
|
+
printCodexLogGuardDoctor();
|
|
181
|
+
}
|
|
176
182
|
return 0;
|
|
177
183
|
},
|
|
178
184
|
debug: async deps => {
|
package/src/cli/help.ts
CHANGED
|
@@ -42,7 +42,7 @@ Usage:
|
|
|
42
42
|
ocx gui Open the opencodex dashboard
|
|
43
43
|
ocx update [--tag <tag>] Update opencodex (keeps preview installs on @preview)
|
|
44
44
|
ocx restart Stop and restart the proxy
|
|
45
|
-
ocx v2 <sub> multi_agent_v2 surface (status|on|off|mode|threads|mode-hint)
|
|
45
|
+
ocx v2 <sub> multi_agent_v2 surface (status|on|off|mode|keep-native-v1|threads|mode-hint)
|
|
46
46
|
ocx health [--json] Check proxy health (exit 0=healthy, 1=not)
|
|
47
47
|
ocx ready [--json] [--wait [--timeout <s>]] Check post-sync readiness (exit 0 only when ready)
|
|
48
48
|
ocx provider <sub> Providers, connectivity, quota, and selected models
|
package/src/cli/models.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { createInterface } from "node:readline/promises";
|
|
|
6
6
|
import { syncModelsToCodex } from "../codex/sync";
|
|
7
7
|
import { hasOwnProvider, isValidProviderName, loadConfig, saveConfig } from "../config";
|
|
8
8
|
import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort";
|
|
9
|
-
import { routedSlug } from "../providers/slug-codec";
|
|
9
|
+
import { encodedModelIdCollides, routedSlug, slugEquals } from "../providers/slug-codec";
|
|
10
|
+
import { knownModelIdsForProvider } from "../router";
|
|
10
11
|
import { findLiveProxy } from "../server/proxy-liveness";
|
|
11
12
|
import type { OcxConfig, OcxCustomModel } from "../types";
|
|
12
13
|
|
|
@@ -180,7 +181,6 @@ async function handleCustomAdd(args: string[]): Promise<void> {
|
|
|
180
181
|
|
|
181
182
|
if (!provider || !modelId) fail("provider and modelId are required", ADD_USAGE);
|
|
182
183
|
if (!isValidProviderName(provider)) fail(`invalid provider name "${provider}"`);
|
|
183
|
-
if (modelId.includes("/")) fail("modelId must not contain /");
|
|
184
184
|
|
|
185
185
|
const config = loadConfig();
|
|
186
186
|
if (!hasOwnProvider(config.providers, provider)) {
|
|
@@ -216,6 +216,10 @@ async function handleCustomAdd(args: string[]): Promise<void> {
|
|
|
216
216
|
if (existing.some(model => routedSlug(model.provider, model.modelId) === slug)) {
|
|
217
217
|
fail(`custom model "${slug}" already exists`);
|
|
218
218
|
}
|
|
219
|
+
const known = knownModelIdsForProvider(provider, config.providers[provider], config);
|
|
220
|
+
if (encodedModelIdCollides(modelId, known)) {
|
|
221
|
+
fail(`custom model "${slug}" is ambiguous; it encodes to an existing model id`);
|
|
222
|
+
}
|
|
219
223
|
|
|
220
224
|
const entry: OcxCustomModel = {
|
|
221
225
|
id: randomUUID(),
|
|
@@ -256,10 +260,16 @@ async function handleCustomRemove(args: string[]): Promise<void> {
|
|
|
256
260
|
|
|
257
261
|
const config = loadConfig();
|
|
258
262
|
const existing = config.customModels ?? [];
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
+
const matchingIndexes = existing.flatMap((model, index) => (
|
|
264
|
+
target.includes("/")
|
|
265
|
+
? slugEquals(target, model.provider, model.modelId)
|
|
266
|
+
: model.id === target
|
|
267
|
+
) ? [index] : []);
|
|
268
|
+
if (matchingIndexes.length === 0) fail(`custom model "${target}" not found`);
|
|
269
|
+
if (matchingIndexes.length > 1) {
|
|
270
|
+
fail(`custom model selector "${target}" is ambiguous; use the custom model id`);
|
|
271
|
+
}
|
|
272
|
+
const index = matchingIndexes[0]!;
|
|
263
273
|
|
|
264
274
|
const model = existing[index];
|
|
265
275
|
if (!confirmed && !(await confirmCustomRemoval(model))) {
|
package/src/cli/observe.ts
CHANGED
|
@@ -18,7 +18,7 @@ const USAGE = `Usage:
|
|
|
18
18
|
ocx logs rebuild-index
|
|
19
19
|
ocx logs index-status
|
|
20
20
|
ocx observe usage [--range <7d|30d|all>] [--surface <all|codex|claude|grok>] [--json]
|
|
21
|
-
ocx observe storage [--json]
|
|
21
|
+
ocx observe storage [codex-logs [status|protect|unprotect|repair|compact] [--mode <compat|quiet>]] [--json]
|
|
22
22
|
ocx observe memory [--json]
|
|
23
23
|
ocx observe debug [--json]
|
|
24
24
|
ocx observe claude-inbound [--limit <n>] [--json]
|
|
@@ -147,6 +147,42 @@ async function simple(path: string, argv: string[], deps: RuntimeApiDeps): Promi
|
|
|
147
147
|
printData(result, wantsJson, summaryLines(result));
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
async function storage(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
151
|
+
if (argv[0] !== "codex-logs") {
|
|
152
|
+
await simple("/api/storage", argv, deps);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const args = argv.slice(1);
|
|
157
|
+
const action = args[0] && !args[0].startsWith("-") ? args.shift()! : "status";
|
|
158
|
+
const wantsJson = takeFlag(args, "--json");
|
|
159
|
+
const mode = takeOption(args, "--mode");
|
|
160
|
+
rejectArgs(args, USAGE);
|
|
161
|
+
|
|
162
|
+
let result: unknown;
|
|
163
|
+
if (action === "status") {
|
|
164
|
+
if (mode !== undefined) throw new CliUsageError("--mode is only valid with codex-logs protect", USAGE);
|
|
165
|
+
result = await runtimeRequest("/api/storage/codex-logs", {}, deps);
|
|
166
|
+
} else if (action === "protect") {
|
|
167
|
+
const requestedMode = mode ?? "compat";
|
|
168
|
+
if (requestedMode !== "compat" && requestedMode !== "quiet") {
|
|
169
|
+
throw new CliUsageError("--mode must be compat or quiet", USAGE);
|
|
170
|
+
}
|
|
171
|
+
result = await runtimeRequest("/api/storage/codex-logs/protect", {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { "content-type": "application/json" },
|
|
174
|
+
body: JSON.stringify({ mode: requestedMode }),
|
|
175
|
+
}, deps);
|
|
176
|
+
} else if (action === "unprotect" || action === "repair" || action === "compact") {
|
|
177
|
+
if (mode !== undefined) throw new CliUsageError("--mode is only valid with codex-logs protect", USAGE);
|
|
178
|
+
result = await runtimeRequest(`/api/storage/codex-logs/${action}`, { method: "POST" }, deps);
|
|
179
|
+
} else {
|
|
180
|
+
throw new CliUsageError(`unknown codex-logs action ${action}`, USAGE);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
printData(result, wantsJson, summaryLines(result));
|
|
184
|
+
}
|
|
185
|
+
|
|
150
186
|
export async function handleObserveCommand(argv: string[], deps: RuntimeApiDeps = {}): Promise<number> {
|
|
151
187
|
return runCliAction(async () => {
|
|
152
188
|
const [sub = "logs", ...rest] = argv;
|
|
@@ -158,7 +194,7 @@ export async function handleObserveCommand(argv: string[], deps: RuntimeApiDeps
|
|
|
158
194
|
else await logs(rest, deps);
|
|
159
195
|
}
|
|
160
196
|
else if (sub === "usage") await usage(rest, deps);
|
|
161
|
-
else if (sub === "storage") await
|
|
197
|
+
else if (sub === "storage") await storage(rest, deps);
|
|
162
198
|
else if (sub === "memory") await simple("/api/system/memory", rest, deps);
|
|
163
199
|
else if (sub === "debug") await simple("/api/debug", rest, deps);
|
|
164
200
|
else if (sub === "claude-inbound") await simple("/api/claude/inbound-debug", rest, deps);
|
package/src/cli/registry.ts
CHANGED
|
@@ -309,12 +309,13 @@ export const CLI_COMMANDS: CliCommandEntry[] = [
|
|
|
309
309
|
},
|
|
310
310
|
{
|
|
311
311
|
name: "v2",
|
|
312
|
-
usage: "ocx v2 <status|on|off|mode <v1|default|v2>|threads <n>>",
|
|
312
|
+
usage: "ocx v2 <status|on|off|mode <v1|default|v2>|keep-native-v1 <on|off>|threads <n>>",
|
|
313
313
|
summary: "Toggle the Codex multi_agent_v2 feature (multi-agent surface).",
|
|
314
314
|
details: [
|
|
315
315
|
"status Show flag, multi-agent mode, and thread limit.",
|
|
316
316
|
"on | off Enable/disable multi_agent_v2 (catalog resyncs).",
|
|
317
317
|
"mode <v1|default|v2> Force all models to one surface, or respect upstream pins.",
|
|
318
|
+
"keep-native-v1 <on|off> Under mode v2, keep ChatGPT-native models on v1.",
|
|
318
319
|
"threads <n> Set max_concurrent_threads_per_session (integer >= 1).",
|
|
319
320
|
"Flips preserve the active thread limit while moving between v1/v2 modes.",
|
|
320
321
|
],
|
package/src/cli/v2.ts
CHANGED
|
@@ -110,6 +110,9 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
|
|
|
110
110
|
log.log(v2StatusLine(isEnabled()));
|
|
111
111
|
const cfg = loadConfig();
|
|
112
112
|
log.log(multiAgentModeLine(cfg.multiAgentMode ?? "default"));
|
|
113
|
+
log.log(cfg.keepNativeChatGptOnV1 === true
|
|
114
|
+
? "keep_native_chatgpt_on_v1: ON — ChatGPT-native rows stay v1 when mode is v2"
|
|
115
|
+
: "keep_native_chatgpt_on_v1: OFF");
|
|
113
116
|
const threads = getLogicalMaxThreads();
|
|
114
117
|
log.log(`max_threads: ${threads ?? "(unset — codex default)"}`);
|
|
115
118
|
const v2Active = isEnabled();
|
|
@@ -204,8 +207,38 @@ export async function cmdV2(args: string[], deps: V2CliDeps = {}, findPort?: ()
|
|
|
204
207
|
log.log("Applies to NEW sessions; running sessions keep their pinned multi-agent version.");
|
|
205
208
|
return 0;
|
|
206
209
|
}
|
|
210
|
+
if (verb === "keep-native-v1") {
|
|
211
|
+
const flag = (args[1] ?? "").trim().toLowerCase();
|
|
212
|
+
if (flag !== "on" && flag !== "off") {
|
|
213
|
+
log.error("v2 keep-native-v1: expected on|off");
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
const cfg = loadConfig();
|
|
217
|
+
const next = flag === "on";
|
|
218
|
+
const already = cfg.keepNativeChatGptOnV1 === true === next;
|
|
219
|
+
if (next) cfg.keepNativeChatGptOnV1 = true;
|
|
220
|
+
else delete cfg.keepNativeChatGptOnV1;
|
|
221
|
+
saveConfig(cfg);
|
|
222
|
+
try {
|
|
223
|
+
const sync = deps.sync ?? (await import("../codex/sync")).syncModelsToCodex;
|
|
224
|
+
await sync(findPort ? await findPort() : undefined);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
log.error(`catalog resync failed: ${err instanceof Error ? err.message : String(err)} — run 'ocx sync' manually.`);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
if (already) {
|
|
230
|
+
log.log(next
|
|
231
|
+
? "keep_native_chatgpt_on_v1 already ON — catalog re-synced."
|
|
232
|
+
: "keep_native_chatgpt_on_v1 already OFF — catalog re-synced.");
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
log.log(next
|
|
236
|
+
? "keep_native_chatgpt_on_v1: ON — ChatGPT-native rows stay v1 when mode is v2 (new sessions)."
|
|
237
|
+
: "keep_native_chatgpt_on_v1: OFF — ChatGPT-native rows follow v1/base/v2 (new sessions).");
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
207
240
|
if (verb !== "on" && verb !== "off") {
|
|
208
|
-
log.error(`v2: unknown verb '${verb}' (expected status|on|off|mode <v1|default|v2>|threads <n>|mode-hint <text|--clear>)`);
|
|
241
|
+
log.error(`v2: unknown verb '${verb}' (expected status|on|off|mode <v1|default|v2>|keep-native-v1 <on|off>|threads <n>|mode-hint <text|--clear>)`);
|
|
209
242
|
return 1;
|
|
210
243
|
}
|
|
211
244
|
|