@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913
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/gui/dist/assets/index-BBOZWGB6.css +1 -0
- package/gui/dist/assets/index-D7ynYo2K.js +128 -0
- package/gui/dist/index.html +2 -2
- package/native/remote-workspace-helper/Cargo.lock +130 -0
- package/native/remote-workspace-helper/Cargo.toml +24 -0
- package/native/remote-workspace-helper/src/main.rs +49 -0
- package/native/remote-workspace-helper/src/protocol.rs +246 -0
- package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
- package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
- package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
- package/package.json +6 -1
- package/src/adapters/anthropic-image-normalize.ts +30 -2
- package/src/adapters/anthropic.ts +1 -1
- package/src/adapters/base.ts +8 -2
- package/src/adapters/cursor/cursor-errors.ts +12 -0
- package/src/adapters/cursor/thread-continuity.ts +93 -0
- package/src/adapters/cursor.ts +104 -73
- package/src/adapters/devin/cloud-direct/chat.ts +312 -23
- package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
- package/src/adapters/devin/live-models.ts +70 -3
- package/src/adapters/devin.ts +281 -21
- package/src/adapters/google-wire-compiler.ts +14 -6
- package/src/adapters/google.ts +22 -8
- package/src/adapters/kiro/adapter.ts +316 -0
- package/src/adapters/kiro/conversation.ts +136 -0
- package/src/adapters/kiro/payload.ts +432 -0
- package/src/adapters/kiro/reasoning.ts +56 -0
- package/src/adapters/kiro/stream.ts +1153 -0
- package/src/adapters/kiro/usage.ts +223 -0
- package/src/adapters/kiro/wire.ts +76 -0
- package/src/adapters/kiro.ts +8 -2319
- package/src/adapters/mimo-free.ts +1 -1
- package/src/adapters/openai-chat-images.ts +101 -0
- package/src/adapters/openai-chat.ts +201 -181
- package/src/adapters/openai-responses.ts +92 -224
- package/src/adapters/registry.ts +0 -7
- package/src/adapters/run-turn-queue.ts +13 -6
- package/src/bridge.ts +14 -15
- package/src/chat/inbound.ts +29 -4
- package/src/chat/outbound.ts +145 -107
- package/src/claude/desktop-profile.ts +4 -6
- package/src/cli/account-api.ts +14 -0
- package/src/cli/account-extended.ts +1 -1
- package/src/cli/account-history.ts +60 -0
- package/src/cli/account-main.ts +80 -0
- package/src/cli/account.ts +11 -3
- package/src/cli/capabilities.ts +113 -0
- package/src/cli/catalog.ts +109 -0
- package/src/cli/dispatch.ts +9 -0
- package/src/cli/help.ts +2 -0
- package/src/cli/index.ts +2 -2
- package/src/cli/observe.ts +28 -1
- package/src/cli/opencode.ts +42 -8
- package/src/cli/provider-runtime.ts +11 -1
- package/src/cli/provider.ts +22 -2
- package/src/cli/registry.ts +21 -0
- package/src/cli/remote-workspace.ts +154 -0
- package/src/cli/status.ts +39 -7
- package/src/cli/usage-report.ts +14 -2
- package/src/client/hub-client.ts +34 -0
- package/src/client/hub-state.ts +9 -1
- package/src/codex/account-store.ts +78 -0
- package/src/codex/auth-api.ts +81 -54
- package/src/codex/auth-context.ts +45 -16
- package/src/codex/catalog/effort.ts +1 -1
- package/src/codex/catalog/metadata.ts +3 -6
- package/src/codex/catalog/native-models.ts +4 -4
- package/src/codex/catalog/parsing.ts +2 -20
- package/src/codex/catalog/provider-fetch.ts +10 -1
- package/src/codex/catalog/remote.ts +233 -0
- package/src/codex/catalog/sync.ts +403 -35
- package/src/codex/convergence.ts +1 -1
- package/src/codex/history-manifest.ts +36 -0
- package/src/codex/history-provider.ts +32 -5
- package/src/codex/inject.ts +9 -0
- package/src/codex/main-account.ts +113 -0
- package/src/codex/main-device-reauth-api.ts +89 -0
- package/src/codex/main-device-reauth.ts +217 -0
- package/src/codex/native-residue.ts +9 -2
- package/src/codex/quota-auto-refresh.ts +3 -2
- package/src/codex/quota-capacity.ts +98 -0
- package/src/codex/quota-history.ts +160 -0
- package/src/codex/quota-types.ts +8 -0
- package/src/codex/quota.ts +118 -91
- package/src/codex/refresh.ts +2 -1
- package/src/codex/routing.ts +90 -17
- package/src/codex/sync.ts +33 -4
- package/src/combos/request.ts +19 -1
- package/src/config/multi-agent-surface.ts +61 -0
- package/src/config/provider-validation.ts +176 -0
- package/src/config.ts +213 -11
- package/src/generated/compatibility-version.json +436 -168
- package/src/images/loop.ts +119 -36
- package/src/lib/admission.ts +12 -6
- package/src/lib/redact.ts +7 -0
- package/src/lib/translator-budget.ts +4 -3
- package/src/lib/windows-atomic-replace.ts +1 -0
- package/src/lib/windows-elevation.ts +1 -1
- package/src/oauth/chatgpt-device.ts +62 -5
- package/src/oauth/devin/cli-import.ts +130 -0
- package/src/oauth/devin.ts +63 -8
- package/src/oauth/index.ts +29 -14
- package/src/oauth/kiro.ts +18 -6
- package/src/oauth/login-cli.ts +9 -1
- package/src/oauth/meta-muse-device.ts +464 -0
- package/src/oauth/meta-muse.ts +123 -32
- package/src/oauth/pool-kernel.ts +9 -0
- package/src/oauth/pool-settings-capability.ts +2 -2
- package/src/oauth/store.ts +57 -0
- package/src/oauth/types.ts +31 -0
- package/src/providers/derive.ts +13 -3
- package/src/providers/devin-cli-authmode-migration.ts +57 -35
- package/src/providers/devin-provider-merge-migration.ts +240 -0
- package/src/providers/muse-key-quota.ts +117 -0
- package/src/providers/muse-subscription-usage.ts +14 -2
- package/src/providers/openai-sidecar.ts +25 -3
- package/src/providers/opencode-zen-rate-limit.ts +58 -0
- package/src/providers/provider-id-rewrite.ts +20 -5
- package/src/providers/quota-types.ts +12 -0
- package/src/providers/quota.ts +143 -102
- package/src/providers/reasoning-metadata.ts +543 -0
- package/src/providers/registry.ts +80 -49
- package/src/reasoning-effort.ts +26 -2
- package/src/remote/hub-usage.ts +32 -0
- package/src/remote-control/index.ts +192 -41
- package/src/remote-control/workspace-activation.ts +9 -0
- package/src/remote-control/workspace-agent-connection.ts +366 -0
- package/src/remote-control/workspace-claude-runtime.ts +243 -0
- package/src/remote-control/workspace-codex-runtime.ts +531 -0
- package/src/remote-control/workspace-codex-sandbox.ts +115 -0
- package/src/remote-control/workspace-command-runner.ts +748 -0
- package/src/remote-control/workspace-coordinator.ts +231 -0
- package/src/remote-control/workspace-device.ts +585 -0
- package/src/remote-control/workspace-executable.ts +43 -0
- package/src/remote-control/workspace-executor.ts +397 -0
- package/src/remote-control/workspace-hub.ts +519 -0
- package/src/remote-control/workspace-pi-runtime.ts +382 -0
- package/src/remote-control/workspace-process.ts +129 -0
- package/src/remote-control/workspace-rpc.ts +304 -0
- package/src/remote-control/workspace-runtime.ts +60 -0
- package/src/remote-control/workspace-secret-store.ts +39 -0
- package/src/remote-control/workspace-sessions.ts +799 -0
- package/src/remote-control/workspace-tool-bridge.ts +192 -0
- package/src/responses/code-mode-helper-compat.ts +22 -3
- package/src/responses/hosted-tool-policy.ts +0 -1
- package/src/responses/muse-tool-name-alias.ts +379 -0
- package/src/responses/plaintext-v2-agent-messages.ts +902 -0
- package/src/router.ts +7 -0
- package/src/routing/compatibility/behavior.ts +0 -1
- package/src/server/audio-client.ts +64 -0
- package/src/server/audio-dictation.ts +91 -0
- package/src/server/audio-live.ts +185 -0
- package/src/server/audio-transcriptions.ts +183 -0
- package/src/server/audio-upstream.ts +153 -0
- package/src/server/auth-cors.ts +61 -2
- package/src/server/chat-completions.ts +1 -1
- package/src/server/chat-native-sse.ts +92 -48
- package/src/server/chat-native.ts +37 -15
- package/src/server/hub-usage.ts +57 -0
- package/src/server/images.ts +4 -0
- package/src/server/index.ts +722 -57
- package/src/server/lifecycle.ts +5 -6
- package/src/server/live-call-bindings.ts +60 -0
- package/src/server/live.ts +12 -1
- package/src/server/management/agent-settings-routes.ts +25 -4
- package/src/server/management/api-access.ts +37 -0
- package/src/server/management/api-key-usage.ts +7 -2
- package/src/server/management/config-routes.ts +1 -18
- package/src/server/management/context.ts +15 -0
- package/src/server/management/logs-usage-routes.ts +2 -0
- package/src/server/management/oauth-account-routes.ts +39 -12
- package/src/server/management/provider-routes.ts +125 -2
- package/src/server/management/remote-workspace-routes.ts +140 -0
- package/src/server/management/route-registry.ts +15 -0
- package/src/server/management/usage-aggregate-cache.ts +14 -15
- package/src/server/management/usage-summary-cache.ts +2 -0
- package/src/server/management-api.ts +23 -0
- package/src/server/ports.ts +17 -0
- package/src/server/relay-eager.ts +4 -1
- package/src/server/relay.ts +70 -10
- package/src/server/request-decompress.ts +6 -3
- package/src/server/responses/agent-task-recovery.ts +25 -32
- package/src/server/responses/codex-auth-error.ts +11 -0
- package/src/server/responses/codex-ws-exchange.ts +52 -3
- package/src/server/responses/codex-ws-wire.ts +55 -0
- package/src/server/responses/compact.ts +9 -1
- package/src/server/responses/core.ts +337 -73
- package/src/server/responses/encrypted-payload.ts +45 -2
- package/src/server/responses/ws-upstream.ts +4 -1
- package/src/server/responses-self-named-namespace-scrub.ts +1 -3
- package/src/server/responses-undeclared-tool-guard.ts +1 -1
- package/src/server/search.ts +3 -0
- package/src/server/sse-payload-rewrite.ts +136 -51
- package/src/server/ws-bridge.ts +35 -1
- package/src/service/cli.ts +372 -0
- package/src/service/diagnostics.ts +340 -0
- package/src/service/guards.ts +303 -0
- package/src/service/health.ts +222 -0
- package/src/service/launchd.ts +853 -0
- package/src/service/orchestration.ts +617 -0
- package/src/service/repair.ts +334 -0
- package/src/service/state.ts +363 -0
- package/src/service/systemd.ts +229 -0
- package/src/service/windows-ops.ts +690 -0
- package/src/service/windows-scheduler.ts +769 -0
- package/src/service/windows-taskxml.ts +613 -0
- package/src/service.ts +22 -5550
- package/src/storage/cleanup/db.ts +258 -0
- package/src/storage/cleanup/execute.ts +358 -0
- package/src/storage/cleanup/paths.ts +189 -0
- package/src/storage/cleanup/pending.ts +140 -0
- package/src/storage/cleanup/preview.ts +292 -0
- package/src/storage/cleanup/reconcile.ts +347 -0
- package/src/storage/cleanup/restore.ts +932 -0
- package/src/storage/cleanup/satellite.ts +474 -0
- package/src/storage/cleanup/staging.ts +129 -0
- package/src/storage/cleanup/types.ts +98 -0
- package/src/storage/cleanup.ts +49 -3127
- package/src/types/accounts.ts +2 -0
- package/src/types/config.ts +13 -12
- package/src/types/provider.ts +37 -0
- package/src/types/request.ts +2 -0
- package/src/types/tools.ts +17 -5
- package/src/types.ts +1 -0
- package/src/usage/expected-prices.ts +127 -0
- package/src/usage/log.ts +58 -1
- package/src/vision/eligibility.ts +13 -2
- package/src/web-search/loop.ts +56 -3
- package/gui/dist/assets/index-CWXut3rG.js +0 -115
- package/gui/dist/assets/index-EdoPnm9_.css +0 -1
- package/src/adapters/devin-cli/acp.ts +0 -204
- package/src/adapters/devin-cli/adapter.ts +0 -345
- package/src/adapters/devin-cli/binary.ts +0 -69
- package/src/adapters/devin-cli/models.ts +0 -57
- package/src/oauth/devin-cli.ts +0 -149
- package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
package/src/router.ts
CHANGED
|
@@ -413,6 +413,13 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
|
|
|
413
413
|
...(provider.preserveResponsesReasoningContent === undefined && registryEntry.preserveResponsesReasoningContent !== undefined
|
|
414
414
|
? { preserveResponsesReasoningContent: registryEntry.preserveResponsesReasoningContent }
|
|
415
415
|
: {}),
|
|
416
|
+
// The request path resolves through routedProviderConfig() and never calls
|
|
417
|
+
// enrichProviderFromRegistry(), so a saved provider row written before the
|
|
418
|
+
// registry learned this flag must be backfilled here or route.provider never
|
|
419
|
+
// carries it and the showThinkingSummary opt-in stays dead.
|
|
420
|
+
...(provider.showThinkingSummary === undefined && registryEntry.showThinkingSummary !== undefined
|
|
421
|
+
? { showThinkingSummary: registryEntry.showThinkingSummary }
|
|
422
|
+
: {}),
|
|
416
423
|
// Registry-only client-facing repair policy (#938): fill only when the
|
|
417
424
|
// saved provider has no explicit policy; clone so runtime never aliases
|
|
418
425
|
// the registry constant.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { formatErrorResponse } from "../bridge";
|
|
3
|
+
import type { OcxConfig } from "../types";
|
|
4
|
+
import { captureExplicitOpenAiCallerAuth, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
|
|
5
|
+
import { isProxyAdmissionSecret, type DataPlaneAdmission } from "./auth-cors";
|
|
6
|
+
import { resolveAudioAdmission } from "./audio-upstream";
|
|
7
|
+
|
|
8
|
+
export const AUDIO_WEBSOCKET_PROTOCOL = "opencodex-audio";
|
|
9
|
+
const KEY_PROTOCOL_PREFIX = "opencodex-key.";
|
|
10
|
+
|
|
11
|
+
export interface AudioClient {
|
|
12
|
+
admission: DataPlaneAdmission;
|
|
13
|
+
headers: Headers;
|
|
14
|
+
owner: string;
|
|
15
|
+
protocol?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Browser protocols are an audio-only credential carrier; only the public marker is echoed. */
|
|
19
|
+
export function resolveAudioClient(req: Request, config: OcxConfig, required = false): AudioClient | Response | null {
|
|
20
|
+
const headers = new Headers(req.headers);
|
|
21
|
+
let protocol: string | undefined;
|
|
22
|
+
let carrier = false;
|
|
23
|
+
if (headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
24
|
+
const raw = headers.get("sec-websocket-protocol") ?? "";
|
|
25
|
+
if (raw.length > 8192) return formatErrorResponse(400, "invalid_request_error", "Audio protocol header too large");
|
|
26
|
+
const protocols = raw.split(",").map(value => value.trim()).filter(Boolean);
|
|
27
|
+
const keys = protocols.filter(value => value.startsWith(KEY_PROTOCOL_PREFIX));
|
|
28
|
+
const markers = protocols.filter(value => value === AUDIO_WEBSOCKET_PROTOCOL);
|
|
29
|
+
carrier = keys.length > 0 || markers.length > 0;
|
|
30
|
+
if (carrier) {
|
|
31
|
+
if (keys.length !== 1 || markers.length !== 1 || protocols.length !== 2) {
|
|
32
|
+
return formatErrorResponse(400, "invalid_request_error", "Expected one audio protocol and one encoded client key");
|
|
33
|
+
}
|
|
34
|
+
const encoded = keys[0]!.slice(KEY_PROTOCOL_PREFIX.length);
|
|
35
|
+
const bytes = Buffer.from(encoded, "base64url");
|
|
36
|
+
const key = bytes.toString("utf8");
|
|
37
|
+
if (!encoded || bytes.toString("base64url") !== encoded || Buffer.from(key).toString("base64url") !== encoded || !key.trim()) {
|
|
38
|
+
return formatErrorResponse(400, "invalid_request_error", "Invalid audio client key encoding");
|
|
39
|
+
}
|
|
40
|
+
protocol = AUDIO_WEBSOCKET_PROTOCOL;
|
|
41
|
+
if (!["authorization", "x-opencodex-api-key", "x-api-key"].some(name => headers.has(name))) {
|
|
42
|
+
headers.set("x-opencodex-api-key", key);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const admission = resolveAudioAdmission(headers, config);
|
|
47
|
+
if (!admission) {
|
|
48
|
+
const bearer = /^Bearer\s+([^\s,]+)$/i.exec(headers.get("authorization") ?? "")?.[1];
|
|
49
|
+
const platformKey = selectOpenAiImagesProvider(config).keyed?.apiKey;
|
|
50
|
+
const knownPlatformBearer = !!bearer && !!platformKey && !isProxyAdmissionSecret(bearer, config)
|
|
51
|
+
&& timingSafeEqual(createHash("sha256").update(bearer).digest(), createHash("sha256").update(platformKey).digest());
|
|
52
|
+
const explicit = headers.has("x-opencodex-api-key") || headers.has("x-api-key")
|
|
53
|
+
|| (headers.has("authorization") && !knownPlatformBearer && !captureExplicitOpenAiCallerAuth(headers, config));
|
|
54
|
+
return required || carrier || explicit
|
|
55
|
+
? formatErrorResponse(401, "authentication_error", "opencodex API key required")
|
|
56
|
+
: null;
|
|
57
|
+
}
|
|
58
|
+
const credential = admission.source === "dedicated" ? headers.get("x-opencodex-api-key")
|
|
59
|
+
: admission.source === "x-api-key" ? headers.get("x-api-key") : headers.get("authorization")?.replace(/^Bearer\s+/i, "");
|
|
60
|
+
const owner = admission.kind === "configured"
|
|
61
|
+
? JSON.stringify(["configured", admission.keyId])
|
|
62
|
+
: JSON.stringify(["environment", createHash("sha256").update(credential?.trim() ?? "").digest("hex")]);
|
|
63
|
+
return { admission, headers, owner, ...(protocol ? { protocol } : {}) };
|
|
64
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { formatErrorResponse } from "../bridge";
|
|
2
|
+
import type { AdmissionLease } from "../lib/admission";
|
|
3
|
+
import type { OcxConfig } from "../types";
|
|
4
|
+
import type { AudioClient } from "./audio-client";
|
|
5
|
+
import { resolveAudioUpstream, TRANSCRIPTION_MODEL, type AudioUpstream } from "./audio-upstream";
|
|
6
|
+
import type { RequestLogContext } from "./request-log";
|
|
7
|
+
|
|
8
|
+
export const DICTATION_SESSION_MAX_MS = 300_000;
|
|
9
|
+
const DICTATION_FRAME_MAX_BYTES = 64 * 1024;
|
|
10
|
+
type JsonObject = Record<string, unknown>;
|
|
11
|
+
function object(value: unknown): value is JsonObject { return !!value && typeof value === "object" && !Array.isArray(value); }
|
|
12
|
+
function integer(value: unknown, min: number, max: number): boolean {
|
|
13
|
+
return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createDictationFrameValidator(): (frame: string | Buffer) => boolean {
|
|
17
|
+
let started = false;
|
|
18
|
+
let closed = false;
|
|
19
|
+
return frame => {
|
|
20
|
+
if (closed || typeof frame !== "string" || Buffer.byteLength(frame) > DICTATION_FRAME_MAX_BYTES) return false;
|
|
21
|
+
let event: unknown;
|
|
22
|
+
try { event = JSON.parse(frame); } catch { return false; }
|
|
23
|
+
if (!object(event)) return false;
|
|
24
|
+
if (event.type === "session.start" && !started) {
|
|
25
|
+
const config = event.config;
|
|
26
|
+
if (!object(config) || Object.keys(event).some(key => key !== "type" && key !== "config")) return false;
|
|
27
|
+
const fields = new Set(["input_audio_format", "sample_rate_hz", "num_channels", "max_buffer_size_bytes", "max_utterance_duration_ms", "session_ttl_ms", "provider_mode", "transcript_delivery_mode", "vad"]);
|
|
28
|
+
if (Object.keys(config).some(key => !fields.has(key)) || config.input_audio_format !== "pcm16" || config.num_channels !== 1
|
|
29
|
+
|| !integer(config.sample_rate_hz, 8000, 192000) || !integer(config.max_buffer_size_bytes, 1, 4 * 1024 * 1024)
|
|
30
|
+
|| !integer(config.max_utterance_duration_ms, 1, 30000) || !integer(config.session_ttl_ms, 1, DICTATION_SESSION_MAX_MS)
|
|
31
|
+
|| !["buffered", "streaming_sse"].includes(String(config.provider_mode))
|
|
32
|
+
|| !["final_only", "segment", "delta"].includes(String(config.transcript_delivery_mode))) return false;
|
|
33
|
+
const vad = config.vad;
|
|
34
|
+
if (!object(vad) || Object.keys(vad).some(key => !["type", "threshold", "prefix_padding_ms", "silence_duration_ms"].includes(key))
|
|
35
|
+
|| vad.type !== "server_vad" || typeof vad.threshold !== "number" || !Number.isFinite(vad.threshold) || vad.threshold < 0 || vad.threshold > 1
|
|
36
|
+
|| !integer(vad.prefix_padding_ms, 0, 1000) || !integer(vad.silence_duration_ms, 0, 5000)) return false;
|
|
37
|
+
started = true;
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (!started) return false;
|
|
41
|
+
if (event.type === "session.close" && Object.keys(event).length === 1) { closed = true; return true; }
|
|
42
|
+
if (event.type !== "audio.append" || Object.keys(event).some(key => key !== "type" && key !== "audio") || typeof event.audio !== "string" || !event.audio) return false;
|
|
43
|
+
const bytes = Buffer.from(event.audio, "base64");
|
|
44
|
+
return bytes.length > 0 && bytes.length % 2 === 0 && bytes.toString("base64") === event.audio;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AudioSocketTarget {
|
|
49
|
+
headers: Record<string, string>;
|
|
50
|
+
upstreamWsUrl: string;
|
|
51
|
+
protocols?: string[];
|
|
52
|
+
validateFrame?: (frame: string | Buffer) => boolean;
|
|
53
|
+
maxSessionMs: number;
|
|
54
|
+
finish: (outcome?: number | "timeout" | "connect_error") => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function finishAudioUpstream(relay: AudioUpstream): AudioSocketTarget["finish"] {
|
|
58
|
+
let finished = false;
|
|
59
|
+
return outcome => {
|
|
60
|
+
if (finished) return;
|
|
61
|
+
finished = true;
|
|
62
|
+
try { if (outcome !== undefined) relay.recordOutcome?.(outcome); }
|
|
63
|
+
finally { relay.release(); }
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function resolveDictationSocket(
|
|
68
|
+
client: AudioClient, config: OcxConfig, log: RequestLogContext, lease: AdmissionLease, signal?: AbortSignal,
|
|
69
|
+
): Promise<AudioSocketTarget | Response> {
|
|
70
|
+
const relay = await resolveAudioUpstream(client.headers, config, log, { admission: client.admission, model: TRANSCRIPTION_MODEL, lease, signal });
|
|
71
|
+
if (relay instanceof Response) return relay;
|
|
72
|
+
if (relay.keyed) {
|
|
73
|
+
relay.release();
|
|
74
|
+
return formatErrorResponse(400, "invalid_request_error", "Streaming dictation requires a connected ChatGPT account");
|
|
75
|
+
}
|
|
76
|
+
const headers = new Headers(relay.headers);
|
|
77
|
+
const token = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
78
|
+
if (!token || /[\s,]/.test(token)) {
|
|
79
|
+
relay.release();
|
|
80
|
+
return formatErrorResponse(401, "authentication_error", "Dictation account authentication unavailable");
|
|
81
|
+
}
|
|
82
|
+
headers.delete("authorization");
|
|
83
|
+
return {
|
|
84
|
+
upstreamWsUrl: "wss://chatgpt.com/backend-api/dictation/stream",
|
|
85
|
+
headers: Object.fromEntries(headers),
|
|
86
|
+
protocols: ["chatgpt-dictation", `openai-bearer.${token}`, "codex-desktop"],
|
|
87
|
+
validateFrame: createDictationFrameValidator(),
|
|
88
|
+
maxSessionMs: DICTATION_SESSION_MAX_MS,
|
|
89
|
+
finish: finishAudioUpstream(relay),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { formatErrorResponse } from "../bridge";
|
|
3
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/account-id";
|
|
4
|
+
import { cancelBodyOnAbort, clearableDeadline } from "../lib/abort";
|
|
5
|
+
import type { AdmissionLease } from "../lib/admission";
|
|
6
|
+
import { captureExplicitOpenAiCallerAuth } from "../providers/openai-sidecar";
|
|
7
|
+
import type { OcxConfig } from "../types";
|
|
8
|
+
import type { AudioClient } from "./audio-client";
|
|
9
|
+
import { finishAudioUpstream, type AudioSocketTarget } from "./audio-dictation";
|
|
10
|
+
import { LIVE_AUDIO_MODEL, resolveAudioUpstream, type AudioUpstream } from "./audio-upstream";
|
|
11
|
+
import { registerTurn, unregisterTurn } from "./lifecycle";
|
|
12
|
+
import {
|
|
13
|
+
backendJsonBodyFromApiMultipart, buildLiveSidebandUpstreamWsUrl, forwardLiveUrl, keyedLiveUrl,
|
|
14
|
+
LIVE_CLIENT_PROTOCOL_HEADERS, LIVE_REQUEST_MAX_BYTES, LIVE_RESPONSE_MAX_BYTES, readBodyCapped,
|
|
15
|
+
type LiveSidebandTarget,
|
|
16
|
+
} from "./live";
|
|
17
|
+
import { LIVE_CALL_TTL_MS, LiveCallBindings, upstreamLiveCallId } from "./live-call-bindings";
|
|
18
|
+
import type { RequestLogContext } from "./request-log";
|
|
19
|
+
|
|
20
|
+
function protocolHeaders(client: AudioClient, relay: AudioUpstream, frameless: boolean): Headers {
|
|
21
|
+
const headers = new Headers();
|
|
22
|
+
for (const name of LIVE_CLIENT_PROTOCOL_HEADERS) {
|
|
23
|
+
const value = client.headers.get(name);
|
|
24
|
+
if (value) headers.set(name, value);
|
|
25
|
+
}
|
|
26
|
+
for (const [name, value] of Object.entries(relay.headers)) headers.set(name, value);
|
|
27
|
+
if (frameless && !headers.has("openai-alpha")) headers.set("openai-alpha", "quicksilver=v2");
|
|
28
|
+
return headers;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function parseExternalOffer(req: Request, signal: AbortSignal): Promise<{ sdp: string; session?: Record<string, unknown> } | Response> {
|
|
32
|
+
const upload = clearableDeadline(30_000, signal);
|
|
33
|
+
try {
|
|
34
|
+
const body = await readBodyCapped(req.body, LIVE_REQUEST_MAX_BYTES, () => "Live offer too large", upload.signal);
|
|
35
|
+
if (body instanceof Response) return formatErrorResponse(413, "invalid_request_error", "Live offer exceeds 16 MiB");
|
|
36
|
+
const type = req.headers.get("content-type") ?? "";
|
|
37
|
+
let payload: unknown;
|
|
38
|
+
if (type.toLowerCase().includes("multipart/form-data")) {
|
|
39
|
+
const converted = await backendJsonBodyFromApiMultipart(body, type);
|
|
40
|
+
if (converted instanceof Response) return converted;
|
|
41
|
+
payload = JSON.parse(new TextDecoder().decode(converted.body));
|
|
42
|
+
} else if (type.toLowerCase().includes("application/json")) {
|
|
43
|
+
payload = JSON.parse(new TextDecoder().decode(body));
|
|
44
|
+
} else if (type.toLowerCase().includes("application/sdp")) {
|
|
45
|
+
payload = { sdp: new TextDecoder().decode(body) };
|
|
46
|
+
}
|
|
47
|
+
if (!payload || typeof payload !== "object" || !("sdp" in payload) || typeof payload.sdp !== "string" || !payload.sdp.trim()) {
|
|
48
|
+
return formatErrorResponse(400, "invalid_request_error", "Live call requires a nonempty SDP offer");
|
|
49
|
+
}
|
|
50
|
+
const session = "session" in payload ? payload.session : undefined;
|
|
51
|
+
if (session !== undefined && (!session || typeof session !== "object" || Array.isArray(session))) {
|
|
52
|
+
return formatErrorResponse(400, "invalid_request_error", "Live session must be an object");
|
|
53
|
+
}
|
|
54
|
+
return { sdp: payload.sdp, ...(session ? { session: session as Record<string, unknown> } : {}) };
|
|
55
|
+
} catch {
|
|
56
|
+
if (signal.aborted) throw signal.reason;
|
|
57
|
+
return formatErrorResponse(upload.didExpire() ? 408 : 400, "invalid_request_error", upload.didExpire() ? "Live offer upload timed out" : "Malformed live offer");
|
|
58
|
+
} finally { upload.clear(); }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function handleExternalLive(
|
|
62
|
+
req: Request, config: OcxConfig, log: RequestLogContext,
|
|
63
|
+
options: { client: AudioClient; lease: AdmissionLease; bindings: LiveCallBindings },
|
|
64
|
+
): Promise<Response> {
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
registerTurn(controller, options.lease);
|
|
67
|
+
const deadline = clearableDeadline(120_000, AbortSignal.any([req.signal, controller.signal]));
|
|
68
|
+
let relay: AudioUpstream | undefined;
|
|
69
|
+
let outcome: number | "timeout" | "connect_error" | undefined;
|
|
70
|
+
try {
|
|
71
|
+
const offer = await parseExternalOffer(req, deadline.signal);
|
|
72
|
+
if (offer instanceof Response) return offer;
|
|
73
|
+
if (!options.bindings.hasCapacity()) return formatErrorResponse(503, "server_busy", "Live call capacity reached");
|
|
74
|
+
const frameless = new URL(req.url).pathname === "/v1/live";
|
|
75
|
+
const session = offer.session ? { ...offer.session } : frameless ? {
|
|
76
|
+
model: LIVE_AUDIO_MODEL, instructions: "", audio: { output: { voice: "cove" } }, delegation: { type: "client" },
|
|
77
|
+
} : undefined;
|
|
78
|
+
if (session?.model === "gpt-live-1") session.model = LIVE_AUDIO_MODEL;
|
|
79
|
+
const model = typeof session?.model === "string" ? session.model : LIVE_AUDIO_MODEL;
|
|
80
|
+
const resolved = await resolveAudioUpstream(options.client.headers, config, log, {
|
|
81
|
+
admission: options.client.admission, model, lease: options.lease, signal: deadline.signal,
|
|
82
|
+
});
|
|
83
|
+
if (!(resolved instanceof Response)) relay = resolved;
|
|
84
|
+
deadline.signal.throwIfAborted();
|
|
85
|
+
if (resolved instanceof Response) return resolved;
|
|
86
|
+
relay = resolved;
|
|
87
|
+
const headers = protocolHeaders(options.client, relay, frameless);
|
|
88
|
+
let body: BodyInit;
|
|
89
|
+
if (relay.keyed) {
|
|
90
|
+
const form = new FormData();
|
|
91
|
+
form.set("sdp", offer.sdp);
|
|
92
|
+
if (session) form.set("session", JSON.stringify(session));
|
|
93
|
+
headers.delete("content-type");
|
|
94
|
+
body = form;
|
|
95
|
+
} else {
|
|
96
|
+
headers.set("content-type", "application/json");
|
|
97
|
+
body = JSON.stringify({ sdp: offer.sdp, ...(session ? { session } : {}) });
|
|
98
|
+
}
|
|
99
|
+
const url = relay.keyed
|
|
100
|
+
? frameless ? forwardLiveUrl(relay.providerBaseUrl, false) : keyedLiveUrl(relay.providerBaseUrl)
|
|
101
|
+
: forwardLiveUrl(relay.providerBaseUrl, true);
|
|
102
|
+
const upstream = await fetch(url, { method: "POST", headers, body, signal: deadline.signal, redirect: "manual" });
|
|
103
|
+
outcome = upstream.ok ? 502 : upstream.status;
|
|
104
|
+
const detach = cancelBodyOnAbort(upstream.body, deadline.signal);
|
|
105
|
+
let responseBody: ArrayBuffer | Response;
|
|
106
|
+
try { responseBody = await readBodyCapped(upstream.body, LIVE_RESPONSE_MAX_BYTES, () => "Live answer too large", deadline.signal); }
|
|
107
|
+
finally { detach(); }
|
|
108
|
+
if (responseBody instanceof Response) return responseBody;
|
|
109
|
+
if (!upstream.ok) return formatErrorResponse(upstream.status >= 400 ? upstream.status : 502, "upstream_error", `Live upstream returned HTTP ${upstream.status}`);
|
|
110
|
+
const callId = upstreamLiveCallId(upstream.headers.get("location"));
|
|
111
|
+
if (!callId || responseBody.byteLength === 0) return formatErrorResponse(502, "upstream_error", "Live upstream returned an invalid call answer");
|
|
112
|
+
const context = relay.authContext;
|
|
113
|
+
const callerOwned = context?.kind === "main" && captureExplicitOpenAiCallerAuth(options.client.headers, config) !== null;
|
|
114
|
+
const alias = options.bindings.create({
|
|
115
|
+
owner: options.client.owner, upstreamCallId: callId,
|
|
116
|
+
joinStyle: frameless ? "frameless-path" : "realtime-query",
|
|
117
|
+
providerName: relay.providerName,
|
|
118
|
+
accountId: context ? context.kind === "main" ? callerOwned ? undefined : MAIN_CODEX_ACCOUNT_ID : context.accountId : undefined,
|
|
119
|
+
chatgptAccountId: new Headers(relay.headers).get("chatgpt-account-id") ?? undefined,
|
|
120
|
+
keyedCredentialDigest: relay.keyed ? createHash("sha256").update(new Headers(relay.headers).get("authorization") ?? "").digest("hex") : undefined,
|
|
121
|
+
callerOwned,
|
|
122
|
+
sidebandBaseUrl: config.experimentalRealtimeWsBaseUrl,
|
|
123
|
+
});
|
|
124
|
+
if (!alias) return formatErrorResponse(503, "server_busy", "Live call could not be registered");
|
|
125
|
+
outcome = upstream.status;
|
|
126
|
+
return new Response(responseBody, { status: upstream.status, headers: {
|
|
127
|
+
"content-type": upstream.headers.get("content-type") ?? "application/sdp",
|
|
128
|
+
location: `/v1/${frameless ? "live" : "realtime/calls"}/${alias}`,
|
|
129
|
+
} });
|
|
130
|
+
} catch {
|
|
131
|
+
if (req.signal.aborted || controller.signal.aborted) {
|
|
132
|
+
outcome = undefined;
|
|
133
|
+
return formatErrorResponse(req.signal.aborted ? 499 : 503, "client_closed_request", "Live call canceled");
|
|
134
|
+
}
|
|
135
|
+
outcome = deadline.didExpire() ? "timeout" : "connect_error";
|
|
136
|
+
return formatErrorResponse(deadline.didExpire() ? 504 : 502, "upstream_error", deadline.didExpire() ? "Live call timed out" : "Live upstream connection failed");
|
|
137
|
+
} finally {
|
|
138
|
+
try { if (outcome !== undefined) relay?.recordOutcome?.(outcome); }
|
|
139
|
+
finally { relay?.release(); deadline.clear(); unregisterTurn(controller); }
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function resolveExternalLiveSocket(
|
|
144
|
+
client: AudioClient, config: OcxConfig, log: RequestLogContext, target: LiveSidebandTarget,
|
|
145
|
+
options: { lease: AdmissionLease; bindings: LiveCallBindings; signal?: AbortSignal },
|
|
146
|
+
): Promise<AudioSocketTarget | Response> {
|
|
147
|
+
const binding = "callId" in target ? options.bindings.get(target.callId, client.owner) : undefined;
|
|
148
|
+
if ("callId" in target && !binding) return formatErrorResponse(404, "not_found", "Live call is unavailable for this key");
|
|
149
|
+
if (binding?.callerOwned && !captureExplicitOpenAiCallerAuth(client.headers, config)) {
|
|
150
|
+
return formatErrorResponse(401, "authentication_error", "Live call requires its original caller account");
|
|
151
|
+
}
|
|
152
|
+
let upstreamTarget = binding ? { style: binding.joinStyle, callId: binding.upstreamCallId } as LiveSidebandTarget : target;
|
|
153
|
+
const frameless = upstreamTarget.style === "frameless-path" || upstreamTarget.style === "frameless-standalone";
|
|
154
|
+
let model = LIVE_AUDIO_MODEL;
|
|
155
|
+
if (upstreamTarget.style === "frameless-standalone") {
|
|
156
|
+
const query = new URLSearchParams(upstreamTarget.query);
|
|
157
|
+
if (!query.has("model") || query.get("model") === "gpt-live-1") query.set("model", LIVE_AUDIO_MODEL);
|
|
158
|
+
model = query.get("model") ?? LIVE_AUDIO_MODEL;
|
|
159
|
+
upstreamTarget = { ...upstreamTarget, query: query.toString() };
|
|
160
|
+
}
|
|
161
|
+
const relay = await resolveAudioUpstream(client.headers, config, log, {
|
|
162
|
+
admission: client.admission, model, lease: options.lease,
|
|
163
|
+
signal: options.signal,
|
|
164
|
+
...(binding ? { exactAccountId: binding.accountId, providerName: binding.providerName } : {}),
|
|
165
|
+
});
|
|
166
|
+
if (relay instanceof Response) return relay;
|
|
167
|
+
try {
|
|
168
|
+
if (binding && (relay.providerName !== binding.providerName
|
|
169
|
+
|| (new Headers(relay.headers).get("chatgpt-account-id") ?? undefined) !== binding.chatgptAccountId
|
|
170
|
+
|| (binding.keyedCredentialDigest !== undefined
|
|
171
|
+
&& createHash("sha256").update(new Headers(relay.headers).get("authorization") ?? "").digest("hex") !== binding.keyedCredentialDigest))) {
|
|
172
|
+
relay.release();
|
|
173
|
+
return formatErrorResponse(409, "authentication_error", "Live call account is no longer available");
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
headers: Object.fromEntries(protocolHeaders(client, relay, frameless)),
|
|
177
|
+
upstreamWsUrl: buildLiveSidebandUpstreamWsUrl(upstreamTarget, binding ? binding.sidebandBaseUrl : config.experimentalRealtimeWsBaseUrl),
|
|
178
|
+
maxSessionMs: LIVE_CALL_TTL_MS,
|
|
179
|
+
finish: finishAudioUpstream(relay),
|
|
180
|
+
};
|
|
181
|
+
} catch {
|
|
182
|
+
relay.release();
|
|
183
|
+
return formatErrorResponse(400, "invalid_request_error", "Invalid live endpoint configuration");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { formatErrorResponse } from "../bridge";
|
|
2
|
+
import { cancelBodyOnAbort, clearableDeadline, signalWithTimeout } from "../lib/abort";
|
|
3
|
+
import type { AdmissionLease } from "../lib/admission";
|
|
4
|
+
import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
5
|
+
import type { OcxConfig } from "../types";
|
|
6
|
+
import type { DataPlaneAdmission } from "./auth-cors";
|
|
7
|
+
import { resolveAudioUpstream, TRANSCRIPTION_MODEL } from "./audio-upstream";
|
|
8
|
+
import { readBodyCapped } from "./live";
|
|
9
|
+
import type { RequestLogContext } from "./request-log";
|
|
10
|
+
import { registerTurn, unregisterTurn } from "./lifecycle";
|
|
11
|
+
|
|
12
|
+
export const AUDIO_FILE_MAX_BYTES = 25_000_000;
|
|
13
|
+
export const AUDIO_BODY_MAX_BYTES = 32 * 1024 * 1024;
|
|
14
|
+
const AUDIO_FIELD_MAX_BYTES = 16 * 1024;
|
|
15
|
+
const AUDIO_RESPONSE_MAX_BYTES = 2 * 1024 * 1024;
|
|
16
|
+
const AUDIO_TIMEOUT_MS = 120_000;
|
|
17
|
+
const FIELDS = new Set(["file", "model", "prompt", "language", "response_format"]);
|
|
18
|
+
const MODELS = new Set([TRANSCRIPTION_MODEL, "gpt-4o-mini-transcribe", "whisper-1"]);
|
|
19
|
+
|
|
20
|
+
interface TranscriptionInput {
|
|
21
|
+
file: File;
|
|
22
|
+
model: string;
|
|
23
|
+
prompt?: string;
|
|
24
|
+
language?: string;
|
|
25
|
+
format: "json" | "text";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function invalid(message: string, status = 400): Response {
|
|
29
|
+
return formatErrorResponse(status, "invalid_request_error", message);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function parseTranscription(req: Request, operationSignal: AbortSignal): Promise<TranscriptionInput | Response> {
|
|
33
|
+
if (!/^multipart\/form-data\s*;/i.test(req.headers.get("content-type") ?? "")) {
|
|
34
|
+
return invalid("Expected multipart/form-data with file and model");
|
|
35
|
+
}
|
|
36
|
+
const encoding = req.headers.get("content-encoding")?.trim().toLowerCase();
|
|
37
|
+
if (encoding && encoding !== "identity") return invalid("Compressed audio request bodies are not supported");
|
|
38
|
+
const length = req.headers.get("content-length");
|
|
39
|
+
if (length && /^\d+$/.test(length) && Number(length) > AUDIO_BODY_MAX_BYTES) {
|
|
40
|
+
await req.body?.cancel().catch(() => {});
|
|
41
|
+
return invalid("Audio request exceeds 32 MiB", 413);
|
|
42
|
+
}
|
|
43
|
+
let form: FormData;
|
|
44
|
+
const uploadDeadline = clearableDeadline(30_000, operationSignal);
|
|
45
|
+
try {
|
|
46
|
+
const body = await readBodyCapped(req.body, AUDIO_BODY_MAX_BYTES, () => "Audio request too large", uploadDeadline.signal);
|
|
47
|
+
if (body instanceof Response) return invalid("Audio request exceeds 32 MiB", 413);
|
|
48
|
+
if (operationSignal.aborted) return formatErrorResponse(499, "client_closed_request", "Audio upload canceled");
|
|
49
|
+
form = await new Response(body, { headers: { "content-type": req.headers.get("content-type")! } }).formData();
|
|
50
|
+
} catch {
|
|
51
|
+
if (uploadDeadline.didExpire()) return formatErrorResponse(408, "request_timeout", "Audio upload timed out");
|
|
52
|
+
return operationSignal.aborted
|
|
53
|
+
? formatErrorResponse(499, "client_closed_request", "Audio upload canceled")
|
|
54
|
+
: invalid("Malformed audio multipart body");
|
|
55
|
+
} finally {
|
|
56
|
+
uploadDeadline.clear();
|
|
57
|
+
}
|
|
58
|
+
const seen = new Set<string>();
|
|
59
|
+
for (const [name, value] of form) {
|
|
60
|
+
if (!FIELDS.has(name)) return invalid(`Unsupported transcription field: ${name.slice(0, 64)}`);
|
|
61
|
+
if (seen.has(name)) return invalid(`Duplicate transcription field: ${name}`);
|
|
62
|
+
seen.add(name);
|
|
63
|
+
if (name !== "file" && (typeof value !== "string" || Buffer.byteLength(value) > AUDIO_FIELD_MAX_BYTES)) {
|
|
64
|
+
return invalid("Audio text fields must be strings no larger than 16 KiB", 413);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const file = form.get("file");
|
|
68
|
+
if (!(file instanceof File) || file.size === 0) return invalid("A nonempty audio file is required");
|
|
69
|
+
if (file.size > AUDIO_FILE_MAX_BYTES) return invalid("Audio file exceeds 25,000,000 bytes", 413);
|
|
70
|
+
const model = form.get("model");
|
|
71
|
+
if (typeof model !== "string" || !MODELS.has(model)) return invalid("Unsupported transcription model");
|
|
72
|
+
const format = form.get("response_format") ?? "json";
|
|
73
|
+
if (format !== "json" && format !== "text") return invalid("response_format must be json or text");
|
|
74
|
+
const prompt = form.get("prompt") as string | null;
|
|
75
|
+
const language = form.get("language") as string | null;
|
|
76
|
+
if (language !== null && !/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/.test(language)) return invalid("Invalid transcription language");
|
|
77
|
+
return { file, model, format, ...(prompt !== null ? { prompt } : {}), ...(language !== null ? { language } : {}) };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function transcribeAdmitted(
|
|
81
|
+
req: Request,
|
|
82
|
+
config: OcxConfig,
|
|
83
|
+
log: RequestLogContext,
|
|
84
|
+
admission: DataPlaneAdmission,
|
|
85
|
+
operation: { lease?: AdmissionLease; signal: AbortSignal; didExpire: () => boolean },
|
|
86
|
+
): Promise<Response> {
|
|
87
|
+
const input = await parseTranscription(req, operation.signal);
|
|
88
|
+
if (input instanceof Response) return input;
|
|
89
|
+
if (operation.signal.aborted) return formatErrorResponse(499, "client_closed_request", "Audio request canceled");
|
|
90
|
+
const relay = await resolveAudioUpstream(req.headers, config, log, { admission, model: input.model, lease: operation.lease, signal: operation.signal });
|
|
91
|
+
if (relay instanceof Response) return relay;
|
|
92
|
+
const signal = signalWithTimeout(AUDIO_TIMEOUT_MS, operation.signal);
|
|
93
|
+
const exit = sidecarEnter("audio-transcription");
|
|
94
|
+
let outcome: number | "timeout" | "connect_error" | undefined;
|
|
95
|
+
try {
|
|
96
|
+
if (!relay.keyed && input.model !== TRANSCRIPTION_MODEL) return invalid(`ChatGPT transcription supports only ${TRANSCRIPTION_MODEL}`);
|
|
97
|
+
const url = relay.keyed
|
|
98
|
+
? `${relay.providerBaseUrl}/audio/transcriptions`
|
|
99
|
+
: "https://chatgpt.com/backend-api/transcribe";
|
|
100
|
+
const headers = new Headers();
|
|
101
|
+
for (const name of ["authorization", "chatgpt-account-id", "user-agent", "originator", "version"]) {
|
|
102
|
+
const value = new Headers(relay.headers).get(name);
|
|
103
|
+
if (value) headers.set(name, value);
|
|
104
|
+
}
|
|
105
|
+
if (!relay.keyed) {
|
|
106
|
+
const nativeAgent = req.headers.get("user-agent");
|
|
107
|
+
headers.set("user-agent", nativeAgent && /^codex(?:[_ /-]|$)/i.test(nativeAgent)
|
|
108
|
+
? nativeAgent.slice(0, 2048) : "codex_cli_rs");
|
|
109
|
+
if (!headers.has("originator")) headers.set("originator", "codex_cli_rs");
|
|
110
|
+
}
|
|
111
|
+
const form = new FormData();
|
|
112
|
+
form.append("file", input.file, "audio" + (/\.[a-z0-9]{1,8}$/i.exec(input.file.name)?.[0] ?? ".webm"));
|
|
113
|
+
if (input.prompt !== undefined) form.append("prompt", input.prompt);
|
|
114
|
+
if (input.language !== undefined) form.append("language", input.language);
|
|
115
|
+
if (relay.keyed) {
|
|
116
|
+
form.append("model", input.model);
|
|
117
|
+
form.append("response_format", "json");
|
|
118
|
+
}
|
|
119
|
+
const upstream = await fetch(url, { method: "POST", headers, body: form, signal: signal.signal, redirect: "manual" });
|
|
120
|
+
outcome = upstream.ok ? 502 : upstream.status;
|
|
121
|
+
const detach = cancelBodyOnAbort(upstream.body, signal.signal);
|
|
122
|
+
let body: ArrayBuffer | Response;
|
|
123
|
+
try {
|
|
124
|
+
body = await readBodyCapped(upstream.body, AUDIO_RESPONSE_MAX_BYTES, () => "Audio upstream response too large", signal.signal);
|
|
125
|
+
} finally {
|
|
126
|
+
detach();
|
|
127
|
+
}
|
|
128
|
+
if (body instanceof Response) return body;
|
|
129
|
+
if (!upstream.ok) {
|
|
130
|
+
const status = upstream.status >= 400 && upstream.status <= 599 ? upstream.status : 502;
|
|
131
|
+
return formatErrorResponse(status, "upstream_error", `Audio upstream returned HTTP ${upstream.status}`);
|
|
132
|
+
}
|
|
133
|
+
let payload: unknown;
|
|
134
|
+
try { payload = JSON.parse(new TextDecoder().decode(body)); } catch {
|
|
135
|
+
return formatErrorResponse(502, "upstream_error", "Audio upstream returned invalid JSON");
|
|
136
|
+
}
|
|
137
|
+
if (!payload || typeof payload !== "object" || !("text" in payload) || typeof payload.text !== "string") {
|
|
138
|
+
return formatErrorResponse(502, "upstream_error", "Audio upstream response is missing text");
|
|
139
|
+
}
|
|
140
|
+
outcome = upstream.status;
|
|
141
|
+
return input.format === "text"
|
|
142
|
+
? new Response(payload.text, { headers: { "content-type": "text/plain; charset=utf-8" } })
|
|
143
|
+
: Response.json({ text: payload.text });
|
|
144
|
+
} catch {
|
|
145
|
+
if (operation.signal.aborted) {
|
|
146
|
+
outcome = operation.didExpire() ? "timeout" : undefined;
|
|
147
|
+
return formatErrorResponse(499, "client_closed_request", "Audio request canceled");
|
|
148
|
+
}
|
|
149
|
+
const timedOut = signal.signal.aborted;
|
|
150
|
+
outcome = timedOut ? "timeout" : "connect_error";
|
|
151
|
+
return formatErrorResponse(timedOut ? 504 : 502, "upstream_error", timedOut ? "Audio upstream timed out" : "Audio upstream connection failed");
|
|
152
|
+
} finally {
|
|
153
|
+
try {
|
|
154
|
+
if (outcome !== undefined) relay.recordOutcome?.(outcome);
|
|
155
|
+
} finally {
|
|
156
|
+
relay.release();
|
|
157
|
+
signal.cleanup();
|
|
158
|
+
exit();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function handleAudioTranscriptions(
|
|
164
|
+
req: Request,
|
|
165
|
+
config: OcxConfig,
|
|
166
|
+
log: RequestLogContext,
|
|
167
|
+
admission: DataPlaneAdmission,
|
|
168
|
+
lease?: AdmissionLease,
|
|
169
|
+
): Promise<Response> {
|
|
170
|
+
const operation = new AbortController();
|
|
171
|
+
if (lease) registerTurn(operation, lease);
|
|
172
|
+
const deadline = clearableDeadline(AUDIO_TIMEOUT_MS, AbortSignal.any([req.signal, operation.signal]));
|
|
173
|
+
try {
|
|
174
|
+
const response = await transcribeAdmitted(req, config, log, admission, { lease, signal: deadline.signal, didExpire: deadline.didExpire });
|
|
175
|
+
if (req.signal.aborted) return formatErrorResponse(499, "client_closed_request", "Audio request canceled");
|
|
176
|
+
if (operation.signal.aborted) return formatErrorResponse(503, "server_draining", "Audio request stopped during shutdown");
|
|
177
|
+
if (deadline.didExpire()) return formatErrorResponse(504, "upstream_error", "Audio request timed out");
|
|
178
|
+
return response;
|
|
179
|
+
} finally {
|
|
180
|
+
deadline.clear();
|
|
181
|
+
if (lease) unregisterTurn(operation);
|
|
182
|
+
}
|
|
183
|
+
}
|