@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,84 @@
|
|
|
1
|
+
/** Bounded local audio input; never reads stdin, devices, or a caller's whole file unchecked. */
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { open, stat, type FileHandle } from "node:fs/promises";
|
|
4
|
+
import { CliUsageError, readSecretBytes } from "./runtime-api";
|
|
5
|
+
|
|
6
|
+
export const AUDIO_FILE_MAX_BYTES = 25_000_000;
|
|
7
|
+
export const AUDIO_BODY_MAX_BYTES = 32 * 1024 * 1024;
|
|
8
|
+
const FILE_TIMEOUT_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
export interface AudioInputDeps {
|
|
11
|
+
audioFileTimeoutMs?: number;
|
|
12
|
+
audioOpen?: typeof open;
|
|
13
|
+
audioStat?: typeof stat;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function audioTimeout(value: number | undefined, maximum: number): number {
|
|
17
|
+
if (value === undefined) return maximum;
|
|
18
|
+
if (!Number.isFinite(value) || value <= 0 || value > maximum) throw new CliUsageError("Invalid audio deadline");
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function withinInputDeadline<T>(operation: Promise<T>, signal: AbortSignal, late?: (value: T) => Promise<void>): Promise<T> {
|
|
23
|
+
let stopped = signal.aborted;
|
|
24
|
+
let abort = () => {};
|
|
25
|
+
const cancellation = new Promise<never>((_, reject) => {
|
|
26
|
+
abort = () => { stopped = true; reject(new Error("Audio input canceled")); };
|
|
27
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
28
|
+
if (signal.aborted) abort();
|
|
29
|
+
});
|
|
30
|
+
try {
|
|
31
|
+
return await Promise.race([operation.then(async value => {
|
|
32
|
+
if (stopped) {
|
|
33
|
+
await late?.(value);
|
|
34
|
+
throw new Error("Audio input canceled");
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
}), cancellation]);
|
|
38
|
+
} finally { signal.removeEventListener("abort", abort); }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function readAudioFile(path: string, signal: AbortSignal, deps: AudioInputDeps = {}): Promise<File> {
|
|
42
|
+
const timeoutMs = audioTimeout(deps.audioFileTimeoutMs, FILE_TIMEOUT_MS);
|
|
43
|
+
signal.throwIfAborted();
|
|
44
|
+
const deadline = new AbortController();
|
|
45
|
+
const abort = () => deadline.abort();
|
|
46
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
47
|
+
const timer = setTimeout(abort, timeoutMs);
|
|
48
|
+
let handle: FileHandle | undefined;
|
|
49
|
+
let stream: ReturnType<FileHandle["createReadStream"]> | undefined;
|
|
50
|
+
let bytes: Uint8Array<ArrayBuffer> | undefined;
|
|
51
|
+
const validate = (value: Awaited<ReturnType<FileHandle["stat"]>>) => {
|
|
52
|
+
if (!value.isFile() || value.size === 0 || value.size > AUDIO_FILE_MAX_BYTES) {
|
|
53
|
+
throw new CliUsageError("Audio input must be a nonempty regular file no larger than 25,000,000 bytes");
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
validate(await withinInputDeadline((deps.audioStat ?? stat)(path), deadline.signal));
|
|
58
|
+
handle = await withinInputDeadline(
|
|
59
|
+
(deps.audioOpen ?? open)(path, constants.O_RDONLY | constants.O_NONBLOCK), deadline.signal, file => file.close(),
|
|
60
|
+
);
|
|
61
|
+
validate(await withinInputDeadline(handle.stat(), deadline.signal));
|
|
62
|
+
deadline.signal.throwIfAborted();
|
|
63
|
+
stream = handle.createReadStream({ autoClose: false });
|
|
64
|
+
// Teardown can emit a pending I/O error after the bounded reader detaches.
|
|
65
|
+
const ignoreTeardownError = () => {};
|
|
66
|
+
stream.on("error", ignoreTeardownError);
|
|
67
|
+
stream.once("close", () => stream?.removeListener("error", ignoreTeardownError));
|
|
68
|
+
bytes = await readSecretBytes({ stdinImpl: stream, stdinTimeoutMs: timeoutMs, stdinSignal: deadline.signal }, "Audio file", AUDIO_FILE_MAX_BYTES);
|
|
69
|
+
deadline.signal.throwIfAborted();
|
|
70
|
+
// Never disclose the user's basename in the multipart body; retain only a bounded format suffix.
|
|
71
|
+
const suffix = /\.[a-z0-9]{1,8}$/i.exec(path)?.[0] ?? ".webm";
|
|
72
|
+
return new File([bytes], `audio${suffix}`, { type: "application/octet-stream" });
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (deadline.signal.aborted) throw new Error("Audio input deadline or cancellation");
|
|
75
|
+
if (error instanceof CliUsageError) throw new CliUsageError("Audio input must be a nonempty regular file no larger than 25,000,000 bytes");
|
|
76
|
+
throw new Error("Unable to read audio input");
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
signal.removeEventListener("abort", abort);
|
|
80
|
+
stream?.destroy();
|
|
81
|
+
bytes?.fill(0);
|
|
82
|
+
await handle?.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** One readiness observation over the existing audio key subprotocol, with bounded local teardown. */
|
|
2
|
+
import { audioTimeout } from "./access-audio-input";
|
|
3
|
+
|
|
4
|
+
const READY_TIMEOUT_MS = 15_000;
|
|
5
|
+
const CLOSE_TIMEOUT_MS = 2_000;
|
|
6
|
+
const FRAME_MAX_BYTES = 64 * 1024;
|
|
7
|
+
const SESSION_MAX_BYTES = 2 * 1024 * 1024;
|
|
8
|
+
const SESSION_UPDATE = {
|
|
9
|
+
type: "session.update",
|
|
10
|
+
session: { instructions: "", audio: { output: { voice: "cove" } }, delegation: { type: "client" } },
|
|
11
|
+
};
|
|
12
|
+
export type AudioClientSocket = WebSocket & { terminate(): void };
|
|
13
|
+
export interface AudioLiveDeps {
|
|
14
|
+
audioReadyTimeoutMs?: number;
|
|
15
|
+
audioCloseTimeoutMs?: number;
|
|
16
|
+
audioSocket?: (url: string, protocols: string[]) => AudioClientSocket;
|
|
17
|
+
}
|
|
18
|
+
export interface AudioLiveReport {
|
|
19
|
+
schemaVersion: 1;
|
|
20
|
+
ready: boolean;
|
|
21
|
+
close: "confirmed" | "unverified";
|
|
22
|
+
check: "session-readiness";
|
|
23
|
+
event?: "session.started" | "session.updated";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function checkAudioLive(origin: string, model: string, key: string, signal: AbortSignal, deps: AudioLiveDeps = {}): Promise<{ report: AudioLiveReport; ok: boolean }> {
|
|
27
|
+
const readyMs = audioTimeout(deps.audioReadyTimeoutMs, READY_TIMEOUT_MS);
|
|
28
|
+
const closeMs = audioTimeout(deps.audioCloseTimeoutMs, CLOSE_TIMEOUT_MS);
|
|
29
|
+
signal.throwIfAborted();
|
|
30
|
+
const url = new URL("/v1/live", origin);
|
|
31
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
32
|
+
url.searchParams.set("model", model);
|
|
33
|
+
const protocols = ["opencodex-audio", `opencodex-key.${Buffer.from(key, "utf8").toString("base64url")}`];
|
|
34
|
+
const report: AudioLiveReport = { schemaVersion: 1, ready: false, close: "unverified", check: "session-readiness" };
|
|
35
|
+
const started = performance.now();
|
|
36
|
+
// Native Bun rejects handshake redirects. Real two-endpoint regressions cover HTTP and WS Location forms.
|
|
37
|
+
// The CLI runs on Bun; its native WebSocket adds terminate beyond the DOM type.
|
|
38
|
+
const socket = (deps.audioSocket ?? ((address, carrier) => new WebSocket(address, carrier) as AudioClientSocket))(url.href, protocols);
|
|
39
|
+
return await new Promise(resolve => {
|
|
40
|
+
let finished = false;
|
|
41
|
+
let closing = false;
|
|
42
|
+
let failed = false;
|
|
43
|
+
let total = 0;
|
|
44
|
+
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
45
|
+
const finish = (confirmed: boolean) => {
|
|
46
|
+
if (finished) return;
|
|
47
|
+
finished = true;
|
|
48
|
+
report.close = confirmed ? "confirmed" : "unverified";
|
|
49
|
+
clearTimeout(readyTimer);
|
|
50
|
+
clearTimeout(closeTimer);
|
|
51
|
+
signal.removeEventListener("abort", onAbort);
|
|
52
|
+
socket.onopen = socket.onmessage = socket.onerror = socket.onclose = null;
|
|
53
|
+
if (socket.readyState !== WebSocket.CLOSED) socket.terminate();
|
|
54
|
+
resolve({ report, ok: !failed && report.ready && confirmed });
|
|
55
|
+
};
|
|
56
|
+
const close = () => {
|
|
57
|
+
if (closing || finished) return;
|
|
58
|
+
closing = true;
|
|
59
|
+
clearTimeout(readyTimer);
|
|
60
|
+
closeTimer = setTimeout(() => finish(false), closeMs);
|
|
61
|
+
try {
|
|
62
|
+
if (socket.readyState === WebSocket.OPEN) socket.send('{"type":"session.close"}');
|
|
63
|
+
socket.close(1000);
|
|
64
|
+
} catch { failed = true; finish(false); }
|
|
65
|
+
};
|
|
66
|
+
const fail = () => { failed = true; close(); };
|
|
67
|
+
const onAbort = () => { failed = true; finish(false); };
|
|
68
|
+
const readyTimer = setTimeout(fail, Math.max(0, readyMs - (performance.now() - started)));
|
|
69
|
+
socket.onopen = () => {
|
|
70
|
+
if (closing || finished) return;
|
|
71
|
+
try { socket.send(JSON.stringify(SESSION_UPDATE)); }
|
|
72
|
+
catch { fail(); }
|
|
73
|
+
};
|
|
74
|
+
socket.onmessage = event => {
|
|
75
|
+
if (finished) return;
|
|
76
|
+
if (typeof event.data !== "string") { fail(); return; }
|
|
77
|
+
const size = Buffer.byteLength(event.data, "utf8");
|
|
78
|
+
total += size;
|
|
79
|
+
if (size > FRAME_MAX_BYTES || total > SESSION_MAX_BYTES) { fail(); return; }
|
|
80
|
+
let message: unknown;
|
|
81
|
+
try { message = JSON.parse(event.data); } catch { fail(); return; }
|
|
82
|
+
if (!message || typeof message !== "object" || Array.isArray(message) || !("type" in message) || typeof message.type !== "string") { fail(); return; }
|
|
83
|
+
if (message.type === "error" || message.type === "protocol.error") { fail(); return; }
|
|
84
|
+
if (message.type !== "session.started" && message.type !== "session.updated") return;
|
|
85
|
+
const session = "session" in message ? message.session : undefined;
|
|
86
|
+
if (!session || typeof session !== "object" || Array.isArray(session)) return;
|
|
87
|
+
if ("status" in session && (session.status === "error" || session.status === "failed" || (!report.ready && session.status === "closed"))) { fail(); return; }
|
|
88
|
+
if (!closing && "id" in session && typeof session.id === "string" && session.id.trim()) {
|
|
89
|
+
report.ready = true;
|
|
90
|
+
report.event = message.type;
|
|
91
|
+
close();
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
socket.onerror = fail;
|
|
95
|
+
socket.onclose = event => finish(event.code === 1000);
|
|
96
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
97
|
+
if (signal.aborted) onAbort();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Explicit-key audio consumers. No management headers, model controls, or credential fallback. */
|
|
2
|
+
import { signalWithTimeout } from "../lib/abort";
|
|
3
|
+
import { CliUsageError, printData, takeFlag, takeOptionWithSyntax } from "./runtime-api";
|
|
4
|
+
import {
|
|
5
|
+
readBoundedDataJson, redactSelectedKey, withDataCommandSignals, withSelectedDataKey,
|
|
6
|
+
type DataClientDeps, type SelectedDataTarget,
|
|
7
|
+
} from "./access-data-client";
|
|
8
|
+
import { AUDIO_BODY_MAX_BYTES, readAudioFile, audioTimeout, type AudioInputDeps } from "./access-audio-input";
|
|
9
|
+
import { checkAudioLive, type AudioLiveDeps, type AudioLiveReport } from "./access-audio-live";
|
|
10
|
+
|
|
11
|
+
const HTTP_TIMEOUT_MS = 130_000;
|
|
12
|
+
const RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
|
|
13
|
+
const MODEL_MAX_BYTES = 16 * 1024;
|
|
14
|
+
const TRANSCRIPTION_MODELS = new Set(["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"]);
|
|
15
|
+
const USAGE = "Usage: ocx access audio transcribe FILE --model ID --api-key-stdin [--json]\n ocx access audio live-check --model ID --api-key-stdin [--json]";
|
|
16
|
+
export interface AccessAudioDeps extends DataClientDeps, AudioInputDeps, AudioLiveDeps {
|
|
17
|
+
audioHttpTimeoutMs?: number;
|
|
18
|
+
}
|
|
19
|
+
interface AudioArgs { action: "transcribe" | "live-check"; model: string; json: boolean; file?: string }
|
|
20
|
+
|
|
21
|
+
function parseArgs(argv: string[]): AudioArgs {
|
|
22
|
+
const args = [...argv];
|
|
23
|
+
const action = args.shift();
|
|
24
|
+
const json = takeFlag(args, "--json");
|
|
25
|
+
const stdin = takeFlag(args, "--api-key-stdin");
|
|
26
|
+
const model = takeOptionWithSyntax(args, "--model")?.value;
|
|
27
|
+
if (!stdin || !model || model.trim() !== model || !model.trim() || Buffer.byteLength(model) > MODEL_MAX_BYTES
|
|
28
|
+
|| /[\x00-\x1f\x7f-\x9f]/.test(model)) throw new CliUsageError("Invalid audio arguments");
|
|
29
|
+
if (action === "transcribe" && TRANSCRIPTION_MODELS.has(model) && args.length === 1 && args[0] && !args[0].startsWith("-")) {
|
|
30
|
+
return { action, model, json, file: args[0] };
|
|
31
|
+
}
|
|
32
|
+
if (action === "live-check" && args.length === 0) return { action, model, json };
|
|
33
|
+
throw new CliUsageError("Invalid audio arguments");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function transcribe(file: File, model: string, context: { target: SelectedDataTarget; key: string }, signal: AbortSignal, deps: AccessAudioDeps): Promise<{ text: string }> {
|
|
37
|
+
// Each bounded ASCII multipart header/boundary plus the fixed field names fits
|
|
38
|
+
// in 4 KiB. File/model/filename are independently bounded before serialization.
|
|
39
|
+
if (file.size + Buffer.byteLength(model) + Buffer.byteLength(file.name) + 4096 > AUDIO_BODY_MAX_BYTES) throw new Error("Audio multipart limit");
|
|
40
|
+
const form = new FormData();
|
|
41
|
+
form.set("file", file);
|
|
42
|
+
form.set("model", model);
|
|
43
|
+
form.set("response_format", "json");
|
|
44
|
+
await context.target.assertCurrent();
|
|
45
|
+
signal.throwIfAborted();
|
|
46
|
+
const deadline = signalWithTimeout(audioTimeout(deps.audioHttpTimeoutMs, HTTP_TIMEOUT_MS), signal);
|
|
47
|
+
let response: Response | undefined;
|
|
48
|
+
try {
|
|
49
|
+
response = await (deps.fetchImpl ?? fetch)(new URL("/v1/audio/transcriptions", context.target.origin), {
|
|
50
|
+
method: "POST", body: form, headers: { "x-opencodex-api-key": context.key },
|
|
51
|
+
credentials: "omit", redirect: "error", signal: deadline.signal,
|
|
52
|
+
});
|
|
53
|
+
deadline.signal.throwIfAborted();
|
|
54
|
+
if (!response.ok) throw new Error("Audio request refused");
|
|
55
|
+
const result = await readBoundedDataJson(response, { maxBytes: RESPONSE_MAX_BYTES, signal: deadline.signal });
|
|
56
|
+
deadline.signal.throwIfAborted();
|
|
57
|
+
if (!result || typeof result !== "object" || Array.isArray(result) || !("text" in result) || typeof result.text !== "string") throw new Error("Unsupported audio response");
|
|
58
|
+
await context.target.assertCurrent();
|
|
59
|
+
deadline.signal.throwIfAborted();
|
|
60
|
+
return { text: redactSelectedKey(result.text, context.key) };
|
|
61
|
+
} finally {
|
|
62
|
+
// Cancellation itself may never settle; it must not retain the key or deadline.
|
|
63
|
+
try {
|
|
64
|
+
if (response?.body && !response.body.locked) void response.body.cancel().catch(() => {});
|
|
65
|
+
} catch { /* best-effort body teardown must preserve the original outcome */ }
|
|
66
|
+
finally { deadline.cleanup(); }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function showLive(report: AudioLiveReport, json: boolean): void {
|
|
71
|
+
printData(report, json, [
|
|
72
|
+
report.ready ? "Session readiness observed." : "Session readiness was not observed.",
|
|
73
|
+
report.close === "confirmed" ? "Normal socket closure confirmed." : "Socket closure is unverified.",
|
|
74
|
+
"This checks session readiness only; it does not test a voice roundtrip.",
|
|
75
|
+
]);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function handleAccessAudioCommand(argv: string[], deps: AccessAudioDeps = {}): Promise<number> {
|
|
79
|
+
let options: AudioArgs;
|
|
80
|
+
try {
|
|
81
|
+
options = parseArgs(argv);
|
|
82
|
+
audioTimeout(deps.audioFileTimeoutMs, 30_000);
|
|
83
|
+
audioTimeout(deps.audioHttpTimeoutMs, HTTP_TIMEOUT_MS);
|
|
84
|
+
audioTimeout(deps.audioReadyTimeoutMs, 15_000);
|
|
85
|
+
audioTimeout(deps.audioCloseTimeoutMs, 2_000);
|
|
86
|
+
} catch {
|
|
87
|
+
console.error("Invalid audio arguments. Use a supported model and pipe the selected key on stdin.");
|
|
88
|
+
console.error(USAGE);
|
|
89
|
+
return 2;
|
|
90
|
+
}
|
|
91
|
+
return await withDataCommandSignals(deps, async signal => {
|
|
92
|
+
let liveReport: AudioLiveReport = { schemaVersion: 1, ready: false, close: "unverified", check: "session-readiness" };
|
|
93
|
+
try {
|
|
94
|
+
const file = options.action === "transcribe" ? await readAudioFile(options.file!, signal, deps) : undefined;
|
|
95
|
+
return await withSelectedDataKey(deps, signal, async context => {
|
|
96
|
+
if (file) {
|
|
97
|
+
const result = await transcribe(file, options.model, context, signal, deps);
|
|
98
|
+
signal.throwIfAborted();
|
|
99
|
+
printData(result, options.json, [result.text]);
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
await context.target.assertCurrent();
|
|
103
|
+
signal.throwIfAborted();
|
|
104
|
+
const result = await checkAudioLive(context.target.origin, options.model, context.key, signal, deps);
|
|
105
|
+
liveReport = result.report;
|
|
106
|
+
await context.target.assertCurrent();
|
|
107
|
+
signal.throwIfAborted();
|
|
108
|
+
showLive(liveReport, options.json);
|
|
109
|
+
if (!result.ok) console.error("Audio readiness or normal closure could not be confirmed. Check the selected target and audio model before retrying.");
|
|
110
|
+
return result.ok ? 0 : 1;
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
if (signal.aborted) throw error;
|
|
114
|
+
if (error instanceof CliUsageError) {
|
|
115
|
+
console.error("Invalid audio file or key input. Use a nonempty regular audio file and pipe a printable ASCII key without extra lines.");
|
|
116
|
+
return 2;
|
|
117
|
+
}
|
|
118
|
+
if (options.action === "live-check") showLive(liveReport, options.json);
|
|
119
|
+
console.error("Audio operation failed. Check the selected target, key, model, file limits and connection before retrying.");
|
|
120
|
+
return 1;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** Explicit data credentials never use management headers or the enrolled secret. */
|
|
2
|
+
import { readClientConnectionState, assertClientConnectionUnchanged } from "../client/state";
|
|
3
|
+
import { normalizeHubOrigin } from "../client/hub-client";
|
|
4
|
+
import { findLiveProxy, probeHostname, type LiveProxy } from "../server/proxy-liveness";
|
|
5
|
+
import { readBoundedResponseBytes } from "../lib/bounded-body";
|
|
6
|
+
import { CliUsageError, readSecretBytes, type RuntimeApiDeps } from "./runtime-api";
|
|
7
|
+
|
|
8
|
+
export interface SelectedDataTarget {
|
|
9
|
+
origin: string;
|
|
10
|
+
assertCurrent(): Promise<void>;
|
|
11
|
+
}
|
|
12
|
+
export interface DataClientDeps extends RuntimeApiDeps {
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
readClientConnectionState?: typeof readClientConnectionState;
|
|
15
|
+
assertClientConnectionUnchanged?: typeof assertClientConnectionUnchanged;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function selectTarget(deps: DataClientDeps, signal: AbortSignal): Promise<SelectedDataTarget> {
|
|
19
|
+
signal.throwIfAborted();
|
|
20
|
+
const readState = deps.readClientConnectionState ?? readClientConnectionState;
|
|
21
|
+
const state = readState();
|
|
22
|
+
if (state.kind === "connected") {
|
|
23
|
+
const expected = structuredClone(state.value);
|
|
24
|
+
const origin = normalizeHubOrigin(expected.serverUrl);
|
|
25
|
+
const check = deps.assertClientConnectionUnchanged ?? assertClientConnectionUnchanged;
|
|
26
|
+
const assertCurrent = async () => { signal.throwIfAborted(); check(expected); };
|
|
27
|
+
await assertCurrent();
|
|
28
|
+
return { origin, assertCurrent };
|
|
29
|
+
}
|
|
30
|
+
if (state.kind !== "disconnected") throw new Error("Selected target is unavailable.");
|
|
31
|
+
const discover = deps.findLiveProxy ?? findLiveProxy;
|
|
32
|
+
const live = await discover();
|
|
33
|
+
signal.throwIfAborted();
|
|
34
|
+
if (!live || live.role === "client") throw new Error("Selected target is unavailable.");
|
|
35
|
+
const originOf = (value: LiveProxy) => normalizeHubOrigin(`http://${probeHostname(value.hostname)}:${value.port}`);
|
|
36
|
+
const origin = originOf(live);
|
|
37
|
+
const assertCurrent = async () => {
|
|
38
|
+
signal.throwIfAborted();
|
|
39
|
+
if (readState().kind !== "disconnected") throw new Error("Selected target changed.");
|
|
40
|
+
const current = await discover();
|
|
41
|
+
signal.throwIfAborted();
|
|
42
|
+
if (!current || current.pid !== live.pid || current.port !== live.port || current.role !== live.role
|
|
43
|
+
|| originOf(current) !== origin || readState().kind !== "disconnected") {
|
|
44
|
+
throw new Error("Selected target changed.");
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
await assertCurrent();
|
|
48
|
+
return { origin, assertCurrent };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function withSelectedDataKey<T>(
|
|
52
|
+
deps: DataClientDeps,
|
|
53
|
+
signal: AbortSignal,
|
|
54
|
+
operation: (context: { target: SelectedDataTarget; key: string }) => Promise<T>,
|
|
55
|
+
): Promise<T> {
|
|
56
|
+
let bytes: Uint8Array | undefined;
|
|
57
|
+
const input = deps.stdinImpl ?? process.stdin;
|
|
58
|
+
try {
|
|
59
|
+
const target = await selectTarget(deps, signal);
|
|
60
|
+
if (input.isTTY) throw new CliUsageError("Pipe the selected API key through stdin.");
|
|
61
|
+
const timeout = deps.stdinTimeoutMs ?? 30_000;
|
|
62
|
+
if (!Number.isFinite(timeout) || timeout <= 0 || timeout > 30_000) throw new Error("Invalid input deadline.");
|
|
63
|
+
try {
|
|
64
|
+
bytes = await readSecretBytes({ ...deps, stdinTimeoutMs: timeout, stdinSignal: signal }, "API key", 4096);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error instanceof CliUsageError && error.message.startsWith("timed out")) {
|
|
67
|
+
throw new Error("API key input timed out.");
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
let key: string;
|
|
72
|
+
try { key = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
|
|
73
|
+
catch { throw new CliUsageError("API key input must be valid UTF-8."); }
|
|
74
|
+
key = key.replace(/\r?\n$/, "");
|
|
75
|
+
if (!key || key.trim() !== key || !/^[\x20-\x7e]+$/.test(key)) {
|
|
76
|
+
throw new CliUsageError("API key must contain printable ASCII without outer whitespace or extra lines.");
|
|
77
|
+
}
|
|
78
|
+
await target.assertCurrent();
|
|
79
|
+
signal.throwIfAborted();
|
|
80
|
+
return await operation({ target, key });
|
|
81
|
+
} finally {
|
|
82
|
+
bytes?.fill(0);
|
|
83
|
+
input.pause();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function readBoundedDataJson(
|
|
88
|
+
response: Response,
|
|
89
|
+
options: { maxBytes: number; signal: AbortSignal },
|
|
90
|
+
): Promise<unknown> {
|
|
91
|
+
const result = await readBoundedResponseBytes(response, options);
|
|
92
|
+
try {
|
|
93
|
+
if (result.oversized) throw new Error("Response exceeded its limit.");
|
|
94
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(result.bytes));
|
|
95
|
+
} finally { result.bytes.fill(0); }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function redactSelectedKey(text: string, key: string): string {
|
|
99
|
+
return key ? text.split(key).join("[redacted]") : text;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One command owns signals; consumers check abortion before every output/effect. */
|
|
103
|
+
export async function withDataCommandSignals(
|
|
104
|
+
deps: DataClientDeps,
|
|
105
|
+
run: (signal: AbortSignal) => Promise<number>,
|
|
106
|
+
): Promise<number> {
|
|
107
|
+
const controller = new AbortController();
|
|
108
|
+
let exit = 130;
|
|
109
|
+
const interrupt = () => { if (!controller.signal.aborted) { exit = 130; controller.abort(); } };
|
|
110
|
+
const terminate = () => { if (!controller.signal.aborted) { exit = 143; controller.abort(); } };
|
|
111
|
+
process.on("SIGINT", interrupt);
|
|
112
|
+
process.on("SIGTERM", terminate);
|
|
113
|
+
deps.signal?.addEventListener("abort", interrupt, { once: true });
|
|
114
|
+
if (deps.signal?.aborted) interrupt();
|
|
115
|
+
try {
|
|
116
|
+
controller.signal.throwIfAborted();
|
|
117
|
+
const result = await run(controller.signal);
|
|
118
|
+
return controller.signal.aborted ? exit : result;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (controller.signal.aborted) return exit;
|
|
121
|
+
throw error;
|
|
122
|
+
} finally {
|
|
123
|
+
process.removeListener("SIGINT", interrupt);
|
|
124
|
+
process.removeListener("SIGTERM", terminate);
|
|
125
|
+
deps.signal?.removeEventListener("abort", interrupt);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { signalWithTimeout } from "../lib/abort";
|
|
2
|
+
import { CliUsageError, printData, takeFlag, takeOptionWithSyntax } from "./runtime-api";
|
|
3
|
+
import { withSelectedDataKey, withDataCommandSignals, readBoundedDataJson, type DataClientDeps, type SelectedDataTarget } from "./access-data-client";
|
|
4
|
+
import { projectModelResponse, type DataProtocol, type SafeModelResponse } from "./access-data-response";
|
|
5
|
+
|
|
6
|
+
export interface SelectedKeyTestDeps extends DataClientDeps {
|
|
7
|
+
controlTimeoutMs?: number;
|
|
8
|
+
requestTimeoutMs?: number;
|
|
9
|
+
}
|
|
10
|
+
interface ProbeReport {
|
|
11
|
+
schemaVersion: 1;
|
|
12
|
+
control: { outcome: "not_run" | "credential_required" | "unavailable"; status?: number };
|
|
13
|
+
request: { outcome: "not_run" | "succeeded" | "failed" | "unsupported_response"; status?: number };
|
|
14
|
+
response?: SafeModelResponse;
|
|
15
|
+
}
|
|
16
|
+
const USAGE = "Usage: ocx access test <model> [--protocol chat|responses|messages] --api-key-stdin [--json]";
|
|
17
|
+
function parse(argv: string[]) {
|
|
18
|
+
const args = [...argv];
|
|
19
|
+
try {
|
|
20
|
+
const json = takeFlag(args, "--json");
|
|
21
|
+
const stdin = takeFlag(args, "--api-key-stdin");
|
|
22
|
+
const protocol = takeOptionWithSyntax(args, "--protocol")?.value ?? "chat";
|
|
23
|
+
const model = args.shift();
|
|
24
|
+
if (!stdin || args.length || !model || model.startsWith("-") || model.trim() !== model
|
|
25
|
+
|| model.length > 16_384 || /[\x00-\x1f\x7f]/.test(model)
|
|
26
|
+
|| (protocol !== "chat" && protocol !== "responses" && protocol !== "messages")) throw new Error();
|
|
27
|
+
return { json, model, protocol } as { json: boolean; model: string; protocol: DataProtocol };
|
|
28
|
+
} catch { throw new CliUsageError("Invalid selected-key test arguments.", USAGE); }
|
|
29
|
+
}
|
|
30
|
+
function nativeRefusal(raw: unknown, protocol: DataProtocol): boolean {
|
|
31
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return false;
|
|
32
|
+
const top = raw as Record<string, unknown>;
|
|
33
|
+
if (!top.error || typeof top.error !== "object" || Array.isArray(top.error)) return false;
|
|
34
|
+
const error = top.error as Record<string, unknown>;
|
|
35
|
+
if (error.message !== "opencodex API key required" || error.type !== "authentication_error") return false;
|
|
36
|
+
return (top.type === undefined && error.code === "invalid_api_key")
|
|
37
|
+
|| (protocol === "messages" && top.type === "error" && error.code === undefined);
|
|
38
|
+
}
|
|
39
|
+
function timeout(value: number | undefined, ceiling: number): number {
|
|
40
|
+
if (value === undefined) return ceiling;
|
|
41
|
+
if (!Number.isFinite(value) || value <= 0 || value > ceiling) throw new Error("Invalid request deadline.");
|
|
42
|
+
return value;
|
|
43
|
+
}
|
|
44
|
+
async function requestJson(
|
|
45
|
+
target: SelectedDataTarget, path: string, body: string, key: string | undefined,
|
|
46
|
+
deps: SelectedKeyTestDeps, signal: AbortSignal, report: { status?: number },
|
|
47
|
+
): Promise<unknown> {
|
|
48
|
+
await target.assertCurrent();
|
|
49
|
+
signal.throwIfAborted();
|
|
50
|
+
const deadline = signalWithTimeout(timeout(key === undefined ? deps.controlTimeoutMs : deps.requestTimeoutMs, key === undefined ? 5000 : 60_000), signal);
|
|
51
|
+
try {
|
|
52
|
+
const headers = new Headers({ "content-type": "application/json", accept: "application/json" });
|
|
53
|
+
if (key !== undefined) headers.set("x-opencodex-api-key", key);
|
|
54
|
+
const response = await (deps.fetchImpl ?? fetch)(`${target.origin}${path}`, {
|
|
55
|
+
method: "POST", body, headers, credentials: "omit", redirect: "error", signal: deadline.signal,
|
|
56
|
+
});
|
|
57
|
+
report.status = response.status;
|
|
58
|
+
const result = await readBoundedDataJson(response, { maxBytes: key === undefined ? 4096 : 2 * 1024 * 1024, signal: deadline.signal });
|
|
59
|
+
deadline.signal.throwIfAborted();
|
|
60
|
+
await target.assertCurrent();
|
|
61
|
+
signal.throwIfAborted();
|
|
62
|
+
return result;
|
|
63
|
+
} finally { deadline.cleanup(); }
|
|
64
|
+
}
|
|
65
|
+
export async function handleSelectedKeyTest(argv: string[], deps: SelectedKeyTestDeps = {}): Promise<number> {
|
|
66
|
+
let options: ReturnType<typeof parse>;
|
|
67
|
+
try { options = parse(argv); }
|
|
68
|
+
catch { console.error(`Error: Invalid selected-key test arguments.\n${USAGE}`); return 2; }
|
|
69
|
+
const report: ProbeReport = { schemaVersion: 1, control: { outcome: "not_run" }, request: { outcome: "not_run" } };
|
|
70
|
+
return withDataCommandSignals(deps, async signal => {
|
|
71
|
+
try {
|
|
72
|
+
await withSelectedDataKey(deps, signal, async ({ target, key }) => {
|
|
73
|
+
const path = options.protocol === "responses" ? "/v1/responses" : options.protocol === "chat" ? "/v1/chat/completions" : "/v1/messages";
|
|
74
|
+
report.control.outcome = "unavailable";
|
|
75
|
+
const control = await requestJson(target, path, "{", undefined, deps, signal, report.control);
|
|
76
|
+
if (report.control.status !== 401 || !nativeRefusal(control, options.protocol)) throw new Error();
|
|
77
|
+
report.control.outcome = "credential_required";
|
|
78
|
+
const body = options.protocol === "responses"
|
|
79
|
+
? { model: options.model, input: "Reply with OK.", max_output_tokens: 16 }
|
|
80
|
+
: { model: options.model, messages: [{ role: "user", content: "Reply with OK." }], max_tokens: 16,
|
|
81
|
+
...(options.protocol === "chat" ? { stream: false } : {}) };
|
|
82
|
+
report.request.outcome = "failed";
|
|
83
|
+
const raw = await requestJson(target, path, JSON.stringify(body), key, deps, signal, report.request);
|
|
84
|
+
if (!report.request.status || report.request.status < 200 || report.request.status >= 300) throw new Error();
|
|
85
|
+
report.request.outcome = "unsupported_response";
|
|
86
|
+
report.response = projectModelResponse(raw, options.protocol, key);
|
|
87
|
+
report.request.outcome = "succeeded";
|
|
88
|
+
});
|
|
89
|
+
signal.throwIfAborted();
|
|
90
|
+
printData(report, options.json, [
|
|
91
|
+
"Credentialless request was refused; the model request using the supplied key succeeded.",
|
|
92
|
+
...(report.response?.completion === "limited" ? ["Completion was limited by the model response."] : []),
|
|
93
|
+
...report.response!.text,
|
|
94
|
+
]);
|
|
95
|
+
return 0;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (signal.aborted) throw error;
|
|
98
|
+
if (error instanceof CliUsageError) { console.error("Error: Invalid API key input. Pipe one printable ASCII key through stdin."); return 2; }
|
|
99
|
+
if (options.json) printData(report, true);
|
|
100
|
+
console.error("Error: Selected-key test did not complete. Check the target, key and supported protocol before retrying.");
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/** Whitelist native text completions; never serialize arbitrary provider payloads. */
|
|
2
|
+
import { redactSelectedKey } from "./access-data-client";
|
|
3
|
+
export type DataProtocol = "chat" | "responses" | "messages";
|
|
4
|
+
export interface SafeModelResponse {
|
|
5
|
+
protocol: DataProtocol;
|
|
6
|
+
text: string[];
|
|
7
|
+
completion: "complete" | "limited";
|
|
8
|
+
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number };
|
|
9
|
+
}
|
|
10
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
11
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
12
|
+
}
|
|
13
|
+
function usageOf(raw: unknown, protocol: DataProtocol): SafeModelResponse["usage"] {
|
|
14
|
+
if (raw === undefined || raw === null) return undefined;
|
|
15
|
+
const usage = record(raw);
|
|
16
|
+
if (!usage) throw new Error("Invalid usage.");
|
|
17
|
+
const result: NonNullable<SafeModelResponse["usage"]> = {};
|
|
18
|
+
const fields = protocol === "chat"
|
|
19
|
+
? { inputTokens: "prompt_tokens", outputTokens: "completion_tokens", totalTokens: "total_tokens" } as const
|
|
20
|
+
: { inputTokens: "input_tokens", outputTokens: "output_tokens", totalTokens: "total_tokens" } as const;
|
|
21
|
+
for (const name of ["inputTokens", "outputTokens", "totalTokens"] as const) {
|
|
22
|
+
const value = usage[fields[name]];
|
|
23
|
+
if (value === undefined) continue;
|
|
24
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new Error("Invalid usage.");
|
|
25
|
+
result[name] = value;
|
|
26
|
+
}
|
|
27
|
+
return Object.keys(result).length ? result : undefined;
|
|
28
|
+
}
|
|
29
|
+
export function projectModelResponse(raw: unknown, protocol: DataProtocol, key: string): SafeModelResponse {
|
|
30
|
+
const row = record(raw);
|
|
31
|
+
if (!row || (row.error !== undefined && row.error !== null)) throw new Error("Unsupported response.");
|
|
32
|
+
const text: string[] = [];
|
|
33
|
+
let limited = false;
|
|
34
|
+
if (protocol === "responses") {
|
|
35
|
+
if (row.object !== "response" || (row.status !== "completed" && row.status !== "incomplete") || !Array.isArray(row.output)) throw new Error("Unsupported response.");
|
|
36
|
+
if (record(row.incomplete_details)?.reason === "content_filter") throw new Error("Unsupported response.");
|
|
37
|
+
limited = row.status === "incomplete";
|
|
38
|
+
for (const value of row.output) {
|
|
39
|
+
const item = record(value);
|
|
40
|
+
if (!item || item.type !== "message" || item.role !== "assistant" || !Array.isArray(item.content)) {
|
|
41
|
+
// Reasoning/tool entries are not text evidence and are never exposed.
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
for (const blockValue of item.content) {
|
|
45
|
+
const block = record(blockValue);
|
|
46
|
+
if (block?.type !== "output_text" || typeof block.text !== "string") throw new Error("Unsupported response.");
|
|
47
|
+
text.push(block.text);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} else if (protocol === "chat") {
|
|
51
|
+
if (row.object !== "chat.completion" || !Array.isArray(row.choices) || row.choices.length === 0) throw new Error("Unsupported response.");
|
|
52
|
+
for (const value of row.choices) {
|
|
53
|
+
const choice = record(value);
|
|
54
|
+
const message = record(choice?.message);
|
|
55
|
+
if (!choice || (choice.finish_reason !== "stop" && choice.finish_reason !== "length")
|
|
56
|
+
|| !message || message.role !== "assistant" || typeof message.content !== "string"
|
|
57
|
+
|| (message.refusal !== undefined && message.refusal !== null)) throw new Error("Unsupported response.");
|
|
58
|
+
limited ||= choice.finish_reason === "length";
|
|
59
|
+
text.push(message.content);
|
|
60
|
+
}
|
|
61
|
+
} else {
|
|
62
|
+
if (row.type !== "message" || row.role !== "assistant" || !Array.isArray(row.content)
|
|
63
|
+
|| (row.stop_reason !== "end_turn" && row.stop_reason !== "stop_sequence" && row.stop_reason !== "max_tokens")) {
|
|
64
|
+
throw new Error("Unsupported response.");
|
|
65
|
+
}
|
|
66
|
+
limited = row.stop_reason === "max_tokens";
|
|
67
|
+
for (const value of row.content) {
|
|
68
|
+
const block = record(value);
|
|
69
|
+
if (block?.type === "text" && typeof block.text === "string") text.push(block.text);
|
|
70
|
+
else if (block?.type !== "thinking" && block?.type !== "redacted_thinking") throw new Error("Unsupported response.");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (!text.length) throw new Error("Unsupported response.");
|
|
74
|
+
const usage = usageOf(row.usage, protocol);
|
|
75
|
+
return { protocol, text: text.map(value => redactSelectedKey(value, key)), completion: limited ? "limited" : "complete", ...(usage ? { usage } : {}) };
|
|
76
|
+
}
|