@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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/** Bounded, serial observation of fixed management read surfaces. */
|
|
2
|
+
import { readBoundedResponseBytes } from "../lib/bounded-body";
|
|
3
|
+
import { runningProxyUpdateHeaders } from "../oauth/login-cli";
|
|
4
|
+
import { findLiveProxy, type LiveProxy } from "../server/proxy-liveness";
|
|
5
|
+
import { runtimeBaseUrl, type RuntimeApiDeps } from "./runtime-api";
|
|
6
|
+
|
|
7
|
+
const POLL_MS = 1_000;
|
|
8
|
+
const REQUEST_MS = 10_000;
|
|
9
|
+
const RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
10
|
+
type ObservationPath = "/api/logs" | "/api/debug/injection-logs" | "/api/companion/settings" | "/api/usage";
|
|
11
|
+
|
|
12
|
+
export interface ObserveStreamDeps extends RuntimeApiDeps {
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
/** Reduced timings for isolated tests only; never command-line options. */
|
|
15
|
+
pollIntervalMs?: number;
|
|
16
|
+
requestTimeoutMs?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class ObservationError extends Error {}
|
|
20
|
+
|
|
21
|
+
function reduced(value: number | undefined, ceiling: number): number {
|
|
22
|
+
return value !== undefined && Number.isFinite(value) && value > 0 && value <= ceiling ? value : ceiling;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function interrupted<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
26
|
+
return new Promise((resolve, reject) => {
|
|
27
|
+
const abort = () => { cleanup(); reject(signal.reason); };
|
|
28
|
+
const cleanup = () => signal.removeEventListener("abort", abort);
|
|
29
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
30
|
+
work.then(value => { cleanup(); resolve(value); }, error => { cleanup(); reject(error); });
|
|
31
|
+
if (signal.aborted) abort();
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function waitForPoll(ms: number, signal: AbortSignal): Promise<void> {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const cleanup = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); };
|
|
38
|
+
const abort = () => { cleanup(); reject(signal.reason); };
|
|
39
|
+
const timer = setTimeout(() => { cleanup(); resolve(); }, ms);
|
|
40
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
41
|
+
if (signal.aborted) abort();
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function identity(live: LiveProxy | undefined): string {
|
|
46
|
+
return JSON.stringify(live ? [live.pid, live.port, live.hostname, live.role] : null);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ObserveStream {
|
|
50
|
+
signal: AbortSignal;
|
|
51
|
+
get(path: ObservationPath, query: URLSearchParams): Promise<unknown>;
|
|
52
|
+
wait(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ObservationContext {
|
|
56
|
+
kind: "follow" | "snapshot";
|
|
57
|
+
limitOption?: "--limit" | "--scan-limit";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Each invocation owns its signals, in-flight request and pinned runtime identity. */
|
|
61
|
+
export async function withObserveStream(
|
|
62
|
+
deps: ObserveStreamDeps,
|
|
63
|
+
run: (stream: ObserveStream) => Promise<void | number>,
|
|
64
|
+
context: ObservationContext = { kind: "follow", limitOption: "--limit" },
|
|
65
|
+
): Promise<number> {
|
|
66
|
+
const retry = context.kind === "follow" ? "restart follow" : "retry the command";
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
let exit = 0;
|
|
69
|
+
const stop = (code: number) => {
|
|
70
|
+
if (controller.signal.aborted) return;
|
|
71
|
+
exit = code;
|
|
72
|
+
controller.abort();
|
|
73
|
+
};
|
|
74
|
+
const onInt = () => stop(130);
|
|
75
|
+
const onTerm = () => stop(143);
|
|
76
|
+
process.on("SIGINT", onInt);
|
|
77
|
+
process.on("SIGTERM", onTerm);
|
|
78
|
+
deps.signal?.addEventListener("abort", onInt, { once: true });
|
|
79
|
+
if (deps.signal?.aborted) onInt();
|
|
80
|
+
let pinned: { origin: string; identity: string } | undefined;
|
|
81
|
+
const signal = controller.signal;
|
|
82
|
+
const timeoutMs = reduced(deps.requestTimeoutMs, REQUEST_MS);
|
|
83
|
+
const get = async (path: ObservationPath, query: URLSearchParams): Promise<unknown> => {
|
|
84
|
+
signal.throwIfAborted();
|
|
85
|
+
let live: LiveProxy | undefined;
|
|
86
|
+
const origin = await interrupted(runtimeBaseUrl({ ...deps, findLiveProxy: async () => {
|
|
87
|
+
const found = await (deps.findLiveProxy ?? findLiveProxy)();
|
|
88
|
+
live = found ? { ...found } : undefined;
|
|
89
|
+
// Snapshot recovery comes from our observed target, never arbitrary transport text.
|
|
90
|
+
if (context.kind === "snapshot") {
|
|
91
|
+
if (!found) throw new ObservationError("Proxy is not running. Start it with: ocx start");
|
|
92
|
+
if (found.role === "client") throw new ObservationError("This listener runs in the client role and has no management API. Run this command on its serving hub, or open the hub dashboard.");
|
|
93
|
+
}
|
|
94
|
+
return found;
|
|
95
|
+
} }), signal);
|
|
96
|
+
signal.throwIfAborted();
|
|
97
|
+
const current = { origin, identity: identity(live) };
|
|
98
|
+
if (pinned && (pinned.origin !== current.origin || pinned.identity !== current.identity)) {
|
|
99
|
+
throw new ObservationError(`The runtime changed. ${context.kind === "follow" ? "Restart follow" : "Retry the command"} to observe the current runtime.`);
|
|
100
|
+
}
|
|
101
|
+
pinned = current;
|
|
102
|
+
const request = new AbortController();
|
|
103
|
+
const abort = () => request.abort();
|
|
104
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
105
|
+
const timer = setTimeout(abort, timeoutMs);
|
|
106
|
+
try {
|
|
107
|
+
signal.throwIfAborted();
|
|
108
|
+
const responsePromise = (deps.fetchImpl ?? fetch)(`${origin}${path}?${query}`, {
|
|
109
|
+
method: "GET", headers: runningProxyUpdateHeaders(), credentials: "omit", redirect: "error", signal: request.signal,
|
|
110
|
+
});
|
|
111
|
+
// A test transport or late fetch may settle after abort. Release its body too.
|
|
112
|
+
void responsePromise.then(response => {
|
|
113
|
+
if (request.signal.aborted) void response.body?.cancel().catch(() => {});
|
|
114
|
+
}, () => {});
|
|
115
|
+
const response = await interrupted(responsePromise, request.signal);
|
|
116
|
+
const result = await readBoundedResponseBytes(response, { maxBytes: RESPONSE_BYTES, signal: request.signal });
|
|
117
|
+
signal.throwIfAborted();
|
|
118
|
+
request.signal.throwIfAborted();
|
|
119
|
+
if (result.oversized) throw new ObservationError(`The observation window exceeds 32 MiB. ${context.limitOption ? `Reduce ${context.limitOption} and ${retry}` : "Check the runtime response size and retry the command"}.`);
|
|
120
|
+
if (!response.ok) throw new ObservationError(`The observation request was refused. Check runtime access and ${retry}.`);
|
|
121
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(result.bytes));
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
signal.removeEventListener("abort", abort);
|
|
125
|
+
request.abort();
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
try {
|
|
129
|
+
signal.throwIfAborted();
|
|
130
|
+
const result = await run({ signal, get, wait: () => waitForPoll(reduced(deps.pollIntervalMs, POLL_MS), signal) });
|
|
131
|
+
return exit || result || 0;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (exit) return exit;
|
|
134
|
+
console.error(`Error: ${error instanceof ObservationError ? error.message : `Observation failed or timed out. Check the runtime and ${retry}.`}`);
|
|
135
|
+
return 1;
|
|
136
|
+
} finally {
|
|
137
|
+
controller.abort();
|
|
138
|
+
process.removeListener("SIGINT", onInt);
|
|
139
|
+
process.removeListener("SIGTERM", onTerm);
|
|
140
|
+
deps.signal?.removeEventListener("abort", onInt);
|
|
141
|
+
}
|
|
142
|
+
}
|
package/src/cli/observe.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
CliUsageError,
|
|
3
|
+
RuntimeApiError,
|
|
3
4
|
printData,
|
|
4
5
|
rejectArgs,
|
|
5
6
|
runCliAction,
|
|
@@ -8,10 +9,16 @@ import {
|
|
|
8
9
|
takeFlag,
|
|
9
10
|
takeIntegerOption,
|
|
10
11
|
takeOption,
|
|
12
|
+
takeOptionWithSyntax,
|
|
11
13
|
type RuntimeApiDeps,
|
|
12
14
|
} from "./runtime-api";
|
|
15
|
+
import { followLogs } from "./log-follow";
|
|
16
|
+
import { handleLogFilterCommand } from "./log-view-filter";
|
|
17
|
+
import { followInjection } from "./injection-follow";
|
|
18
|
+
import type { ObserveStreamDeps } from "./observe-stream";
|
|
13
19
|
import { formatUsageReport } from "./usage-report";
|
|
14
|
-
import {
|
|
20
|
+
import { selectUsageModelView, takeUsageSearchOption } from "./usage-model-search";
|
|
21
|
+
import { USAGE_RANGES, USAGE_SURFACES, type UsageSummary, type UsageFilterEcho } from "../usage/summary";
|
|
15
22
|
import { parseUsageTimeWindow, type UsageTimeWindow } from "../usage/time-range";
|
|
16
23
|
import { redactSecretString } from "../lib/redact";
|
|
17
24
|
import { readClientConnectionState, sameClientConnectionOwner } from "../client/state";
|
|
@@ -21,18 +28,19 @@ import type { HubUsageReport } from "../remote/hub-usage";
|
|
|
21
28
|
|
|
22
29
|
const USAGE = `Usage:
|
|
23
30
|
ocx observe logs [--provider <name>] [--model <id>] [--status <code>]
|
|
24
|
-
[--conversation <id>] [--account <label>] [--limit <n>] [--follow] [--json|--jsonl]
|
|
31
|
+
[--conversation <id>] [--account <label>] [--limit <n>] [--follow] [--json|--jsonl] [--events]
|
|
25
32
|
ocx logs explain <request-id> [--json]
|
|
33
|
+
ocx logs filter [selectors] [--scan-limit <1..2000>] [--limit <1..2000>] [--json|--jsonl]
|
|
26
34
|
ocx logs rebuild-index
|
|
27
35
|
ocx logs index-status
|
|
28
36
|
ocx observe usage [--range <today|1d|7d|30d|all>] [--surface <all|codex|claude|grok>]
|
|
29
37
|
[--since <epoch-ms|ISO-datetime>] [--until <epoch-ms|ISO-datetime>]
|
|
30
|
-
[--provider <name>] [--model <id>] [--json]
|
|
38
|
+
[--provider <name>] [--model <id>] [--api-key-id <id>] [--search <text>] [--json]
|
|
31
39
|
ocx observe storage [codex-logs [status|protect|unprotect|repair|compact] [--mode <compat|quiet>]] [--json]
|
|
32
40
|
ocx observe memory [--json]
|
|
33
41
|
ocx observe debug [--json]
|
|
34
42
|
ocx observe claude-inbound [--limit <n>] [--json]
|
|
35
|
-
ocx observe injection [--limit <n>] [--json]`;
|
|
43
|
+
ocx observe injection [--limit <n>] [--follow [--jsonl]] [--json]`;
|
|
36
44
|
|
|
37
45
|
type LogEntry = Record<string, unknown> & { id?: string | number; timestamp?: string; provider?: string; model?: string; status?: number };
|
|
38
46
|
|
|
@@ -77,10 +85,11 @@ function formatLog(row: LogEntry): string {
|
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
/** Read or follow request logs while preserving raw JSON and JSONL output. */
|
|
80
|
-
async function logs(argv: string[], deps:
|
|
88
|
+
async function logs(argv: string[], deps: ObserveStreamDeps): Promise<number> {
|
|
81
89
|
const args = [...argv];
|
|
82
90
|
const wantsJson = takeFlag(args, "--json");
|
|
83
|
-
const
|
|
91
|
+
const wantsEvents = takeFlag(args, "--events");
|
|
92
|
+
const wantsJsonl = takeFlag(args, "--jsonl") || wantsEvents;
|
|
84
93
|
const follow = takeFlag(args, "--follow") || takeFlag(args, "-f");
|
|
85
94
|
const provider = takeOption(args, "--provider");
|
|
86
95
|
const model = takeOption(args, "--model");
|
|
@@ -97,24 +106,19 @@ async function logs(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
97
106
|
if (follow && wantsJson) {
|
|
98
107
|
throw new CliUsageError("--follow cannot be combined with --json; use --jsonl for streaming JSONL", USAGE);
|
|
99
108
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
if (!follow) return;
|
|
115
|
-
if (seen.size > 5_000) seen = new Set([...seen].slice(-2_500));
|
|
116
|
-
await Bun.sleep(1_000);
|
|
117
|
-
} while (true);
|
|
109
|
+
if (wantsEvents && !follow) throw new CliUsageError("--events requires --follow", USAGE);
|
|
110
|
+
const params = { provider, model, status, conversationId, account, limit };
|
|
111
|
+
if (follow) {
|
|
112
|
+
if (limit > 2000) throw new CliUsageError("--limit must be between 1 and 2000 for follow", USAGE);
|
|
113
|
+
return followLogs({ query: new URLSearchParams(query(params)), limit, jsonl: wantsJsonl, events: wantsEvents, formatRow: formatLog }, deps);
|
|
114
|
+
}
|
|
115
|
+
const data = await runtimeRequest(`/api/logs${query(params)}`, {}, deps);
|
|
116
|
+
if (wantsJson) printData(data, true);
|
|
117
|
+
else for (const row of logRows(data)) {
|
|
118
|
+
if (wantsJsonl) console.log(JSON.stringify(row));
|
|
119
|
+
else console.log(formatLog(row));
|
|
120
|
+
}
|
|
121
|
+
return 0;
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
async function explain(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
@@ -163,6 +167,7 @@ async function indexStatus(argv: string[], deps: RuntimeApiDeps): Promise<void>
|
|
|
163
167
|
|
|
164
168
|
async function usage(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
165
169
|
const args = [...argv];
|
|
170
|
+
const search = takeUsageSearchOption(args);
|
|
166
171
|
const wantsJson = takeFlag(args, "--json");
|
|
167
172
|
const range = takeOption(args, "--range") ?? "30d";
|
|
168
173
|
const surface = takeOption(args, "--surface") ?? "all";
|
|
@@ -170,6 +175,9 @@ async function usage(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
170
175
|
const model = takeOption(args, "--model");
|
|
171
176
|
const since = takeOption(args, "--since");
|
|
172
177
|
const until = takeOption(args, "--until");
|
|
178
|
+
const rawKeyId = takeOptionWithSyntax(args, "--api-key-id")?.value;
|
|
179
|
+
const apiKeyId = rawKeyId?.trim();
|
|
180
|
+
if (apiKeyId !== undefined && !apiKeyId) throw new CliUsageError("--api-key-id must not be blank", USAGE);
|
|
173
181
|
let window: UsageTimeWindow | undefined;
|
|
174
182
|
try {
|
|
175
183
|
window = parseUsageTimeWindow(since, until);
|
|
@@ -184,13 +192,14 @@ async function usage(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
184
192
|
throw new CliUsageError(`--surface must be one of ${USAGE_SURFACES.join(", ")}`, USAGE);
|
|
185
193
|
}
|
|
186
194
|
rejectArgs(args.map(redactSecretString), USAGE);
|
|
187
|
-
const suffix = query({ range, surface, provider, model, since: window?.since, until: window?.until });
|
|
195
|
+
const suffix = query({ range, surface, provider, model, apiKeyId, since: window?.since, until: window?.until });
|
|
188
196
|
const connection = readClientConnectionState();
|
|
189
|
-
let result: UsageSummary | HubUsageReport;
|
|
197
|
+
let result: (UsageSummary & { filter?: UsageFilterEcho }) | HubUsageReport;
|
|
190
198
|
if (connection.kind === "invalid" || connection.kind === "mismatched") {
|
|
191
199
|
throw new Error(`Client usage unavailable: ${connection.reason}`);
|
|
192
200
|
}
|
|
193
201
|
if (connection.kind === "connected") {
|
|
202
|
+
if (apiKeyId !== undefined) throw new CliUsageError("--api-key-id is unavailable on connected clients; run on the hub for a selected key, or omit it for this client's usage", USAGE);
|
|
194
203
|
const token = readServiceApiTokenState();
|
|
195
204
|
if (token.kind !== "present" || token.fingerprint !== connection.value.tokenFingerprint) {
|
|
196
205
|
throw new Error("Client usage unavailable: the enrolled data key is missing or changed; repair the client connection");
|
|
@@ -206,7 +215,18 @@ async function usage(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
206
215
|
throw new Error("Client connection changed while reading usage; retry for the current connection");
|
|
207
216
|
}
|
|
208
217
|
} else {
|
|
209
|
-
result = await runtimeRequest<UsageSummary>(`/api/usage${suffix}`, {}, deps);
|
|
218
|
+
if (apiKeyId === undefined) result = await runtimeRequest<UsageSummary>(`/api/usage${suffix}`, {}, deps);
|
|
219
|
+
else {
|
|
220
|
+
try {
|
|
221
|
+
result = await runtimeRequest<UsageSummary & { filter?: UsageFilterEcho }>(`/api/usage${suffix}`, { redirect: "error", credentials: "omit" }, deps);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (error instanceof RuntimeApiError && error.code === "proxy_not_running") throw error;
|
|
224
|
+
throw new Error("Key-scoped usage could not be read. Check runtime access and retry.");
|
|
225
|
+
}
|
|
226
|
+
if (result?.filter?.apiKeyId !== apiKeyId) {
|
|
227
|
+
throw new Error("The server did not confirm the requested API key scope. Upgrade and restart the proxy, then retry.");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
210
230
|
}
|
|
211
231
|
// Older daemons ignore custom bounds and return successful preset reports.
|
|
212
232
|
if (window && (result?.customWindow !== true || result.since !== window.since || result.until !== window.until)) {
|
|
@@ -216,8 +236,10 @@ async function usage(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
216
236
|
// the call, so passing formatUsageReport(...) inline would run the human
|
|
217
237
|
// renderer during --json and let its assumptions affect a path that is meant
|
|
218
238
|
// to bypass it entirely.
|
|
219
|
-
|
|
220
|
-
|
|
239
|
+
const view = search === undefined ? undefined : selectUsageModelView(result, search);
|
|
240
|
+
const displayed = view ?? result;
|
|
241
|
+
if (wantsJson) printData(displayed, true);
|
|
242
|
+
else printData(displayed, false, formatUsageReport(displayed as Parameters<typeof formatUsageReport>[0], view ? { modelView: view.modelView } : undefined));
|
|
221
243
|
}
|
|
222
244
|
|
|
223
245
|
async function simple(path: string, argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
@@ -265,24 +287,43 @@ async function storage(argv: string[], deps: RuntimeApiDeps): Promise<void> {
|
|
|
265
287
|
printData(result, wantsJson, summaryLines(result));
|
|
266
288
|
}
|
|
267
289
|
|
|
268
|
-
|
|
269
|
-
|
|
290
|
+
async function injection(argv: string[], deps: ObserveStreamDeps): Promise<number> {
|
|
291
|
+
const args = [...argv];
|
|
292
|
+
const follow = takeFlag(args, "--follow");
|
|
293
|
+
const jsonl = takeFlag(args, "--jsonl");
|
|
294
|
+
if (!follow) {
|
|
295
|
+
if (jsonl) throw new CliUsageError("--jsonl requires --follow for injection logs", USAGE);
|
|
296
|
+
await simple("/api/debug/injection-logs", args, deps);
|
|
297
|
+
return 0;
|
|
298
|
+
}
|
|
299
|
+
if (takeFlag(args, "--json")) throw new CliUsageError("--follow cannot be combined with --json; use --jsonl", USAGE);
|
|
300
|
+
const limit = takeIntegerOption(args, "--limit", { min: 1 }) ?? 500;
|
|
301
|
+
if (limit > 2000) throw new CliUsageError("--limit must be between 1 and 2000 for follow", USAGE);
|
|
302
|
+
rejectArgs(args, USAGE);
|
|
303
|
+
return followInjection(limit, jsonl, deps);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export async function handleObserveCommand(argv: string[], deps: ObserveStreamDeps = {}): Promise<number> {
|
|
307
|
+
let streamExit = 0;
|
|
308
|
+
const exit = await runCliAction(async () => {
|
|
270
309
|
const [sub = "logs", ...rest] = argv;
|
|
271
310
|
if (sub === "logs") {
|
|
272
311
|
const action = rest[0];
|
|
273
|
-
if (action === "
|
|
312
|
+
if (action === "filter") streamExit = await handleLogFilterCommand(rest.slice(1), deps, formatLog);
|
|
313
|
+
else if (action === "explain") await explain(rest.slice(1), deps);
|
|
274
314
|
else if (action === "rebuild-index") await rebuildIndex(rest.slice(1), deps);
|
|
275
315
|
else if (action === "index-status") await indexStatus(rest.slice(1), deps);
|
|
276
|
-
else await logs(rest, deps);
|
|
316
|
+
else streamExit = await logs(rest, deps);
|
|
277
317
|
}
|
|
278
318
|
else if (sub === "usage") await usage(rest, deps);
|
|
279
319
|
else if (sub === "storage") await storage(rest, deps);
|
|
280
320
|
else if (sub === "memory") await simple("/api/system/memory", rest, deps);
|
|
281
321
|
else if (sub === "debug") await simple("/api/debug", rest, deps);
|
|
282
322
|
else if (sub === "claude-inbound") await simple("/api/claude/inbound-debug", rest, deps);
|
|
283
|
-
else if (sub === "injection") await
|
|
323
|
+
else if (sub === "injection") streamExit = await injection(rest, deps);
|
|
284
324
|
else throw new CliUsageError(`unknown observe command ${sub}`, USAGE);
|
|
285
325
|
});
|
|
326
|
+
return exit || streamExit;
|
|
286
327
|
}
|
|
287
328
|
|
|
288
329
|
export const OBSERVE_USAGE = USAGE;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { parseProviderEditorConfigDTO } from "../server/auth-cors";
|
|
2
|
+
import { readJsonInput, serializeManagementJson } from "./json-input";
|
|
3
|
+
import { printProviderReceipt, runProviderAction } from "./provider-result";
|
|
4
|
+
import {
|
|
5
|
+
CliUsageError, printData, runtimeBaseUrl, runtimeRequest, takeFlag,
|
|
6
|
+
takeOptionWithSyntax, type RuntimeApiDeps,
|
|
7
|
+
} from "./runtime-api";
|
|
8
|
+
|
|
9
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
11
|
+
const prototype = Object.getPrototypeOf(value);
|
|
12
|
+
return prototype === Object.prototype || prototype === null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function editorInput(value: unknown) {
|
|
16
|
+
const parsed = parseProviderEditorConfigDTO(value);
|
|
17
|
+
if (!parsed.ok) throw new CliUsageError("Provider batch input must be an editable provider snapshot without secret, derived or unknown fields.");
|
|
18
|
+
return parsed.value;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function snapshot(wantsJson: boolean, deps: RuntimeApiDeps): Promise<number> {
|
|
22
|
+
const baseUrl = await runtimeBaseUrl(deps);
|
|
23
|
+
const fetched = await runtimeRequest("/api/config", { method: "GET", redirect: "error" }, { ...deps, baseUrl });
|
|
24
|
+
if (!record(fetched) || !record(fetched.providers)) throw new Error("Invalid provider snapshot response.");
|
|
25
|
+
const entries = Object.entries(fetched.providers).map(([name, provider]) => {
|
|
26
|
+
if (!record(provider)) throw new Error("Invalid provider snapshot response.");
|
|
27
|
+
// These are the four dashboard decorations, not editor fields. Any other
|
|
28
|
+
// unexpected field must reach the canonical parser and fail closed.
|
|
29
|
+
const { hasApiKey, hasHeaders, xaiResponsesOptInState, initialModelSelection, ...editable } = provider;
|
|
30
|
+
return [name, editable] as const;
|
|
31
|
+
});
|
|
32
|
+
const parsed = parseProviderEditorConfigDTO({
|
|
33
|
+
defaultProvider: fetched.defaultProvider,
|
|
34
|
+
providers: Object.fromEntries(entries),
|
|
35
|
+
});
|
|
36
|
+
if (!parsed.ok) throw new Error("Invalid provider snapshot response.");
|
|
37
|
+
printData(parsed.value, wantsJson);
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function apply(args: string[], wantsJson: boolean, deps: RuntimeApiDeps): Promise<number> {
|
|
42
|
+
const baselinePath = takeOptionWithSyntax(args, "--baseline")?.value;
|
|
43
|
+
const nextPath = takeOptionWithSyntax(args, "--file")?.value;
|
|
44
|
+
const confirmed = takeFlag(args, "--yes");
|
|
45
|
+
if (args.length || !baselinePath?.trim() || !nextPath?.trim()) {
|
|
46
|
+
throw new CliUsageError("Provider apply requires --baseline FILE and --file FILE, with optional --yes and --json.");
|
|
47
|
+
}
|
|
48
|
+
if (baselinePath === "-" && nextPath === "-") {
|
|
49
|
+
throw new CliUsageError("Provider apply accepts at most one stdin source.");
|
|
50
|
+
}
|
|
51
|
+
// Parse both documents as supplied: normalizing a public baseline would
|
|
52
|
+
// change the server's CAS comparison and can discard unseen private data.
|
|
53
|
+
const baseline = editorInput(await readJsonInput(baselinePath, deps, "Provider baseline"));
|
|
54
|
+
const next = editorInput(await readJsonInput(nextPath, deps, "Provider next snapshot"));
|
|
55
|
+
if (!confirmed && Object.keys(baseline.providers).some(name => !Object.hasOwn(next.providers, name))) {
|
|
56
|
+
throw new CliUsageError("Provider removals or renames require --yes. Batch apply does not perform single-provider DELETE OAuth account cleanup.");
|
|
57
|
+
}
|
|
58
|
+
const body = serializeManagementJson({ baseline, next });
|
|
59
|
+
const baseUrl = await runtimeBaseUrl(deps);
|
|
60
|
+
const result = await runtimeRequest("/api/providers", {
|
|
61
|
+
method: "PUT", redirect: "error", headers: { "content-type": "application/json" }, body,
|
|
62
|
+
}, { ...deps, baseUrl });
|
|
63
|
+
return printProviderReceipt(result, wantsJson, "Provider batch");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Public editor snapshots and one server-authoritative compare-and-swap. */
|
|
67
|
+
export async function handleProviderBatchCommand(
|
|
68
|
+
sub: "snapshot" | "apply", args: string[], deps: RuntimeApiDeps = {},
|
|
69
|
+
): Promise<number> {
|
|
70
|
+
return runProviderAction(async () => {
|
|
71
|
+
const argv = [...args];
|
|
72
|
+
const wantsJson = takeFlag(argv, "--json");
|
|
73
|
+
if (sub === "apply") return apply(argv, wantsJson, deps);
|
|
74
|
+
if (argv.length) throw new CliUsageError("Provider snapshot accepts only --json.");
|
|
75
|
+
return snapshot(wantsJson, deps);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/** Explicit live lifecycle writes; the server remains the persistence authority. */
|
|
2
|
+
import { isValidProviderName } from "../config/provider-name";
|
|
3
|
+
import { providerRelativeSendPathConfigError } from "../config/provider-relative-send-path";
|
|
4
|
+
import { modelCapabilitiesConfigError, providerBaseUrlConfigError } from "../config/provider-validation";
|
|
5
|
+
import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers-destination";
|
|
6
|
+
import { parseProviderEditorConfigDTO, providerManagementConfigError } from "../server/auth-cors";
|
|
7
|
+
import type { OcxProviderConfig } from "../types";
|
|
8
|
+
import { printProviderReceipt, runProviderAction } from "./provider-result";
|
|
9
|
+
import { serializeManagementJson } from "./json-input";
|
|
10
|
+
import {
|
|
11
|
+
CliUsageError, runtimeBaseUrl, runtimeRequest, takeFlag, takeOptionWithSyntax,
|
|
12
|
+
type RuntimeApiDeps,
|
|
13
|
+
} from "./runtime-api";
|
|
14
|
+
|
|
15
|
+
const USAGE = `Usage:
|
|
16
|
+
ocx provider add <name> --live [--adapter <id>] [--base-url <url>]
|
|
17
|
+
[--api-key <key>] [--api-key-transport <x-api-key|bearer>]
|
|
18
|
+
[--auth-mode <key|forward|oauth|local>] [--responses-path <path>]
|
|
19
|
+
[--default-model <id>] [--model <id> --text-only]
|
|
20
|
+
[--google-tool-schema-policy <compatible|reject-lossy>]
|
|
21
|
+
[--allow-private-network] [--set-default] [--force] [--json]
|
|
22
|
+
ocx provider set-default <name> --live [--json]
|
|
23
|
+
ocx provider remove <name> --live --yes [--json]
|
|
24
|
+
Add checks the target roster before writing; a concurrent change can still win the upsert race.
|
|
25
|
+
--force permits overwriting an observed provider. --sync is not supported with --live.
|
|
26
|
+
Remove uses server default reassignment, dependency checks and account/custom-model cleanup.`;
|
|
27
|
+
const AUTH_MODES = new Set(["key", "forward", "oauth", "local"]);
|
|
28
|
+
const OPTION_FIELDS = {
|
|
29
|
+
"--adapter": "adapter", "--base-url": "baseUrl", "--api-key": "apiKey",
|
|
30
|
+
"--api-key-transport": "apiKeyTransport", "--auth-mode": "authMode",
|
|
31
|
+
"--responses-path": "responsesPath", "--default-model": "defaultModel",
|
|
32
|
+
"--google-tool-schema-policy": "googleToolSchemaPolicy",
|
|
33
|
+
} as const;
|
|
34
|
+
|
|
35
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
36
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
function invalid(message: string): never { throw new CliUsageError(message, USAGE); }
|
|
39
|
+
function flag(args: string[], name: string): boolean {
|
|
40
|
+
if (args.filter(arg => arg === name).length > 1) invalid("An option was given more than once.");
|
|
41
|
+
return takeFlag(args, name);
|
|
42
|
+
}
|
|
43
|
+
function checkExplicitFields(fields: Record<string, unknown>): void {
|
|
44
|
+
if (typeof fields.baseUrl === "string" && providerBaseUrlConfigError(fields.baseUrl)) invalid("Invalid provider base URL.");
|
|
45
|
+
if (providerRelativeSendPathConfigError("responsesPath", fields.responsesPath)) invalid("Invalid responses path.");
|
|
46
|
+
if (fields.authMode !== undefined && !AUTH_MODES.has(String(fields.authMode))) invalid("Invalid auth mode.");
|
|
47
|
+
if (fields.apiKeyTransport !== undefined && !["x-api-key", "bearer"].includes(String(fields.apiKeyTransport))) invalid("Invalid API key transport.");
|
|
48
|
+
if (fields.googleToolSchemaPolicy !== undefined && !["compatible", "reject-lossy"].includes(String(fields.googleToolSchemaPolicy))) invalid("Invalid Google tool schema policy.");
|
|
49
|
+
}
|
|
50
|
+
function parseAdd(args: string[]) {
|
|
51
|
+
const force = flag(args, "--force");
|
|
52
|
+
const setDefault = flag(args, "--set-default");
|
|
53
|
+
const allowPrivateNetwork = flag(args, "--allow-private-network");
|
|
54
|
+
const textOnly = flag(args, "--text-only");
|
|
55
|
+
const model = takeOptionWithSyntax(args, "--model")?.value;
|
|
56
|
+
const fields: Record<string, unknown> = {};
|
|
57
|
+
for (const [option, field] of Object.entries(OPTION_FIELDS)) {
|
|
58
|
+
const value = takeOptionWithSyntax(args, option)?.value;
|
|
59
|
+
if (value !== undefined) {
|
|
60
|
+
if (!value.trim()) invalid("Provider options require nonempty values.");
|
|
61
|
+
fields[field] = value;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (allowPrivateNetwork) fields.allowPrivateNetwork = true;
|
|
65
|
+
if (args.length) invalid("Unexpected provider argument or option.");
|
|
66
|
+
if (model !== undefined && !textOnly) invalid("--model requires --text-only.");
|
|
67
|
+
if (model !== undefined && modelCapabilitiesConfigError({ [model]: { inputModalities: ["text"] } })) invalid("Invalid model capability selection.");
|
|
68
|
+
checkExplicitFields(fields);
|
|
69
|
+
return { force, setDefault, fields, textOnly, model };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function presetSeed(name: string, payload: unknown): Record<string, unknown> | undefined {
|
|
73
|
+
if (!record(payload) || !Array.isArray(payload.providers)
|
|
74
|
+
|| payload.providers.some(row => !record(row) || typeof row.id !== "string")) {
|
|
75
|
+
throw new Error("Invalid provider preset response.");
|
|
76
|
+
}
|
|
77
|
+
const matches = payload.providers.filter(row => row.id === name);
|
|
78
|
+
if (matches.length > 1) throw new Error("Ambiguous provider preset response.");
|
|
79
|
+
const preset = matches[0] as Record<string, unknown> | undefined;
|
|
80
|
+
if (!preset) return undefined;
|
|
81
|
+
// The target catalog's custom row is a form placeholder, not a provider seed.
|
|
82
|
+
if (name === "custom" && preset.baseUrl === "") return undefined;
|
|
83
|
+
if (name === "openai") {
|
|
84
|
+
const seed = preset.provider;
|
|
85
|
+
if (!record(seed) || typeof seed.baseUrl !== "string"
|
|
86
|
+
|| !isCanonicalOpenAiForwardProvider(seed as unknown as OcxProviderConfig)
|
|
87
|
+
|| !["pool", "direct"].includes(String(seed.codexAccountMode))
|
|
88
|
+
|| Object.hasOwn(seed, "allowPrivateNetwork")
|
|
89
|
+
|| !parseProviderEditorConfigDTO({ defaultProvider: name, providers: { [name]: seed } }).ok
|
|
90
|
+
|| providerRelativeSendPathConfigError("responsesPath", seed.responsesPath)) {
|
|
91
|
+
throw new Error("Invalid canonical provider preset.");
|
|
92
|
+
}
|
|
93
|
+
// Use the selected target's seed verbatim, never a local registry fallback.
|
|
94
|
+
return structuredClone(seed);
|
|
95
|
+
}
|
|
96
|
+
if (typeof preset.adapter !== "string" || !preset.adapter.trim()
|
|
97
|
+
|| typeof preset.baseUrl !== "string" || providerBaseUrlConfigError(preset.baseUrl)
|
|
98
|
+
|| typeof preset.auth !== "string" || !AUTH_MODES.has(preset.auth)) {
|
|
99
|
+
throw new Error("Invalid provider preset.");
|
|
100
|
+
}
|
|
101
|
+
const seed: Record<string, unknown> = { adapter: preset.adapter, baseUrl: preset.baseUrl, authMode: preset.auth };
|
|
102
|
+
for (const field of ["responsesPath", "chatCompletionsPath", "defaultModel"] as const) {
|
|
103
|
+
if (preset[field] !== undefined) {
|
|
104
|
+
if (typeof preset[field] !== "string" || !preset[field].trim()) throw new Error("Invalid provider preset.");
|
|
105
|
+
seed[field] = preset[field];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return seed;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function add(name: string, options: ReturnType<typeof parseAdd>, wantsJson: boolean, deps: RuntimeApiDeps): Promise<number> {
|
|
112
|
+
const roster = await runtimeRequest<unknown>("/api/providers", { redirect: "error" }, deps);
|
|
113
|
+
if (!Array.isArray(roster) || roster.some(row => !record(row) || typeof row.name !== "string" || !isValidProviderName(row.name))) {
|
|
114
|
+
throw new Error("Invalid provider roster response.");
|
|
115
|
+
}
|
|
116
|
+
if (!options.force && roster.some(row => row.name === name)) {
|
|
117
|
+
console.error("Provider already exists on this target. Use --force to permit overwrite.");
|
|
118
|
+
return 5;
|
|
119
|
+
}
|
|
120
|
+
const presets = await runtimeRequest("/api/provider-presets", { redirect: "error" }, deps);
|
|
121
|
+
const seed = presetSeed(name, presets);
|
|
122
|
+
if (!seed && (name === "openai" || !options.fields.adapter || !options.fields.baseUrl)) {
|
|
123
|
+
throw new Error("Target preset unavailable. Custom providers require --adapter and --base-url.");
|
|
124
|
+
}
|
|
125
|
+
const provider = { ...seed, ...options.fields };
|
|
126
|
+
if (provider.googleToolSchemaPolicy !== undefined && provider.adapter !== "google") {
|
|
127
|
+
invalid("--google-tool-schema-policy requires the google adapter.");
|
|
128
|
+
}
|
|
129
|
+
if (provider.apiKey !== undefined && provider.authMode !== undefined && provider.authMode !== "key") {
|
|
130
|
+
invalid("--api-key requires key authentication; use the provider login workflow for account authentication.");
|
|
131
|
+
}
|
|
132
|
+
if (options.textOnly) {
|
|
133
|
+
const model = options.model ?? provider.defaultModel;
|
|
134
|
+
if (typeof model !== "string") invalid("--text-only requires --model or a default model.");
|
|
135
|
+
const capabilities = { [model]: { inputModalities: ["text"] } };
|
|
136
|
+
if (modelCapabilitiesConfigError(capabilities)) invalid("Invalid model capability selection.");
|
|
137
|
+
provider.modelCapabilities = capabilities;
|
|
138
|
+
}
|
|
139
|
+
if (name !== "openai" && providerManagementConfigError(name, provider)) invalid("Invalid provider configuration.");
|
|
140
|
+
const result = await runtimeRequest("/api/providers", {
|
|
141
|
+
method: "POST", redirect: "error",
|
|
142
|
+
body: serializeManagementJson({ name, provider, ...(options.setDefault ? { setDefault: true } : {}) }),
|
|
143
|
+
}, deps);
|
|
144
|
+
return printProviderReceipt(result, wantsJson, "added");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function handleProviderLifecycleRuntimeCommand(
|
|
148
|
+
sub: "add" | "remove" | "set-default", argv: string[], deps: RuntimeApiDeps = {},
|
|
149
|
+
): Promise<number> {
|
|
150
|
+
return runProviderAction(async () => {
|
|
151
|
+
const args = [...argv];
|
|
152
|
+
const wantsJson = flag(args, "--json");
|
|
153
|
+
const name = args.shift();
|
|
154
|
+
if (!name || !isValidProviderName(name)) invalid("A valid provider name is required.");
|
|
155
|
+
const options = sub === "add" ? parseAdd(args) : undefined;
|
|
156
|
+
const yes = sub === "remove" ? flag(args, "--yes") : false;
|
|
157
|
+
if (args.length) invalid("Unexpected provider argument or option.");
|
|
158
|
+
if (sub === "remove" && !yes) invalid("Live removal requires --yes.");
|
|
159
|
+
if (name === "openai" && options && (Object.keys(options.fields).length || options.textOnly)) {
|
|
160
|
+
invalid("Canonical OpenAI uses the target preset unchanged; add flags cannot override its seed.");
|
|
161
|
+
}
|
|
162
|
+
const pinned = { ...deps, baseUrl: await runtimeBaseUrl(deps) };
|
|
163
|
+
if (options) return add(name, options, wantsJson, pinned);
|
|
164
|
+
const result = await runtimeRequest(`/api/providers?name=${encodeURIComponent(name)}`, {
|
|
165
|
+
method: sub === "remove" ? "DELETE" : "PATCH", redirect: "error",
|
|
166
|
+
...(sub === "set-default" ? { body: JSON.stringify({ setDefault: true }) } : {}),
|
|
167
|
+
}, pinned);
|
|
168
|
+
return printProviderReceipt(result, wantsJson, sub === "remove" ? "removed" : "set-default");
|
|
169
|
+
});
|
|
170
|
+
}
|