@bitkyc08/opencodex 2.77.0 → 2.78.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 +11 -10
- package/gui/dist/assets/App-BtfG5id-.js +54 -0
- package/gui/dist/assets/App-lCIslZ1L.css +1 -0
- package/gui/dist/assets/{Tray-gloIYNaW.js → Tray-DiJpSub0.js} +1 -1
- package/gui/dist/assets/index-Btc3Ogw4.js +94 -0
- package/gui/dist/assets/index-DmQB8D_R.css +1 -0
- package/gui/dist/assets/quota-summary-B_a96nXj.js +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +1 -1
- package/src/adapters/anthropic/beta-allowlist.ts +19 -2
- package/src/adapters/anthropic/client-identity.ts +13 -4
- package/src/adapters/anthropic/native-client-preamble.ts +31 -0
- package/src/adapters/anthropic/passthrough.ts +103 -25
- package/src/adapters/devin/cloud-direct/chat.ts +7 -2
- package/src/adapters/devin/trajectory.ts +49 -0
- package/src/adapters/devin.ts +13 -0
- package/src/adapters/ollama-native.ts +35 -19
- package/src/claude/intercept/client-hello.ts +103 -0
- package/src/claude/intercept/model-bindings.ts +3 -2
- package/src/claude/intercept/picker-listener.ts +279 -66
- package/src/cli/access-audio-input.ts +84 -0
- package/src/cli/access-audio-live.ts +99 -0
- package/src/cli/access-audio.ts +123 -0
- package/src/cli/access-data-client.ts +127 -0
- package/src/cli/access-data-plane.ts +104 -0
- package/src/cli/access-data-response.ts +76 -0
- package/src/cli/access.ts +65 -2
- package/src/cli/account-api.ts +30 -6
- package/src/cli/account-auth.ts +111 -42
- package/src/cli/account-key-quota.ts +118 -0
- package/src/cli/account-main.ts +11 -4
- package/src/cli/account-next-actions.ts +63 -0
- package/src/cli/account-policy-dto.ts +68 -0
- package/src/cli/account-policy.ts +167 -0
- package/src/cli/account-target.ts +8 -0
- package/src/cli/account.ts +34 -8
- package/src/cli/agent-runtime-settings.ts +130 -0
- package/src/cli/agent-settings.ts +101 -0
- package/src/cli/agent.ts +19 -2
- package/src/cli/alias.ts +7 -2
- package/src/cli/aside-profiles.ts +2 -2
- package/src/cli/capabilities-access-remote.ts +147 -0
- package/src/cli/capabilities-accounts.ts +195 -0
- package/src/cli/capabilities-agents-routing.ts +414 -0
- package/src/cli/capabilities-base.ts +783 -0
- package/src/cli/capabilities-command.ts +1 -0
- package/src/cli/capabilities-integrations.ts +264 -0
- package/src/cli/capabilities-lab.ts +214 -0
- package/src/cli/capabilities-observe-system.ts +662 -0
- package/src/cli/capabilities-provider-models.ts +302 -0
- package/src/cli/capabilities.ts +30 -1199
- package/src/cli/capability-types.ts +54 -0
- package/src/cli/catalog-command-result.ts +57 -0
- package/src/cli/chatgpt-command.ts +25 -2
- package/src/cli/claude-agent-startup-sync.ts +17 -0
- package/src/cli/claude-desktop-profile.ts +100 -0
- package/src/cli/claude-desktop.ts +39 -6
- package/src/cli/codex-shim-autorestore.ts +2 -1
- package/src/cli/codex-shim-readiness.ts +23 -3
- package/src/cli/combo-input.ts +88 -0
- package/src/cli/combo-result.ts +52 -0
- package/src/cli/combo-stats.ts +123 -0
- package/src/cli/combo.ts +37 -14
- package/src/cli/companion-timeline.ts +142 -0
- package/src/cli/companion-usage.ts +146 -0
- package/src/cli/companion.ts +12 -3
- package/src/cli/config-command.ts +24 -8
- package/src/cli/dispatch.ts +52 -79
- package/src/cli/doctor.ts +22 -4
- package/src/cli/help-catalog.ts +31 -14
- package/src/cli/help-recovery.ts +2 -2
- package/src/cli/help.ts +25 -11
- package/src/cli/index.ts +38 -27
- package/src/cli/injection-follow.ts +35 -0
- package/src/cli/inspect.ts +5 -0
- package/src/cli/integration-aside-sync.ts +69 -0
- package/src/cli/integration-cursor.ts +106 -0
- package/src/cli/integration-input.ts +60 -0
- package/src/cli/integration-journal.ts +49 -0
- package/src/cli/integration-plan-dto.ts +156 -0
- package/src/cli/integration-preview.ts +215 -0
- package/src/cli/integrations.ts +24 -18
- package/src/cli/json-input.ts +134 -0
- package/src/cli/link.ts +32 -4
- package/src/cli/local-sync-result.ts +41 -0
- package/src/cli/log-follow.ts +84 -0
- package/src/cli/log-view-filter.ts +146 -0
- package/src/cli/logout-command.ts +44 -0
- package/src/cli/model-picker-ordering.ts +137 -0
- package/src/cli/models-custom-input.ts +107 -0
- package/src/cli/models-custom-runtime.ts +112 -0
- package/src/cli/models-order.ts +194 -0
- package/src/cli/models-runtime-subcommands.ts +2 -0
- package/src/cli/models-runtime.ts +7 -0
- package/src/cli/models.ts +66 -81
- package/src/cli/observe-stream.ts +142 -0
- package/src/cli/observe.ts +75 -34
- package/src/cli/provider-batch.ts +77 -0
- package/src/cli/provider-lifecycle-runtime.ts +170 -0
- package/src/cli/provider-result.ts +124 -0
- package/src/cli/provider-runtime.ts +15 -12
- package/src/cli/provider-settings.ts +143 -0
- package/src/cli/provider.ts +112 -34
- package/src/cli/registry.ts +62 -20
- package/src/cli/remote-workspace-hub.ts +83 -0
- package/src/cli/remote-workspace.ts +6 -1
- package/src/cli/restart-failure.ts +27 -0
- package/src/cli/root.ts +12 -0
- package/src/cli/route-policy-write.ts +137 -0
- package/src/cli/route-policy.ts +8 -0
- package/src/cli/runtime-api.ts +56 -79
- package/src/cli/secret-args.ts +149 -0
- package/src/cli/settings-result.ts +30 -0
- package/src/cli/storage.ts +77 -7
- package/src/cli/system-command.ts +60 -2
- package/src/cli/system-restart-client.ts +20 -3
- package/src/cli/system-settings-parity.ts +83 -0
- package/src/cli/update-restart-candidate.ts +21 -0
- package/src/cli/update-restart-child.ts +81 -0
- package/src/cli/update-restart-home.ts +38 -0
- package/src/cli/update-restart-transport.ts +193 -0
- package/src/cli/update-restart.ts +260 -0
- package/src/cli/usage-model-search.ts +58 -0
- package/src/cli/usage-report.ts +13 -5
- package/src/cli/v2-input.ts +66 -0
- package/src/cli/v2-local-output.ts +37 -0
- package/src/cli/v2-runtime.ts +90 -0
- package/src/cli/v2.ts +102 -144
- package/src/clients/config-export/dsh.ts +21 -0
- package/src/clients/config-export.ts +7 -1
- package/src/codex/catalog/build-entries.ts +1 -1
- package/src/codex/catalog/bundled.ts +9 -2
- package/src/codex/catalog/heal-observation.ts +66 -0
- package/src/codex/catalog/publication-observer.ts +19 -0
- package/src/codex/catalog/remote.ts +13 -15
- package/src/codex/catalog/restore.ts +19 -6
- package/src/codex/catalog/retained-sync.ts +58 -10
- package/src/codex/catalog/routed-removal.ts +116 -0
- package/src/codex/catalog/write-audit-contract.ts +21 -0
- package/src/codex/catalog/write-audit.ts +171 -0
- package/src/codex/catalog-self-heal.ts +284 -0
- package/src/codex/catalog-write-serialization.ts +71 -7
- package/src/codex/codex-home-owner.ts +124 -0
- package/src/codex/convergence.ts +55 -15
- package/src/codex/features.ts +8 -1
- package/src/codex/inject/restore.ts +46 -4
- package/src/codex/inject.ts +11 -1
- package/src/codex/internal/catalog-writer.ts +120 -13
- package/src/codex/journal.ts +49 -13
- package/src/codex/management-convergence.ts +3 -0
- package/src/codex/quota-types.ts +3 -0
- package/src/codex/quota.ts +13 -3
- package/src/codex/refresh.ts +20 -9
- package/src/codex/runtime.ts +7 -1
- package/src/codex/shim-diagnostics.ts +80 -0
- package/src/codex/shim-fingerprint.ts +7 -2
- package/src/codex/shim-migration.ts +153 -0
- package/src/codex/shim-overlay.ts +509 -0
- package/src/codex/shim-path-resolution.ts +14 -2
- package/src/codex/shim-probe.ts +4 -3
- package/src/codex/shim-state-file.ts +93 -7
- package/src/codex/shim.ts +15 -68
- package/src/codex/sync.ts +24 -4
- package/src/combos/failover.ts +19 -0
- package/src/combos/index.ts +1 -0
- package/src/combos/resolve.ts +12 -0
- package/src/config/atomic-write.ts +13 -8
- package/src/config/diagnostics.ts +19 -1
- package/src/config/load-degrade.ts +3 -0
- package/src/config/schema/chatgpt-desktop.ts +22 -0
- package/src/config/schema/config-schema.ts +32 -14
- package/src/config/schema/leaf-validators.ts +3 -2
- package/src/generated/compatibility-version.json +445 -129
- package/src/integrations/merge.ts +13 -5
- package/src/integrations/mutation-plan.ts +46 -3
- package/src/integrations/omp-yaml-source.ts +181 -1
- package/src/integrations/ownership-policy.ts +66 -0
- package/src/integrations/registry.ts +50 -1
- package/src/integrations/state.ts +46 -21
- package/src/integrations/target.ts +25 -2
- package/src/integrations/writer.ts +89 -31
- package/src/lib/package-tree-integrity.ts +42 -1
- package/src/lib/proxy-env.ts +3 -0
- package/src/lib/test-home-guard.ts +2 -1
- package/src/oauth/anthropic-account-refusal.ts +19 -8
- package/src/oauth/anthropic-routing.ts +17 -12
- package/src/oauth/anthropic.ts +27 -2
- package/src/oauth/index.ts +6 -8
- package/src/oauth/pool-settings-capability.ts +9 -2
- package/src/protocols/plan-snapshot.ts +1 -1
- package/src/protocols/settings.ts +28 -4
- package/src/server/caller-session-identity.ts +32 -0
- package/src/server/claude-messages.ts +11 -4
- package/src/server/index/serve-options.ts +12 -7
- package/src/server/index.ts +1 -1
- package/src/server/management/agent-settings-routes.ts +6 -5
- package/src/server/management/context.ts +8 -1
- package/src/server/management/cursor-integration-routes.ts +3 -3
- package/src/server/management/integration-routes.ts +5 -5
- package/src/server/management/model-routes.ts +3 -3
- package/src/server/management/native-integration-routes.ts +2 -2
- package/src/server/management/oauth-account-routes.ts +96 -30
- package/src/server/management/route-registry.ts +25 -24
- package/src/server/messages-native-eligibility.ts +3 -6
- package/src/server/messages-native-oauth.ts +82 -35
- package/src/server/messages-native.ts +78 -16
- package/src/server/responses/adapter-dispatch.ts +53 -28
- package/src/server/responses/core-auth.ts +10 -4
- package/src/server/responses/core-combo.ts +46 -6
- package/src/service/cli.ts +6 -1
- package/src/service/ownership-mutation-lease.d.mts +26 -0
- package/src/service/ownership-mutation-lease.mjs +76 -1
- package/src/types/config.ts +2 -0
- package/src/types/provider.ts +10 -10
- package/src/update/index.ts +2 -1
- package/src/update/update-failure-guidance.d.mts +6 -0
- package/src/update/update-failure-guidance.mjs +30 -5
- package/gui/dist/assets/App-COdEeqSj.css +0 -1
- package/gui/dist/assets/App-DEpCJEP_.js +0 -54
- package/gui/dist/assets/index-Cd7ittMv.css +0 -1
- package/gui/dist/assets/index-DwJKFJm5.js +0 -94
- package/gui/dist/assets/quota-summary-DGcEJU3h.js +0 -1
package/src/cli/link.ts
CHANGED
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
runtimeBaseUrl,
|
|
12
12
|
runtimeRequest,
|
|
13
13
|
takeIntegerOption,
|
|
14
|
+
takeFlag,
|
|
14
15
|
takeJsonFlag,
|
|
16
|
+
printData,
|
|
15
17
|
takeOption,
|
|
16
18
|
type RuntimeApiDeps,
|
|
17
19
|
} from "./runtime-api";
|
|
@@ -20,7 +22,9 @@ export const LINK_USAGE = `Usage:
|
|
|
20
22
|
ocx link port [--json]
|
|
21
23
|
ocx link issue --alias <alias> --tunnel-port <port> [--json]
|
|
22
24
|
ocx link status [--json]
|
|
23
|
-
ocx link revoke --link-id <id> [--json]
|
|
25
|
+
ocx link revoke --link-id <id> [--force --yes] [--json]
|
|
26
|
+
|
|
27
|
+
Forced revoke skips remote cleanup. Run ocx disconnect on the remote client.`;
|
|
24
28
|
|
|
25
29
|
type LinkState = "connecting" | "connected" | "reconnecting" | "failed" | "idle";
|
|
26
30
|
type ListenerState = "off" | "listening" | "failed";
|
|
@@ -258,19 +262,43 @@ async function runStatus(args: string[], deps: LinkCliDeps): Promise<void> {
|
|
|
258
262
|
}
|
|
259
263
|
|
|
260
264
|
async function runRevoke(args: string[], deps: LinkCliDeps): Promise<void> {
|
|
261
|
-
takeJsonFlag(args);
|
|
265
|
+
const wantsJson = takeJsonFlag(args);
|
|
266
|
+
const force = takeFlag(args, "--force");
|
|
267
|
+
const confirmed = takeFlag(args, "--yes");
|
|
262
268
|
const linkId = takeOption(args, "--link-id");
|
|
263
269
|
if (!linkId || !LINK_ID.test(linkId)) throw new CliUsageError("revoke requires a valid --link-id", LINK_USAGE);
|
|
264
270
|
rejectArgs(args, LINK_USAGE);
|
|
271
|
+
if (force && !confirmed) throw new CliUsageError("forced revoke skips remote cleanup; pass --yes to confirm", LINK_USAGE);
|
|
272
|
+
if (confirmed && !force) throw new CliUsageError("--yes requires --force", LINK_USAGE);
|
|
273
|
+
let remoteCleanup: "skipped" | "unverified" = "skipped";
|
|
265
274
|
try {
|
|
266
|
-
const response = await linkRequest<unknown>(`/api/link/${encodeURIComponent(linkId)}`,
|
|
275
|
+
const response = await linkRequest<unknown>(`/api/link/${encodeURIComponent(linkId)}`, force ? {
|
|
276
|
+
method: "DELETE",
|
|
277
|
+
redirect: "error",
|
|
278
|
+
headers: { "content-type": "application/json" },
|
|
279
|
+
body: JSON.stringify({ force: true }),
|
|
280
|
+
} : { method: "DELETE" }, deps);
|
|
281
|
+
// Legacy revoke accepts an empty response; a forced receipt needs observed identity.
|
|
282
|
+
if (force && response === null) throw new Error("invalid link API response: revoke object");
|
|
267
283
|
validateRevoke(response, linkId);
|
|
268
284
|
} catch (error) {
|
|
269
285
|
// Revoke is idempotent: a link the Home no longer has is already revoked, and a Child retrying
|
|
270
286
|
// a join rollback depends on that answer being success. A 404 without this code comes from a
|
|
271
287
|
// listener that does not serve the management API and stays a failure.
|
|
272
288
|
const code = error instanceof RuntimeApiError && error.status === 404 ? errorCodeBody(error.body)?.error.code : undefined;
|
|
273
|
-
if (code !== "link_not_found")
|
|
289
|
+
if (code !== "link_not_found") {
|
|
290
|
+
if (force && !(error instanceof RuntimeApiError)) throw new Error("Forced link revoke failed; inspect link status before retrying.");
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
remoteCleanup = "unverified";
|
|
294
|
+
}
|
|
295
|
+
if (force) {
|
|
296
|
+
const recovery = "Run ocx disconnect on the remote client.";
|
|
297
|
+
printData({ linkId, remoteCleanup, recovery }, wantsJson, [
|
|
298
|
+
remoteCleanup === "skipped" ? `Revoked ${linkId}; remote cleanup was skipped.` : `Link ${linkId} is already absent; remote cleanup is unverified.`,
|
|
299
|
+
recovery,
|
|
300
|
+
]);
|
|
301
|
+
return;
|
|
274
302
|
}
|
|
275
303
|
console.log(JSON.stringify({ linkId }));
|
|
276
304
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { CodexSyncResult } from "../codex/sync";
|
|
2
|
+
import { FOREIGN_CODEX_HOME_OWNER_MESSAGE, UNKNOWN_CODEX_HOME_OWNER_MESSAGE,
|
|
3
|
+
unbackedRoutedRemovalMessage } from "../codex/catalog/routed-removal";
|
|
4
|
+
|
|
5
|
+
/** Safe local evidence; callers own required versus opportunistic sync policy. */
|
|
6
|
+
export interface LocalSyncResult {
|
|
7
|
+
status: CodexSyncResult["status"] | "not-running" | "not-attempted" | "failed";
|
|
8
|
+
ok: boolean;
|
|
9
|
+
/** Only the catalog owner's fixed, path-free safety guidance; never a raw warning. */
|
|
10
|
+
warning?: string;
|
|
11
|
+
configApplied?: boolean;
|
|
12
|
+
catalog?: { exists: boolean; written: boolean; cacheSynced: boolean; converged: boolean };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Project only the catalog owner's path-free safety guidance, not arbitrary sync errors. */
|
|
16
|
+
function catalogSafetyWarning(warning: string | undefined): string | undefined {
|
|
17
|
+
if (!warning) return undefined;
|
|
18
|
+
for (const message of [FOREIGN_CODEX_HOME_OWNER_MESSAGE, UNKNOWN_CODEX_HOME_OWNER_MESSAGE]) {
|
|
19
|
+
if (warning.startsWith(message)) return message;
|
|
20
|
+
}
|
|
21
|
+
const countText = /^Codex catalog left unchanged: this refresh would remove the routed models of (\d+) /.exec(warning)?.[1];
|
|
22
|
+
if (!countText) return undefined;
|
|
23
|
+
const count = Number(countText);
|
|
24
|
+
if (!Number.isSafeInteger(count) || count < 1) return undefined;
|
|
25
|
+
const message = unbackedRoutedRemovalMessage(count);
|
|
26
|
+
return warning.startsWith(message) ? message : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** No discovery, sync, logging, private paths or raw warning/error projection. */
|
|
30
|
+
export function projectLocalSyncResult(result: CodexSyncResult): LocalSyncResult {
|
|
31
|
+
const warning = catalogSafetyWarning(result.warning);
|
|
32
|
+
const converged = result.catalogExists && result.refreshOutcome !== "refused" && !warning;
|
|
33
|
+
return {
|
|
34
|
+
status: result.status,
|
|
35
|
+
...(warning ? { warning } : {}),
|
|
36
|
+
ok: result.ok && (result.status === "skipped" || (result.status !== "refused" && converged)),
|
|
37
|
+
configApplied: result.status === "applied" && result.ok,
|
|
38
|
+
catalog: { exists: result.catalogExists, written: result.catalogWritten,
|
|
39
|
+
cacheSynced: result.cacheSynced, converged },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { decodeRequestLogCursor } from "../server/request-log-cursor";
|
|
2
|
+
import { printData } from "./runtime-api";
|
|
3
|
+
import { ObservationError, withObserveStream, type ObserveStreamDeps } from "./observe-stream";
|
|
4
|
+
|
|
5
|
+
type LogRow = Record<string, unknown>;
|
|
6
|
+
interface Poll { rows: LogRow[]; cursor: string | null; reset: boolean }
|
|
7
|
+
|
|
8
|
+
function parsePoll(body: unknown, limit: number): Poll {
|
|
9
|
+
let rows: unknown;
|
|
10
|
+
let cursor: string | null = null;
|
|
11
|
+
let reset = false;
|
|
12
|
+
if (Array.isArray(body)) rows = body;
|
|
13
|
+
else if (body && typeof body === "object") {
|
|
14
|
+
const value = body as Record<string, unknown>;
|
|
15
|
+
if (Object.hasOwn(value, "cursor") || Object.hasOwn(value, "reset")) {
|
|
16
|
+
if (typeof value.cursor !== "string" || !decodeRequestLogCursor(value.cursor) || typeof value.reset !== "boolean") {
|
|
17
|
+
throw new ObservationError("Invalid log cursor response. Upgrade the runtime and restart follow.");
|
|
18
|
+
}
|
|
19
|
+
cursor = value.cursor;
|
|
20
|
+
reset = value.reset;
|
|
21
|
+
rows = value.logs;
|
|
22
|
+
} else {
|
|
23
|
+
for (const key of ["logs", "entries", "requests"]) {
|
|
24
|
+
if (Object.hasOwn(value, key)) { rows = value[key]; break; }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (!Array.isArray(rows) || rows.length > limit || rows.some(row => !row || typeof row !== "object" || Array.isArray(row))) {
|
|
29
|
+
throw new ObservationError("Invalid log window. Check --limit and upgrade the runtime before restarting follow.");
|
|
30
|
+
}
|
|
31
|
+
return { rows: rows as LogRow[], cursor, reset };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Compare row occurrences, not request IDs: identical duplicates each consume one match. */
|
|
35
|
+
function newOccurrences(previous: LogRow[], incoming: LogRow[]): LogRow[] {
|
|
36
|
+
const counts = new Map<string, number>();
|
|
37
|
+
for (const row of previous) {
|
|
38
|
+
const key = JSON.stringify(row);
|
|
39
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
40
|
+
}
|
|
41
|
+
return incoming.filter(row => {
|
|
42
|
+
const key = JSON.stringify(row);
|
|
43
|
+
const remaining = counts.get(key) ?? 0;
|
|
44
|
+
if (!remaining) return true;
|
|
45
|
+
counts.set(key, remaining - 1);
|
|
46
|
+
return false;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function followLogs(
|
|
51
|
+
options: { query: URLSearchParams; limit: number; jsonl: boolean; events: boolean; formatRow: (row: LogRow) => string },
|
|
52
|
+
deps: ObserveStreamDeps,
|
|
53
|
+
): Promise<number> {
|
|
54
|
+
return withObserveStream(deps, async stream => {
|
|
55
|
+
let window: LogRow[] = [];
|
|
56
|
+
let cursor: string | null = null;
|
|
57
|
+
let initial = true;
|
|
58
|
+
while (true) {
|
|
59
|
+
const query = new URLSearchParams(options.query);
|
|
60
|
+
if (cursor !== null) query.set("cursor", cursor);
|
|
61
|
+
const poll = parsePoll(await stream.get("/api/logs", query), options.limit);
|
|
62
|
+
stream.signal.throwIfAborted();
|
|
63
|
+
const append = !initial && cursor !== null && poll.cursor !== null && !poll.reset;
|
|
64
|
+
const next = append ? [...window, ...poll.rows].slice(-options.limit) : poll.rows;
|
|
65
|
+
if (options.events) {
|
|
66
|
+
const changed = JSON.stringify(next) !== JSON.stringify(window);
|
|
67
|
+
if (initial || poll.reset || (append ? poll.rows.length > 0 : changed)) {
|
|
68
|
+
console.log(JSON.stringify({ schemaVersion: 1, type: append ? "append" : "snapshot", rows: poll.rows, cursor: poll.cursor, limit: options.limit }));
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
const emitted = append ? poll.rows : newOccurrences(window, next);
|
|
72
|
+
for (const row of emitted) {
|
|
73
|
+
stream.signal.throwIfAborted();
|
|
74
|
+
if (options.jsonl) console.log(JSON.stringify(row));
|
|
75
|
+
else printData(row, false, [options.formatRow(row)]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
window = next;
|
|
79
|
+
cursor = poll.cursor;
|
|
80
|
+
initial = false;
|
|
81
|
+
await stream.wait();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** GUI-equivalent selection within one bounded, observed request-log window. */
|
|
2
|
+
import { parseProtocolTraceV1 } from "../protocols/dto";
|
|
3
|
+
import { decodeRequestLogCursor } from "../server/request-log-cursor";
|
|
4
|
+
import { matchesLogConversationId, normalizeLogConversationId, LOG_CONVERSATION_ID_INPUT_MAX } from "../server/request-log-conversation";
|
|
5
|
+
import { CliUsageError, printData, runCliAction, takeFlag, takeOptionWithSyntax } from "./runtime-api";
|
|
6
|
+
import { ObservationError, withObserveStream, type ObserveStreamDeps } from "./observe-stream";
|
|
7
|
+
|
|
8
|
+
type LogRow = Record<string, unknown>;
|
|
9
|
+
export interface LogViewFilterDeps extends ObserveStreamDeps { now?: () => number }
|
|
10
|
+
const MAX_WINDOW = 2000;
|
|
11
|
+
const WINDOWS = { all: undefined, "15m": 15 * 60_000, "1h": 60 * 60_000, "24h": 24 * 60 * 60_000 };
|
|
12
|
+
|
|
13
|
+
function enumOption<T extends string>(args: string[], flag: string, choices: readonly T[], fallback: T): T {
|
|
14
|
+
const value = takeOptionWithSyntax(args, flag)?.value;
|
|
15
|
+
if (value === undefined) return fallback;
|
|
16
|
+
if (!choices.includes(value as T)) throw new CliUsageError(`${flag} must be ${choices.join("|")}`);
|
|
17
|
+
return value as T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function numberOption(args: string[], flag: string, integer = false): number | undefined {
|
|
21
|
+
const raw = takeOptionWithSyntax(args, flag)?.value;
|
|
22
|
+
if (raw === undefined) return undefined;
|
|
23
|
+
const value = raw.trim() === "" ? NaN : Number(raw);
|
|
24
|
+
if (!Number.isFinite(value) || value < 0 || (integer && (!Number.isInteger(value) || value < 1 || value > MAX_WINDOW))) {
|
|
25
|
+
throw new CliUsageError(`${flag} must be ${integer ? "an integer between 1 and 2000" : "a finite nonnegative number"}`);
|
|
26
|
+
}
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function parseOptions(argv: string[]) {
|
|
31
|
+
const args = [...argv];
|
|
32
|
+
const json = takeFlag(args, "--json"), jsonl = takeFlag(args, "--jsonl");
|
|
33
|
+
const surface = enumOption(args, "--surface", ["all", "codex", "claude", "grok"], "all");
|
|
34
|
+
const model = (takeOptionWithSyntax(args, "--model")?.value ?? "").trim().toLowerCase();
|
|
35
|
+
const provider = (takeOptionWithSyntax(args, "--provider")?.value ?? "").trim().toLowerCase();
|
|
36
|
+
const status = enumOption(args, "--status", ["all", "success", "errors"], "all");
|
|
37
|
+
const timeWindow = enumOption(args, "--time-window", ["all", "15m", "1h", "24h"], "all");
|
|
38
|
+
const protocolMode = enumOption(args, "--protocol-mode", ["all", "native", "translated", "legacy-bridge", "blocked", "none"], "all");
|
|
39
|
+
const minTokPerSec = numberOption(args, "--min-tok-per-sec");
|
|
40
|
+
const maxTokPerSec = numberOption(args, "--max-tok-per-sec");
|
|
41
|
+
const interceptedOnly = takeFlag(args, "--intercepted-only");
|
|
42
|
+
const conversation = takeOptionWithSyntax(args, "--conversation")?.value;
|
|
43
|
+
const alias = takeOptionWithSyntax(args, "--conversationId")?.value;
|
|
44
|
+
if (conversation !== undefined && alias !== undefined) throw new CliUsageError("Use only one conversation option");
|
|
45
|
+
const rawConversation = conversation ?? alias;
|
|
46
|
+
if (rawConversation !== undefined && (rawConversation.length > LOG_CONVERSATION_ID_INPUT_MAX
|
|
47
|
+
|| /[\x00-\x1f\x7f-\x9f\u2028\u2029]/.test(rawConversation) || !normalizeLogConversationId(rawConversation))) {
|
|
48
|
+
throw new CliUsageError("Conversation must be a nonempty, control-free identifier of at most 4096 characters");
|
|
49
|
+
}
|
|
50
|
+
const scanLimit = numberOption(args, "--scan-limit", true) ?? MAX_WINDOW;
|
|
51
|
+
const limit = numberOption(args, "--limit", true) ?? 200;
|
|
52
|
+
if (args.length) throw new CliUsageError("Unknown or repeated option. Use ocx logs filter --help; follow/events are not supported");
|
|
53
|
+
if (json && jsonl) throw new CliUsageError("--json and --jsonl cannot be combined");
|
|
54
|
+
if (minTokPerSec !== undefined && maxTokPerSec !== undefined && minTokPerSec >= maxTokPerSec) {
|
|
55
|
+
throw new CliUsageError("--min-tok-per-sec must be less than --max-tok-per-sec");
|
|
56
|
+
}
|
|
57
|
+
return { json, jsonl, scanLimit, limit, filters: {
|
|
58
|
+
surface, model, provider, status, timeWindow, protocolMode, minTokPerSec, maxTokPerSec,
|
|
59
|
+
interceptedOnly, conversationId: rawConversation?.trim() ?? "",
|
|
60
|
+
} };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function record(value: unknown): value is LogRow {
|
|
64
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function parseWindow(body: unknown, limit: number): { logs: LogRow[]; cursor: string | null } {
|
|
68
|
+
let rows: unknown = body;
|
|
69
|
+
let cursor: string | null = null;
|
|
70
|
+
if (record(body)) {
|
|
71
|
+
if (Object.hasOwn(body, "cursor") || Object.hasOwn(body, "reset")) {
|
|
72
|
+
if (typeof body.cursor !== "string" || !decodeRequestLogCursor(body.cursor) || typeof body.reset !== "boolean") {
|
|
73
|
+
throw new ObservationError("Invalid log cursor response. Upgrade the runtime and retry the command.");
|
|
74
|
+
}
|
|
75
|
+
cursor = body.cursor;
|
|
76
|
+
rows = body.logs;
|
|
77
|
+
} else {
|
|
78
|
+
for (const key of ["logs", "entries", "requests"]) {
|
|
79
|
+
if (Object.hasOwn(body, key)) { rows = body[key]; break; }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (!Array.isArray(rows) || rows.length > limit || !rows.every(record)) {
|
|
84
|
+
throw new ObservationError("Invalid log window. Check --scan-limit and upgrade the runtime before retrying the command.");
|
|
85
|
+
}
|
|
86
|
+
return { logs: rows, cursor };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function normalized(value: unknown): string | undefined {
|
|
90
|
+
return typeof value === "string" ? value.trim().toLowerCase() : undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function matches(row: LogRow, filters: ReturnType<typeof parseOptions>["filters"], now: number): boolean {
|
|
94
|
+
const { surface, status, protocolMode, model, provider, conversationId } = filters;
|
|
95
|
+
if (surface === "codex" && row.surface !== undefined) return false;
|
|
96
|
+
if (surface === "claude" && row.surface !== "claude" && row.surface !== "claude-desktop") return false;
|
|
97
|
+
if (surface === "grok" && row.surface !== "grok") return false;
|
|
98
|
+
if (filters.interceptedOnly && typeof row.shadowCallRewrittenFrom !== "string") return false;
|
|
99
|
+
if (protocolMode !== "all") {
|
|
100
|
+
const mode = parseProtocolTraceV1(row.protocolTrace)?.mode;
|
|
101
|
+
if (protocolMode === "none" ? mode !== undefined : mode !== protocolMode) return false;
|
|
102
|
+
}
|
|
103
|
+
if (conversationId && !matchesLogConversationId(typeof row.conversationId === "string" ? row.conversationId : undefined, conversationId)) return false;
|
|
104
|
+
if (status !== "all") {
|
|
105
|
+
if (typeof row.status !== "number" || !Number.isInteger(row.status)) return false;
|
|
106
|
+
if (status === "success" ? row.status < 200 || row.status >= 300 : row.status < 400 || row.status > 599) return false;
|
|
107
|
+
}
|
|
108
|
+
const attempts = Array.isArray(row.attempts) ? row.attempts.filter(record) : [];
|
|
109
|
+
if (model && ![row.model, row.resolvedModel, row.servedModel, ...attempts.map(attempt => attempt.model)].some(value => normalized(value) === model)) return false;
|
|
110
|
+
if (provider && ![row.provider, ...attempts.map(attempt => attempt.provider)].some(value => normalized(value) === provider)) return false;
|
|
111
|
+
const duration = WINDOWS[filters.timeWindow];
|
|
112
|
+
if (duration !== undefined && (typeof row.timestamp !== "number" || !Number.isFinite(row.timestamp) || row.timestamp < now - duration)) return false;
|
|
113
|
+
const metric = record(row.displayMetrics) ? row.displayMetrics.tokPerSecond : undefined;
|
|
114
|
+
const speed = record(metric) && metric.kind === "value" && typeof metric.value === "number" && Number.isFinite(metric.value) ? metric.value : undefined;
|
|
115
|
+
if (filters.minTokPerSec !== undefined && (speed === undefined || speed < filters.minTokPerSec)) return false;
|
|
116
|
+
if (filters.maxTokPerSec !== undefined && (speed === undefined || speed >= filters.maxTokPerSec)) return false;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** argv starts after `logs filter`; the shared renderer escapes human row values. */
|
|
121
|
+
export async function handleLogFilterCommand(
|
|
122
|
+
argv: string[], deps: LogViewFilterDeps, formatRow: (row: LogRow) => string,
|
|
123
|
+
): Promise<number> {
|
|
124
|
+
let result = 0;
|
|
125
|
+
const parsingExit = await runCliAction(async () => {
|
|
126
|
+
const options = parseOptions(argv);
|
|
127
|
+
result = await withObserveStream(deps, async stream => {
|
|
128
|
+
const snapshot = parseWindow(await stream.get("/api/logs", new URLSearchParams({ limit: String(options.scanLimit) })), options.scanLimit);
|
|
129
|
+
stream.signal.throwIfAborted();
|
|
130
|
+
const now = (deps.now ?? Date.now)();
|
|
131
|
+
const selected = snapshot.logs.filter(row => matches(row, options.filters, now));
|
|
132
|
+
const logs = selected.slice(-options.limit);
|
|
133
|
+
const window = { scanLimit: options.scanLimit, loaded: snapshot.logs.length, matched: selected.length, returned: logs.length, limit: options.limit };
|
|
134
|
+
if (options.json) printData({ schemaVersion: 1, logs, cursor: snapshot.cursor, filters: options.filters, window }, true);
|
|
135
|
+
else if (options.jsonl) {
|
|
136
|
+
for (const row of logs) { stream.signal.throwIfAborted(); console.log(JSON.stringify(row)); }
|
|
137
|
+
} else {
|
|
138
|
+
printData(null, false, [
|
|
139
|
+
`${logs.length} returned / ${selected.length} matched / ${snapshot.logs.length} scanned (current window; scan limit ${options.scanLimit}, output limit ${options.limit}).`,
|
|
140
|
+
...(logs.length ? logs.map(formatRow) : ["No matching logs in this window. Widen filters or increase --scan-limit to inspect a larger current window."]),
|
|
141
|
+
]);
|
|
142
|
+
}
|
|
143
|
+
}, { kind: "snapshot", limitOption: "--scan-limit" });
|
|
144
|
+
});
|
|
145
|
+
return parsingExit || result;
|
|
146
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { isValidProviderName } from "../config/provider-name";
|
|
2
|
+
import { runCatalogAction } from "./catalog-command-result";
|
|
3
|
+
import { CliUsageError, printData, runtimeBaseUrl, runtimeRequest, takeFlag, type RuntimeApiDeps } from "./runtime-api";
|
|
4
|
+
|
|
5
|
+
export interface LogoutCommandDeps extends RuntimeApiDeps {
|
|
6
|
+
/** Atomic store disposition; test callers use a synthetic store only. */
|
|
7
|
+
removeCredential?: (provider: string) => Promise<"removed" | "not-found">;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Explicit target selection precedes all credential access; never fall back between targets. */
|
|
11
|
+
export async function handleLogoutCommand(argv: string[], deps: LogoutCommandDeps = {}): Promise<number> {
|
|
12
|
+
return runCatalogAction(async () => {
|
|
13
|
+
const args = [...argv];
|
|
14
|
+
const json = takeFlag(args, "--json");
|
|
15
|
+
const live = takeFlag(args, "--live");
|
|
16
|
+
const provider = args.shift()?.trim().toLowerCase();
|
|
17
|
+
if (!provider || !isValidProviderName(provider) || args.length) {
|
|
18
|
+
throw new CliUsageError("Expected one provider and no unknown or repeated options.", "Usage: ocx logout <provider> [--live] [--json]");
|
|
19
|
+
}
|
|
20
|
+
if (live) {
|
|
21
|
+
const { isPublicOAuthProvider } = await import("../oauth");
|
|
22
|
+
if (!isPublicOAuthProvider(provider)) throw new CliUsageError("Live logout requires a public OAuth provider; Codex and native-main logout are not supported.");
|
|
23
|
+
const pinned = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
|
|
24
|
+
const data = await runtimeRequest<unknown>(`/api/oauth/logout?provider=${encodeURIComponent(provider)}`, {
|
|
25
|
+
method: "POST", redirect: "error",
|
|
26
|
+
}, pinned);
|
|
27
|
+
if (!data || typeof data !== "object" || Array.isArray(data) || (data as Record<string, unknown>).success !== true) {
|
|
28
|
+
throw new Error("Unverified logout result");
|
|
29
|
+
}
|
|
30
|
+
printData({ schemaVersion: 1, success: true, provider, live: true }, json, [`Logged out of ${provider} on the selected proxy.`]);
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
const remove = deps.removeCredential ?? (await import("../oauth/store")).removeCredential;
|
|
34
|
+
const outcome = await remove(provider);
|
|
35
|
+
if (outcome === "not-found") {
|
|
36
|
+
if (json) printData({ schemaVersion: 1, ok: false, provider, removed: false, reason: "not_found" }, true);
|
|
37
|
+
else console.error(`No stored credential for '${provider}'.`);
|
|
38
|
+
return 4;
|
|
39
|
+
}
|
|
40
|
+
if (outcome !== "removed") throw new Error("Unverified local logout result");
|
|
41
|
+
printData({ schemaVersion: 1, ok: true, provider, removed: true }, json, [`Logged out of ${provider}.`]);
|
|
42
|
+
return 0;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// Deliberate pure port of gui/src/model-picker-order.ts; test-only conformance guards drift.
|
|
2
|
+
export type ModelPickerOrderMode = "default" | "alphabetical" | "provider" | "most-used" | "custom";
|
|
3
|
+
export type SavedModelPickerOrderMode = Exclude<ModelPickerOrderMode, "default" | "custom">;
|
|
4
|
+
export interface ModelPickerUsage {
|
|
5
|
+
provider: string;
|
|
6
|
+
model: string;
|
|
7
|
+
resolvedModel?: string;
|
|
8
|
+
requests: number;
|
|
9
|
+
}
|
|
10
|
+
export interface PickerModelIdentity { provider: string; id: string; namespaced: string }
|
|
11
|
+
export interface PickerOrderSaved {
|
|
12
|
+
pickerOrder: string[];
|
|
13
|
+
pickerOrderMode: SavedModelPickerOrderMode | null;
|
|
14
|
+
}
|
|
15
|
+
export interface PickerOrderSettings extends PickerOrderSaved { pickerAvailable: string[]; chosen?: string[] }
|
|
16
|
+
|
|
17
|
+
function stringList(value: unknown): value is string[] {
|
|
18
|
+
return Array.isArray(value) && value.every(id => typeof id === "string" && id.trim().length > 0);
|
|
19
|
+
}
|
|
20
|
+
function savedMode(value: unknown): value is SavedModelPickerOrderMode | null {
|
|
21
|
+
return value === null || value === "alphabetical" || value === "provider" || value === "most-used";
|
|
22
|
+
}
|
|
23
|
+
export function isPickerOrderSaved(value: unknown): value is PickerOrderSaved {
|
|
24
|
+
if (value === null || typeof value !== "object") return false;
|
|
25
|
+
const row = value as Record<string, unknown>;
|
|
26
|
+
return stringList(row.pickerOrder) && savedMode(row.pickerOrderMode);
|
|
27
|
+
}
|
|
28
|
+
export function isPickerOrderSettings(value: unknown): value is PickerOrderSettings {
|
|
29
|
+
if (!isPickerOrderSaved(value)) return false;
|
|
30
|
+
const row = value as PickerOrderSettings;
|
|
31
|
+
// Roster writes accept every string, including blanks; picker fields remain nonempty-string lists.
|
|
32
|
+
return stringList(row.pickerAvailable) && (!("chosen" in row)
|
|
33
|
+
|| (Array.isArray(row.chosen) && row.chosen.every(id => typeof id === "string")));
|
|
34
|
+
}
|
|
35
|
+
export function isModelPickerUsage(value: unknown): value is ModelPickerUsage[] {
|
|
36
|
+
return Array.isArray(value) && value.every(row => row !== null && typeof row === "object"
|
|
37
|
+
&& typeof row.provider === "string" && typeof row.model === "string"
|
|
38
|
+
&& (row.resolvedModel === undefined || typeof row.resolvedModel === "string")
|
|
39
|
+
&& typeof row.requests === "number" && Number.isFinite(row.requests) && row.requests >= 0);
|
|
40
|
+
}
|
|
41
|
+
function parts(slug: string): [string, string] {
|
|
42
|
+
const slash = slug.indexOf("/");
|
|
43
|
+
return slash < 0 ? ["", slug] : [slug.slice(0, slash), slug.slice(slash + 1)];
|
|
44
|
+
}
|
|
45
|
+
// Fixed locale makes snapshots independent of the user's display language/OS locale.
|
|
46
|
+
const compare = (a: string, b: string) => a.localeCompare(b, "en");
|
|
47
|
+
function byProvider(a: string, b: string): number {
|
|
48
|
+
const [ap, am] = parts(a), [bp, bm] = parts(b);
|
|
49
|
+
return compare(ap, bp) || compare(am, bm);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function modelPickerOrder(
|
|
53
|
+
mode: Exclude<ModelPickerOrderMode, "custom">,
|
|
54
|
+
models: readonly string[],
|
|
55
|
+
usage: readonly ModelPickerUsage[] = [],
|
|
56
|
+
identities: readonly PickerModelIdentity[] = [],
|
|
57
|
+
): string[] | null {
|
|
58
|
+
if (mode === "default") return null;
|
|
59
|
+
const unique = [...new Set(models)];
|
|
60
|
+
if (mode === "alphabetical") return unique.sort((a, b) => compare(parts(a)[1], parts(b)[1]) || byProvider(a, b));
|
|
61
|
+
if (mode === "provider") return unique.sort(byProvider);
|
|
62
|
+
const candidates = new Set(unique);
|
|
63
|
+
const raw = new Map<string, Set<string>>();
|
|
64
|
+
const owners = new Map<string, Set<string>>();
|
|
65
|
+
for (const row of identities) {
|
|
66
|
+
if (!candidates.has(row.namespaced)) continue;
|
|
67
|
+
const key = JSON.stringify([row.provider, row.id]);
|
|
68
|
+
const values = raw.get(key) ?? new Set<string>();
|
|
69
|
+
values.add(row.namespaced);
|
|
70
|
+
raw.set(key, values);
|
|
71
|
+
const sources = owners.get(row.namespaced) ?? new Set<string>();
|
|
72
|
+
sources.add(key);
|
|
73
|
+
owners.set(row.namespaced, sources);
|
|
74
|
+
}
|
|
75
|
+
const unambiguous = (slug: string): string | null => (owners.get(slug)?.size ?? 0) > 1 ? null : slug;
|
|
76
|
+
const resolve = (provider: string, id: string): string | null | undefined => {
|
|
77
|
+
const exact = raw.get(JSON.stringify([provider, id]));
|
|
78
|
+
if (exact) return exact.size === 1 ? unambiguous([...exact][0]!) : null;
|
|
79
|
+
// A raw upstream slash is not a namespace. Use the observed identity table above;
|
|
80
|
+
// only fall back to an exact same-provider catalog id or an ordinary bare model id.
|
|
81
|
+
if (id.startsWith(`${provider}/`) && candidates.has(id)) return unambiguous(id);
|
|
82
|
+
const slug = `${provider}/${id}`;
|
|
83
|
+
return !id.includes("/") && candidates.has(slug) ? unambiguous(slug) : undefined;
|
|
84
|
+
};
|
|
85
|
+
const counts = new Map<string, number>();
|
|
86
|
+
for (const row of usage) {
|
|
87
|
+
// Summary buckets are keyed by requested model. resolvedModel is only a
|
|
88
|
+
// representative observation, not proof that every request used that target.
|
|
89
|
+
const target = resolve(row.provider, row.model);
|
|
90
|
+
if (target) counts.set(target, (counts.get(target) ?? 0) + row.requests);
|
|
91
|
+
}
|
|
92
|
+
return unique.sort((a, b) => (counts.get(b) ?? 0) - (counts.get(a) ?? 0) || byProvider(a, b));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Resolve exact canonical ids before legacy provider/raw spellings; never guess a bare native id. */
|
|
96
|
+
export function normalizePickerIds(ids: readonly string[], available: readonly string[], identities: readonly PickerModelIdentity[]): string[] {
|
|
97
|
+
const candidates = new Set(available.filter(id => id.includes("/")));
|
|
98
|
+
const resolve = (id: string): string | undefined => {
|
|
99
|
+
if (candidates.has(id)) return id;
|
|
100
|
+
const matches = new Set(identities.filter(row => candidates.has(row.namespaced)
|
|
101
|
+
&& id === `${row.provider}/${row.id}`).map(row => row.namespaced));
|
|
102
|
+
return matches.size === 1 ? [...matches][0] : undefined;
|
|
103
|
+
};
|
|
104
|
+
return [...new Set(ids.map(id => resolve(id.trim())).filter((id): id is string => id !== undefined))];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Every candidate needs one observed provider/raw identity, with no encoded/raw collisions. */
|
|
108
|
+
export function pickerIdentityCoverage(available: readonly string[], identities: readonly PickerModelIdentity[]): boolean {
|
|
109
|
+
const candidates = new Set(available.filter(id => id.includes("/")));
|
|
110
|
+
const rawBySlug = new Map<string, Set<string>>(), slugsByRaw = new Map<string, Set<string>>();
|
|
111
|
+
for (const row of identities) {
|
|
112
|
+
if (!candidates.has(row.namespaced)) continue;
|
|
113
|
+
const raw = `${row.provider}/${row.id}`;
|
|
114
|
+
const raws = rawBySlug.get(row.namespaced) ?? new Set<string>();
|
|
115
|
+
const slugs = slugsByRaw.get(raw) ?? new Set<string>();
|
|
116
|
+
raws.add(raw); slugs.add(row.namespaced);
|
|
117
|
+
rawBySlug.set(row.namespaced, raws); slugsByRaw.set(raw, slugs);
|
|
118
|
+
}
|
|
119
|
+
return [...candidates].every(slug => {
|
|
120
|
+
const raws = rawBySlug.get(slug);
|
|
121
|
+
return raws?.size === 1 && slugsByRaw.get([...raws][0]!)?.size === 1;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function customPickerRows(settings: PickerOrderSettings, identities: readonly PickerModelIdentity[]): { order: string[]; fixed: string[] } | null {
|
|
126
|
+
// Unknown featured state and complete/native orders cannot safely become routed-only drafts.
|
|
127
|
+
if (settings.chosen === undefined || settings.pickerOrder.some(id => !id.includes("/"))) return null;
|
|
128
|
+
const available = [...new Set(settings.pickerAvailable.filter(id => id.includes("/")))];
|
|
129
|
+
if (!pickerIdentityCoverage(available, identities)) return null;
|
|
130
|
+
// Roster strings stay verbatim. Map uses the LAST occurrence; each row prefers its exact canonical rank.
|
|
131
|
+
const chosenRank = new Map(settings.chosen.map((id, index) => [id, index]));
|
|
132
|
+
const rawBySlug = new Map(identities.map(row => [row.namespaced, `${row.provider}/${row.id}`]));
|
|
133
|
+
const rankOf = (slug: string) => chosenRank.get(slug) ?? chosenRank.get(rawBySlug.get(slug)!);
|
|
134
|
+
const fixed = available.filter(slug => rankOf(slug) !== undefined).sort((a, b) => rankOf(a)! - rankOf(b)!);
|
|
135
|
+
const saved = normalizePickerIds(settings.pickerOrder, available, identities);
|
|
136
|
+
return { fixed, order: [...new Set([...fixed, ...saved, ...available])] };
|
|
137
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { canonicalizeReasoningEfforts, isDeclaredReasoningEffort } from "../reasoning-effort";
|
|
2
|
+
import { isValidProviderName } from "../config/provider-name";
|
|
3
|
+
import { CliUsageError, takeOptionWithSyntax } from "./runtime-api";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse and validate the reasoning flags shared by `ocx models add` (offline path).
|
|
7
|
+
* "-" means "inherit" and omits the field entirely; "" means an explicit empty ladder
|
|
8
|
+
* ("no reasoning" override, the same state the dashboard stores for the toggle-off
|
|
9
|
+
* checkbox set). Malformed CSV like `low,,high` or `,,` is rejected instead of being
|
|
10
|
+
* silently normalized. Values are canonicalized into Codex ladder order so the stored
|
|
11
|
+
* config matches what the API stores.
|
|
12
|
+
*/
|
|
13
|
+
export function parseReasoningArgs(
|
|
14
|
+
reasoningEffortsValue: string | undefined,
|
|
15
|
+
defaultEffortValue: string | undefined,
|
|
16
|
+
): { reasoningEfforts?: string[]; defaultReasoningEffort?: string; error?: string } {
|
|
17
|
+
if (reasoningEffortsValue === undefined && defaultEffortValue === undefined) return {};
|
|
18
|
+
let reasoningEfforts: string[] | undefined;
|
|
19
|
+
if (reasoningEffortsValue !== undefined) {
|
|
20
|
+
const trimmed = reasoningEffortsValue.trim();
|
|
21
|
+
if (trimmed === "-") {
|
|
22
|
+
reasoningEfforts = undefined;
|
|
23
|
+
} else if (trimmed === "") {
|
|
24
|
+
// Explicit no-reasoning override, exactly like the API's [] / the dashboard's
|
|
25
|
+
// uncheck-all state.
|
|
26
|
+
reasoningEfforts = [];
|
|
27
|
+
} else {
|
|
28
|
+
const parts = trimmed.split(",").map(value => value.trim());
|
|
29
|
+
if (parts.some(part => part === "")) {
|
|
30
|
+
return { error: "--reasoning-efforts must be comma-separated values from none, minimal, low, medium, high, xhigh, max, ultra (\"\" for no reasoning, \"-\" to inherit)" };
|
|
31
|
+
}
|
|
32
|
+
const invalid = parts.filter(value => !isDeclaredReasoningEffort(value));
|
|
33
|
+
if (invalid.length > 0) {
|
|
34
|
+
return { error: `unsupported reasoning effort: ${invalid.join(", ")} (allowed: none, minimal, low, medium, high, xhigh, max, ultra)` };
|
|
35
|
+
}
|
|
36
|
+
reasoningEfforts = canonicalizeReasoningEfforts(parts);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
let defaultReasoningEffort: string | undefined;
|
|
40
|
+
if (defaultEffortValue !== undefined) {
|
|
41
|
+
const trimmed = defaultEffortValue.trim();
|
|
42
|
+
if (trimmed === "-") {
|
|
43
|
+
defaultReasoningEffort = undefined;
|
|
44
|
+
} else {
|
|
45
|
+
if (!isDeclaredReasoningEffort(trimmed)) {
|
|
46
|
+
return { error: `unsupported reasoning effort: ${trimmed} (allowed: none, minimal, low, medium, high, xhigh, max, ultra)` };
|
|
47
|
+
}
|
|
48
|
+
if (!reasoningEfforts || reasoningEfforts.length === 0) {
|
|
49
|
+
return { error: "--default-reasoning-effort requires --reasoning-efforts" };
|
|
50
|
+
}
|
|
51
|
+
if (!reasoningEfforts.includes(trimmed)) {
|
|
52
|
+
return { error: `--default-reasoning-effort "${trimmed}" is not in the declared reasoning efforts` };
|
|
53
|
+
}
|
|
54
|
+
defaultReasoningEffort = trimmed;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { reasoningEfforts, defaultReasoningEffort };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface CustomModelAddInput {
|
|
61
|
+
provider: string;
|
|
62
|
+
modelId: string;
|
|
63
|
+
displayName?: string;
|
|
64
|
+
contextWindow?: number;
|
|
65
|
+
inputModalities?: string[];
|
|
66
|
+
reasoningEfforts?: string[];
|
|
67
|
+
defaultReasoningEffort?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Parses only the existing add fields; the caller consumes --live and --json. */
|
|
71
|
+
export function parseCustomModelAddInput(args: string[]): CustomModelAddInput {
|
|
72
|
+
const display = takeOptionWithSyntax(args, "--display-name")?.value;
|
|
73
|
+
const context = takeOptionWithSyntax(args, "--context-window")?.value;
|
|
74
|
+
const modalities = takeOptionWithSyntax(args, "--modalities")?.value;
|
|
75
|
+
// The inline empty value is meaningful here, unlike ordinary value options.
|
|
76
|
+
const emptyReasoning = args.indexOf("--reasoning-efforts=");
|
|
77
|
+
if (emptyReasoning >= 0) args.splice(emptyReasoning, 1, "--reasoning-efforts", "");
|
|
78
|
+
const efforts = takeOptionWithSyntax(args, "--reasoning-efforts")?.value;
|
|
79
|
+
const defaultEffort = takeOptionWithSyntax(args, "--default-reasoning-effort")?.value;
|
|
80
|
+
if (args.length !== 2 || args.some(arg => !arg.trim() || arg.startsWith("-"))) {
|
|
81
|
+
throw new CliUsageError("A provider and model ID are required; unexpected arguments are not accepted");
|
|
82
|
+
}
|
|
83
|
+
const provider = args[0]!.trim();
|
|
84
|
+
const modelId = args[1]!.trim();
|
|
85
|
+
if (!isValidProviderName(provider)) throw new CliUsageError("Invalid provider name");
|
|
86
|
+
const input: CustomModelAddInput = { provider, modelId };
|
|
87
|
+
const displayName = display?.trim();
|
|
88
|
+
if (displayName?.includes("/")) throw new CliUsageError("Display name must not contain /");
|
|
89
|
+
if (displayName) input.displayName = displayName;
|
|
90
|
+
if (context !== undefined) {
|
|
91
|
+
const value = Number(context);
|
|
92
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new CliUsageError("Context window must be a positive safe integer");
|
|
93
|
+
input.contextWindow = value;
|
|
94
|
+
}
|
|
95
|
+
if (modalities !== undefined) {
|
|
96
|
+
const values = modalities.split(",").map(value => value.trim());
|
|
97
|
+
if (values.some(value => !["text", "image", "audio"].includes(value))) {
|
|
98
|
+
throw new CliUsageError("Modalities must be comma-separated text, image or audio");
|
|
99
|
+
}
|
|
100
|
+
input.inputModalities = [...new Set(values)];
|
|
101
|
+
}
|
|
102
|
+
const parsed = parseReasoningArgs(efforts, defaultEffort);
|
|
103
|
+
if (parsed.error) throw new CliUsageError("Invalid reasoning options");
|
|
104
|
+
if (parsed.reasoningEfforts !== undefined) input.reasoningEfforts = parsed.reasoningEfforts;
|
|
105
|
+
if (parsed.defaultReasoningEffort !== undefined) input.defaultReasoningEffort = parsed.defaultReasoningEffort;
|
|
106
|
+
return input;
|
|
107
|
+
}
|