From 5e4ade843258dc3ae0b9d9ceec150ac126c26c92 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 17:18:26 +0900 Subject: [PATCH 01/10] docs: plan standalone audio API stack --- .../_plan/260912_audio_apis_stack/000_plan.md | 41 +++++++++++++++ .../002_review_disposition.md | 7 +++ .../010_transcription.md | 50 ++++++++++++++++++ .../020_streaming_voice.md | 51 +++++++++++++++++++ .../030_connections.md | 45 ++++++++++++++++ 5 files changed, 194 insertions(+) create mode 100644 devlog/_plan/260912_audio_apis_stack/000_plan.md create mode 100644 devlog/_plan/260912_audio_apis_stack/002_review_disposition.md create mode 100644 devlog/_plan/260912_audio_apis_stack/010_transcription.md create mode 100644 devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md create mode 100644 devlog/_plan/260912_audio_apis_stack/030_connections.md diff --git a/devlog/_plan/260912_audio_apis_stack/000_plan.md b/devlog/_plan/260912_audio_apis_stack/000_plan.md new file mode 100644 index 0000000000..2f840a7547 --- /dev/null +++ b/devlog/_plan/260912_audio_apis_stack/000_plan.md @@ -0,0 +1,41 @@ +# Standalone audio APIs + +Expose file transcription, streaming dictation and GPT-Live to external clients with OpenCodex credentials. Connections gains separate Dictation and Live Voice sections. Existing OpenAI account selection and transport lifecycle remain the integration points. + +## Loop contract + +- Archetype: satisfy-spec, C4 API/auth and C3 dashboard integration. +- Trigger: owner requested both audio capabilities, inherited subagent verification and a published dependent PR stack. +- Goal: three independently reviewable ordinary PRs with protocol tests, documentation and usable client controls. +- Non-goals: merge, release, deployment, account configuration, microphone capture during agent QA, paid upstream probes, Responses protocol conversion, native GitHub stack registration. +- Verification: focused Bun transport tests observe mock upstream requests and real loopback sockets; typecheck observes tsconfig source includes; GUI build/lint/i18n and browser smoke observe rendered controls. Final review readiness also requires repository test runner and exact-head remote CI. +- Stop: all three PRs published with final-head evidence and no unresolved actionable independent findings. +- Artifacts: this unit for functional design; ignored .tmp/audio-security for trust-boundary working notes; session-bound goalplan and test receipts for orchestration. +- Outcomes: DONE means all criteria met; external dependency failures remain unmet with evidence. No invented budget exhaustion or scope reduction. +- Escalation: main resolves technical review findings; no additional authority for destructive operations or external messages. Main reclaims a failed delegated packet after two distinct agents; implementation delegation requires an explicit plan amendment. +- Resources: existing shell/Git/GitHub and inherited model tools; task worktree only; no new paid services, billable voice calls or personal recordings. User set no token, cost or wall-clock cap and allowed unrestricted parallel subagents. + +## Current ownership + +Source root: task-owned linked worktree on origin/dev ec065aa0c6fb46b376a2f01873bd677327b99150. Native session state stays in the original checkout. Existing uncommitted user work stays there. + +| Cycle | Plan | Branch / PR base | Output | +| --- | --- | --- | --- | +| wp0 | all documents in this unit | documentation checkpoint on first branch | audited complete roadmap | +| wp1 | 010_transcription.md | codex/audio-transcription -> dev | bounded file transcription and audio upstream contract | +| wp2 | 020_streaming_voice.md | codex/audio-streaming -> codex/audio-transcription | dictation stream and externally owned live sessions | +| wp3 | 030_connections.md | codex/audio-connections -> codex/audio-streaming | endpoint metadata, audio controls, examples and publication | + +All layers include their own regression coverage and source-of-truth sync. The branch base is the dependency edge; no branch is merged in this task. + +## Evidence and decisions + +Local Codex reference: 095da4b7e8b70b01afb5c6131ef926dcb8c0d85d, realtime default gpt-live-1-codex. Installed desktop package 26.908.40834 has separate file and dictation-stream transports. File API interoperability reference: Soju06/codex-lb 82567556f9f75ea13986667fc5282f035b7ca8d2, app/modules/proxy/api.py and app/core/clients/proxy.py. Its gpt-4o-transcribe subscription model is a compatibility identifier, not proof of the backend model. + +No native architect field is exposed by the host spawn schema. Main owns the plan; an inherited generic read-only design verifier supplies the consultation evidence. This is recorded as a transport deviation, not native architect completion. Independent A/code reviewers inherit the requested model/context; model-family independence is not claimed. + +Configuration alone cannot create the missing audio endpoints. Reuse the existing sidecar selection, admission, response envelope, stream limits and API workspace instead of a separate service or provider registry. + +## Progress + +wp0: planning in progress. Production code unchanged. diff --git a/devlog/_plan/260912_audio_apis_stack/002_review_disposition.md b/devlog/_plan/260912_audio_apis_stack/002_review_disposition.md new file mode 100644 index 0000000000..af90b33f1b --- /dev/null +++ b/devlog/_plan/260912_audio_apis_stack/002_review_disposition.md @@ -0,0 +1,7 @@ +# Roadmap review disposition + +Inherited design verification accepted the narrow sidecar reuse, bounded transport and call-lifetime decisions. Reflection found an ambiguous Location contract; 020 now requires a proxy-relative join path for external callers and independently trusted upstream destinations. Follow-up reflection: ALIGNED. + +Independent A round 1 returned FAIL on three concrete contract omissions. All accepted: 010 now specifies explicit-key resolution before loopback shortcut and stored-main Direct materialization under a lease; 020 specifies tagged key/native ownership on both listeners; HTTP-only AUTH_MATRIX gets protocol-correct multipart fixtures in api-key-attribution.test.ts, while WebSocket auth is separately advertised and tested with upgrades. + +Baseline command: bun test tests/server/server-live.test.ts tests/server/api-access-endpoints.test.ts. Result: 46 pass, 0 fail, 351 assertions at ec065aa0c6fb46b376a2f01873bd677327b99150 before production changes. This is baseline evidence, not feature verification. diff --git a/devlog/_plan/260912_audio_apis_stack/010_transcription.md b/devlog/_plan/260912_audio_apis_stack/010_transcription.md new file mode 100644 index 0000000000..b5429ec10c --- /dev/null +++ b/devlog/_plan/260912_audio_apis_stack/010_transcription.md @@ -0,0 +1,50 @@ +# File transcription + +Depends on wp0. This layer owns the unary audio transport and the shared upstream boundary used by wp2. + +## File changes + +| Operation | Path | Contract change | +| --- | --- | --- | +| NEW | src/server/audio-upstream.ts | audio model constants, proxy-owned upstream resolution reusing existing OpenAI sidecar auth; no provider registry mutation | +| NEW | src/server/audio-transcriptions.ts | parse bounded multipart, select transcription model, relay to subscription or keyed upstream, map response/errors and cancel | +| MODIFY | src/server/live.ts | retain public live exports; share only reusable upstream resolution as needed, preserving native call-create behavior | +| MODIFY | src/server/index.ts | register exact POST /v1/audio/transcriptions before unknown-v1 guard; perform admission/origin/drain checks before body consumption; record request outcome | +| MODIFY | src/server/auth-cors.ts | append matching transcription row to AUTH_MATRIX; preserve existing header precedence | +| MODIFY | tests/server/api-key-attribution.test.ts | send valid multipart for transcription matrix rows, retaining per-header denial and attribution assertions | +| NEW | tests/server/audio-transcriptions.test.ts | isolated home and synthetic file fixtures, mock canonical upstream fetch, actual ingress requests | +| MODIFY | scripts/test-layout/layout.json | register the new server-domain test | +| MODIFY | tests/fixtures/test-layout-expected.json | add matching expected test path | +| MODIFY | structure/data-planes/inbound-compat.md | describe the audio data-plane contract and owned source/test paths | +| MODIFY | docs-site/src/content/docs/reference/proxy-formats.md | document multipart audio usage and supported response fields | + +## Before / after contracts + +Before: unknown POST /v1/audio/transcriptions falls through to JSON 404. After: authentication and origin rejection run first; an admitted request reaches handleAudioTranscriptions(req, config, logCtx, lease). + +New handler accepts exactly one nonempty file, required model, optional prompt/language/response_format. gpt-4o-transcribe is the subscription compatibility model. Only explicitly supported JSON/text output is accepted; unsupported streaming/timestamp/temperature extensions fail clearly rather than being silently claimed. File limit 25,000,000 bytes; whole multipart limit 32 MiB; text field limits and duplicate checks bound parsing. No file is written to disk. The entire body is capped while reading before Web-platform multipart parsing; multipart overhead therefore remains bounded. + +Subscription destination is canonical https://chatgpt.com/backend-api/transcribe (remove only the known /codex suffix). Rebuild FormData so the boundary matches; send file and supported context fields, omit compatibility model and response_format. Keyed OpenAI destination is /v1/audio/transcriptions and receives the validated model/options. Use registered OpenAI account helpers; never forward the client OpenCodex secret. Existing native explicit credential flow remains available only under its existing admission contract. No generic caller-controlled destination. + +The resolver takes the already-resolved DataPlaneAdmission and uses resolveFirstUsableOpenAiSidecar directly, rather than invoking the complete Live handler. Retain account context/provider identity/outcome callback for wp2. For a proxy-owned Authorization value, remove only that admission credential before constructing upstream headers and use admitted stored-account resolution. Direct-mode behavior is explicitly tested; no automatic paid-provider fallback after a selected ChatGPT account error. Missing stored Direct credentials report unavailable rather than silently reclassifying an API key as a native token. + +New audio ingress resolves explicitly supplied credentials with resolveDataPlaneAdmissionSecret even on loopback; precedence is dedicated header, Bearer, x-api-key. Invalid explicit credentials cannot fall through to loopback admission. File transcription and dictation streaming require a valid proxy key on either listener. This rule is audio-owned and does not modify global resolveApiAuth behavior. + +For Direct with a valid proxy key, call resolveCodexAuthContext with mode=direct, substituteMainCredentialForDirect=true and beginCodexAccountSelection=codexAccountSelectionForTurn(lease); then materializeCodexUpstreamAuth with substituteMainCredential=true. This path claims stored-main ownership before reading it. A missing lease, draining main, missing/expired stored credential fails before I/O. Test successful stored-main substitution with isolated fake home, missing credentials and draining state. Native explicit caller auth keeps the existing sidecar path. + +Response is bounded before JSON parsing. JSON requires a string text field. text format returns text/plain. Forward sanitized machine-readable upstream status/errors, not arbitrary exception bodies. Client abort, upstream timeout and redirect rejection follow existing relay conventions; outcome callback and admission/sidecar cleanup must settle once. + +New transport types are internal: creation in audio-transcriptions, in-memory consumers only, no persistence or reviver. Public multipart is parsed once at ingress. AUTH_MATRIX is serialized by existing management response and validated by GUI isApiAuthMatrix; its consumer remains ApiKeysEndpointsPanel. + +## Acceptance and checks + +1. POST synthetic WAV with a configured proxy Bearer key: upstream receives the selected stored account/API credential and correct multipart bytes; client gets text. +2. Missing/wrong key and hostile Origin: 401/403 before upload read or upstream call. Main and optional companion listener policies are tested separately. +3. Missing/duplicate file/model, unsupported model or format, malformed multipart: explicit 400; no upstream call. +4. Declared and streamed body oversize, file oversize, text oversize: 413 at configured boundary; body cancellation observed. +5. JSON and text response modes, upstream malformed JSON and excessive response, redirect, timeout and abort: exact response and cleanup assertions. +6. Existing live and auth matrix tests retain their behavior. + +Commands: bun test tests/server/audio-transcriptions.test.ts tests/server/server-live.test.ts tests/server/api-keys-routes.test.ts; bun run typecheck; bun run structure:check; bun run privacy:scan. Before new test exists, its execution is NOT RUN; existing commands are declared in package.json and focused file arguments directly observe this layer. Full repository suite runs before review-ready publication, not as a documentation check. + +Security control activation cases and residual assumptions are recorded in ignored scratch, reviewed independently, and never copied into the public planning record. diff --git a/devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md b/devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md new file mode 100644 index 0000000000..68d6fa8392 --- /dev/null +++ b/devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md @@ -0,0 +1,51 @@ +# Streaming dictation and external live voice + +Depends on wp1 audio upstream resolution. Preserve existing Codex transport exports and native integrations. + +## File changes + +| Operation | Path | Contract change | +| --- | --- | --- | +| NEW | src/server/audio-dictation.ts | exact streaming route, canonical upstream URL/protocol construction and bounded session policy | +| NEW | src/server/live-call-bindings.ts | per-server bounded expiring call ownership, keyed by opaque call id and admission owner | +| MODIFY | src/server/live.ts | bind successful call-create to its resolved upstream; join via binding; proxy Bearer credential support; default V3 model/negotiation only on standalone /live when absent | +| MODIFY | src/server/index.ts | route dictation upgrades through existing bounded WebSocket bridge; thread lifecycle metadata and close cleanup | +| MODIFY | src/server/ws-bridge.ts | add only necessary session protocol/expiry fields to WsData | +| MODIFY | src/server/auth-cors.ts | advertise only HTTP call-create rows; WebSocket admission is separate audio metadata | +| MODIFY | tests/server/api-key-attribution.test.ts | valid SDP multipart fixtures for HTTP call-create matrix rows; WebSocket auth is tested by real upgrades in audio/server-live tests | +| NEW | tests/server/audio-dictation.test.ts | mock WebSocket upstream with real JSON audio events and close/cancel checks | +| MODIFY | tests/server/server-live.test.ts | external Bearer key and call-owner/session lifecycle regressions | +| MODIFY | scripts/test-layout/layout.json | register dictation test in server domain | +| MODIFY | tests/fixtures/test-layout-expected.json | matching expected test path | +| MODIFY | structure/data-planes/inbound-compat.md | document streams, ownership and source/test contract | +| MODIFY | docs-site/src/content/docs/reference/proxy-formats.md | document distinct dictation and GPT-Live wire examples and limitations | + +## Before / after contracts + +Before: existing native HTTP and WebSocket voice relay has no exported dictation transport. After: WS /v1/audio/transcriptions/stream resolves ChatGPT auth on the server and connects to wss://chatgpt.com/backend-api/dictation/stream with the observed chatgpt-dictation, openai-bearer token, codex-desktop subprotocols. Upstream token remains confined to server WebSocket construction, never the downstream selected protocol or errors. The route is explicitly an OpenCodex extension, not public Realtime API compatibility. + +Client uses observed session.start/config, audio append and session.close shapes; server events include session.started, transcript.segment/final and session.updated closed. Preserve text frames without UTF-8 reframing. Reuse existing pending-frame and frame-byte limits, add finite session lifetime consistent with five-minute desktop contract, and close both directions on error/abort/shutdown. Unsupported keyed-only dictation reports an actionable unavailable response. No automatic replay after audio has been accepted. + +Observed dictation audio is JSON {type: "audio.append", audio: "BASE64_PCM16"}; mono PCM16 uses the actual sample_rate_hz supplied by the client. transcript.segment/final revisions replace prior text for the same utterance_id. Closing acknowledgment is session.updated with session.status=closed. The requested limits are client policy, not proven upstream maxima. For browser clients the downstream protocol pair is opencodex-audio plus opencodex-key.; only opencodex-audio can be selected back. Explicit HTTP admission headers retain precedence. Never accept this carrier on ordinary Responses routes. + +Live call-create keeps SDP/multipart conversion and Location response. A successful call stores a bounded per-server binding to the resolved account/provider and caller admission identity for follow-up joins. Never retain raw client API keys in persisted state; no persistence is needed. Follow-up requests authenticate again, reject mismatched/expired owners, and cannot change the selected account. Existing native clients using the same local/session identity retain their workflow. Validate invalid Location before reporting usable creation. + +Bindings survive sideband disconnect for bounded reconnect; server shutdown clears them. Resolve the recorded exact account before join, preserving physical account identity across credential refresh. Tagged ownership distinguishes configured key ID, environment admission and legacy loopback native session. Unknown calls are rejected for proxy-key clients; any native externally-created-call compatibility must remain limited to explicit caller-auth and documented separately. Reuse the existing socket bridge within the composition root for this layer; extracting all legacy socket machinery is optional and requires its own regression evidence. + +Live resolves a presented proxy key before the global loopback shortcut, so a key-owned call remains configured-key owned on both listeners. Invalid explicit proxy credentials fail closed. Legacy native callers with no proxy key retain loopback/session identity only under existing local policy and cannot join configured-key-owned calls. The registry's tagged owner prevents any equality between native loopback and configured key IDs. Test same and different key joins on both primary and companion listeners. Do not add WS paths to the existing HTTP-only AUTH_MATRIX; advertise those through wp3 audio metadata and prove header/protocol admission with actual WebSocket upgrades. + +For external proxy-key call-create, return a proxy-relative Location /v1/live/ (or the matching realtime/calls form). Never return an absolute upstream Location to a proxy-key client. The upstream WebSocket destination is independently selected from trusted config, not from Location. Native legacy response compatibility remains scoped to its existing explicit caller-auth contract. A regression follows the returned relative Location with the creator key and checks the recorded account after pool rotation. + +Standalone WS /v1/live accepts an explicit model or defaults to gpt-live-1-codex, with gpt-live-1 as documented alias if implemented. Missing V3 negotiation is added only to this Frameless path. /v1/realtime preserves its current adapter semantics. A raw live client receives delegation events; proxy does not execute tools or fabricate delegation results. + +WsData fields are created at server.upgrade, serialized only by Bun in process, read at open/message/close, and disposed at relay closure; no disk reviver. Call binding types are in-memory only. Public query/model and protocol inputs are validated at ingress; no secret-bearing URL query authentication is added. + +## Acceptance and checks + +1. Synthetic streamed PCM JSON reaches the mock upstream under server-owned auth; transcript events reach the client byte-for-byte. +2. Wrong key/origin, malformed start/audio, large frames/pending data, missing ChatGPT capability: fail before further forwarding. +3. Session close, disconnect before upstream opens, expired session and upstream refusal: both sockets, timers and leases settle. +4. Two configured keys and two upstream accounts: creator can join even after pool selection changes; other key cannot join; unknown/expired binding fails. +5. Existing native sideband forms and standalone V1/V3 remain covered; HTTP SDP forwarding retains status/content-type and returns the documented proxy-relative Location for external clients. + +Commands: bun test tests/server/audio-dictation.test.ts tests/server/server-live.test.ts; bun run typecheck; bun run structure:check; bun run privacy:scan. New paths are NOT RUN until implemented. Real socket fixtures observe the actual branch and cleanup, not only helper return values. diff --git a/devlog/_plan/260912_audio_apis_stack/030_connections.md b/devlog/_plan/260912_audio_apis_stack/030_connections.md new file mode 100644 index 0000000000..ccb9fa24ce --- /dev/null +++ b/devlog/_plan/260912_audio_apis_stack/030_connections.md @@ -0,0 +1,45 @@ +# Connections audio controls and PR publication + +Depends on wp2 completed audio routes. The first two layers remain independently usable through external client examples. + +## File changes + +| Operation | Path | Contract change | +| --- | --- | --- | +| MODIFY | src/server/management/api-access.ts | extend ApiAccessEndpoints with transcription, dictationStream, live and realtimeCalls URLs plus truthful capability metadata | +| MODIFY | src/server/management-api.ts | include audio metadata using existing management authentication | +| MODIFY | tests/server/api-access-endpoints.test.ts | URL host/protocol and capability projection tests | +| MODIFY | gui/src/pages/api-keys-utils.ts | extend endpoint type/default/derive chain for new endpoints | +| MODIFY | gui/src/pages/ApiKeys.tsx | consume serialized endpoint metadata through KeysResponse, CachedKeysShape, cache validation and fetchKeys | +| MODIFY | gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx | place two unframed audio sections in existing Connections/API layout | +| NEW | gui/src/components/apikeys-workspace/AudioApiPanel.tsx | accessible Dictation and Live Voice controls, endpoint/model display, sample copying, transient key/file controls and result/error states | +| NEW | gui/src/audio-api-client.ts | bounded cancelable upload and socket client protocol helpers; no saved secrets | +| NEW | gui/tests/audio-api-client.test.ts | request generation, cancellation and transcript assembly tests | +| MODIFY | gui/src/i18n/en.ts and every locale module | complete localized label/status/action keys | +| MODIFY | gui/src/styles-apikeys-workspace.css | restrained aligned responsive audio sections using existing tokens | +| MODIFY | structure/gui-and-management-api.md | metadata and control ownership/current contract | +| MODIFY | docs-site/src/content/docs/reference/proxy-formats.md and localized counterparts | final client examples and explicit protocol support | + +## Before / after contracts + +Before: ApiAccessEndpoints contains Responses/chat/messages/models only. After: backend generates audio endpoints from the same resolved base, converts HTTP->WS and HTTPS->WSS with URL APIs, and reports configured availability separately from runtime-proven connectivity. GUI consumes these fields, with a conservative unavailable/unknown fallback for older servers. + +Metadata fields complete chain: creation buildApiAccessEndpoints -> JSON management response -> API page validation/mapping -> ApiEndpointInfo/AudioApiPanel. Defaults cannot claim configured availability. No new provider is registered and no audio model enters a text completion test. + +Dictation section contains model and endpoint copy actions, file input, transient API key input, transcribe/cancel, text result/copy and clear error states. Stream example names extension protocol and gives start/audio/close events. Live Voice section contains actual GPT-Live model and both WS/WebRTC connection endpoints, transient client key, a connect/disconnect test with status and observed event output. Browser WebSocket auth must use a short-lived local session mechanism or supported client protocol carrier; never expose ChatGPT credentials or persist raw keys. Do not create a fake success check or billable background probe. All test actions require a deliberate user click. + +UI is unframed and follows existing workspace colors/type/spacing. Icons reuse gui icons, all visible text is localized. At desktop and mobile widths long endpoint text wraps or scrolls within its own element without overlapping controls. Buttons have stable dimensions and stateful controls are keyboard reachable. + +Pass the existing active flag through ApiKeysWorkspace. Integrations hides panels without unmounting; requests, sockets and timers must stop on deactivation as well as unmount. Existing key rows contain only prefixes: controls use a newly generated key or an explicitly entered transient key, never pretend a key ID can authenticate. Browser voice connection uses an OpenCodex-only WebSocket protocol credential carrier accepted solely by the audio routes; exact supported carrier and precedence are documented/tested in wp2. No persistent key or query authentication. + +## Acceptance and publication + +1. API metadata correctly derives HTTPS/WSS, wildcard, IPv6 and companion-listener addresses and shows missing upstream as unavailable. +2. Mocked browser flow uploads a fixture, receives text, copies it, cancels a pending call and displays a server error. No real audio/provider requests during agent QA. +3. Mocked voice flow connects, observes a protocol event, disconnects and releases callbacks/timers; API keys never enter storage, screenshots or URL queries. +4. Desktop and mobile browser screenshots are read back and corrected. Screenshot attached to UI PR with synthetic data only. +5. Run GUI focused tests, lint:i18n, lint, build and repository typecheck/full suite before review ready; per-layer CI uses exact PR head. The final PRs fill Summary, Verification and Checklist plus ordinary stack map. + +Commands are defined by root/gui package.json. Source paths and existing stylesheet/fetch owner are revalidated at this cycle P before implementation; any renamed path is amended with exact ownership evidence. No disconnected metadata fields or fake audio model tests are acceptable. + +The three PRs use codex/audio-transcription -> dev, codex/audio-streaming -> codex/audio-transcription, codex/audio-connections -> codex/audio-streaming. Leave all open. Record test results and head/base SHAs without claiming human approval, merge or real provider availability. From 1e020e34cd902d58b707100a67a6758ff872a2da Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 17:42:05 +0900 Subject: [PATCH 02/10] feat(audio): expose standalone file transcription API --- .../_plan/260912_audio_apis_stack/000_plan.md | 2 +- .../010_transcription.md | 2 + .../011_transcription_checks.md | 11 + .../content/docs/reference/proxy-formats.md | 36 ++ scripts/test-layout/layout.json | 6 +- src/providers/openai-sidecar.ts | 23 +- src/server/audio-transcriptions.ts | 183 ++++++++ src/server/audio-upstream.ts | 146 +++++++ src/server/auth-cors.ts | 1 + src/server/index.ts | 21 + src/server/live.ts | 7 + structure/data-planes/inbound-compat.md | 17 + structure/providers/openai-tiers.md | 7 + structure/runtime.md | 3 +- tests/fixtures/test-layout-expected.json | 3 +- tests/server/api-key-attribution.test.ts | 9 +- tests/server/audio-transcriptions.test.ts | 390 ++++++++++++++++++ 17 files changed, 857 insertions(+), 10 deletions(-) create mode 100644 devlog/_plan/260912_audio_apis_stack/011_transcription_checks.md create mode 100644 src/server/audio-transcriptions.ts create mode 100644 src/server/audio-upstream.ts create mode 100644 tests/server/audio-transcriptions.test.ts diff --git a/devlog/_plan/260912_audio_apis_stack/000_plan.md b/devlog/_plan/260912_audio_apis_stack/000_plan.md index 2f840a7547..333e2abfd6 100644 --- a/devlog/_plan/260912_audio_apis_stack/000_plan.md +++ b/devlog/_plan/260912_audio_apis_stack/000_plan.md @@ -38,4 +38,4 @@ Configuration alone cannot create the missing audio endpoints. Reuse the existin ## Progress -wp0: planning in progress. Production code unchanged. +wp0: roadmap locked after independent PASS and resolved design reflection. Documentation checkpoint 5e4ade8432; staged whitespace check passed. Production code unchanged. Next cycle executes 010_transcription.md; protocol and auth decisions remain the dependency foundation. diff --git a/devlog/_plan/260912_audio_apis_stack/010_transcription.md b/devlog/_plan/260912_audio_apis_stack/010_transcription.md index b5429ec10c..ba5b6551db 100644 --- a/devlog/_plan/260912_audio_apis_stack/010_transcription.md +++ b/devlog/_plan/260912_audio_apis_stack/010_transcription.md @@ -48,3 +48,5 @@ New transport types are internal: creation in audio-transcriptions, in-memory co Commands: bun test tests/server/audio-transcriptions.test.ts tests/server/server-live.test.ts tests/server/api-keys-routes.test.ts; bun run typecheck; bun run structure:check; bun run privacy:scan. Before new test exists, its execution is NOT RUN; existing commands are declared in package.json and focused file arguments directly observe this layer. Full repository suite runs before review-ready publication, not as a documentation check. Security control activation cases and residual assumptions are recorded in ignored scratch, reviewed independently, and never copied into the public planning record. + +Implementation review amendment: readBodyCapped gains an optional AbortSignal to cancel its locked reader; the transcription operation registers a controller with its turn lease and has an overall deadline plus upload deadline. src/providers/openai-sidecar.ts releases probe ownership if header materialization or post-resolution validation fails before returning a context. Explicit validated native Direct caller auth uses the existing sidecar path; stored-main substitution applies only to proxy-key-only Direct. These narrow changes are prerequisites for safe reuse and preserve existing callers by default. diff --git a/devlog/_plan/260912_audio_apis_stack/011_transcription_checks.md b/devlog/_plan/260912_audio_apis_stack/011_transcription_checks.md new file mode 100644 index 0000000000..0b0a3677c9 --- /dev/null +++ b/devlog/_plan/260912_audio_apis_stack/011_transcription_checks.md @@ -0,0 +1,11 @@ +# Transcription implementation checks + +File transcription is implemented with explicit client-key admission, canonical OpenAI upstreams, bounded multipart and response handling, leased Direct substitution, explicit native Direct passthrough, and cancellation linked from upload through shutdown. Responses/chat routing is unchanged. + +Fresh targeted evidence: 24 audio regression tests pass; 17 test-layout tests pass; typecheck and structure checks pass. Import-connected check: bun run test:changed selected 326 of 1202 files and completed 8138 pass, 2 skip, 0 fail. The final two probe-release regressions were added afterwards and passed in the focused audio run. No paid upstream call or personal recording was used. + +Independent review found upload lifetime, shutdown cancellation, explicit Direct handling, probe cleanup and final-outcome defects; these were fixed and rechecked. The final bounded Noether review returned VERDICT: PASS. Additional probe-release tests cover both pre-return helper failure paths. + +The original ec065aa0c6 layout JSON incorrectly seeded cline-client.test.ts and cline-writer.test.ts as providers despite explicit clients ownership. This was reproduced using the original JSON and unchanged resolver. The two seed families now agree; no assertion or coverage was removed. + +Full repository review-readiness checks and synthetic curl QA are the remaining C gate. Next cycle consumes 020_streaming_voice.md and the completed audio upstream boundary. Real OpenAI/ChatGPT account entitlement and server behavior remain outside synthetic verification and are not claimed. diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 7cec1af766..c5fa271bf1 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -33,9 +33,45 @@ Credential-bearing model, image, video, and search requests do not automatically | Anthropic Messages | `POST /v1/messages` | Anthropic `message` JSON | Anthropic Messages SSE | | Anthropic token count | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | Not applicable | | Model discovery | `GET /v1/models` | Catalog or explicit Desktop snapshot | Not applicable | +| File transcription | `POST /v1/audio/transcriptions` | `{ "text": string }` or plain text | Not supported on this file endpoint | | Voice and Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | Relayed call-creation response | A separate sideband WebSocket relays frames in both directions | | Responses compaction | `POST /v1/responses/compact` | Replacement-history JSON | Not applicable | +## File transcription + +`POST /v1/audio/transcriptions` accepts an OpenCodex data-plane key in +`Authorization: Bearer`, `x-opencodex-api-key`, or `x-api-key`, including on a local +listener. An explicitly supplied invalid key is rejected. Upload one audio file +as multipart `file` and provide `model=gpt-4o-transcribe` for a connected ChatGPT +account. OpenCodex resolves the upstream credential; never supply a ChatGPT token +as the client API key. + +```bash +curl "$OPENCODEX_BASE_URL/audio/transcriptions" \ + -H "Authorization: Bearer $OPENCODEX_API_KEY" \ + -F 'model=gpt-4o-transcribe' \ + -F 'file=@recording.wav' \ + -F 'language=ko' +``` + +Set `OPENCODEX_BASE_URL` to your proxy URL ending in `/v1`. Optional fields are +`prompt`, `language`, and `response_format` (`json`, the default, or `text`). The +JSON result contains `text` only. Files must be nonempty and no larger than +25,000,000 bytes; multipart bodies are limited to 32 MiB and text fields to +16 KiB. The configured listener body limit can impose a smaller ceiling. +Duplicate or unsupported fields, including `stream`, are rejected. This endpoint +does not promise timestamps, diarization, subtitles, or token-usage metadata. + +The ChatGPT subscription path uses `gpt-4o-transcribe` as a compatibility identifier +and does not send a model name to the private transcription endpoint. It is not +evidence of the backend's internal model. An enabled OpenAI API-key provider also +supports `gpt-4o-mini-transcribe` and `whisper-1`; when a ChatGPT provider is selected, +an authentication failure does not silently switch to that paid provider. +Direct mode uses the stored main account under the existing profile admission +rules; Pool mode uses the selected stored account. Missing, expired or draining +credentials return an error. Cancellation stops the outbound request and audio +content is not written to request history. + ## `POST /v1/responses` This is the native opencodex data-plane shape. The request body must be a JSON object with a diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8f40c0714d..e4f59ef574 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -11,7 +11,7 @@ "domains": { "providers": { "match": [ - "^(?:aside(?!-profile)|auto|azure|baseten|chutes|cline|command|commandcode|context|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-" + "^(?:aside(?!-profile)|auto|azure|baseten|chutes|cline(?!-(?:client|writer))|command|commandcode|context|cyber|deepinfra|deepseek|digitalocean|exa|featherless|forward|hyperbolic|kimi|meta|mimo|minimax|moonshot|muse|new|nous|novita|nscale|nvidia|opencode|openrouter|qwen38|sambanova|umans|vercel|zcode|zhipu)-" ], "children": { "cursor": [ @@ -109,6 +109,7 @@ "clients": { "match": [ "^aside-profile(?!s-routes)", + "^cline-(?:client|writer)\\.test\\.ts$", "^(?:desktop|omp|pi|prime|remote|sync)-" ] }, @@ -216,6 +217,7 @@ "antigravity-baseurl-override.test.ts": "adapters/google", "antigravity-static-catalog.test.ts": "adapters/google", "api-access-endpoints.test.ts": "server", + "audio-transcriptions.test.ts": "server", "api-catalog-route.test.ts": "server", "api-codex-log-guard-compact.test.ts": "server", "api-codex-log-guard-protection.test.ts": "server", @@ -1386,4 +1388,4 @@ "web-search", "windows" ] -} \ No newline at end of file +} diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index 3b98a2b521..2b0b41d26d 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -5,6 +5,7 @@ import { hasCallerCodexBearer, isCodexAuthContextUsable, resolveCodexAuthContext, + releaseCodexAuthContextProbeLease, type CodexAccountSelectionAdmission, type CodexAuthContext, type CodexAuthPolicyConfig, @@ -152,11 +153,18 @@ export async function resolveFirstUsableOpenAiSidecar( admission: options.admission, beginCodexAccountSelection: options.beginCodexAccountSelection, }); - const selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, policy, exactAccount.modelId, options.admission); + let selectedHeaders: Headers; + try { + selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, policy, exactAccount.modelId, options.admission); + } catch (error) { + releaseCodexAuthContextProbeLease(authContext); + throw error; + } if ((authContext.kind !== "pool" && authContext.kind !== "main-pool") || !isCodexAuthContextUsable(authContext, config)) { // Exact selection is fail-closed. A generation/runtime-state race must not fall through // to the caller-bearer error or let a later candidate select another account. + releaseCodexAuthContextProbeLease(authContext); throw new CodexPoolAuthenticationError("Selected Codex account is unavailable"); } return { @@ -195,8 +203,17 @@ export async function resolveFirstUsableOpenAiSidecar( admission: options.admission, beginCodexAccountSelection: options.beginCodexAccountSelection, }); - const selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, policy, undefined, options.admission); - if (!isCodexAuthContextUsable(authContext, config)) continue; + let selectedHeaders: Headers; + try { + selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, policy, undefined, options.admission); + } catch (error) { + releaseCodexAuthContextProbeLease(authContext); + throw error; + } + if (!isCodexAuthContextUsable(authContext, config)) { + releaseCodexAuthContextProbeLease(authContext); + continue; + } return { ...candidate, authContext, diff --git a/src/server/audio-transcriptions.ts b/src/server/audio-transcriptions.ts new file mode 100644 index 0000000000..4de98b0b86 --- /dev/null +++ b/src/server/audio-transcriptions.ts @@ -0,0 +1,183 @@ +import { formatErrorResponse } from "../bridge"; +import { cancelBodyOnAbort, clearableDeadline, signalWithTimeout } from "../lib/abort"; +import type { AdmissionLease } from "../lib/admission"; +import { sidecarEnter } from "../lib/sidecar-tracker"; +import type { OcxConfig } from "../types"; +import type { DataPlaneAdmission } from "./auth-cors"; +import { resolveAudioUpstream, TRANSCRIPTION_MODEL } from "./audio-upstream"; +import { readBodyCapped } from "./live"; +import type { RequestLogContext } from "./request-log"; +import { registerTurn, unregisterTurn } from "./lifecycle"; + +export const AUDIO_FILE_MAX_BYTES = 25_000_000; +export const AUDIO_BODY_MAX_BYTES = 32 * 1024 * 1024; +const AUDIO_FIELD_MAX_BYTES = 16 * 1024; +const AUDIO_RESPONSE_MAX_BYTES = 2 * 1024 * 1024; +const AUDIO_TIMEOUT_MS = 120_000; +const FIELDS = new Set(["file", "model", "prompt", "language", "response_format"]); +const MODELS = new Set([TRANSCRIPTION_MODEL, "gpt-4o-mini-transcribe", "whisper-1"]); + +interface TranscriptionInput { + file: File; + model: string; + prompt?: string; + language?: string; + format: "json" | "text"; +} + +function invalid(message: string, status = 400): Response { + return formatErrorResponse(status, "invalid_request_error", message); +} + +async function parseTranscription(req: Request, operationSignal: AbortSignal): Promise { + if (!/^multipart\/form-data\s*;/i.test(req.headers.get("content-type") ?? "")) { + return invalid("Expected multipart/form-data with file and model"); + } + const encoding = req.headers.get("content-encoding")?.trim().toLowerCase(); + if (encoding && encoding !== "identity") return invalid("Compressed audio request bodies are not supported"); + const length = req.headers.get("content-length"); + if (length && /^\d+$/.test(length) && Number(length) > AUDIO_BODY_MAX_BYTES) { + await req.body?.cancel().catch(() => {}); + return invalid("Audio request exceeds 32 MiB", 413); + } + let form: FormData; + const uploadDeadline = clearableDeadline(30_000, operationSignal); + try { + const body = await readBodyCapped(req.body, AUDIO_BODY_MAX_BYTES, () => "Audio request too large", uploadDeadline.signal); + if (body instanceof Response) return invalid("Audio request exceeds 32 MiB", 413); + if (operationSignal.aborted) return formatErrorResponse(499, "client_closed_request", "Audio upload canceled"); + form = await new Response(body, { headers: { "content-type": req.headers.get("content-type")! } }).formData(); + } catch { + if (uploadDeadline.didExpire()) return formatErrorResponse(408, "request_timeout", "Audio upload timed out"); + return operationSignal.aborted + ? formatErrorResponse(499, "client_closed_request", "Audio upload canceled") + : invalid("Malformed audio multipart body"); + } finally { + uploadDeadline.clear(); + } + const seen = new Set(); + for (const [name, value] of form) { + if (!FIELDS.has(name)) return invalid(`Unsupported transcription field: ${name.slice(0, 64)}`); + if (seen.has(name)) return invalid(`Duplicate transcription field: ${name}`); + seen.add(name); + if (name !== "file" && (typeof value !== "string" || Buffer.byteLength(value) > AUDIO_FIELD_MAX_BYTES)) { + return invalid("Audio text fields must be strings no larger than 16 KiB", 413); + } + } + const file = form.get("file"); + if (!(file instanceof File) || file.size === 0) return invalid("A nonempty audio file is required"); + if (file.size > AUDIO_FILE_MAX_BYTES) return invalid("Audio file exceeds 25,000,000 bytes", 413); + const model = form.get("model"); + if (typeof model !== "string" || !MODELS.has(model)) return invalid("Unsupported transcription model"); + const format = form.get("response_format") ?? "json"; + if (format !== "json" && format !== "text") return invalid("response_format must be json or text"); + const prompt = form.get("prompt") as string | null; + const language = form.get("language") as string | null; + if (language !== null && !/^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})?$/.test(language)) return invalid("Invalid transcription language"); + return { file, model, format, ...(prompt !== null ? { prompt } : {}), ...(language !== null ? { language } : {}) }; +} + +async function transcribeAdmitted( + req: Request, + config: OcxConfig, + log: RequestLogContext, + admission: DataPlaneAdmission, + operation: { lease?: AdmissionLease; signal: AbortSignal; didExpire: () => boolean }, +): Promise { + const input = await parseTranscription(req, operation.signal); + if (input instanceof Response) return input; + if (operation.signal.aborted) return formatErrorResponse(499, "client_closed_request", "Audio request canceled"); + const relay = await resolveAudioUpstream(req.headers, config, log, { admission, model: input.model, lease: operation.lease, signal: operation.signal }); + if (relay instanceof Response) return relay; + const signal = signalWithTimeout(AUDIO_TIMEOUT_MS, operation.signal); + const exit = sidecarEnter("audio-transcription"); + let outcome: number | "timeout" | "connect_error" | undefined; + try { + if (!relay.keyed && input.model !== TRANSCRIPTION_MODEL) return invalid(`ChatGPT transcription supports only ${TRANSCRIPTION_MODEL}`); + const url = relay.keyed + ? `${relay.providerBaseUrl}/audio/transcriptions` + : "https://chatgpt.com/backend-api/transcribe"; + const headers = new Headers(); + for (const name of ["authorization", "chatgpt-account-id", "user-agent", "originator", "version"]) { + const value = new Headers(relay.headers).get(name); + if (value) headers.set(name, value); + } + if (!relay.keyed) { + const nativeAgent = req.headers.get("user-agent"); + headers.set("user-agent", nativeAgent && /^codex(?:[_ /-]|$)/i.test(nativeAgent) + ? nativeAgent.slice(0, 2048) : "codex_cli_rs"); + if (!headers.has("originator")) headers.set("originator", "codex_cli_rs"); + } + const form = new FormData(); + form.append("file", input.file, "audio" + (/\.[a-z0-9]{1,8}$/i.exec(input.file.name)?.[0] ?? ".webm")); + if (input.prompt !== undefined) form.append("prompt", input.prompt); + if (input.language !== undefined) form.append("language", input.language); + if (relay.keyed) { + form.append("model", input.model); + form.append("response_format", "json"); + } + const upstream = await fetch(url, { method: "POST", headers, body: form, signal: signal.signal, redirect: "manual" }); + outcome = upstream.ok ? 502 : upstream.status; + const detach = cancelBodyOnAbort(upstream.body, signal.signal); + let body: ArrayBuffer | Response; + try { + body = await readBodyCapped(upstream.body, AUDIO_RESPONSE_MAX_BYTES, () => "Audio upstream response too large", signal.signal); + } finally { + detach(); + } + if (body instanceof Response) return body; + if (!upstream.ok) { + const status = upstream.status >= 400 && upstream.status <= 599 ? upstream.status : 502; + return formatErrorResponse(status, "upstream_error", `Audio upstream returned HTTP ${upstream.status}`); + } + let payload: unknown; + try { payload = JSON.parse(new TextDecoder().decode(body)); } catch { + return formatErrorResponse(502, "upstream_error", "Audio upstream returned invalid JSON"); + } + if (!payload || typeof payload !== "object" || !("text" in payload) || typeof payload.text !== "string") { + return formatErrorResponse(502, "upstream_error", "Audio upstream response is missing text"); + } + outcome = upstream.status; + return input.format === "text" + ? new Response(payload.text, { headers: { "content-type": "text/plain; charset=utf-8" } }) + : Response.json({ text: payload.text }); + } catch { + if (operation.signal.aborted) { + outcome = operation.didExpire() ? "timeout" : undefined; + return formatErrorResponse(499, "client_closed_request", "Audio request canceled"); + } + const timedOut = signal.signal.aborted; + outcome = timedOut ? "timeout" : "connect_error"; + return formatErrorResponse(timedOut ? 504 : 502, "upstream_error", timedOut ? "Audio upstream timed out" : "Audio upstream connection failed"); + } finally { + try { + if (outcome !== undefined) relay.recordOutcome?.(outcome); + } finally { + relay.release(); + signal.cleanup(); + exit(); + } + } +} + +export async function handleAudioTranscriptions( + req: Request, + config: OcxConfig, + log: RequestLogContext, + admission: DataPlaneAdmission, + lease?: AdmissionLease, +): Promise { + const operation = new AbortController(); + if (lease) registerTurn(operation, lease); + const deadline = clearableDeadline(AUDIO_TIMEOUT_MS, AbortSignal.any([req.signal, operation.signal])); + try { + const response = await transcribeAdmitted(req, config, log, admission, { lease, signal: deadline.signal, didExpire: deadline.didExpire }); + if (req.signal.aborted) return formatErrorResponse(499, "client_closed_request", "Audio request canceled"); + if (operation.signal.aborted) return formatErrorResponse(503, "server_draining", "Audio request stopped during shutdown"); + if (deadline.didExpire()) return formatErrorResponse(504, "upstream_error", "Audio request timed out"); + return response; + } finally { + deadline.clear(); + if (lease) unregisterTurn(operation); + } +} diff --git a/src/server/audio-upstream.ts b/src/server/audio-upstream.ts new file mode 100644 index 0000000000..a59b7aef95 --- /dev/null +++ b/src/server/audio-upstream.ts @@ -0,0 +1,146 @@ +import { formatErrorResponse } from "../bridge"; +import { + CodexAccountCooldownError, + CodexMainProfileDrainingError, + codexMainProfileDrainingResponse, + cooldownErrorResponse, + isCodexAuthContextUsable, + materializeCodexUpstreamAuth, + releaseCodexAuthContextProbeLease, + resolveCodexAuthContext, + type CodexAuthContext, +} from "../codex/auth-context"; +import { formatCodexProviderForLog } from "../codex/routing"; +import type { AdmissionLease } from "../lib/admission"; +import { captureExplicitOpenAiCallerAuth, resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar"; +import type { OcxConfig } from "../types"; +import { + isProxyAdmissionSecret, + resolveDataPlaneAdmissionSecret, + validateForwardAdmissionCredential, + type DataPlaneAdmission, +} from "./auth-cors"; +import { codexAccountSelectionForTurn } from "./lifecycle"; +import type { RequestLogContext } from "./request-log"; + +export const TRANSCRIPTION_MODEL = "gpt-4o-transcribe"; +export const LIVE_AUDIO_MODEL = "gpt-live-1-codex"; + +/** Audio keys remain identifiable even on the otherwise unauthenticated local listener. */ +export function resolveAudioAdmission(headers: Headers, config: OcxConfig): DataPlaneAdmission | null { + const dedicated = headers.get("x-opencodex-api-key")?.trim(); + if (dedicated) return resolveDataPlaneAdmissionSecret(dedicated, config, "dedicated"); + const authorization = headers.get("authorization")?.trim(); + if (authorization) { + const token = /^Bearer\s+([^\s,]+)$/i.exec(authorization)?.[1]; + return token ? resolveDataPlaneAdmissionSecret(token, config, "bearer") : null; + } + const key = headers.get("x-api-key")?.trim(); + return key ? resolveDataPlaneAdmissionSecret(key, config, "x-api-key") : null; +} + +export interface AudioUpstream { + providerName: string; + providerBaseUrl: string; + headers: Record; + keyed: boolean; + authContext?: CodexAuthContext; + recordOutcome?: (status: number | "timeout" | "connect_error") => void; + release: () => void; +} + +export interface AudioUpstreamOptions { + admission: DataPlaneAdmission; + model: string; + lease?: AdmissionLease; + exactAccountId?: string; + signal?: AbortSignal; +} + +/** Keep client admission, account selection, and outbound credentials as separate values. */ +export async function resolveAudioUpstream( + incoming: Headers, + config: OcxConfig, + log: RequestLogContext, + options: AudioUpstreamOptions, +): Promise { + const candidates = selectOpenAiImagesProvider(config); + const headers = new Headers(incoming); + const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + if (bearer && isProxyAdmissionSecret(bearer, config)) { + headers.delete("authorization"); + headers.delete("chatgpt-account-id"); + } + headers.delete("x-opencodex-api-key"); + headers.delete("x-api-key"); + headers.delete("sec-websocket-protocol"); + let context: CodexAuthContext | undefined; + try { + options.signal?.throwIfAborted(); + const candidate = candidates.forwardCandidates[0]; + if (candidate) { + let selected: Headers; + let recordOutcome: AudioUpstream["recordOutcome"]; + const beginCodexAccountSelection = codexAccountSelectionForTurn(options.lease); + if (candidate.accountMode === "direct" && options.exactAccountId === undefined + && !captureExplicitOpenAiCallerAuth(incoming, config)) { + context = await resolveCodexAuthContext(headers, config, "direct", { + admission: options.admission, + substituteMainCredentialForDirect: true, + beginCodexAccountSelection, + signal: options.signal, + }); + selected = materializeCodexUpstreamAuth(headers, context, { + config, + admission: options.admission, + substituteMainCredential: true, + }); + } else { + const resolved = await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, headers, config, { + admission: options.admission, + beginCodexAccountSelection, + ...(options.exactAccountId ? { exactAccount: { accountId: options.exactAccountId, modelId: options.model } } : {}), + }); + if (!resolved) return formatErrorResponse(401, "authentication_error", "Connect a ChatGPT account to use audio"); + context = resolved.authContext; + selected = resolved.headers; + recordOutcome = resolved.recordOutcome; + } + options.signal?.throwIfAborted(); + if (!isCodexAuthContextUsable(context, config)) { + releaseCodexAuthContextProbeLease(context); + return formatErrorResponse(401, "authentication_error", "Selected audio account is unavailable"); + } + validateForwardAdmissionCredential(selected, config); + log.provider = formatCodexProviderForLog(candidate.providerName, context.accountId, config); + log.model = options.model; + return { + providerName: candidate.providerName, + providerBaseUrl: candidate.provider.baseUrl, + headers: Object.fromEntries(selected), + keyed: false, + authContext: context, + recordOutcome, + release: () => releaseCodexAuthContextProbeLease(context), + }; + } + if (candidates.keyed && options.exactAccountId === undefined) { + const { providerName, provider, apiKey } = candidates.keyed; + const selected = new Headers(provider.headers); + selected.set("authorization", `Bearer ${apiKey}`); + validateForwardAdmissionCredential(selected, config); + log.provider = providerName; + log.model = options.model; + return { + providerName, providerBaseUrl: provider.baseUrl, headers: Object.fromEntries(selected), + keyed: true, release: () => {}, + }; + } + return formatErrorResponse(400, "invalid_request_error", "Audio requires a connected ChatGPT account or OpenAI API provider"); + } catch (error) { + releaseCodexAuthContextProbeLease(context); + if (error instanceof CodexAccountCooldownError) return cooldownErrorResponse(error); + if (error instanceof CodexMainProfileDrainingError) return codexMainProfileDrainingResponse(); + return formatErrorResponse(401, "authentication_error", "Audio account authentication unavailable; reconnect the selected OpenAI account"); + } +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index 0910698a0c..ac00b0d4f7 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -427,6 +427,7 @@ export interface ApiAuthMatrixRow { * against every cell rather than reading the table back to itself. */ export const AUTH_MATRIX: readonly ApiAuthMatrixRow[] = [ + { endpoint: "/v1/audio/transcriptions", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }, // #1686: a bearer that is one of OUR admission secrets is now accepted here. It is safe // because materializeCodexUpstreamAuth substitutes the stored main credential rather than // forwarding it; a bearer that is NOT our secret stays unadmitted and remains Codex Direct diff --git a/src/server/index.ts b/src/server/index.ts index 2cb11c1e9f..c73cbce68a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -204,6 +204,8 @@ import { } from "../codex/native-profile-startup"; import { handleImages } from "./images"; import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live"; +import { handleAudioTranscriptions } from "./audio-transcriptions"; +import { resolveAudioAdmission, TRANSCRIPTION_MODEL } from "./audio-upstream"; import { handleSearch } from "./search"; import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "./management-api"; import { @@ -844,6 +846,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + const response = await handleAudioTranscriptions(req, config, logCtx, admission, lease); + addFinalRequestLog(requestId, start, logCtx, response.status); + return withCors(response, req, policy); + }); + } + // ChatGPT / Codex App voice (GPT‑Live / Frameless Bidi) + OpenAI Realtime call-create. // Clients hit either /v1/live (Frameless App) or /v1/realtime/calls (codex RealtimeCallClient / // public Realtime API). Sideband WS joins are handled just below. diff --git a/src/server/live.ts b/src/server/live.ts index 6d983ce0aa..48229814f9 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -423,14 +423,20 @@ export async function readBodyCapped( stream: ReadableStream | null, maxBytes: number, tooLargeMessage: (total: number) => string, + signal?: AbortSignal, ): Promise { if (!stream) return new ArrayBuffer(0); const reader = stream.getReader(); + const abortRead = () => { void reader.cancel(signal?.reason).catch(() => {}); }; + signal?.addEventListener("abort", abortRead, { once: true }); const chunks: Uint8Array[] = []; let total = 0; try { + if (signal?.aborted) abortRead(); + signal?.throwIfAborted(); for (;;) { const { done, value } = await reader.read(); + signal?.throwIfAborted(); if (done) break; if (!value || value.byteLength === 0) continue; total += value.byteLength; @@ -449,6 +455,7 @@ export async function readBodyCapped( await reader.cancel(err).catch(() => {}); throw err; } finally { + signal?.removeEventListener("abort", abortRead); try { // Always release: `reader.cancel()` does NOT drop the lock, and holding it would leave // the stream permanently locked for any later consumer (audit R-WP5-2). diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 2d17c11875..fd3a3cfdf2 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -1,5 +1,22 @@ # Inbound Compatibility Surfaces +## Standalone file transcription + +`src/server/audio-transcriptions.ts` owns `POST /v1/audio/transcriptions`, independently of +Responses and Chat conversion. `src/server/audio-upstream.ts` resolves explicit data-plane keys +on both listeners and substitutes stored OpenAI credentials. Direct stored-main access claims +the enclosing admission lease; Pool uses the existing sidecar account resolver. A selected +ChatGPT authentication failure never falls through to the paid OpenAI provider. + +The bounded multipart input accepts one nonempty file up to 25,000,000 bytes within a 32 MiB +body, required model, and optional prompt, language and JSON/text response format. Unknown or +duplicate fields fail. Subscription requests use the compatibility identifier gpt-4o-transcribe +and send no model upstream; the keyed API also accepts gpt-4o-mini-transcribe and whisper-1. +Responses retain only text. Manual redirects, capped response reads and linked cancellation +keep credentials and audio content out of redirects, request logs and durable storage. +`tests/server/audio-transcriptions.test.ts` exercises the real ingress and synthetic upstream; +`tests/server/api-key-attribution.test.ts` uses multipart fixtures for the HTTP auth matrix. + ## Chat Completions inbound native path `POST /v1/chat/completions` sends eligible `openai-chat` routes directly to the provider's Chat diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a44edff557..a2e2ab698d 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -386,6 +386,13 @@ HTTP/SSE, Responses WebSocket, compact, images, search, and vision resolve the s There is one mode-aware `openai` forward sidecar candidate; `openai-apikey` is not a ChatGPT-forward sidecar candidate and cannot hide a failed Codex credential with separately billed API usage. +`src/server/audio-upstream.ts` uses the same selection for standalone transcription. Explicit +native Direct auth remains caller-owned; proxy-key-only Direct claims stored main before +materialization. `src/providers/openai-sidecar.ts` releases quota-probe ownership on every +materialization or usability failure before transferring a resolved context to its caller. +Audio reports one terminal upstream outcome after validating the response body; redirects remain +neutral and client/shutdown cancellation does not manufacture an account failure. + The dashboard presents one OpenAI Codex card with accessible Pool/Direct controls and a separate, unchanged API-key card. `PATCH /api/providers?name=openai` persists exactly one `codexAccountMode`, clears affinity/quota cache, primes only when entering Pool, and does not refresh diff --git a/structure/runtime.md b/structure/runtime.md index 522e5cabb9..37adf3d2d4 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -9,6 +9,7 @@ | `src/cli/index.ts` | `ocx` / `opencodex` CLI. Lifecycle: init, start, stop, restart, status, sync, restore/eject, gui, service, update. Configuration: provider, account, models, combo/route, access, integrations, v2. Client launchers: Claude, OpenCode, MiniMax Code, and MiniMax CLI text. The MMX launcher owns a child-lifetime loopback path bridge from the client's hard-coded `/anthropic/v1/messages` path to the canonical `/v1/messages` data plane; the server does not expose an extra auth surface. Diagnostics: doctor, debug, observe, health. Windows adds tray. The full command surface is `src/cli/help.ts`; this table names the groups, not every verb. After help/version early exits, ordinary commands run the bounded best-effort Codex-shim auto-restore policy before dispatch. `system codex-cli-update` is the deliberate read-only exception and suppresses auto-restore for its whole namespace, including malformed invocations. Keeps the `#!/usr/bin/env bun` shebang for from-source dev (`bun run src/cli/index.ts`). | | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | +| `src/server/audio-transcriptions.ts` | Standalone multipart transcription; audio-specific key admission, bounded upload/response, stored OpenAI credential resolution and lease-bound cancellation. See [audio contracts](data-planes/inbound-compat.md#standalone-file-transcription). | | `src/config.ts` | Persisted `~/.opencodex/config.json` schema, defaults, migrations, transactions, and compatibility re-exports for split config modules. | | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | | `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. | @@ -61,7 +62,7 @@ listener, the optional unauthenticated data-loopback listener, and the optional listener. The data-loopback socket serves a fixed data-plane allowlist: Responses and its compact sibling, -the native search relay, the standalone Images POSTs, `GET /v1/models`, the realtime voice shapes, +the native search relay, the standalone Images POSTs, keyed file transcription, `GET /v1/models`, the realtime voice shapes, and the Anthropic and OpenAI chat wires the host's own local clients speak — `POST /v1/messages`, `POST /v1/messages/count_tokens`, and `POST /v1/chat/completions`. It never serves `/api/*`, `/healthz`, `/readyz`, or GUI routes, so local management discovery has to use an authenticated diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index f116d70a11..cb43e5b19b 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -51,6 +51,7 @@ "antigravity-baseurl-override.test.ts": "adapters/google", "antigravity-static-catalog.test.ts": "adapters/google", "api-access-endpoints.test.ts": "server", + "audio-transcriptions.test.ts": "server", "api-catalog-route.test.ts": "server", "api-codex-log-guard-compact.test.ts": "server", "api-codex-log-guard-protection.test.ts": "server", @@ -1196,4 +1197,4 @@ "zz-pr-coderabbit-readiness-revalidation.test.ts": "ci-workflows", "devin-cli-login.test.ts": "providers", "devin-cli-authmode-migration.test.ts": "providers" -} \ No newline at end of file +} diff --git a/tests/server/api-key-attribution.test.ts b/tests/server/api-key-attribution.test.ts index 6be92c4652..6bc42acf62 100644 --- a/tests/server/api-key-attribution.test.ts +++ b/tests/server/api-key-attribution.test.ts @@ -634,10 +634,15 @@ describe("AUTH_MATRIX is true of the running server", () => { // than admission. /v1/catalog joined this set in #809. const isGet = row.endpoint === "/v1/models" || row.endpoint === "/v1/catalog" || row.endpoint === "/v1/hub-state"; + const audio = row.endpoint === "/v1/audio/transcriptions" ? new FormData() : null; + if (audio) { + audio.append("model", "gpt-4o-transcribe"); + audio.append("file", new File([new Uint8Array([0, 0])], "sample.wav", { type: "audio/wav" })); + } const res = await fetch(new URL(row.endpoint, server.url), { method: isGet ? "GET" : "POST", - headers: { "content-type": "application/json", ...headers }, - ...(isGet ? {} : { body: JSON.stringify({ model: "test/gpt-test", input: "hi", messages: [{ role: "user", content: "hi" }] }) }), + headers: { ...(audio ? {} : { "content-type": "application/json" }), ...headers }, + ...(isGet ? {} : { body: audio ?? JSON.stringify({ model: "test/gpt-test", input: "hi", messages: [{ role: "user", content: "hi" }] }) }), }); // A 401 means the header was refused; anything else means it got past // admission (the upstream is disabled, so later failures are expected). diff --git a/tests/server/audio-transcriptions.test.ts b/tests/server/audio-transcriptions.test.ts new file mode 100644 index 0000000000..98e44e6626 --- /dev/null +++ b/tests/server/audio-transcriptions.test.ts @@ -0,0 +1,390 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearAccountQuota } from "../../src/codex/auth-api"; +import { clearMainAccountInfoCache } from "../../src/codex/main-account-cache"; +import { resetMainCodexAccountIdentityTrackingForTests } from "../../src/codex/account-lifecycle"; +import { waitForNativeMainStartupGate } from "../../src/codex/native-profile-startup"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import * as routing from "../../src/codex/routing"; +import * as authContext from "../../src/codex/auth-context"; +import { startServer } from "../../src/server"; +import { handleAudioTranscriptions, AUDIO_BODY_MAX_BYTES, AUDIO_FILE_MAX_BYTES } from "../../src/server/audio-transcriptions"; +import { resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import { acquireNativeMainProfileDrain, abortAndReleaseAllTurns, getActiveTurnCount, tryAdmitTurn } from "../../src/server/lifecycle"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { ownedServiceHomeInspection } from "../helpers/owned-service-home-inspection"; + +const KEY = "ocx_data_audio_test_client"; +const originalFetch = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +const previousToken = process.env.OPENCODEX_API_AUTH_TOKEN; +let home = ""; +let codex: IsolatedCodexHome; +let captured: Request[]; +let respond: (req: Request) => Response | Promise; +let server: ReturnType | undefined; + +function config(): OcxConfig { + return { + port: 0, hostname: "127.0.0.1", defaultProvider: "openai-apikey", openaiProviderTierVersion: 2, + providers: { "openai-apikey": { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "fixture-upstream-key", authMode: "key" } }, + apiKeys: [{ id: "audio-client", name: "audio", key: KEY, createdAt: "2026-09-12T00:00:00Z" }], + } as OcxConfig; +} + +function form(fields: Record = {}): FormData { + const data = new FormData(); + data.append("file", new File([new Uint8Array([82, 73, 70, 70, 0, 0])], "sample.wav", { type: "audio/wav" })); + data.append("model", "gpt-4o-transcribe"); + for (const [name, value] of Object.entries(fields)) data.set(name, value); + return data; +} + +function savePoolConfig(): OcxConfig { + const cfg = config(); + cfg.defaultProvider = "openai"; + cfg.providers = { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool" } }; + cfg.codexAccounts = [{ id: "pool-a", email: "pool@example.test", isMain: false, chatgptAccountId: "acct-pool-a" }]; + cfg.activeCodexAccountId = "pool-a"; + saveCodexAccountCredential("pool-a", { accessToken: fakeChatGptJwt({ chatgpt_account_id: "acct-pool-a" }), refreshToken: "fixture-refresh", expiresAt: Date.now() + 3_600_000, chatgptAccountId: "acct-pool-a" }); + saveConfig(cfg); + return cfg; +} + +async function request(body: BodyInit = form(), headers: Record = {}): Promise { + server ??= startServer(0, { inspectNativeCodexOwnership: ownedServiceHomeInspection("audio fixture") }); + await waitForNativeMainStartupGate(); + return originalFetch(new URL("/v1/audio/transcriptions", server.url), { + method: "POST", body, headers: { authorization: `Bearer ${KEY}`, ...headers }, + }); +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-audio-")); + process.env.OPENCODEX_HOME = home; + delete process.env.OPENCODEX_API_AUTH_TOKEN; + codex = installIsolatedCodexHome("ocx-audio-codex-"); + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + captured = []; + respond = () => Response.json({ text: "synthetic transcript" }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (["api.openai.com", "chatgpt.com"].includes(new URL(req.url).hostname)) { + if (!req.url.endsWith("/transcribe") && !req.url.endsWith("/audio/transcriptions")) return Response.json({}); + captured.push(req); + return respond(req); + } + throw new Error("Unexpected non-fixture outbound request"); + }) as typeof fetch; + saveConfig(config()); +}); + +afterEach(async () => { + await server?.stop(true); + server = undefined; + globalThis.fetch = originalFetch; + clearAccountQuota(); + clearCodexUpstreamHealth(); + clearThreadAccountMap(); + clearMainAccountInfoCache(); + resetMainCodexAccountIdentityTrackingForTests(); + resetLifecycleDrainStateForTests(); + codex.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + if (previousToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previousToken; + removeTreeWithRetry(home); +}); + +describe("standalone transcription API", () => { + test("forwards multipart bytes with upstream credentials and returns only transcript", async () => { + respond = () => Response.json({ text: "synthetic transcript", internal: "must not escape" }); + const response = await request(form({ prompt: "technical terms", language: "ko" })); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ text: "synthetic transcript" }); + expect(captured).toHaveLength(1); + const upstream = captured[0]!; + expect(upstream.url).toBe("https://api.openai.com/v1/audio/transcriptions"); + expect(upstream.headers.get("authorization")).toBe("Bearer fixture-upstream-key"); + expect(upstream.headers.get("x-opencodex-api-key")).toBeNull(); + const data = await upstream.formData(); + expect(data.get("model")).toBe("gpt-4o-transcribe"); + expect(data.get("prompt")).toBe("technical terms"); + expect(data.get("language")).toBe("ko"); + expect(Array.from(new Uint8Array(await (data.get("file") as File).arrayBuffer()))).toEqual([82, 73, 70, 70, 0, 0]); + }); + + test("text format is a text response while upstream uses JSON", async () => { + const response = await request(form({ response_format: "text" })); + expect(response.headers.get("content-type")).toContain("text/plain"); + expect(await response.text()).toBe("synthetic transcript"); + }); + + test("explicit bad dedicated key cannot fall through to a valid bearer on loopback", async () => { + expect((await request(form(), { "x-opencodex-api-key": "wrong" })).status).toBe(401); + expect((await request(form(), { authorization: "Bearer wrong" })).status).toBe(401); + expect((await request(form(), { authorization: "" })).status).toBe(401); + expect(captured).toHaveLength(0); + }); + + test("hostile origin is rejected even with a valid key", async () => { + expect((await request(form(), { origin: "https://untrusted.example" })).status).toBe(403); + expect(captured).toHaveLength(0); + }); + + test("duplicate, missing and unsupported fields fail before upstream", async () => { + const duplicate = form(); duplicate.append("model", "whisper-1"); + const missing = form(); missing.delete("file"); + for (const body of [duplicate, missing, form({ model: "chat-model" }), form({ stream: "true" }), form({ response_format: "srt" })]) { + expect((await request(body)).status).toBe(400); + } + expect(captured).toHaveLength(0); + }); + + test("malformed and compressed multipart are rejected", async () => { + expect((await request("broken", { "content-type": "multipart/form-data; boundary=test" })).status).toBe(400); + expect((await request(form(), { "content-encoding": "gzip" })).status).toBe(400); + expect(captured).toHaveLength(0); + }); + + test("file and text limits are enforced", async () => { + const oversized = form(); oversized.set("file", new File([new Uint8Array(AUDIO_FILE_MAX_BYTES + 1)], "large.wav")); + expect((await request(oversized)).status).toBe(413); + expect((await request(form({ prompt: "x".repeat(16 * 1024 + 1) }))).status).toBe(413); + expect(captured).toHaveLength(0); + }); + + test("chunked body limit cancels the reader before account resolution", async () => { + let canceled = false; + const body = new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(AUDIO_BODY_MAX_BYTES + 1)); }, + cancel() { canceled = true; }, + }); + const req = new Request("http://localhost/v1/audio/transcriptions", { method: "POST", headers: { "content-type": "multipart/form-data; boundary=x" }, body }); + const result = await handleAudioTranscriptions(req, config(), { model: "unknown", provider: "unknown" }, { kind: "configured", keyId: "audio-client", source: "bearer" }); + expect(result.status).toBe(413); + expect(canceled).toBe(true); + expect(captured).toHaveLength(0); + }); + + test("declared body limit rejects without pulling audio", async () => { + let canceled = false; + const req = new Request("http://localhost/v1/audio/transcriptions", { + method: "POST", headers: { "content-type": "multipart/form-data; boundary=x", "content-length": String(AUDIO_BODY_MAX_BYTES + 1) }, + body: new ReadableStream({ cancel() { canceled = true; } }), + }); + const response = await handleAudioTranscriptions(req, config(), { model: "unknown", provider: "unknown" }, { kind: "configured", keyId: "audio-client", source: "bearer" }); + expect(response.status).toBe(413); + expect(canceled).toBe(true); + expect(captured).toHaveLength(0); + }); + + test("client cancellation aborts the upstream request", async () => { + const controller = new AbortController(); + const ready = Promise.withResolvers(); + let aborted = false; + respond = req => new Promise((_resolve, reject) => { + req.signal.addEventListener("abort", () => { aborted = true; reject(req.signal.reason); }, { once: true }); + ready.resolve(); + }); + const pending = handleAudioTranscriptions(new Request("http://localhost/v1/audio/transcriptions", { + method: "POST", body: form(), signal: controller.signal, + }), config(), { model: "unknown", provider: "unknown" }, { kind: "configured", keyId: "audio-client", source: "bearer" }); + await ready.promise; + controller.abort(); + expect((await pending).status).toBe(499); + expect(aborted).toBe(true); + }); + + test("upload deadline cancels a stalled reader and releases its turn", async () => { + const schedule = globalThis.setTimeout; + let expire: (() => void) | undefined; + const timers = spyOn(globalThis, "setTimeout").mockImplementation(((callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) => { + if (delay === 30_000) expire = () => callback(...args); + return schedule(callback, delay, ...args); + }) as typeof setTimeout); + let canceled = false; + const lease = tryAdmitTurn()!; + try { + const pending = handleAudioTranscriptions(new Request("http://localhost/v1/audio/transcriptions", { + method: "POST", headers: { "content-type": "multipart/form-data; boundary=x" }, + body: new ReadableStream({ cancel() { canceled = true; } }), + }), config(), { model: "unknown", provider: "unknown" }, { kind: "configured", keyId: "audio-client", source: "bearer" }, lease); + expect(expire).toBeDefined(); + expire!(); + expect((await pending).status).toBe(408); + expect(canceled).toBe(true); + expect(getActiveTurnCount()).toBe(0); + expect(captured).toHaveLength(0); + } finally { timers.mockRestore(); lease.release(); } + }); + + test("shutdown aborts upstream through the registered turn controller", async () => { + const ready = Promise.withResolvers(); + let aborted = false; + respond = req => new Promise((_resolve, reject) => { + req.signal.addEventListener("abort", () => { aborted = true; reject(req.signal.reason); }, { once: true }); + ready.resolve(); + }); + const lease = tryAdmitTurn()!; + const pending = handleAudioTranscriptions(new Request("http://localhost/v1/audio/transcriptions", { + method: "POST", body: form(), + }), config(), { model: "unknown", provider: "unknown" }, { kind: "configured", keyId: "audio-client", source: "bearer" }, lease); + await ready.promise; + abortAndReleaseAllTurns(); + expect((await pending).status).toBe(503); + expect(aborted).toBe(true); + expect(getActiveTurnCount()).toBe(0); + }); + + test("malformed responses, upstream errors and redirects remain content-free errors", async () => { + for (const reply of [new Response("not-json"), Response.json({ missing: true }), new Response("private detail", { status: 429 }), new Response("", { status: 302, headers: { location: "https://untrusted.example" } })]) { + respond = () => reply; + const response = await request(); + expect(response.status).toBe(reply.status === 429 ? 429 : 502); + expect(await response.text()).not.toContain("private detail"); + } + }); + + test("oversized upstream result is rejected and canceled", async () => { + let canceled = false; + respond = () => new Response(new ReadableStream({ + start(controller) { controller.enqueue(new Uint8Array(2 * 1024 * 1024 + 1)); }, + cancel() { canceled = true; }, + })); + expect((await request()).status).toBe(502); + expect(canceled).toBe(true); + }); + + test("stored Direct credentials replace the proxy key", async () => { + writeFileSync(join(codex.path, "auth.json"), JSON.stringify({ tokens: { access_token: "fixture-main-access", account_id: "fixture-main-account" } })); + clearMainAccountInfoCache(); + const cfg = config(); + cfg.defaultProvider = "openai"; + cfg.providers = { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } }; + saveConfig(cfg); + expect((await request()).status).toBe(200); + expect(captured[0]!.url).toBe("https://chatgpt.com/backend-api/transcribe"); + expect(captured[0]!.headers.get("authorization")).toBe("Bearer fixture-main-access"); + expect(captured[0]!.headers.get("user-agent")).toBe("codex_cli_rs"); + expect(captured[0]!.headers.get("originator")).toBe("codex_cli_rs"); + expect((await captured[0]!.formData()).get("model")).toBeNull(); + }); + + test("a missing stored Direct credential fails without paid-provider fallback", async () => { + const cfg = config(); + cfg.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" }; + saveConfig(cfg); + expect((await request()).status).toBe(401); + expect(captured).toHaveLength(0); + }); + + test("a validated explicit Direct caller remains distinct from stored main", async () => { + const cfg = config(); + cfg.defaultProvider = "openai"; + cfg.providers = { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" } }; + saveConfig(cfg); + const token = fakeChatGptJwt({ chatgpt_account_id: "explicit-native" }); + expect((await request(form(), { "x-opencodex-api-key": KEY, authorization: `Bearer ${token}`, "chatgpt-account-id": "explicit-native" })).status).toBe(200); + expect(captured[0]!.headers.get("authorization")).toBe(`Bearer ${token}`); + expect(captured[0]!.headers.get("chatgpt-account-id")).toBe("explicit-native"); + }); + + test("draining stored Direct profile does not dispatch transcription", async () => { + writeFileSync(join(codex.path, "auth.json"), JSON.stringify({ tokens: { access_token: "fixture-main-access", account_id: "fixture-main-account" } })); + clearMainAccountInfoCache(); + const cfg = config(); + cfg.providers.openai = { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "direct" }; + saveConfig(cfg); + server = startServer(0, { inspectNativeCodexOwnership: ownedServiceHomeInspection("audio fixture") }); + await waitForNativeMainStartupGate(); + const drain = acquireNativeMainProfileDrain("audio-test"); + expect(drain).not.toBeNull(); + try { + expect((await request()).status).toBe(503); + expect(captured).toHaveLength(0); + } finally { drain?.release(); } + }); + + test("Pool substitutes the selected stored account and strips compatibility model", async () => { + savePoolConfig(); + expect((await request()).status).toBe(200); + expect(captured[0]!.headers.get("chatgpt-account-id")).toBe("acct-pool-a"); + expect(captured[0]!.headers.get("authorization")).not.toContain(KEY); + expect((await captured[0]!.formData()).get("model")).toBeNull(); + }); + + test("malformed Pool response records one failure and no provisional success", async () => { + savePoolConfig(); + respond = () => Response.json({ missing: "text" }); + const outcomes = spyOn(routing, "recordCodexUpstreamOutcome"); + try { + expect((await request()).status).toBe(502); + expect(outcomes.mock.calls.filter(call => call[1] === "pool-a").map(call => call[2])).toEqual([502]); + } finally { outcomes.mockRestore(); } + }); + + test("overall timeout records one Pool timeout after dispatch", async () => { + const cfg = savePoolConfig(); + const schedule = globalThis.setTimeout; + let expire: (() => void) | undefined; + const timers = spyOn(globalThis, "setTimeout").mockImplementation(((callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) => { + if (delay === 120_000 && !expire) expire = () => callback(...args); + return schedule(callback, delay, ...args); + }) as typeof setTimeout); + const ready = Promise.withResolvers(); + respond = req => new Promise((_resolve, reject) => { + req.signal.addEventListener("abort", () => reject(req.signal.reason), { once: true }); + ready.resolve(); + }); + const outcomes = spyOn(routing, "recordCodexUpstreamOutcome"); + const lease = tryAdmitTurn()!; + try { + const pending = handleAudioTranscriptions(new Request("http://localhost/v1/audio/transcriptions", { method: "POST", body: form() }), cfg, + { model: "unknown", provider: "unknown" }, { kind: "configured", keyId: "audio-client", source: "bearer" }, lease); + await ready.promise; + expect(expire).toBeDefined(); + expire!(); + expect((await pending).status).toBe(504); + expect(outcomes.mock.calls.filter(call => call[1] === "pool-a").map(call => call[2])).toEqual(["timeout"]); + } finally { outcomes.mockRestore(); timers.mockRestore(); lease.release(); } + }); + + test("Pool redirect is recorded once without penalizing the account", async () => { + savePoolConfig(); + respond = () => new Response("", { status: 302, headers: { location: "https://untrusted.example" } }); + const before = routing.getCodexUpstreamHealth("pool-a"); + const outcomes = spyOn(routing, "recordCodexUpstreamOutcome"); + try { + expect((await request()).status).toBe(502); + expect(outcomes.mock.calls.filter(call => call[1] === "pool-a").map(call => call[2])).toEqual([302]); + expect(routing.getCodexUpstreamHealth("pool-a")).toEqual(before); + } finally { outcomes.mockRestore(); } + }); + + test.each(["materialization", "usability"] as const)("Pool %s failure releases the acquired context before returning", async failure => { + savePoolConfig(); + const release = spyOn(authContext, "releaseCodexAuthContextProbeLease"); + const fault = failure === "materialization" + ? spyOn(authContext, "headersForCodexAuthContext").mockImplementation(() => { throw new Error("fixture materialization failure"); }) + : spyOn(authContext, "isCodexAuthContextUsable").mockReturnValue(false); + try { + expect((await request()).status).toBe(401); + expect(release.mock.calls.some(([ctx]) => ctx?.accountId === "pool-a")).toBe(true); + expect(captured).toHaveLength(0); + } finally { fault.mockRestore(); release.mockRestore(); } + }); +}); From 156e28f4bacc656e14fb2e39ddf09adaef80c2db Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 17:55:36 +0900 Subject: [PATCH 03/10] docs: record remote-only audio verification policy --- devlog/_plan/260912_audio_apis_stack/000_plan.md | 2 ++ .../260912_audio_apis_stack/011_transcription_checks.md | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/devlog/_plan/260912_audio_apis_stack/000_plan.md b/devlog/_plan/260912_audio_apis_stack/000_plan.md index 333e2abfd6..e237ea9722 100644 --- a/devlog/_plan/260912_audio_apis_stack/000_plan.md +++ b/devlog/_plan/260912_audio_apis_stack/000_plan.md @@ -4,6 +4,8 @@ Expose file transcription, streaming dictation and GPT-Live to external clients ## Loop contract +Owner steering during wp1 C: no local Bun suites, product tests, typecheck, build or dependency installation. Push with --no-verify and use exact-head remote CI for remaining executable verification. This supersedes every local command example in the layer plans. Already completed checks are historical evidence only; interrupted/crashed checks are not passing evidence. All active task-owned local suites were stopped. Functional layer closure uses the completed source review and pre-restriction focused evidence; PR readiness and final completion retain the remote CI gate under wp3 publication. + - Archetype: satisfy-spec, C4 API/auth and C3 dashboard integration. - Trigger: owner requested both audio capabilities, inherited subagent verification and a published dependent PR stack. - Goal: three independently reviewable ordinary PRs with protocol tests, documentation and usable client controls. diff --git a/devlog/_plan/260912_audio_apis_stack/011_transcription_checks.md b/devlog/_plan/260912_audio_apis_stack/011_transcription_checks.md index 0b0a3677c9..738be4ba17 100644 --- a/devlog/_plan/260912_audio_apis_stack/011_transcription_checks.md +++ b/devlog/_plan/260912_audio_apis_stack/011_transcription_checks.md @@ -8,4 +8,8 @@ Independent review found upload lifetime, shutdown cancellation, explicit Direct The original ec065aa0c6 layout JSON incorrectly seeded cline-client.test.ts and cline-writer.test.ts as providers despite explicit clients ownership. This was reproduced using the original JSON and unchanged resolver. The two seed families now agree; no assertion or coverage was removed. -Full repository review-readiness checks and synthetic curl QA are the remaining C gate. Next cycle consumes 020_streaming_voice.md and the completed audio upstream boundary. Real OpenAI/ChatGPT account entitlement and server behavior remain outside synthetic verification and are not claimed. +Synthetic curl QA completed against the built handler with a mocked canonical upstream: success 200, invalid key 401, unsupported model 400; teardown confirmed no listener. Documentation build completed 425 pages. A later expanded curl case correctly returned 413 after HTTP 100 Continue; the QA script misclassified that interim status, so the expanded run is not a passing receipt. + +The default full suite crashed inside Bun 1.4.2 with SIGSEGV on a separate immutable verification checkout. Its failed-file count includes aborted work and is not an assertion-failure count; baseline causation remains unproven. The serial diagnostic was interrupted by the owner's explicit no-local-suite instruction and is NOT PASS. All future product checks move to exact-head remote CI; first PR 4391 stays draft pending that gate. Local product tests/typecheck/build/install are NOT RUN after this steering, and pushes use --no-verify. + +wp1 functional implementation is complete based on the pre-restriction focused/affected checks and independent source review; remote review-readiness remains tracked by the publication criterion. Next cycle consumes 020_streaming_voice.md and the completed audio upstream boundary. Real OpenAI/ChatGPT account entitlement and server behavior remain outside synthetic verification and are not claimed. From 0d6e146e4a76994d7fb7f76f4cb1526433606875 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 18:37:23 +0900 Subject: [PATCH 04/10] test: repair baseline client and preimage CI fixtures --- gui/public/provider-icons/README.md | 5 +++++ gui/tests/fr-localization.test.ts | 3 +++ gui/tests/integrations-api.test.ts | 4 ++-- gui/tests/locale-parity.test.ts | 3 +++ src/cli/help.ts | 2 +- .../codex-inject-integration.test.ts | 22 +++++++++++++------ 6 files changed, 29 insertions(+), 10 deletions(-) diff --git a/gui/public/provider-icons/README.md b/gui/public/provider-icons/README.md index 39be97558e..de4986c702 100644 --- a/gui/public/provider-icons/README.md +++ b/gui/public/provider-icons/README.md @@ -12,6 +12,11 @@ License/source notes for the additional candidates are recorded in Export-client marks (used by the API tab's connect rows, not the provider list): +- `cline-color.svg` is reused from the existing provider-icon baseline described + above for Cline CLI, the same Cline brand. Its repository history predates this + client integration (blob introduced in `e088d46e94b453b848bf7e46dfc5bb069d64503b`). + The integration does not modify the SVG or claim a new asset license. + - `pi.svg` — fetched 2026-08-02 from `https://pi.dev/favicon.svg`, the Pi project's own favicon, unmodified. Pi is `earendil-works/pi` (formerly `badlogic/pi-mono`). diff --git a/gui/tests/fr-localization.test.ts b/gui/tests/fr-localization.test.ts index 04a3e03d9e..ccdb2bdd3b 100644 --- a/gui/tests/fr-localization.test.ts +++ b/gui/tests/fr-localization.test.ts @@ -123,6 +123,9 @@ const INTENTIONAL_ENGLISH = new Set([ "api.clientConfig.clientRaycast", "integrations.tab.omo", "api.clientConfig.clientOmo", + // Cline CLI is the product name in every locale. + "integrations.tab.cline", + "api.clientConfig.clientCline", "models.reasoningEffort.minimal", "models.reasoningEffort.max", "pws.pacingRpmUnit", diff --git a/gui/tests/integrations-api.test.ts b/gui/tests/integrations-api.test.ts index 7b540be78f..ca03ac2a87 100644 --- a/gui/tests/integrations-api.test.ts +++ b/gui/tests/integrations-api.test.ts @@ -16,9 +16,9 @@ import { const originalFetch = globalThis.fetch; -test("DSH, Aside, Raycast and omo are file integration clients", () => { +test("DSH, Aside, Raycast, omo and Cline are file integration clients", () => { expect(FILE_INTEGRATION_CLIENTS).toEqual([ - "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", + "opencode", "pi", "omp", "hermes", "openclaw", "kimi", "gajae", "dsh", "mcode", "zcode", "prime", "aside", "raycast", "omo", "cline", ]); }); diff --git a/gui/tests/locale-parity.test.ts b/gui/tests/locale-parity.test.ts index 883827baa7..92f0931d16 100644 --- a/gui/tests/locale-parity.test.ts +++ b/gui/tests/locale-parity.test.ts @@ -99,6 +99,9 @@ const ZH_TW_KEEP_ENGLISH: ReadonlySet = new Set([ "nav.grok", // Other short identifiers, commands, and product names kept in English "api.clientConfig.clientOpencode", + // Cline CLI is a product name, not untranslated interface copy. + "integrations.tab.cline", + "api.clientConfig.clientCline", "api.clientConfig.clientPi", "api.clientConfig.clientOmp", "api.clientConfig.clientHermes", diff --git a/src/cli/help.ts b/src/cli/help.ts index 70d53f9eb2..8608e5789d 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -81,7 +81,7 @@ Usage: ocx memory [--json] Alias of ocx observe memory ocx api-key Alias of ocx access key ocx access External API keys and endpoint information - ocx export --client Print a client config wired to the running proxy (14 clients) + ocx export --client Print a client config wired to the running proxy (15 clients) ocx integration client Enable, disable, inspect or roll back a client integration ocx grok Grok Build model selection and apply ocx system Runtime settings, startup, sync, OpenCodex updates, and Codex CLI inspection diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index e471961311..8b57165b1c 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -116,17 +116,23 @@ describe("injectCodexConfig integration (Design B)", () => { const target = join(process.env.CODEX_HOME,"opencodex.config.toml"); const configPath = join(process.env.CODEX_HOME,"config.toml"); fs.writeFileSync(configPath,'model="test"'); + const realRead = fs.readFileSync; + let failReads = false; + let deniedReads = 0; + const readSpy = spyOn(fs,"readFileSync").mockImplementation((path,...args)=>{ + if (failReads && String(path)===target) { + deniedReads++; + throw Object.assign(new Error("fixture denied"),{code:"EACCES"}); + } + return realRead(path,...args); + }); const {injectCodexConfig,restoreNativeCodex,restoreNativeCodexAsync}=require("./src/codex/inject"); const initial=await injectCodexConfig(10100,{}); if(!initial.success) throw new Error("fixture injection failed"); const watched=[configPath,target,join(process.env.CODEX_HOME,"opencodex-journal.json")]; const original=watched.map(path=>fs.readFileSync(path,"utf8")); - const realRead = fs.readFileSync; - const readSpy = spyOn(fs,"readFileSync").mockImplementation((path,...args)=>{ - if (String(path)===target) throw Object.assign(new Error("fixture denied"),{code:"EACCES"}); - return realRead(path,...args); - }); const {captureCodexPreImages,restoreCodexPreImages}=require("./src/codex/inject-coordination"); + failReads = true; let captureCode; try { captureCodexPreImages(); } catch(error) { captureCode=error.code; } const outcomes=[]; @@ -138,14 +144,16 @@ describe("injectCodexConfig integration (Design B)", () => { } const restored=restoreCodexPreImages({config:original[0],profile:original[1],journal:original[2]}); readSpy.mockRestore(); - console.log(JSON.stringify({captureCode,restored,outcomes,unchangedAfterEach,preserved:watched.every((p,i)=>realRead(p,"utf8")===original[i])})); + console.log(JSON.stringify({deniedReads,captureCode,restored,outcomes,unchangedAfterEach,preserved:watched.every((p,i)=>realRead(p,"utf8")===original[i])})); `; const child = spawnSync(process.execPath, ["--eval", script], { cwd: repoRoot, env: { ...process.env, CODEX_HOME: codexHome, OPENCODEX_HOME: ocxHome }, encoding: "utf8", timeout: SPAWN_BUDGET_MS - 5_000, }); expect(child.status, child.stderr).toBe(0); - expect(JSON.parse(child.stdout)).toEqual({ + const { deniedReads, ...result } = JSON.parse(child.stdout); + expect(deniedReads).toBeGreaterThanOrEqual(5); + expect(result).toEqual({ captureCode: "EACCES", restored: { complete: false, unrestored: ["profile"] }, outcomes: [true, true, true], unchangedAfterEach: [true, true, true], preserved: true, }); }); From fcaf4c54ed79232a2e27eb72ec559e75c85fd0ea Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 18:52:22 +0900 Subject: [PATCH 05/10] feat(audio): expose dictation streaming and owned live calls --- .../020_streaming_voice.md | 22 +- .../docs/fr/reference/proxy-formats.md | 2 + .../docs/ja/reference/proxy-formats.md | 2 + .../docs/ko/reference/proxy-formats.md | 2 + .../content/docs/reference/proxy-formats.md | 74 ++++++ .../docs/ru/reference/proxy-formats.md | 2 + .../docs/tr/reference/proxy-formats.md | 2 + .../docs/zh-cn/reference/proxy-formats.md | 2 + .../docs/zh-tw/reference/proxy-formats.md | 2 + scripts/test-layout/layout.json | 3 + src/providers/openai-sidecar.ts | 5 + src/server/audio-client.ts | 60 +++++ src/server/audio-dictation.ts | 91 +++++++ src/server/audio-live.ts | 185 +++++++++++++ src/server/audio-upstream.ts | 7 + src/server/auth-cors.ts | 2 + src/server/index.ts | 167 ++++++++++-- src/server/live-call-bindings.ts | 60 +++++ src/server/live.ts | 2 +- src/server/ws-bridge.ts | 10 + structure/data-planes/inbound-compat.md | 22 ++ structure/providers/openai-tiers.md | 6 + structure/runtime.md | 3 +- tests/fixtures/test-layout-expected.json | 3 + tests/server/api-key-attribution.test.ts | 10 +- tests/server/audio-client.test.ts | 61 +++++ tests/server/audio-dictation.test.ts | 243 ++++++++++++++++++ tests/server/live-call-bindings.test.ts | 45 ++++ 28 files changed, 1069 insertions(+), 26 deletions(-) create mode 100644 src/server/audio-client.ts create mode 100644 src/server/audio-dictation.ts create mode 100644 src/server/audio-live.ts create mode 100644 src/server/live-call-bindings.ts create mode 100644 tests/server/audio-client.test.ts create mode 100644 tests/server/audio-dictation.test.ts create mode 100644 tests/server/live-call-bindings.test.ts diff --git a/devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md b/devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md index 68d6fa8392..88df49026a 100644 --- a/devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md +++ b/devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md @@ -2,19 +2,27 @@ Depends on wp1 audio upstream resolution. Preserve existing Codex transport exports and native integrations. +P revalidation at 156e28f4ba: prior D completed the functional transcription layer and retained final-head CI as a publication gate. Production interface now resolves AudioUpstream with admission/model/lease/exactAccountId/signal and owns explicit-key admission before loopback fallback. Remote-only owner policy supersedes local test commands below; implement tests but run them only in CI. + +Dependency repair: remote run34684544174 identified pre-existing Cline count/expectation/provenance gaps and a preimage-read injection fixture whose interception was not observed. The bottom branch carries narrow corrections reviewed independently, with all original preimage assertions retained and an added interception counter. This upper branch must merge the updated parent before publication. No local product checks run; the fixture hypothesis is validated only by new remote CI. + ## File changes | Operation | Path | Contract change | | --- | --- | --- | | NEW | src/server/audio-dictation.ts | exact streaming route, canonical upstream URL/protocol construction and bounded session policy | +| NEW | src/server/audio-client.ts | audio-only browser credential carrier, explicit key detection and tagged owner identity | | NEW | src/server/live-call-bindings.ts | per-server bounded expiring call ownership, keyed by opaque call id and admission owner | -| MODIFY | src/server/live.ts | bind successful call-create to its resolved upstream; join via binding; proxy Bearer credential support; default V3 model/negotiation only on standalone /live when absent | +| NEW | src/server/audio-live.ts | external keyed call-create/join, account binding, safe Location and default standalone V3 negotiation; reuse Live protocol primitives | +| MODIFY | src/server/live.ts | expose the existing multipart conversion primitive for the external audio owner; preserve native handler behavior | | MODIFY | src/server/index.ts | route dictation upgrades through existing bounded WebSocket bridge; thread lifecycle metadata and close cleanup | | MODIFY | src/server/ws-bridge.ts | add only necessary session protocol/expiry fields to WsData | | MODIFY | src/server/auth-cors.ts | advertise only HTTP call-create rows; WebSocket admission is separate audio metadata | | MODIFY | tests/server/api-key-attribution.test.ts | valid SDP multipart fixtures for HTTP call-create matrix rows; WebSocket auth is tested by real upgrades in audio/server-live tests | | NEW | tests/server/audio-dictation.test.ts | mock WebSocket upstream with real JSON audio events and close/cancel checks | | MODIFY | tests/server/server-live.test.ts | external Bearer key and call-owner/session lifecycle regressions | +| NEW | tests/server/audio-client.test.ts | browser carrier, key precedence and stable key-rotation ownership | +| NEW | tests/server/live-call-bindings.test.ts | opaque call aliases, expiry, capacity and Location parsing | | MODIFY | scripts/test-layout/layout.json | register dictation test in server domain | | MODIFY | tests/fixtures/test-layout-expected.json | matching expected test path | | MODIFY | structure/data-planes/inbound-compat.md | document streams, ownership and source/test contract | @@ -28,6 +36,14 @@ Client uses observed session.start/config, audio append and session.close shapes Observed dictation audio is JSON {type: "audio.append", audio: "BASE64_PCM16"}; mono PCM16 uses the actual sample_rate_hz supplied by the client. transcript.segment/final revisions replace prior text for the same utterance_id. Closing acknowledgment is session.updated with session.status=closed. The requested limits are client policy, not proven upstream maxima. For browser clients the downstream protocol pair is opencodex-audio plus opencodex-key.; only opencodex-audio can be selected back. Explicit HTTP admission headers retain precedence. Never accept this carrier on ordinary Responses routes. +The protocol carrier suffix is canonical base64url of the UTF-8 proxy key, not a raw key; encoding is transport syntax, not encryption. Bound header/token sizes, reject duplicates and mismatched marker/key pairs, preserve explicit HTTP-header precedence and use only opencodex-audio as the selected downstream protocol. WsData receives upstream protocols through Bun.WebSocketOptions.protocols (verified in installed bun-types), plus optional validation/lifetime callbacks. Clear retained handshake credentials after connecting. This new field is constructed at upgrade, consumed by attachLiveSidebandUpstream and cleared on close; it never enters JSON persistence. + +The per-server registry stores only tagged owner identity, provider ID, account ID/physical account identity, endpoint policy and expiry. Configured-key owner identity uses key ID (rotation preserves it); environment identity uses a digest of the verified admission token. Proxy-key-created calls reject unknown, expired, mismatched owners before selecting credentials. Resolve the recorded account freshly and verify physical identity before joining; caller-owned native context must be resupplied. Do not keep credential snapshots as a substitute for ownership. Bound capacity at 1024 and TTL at 30 minutes; pruning is demand-driven and shutdown clears the map. + +Module-responsibility refinement: external keyed live HTTP/WS orchestration belongs in audio-live.ts so the native compatibility handler retains its existing behavior. index.ts selects this owner only after audio-client resolves a verified explicit key. Shared multipart conversion, URL builders and protocol header names stay owned by live.ts. The external create path has a lease-bound operation controller, upload/overall deadlines, one final outcome, and content-free failure responses. A bound join restricts provider selection to the recorded provider as well as recorded account; a newly enabled provider cannot displace the call owner. Opaque sideband close reasons are replaced with a generic reason for external audio clients. + +A audit fold-back: transfer AudioUpstream.release and recordOutcome into a once-only WsData.liveFinish callback only after server.upgrade succeeds. Before-transfer refusal/exception releases the acquired context and lease directly. The bridge calls liveFinish exactly once from finalizeLiveSideband when the upstream is observed CLOSED (or no upstream was created), then releases the turn lease in finally. Socket construction/open/error/timeout/close paths carry a single terminal outcome; client cancellation before connection is neutral. Clear handshake/session timers and retained headers/protocols on all finalization paths. Do not release ownership merely because downstream closed while upstream remains CONNECTING/CLOSING. + Live call-create keeps SDP/multipart conversion and Location response. A successful call stores a bounded per-server binding to the resolved account/provider and caller admission identity for follow-up joins. Never retain raw client API keys in persisted state; no persistence is needed. Follow-up requests authenticate again, reject mismatched/expired owners, and cannot change the selected account. Existing native clients using the same local/session identity retain their workflow. Validate invalid Location before reporting usable creation. Bindings survive sideband disconnect for bounded reconnect; server shutdown clears them. Resolve the recorded exact account before join, preserving physical account identity across credential refresh. Tagged ownership distinguishes configured key ID, environment admission and legacy loopback native session. Unknown calls are rejected for proxy-key clients; any native externally-created-call compatibility must remain limited to explicit caller-auth and documented separately. Reuse the existing socket bridge within the composition root for this layer; extracting all legacy socket machinery is optional and requires its own regression evidence. @@ -36,6 +52,10 @@ Live resolves a presented proxy key before the global loopback shortcut, so a ke For external proxy-key call-create, return a proxy-relative Location /v1/live/ (or the matching realtime/calls form). Never return an absolute upstream Location to a proxy-key client. The upstream WebSocket destination is independently selected from trusted config, not from Location. Native legacy response compatibility remains scoped to its existing explicit caller-auth contract. A regression follows the returned relative Location with the creator key and checks the recorded account after pool rotation. +The externally returned call ID is a proxy-generated rtc_ocx_ alias mapped to the validated upstream call ID, retaining compatibility with rtc_-accepting clients. The reserved rtc_ocx_ namespace never falls through to legacy joins, including after expiry/removal. This prevents an expired external alias from being reclassified as an unowned native call. The binding also preserves the originating Frameless/realtime join style; aliases do not let the caller change the upstream protocol family. + +Keyed upstreams have no verified physical account ID, so their bindings additionally retain a digest of the upstream credential. Fresh join resolution compares that digest and refuses a changed credential instead of guessing that a replacement key owns the original call. This digest is created at call registration, copied only in the in-memory registry, consumed at join and removed at expiry/shutdown; it is not serialized or exposed. + Standalone WS /v1/live accepts an explicit model or defaults to gpt-live-1-codex, with gpt-live-1 as documented alias if implemented. Missing V3 negotiation is added only to this Frameless path. /v1/realtime preserves its current adapter semantics. A raw live client receives delegation events; proxy does not execute tools or fabricate delegation results. WsData fields are created at server.upgrade, serialized only by Bun in process, read at open/message/close, and disposed at relay closure; no disk reviver. Call binding types are in-memory only. Public query/model and protocol inputs are validated at ingress; no secret-bearing URL query authentication is added. diff --git a/docs-site/src/content/docs/fr/reference/proxy-formats.md b/docs-site/src/content/docs/fr/reference/proxy-formats.md index 04637428e1..3c6d010973 100644 --- a/docs-site/src/content/docs/fr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/fr/reference/proxy-formats.md @@ -276,6 +276,8 @@ Voir [le guide Desktop](/fr/guides/claude-code/). Relecture thinking et cache re ## `POST /v1/live` et bande latérale en temps réel +La liaison de compte ci-dessous concerne les clients Codex natifs. Pour la dictée et GPT-Live avec une clé API externe, consultez la [spécification audio en anglais](/reference/proxy-formats/#streaming-dictation). + `POST /v1/live` accepte la surface de création d'appel ChatGPT/Codex App sans cadre. `POST /v1/realtime/calls` accepte la surface de création d'appel OpenAI Realtime. opencodex sélectionne un route OpenAI-family éligible, normalise la demande de création d'appel pour l'authentification en amont diff --git a/docs-site/src/content/docs/ja/reference/proxy-formats.md b/docs-site/src/content/docs/ja/reference/proxy-formats.md index 7f875af2f5..565321c19d 100644 --- a/docs-site/src/content/docs/ja/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ja/reference/proxy-formats.md @@ -202,6 +202,8 @@ API ではありません。Desktop のキー移行・復旧・切断は既存 ## `POST /v1/live` とRealtime サイドバンド +以下のアカウント連携は既存の Codex クライアント向けです。外部 API キーで利用する音声入力と GPT-Live は[英語版の音声 API 仕様](/reference/proxy-formats/#streaming-dictation)を参照してください。 + `POST /v1/live` は、ChatGPT/Codex アプリのフレームレス通話作成サーフェスを受け入れます。 `POST /v1/realtime/calls` は、OpenAI Realtime 呼び出し作成サーフェスを受け入れます。 opencodex は、適格な OpenAI ファミリ ルートを選択し、アップストリーム認証モードのコール作成リクエストを正規化し、制限付き応答を中継します。 コールの作成後、クライアントはサポートされている受信フォームを使用してサイドバンド WebSocket に参加できます。 diff --git a/docs-site/src/content/docs/ko/reference/proxy-formats.md b/docs-site/src/content/docs/ko/reference/proxy-formats.md index a537d77a73..969ed6bf63 100644 --- a/docs-site/src/content/docs/ko/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ko/reference/proxy-formats.md @@ -249,6 +249,8 @@ HTTP 400을 반환합니다. 두 경우 모두 날짜 제거나 다른 경로로 ## `POST /v1/live`와 Realtime sideband +아래 계정 연결 설명은 기존 Codex 클라이언트 기준입니다. 외부 API 키로 쓰는 받아쓰기와 GPT-Live는 [영문 음성 API 명세](/reference/proxy-formats/#streaming-dictation)를 따릅니다. + `POST /v1/live`는 ChatGPT/Codex App Frameless call-creation 표면을 받습니다. `POST /v1/realtime/calls`는 OpenAI Realtime call-creation 표면을 받습니다. opencodex는 적절한 OpenAI 계열 경로를 선택하고, 업스트림 인증 모드에 맞게 call-creation 요청을 정규화한 뒤, 제한된 응답을 릴레이합니다. diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index c5fa271bf1..d17445adc9 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -34,6 +34,7 @@ Credential-bearing model, image, video, and search requests do not automatically | Anthropic token count | `POST /v1/messages/count_tokens` | `{ "input_tokens": number }` | Not applicable | | Model discovery | `GET /v1/models` | Catalog or explicit Desktop snapshot | Not applicable | | File transcription | `POST /v1/audio/transcriptions` | `{ "text": string }` or plain text | Not supported on this file endpoint | +| Streaming dictation | `WS /v1/audio/transcriptions/stream` | Not applicable | Desktop dictation JSON events | | Voice and Realtime | `POST /v1/live`, `POST /v1/realtime/calls` | Relayed call-creation response | A separate sideband WebSocket relays frames in both directions | | Responses compaction | `POST /v1/responses/compact` | Replacement-history JSON | Not applicable | @@ -72,6 +73,40 @@ rules; Pool mode uses the selected stored account. Missing, expired or draining credentials return an error. Cancellation stops the outbound request and audio content is not written to request history. +## Streaming dictation + +`WS /v1/audio/transcriptions/stream` is an OpenCodex extension for a connected +ChatGPT account. It is separate from OpenAI's public Realtime transcription +protocol. Authenticate with the same proxy-key headers as file transcription. +Browser clients instead offer these two WebSocket subprotocols: + +```text +opencodex-audio +opencodex-key. +``` + +Only `opencodex-audio` is selected in the response. Encoding is transport syntax, +not encryption. Explicit HTTP credential headers take precedence. ChatGPT tokens +stay on the proxy; an API-key-only upstream cannot serve this dictation protocol. + +After connecting, send: + +```json +{"type":"session.start","config":{"input_audio_format":"pcm16","sample_rate_hz":48000,"num_channels":1,"max_buffer_size_bytes":4194304,"max_utterance_duration_ms":30000,"session_ttl_ms":300000,"provider_mode":"streaming_sse","transcript_delivery_mode":"segment","vad":{"type":"server_vad","threshold":0.5,"prefix_padding_ms":300,"silence_duration_ms":500}}} +``` + +Use the actual sample rate of mono PCM16 audio. Wait for `session.started`, then +send `{"type":"audio.append","audio":""}`. These are JSON text +frames, not WAV files or binary WebSocket frames. The gateway accepts sample +rates from 8,000 through 192,000 Hz; upstream support is account/service-dependent. +Client frames are limited to 64 KiB and sessions to five minutes. Unsupported or +malformed event/config fields close the stream with code 1008. + +`transcript.segment` and `transcript.final` contain `utterance_id`, `revision`, and +`text`. Replace prior text for the same utterance when its revision increases; +do not concatenate revisions. Finish with `{"type":"session.close"}` and wait +for final text and `session.updated` with `session.status="closed"`. + ## `POST /v1/responses` This is the native opencodex data-plane shape. The request body must be a JSON object with a @@ -420,6 +455,45 @@ Thinking replay and prompt-cache work remain separate in [#3719](https://github. ## `POST /v1/live` and Realtime sideband +### External API keys + +External clients use an OpenCodex key in any supported audio credential header, +or the browser subprotocol pair described above. Standalone +`WS /v1/live?model=gpt-live-1-codex` uses the Frameless protocol; an omitted model +defaults to that identifier and `gpt-live-1` is its proxy alias. This is not a +claim that every public OpenAI Realtime SDK or API key supports GPT-Live. + +For a new standalone connection, send the source-compatible initialization below +after socket open and wait for `session.started` with a nonempty `session.id`. +An updated-session event with the same shape is also accepted by the native client. + +```json +{"type":"session.update","session":{"instructions":"","audio":{"output":{"voice":"cove"}},"delegation":{"type":"client"}}} +``` + +Frameless uses `input_audio.append` and `output_audio.delta`, unlike dictation's +`audio.append`. A connection-only check needs no microphone or audio frames; send +`{"type":"session.close"}` and close the socket after readiness. Delegation +events are work requests, not readiness signals, and the external client owns +their execution and responses. + +For WebRTC, post an SDP offer to `/v1/live` as multipart `sdp` and optional JSON +`session`, JSON `{sdp, session?}`, or raw `application/sdp`. The response contains +the answer and a proxy-relative `Location` with an opaque `rtc_ocx_` call ID. Join +that location using the same proxy key. The proxy resolves the creating provider +and physical account even if Pool selection changes. Unknown, expired or +other-key aliases fail before an upstream connection. Client key rotation +preserves ownership by key ID; replacement of a keyed upstream credential +requires a new call. Existing-call sidebands do not need another session update. + +Call bindings last 30 minutes, are bounded to 1024 entries per server, and end on +server restart. Socket lifetimes are bounded independently; media travels +directly over WebRTC and is not proxied. The proxy never executes delegation +requests. OpenAI account availability is established by the actual upstream +response, not by the presence of a model name in the dashboard. + +### Native Codex compatibility + `POST /v1/live` accepts the ChatGPT/Codex App Frameless call-creation surface. `POST /v1/realtime/calls` accepts the OpenAI Realtime call-creation surface. opencodex selects an eligible OpenAI-family route, normalizes the call-creation request for the upstream authentication diff --git a/docs-site/src/content/docs/ru/reference/proxy-formats.md b/docs-site/src/content/docs/ru/reference/proxy-formats.md index 04934d0c50..e99fdb46b5 100644 --- a/docs-site/src/content/docs/ru/reference/proxy-formats.md +++ b/docs-site/src/content/docs/ru/reference/proxy-formats.md @@ -260,6 +260,8 @@ HTTP 400. В обоих случаях дата не удаляется и др ## `POST /v1/live` и Realtime sideband +Привязка аккаунта ниже относится к штатным клиентам Codex. Для диктовки и GPT-Live с внешним API-ключом см. [спецификацию аудио API на английском](/reference/proxy-formats/#streaming-dictation). + `POST /v1/live` принимает surface Frameless call-creation из ChatGPT/Codex App. `POST /v1/realtime/calls` принимает surface call-creation OpenAI Realtime. opencodex выбирает подходящий маршрут семейства OpenAI, нормализует запрос call-creation под нужный режим diff --git a/docs-site/src/content/docs/tr/reference/proxy-formats.md b/docs-site/src/content/docs/tr/reference/proxy-formats.md index 84ad0cf5e1..e62daa374f 100644 --- a/docs-site/src/content/docs/tr/reference/proxy-formats.md +++ b/docs-site/src/content/docs/tr/reference/proxy-formats.md @@ -281,6 +281,8 @@ Thinking yeniden gönderimi ve önbellek, ayrı [#3719](https://github.com/lidge ## `POST /v1/live` ve Realtime yan bandı +Aşağıdaki hesap bağlantısı yerel Codex istemcileri içindir. Harici API anahtarıyla dikte ve GPT-Live kullanımı için [İngilizce ses API belirtimine](/reference/proxy-formats/#streaming-dictation) bakın. + `POST /v1/live`, ChatGPT/Codex App Frameless çağrı oluşturma yüzeyini kabul eder. `POST /v1/realtime/calls`, OpenAI Realtime çağrı oluşturma yüzeyini kabul eder. opencodex uygun bir OpenAI ailesi rotası seçer, yukarı akış kimlik diff --git a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md index 9f83638d3c..2c9e019b03 100644 --- a/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-cn/reference/proxy-formats.md @@ -213,6 +213,8 @@ thinking 重放与提示缓存仍由独立的 [#3719](https://github.com/lidge-j ## `POST /v1/live` 和 Realtime sideband +下文的账户绑定说明适用于原生 Codex 客户端。通过外部 API 密钥使用语音转写和 GPT-Live,请参阅[英文音频 API 规范](/reference/proxy-formats/#streaming-dictation)。 + `POST /v1/live` 接受 ChatGPT/Codex App 的 Frameless call-creation 表面。 `POST /v1/realtime/calls` 接受 OpenAI Realtime 的 call-creation 表面。opencodex 会选择 一个符合条件的 OpenAI 家族路由,将 call-creation 请求规范化为上游认证模式,并转发有界响应。 diff --git a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md index 483319ac58..5c1672662e 100644 --- a/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md +++ b/docs-site/src/content/docs/zh-tw/reference/proxy-formats.md @@ -191,6 +191,8 @@ thinking 重播與提示快取仍由獨立的 [#3719](https://github.com/lidge-j ## `POST /v1/live` 與 Realtime sideband +下方帳戶綁定說明適用於原生 Codex 用戶端。透過外部 API 金鑰使用語音轉寫及 GPT-Live,請參閱[英文音訊 API 規格](/reference/proxy-formats/#streaming-dictation)。 + `POST /v1/live` 接受 ChatGPT/Codex App Frameless call-creation 介面。 `POST /v1/realtime/calls` 接受 OpenAI Realtime call-creation 介面。opencodex 選擇一個合格的 OpenAI 家族路由、為上游認證模式正規化 call-creation 請求,並中繼有界的回應。 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e4f59ef574..cee2d1b5ba 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -218,6 +218,9 @@ "antigravity-static-catalog.test.ts": "adapters/google", "api-access-endpoints.test.ts": "server", "audio-transcriptions.test.ts": "server", + "audio-client.test.ts": "server", + "audio-dictation.test.ts": "server", + "live-call-bindings.test.ts": "server", "api-catalog-route.test.ts": "server", "api-codex-log-guard-compact.test.ts": "server", "api-codex-log-guard-protection.test.ts": "server", diff --git a/src/providers/openai-sidecar.ts b/src/providers/openai-sidecar.ts index 2b0b41d26d..1021e6a5c6 100644 --- a/src/providers/openai-sidecar.ts +++ b/src/providers/openai-sidecar.ts @@ -130,6 +130,7 @@ export async function resolveFirstUsableOpenAiSidecar( admission?: Pick; codexAuthPolicy?: CodexAuthPolicyConfig; beginCodexAccountSelection?: () => CodexAccountSelectionAdmission | undefined; + signal?: AbortSignal; } = {}, ): Promise { const { exactAccount } = options; @@ -152,9 +153,11 @@ export async function resolveFirstUsableOpenAiSidecar( modelId: exactAccount.modelId, admission: options.admission, beginCodexAccountSelection: options.beginCodexAccountSelection, + signal: options.signal, }); let selectedHeaders: Headers; try { + options.signal?.throwIfAborted(); selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, policy, exactAccount.modelId, options.admission); } catch (error) { releaseCodexAuthContextProbeLease(authContext); @@ -202,9 +205,11 @@ export async function resolveFirstUsableOpenAiSidecar( codexAuthPolicy: policy, admission: options.admission, beginCodexAccountSelection: options.beginCodexAccountSelection, + signal: options.signal, }); let selectedHeaders: Headers; try { + options.signal?.throwIfAborted(); selectedHeaders = headersForCodexAuthContext(incomingHeaders, authContext, policy, undefined, options.admission); } catch (error) { releaseCodexAuthContextProbeLease(authContext); diff --git a/src/server/audio-client.ts b/src/server/audio-client.ts new file mode 100644 index 0000000000..2f0e082793 --- /dev/null +++ b/src/server/audio-client.ts @@ -0,0 +1,60 @@ +import { createHash } from "node:crypto"; +import { formatErrorResponse } from "../bridge"; +import type { OcxConfig } from "../types"; +import { captureExplicitOpenAiCallerAuth } from "../providers/openai-sidecar"; +import type { DataPlaneAdmission } from "./auth-cors"; +import { resolveAudioAdmission } from "./audio-upstream"; + +export const AUDIO_WEBSOCKET_PROTOCOL = "opencodex-audio"; +const KEY_PROTOCOL_PREFIX = "opencodex-key."; + +export interface AudioClient { + admission: DataPlaneAdmission; + headers: Headers; + owner: string; + protocol?: string; +} + +/** Browser protocols are an audio-only credential carrier; only the public marker is echoed. */ +export function resolveAudioClient(req: Request, config: OcxConfig, required = false): AudioClient | Response | null { + const headers = new Headers(req.headers); + let protocol: string | undefined; + let carrier = false; + if (headers.get("upgrade")?.toLowerCase() === "websocket") { + const raw = headers.get("sec-websocket-protocol") ?? ""; + if (raw.length > 8192) return formatErrorResponse(400, "invalid_request_error", "Audio protocol header too large"); + const protocols = raw.split(",").map(value => value.trim()).filter(Boolean); + const keys = protocols.filter(value => value.startsWith(KEY_PROTOCOL_PREFIX)); + const markers = protocols.filter(value => value === AUDIO_WEBSOCKET_PROTOCOL); + carrier = keys.length > 0 || markers.length > 0; + if (carrier) { + if (keys.length !== 1 || markers.length !== 1 || protocols.length !== 2) { + return formatErrorResponse(400, "invalid_request_error", "Expected one audio protocol and one encoded client key"); + } + const encoded = keys[0]!.slice(KEY_PROTOCOL_PREFIX.length); + const bytes = Buffer.from(encoded, "base64url"); + const key = bytes.toString("utf8"); + if (!encoded || bytes.toString("base64url") !== encoded || Buffer.from(key).toString("base64url") !== encoded || !key.trim()) { + return formatErrorResponse(400, "invalid_request_error", "Invalid audio client key encoding"); + } + protocol = AUDIO_WEBSOCKET_PROTOCOL; + if (!["authorization", "x-opencodex-api-key", "x-api-key"].some(name => headers.has(name))) { + headers.set("x-opencodex-api-key", key); + } + } + } + const admission = resolveAudioAdmission(headers, config); + if (!admission) { + const explicit = headers.has("x-opencodex-api-key") || headers.has("x-api-key") + || (headers.has("authorization") && !captureExplicitOpenAiCallerAuth(headers, config)); + return required || carrier || explicit + ? formatErrorResponse(401, "authentication_error", "opencodex API key required") + : null; + } + const credential = admission.source === "dedicated" ? headers.get("x-opencodex-api-key") + : admission.source === "x-api-key" ? headers.get("x-api-key") : headers.get("authorization")?.replace(/^Bearer\s+/i, ""); + const owner = admission.kind === "configured" + ? JSON.stringify(["configured", admission.keyId]) + : JSON.stringify(["environment", createHash("sha256").update(credential?.trim() ?? "").digest("hex")]); + return { admission, headers, owner, ...(protocol ? { protocol } : {}) }; +} diff --git a/src/server/audio-dictation.ts b/src/server/audio-dictation.ts new file mode 100644 index 0000000000..87c2fe55d1 --- /dev/null +++ b/src/server/audio-dictation.ts @@ -0,0 +1,91 @@ +import { formatErrorResponse } from "../bridge"; +import type { AdmissionLease } from "../lib/admission"; +import type { OcxConfig } from "../types"; +import type { AudioClient } from "./audio-client"; +import { resolveAudioUpstream, TRANSCRIPTION_MODEL, type AudioUpstream } from "./audio-upstream"; +import type { RequestLogContext } from "./request-log"; + +export const DICTATION_SESSION_MAX_MS = 300_000; +const DICTATION_FRAME_MAX_BYTES = 64 * 1024; +type JsonObject = Record; +function object(value: unknown): value is JsonObject { return !!value && typeof value === "object" && !Array.isArray(value); } +function integer(value: unknown, min: number, max: number): boolean { + return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max; +} + +export function createDictationFrameValidator(): (frame: string | Buffer) => boolean { + let started = false; + let closed = false; + return frame => { + if (closed || typeof frame !== "string" || Buffer.byteLength(frame) > DICTATION_FRAME_MAX_BYTES) return false; + let event: unknown; + try { event = JSON.parse(frame); } catch { return false; } + if (!object(event)) return false; + if (event.type === "session.start" && !started) { + const config = event.config; + if (!object(config) || Object.keys(event).some(key => key !== "type" && key !== "config")) return false; + 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"]); + if (Object.keys(config).some(key => !fields.has(key)) || config.input_audio_format !== "pcm16" || config.num_channels !== 1 + || !integer(config.sample_rate_hz, 8000, 192000) || !integer(config.max_buffer_size_bytes, 1, 4 * 1024 * 1024) + || !integer(config.max_utterance_duration_ms, 1, 30000) || !integer(config.session_ttl_ms, 1, DICTATION_SESSION_MAX_MS) + || !["buffered", "streaming_sse"].includes(String(config.provider_mode)) + || !["final_only", "segment", "delta"].includes(String(config.transcript_delivery_mode))) return false; + const vad = config.vad; + if (!object(vad) || Object.keys(vad).some(key => !["type", "threshold", "prefix_padding_ms", "silence_duration_ms"].includes(key)) + || vad.type !== "server_vad" || typeof vad.threshold !== "number" || !Number.isFinite(vad.threshold) || vad.threshold < 0 || vad.threshold > 1 + || !integer(vad.prefix_padding_ms, 0, 1000) || !integer(vad.silence_duration_ms, 0, 5000)) return false; + started = true; + return true; + } + if (!started) return false; + if (event.type === "session.close" && Object.keys(event).length === 1) { closed = true; return true; } + if (event.type !== "audio.append" || Object.keys(event).some(key => key !== "type" && key !== "audio") || typeof event.audio !== "string" || !event.audio) return false; + const bytes = Buffer.from(event.audio, "base64"); + return bytes.length > 0 && bytes.length % 2 === 0 && bytes.toString("base64") === event.audio; + }; +} + +export interface AudioSocketTarget { + headers: Record; + upstreamWsUrl: string; + protocols?: string[]; + validateFrame?: (frame: string | Buffer) => boolean; + maxSessionMs: number; + finish: (outcome?: number | "timeout" | "connect_error") => void; +} + +export function finishAudioUpstream(relay: AudioUpstream): AudioSocketTarget["finish"] { + let finished = false; + return outcome => { + if (finished) return; + finished = true; + try { if (outcome !== undefined) relay.recordOutcome?.(outcome); } + finally { relay.release(); } + }; +} + +export async function resolveDictationSocket( + client: AudioClient, config: OcxConfig, log: RequestLogContext, lease: AdmissionLease, signal?: AbortSignal, +): Promise { + const relay = await resolveAudioUpstream(client.headers, config, log, { admission: client.admission, model: TRANSCRIPTION_MODEL, lease, signal }); + if (relay instanceof Response) return relay; + if (relay.keyed) { + relay.release(); + return formatErrorResponse(400, "invalid_request_error", "Streaming dictation requires a connected ChatGPT account"); + } + const headers = new Headers(relay.headers); + const token = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); + if (!token || /[\s,]/.test(token)) { + relay.release(); + return formatErrorResponse(401, "authentication_error", "Dictation account authentication unavailable"); + } + headers.delete("authorization"); + return { + upstreamWsUrl: "wss://chatgpt.com/backend-api/dictation/stream", + headers: Object.fromEntries(headers), + protocols: ["chatgpt-dictation", `openai-bearer.${token}`, "codex-desktop"], + validateFrame: createDictationFrameValidator(), + maxSessionMs: DICTATION_SESSION_MAX_MS, + finish: finishAudioUpstream(relay), + }; +} diff --git a/src/server/audio-live.ts b/src/server/audio-live.ts new file mode 100644 index 0000000000..b90921a98e --- /dev/null +++ b/src/server/audio-live.ts @@ -0,0 +1,185 @@ +import { createHash } from "node:crypto"; +import { formatErrorResponse } from "../bridge"; +import { MAIN_CODEX_ACCOUNT_ID } from "../codex/account-id"; +import { cancelBodyOnAbort, clearableDeadline } from "../lib/abort"; +import type { AdmissionLease } from "../lib/admission"; +import { captureExplicitOpenAiCallerAuth } from "../providers/openai-sidecar"; +import type { OcxConfig } from "../types"; +import type { AudioClient } from "./audio-client"; +import { finishAudioUpstream, type AudioSocketTarget } from "./audio-dictation"; +import { LIVE_AUDIO_MODEL, resolveAudioUpstream, type AudioUpstream } from "./audio-upstream"; +import { registerTurn, unregisterTurn } from "./lifecycle"; +import { + backendJsonBodyFromApiMultipart, buildLiveSidebandUpstreamWsUrl, forwardLiveUrl, keyedLiveUrl, + LIVE_CLIENT_PROTOCOL_HEADERS, LIVE_REQUEST_MAX_BYTES, LIVE_RESPONSE_MAX_BYTES, readBodyCapped, + type LiveSidebandTarget, +} from "./live"; +import { LIVE_CALL_TTL_MS, LiveCallBindings, upstreamLiveCallId } from "./live-call-bindings"; +import type { RequestLogContext } from "./request-log"; + +function protocolHeaders(client: AudioClient, relay: AudioUpstream, frameless: boolean): Headers { + const headers = new Headers(); + for (const name of LIVE_CLIENT_PROTOCOL_HEADERS) { + const value = client.headers.get(name); + if (value) headers.set(name, value); + } + for (const [name, value] of Object.entries(relay.headers)) headers.set(name, value); + if (frameless && !headers.has("openai-alpha")) headers.set("openai-alpha", "quicksilver=v2"); + return headers; +} + +async function parseExternalOffer(req: Request, signal: AbortSignal): Promise<{ sdp: string; session?: Record } | Response> { + const upload = clearableDeadline(30_000, signal); + try { + const body = await readBodyCapped(req.body, LIVE_REQUEST_MAX_BYTES, () => "Live offer too large", upload.signal); + if (body instanceof Response) return formatErrorResponse(413, "invalid_request_error", "Live offer exceeds 16 MiB"); + const type = req.headers.get("content-type") ?? ""; + let payload: unknown; + if (type.toLowerCase().includes("multipart/form-data")) { + const converted = await backendJsonBodyFromApiMultipart(body, type); + if (converted instanceof Response) return converted; + payload = JSON.parse(new TextDecoder().decode(converted.body)); + } else if (type.toLowerCase().includes("application/json")) { + payload = JSON.parse(new TextDecoder().decode(body)); + } else if (type.toLowerCase().includes("application/sdp")) { + payload = { sdp: new TextDecoder().decode(body) }; + } + if (!payload || typeof payload !== "object" || !("sdp" in payload) || typeof payload.sdp !== "string" || !payload.sdp.trim()) { + return formatErrorResponse(400, "invalid_request_error", "Live call requires a nonempty SDP offer"); + } + const session = "session" in payload ? payload.session : undefined; + if (session !== undefined && (!session || typeof session !== "object" || Array.isArray(session))) { + return formatErrorResponse(400, "invalid_request_error", "Live session must be an object"); + } + return { sdp: payload.sdp, ...(session ? { session: session as Record } : {}) }; + } catch { + if (signal.aborted) throw signal.reason; + return formatErrorResponse(upload.didExpire() ? 408 : 400, "invalid_request_error", upload.didExpire() ? "Live offer upload timed out" : "Malformed live offer"); + } finally { upload.clear(); } +} + +export async function handleExternalLive( + req: Request, config: OcxConfig, log: RequestLogContext, + options: { client: AudioClient; lease: AdmissionLease; bindings: LiveCallBindings }, +): Promise { + const controller = new AbortController(); + registerTurn(controller, options.lease); + const deadline = clearableDeadline(120_000, AbortSignal.any([req.signal, controller.signal])); + let relay: AudioUpstream | undefined; + let outcome: number | "timeout" | "connect_error" | undefined; + try { + const offer = await parseExternalOffer(req, deadline.signal); + if (offer instanceof Response) return offer; + if (!options.bindings.hasCapacity()) return formatErrorResponse(503, "server_busy", "Live call capacity reached"); + const frameless = new URL(req.url).pathname === "/v1/live"; + const session = offer.session ? { ...offer.session } : frameless ? { + model: LIVE_AUDIO_MODEL, instructions: "", audio: { output: { voice: "cove" } }, delegation: { type: "client" }, + } : undefined; + if (session?.model === "gpt-live-1") session.model = LIVE_AUDIO_MODEL; + const model = typeof session?.model === "string" ? session.model : LIVE_AUDIO_MODEL; + const resolved = await resolveAudioUpstream(options.client.headers, config, log, { + admission: options.client.admission, model, lease: options.lease, signal: deadline.signal, + }); + if (!(resolved instanceof Response)) relay = resolved; + deadline.signal.throwIfAborted(); + if (resolved instanceof Response) return resolved; + relay = resolved; + const headers = protocolHeaders(options.client, relay, frameless); + let body: BodyInit; + if (relay.keyed) { + const form = new FormData(); + form.set("sdp", offer.sdp); + if (session) form.set("session", JSON.stringify(session)); + headers.delete("content-type"); + body = form; + } else { + headers.set("content-type", "application/json"); + body = JSON.stringify({ sdp: offer.sdp, ...(session ? { session } : {}) }); + } + const url = relay.keyed + ? frameless ? forwardLiveUrl(relay.providerBaseUrl, false) : keyedLiveUrl(relay.providerBaseUrl) + : forwardLiveUrl(relay.providerBaseUrl, true); + const upstream = await fetch(url, { method: "POST", headers, body, signal: deadline.signal, redirect: "manual" }); + outcome = upstream.ok ? 502 : upstream.status; + const detach = cancelBodyOnAbort(upstream.body, deadline.signal); + let responseBody: ArrayBuffer | Response; + try { responseBody = await readBodyCapped(upstream.body, LIVE_RESPONSE_MAX_BYTES, () => "Live answer too large", deadline.signal); } + finally { detach(); } + if (responseBody instanceof Response) return responseBody; + if (!upstream.ok) return formatErrorResponse(upstream.status >= 400 ? upstream.status : 502, "upstream_error", `Live upstream returned HTTP ${upstream.status}`); + const callId = upstreamLiveCallId(upstream.headers.get("location")); + if (!callId || responseBody.byteLength === 0) return formatErrorResponse(502, "upstream_error", "Live upstream returned an invalid call answer"); + const context = relay.authContext; + const callerOwned = context?.kind === "main" && captureExplicitOpenAiCallerAuth(options.client.headers, config) !== null; + const alias = options.bindings.create({ + owner: options.client.owner, upstreamCallId: callId, + joinStyle: frameless ? "frameless-path" : "realtime-query", + providerName: relay.providerName, + accountId: context ? context.kind === "main" ? callerOwned ? undefined : MAIN_CODEX_ACCOUNT_ID : context.accountId : undefined, + chatgptAccountId: new Headers(relay.headers).get("chatgpt-account-id") ?? undefined, + keyedCredentialDigest: relay.keyed ? createHash("sha256").update(new Headers(relay.headers).get("authorization") ?? "").digest("hex") : undefined, + callerOwned, + sidebandBaseUrl: config.experimentalRealtimeWsBaseUrl, + }); + if (!alias) return formatErrorResponse(503, "server_busy", "Live call could not be registered"); + outcome = upstream.status; + return new Response(responseBody, { status: upstream.status, headers: { + "content-type": upstream.headers.get("content-type") ?? "application/sdp", + location: `/v1/${frameless ? "live" : "realtime/calls"}/${alias}`, + } }); + } catch { + if (req.signal.aborted || controller.signal.aborted) { + outcome = undefined; + return formatErrorResponse(req.signal.aborted ? 499 : 503, "client_closed_request", "Live call canceled"); + } + outcome = deadline.didExpire() ? "timeout" : "connect_error"; + return formatErrorResponse(deadline.didExpire() ? 504 : 502, "upstream_error", deadline.didExpire() ? "Live call timed out" : "Live upstream connection failed"); + } finally { + try { if (outcome !== undefined) relay?.recordOutcome?.(outcome); } + finally { relay?.release(); deadline.clear(); unregisterTurn(controller); } + } +} + +export async function resolveExternalLiveSocket( + client: AudioClient, config: OcxConfig, log: RequestLogContext, target: LiveSidebandTarget, + options: { lease: AdmissionLease; bindings: LiveCallBindings; signal?: AbortSignal }, +): Promise { + const binding = "callId" in target ? options.bindings.get(target.callId, client.owner) : undefined; + if ("callId" in target && !binding) return formatErrorResponse(404, "not_found", "Live call is unavailable for this key"); + if (binding?.callerOwned && !captureExplicitOpenAiCallerAuth(client.headers, config)) { + return formatErrorResponse(401, "authentication_error", "Live call requires its original caller account"); + } + let upstreamTarget = binding ? { style: binding.joinStyle, callId: binding.upstreamCallId } as LiveSidebandTarget : target; + const frameless = upstreamTarget.style === "frameless-path" || upstreamTarget.style === "frameless-standalone"; + let model = LIVE_AUDIO_MODEL; + if (upstreamTarget.style === "frameless-standalone") { + const query = new URLSearchParams(upstreamTarget.query); + if (!query.has("model") || query.get("model") === "gpt-live-1") query.set("model", LIVE_AUDIO_MODEL); + model = query.get("model") ?? LIVE_AUDIO_MODEL; + upstreamTarget = { ...upstreamTarget, query: query.toString() }; + } + const relay = await resolveAudioUpstream(client.headers, config, log, { + admission: client.admission, model, lease: options.lease, + signal: options.signal, + ...(binding ? { exactAccountId: binding.accountId, providerName: binding.providerName } : {}), + }); + if (relay instanceof Response) return relay; + try { + if (binding && (relay.providerName !== binding.providerName + || (new Headers(relay.headers).get("chatgpt-account-id") ?? undefined) !== binding.chatgptAccountId + || (binding.keyedCredentialDigest !== undefined + && createHash("sha256").update(new Headers(relay.headers).get("authorization") ?? "").digest("hex") !== binding.keyedCredentialDigest))) { + relay.release(); + return formatErrorResponse(409, "authentication_error", "Live call account is no longer available"); + } + return { + headers: Object.fromEntries(protocolHeaders(client, relay, frameless)), + upstreamWsUrl: buildLiveSidebandUpstreamWsUrl(upstreamTarget, binding ? binding.sidebandBaseUrl : config.experimentalRealtimeWsBaseUrl), + maxSessionMs: LIVE_CALL_TTL_MS, + finish: finishAudioUpstream(relay), + }; + } catch { + relay.release(); + return formatErrorResponse(400, "invalid_request_error", "Invalid live endpoint configuration"); + } +} diff --git a/src/server/audio-upstream.ts b/src/server/audio-upstream.ts index a59b7aef95..fa8bc4e9e7 100644 --- a/src/server/audio-upstream.ts +++ b/src/server/audio-upstream.ts @@ -54,6 +54,7 @@ export interface AudioUpstreamOptions { model: string; lease?: AdmissionLease; exactAccountId?: string; + providerName?: string; signal?: AbortSignal; } @@ -65,6 +66,10 @@ export async function resolveAudioUpstream( options: AudioUpstreamOptions, ): Promise { const candidates = selectOpenAiImagesProvider(config); + if (options.providerName) { + candidates.forwardCandidates = candidates.forwardCandidates.filter(candidate => candidate.providerName === options.providerName); + if (candidates.keyed?.providerName !== options.providerName) delete candidates.keyed; + } const headers = new Headers(incoming); const bearer = headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim(); if (bearer && isProxyAdmissionSecret(bearer, config)) { @@ -90,6 +95,7 @@ export async function resolveAudioUpstream( beginCodexAccountSelection, signal: options.signal, }); + options.signal?.throwIfAborted(); selected = materializeCodexUpstreamAuth(headers, context, { config, admission: options.admission, @@ -99,6 +105,7 @@ export async function resolveAudioUpstream( const resolved = await resolveFirstUsableOpenAiSidecar(candidates.forwardCandidates, headers, config, { admission: options.admission, beginCodexAccountSelection, + signal: options.signal, ...(options.exactAccountId ? { exactAccount: { accountId: options.exactAccountId, modelId: options.model } } : {}), }); if (!resolved) return formatErrorResponse(401, "authentication_error", "Connect a ChatGPT account to use audio"); diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index ac00b0d4f7..4a6e951368 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -428,6 +428,8 @@ export interface ApiAuthMatrixRow { */ export const AUTH_MATRIX: readonly ApiAuthMatrixRow[] = [ { endpoint: "/v1/audio/transcriptions", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }, + { endpoint: "/v1/live", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }, + { endpoint: "/v1/realtime/calls", bearer: "accepted", dedicated: "accepted", xApiKey: "accepted" }, // #1686: a bearer that is one of OUR admission secrets is now accepted here. It is safe // because materializeCodexUpstreamAuth substitutes the stored main credential rather than // forwarding it; a bearer that is NOT our secret stays unadmitted and remains Codex Direct diff --git a/src/server/index.ts b/src/server/index.ts index c73cbce68a..6c39555291 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -206,6 +206,11 @@ import { handleImages } from "./images"; import { handleLive, logLiveSidebandFrame, parseLiveSidebandTarget, resolveLiveSidebandUpgrade } from "./live"; import { handleAudioTranscriptions } from "./audio-transcriptions"; import { resolveAudioAdmission, TRANSCRIPTION_MODEL } from "./audio-upstream"; +import { resolveAudioClient } from "./audio-client"; +import { resolveDictationSocket } from "./audio-dictation"; +import { handleExternalLive, resolveExternalLiveSocket } from "./audio-live"; +import { EXTERNAL_CALL_PREFIX, LiveCallBindings } from "./live-call-bindings"; +import { clearableDeadline } from "../lib/abort"; import { handleSearch } from "./search"; import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "./management-api"; import { @@ -355,6 +360,7 @@ export function enqueueLiveSidebandPendingFrame( type LiveSidebandWebSocketFactory = ( url: string, headers: Record, + protocols?: string[], ) => WebSocket; function releaseLiveSidebandAdmission(ws: ServerWebSocket): void { @@ -387,8 +393,22 @@ function finalizeLiveSideband(ws: ServerWebSocket, upstream?: WebSocket) ws.data.liveUpstream = undefined; ws.data.livePending = undefined; ws.data.livePendingBytes = undefined; + if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer); + if (ws.data.liveSessionTimer !== undefined) clearTimeout(ws.data.liveSessionTimer); + ws.data.liveConnectTimer = undefined; + ws.data.liveSessionTimer = undefined; + ws.data.liveUpstreamHeaders = undefined; + ws.data.liveUpstreamProtocols = undefined; + ws.data.liveValidateFrame = undefined; + if (ws.data.liveAbortListener) ws.data.liveAbortSignal?.removeEventListener("abort", ws.data.liveAbortListener); + ws.data.liveAbortSignal = undefined; + ws.data.liveAbortListener = undefined; ws.data.cancel = undefined; - releaseLiveSidebandAdmission(ws); + const finish = ws.data.liveFinish; + ws.data.liveFinish = undefined; + try { finish?.(ws.data.liveOutcome); } + catch { console.warn("[audio] upstream accounting failed during close"); } + finally { releaseLiveSidebandAdmission(ws); } } function armLiveSidebandCloseFallback(ws: ServerWebSocket, upstream: WebSocket): void { @@ -421,6 +441,10 @@ function armLiveSidebandCloseFallback(ws: ServerWebSocket, upstream: Web function closeLiveSideband(ws: ServerWebSocket, code = 1000, reason = ""): void { if (ws.data.liveClosing) return; ws.data.liveClosing = true; + if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer); + if (ws.data.liveSessionTimer !== undefined) clearTimeout(ws.data.liveSessionTimer); + ws.data.liveConnectTimer = undefined; + ws.data.liveSessionTimer = undefined; ws.data.livePending = undefined; ws.data.livePendingBytes = undefined; ws.data.cancel = undefined; @@ -452,10 +476,14 @@ function closeLiveSideband(ws: ServerWebSocket, code = 1000, reason = "" function attachLiveSidebandUpstream( ws: ServerWebSocket, - createWebSocket: LiveSidebandWebSocketFactory = (url, headers) => ( - new WebSocket(url, { headers } as unknown as string[]) + createWebSocket: LiveSidebandWebSocketFactory = (url, headers, protocols) => ( + new WebSocket(url, { headers, protocols } as unknown as string[]) ), ): void { + if (ws.data.liveAbortSignal?.aborted) { + closeLiveSideband(ws, 1000, "audio connection canceled"); + return; + } const url = ws.data.liveUpstreamUrl; if (!url) { closeLiveSideband(ws, 1011, "missing upstream"); @@ -464,18 +492,30 @@ function attachLiveSidebandUpstream( let upstream: WebSocket; try { // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays. - upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}); + upstream = createWebSocket(url, ws.data.liveUpstreamHeaders ?? {}, ws.data.liveUpstreamProtocols); } catch { closeLiveSideband(ws, 1011, "upstream connect failed"); return; } ws.data.liveUpstream = upstream; + ws.data.liveUpstreamHeaders = undefined; + ws.data.liveUpstreamProtocols = undefined; ws.data.liveClosing = false; ws.data.cancel = () => closeLiveSideband(ws, 1000, "client closed"); + if (ws.data.liveMaxSessionMs !== undefined) { + ws.data.liveConnectTimer = setTimeout(() => { + ws.data.liveOutcome = "timeout"; + closeLiveSideband(ws, 1011, "audio connection timed out"); + }, 10_000); + ws.data.liveSessionTimer = setTimeout(() => closeLiveSideband(ws, 1000, "audio session expired"), ws.data.liveMaxSessionMs); + } upstream.addEventListener("open", () => { if (ws.data.liveUpstream !== upstream || ws.data.liveClosing) return; ws.data.liveOpened = true; + if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer); + ws.data.liveConnectTimer = undefined; + if (ws.data.liveFinish) ws.data.liveOutcome = 200; const pending = ws.data.livePending ?? []; ws.data.livePending = undefined; ws.data.livePendingBytes = undefined; @@ -496,29 +536,43 @@ function attachLiveSidebandUpstream( return; } logLiveSidebandFrame("u2c", event.data); - if (typeof event.data === "string") ws.send(event.data); - else if (event.data instanceof ArrayBuffer) ws.send(event.data); + let sent: number; + if (typeof event.data === "string") sent = ws.send(event.data); + else if (event.data instanceof ArrayBuffer) sent = ws.send(event.data); else if (ArrayBuffer.isView(event.data)) { - ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength)); - } else ws.send(event.data as Buffer); + sent = ws.send(event.data.buffer.slice(event.data.byteOffset, event.data.byteOffset + event.data.byteLength)); + } else sent = ws.send(event.data as Buffer); + if (ws.data.liveMaxSessionMs !== undefined && (sent === 0 || ws.getBufferedAmount() > MAX_WS_FRAME_BYTES)) { + closeLiveSideband(ws, 1013, "audio client backpressure"); + } } catch { closeLiveSideband(ws, 1011, "client send failed"); } }); upstream.addEventListener("close", (event) => { if (ws.data.liveUpstream !== upstream) return; + if (ws.data.liveFinish && !ws.data.liveClosing && event.code !== 1000) ws.data.liveOutcome = "connect_error"; ws.data.liveClosing = true; finalizeLiveSideband(ws, upstream); try { - ws.close(event.code || 1000, event.reason || ""); + const external = ws.data.liveMaxSessionMs !== undefined; + const validCode = event.code === 1000 || (event.code >= 1001 && event.code <= 1014 && ![1004, 1005, 1006].includes(event.code)) + || (event.code >= 3000 && event.code <= 4999); + ws.close(external && !validCode ? 1011 : event.code || 1000, external ? "audio upstream closed" : event.reason || ""); } catch { /* ignore */ } }); upstream.addEventListener("error", () => { if (ws.data.liveUpstream !== upstream) return; + if (ws.data.liveFinish && !ws.data.liveClosing) ws.data.liveOutcome = "connect_error"; closeLiveSideband(ws, 1011, "upstream error"); }); + if (ws.data.liveAbortSignal) { + ws.data.liveAbortListener = () => closeLiveSideband(ws, 1000, "audio connection canceled"); + ws.data.liveAbortSignal.addEventListener("abort", ws.data.liveAbortListener, { once: true }); + if (ws.data.liveAbortSignal.aborted) closeLiveSideband(ws, 1000, "audio connection canceled"); + } } // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the @@ -678,6 +732,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { - const response = await handleLive(req, config, logCtx, turnAdmissionLease); + const response = audioClient + ? await handleExternalLive(req, config, logCtx, { client: audioClient, lease: turnAdmissionLease, bindings: liveCallBindings }) + : await handleLive(req, config, logCtx, turnAdmissionLease); addFinalRequestLog( requestId, start, @@ -2176,11 +2236,19 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server { + acquisition?.clear(); + if (audioController) unregisterTurn(audioController); + else turnAdmissionLease.release(); + }; let resolved; try { - resolved = await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease); + resolved = dictationSocket && audioClient + ? await resolveDictationSocket(audioClient, config, logCtx, turnAdmissionLease, acquisition?.signal) + : liveSidebandTarget && audioClient + ? await resolveExternalLiveSocket(audioClient, config, logCtx, liveSidebandTarget, { lease: turnAdmissionLease, bindings: liveCallBindings, signal: acquisition?.signal }) + : liveSidebandTarget + ? await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease) + : formatErrorResponse(401, "authentication_error", "opencodex API key required"); } catch (error) { - turnAdmissionLease.release(); + releaseAcquisition(); throw error; } + if (acquisition?.signal.aborted) { + try { if (!(resolved instanceof Response) && "finish" in resolved) resolved.finish(); } + finally { releaseAcquisition(); } + return withCors(formatErrorResponse(req.signal.aborted ? 499 : acquisition.didExpire() ? 504 : 503, + "upstream_error", acquisition.didExpire() ? "Audio connection timed out" : "Audio connection canceled"), req, policy); + } if (resolved instanceof Response) { - turnAdmissionLease.release(); + releaseAcquisition(); addFinalRequestLog(requestId, start, logCtx, resolved.status); return withCors(resolved, req, policy); } - addFinalRequestLog(requestId, start, logCtx, 101); - if (requestServer.upgrade(req, { + const audio = "finish" in resolved ? resolved : undefined; + const finish = audio ? (outcome?: number | "timeout" | "connect_error") => { + try { audio.finish(outcome); } + finally { releaseAcquisition(); } + } : undefined; + const discardUpgrade = () => { + if (finish) finish(); + else releaseAcquisition(); + }; + if (req.signal.aborted) { + discardUpgrade(); + return withCors(formatErrorResponse(499, "client_closed_request", "Audio connection canceled"), req, policy); + } + let upgraded = false; + try { + upgraded = requestServer.upgrade(req, { + ...(audioClient?.protocol ? { headers: { "sec-websocket-protocol": audioClient.protocol } } : {}), data: { kind: "live-sideband", liveUpstreamUrl: resolved.upstreamWsUrl, liveUpstreamHeaders: resolved.headers, + admission, + liveUpstreamProtocols: audio?.protocols, + liveValidateFrame: audio?.validateFrame, + liveMaxSessionMs: audio?.maxSessionMs, + liveFinish: finish, + liveAbortSignal: audioController?.signal, livePending: [], livePendingBytes: 0, liveOpened: false, liveTurnAdmissionLease: turnAdmissionLease, } satisfies WsData, - })) return undefined as unknown as Response; - turnAdmissionLease.release(); + }); + } catch { + discardUpgrade(); + return withCors(formatErrorResponse(502, "upstream_error", "Audio WebSocket upgrade failed"), req, policy); + } + if (upgraded) { + acquisition?.clear(); + addFinalRequestLog(requestId, start, logCtx, 101); + return undefined as unknown as Response; + } + discardUpgrade(); return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy); } @@ -2335,6 +2454,10 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server, raw: string | Buffer) { if (ws.data.kind === "live-sideband") { if (ws.data.liveClosing) return; + if (ws.data.liveValidateFrame && !ws.data.liveValidateFrame(raw)) { + closeLiveSideband(ws, 1008, "invalid audio event"); + return; + } const rawBytes = webSocketFrameBytes(raw); if (exceedsLiveSidebandFrameByteLimit(rawBytes)) { closeLiveSideband(ws, 1009, "message too large"); @@ -2360,6 +2483,9 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server MAX_WS_FRAME_BYTES) { + closeLiveSideband(ws, 1013, "audio upstream backpressure"); + } } catch { closeLiveSideband(ws, 1011, "upstream send failed"); } @@ -2581,6 +2707,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server => { + liveCallBindings.clear(); // The orchestration lives in `runListenerShutdown` so its two competing properties — // cleanup completes, failure propagates — are testable without a live socket. await runListenerShutdown( diff --git a/src/server/live-call-bindings.ts b/src/server/live-call-bindings.ts new file mode 100644 index 0000000000..dd90e8aa43 --- /dev/null +++ b/src/server/live-call-bindings.ts @@ -0,0 +1,60 @@ +import { randomUUID } from "node:crypto"; + +export const EXTERNAL_CALL_PREFIX = "rtc_ocx_"; +export const LIVE_CALL_TTL_MS = 30 * 60_000; +const MAX_CALL_BINDINGS = 1024; + +export interface LiveCallBinding { + owner: string; + upstreamCallId: string; + joinStyle: "frameless-path" | "realtime-query"; + providerName: string; + accountId?: string; + chatgptAccountId?: string; + keyedCredentialDigest?: string; + callerOwned: boolean; + sidebandBaseUrl?: string; +} + +/** Caller-visible IDs are opaque aliases; expired aliases never become legacy upstream IDs. */ +export class LiveCallBindings { + private entries = new Map(); + + constructor(private readonly now: () => number = Date.now) {} + + private prune(): void { + const now = this.now(); + for (const [id, value] of this.entries) if (value.expiresAt <= now) this.entries.delete(id); + } + + hasCapacity(): boolean { + this.prune(); + return this.entries.size < MAX_CALL_BINDINGS; + } + + create(binding: LiveCallBinding): string | null { + if (!this.hasCapacity()) return null; + const id = EXTERNAL_CALL_PREFIX + randomUUID().replaceAll("-", ""); + this.entries.set(id, { binding: { ...binding }, expiresAt: this.now() + LIVE_CALL_TTL_MS }); + return id; + } + + get(id: string, owner: string): LiveCallBinding | undefined { + this.prune(); + const value = this.entries.get(id); + return value?.binding.owner === owner ? { ...value.binding } : undefined; + } + + clear(): void { this.entries.clear(); } +} + +export function upstreamLiveCallId(location: string | null): string | null { + if (!location || location.length > 4096) return null; + try { + const url = new URL(location, "https://unused.invalid"); + if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) return null; + const segment = url.pathname.replace(/\/+$/, "").split("/").at(-1) ?? ""; + const id = decodeURIComponent(segment); + return /^(?:rtc_[A-Za-z0-9_-]+|[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})$/.test(id) && id.length <= 128 ? id : null; + } catch { return null; } +} diff --git a/src/server/live.ts b/src/server/live.ts index 48229814f9..5baa76448d 100644 --- a/src/server/live.ts +++ b/src/server/live.ts @@ -370,7 +370,7 @@ export function buildLiveSidebandUpstreamWsUrl( ); } -async function backendJsonBodyFromApiMultipart( +export async function backendJsonBodyFromApiMultipart( body: ArrayBuffer, contentType: string, ): Promise<{ body: Uint8Array; contentType: string } | Response> { diff --git a/src/server/ws-bridge.ts b/src/server/ws-bridge.ts index 5777b45a10..1c8513de3d 100644 --- a/src/server/ws-bridge.ts +++ b/src/server/ws-bridge.ts @@ -8,6 +8,7 @@ import type { DataPlaneAdmission } from "./auth-cors"; import type { AdmissionLease, AdmissionReservation } from "../lib/admission"; import { BoundedSseFrameBuffer } from "./sse-frame-buffer"; import { safeResponseHeaders } from "./safe-response-headers"; +import type { AudioSocketTarget } from "./audio-dictation"; export { safeResponseHeaders } from "./safe-response-headers"; @@ -35,6 +36,15 @@ export interface WsData { liveUpstream?: WebSocket; liveUpstreamUrl?: string; liveUpstreamHeaders?: Record; + liveUpstreamProtocols?: string[]; + liveValidateFrame?: AudioSocketTarget["validateFrame"]; + liveFinish?: AudioSocketTarget["finish"]; + liveOutcome?: number | "timeout" | "connect_error"; + liveMaxSessionMs?: number; + liveConnectTimer?: ReturnType; + liveSessionTimer?: ReturnType; + liveAbortSignal?: AbortSignal; + liveAbortListener?: () => void; livePending?: Array; /** Total encoded bytes retained in livePending while the upstream connects. */ livePendingBytes?: number; diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index fd3a3cfdf2..c277313124 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -17,6 +17,28 @@ keep credentials and audio content out of redirects, request logs and durable st `tests/server/audio-transcriptions.test.ts` exercises the real ingress and synthetic upstream; `tests/server/api-key-attribution.test.ts` uses multipart fixtures for the HTTP auth matrix. +## Streaming audio + +`src/server/audio-client.ts` recognizes explicit audio keys before local legacy admission. +Browser sockets offer opencodex-audio and opencodex-key.; only the public +marker is selected downstream. Invalid presented keys cannot become credential-free native calls. +`src/server/audio-dictation.ts` maps the validated desktop session.start/audio.append/session.close +protocol to ChatGPT dictation with server-owned credentials and a five-minute lifetime. +The `src/server/index.ts` byte relay retains bounded queues, handshake/session deadlines and +the account/turn lifecycle until its upstream closes. `src/server/ws-bridge.ts` carries the +in-memory callbacks and signal; handshake credentials are cleared after socket construction. + +`src/server/audio-live.ts` owns external keyed GPT-Live creation and joins while the original +`src/server/live.ts` keeps the native compatibility path. `src/server/live-call-bindings.ts` +maps opaque rtc_ocx_ aliases to the creating key, provider, physical account and protocol. +Expired aliases never fall through to native joins. Reconnect resolves the recorded account +freshly; keyed provider replacement fails unless its credential digest still matches. +The registry is per server, holds at most 1024 entries for 30 minutes and is cleared on shutdown. +It does not proxy WebRTC media or execute delegation requests. Standalone Frameless defaults +to gpt-live-1-codex; gpt-live-1 is an explicit alias. Dictation and Frameless event formats remain +separate. Coverage lives in `tests/server/audio-client.test.ts`, +`tests/server/audio-dictation.test.ts` and `tests/server/live-call-bindings.test.ts`. + ## Chat Completions inbound native path `POST /v1/chat/completions` sends eligible `openai-chat` routes directly to the provider's Chat diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index a2e2ab698d..536eee3979 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -393,6 +393,12 @@ materialization or usability failure before transferring a resolved context to i Audio reports one terminal upstream outcome after validating the response body; redirects remain neutral and client/shutdown cancellation does not manufacture an account failure. +External voice reconnects restrict provider selection as well as exact account selection to the +original call binding. Credential acquisition accepts a cancellation signal; post-resolution +materialization checks cancellation before returning ownership. WebSocket accounting maps an +established healthy transport to the recorder's supported success outcome, independently of +the public HTTP 101 upgrade status, and settles only once during transport cleanup. + The dashboard presents one OpenAI Codex card with accessible Pool/Direct controls and a separate, unchanged API-key card. `PATCH /api/providers?name=openai` persists exactly one `codexAccountMode`, clears affinity/quota cache, primes only when entering Pool, and does not refresh diff --git a/structure/runtime.md b/structure/runtime.md index 37adf3d2d4..ff981612b2 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -10,6 +10,7 @@ | `src/server/index.ts` | Bun server entrypoint: `startServer`, `/v1/responses` HTTP + WebSocket routing (compact handled before generic Responses), exact `POST /v1/images/generations` and `POST /v1/images/edits` routing, `/v1/models`, the Anthropic-shaped `/v1/messages` and OpenAI-shaped `/v1/chat/completions` compatibility surfaces, the Live/Realtime surface, the hosted-search relay, artifact serving, `/healthz`, the `/api/*` auth gate, the `/v1/*` JSON 404 guard, GUI fallback, the opt-in loopback-only hub-management listener, and facade re-exports for split server modules. | | `src/server/images.ts` | Standalone Images data plane: default OpenAI or explicit custom-provider selection, Codex account affinity, bounded opaque request relay, single-attempt upstream fetch, pool health recording, and safe response/cancellation relay. | | `src/server/audio-transcriptions.ts` | Standalone multipart transcription; audio-specific key admission, bounded upload/response, stored OpenAI credential resolution and lease-bound cancellation. See [audio contracts](data-planes/inbound-compat.md#standalone-file-transcription). | +| `src/server/audio-live.ts`, `src/server/audio-dictation.ts` | External voice/dictation orchestration using the existing bounded socket relay, server-owned credentials, cancellation and opaque call ownership. See [streaming audio](data-planes/inbound-compat.md#streaming-audio). | | `src/config.ts` | Persisted `~/.opencodex/config.json` schema, defaults, migrations, transactions, and compatibility re-exports for split config modules. | | `src/config/paths.ts` | Resolves `OPENCODEX_HOME`, `config.json`, and owner-only directory hardening. | | `src/config/atomic-write.ts` | Shared synchronous/asynchronous temp-harden-rename writer and residual-temp failure contract. | @@ -62,7 +63,7 @@ listener, the optional unauthenticated data-loopback listener, and the optional listener. The data-loopback socket serves a fixed data-plane allowlist: Responses and its compact sibling, -the native search relay, the standalone Images POSTs, keyed file transcription, `GET /v1/models`, the realtime voice shapes, +the native search relay, the standalone Images POSTs, keyed file/stream transcription, `GET /v1/models`, the realtime voice shapes, and the Anthropic and OpenAI chat wires the host's own local clients speak — `POST /v1/messages`, `POST /v1/messages/count_tokens`, and `POST /v1/chat/completions`. It never serves `/api/*`, `/healthz`, `/readyz`, or GUI routes, so local management discovery has to use an authenticated diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index cb43e5b19b..061f7a5cea 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -52,6 +52,9 @@ "antigravity-static-catalog.test.ts": "adapters/google", "api-access-endpoints.test.ts": "server", "audio-transcriptions.test.ts": "server", + "audio-client.test.ts": "server", + "audio-dictation.test.ts": "server", + "live-call-bindings.test.ts": "server", "api-catalog-route.test.ts": "server", "api-codex-log-guard-compact.test.ts": "server", "api-codex-log-guard-protection.test.ts": "server", diff --git a/tests/server/api-key-attribution.test.ts b/tests/server/api-key-attribution.test.ts index 6bc42acf62..6962be9ff4 100644 --- a/tests/server/api-key-attribution.test.ts +++ b/tests/server/api-key-attribution.test.ts @@ -634,10 +634,14 @@ describe("AUTH_MATRIX is true of the running server", () => { // than admission. /v1/catalog joined this set in #809. const isGet = row.endpoint === "/v1/models" || row.endpoint === "/v1/catalog" || row.endpoint === "/v1/hub-state"; - const audio = row.endpoint === "/v1/audio/transcriptions" ? new FormData() : null; + const live = row.endpoint === "/v1/live" || row.endpoint === "/v1/realtime/calls"; + const audio = row.endpoint === "/v1/audio/transcriptions" || live ? new FormData() : null; if (audio) { - audio.append("model", "gpt-4o-transcribe"); - audio.append("file", new File([new Uint8Array([0, 0])], "sample.wav", { type: "audio/wav" })); + if (live) audio.append("sdp", "v=0\r\n"); + else { + audio.append("model", "gpt-4o-transcribe"); + audio.append("file", new File([new Uint8Array([0, 0])], "sample.wav", { type: "audio/wav" })); + } } const res = await fetch(new URL(row.endpoint, server.url), { method: isGet ? "GET" : "POST", diff --git a/tests/server/audio-client.test.ts b/tests/server/audio-client.test.ts new file mode 100644 index 0000000000..8da1b6e509 --- /dev/null +++ b/tests/server/audio-client.test.ts @@ -0,0 +1,61 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { resolveAudioClient, type AudioClient } from "../../src/server/audio-client"; +import type { OcxConfig } from "../../src/types"; + +const KEY = "ocx_data_audio_client_fixture"; +const originalToken = process.env.OPENCODEX_API_AUTH_TOKEN; +const config = { providers: {}, defaultProvider: "none", apiKeys: [{ id: "one", name: "one", key: KEY, createdAt: "2026-09-12T00:00:00Z" }] } as OcxConfig; +beforeEach(() => { delete process.env.OPENCODEX_API_AUTH_TOKEN; }); +afterEach(() => { + if (originalToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = originalToken; +}); + +function request(headers: Record): Request { + return new Request("http://localhost/v1/live", { headers: { upgrade: "websocket", ...headers } }); +} +function carrier(key = KEY): string { return `opencodex-audio, opencodex-key.${Buffer.from(key).toString("base64url")}`; } +function client(result: ReturnType): AudioClient { + if (!result || result instanceof Response) throw new Error("Expected admitted audio client"); + return result; +} + +describe("audio-only WebSocket admission", () => { + test("browser carrier resolves a configured owner and selects only the public protocol", () => { + const result = client(resolveAudioClient(request({ "sec-websocket-protocol": carrier() }), config, true)); + expect(result.admission).toEqual({ kind: "configured", keyId: "one", source: "dedicated" }); + expect(result.protocol).toBe("opencodex-audio"); + expect(result.owner).toBe('["configured","one"]'); + expect(result.owner).not.toContain(KEY); + }); + test("bad explicit headers win over a valid carrier", () => { + const result = resolveAudioClient(request({ "sec-websocket-protocol": carrier(), "x-opencodex-api-key": "wrong" }), config, true); + expect(result).toBeInstanceOf(Response); + expect((result as Response).status).toBe(401); + }); + test.each([ + "opencodex-audio", `opencodex-key.${Buffer.from(KEY).toString("base64url")}`, + carrier() + ", opencodex-audio", carrier() + ", other", + "opencodex-audio, opencodex-key.bm9uY2Fub25pY2Fs=", + ])("rejects malformed protocol pair %s", value => { + expect((resolveAudioClient(request({ "sec-websocket-protocol": value }), config, true) as Response).status).toBe(400); + }); + test("same environment key has one owner across header carriers", () => { + process.env.OPENCODEX_API_AUTH_TOKEN = "fixture-env-audio"; + const bearer = client(resolveAudioClient(request({ authorization: "Bearer fixture-env-audio" }), config)); + const dedicated = client(resolveAudioClient(request({ "x-opencodex-api-key": "fixture-env-audio" }), config)); + expect(bearer.owner).toBe(dedicated.owner); + expect(bearer.owner).not.toContain("fixture-env-audio"); + }); + test("required audio never falls through to unauthenticated loopback", () => { + expect((resolveAudioClient(request({}), config, true) as Response).status).toBe(401); + expect(resolveAudioClient(request({}), config)).toBeNull(); + expect((resolveAudioClient(request({ authorization: "Bearer custom-revoked-key" }), config) as Response).status).toBe(401); + }); + test("pending rotation key keeps configured call ownership", () => { + const rotated = { ...config, apiKeys: [{ ...config.apiKeys![0]!, pendingRotation: { id: "rotation", key: "ocx_data_rotated_fixture", createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString() } }] }; + const old = client(resolveAudioClient(request({ authorization: `Bearer ${KEY}` }), rotated)); + const next = client(resolveAudioClient(request({ authorization: "Bearer ocx_data_rotated_fixture" }), rotated)); + expect(next.owner).toBe(old.owner); + }); +}); diff --git a/tests/server/audio-dictation.test.ts b/tests/server/audio-dictation.test.ts new file mode 100644 index 0000000000..864a605f84 --- /dev/null +++ b/tests/server/audio-dictation.test.ts @@ -0,0 +1,243 @@ +import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveCodexAccountCredential } from "../../src/codex/account-store"; +import { clearAccountQuota } from "../../src/codex/auth-api"; +import { clearCodexUpstreamHealth, clearThreadAccountMap } from "../../src/codex/routing"; +import * as routing from "../../src/codex/routing"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { createDictationFrameValidator } from "../../src/server/audio-dictation"; +import { abortAndReleaseAllTurns, resetLifecycleDrainStateForTests } from "../../src/server/lifecycle"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +const KEY = "ocx_data_audio_stream_fixture"; +const OTHER_KEY = "ocx_data_audio_other_fixture"; +const fetchOriginal = globalThis.fetch; +const previousHome = process.env.OPENCODEX_HOME; +const previousToken = process.env.OPENCODEX_API_AUTH_TOKEN; +let home: string; +let codex: IsolatedCodexHome; +let fixture: ReturnType | undefined; +const clients = new Set(); + +const startEvent = { type: "session.start", config: { + input_audio_format: "pcm16", sample_rate_hz: 48000, num_channels: 1, + max_buffer_size_bytes: 4194304, max_utterance_duration_ms: 30000, session_ttl_ms: 300000, + provider_mode: "streaming_sse", transcript_delivery_mode: "segment", + vad: { type: "server_vad", threshold: 0.5, prefix_padding_ms: 300, silence_duration_ms: 500 }, +} }; + +function createFixture() { + const creates: Headers[] = []; + const handshakes: Array<{ url: string; headers: Headers; protocols?: string[] }> = []; + const frames: string[] = []; + const upstreamClosed = Promise.withResolvers(); + const upstream = Bun.serve({ + port: 0, + fetch(req, server) { + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { + const protocol = req.headers.get("sec-websocket-protocol")?.includes("chatgpt-dictation") ? "chatgpt-dictation" : undefined; + if (server.upgrade(req, { data: {}, ...(protocol ? { headers: { "sec-websocket-protocol": protocol } } : {}) })) return; + } + return new Response("not found", { status: 404 }); + }, + websocket: { + message(ws, raw) { + const message = String(raw); + frames.push(message); + let event: { type?: string }; + try { event = JSON.parse(message); } catch { ws.send(message); return; } + if (event.type === "session.start") ws.send(JSON.stringify({ type: "session.started", sequence_no: 1, session: { session_id: "fixture", status: "active", config: { provider_mode: "streaming_sse", transcript_delivery_mode: "segment" } } })); + else if (event.type === "audio.append") ws.send(JSON.stringify({ type: "transcript.final", sequence_no: 2, utterance_id: "u1", revision: 1, text: "fixture transcript" })); + else if (event.type === "session.close") { + ws.send(JSON.stringify({ type: "session.updated", sequence_no: 3, session: { session_id: "fixture", status: "closed", config: { provider_mode: "streaming_sse", transcript_delivery_mode: "segment" } } })); + ws.close(1000); + } else ws.send(message); + }, + }, + }); + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (["chatgpt.com", "api.openai.com"].includes(new URL(req.url).hostname)) { + if (new URL(req.url).pathname.endsWith("/realtime/calls") || new URL(req.url).pathname === "/v1/live") { + creates.push(new Headers(req.headers)); + return new Response("v=0\r\n", { status: 201, headers: { "content-type": "application/sdp", location: `https://api.openai.com/v1/live/rtc_upstream_${creates.length}` } }); + } + return Response.json({}); + } + return fetchOriginal(input, init); + }) as typeof fetch; + const server = startServer(0, { + liveSidebandWebSocketFactory(url, headers, protocols) { + handshakes.push({ url, headers: new Headers(headers), protocols }); + const local = new URL("/socket", upstream.url); local.protocol = "ws:"; + const socket = new WebSocket(local, { headers, protocols } as unknown as string[]); + socket.addEventListener("close", () => upstreamClosed.resolve(), { once: true }); + return socket; + }, + }); + return { server, upstream, creates, handshakes, frames, upstreamClosed: upstreamClosed.promise }; +} + +beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "ocx-stream-")); + process.env.OPENCODEX_HOME = home; + delete process.env.OPENCODEX_API_AUTH_TOKEN; + codex = installIsolatedCodexHome("ocx-stream-codex-"); + resetLifecycleDrainStateForTests(); clearAccountQuota(); clearCodexUpstreamHealth(); clearThreadAccountMap(); + const config: OcxConfig = { + port: 0, hostname: "127.0.0.1", defaultProvider: "openai", openaiProviderTierVersion: 2, accountPoolStrategy: "round-robin", + providers: { openai: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward", codexAccountMode: "pool" } }, + codexAccounts: [ + { id: "pool-a", email: "a@example.test", isMain: false, chatgptAccountId: "acct-a" }, + { id: "pool-b", email: "b@example.test", isMain: false, chatgptAccountId: "acct-b" }, + ], + apiKeys: [ + { id: "one", name: "one", key: KEY, createdAt: "2026-09-12T00:00:00Z" }, + { id: "two", name: "two", key: OTHER_KEY, createdAt: "2026-09-12T00:00:00Z" }, + ], + }; + for (const [id, account] of [["pool-a", "acct-a"], ["pool-b", "acct-b"]] as const) { + saveCodexAccountCredential(id, { accessToken: fakeChatGptJwt({ chatgpt_account_id: account }), refreshToken: "fixture-refresh", expiresAt: Date.now() + 3600000, chatgptAccountId: account }); + } + saveConfig(config); +}); + +afterEach(async () => { + for (const ws of clients) ws.close(); + clients.clear(); + if (fixture) await Promise.all([fixture.server.stop(true), fixture.upstream.stop(true)]); + fixture = undefined; + globalThis.fetch = fetchOriginal; + resetLifecycleDrainStateForTests(); clearAccountQuota(); clearCodexUpstreamHealth(); clearThreadAccountMap(); + codex.restore(); removeTreeWithRetry(home); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; else process.env.OPENCODEX_HOME = previousHome; + if (previousToken === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; else process.env.OPENCODEX_API_AUTH_TOKEN = previousToken; +}); + +function socket(path: string, browser = false): WebSocket { + const url = new URL(path, fixture!.server.url); url.protocol = "ws:"; + const ws = browser + ? new WebSocket(url, ["opencodex-audio", `opencodex-key.${Buffer.from(KEY).toString("base64url")}`]) + : new WebSocket(url, { headers: { authorization: `Bearer ${KEY}` } } as unknown as string[]); + clients.add(ws); + return ws; +} + +function receive(ws: WebSocket, send: unknown, expected: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { ws.close(); reject(new Error("Fixture socket timed out")); }, 10000); + ws.addEventListener("message", event => { + const text = String(event.data); + if (text.includes(expected)) { clearTimeout(timer); resolve(text); } + }); + ws.addEventListener("error", () => { clearTimeout(timer); reject(new Error("Fixture socket failed")); }, { once: true }); + const transmit = () => ws.send(typeof send === "string" ? send : JSON.stringify(send)); + if (ws.readyState === WebSocket.OPEN) transmit(); else ws.addEventListener("open", transmit, { once: true }); + }); +} + +async function createCall(): Promise { + const response = await fetchOriginal(new URL("/v1/live", fixture!.server.url), { + method: "POST", headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" }, body: JSON.stringify({ sdp: "v=0\r\n" }), + }); + expect(response.status).toBe(201); + await response.text(); + return response.headers.get("location")!; +} + +describe("dictation protocol validation", () => { + test("PCM frames require a valid start, canonical base64 and an open session", () => { + const validate = createDictationFrameValidator(); + expect(validate(JSON.stringify({ type: "audio.append", audio: "AAA=" }))).toBe(false); + expect(validate(JSON.stringify(startEvent))).toBe(true); + expect(validate(JSON.stringify(startEvent))).toBe(false); + expect(validate(JSON.stringify({ type: "audio.append", audio: "AA==" }))).toBe(false); + expect(validate(JSON.stringify({ type: "audio.append", audio: "AAA=" }))).toBe(true); + expect(validate(JSON.stringify({ type: "session.close" }))).toBe(true); + expect(validate(JSON.stringify({ type: "audio.append", audio: "AAA=" }))).toBe(false); + }); + test("bad formats and oversized configurations do not start sessions", () => { + for (const change of [{ num_channels: 2 }, { sample_rate_hz: 0 }, { session_ttl_ms: 300001 }, { max_buffer_size_bytes: 4194305 }]) { + expect(createDictationFrameValidator()(JSON.stringify({ ...startEvent, config: { ...startEvent.config, ...change } }))).toBe(false); + } + }); +}); + +describe("external audio sockets", () => { + test("browser key carrier relays the actual dictation protocol without exposing upstream credentials", async () => { + fixture = createFixture(); + const ws = socket("/v1/audio/transcriptions/stream", true); + await receive(ws, startEvent, "session.started"); + expect(ws.protocol).toBe("opencodex-audio"); + const transcript = await receive(ws, { type: "audio.append", audio: "AAA=" }, "transcript.final"); + expect(JSON.parse(transcript).text).toBe("fixture transcript"); + await receive(ws, { type: "session.close" }, "session.updated"); + expect(fixture.handshakes[0]!.url).toBe("wss://chatgpt.com/backend-api/dictation/stream"); + expect(fixture.handshakes[0]!.protocols?.[0]).toBe("chatgpt-dictation"); + expect(fixture.handshakes[0]!.protocols?.[1]).toStartWith("openai-bearer."); + expect(fixture.handshakes[0]!.protocols?.join(",")).not.toContain(KEY); + }); + test("standalone live defaults model and negotiation only for external clients", async () => { + fixture = createFixture(); + const ws = socket("/v1/live"); + expect(await receive(ws, "fixture-echo", "fixture-echo")).toBe("fixture-echo"); + expect(fixture.handshakes[0]!.url).toBe("wss://api.openai.com/v1/live?model=gpt-live-1-codex"); + expect(fixture.handshakes[0]!.headers.get("openai-alpha")).toBe("quicksilver=v2"); + }); + test("successful external sockets record one supported success outcome", async () => { + fixture = createFixture(); + const outcomes = spyOn(routing, "recordCodexUpstreamOutcome"); + try { + const ws = socket("/v1/live"); + await receive(ws, "healthy", "healthy"); + ws.close(); + await fixture.upstreamClosed; + expect(outcomes.mock.calls.map(call => call[2])).toEqual([200]); + } finally { outcomes.mockRestore(); } + }); + test("shutdown cancellation reaches the authenticated upstream socket", async () => { + fixture = createFixture(); + const ws = socket("/v1/live"); + await receive(ws, "before-shutdown", "before-shutdown"); + const closed = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("Shutdown left the client open")), 10000); + ws.addEventListener("close", () => { clearTimeout(timer); resolve(); }, { once: true }); + }); + abortAndReleaseAllTurns(); + await Promise.all([closed, fixture.upstreamClosed]); + expect(ws.readyState).toBe(WebSocket.CLOSED); + }, { timeout: 15000 }); + test("a returned call alias keeps the creating account after pool rotation", async () => { + fixture = createFixture(); + const location = await createCall(); + expect(location).toStartWith("/v1/live/rtc_ocx_"); + await createCall(); + expect(fixture.creates[1]!.get("chatgpt-account-id")).not.toBe(fixture.creates[0]!.get("chatgpt-account-id")); + const ws = socket(location); + await receive(ws, "bound-echo", "bound-echo"); + expect(fixture.handshakes[0]!.url).toBe("wss://api.openai.com/v1/live/rtc_upstream_1"); + expect(fixture.handshakes[0]!.headers.get("chatgpt-account-id")).toBe(fixture.creates[0]!.get("chatgpt-account-id")); + }); + test("wrong key and unkeyed callers cannot join an external alias", async () => { + fixture = createFixture(); + const location = await createCall(); + for (const [key, expected] of [[OTHER_KEY, 404], ["", 401]] as const) { + const response = await fetchOriginal(new URL(location, fixture.server.url), { headers: { upgrade: "websocket", connection: "upgrade", "sec-websocket-key": "MDEyMzQ1Njc4OWFiY2RlZg==", "sec-websocket-version": "13", ...(key ? { authorization: `Bearer ${key}` } : {}) } }); + expect(response.status).toBe(expected); + await response.text(); + } + expect(fixture.handshakes).toHaveLength(0); + }); + test("missing reserved aliases never become legacy native joins", async () => { + fixture = createFixture(); + const response = await fetchOriginal(new URL("/v1/live/rtc_ocx_expired", fixture.server.url), { headers: { upgrade: "websocket" } }); + expect(response.status).toBe(401); + expect(fixture.handshakes).toHaveLength(0); + }); +}); diff --git a/tests/server/live-call-bindings.test.ts b/tests/server/live-call-bindings.test.ts new file mode 100644 index 0000000000..161c68f1fa --- /dev/null +++ b/tests/server/live-call-bindings.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { EXTERNAL_CALL_PREFIX, LIVE_CALL_TTL_MS, LiveCallBindings, upstreamLiveCallId, type LiveCallBinding } from "../../src/server/live-call-bindings"; + +const binding: LiveCallBinding = { owner: "key-one", upstreamCallId: "rtc_upstream", joinStyle: "frameless-path", providerName: "openai", accountId: "account-a", chatgptAccountId: "workspace-a", callerOwned: false }; + +describe("external live call ownership", () => { + test("opaque aliases isolate owners and do not expose upstream call IDs", () => { + const registry = new LiveCallBindings(); + const id = registry.create(binding)!; + expect(id.startsWith(EXTERNAL_CALL_PREFIX)).toBe(true); + expect(id).not.toContain("upstream"); + expect(registry.get(id, "key-one")).toEqual(binding); + expect(registry.get(id, "key-two")).toBeUndefined(); + const copy = registry.get(id, "key-one")!; + copy.accountId = "other"; + expect(registry.get(id, "key-one")!.accountId).toBe("account-a"); + }); + test("expiry and clear retire aliases independently of socket reconnection", () => { + let now = 0; + const registry = new LiveCallBindings(() => now); + const id = registry.create(binding)!; + now = LIVE_CALL_TTL_MS - 1; + expect(registry.get(id, "key-one")).toBeDefined(); + now += 1; + expect(registry.get(id, "key-one")).toBeUndefined(); + const next = registry.create(binding)!; + registry.clear(); + expect(registry.get(next, "key-one")).toBeUndefined(); + }); + test("capacity is bounded and expired entries reclaim capacity", () => { + let now = 0; + const registry = new LiveCallBindings(() => now); + for (let i = 0; i < 1024; i++) expect(registry.create(binding)).not.toBeNull(); + expect(registry.create(binding)).toBeNull(); + now += LIVE_CALL_TTL_MS; + expect(registry.create(binding)).not.toBeNull(); + }); + test("Location parsing extracts only a bounded rtc or UUID identifier", () => { + expect(upstreamLiveCallId("https://api.openai.com/v1/live/rtc_upstream?private=context")).toBe("rtc_upstream"); + expect(upstreamLiveCallId("/v1/live/01234567-89ab-cdef-0123-456789abcdef")).toBe("01234567-89ab-cdef-0123-456789abcdef"); + for (const location of [null, "javascript:rtc_upstream", "/v1/live/%2fsecret", "/v1/live/%ZZ", "/v1/live/unknown", "/v1/live/rtc_" + "x".repeat(128)]) { + expect(upstreamLiveCallId(location)).toBeNull(); + } + }); +}); From e1442ef3473fb3333ee1dd3383e884fd4705136e Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 18:57:04 +0900 Subject: [PATCH 06/10] fix(audio): preserve native keys and neutral socket accounting --- .../020_streaming_voice.md | 2 + .../content/docs/reference/proxy-formats.md | 6 +++ src/server/audio-client.ts | 12 ++++-- src/server/index.ts | 3 +- structure/providers/openai-tiers.md | 6 +-- tests/server/audio-client.test.ts | 5 +++ tests/server/audio-dictation.test.ts | 42 +++++++++++++++++-- 7 files changed, 65 insertions(+), 11 deletions(-) diff --git a/devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md b/devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md index 88df49026a..8dceff01a8 100644 --- a/devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md +++ b/devlog/_plan/260912_audio_apis_stack/020_streaming_voice.md @@ -56,6 +56,8 @@ The externally returned call ID is a proxy-generated rtc_ocx_ alias mapp Keyed upstreams have no verified physical account ID, so their bindings additionally retain a digest of the upstream credential. Fresh join resolution compares that digest and refuses a changed credential instead of guessing that a replacement key owns the original call. This digest is created at call registration, copied only in the in-memory registry, consumed at join and removed at expiry/shutdown; it is not serialized or exposed. +Source-review refinement: normal WebSocket completion is neutral for account health because transport open can be followed by a protocol rejection. Only explicit transport failures/timeouts reach the recorder. Native platform-bearer compatibility uses equality with the configured canonical OpenAI API credential, never an sk- prefix guess; unrecognized/revoked custom bearers are rejected. The existing global listener policy still governs whether a native request is admitted without a proxy key. + Standalone WS /v1/live accepts an explicit model or defaults to gpt-live-1-codex, with gpt-live-1 as documented alias if implemented. Missing V3 negotiation is added only to this Frameless path. /v1/realtime preserves its current adapter semantics. A raw live client receives delegation events; proxy does not execute tools or fabricate delegation results. WsData fields are created at server.upgrade, serialized only by Bun in process, read at open/message/close, and disposed at relay closure; no disk reviver. Call binding types are in-memory only. Public query/model and protocol inputs are validated at ingress; no secret-bearing URL query authentication is added. diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index d17445adc9..e62c6ec419 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -494,6 +494,12 @@ response, not by the presence of a model name in the dashboard. ### Native Codex compatibility +Native API-key-mode callers on the trusted local listener may use the exact +credential configured for the canonical OpenAI API tier. Other presented bearer +values require a registered proxy key or an explicit, matching ChatGPT +token/account pair; an arbitrary key prefix is not proof of native credentials. +Credential-free trusted-local native calls retain their existing behavior. + `POST /v1/live` accepts the ChatGPT/Codex App Frameless call-creation surface. `POST /v1/realtime/calls` accepts the OpenAI Realtime call-creation surface. opencodex selects an eligible OpenAI-family route, normalizes the call-creation request for the upstream authentication diff --git a/src/server/audio-client.ts b/src/server/audio-client.ts index 2f0e082793..0e089c1aad 100644 --- a/src/server/audio-client.ts +++ b/src/server/audio-client.ts @@ -1,8 +1,8 @@ -import { createHash } from "node:crypto"; +import { createHash, timingSafeEqual } from "node:crypto"; import { formatErrorResponse } from "../bridge"; import type { OcxConfig } from "../types"; -import { captureExplicitOpenAiCallerAuth } from "../providers/openai-sidecar"; -import type { DataPlaneAdmission } from "./auth-cors"; +import { captureExplicitOpenAiCallerAuth, selectOpenAiImagesProvider } from "../providers/openai-sidecar"; +import { isProxyAdmissionSecret, type DataPlaneAdmission } from "./auth-cors"; import { resolveAudioAdmission } from "./audio-upstream"; export const AUDIO_WEBSOCKET_PROTOCOL = "opencodex-audio"; @@ -45,8 +45,12 @@ export function resolveAudioClient(req: Request, config: OcxConfig, required = f } const admission = resolveAudioAdmission(headers, config); if (!admission) { + const bearer = /^Bearer\s+([^\s,]+)$/i.exec(headers.get("authorization") ?? "")?.[1]; + const platformKey = selectOpenAiImagesProvider(config).keyed?.apiKey; + const knownPlatformBearer = !!bearer && !!platformKey && !isProxyAdmissionSecret(bearer, config) + && timingSafeEqual(createHash("sha256").update(bearer).digest(), createHash("sha256").update(platformKey).digest()); const explicit = headers.has("x-opencodex-api-key") || headers.has("x-api-key") - || (headers.has("authorization") && !captureExplicitOpenAiCallerAuth(headers, config)); + || (headers.has("authorization") && !knownPlatformBearer && !captureExplicitOpenAiCallerAuth(headers, config)); return required || carrier || explicit ? formatErrorResponse(401, "authentication_error", "opencodex API key required") : null; diff --git a/src/server/index.ts b/src/server/index.ts index 6c39555291..23150fde8e 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -515,7 +515,8 @@ function attachLiveSidebandUpstream( ws.data.liveOpened = true; if (ws.data.liveConnectTimer !== undefined) clearTimeout(ws.data.liveConnectTimer); ws.data.liveConnectTimer = undefined; - if (ws.data.liveFinish) ws.data.liveOutcome = 200; + // An accepted transport alone does not prove inference/quota recovery. + // Keep healthy closes neutral; explicit transport failures are recorded below. const pending = ws.data.livePending ?? []; ws.data.livePending = undefined; ws.data.livePendingBytes = undefined; diff --git a/structure/providers/openai-tiers.md b/structure/providers/openai-tiers.md index 536eee3979..5d2c654610 100644 --- a/structure/providers/openai-tiers.md +++ b/structure/providers/openai-tiers.md @@ -395,9 +395,9 @@ neutral and client/shutdown cancellation does not manufacture an account failure External voice reconnects restrict provider selection as well as exact account selection to the original call binding. Credential acquisition accepts a cancellation signal; post-resolution -materialization checks cancellation before returning ownership. WebSocket accounting maps an -established healthy transport to the recorder's supported success outcome, independently of -the public HTTP 101 upgrade status, and settles only once during transport cleanup. +materialization checks cancellation before returning ownership. Connectivity-only WebSocket +completion is neutral: HTTP 101 does not prove inference or quota recovery, and a normal close +may follow a protocol error. Explicit transport errors/timeouts settle once during cleanup. The dashboard presents one OpenAI Codex card with accessible Pool/Direct controls and a separate, unchanged API-key card. `PATCH /api/providers?name=openai` persists exactly one diff --git a/tests/server/audio-client.test.ts b/tests/server/audio-client.test.ts index 8da1b6e509..06ebdbb3f9 100644 --- a/tests/server/audio-client.test.ts +++ b/tests/server/audio-client.test.ts @@ -58,4 +58,9 @@ describe("audio-only WebSocket admission", () => { const next = client(resolveAudioClient(request({ authorization: "Bearer ocx_data_rotated_fixture" }), rotated)); expect(next.owner).toBe(old.owner); }); + test("known native platform bearer retains legacy handling without guessing by prefix", () => { + const keyed: OcxConfig = { ...config, providers: { "openai-apikey": { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "sk-fixture-native", authMode: "key" } } }; + expect(resolveAudioClient(request({ authorization: "Bearer sk-fixture-native" }), keyed)).toBeNull(); + expect((resolveAudioClient(request({ authorization: "Bearer sk-other-revoked" }), keyed) as Response).status).toBe(401); + }); }); diff --git a/tests/server/audio-dictation.test.ts b/tests/server/audio-dictation.test.ts index 864a605f84..1c9020e270 100644 --- a/tests/server/audio-dictation.test.ts +++ b/tests/server/audio-dictation.test.ts @@ -32,7 +32,7 @@ const startEvent = { type: "session.start", config: { vad: { type: "server_vad", threshold: 0.5, prefix_padding_ms: 300, silence_duration_ms: 500 }, } }; -function createFixture() { +function createFixture(options: { failDictation?: boolean } = {}) { const creates: Headers[] = []; const handshakes: Array<{ url: string; headers: Headers; protocols?: string[] }> = []; const frames: string[] = []; @@ -52,6 +52,11 @@ function createFixture() { frames.push(message); let event: { type?: string }; try { event = JSON.parse(message); } catch { ws.send(message); return; } + if (options.failDictation && event.type === "session.start") { + ws.send(JSON.stringify({ type: "session.error", sequence_no: 1, fatal: true, error: { code: "fixture_error", message: "fixture rejection", retryable: false } })); + ws.close(1000); + return; + } if (event.type === "session.start") ws.send(JSON.stringify({ type: "session.started", sequence_no: 1, session: { session_id: "fixture", status: "active", config: { provider_mode: "streaming_sse", transcript_delivery_mode: "segment" } } })); else if (event.type === "audio.append") ws.send(JSON.stringify({ type: "transcript.final", sequence_no: 2, utterance_id: "u1", revision: 1, text: "fixture transcript" })); else if (event.type === "session.close") { @@ -170,6 +175,25 @@ describe("dictation protocol validation", () => { }); describe("external audio sockets", () => { + test("native platform bearer keeps HTTP creation and WebSocket relay on its configured tier", async () => { + saveConfig({ port: 0, hostname: "127.0.0.1", defaultProvider: "openai-apikey", openaiProviderTierVersion: 2, + providers: { "openai-apikey": { adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", apiKey: "sk-fixture-native", authMode: "key" } }, + apiKeys: [{ id: "one", name: "one", key: KEY, createdAt: "2026-09-12T00:00:00Z" }], + }); + fixture = createFixture(); + const form = new FormData(); form.set("sdp", "v=0\r\n"); + const response = await fetchOriginal(new URL("/v1/live", fixture.server.url), { + method: "POST", body: form, headers: { authorization: "Bearer sk-fixture-native" }, + }); + expect(response.status).toBe(201); + await response.text(); + expect(response.headers.get("location")).toBe("https://api.openai.com/v1/live/rtc_upstream_1"); + const url = new URL("/v1/realtime?model=gpt-realtime-1.5", fixture.server.url); url.protocol = "ws:"; + const ws = new WebSocket(url, { headers: { authorization: "Bearer sk-fixture-native" } } as unknown as string[]); + clients.add(ws); + await receive(ws, "native-echo", "native-echo"); + expect(fixture.handshakes[0]!.headers.get("authorization")).toBe("Bearer sk-fixture-native"); + }); test("browser key carrier relays the actual dictation protocol without exposing upstream credentials", async () => { fixture = createFixture(); const ws = socket("/v1/audio/transcriptions/stream", true); @@ -190,7 +214,7 @@ describe("external audio sockets", () => { expect(fixture.handshakes[0]!.url).toBe("wss://api.openai.com/v1/live?model=gpt-live-1-codex"); expect(fixture.handshakes[0]!.headers.get("openai-alpha")).toBe("quicksilver=v2"); }); - test("successful external sockets record one supported success outcome", async () => { + test("connectivity-only completion does not claim inference recovery", async () => { fixture = createFixture(); const outcomes = spyOn(routing, "recordCodexUpstreamOutcome"); try { @@ -198,7 +222,19 @@ describe("external audio sockets", () => { await receive(ws, "healthy", "healthy"); ws.close(); await fixture.upstreamClosed; - expect(outcomes.mock.calls.map(call => call[2])).toEqual([200]); + expect(outcomes.mock.calls).toEqual([]); + } finally { outcomes.mockRestore(); } + }); + test("protocol failure followed by a normal close never records success", async () => { + fixture = createFixture({ failDictation: true }); + const before = routing.getCodexUpstreamHealth("pool-a"); + const outcomes = spyOn(routing, "recordCodexUpstreamOutcome"); + try { + const ws = socket("/v1/audio/transcriptions/stream", true); + await receive(ws, startEvent, "session.error"); + await fixture.upstreamClosed; + expect(outcomes.mock.calls).toEqual([]); + expect(routing.getCodexUpstreamHealth("pool-a")).toEqual(before); } finally { outcomes.mockRestore(); } }); test("shutdown cancellation reaches the authenticated upstream socket", async () => { From 71e22d967ffafbd3492935c299623c9127eaf17b Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 19:01:46 +0900 Subject: [PATCH 07/10] test: canonicalize fixture homes and align client census --- tests/codex-integration/codex-inject-integration.test.ts | 7 ++++--- tests/gui/integrations-invariants.test.ts | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/codex-integration/codex-inject-integration.test.ts b/tests/codex-integration/codex-inject-integration.test.ts index 8b57165b1c..5cd6572db6 100644 --- a/tests/codex-integration/codex-inject-integration.test.ts +++ b/tests/codex-integration/codex-inject-integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, beforeEach, afterEach, setDefaultTimeout } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync, readFileSync, realpathSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; @@ -59,8 +59,9 @@ describe("injectCodexConfig integration (Design B)", () => { let ocxHome: string; beforeEach(() => { - codexHome = mkdtempSync(join(tmpdir(), "ocx-inject-codex-")); - ocxHome = mkdtempSync(join(tmpdir(), "ocx-inject-home-")); + // Production canonicalizes homes before selecting journal/manifest paths. + codexHome = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-inject-codex-"))); + ocxHome = realpathSync.native(mkdtempSync(join(tmpdir(), "ocx-inject-home-"))); }); afterEach(() => { diff --git a/tests/gui/integrations-invariants.test.ts b/tests/gui/integrations-invariants.test.ts index 2c89054a3b..141421fbde 100644 --- a/tests/gui/integrations-invariants.test.ts +++ b/tests/gui/integrations-invariants.test.ts @@ -80,9 +80,9 @@ afterEach(() => { }); describe("the client registries cannot drift apart", () => { - test("every list of clients holds exactly the same thirteen ids", async () => { + test("every list of clients holds the same registered ids", async () => { /* - * Five lists name the same thirteen clients, and two of them are maintained by + * Five lists name the same clients, and two of them are maintained by * hand: the GUI cannot import the backend registry, because that would * pull node:os and node:path into the browser bundle. A client added * server-side renders no row until someone remembers the tuple, and the @@ -93,7 +93,7 @@ describe("the client registries cannot drift apart", () => { const guiRouting = await import("../../gui/src/app-routing"); const expected = [...EXPORT_CLIENT_IDS].sort(); - expect(expected).toHaveLength(14); + expect(expected).toHaveLength(15); expect([...INTEGRATION_CLIENT_IDS].sort()).toEqual(expected); expect([...gui.CLIENTS].sort()).toEqual(expected); From a5ba6c9ed446bbfcbbf7343d2363917d3d5beb6f Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 12 Sep 2026 19:10:03 +0900 Subject: [PATCH 08/10] test(audio): use a shared synthetic rotation credential --- devlog/_plan/260912_audio_apis_stack/000_plan.md | 2 ++ tests/server/audio-client.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/devlog/_plan/260912_audio_apis_stack/000_plan.md b/devlog/_plan/260912_audio_apis_stack/000_plan.md index e237ea9722..b7cd772090 100644 --- a/devlog/_plan/260912_audio_apis_stack/000_plan.md +++ b/devlog/_plan/260912_audio_apis_stack/000_plan.md @@ -6,6 +6,8 @@ Expose file transcription, streaming dictation and GPT-Live to external clients Owner steering during wp1 C: no local Bun suites, product tests, typecheck, build or dependency installation. Push with --no-verify and use exact-head remote CI for remaining executable verification. This supersedes every local command example in the layer plans. Already completed checks are historical evidence only; interrupted/crashed checks are not passing evidence. All active task-owned local suites were stopped. Functional layer closure uses the completed source review and pre-restriction focused evidence; PR readiness and final completion retain the remote CI gate under wp3 publication. +Owner scope steering during wp2: finish the audio stack and record pre-existing unrelated CI failures separately. Do not extend this task into further journal-restore or CLI stale-process repairs. Audio-owned regressions and source-review blockers still require closure. The already-published prerequisite corrections remain in the bottom branch; their remote outcomes are reported honestly. Aggregate CI failures are not represented as passing checks, and PRs remain drafts where baseline failures prevent full readiness. + - Archetype: satisfy-spec, C4 API/auth and C3 dashboard integration. - Trigger: owner requested both audio capabilities, inherited subagent verification and a published dependent PR stack. - Goal: three independently reviewable ordinary PRs with protocol tests, documentation and usable client controls. diff --git a/tests/server/audio-client.test.ts b/tests/server/audio-client.test.ts index 06ebdbb3f9..057839c4d3 100644 --- a/tests/server/audio-client.test.ts +++ b/tests/server/audio-client.test.ts @@ -3,6 +3,7 @@ import { resolveAudioClient, type AudioClient } from "../../src/server/audio-cli import type { OcxConfig } from "../../src/types"; const KEY = "ocx_data_audio_client_fixture"; +const ROTATED_KEY = "ocx_data_rotated_fixture"; const originalToken = process.env.OPENCODEX_API_AUTH_TOKEN; const config = { providers: {}, defaultProvider: "none", apiKeys: [{ id: "one", name: "one", key: KEY, createdAt: "2026-09-12T00:00:00Z" }] } as OcxConfig; beforeEach(() => { delete process.env.OPENCODEX_API_AUTH_TOKEN; }); @@ -53,9 +54,9 @@ describe("audio-only WebSocket admission", () => { expect((resolveAudioClient(request({ authorization: "Bearer custom-revoked-key" }), config) as Response).status).toBe(401); }); test("pending rotation key keeps configured call ownership", () => { - const rotated = { ...config, apiKeys: [{ ...config.apiKeys![0]!, pendingRotation: { id: "rotation", key: "ocx_data_rotated_fixture", createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString() } }] }; + const rotated = { ...config, apiKeys: [{ ...config.apiKeys![0]!, pendingRotation: { id: "rotation", key: ROTATED_KEY, createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + 60_000).toISOString() } }] }; const old = client(resolveAudioClient(request({ authorization: `Bearer ${KEY}` }), rotated)); - const next = client(resolveAudioClient(request({ authorization: "Bearer ocx_data_rotated_fixture" }), rotated)); + const next = client(resolveAudioClient(request({ authorization: `Bearer ${ROTATED_KEY}` }), rotated)); expect(next.owner).toBe(old.owner); }); test("known native platform bearer retains legacy handling without guessing by prefix", () => { From bf52b1940ae247d65d3ae2039b0186b03c53ed4e Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 10:37:23 +0900 Subject: [PATCH 09/10] test(audio): accept context principal on configured admission [skip ci] origin/dev now mints contextPrincipalId on data-plane admission. The streaming browser-carrier fixture still owns kind/keyId/source; it should not fail closed on that extra identity field. --- tests/server/audio-client.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/server/audio-client.test.ts b/tests/server/audio-client.test.ts index 057839c4d3..39765498bb 100644 --- a/tests/server/audio-client.test.ts +++ b/tests/server/audio-client.test.ts @@ -24,7 +24,8 @@ function client(result: ReturnType): AudioClient { describe("audio-only WebSocket admission", () => { test("browser carrier resolves a configured owner and selects only the public protocol", () => { const result = client(resolveAudioClient(request({ "sec-websocket-protocol": carrier() }), config, true)); - expect(result.admission).toEqual({ kind: "configured", keyId: "one", source: "dedicated" }); + expect(result.admission).toMatchObject({ kind: "configured", keyId: "one", source: "dedicated" }); + expect(result.admission).toHaveProperty("contextPrincipalId"); expect(result.protocol).toBe("opencodex-audio"); expect(result.owner).toBe('["configured","one"]'); expect(result.owner).not.toContain(KEY); From 091e0c0a379fe744396ec23a03e3b28f82a9abdf Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 13 Sep 2026 10:58:38 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat(audio):=20land=20the=20audio=20stack?= =?UTF-8?q?=20=E2=80=94=20transcription,=20streaming=20dictation=20and=20d?= =?UTF-8?q?ashboard=20controls=20(#4395)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the cumulative audio lane in one commit: standalone transcription API, streaming dictation with owned live calls, and the dashboard dictation and live voice API controls. Also carries the content of #4392. Verified by Cross-platform CI run 34731037202 on the stack tip. --- .../021_streaming_checks.md | 39 +++ .../030_connections.md | 58 ++++- .../031_connections_checks.md | 78 ++++++ .../screenshots/audio-1440-light.png | Bin 0 -> 177047 bytes .../screenshots/audio-320-ko.png | Bin 0 -> 77968 bytes .../screenshots/audio-390-light.png | Bin 0 -> 90755 bytes .../transcription-layer-baseline.png | Bin 0 -> 222807 bytes .../docs/fr/reference/proxy-formats.md | 2 + .../docs/ja/reference/proxy-formats.md | 2 + .../docs/ko/reference/proxy-formats.md | 2 + .../content/docs/reference/proxy-formats.md | 11 + .../docs/ru/reference/proxy-formats.md | 2 + .../docs/tr/reference/proxy-formats.md | 2 + .../docs/zh-cn/reference/proxy-formats.md | 2 + .../docs/zh-tw/reference/proxy-formats.md | 2 + gui/src/api.ts | 10 + gui/src/audio-api-client.ts | 153 ++++++++++++ gui/src/audio-api-examples.ts | 29 +++ .../apikeys-workspace/ApiKeysWorkspace.tsx | 13 +- .../apikeys-workspace/AudioApiPanel.tsx | 141 +++++++++++ gui/src/components/section-tabs.tsx | 19 +- gui/src/i18n/de.ts | 31 ++- gui/src/i18n/en.ts | 31 ++- gui/src/i18n/fr.ts | 31 ++- gui/src/i18n/ja.ts | 31 ++- gui/src/i18n/ko.ts | 31 ++- gui/src/i18n/ru.ts | 31 ++- gui/src/i18n/tr.ts | 31 ++- gui/src/i18n/zh-TW.ts | 31 ++- gui/src/i18n/zh.ts | 31 ++- gui/src/pages/ApiKeys.tsx | 8 + gui/src/pages/api-keys-utils.ts | 41 ++++ gui/src/styles-apikeys-workspace.css | 93 +++++++ gui/tests/api-auth-memory.test.ts | 25 +- gui/tests/audio-api-client.test.ts | 173 +++++++++++++ gui/tests/audio-api-panel.test.tsx | 229 ++++++++++++++++++ gui/tests/section-tabs-scroll-lock.test.tsx | 26 +- src/server/management/api-access.ts | 37 +++ structure/gui-and-management-api.md | 23 ++ tests/server/api-access-endpoints.test.ts | 36 ++- 40 files changed, 1510 insertions(+), 25 deletions(-) create mode 100644 devlog/_plan/260912_audio_apis_stack/021_streaming_checks.md create mode 100644 devlog/_plan/260912_audio_apis_stack/031_connections_checks.md create mode 100644 devlog/_plan/260912_audio_apis_stack/screenshots/audio-1440-light.png create mode 100644 devlog/_plan/260912_audio_apis_stack/screenshots/audio-320-ko.png create mode 100644 devlog/_plan/260912_audio_apis_stack/screenshots/audio-390-light.png create mode 100644 devlog/_plan/260912_audio_apis_stack/screenshots/transcription-layer-baseline.png create mode 100644 gui/src/audio-api-client.ts create mode 100644 gui/src/audio-api-examples.ts create mode 100644 gui/src/components/apikeys-workspace/AudioApiPanel.tsx create mode 100644 gui/tests/audio-api-client.test.ts create mode 100644 gui/tests/audio-api-panel.test.tsx diff --git a/devlog/_plan/260912_audio_apis_stack/021_streaming_checks.md b/devlog/_plan/260912_audio_apis_stack/021_streaming_checks.md new file mode 100644 index 0000000000..da208293c7 --- /dev/null +++ b/devlog/_plan/260912_audio_apis_stack/021_streaming_checks.md @@ -0,0 +1,39 @@ +# Streaming outcome and next layer + +Functional layer: PASS at `011f2dff5ca3667b88f090fe711c4b2c77efd190`, PR #4392 onto +`codex/audio-transcription` (`71e22d967ffafbd3492935c299623c9127eaf17b`). +Remote run [34687731903](https://github.com/lidge-jun/opencodex/actions/runs/34687731903) +tested merge `df1c26d1`. + +- Audio client: 11 passing cases, job 103537614446. +- File transcription: 24 passing cases, job 103537614468. +- Dictation/voice ingress and lifecycle: 11 passing cases, job 103537614422. +- Call bindings: four passing cases on Linux and macOS, job 103537614428. +- Gates: typecheck, 1,979 dashboard tests, privacy, skill surface, release syntax + and CLI smoke passed, job 103537614453. Dashboard build skipped for this layer. +- Independent inherited source reviews: Ramanujan closure PASS; Feynman final + PASS after native platform-key compatibility and neutral WebSocket accounting + corrections. No unresolved blocking finding in these bounded reviews. + +All checks above ran remotely. Local product tests, typecheck, build and install +were NOT RUN for this layer, per owner instruction. Pushes used `--no-verify`. +No real upstream audio or personal recording was used. + +## Separate baseline failures + +Linux test 2/4 (103537614443) and macOS 2/2 (103537614422) still report +`tests/codex-integration/codex-journal.test.ts:170` (failed versus skipped restore) +and `:528` (routing retained after compensated failed restore). These failures +were observed before the final audio changes. They are recorded, not included +in this feature's repair scope, following the owner's explicit decision. +An earlier run also showed the stale-process status assertion at +`tests/cli/cli-status-json.test.ts:893`; do not claim it repaired without evidence. +Bun batch crashes that recovered through CI singleton retries are not runtime-fix +evidence. Whole-run green and merge readiness are not claimed. + +## Next + +Proceed to wp3: configured endpoint metadata, separate Dictation and Live Voice +controls, synthetic browser QA of the CI-built dashboard, and ordinary stacked +publication. Configuration is not entitlement or observed connectivity. Leave +all PRs open; aggregate baseline failures remain a separate publication note. diff --git a/devlog/_plan/260912_audio_apis_stack/030_connections.md b/devlog/_plan/260912_audio_apis_stack/030_connections.md index ccb9fa24ce..dd3bcac7f3 100644 --- a/devlog/_plan/260912_audio_apis_stack/030_connections.md +++ b/devlog/_plan/260912_audio_apis_stack/030_connections.md @@ -2,19 +2,31 @@ Depends on wp2 completed audio routes. The first two layers remain independently usable through external client examples. +P revalidation: wp2 D concluded functional audio tests and source reviews PASS at +011f2dff5c, with unrelated journal failures recorded separately. Follow that +direction without expanding baseline repairs. C4 because transient credentials +cross the browser/data-plane boundary. Main owns implementation; inherited +architect and independent reviewers remain read-only. No additional cost/time +budget was imposed. No paid calls, personal audio, service changes or merge. +Local product suites/typecheck/build/install are prohibited. Existing remote CI +runs the checks; browser QA reads its built artifact with synthetic routes only. + ## File changes | Operation | Path | Contract change | | --- | --- | --- | | MODIFY | src/server/management/api-access.ts | extend ApiAccessEndpoints with transcription, dictationStream, live and realtimeCalls URLs plus truthful capability metadata | -| MODIFY | src/server/management-api.ts | include audio metadata using existing management authentication | +| EXISTING | src/server/management/oauth-account-routes.ts | /api/keys already serializes ...endpoints; no new route or management authority | | MODIFY | tests/server/api-access-endpoints.test.ts | URL host/protocol and capability projection tests | | MODIFY | gui/src/pages/api-keys-utils.ts | extend endpoint type/default/derive chain for new endpoints | | MODIFY | gui/src/pages/ApiKeys.tsx | consume serialized endpoint metadata through KeysResponse, CachedKeysShape, cache validation and fetchKeys | | MODIFY | gui/src/components/apikeys-workspace/ApiKeysWorkspace.tsx | place two unframed audio sections in existing Connections/API layout | | NEW | gui/src/components/apikeys-workspace/AudioApiPanel.tsx | accessible Dictation and Live Voice controls, endpoint/model display, sample copying, transient key/file controls and result/error states | | NEW | gui/src/audio-api-client.ts | bounded cancelable upload and socket client protocol helpers; no saved secrets | -| NEW | gui/tests/audio-api-client.test.ts | request generation, cancellation and transcript assembly tests | +| NEW | gui/src/audio-api-examples.ts | executable protocol examples outside component markup; localized prompt label supplied by caller | +| MODIFY | gui/src/api.ts | narrow audio upload fetch entry bypasses management auth injection/recovery, validates exact inference path | +| NEW | gui/tests/audio-api-client.test.ts | request generation, cancellation and protocol readiness tests | +| NEW | gui/tests/audio-api-panel.test.tsx | real component upload, error, cancellation, missing metadata and deactivation flows | | MODIFY | gui/src/i18n/en.ts and every locale module | complete localized label/status/action keys | | MODIFY | gui/src/styles-apikeys-workspace.css | restrained aligned responsive audio sections using existing tokens | | MODIFY | structure/gui-and-management-api.md | metadata and control ownership/current contract | @@ -26,11 +38,23 @@ Before: ApiAccessEndpoints contains Responses/chat/messages/models only. After: Metadata fields complete chain: creation buildApiAccessEndpoints -> JSON management response -> API page validation/mapping -> ApiEndpointInfo/AudioApiPanel. Defaults cannot claim configured availability. No new provider is registered and no audio model enters a text completion test. +The optional `audio` projection has transcriptionEndpoint, dictationStreamEndpoint, +liveEndpoint, realtimeCallsEndpoint, transcriptionModel, liveModel, +transcriptionConfigured, dictationConfigured and liveConfigured. URL/model strings +and booleans are validated on both network and cached payloads; endpoint origins +and paths must match the published base after WS-to-HTTP normalization. Missing +or invalid audio metadata leaves the audio controls unavailable, while existing +key management keeps working. Configuration flags use canonical enabled provider +configuration only, never credential resolution, account reads or a network probe. +File transcription accepts canonical ChatGPT or configured OpenAI API routing; +dictation/live GPT-Live flags require canonical ChatGPT routing (an API-key-only +configuration does not prove access to the Codex live model). + Dictation section contains model and endpoint copy actions, file input, transient API key input, transcribe/cancel, text result/copy and clear error states. Stream example names extension protocol and gives start/audio/close events. Live Voice section contains actual GPT-Live model and both WS/WebRTC connection endpoints, transient client key, a connect/disconnect test with status and observed event output. Browser WebSocket auth must use a short-lived local session mechanism or supported client protocol carrier; never expose ChatGPT credentials or persist raw keys. Do not create a fake success check or billable background probe. All test actions require a deliberate user click. UI is unframed and follows existing workspace colors/type/spacing. Icons reuse gui icons, all visible text is localized. At desktop and mobile widths long endpoint text wraps or scrolls within its own element without overlapping controls. Buttons have stable dimensions and stateful controls are keyboard reachable. -Pass the existing active flag through ApiKeysWorkspace. Integrations hides panels without unmounting; requests, sockets and timers must stop on deactivation as well as unmount. Existing key rows contain only prefixes: controls use a newly generated key or an explicitly entered transient key, never pretend a key ID can authenticate. Browser voice connection uses an OpenCodex-only WebSocket protocol credential carrier accepted solely by the audio routes; exact supported carrier and precedence are documented/tested in wp2. No persistent key or query authentication. +Pass the existing active flag through ApiKeysWorkspace. Integrations hides panels without unmounting; conditionally unmount just audio controls when inactive, preserving the rest of the workspace drafts. Requests, sockets and timers stop on deactivation, origin change and unmount. Existing key rows contain only prefixes: controls use an explicitly entered transient key, never pretend a key ID can authenticate. Key edits cancel pending work. Browser voice connection uses the OpenCodex-only WebSocket protocol credential carrier accepted solely by the audio routes; exact supported carrier and precedence are documented/tested in wp2. No persistent key or query authentication. Raw upstream messages are not rendered: show localized error categories and allowlisted event types only. Socket open is not success: wait for session.started/session.updated with a nonempty session.id. Probe sends no audio and closes after a bounded interval or explicit disconnect. ## Acceptance and publication @@ -38,7 +62,33 @@ Pass the existing active flag through ApiKeysWorkspace. Integrations hides panel 2. Mocked browser flow uploads a fixture, receives text, copies it, cancels a pending call and displays a server error. No real audio/provider requests during agent QA. 3. Mocked voice flow connects, observes a protocol event, disconnects and releases callbacks/timers; API keys never enter storage, screenshots or URL queries. 4. Desktop and mobile browser screenshots are read back and corrected. Screenshot attached to UI PR with synthetic data only. -5. Run GUI focused tests, lint:i18n, lint, build and repository typecheck/full suite before review ready; per-layer CI uses exact PR head. The final PRs fill Summary, Verification and Checklist plus ordinary stack map. +5. Remote CI executes dashboard tests/lint/build and repository typecheck/suite; no local execution. Inspect exact-head logs and download its dashboard-preview artifact. The final PRs fill Summary, Verification and Checklist plus ordinary stack map. Existing unrelated CI failures stay separately documented; never attest whole-suite green. + +Conditional acceptance includes empty/malformed metadata (disabled controls, no +request), file >25,000,000 bytes (local rejection before fetch), 401/429/503 +(localized categories, no raw body), aborted upload (no stale text), WS open +without ready event (timeout), protocol error (failed, not connected), key/origin +change and inactive/unmount (all resources closed, no late callback). Copy samples +contain placeholders only, never the transient input. Metadata tests cover TLS, +wildcard, IPv6 and loopback companion URLs. No new enforcement layer is claimed; +browser guards are early UX validation, and server admission remains authoritative. + +## P/A review disposition + +Accepted architect CONN-META-01, WIRE-02, URL-03, UI-04 and LIFE-06. Folded +CONN-PROBE-05 and the independent A review's three residuals: + +- On socket open send exactly `{"type":"session.update","session":{"instructions":"","audio":{"output":{"voice":"cove"}},"delegation":{"type":"client"}}}`. + Acknowledgments with closed/error/failed session status are terminal, never + ready. An error followed by normal close remains failed. +- The API module exposes a narrow raw audio-upload entry. It validates the exact + HTTP(S) `/v1/audio/transcriptions` destination and bypasses installed management + authentication and 401 recovery. Connected-mode tests install that wrapper and + assert the typed data key survives, with no session/CSRF/machine credentials. +- Endpoint validation rejects userinfo, query, fragment and incorrect schemes as + well as wrong origins/paths. Network and cache paths use the same validator. + Only the wp2 `opencodex-audio` / `opencodex-key.` carrier is used; + observed output means allowlisted event types, not raw messages. Commands are defined by root/gui package.json. Source paths and existing stylesheet/fetch owner are revalidated at this cycle P before implementation; any renamed path is amended with exact ownership evidence. No disconnected metadata fields or fake audio model tests are acceptable. diff --git a/devlog/_plan/260912_audio_apis_stack/031_connections_checks.md b/devlog/_plan/260912_audio_apis_stack/031_connections_checks.md new file mode 100644 index 0000000000..ff74412621 --- /dev/null +++ b/devlog/_plan/260912_audio_apis_stack/031_connections_checks.md @@ -0,0 +1,78 @@ +# Connections audio verification + +## Delivered surface + +Ordinary dependency chain remains open: [#4391](https://github.com/lidge-jun/opencodex/pull/4391) +-> [#4392](https://github.com/lidge-jun/opencodex/pull/4392) +-> [#4395](https://github.com/lidge-jun/opencodex/pull/4395). +No native stack registration, merge, release, deployment or live-service restart. + +The top layer adds validated audio metadata, separate Dictation and Live Voice +blocks, temporary data-key input, cancelable upload/copy, connection-only native +session readiness, client examples and all nine dashboard locales. It does not +add audio models to text completion tests. Invalid/missing metadata leaves existing +key management usable. Configured availability is not account entitlement. + +## Executable proof + +Runtime/UI source head: `f5aefd88af3116bec4f2ddc72c0c6fa974f52a83`. +Remote [run 34690242138](https://github.com/lidge-jun/opencodex/actions/runs/34690242138), +gates job 103544165369: SUCCESS for lint, typecheck, dashboard tests, privacy, +generated skill surface, release syntax, dashboard build and CLI smoke. +Artifact 10296892796 is `dashboard-preview-bb0f30fbab4496b2b69bbc1e8148e59035ba3778`. +Its GUI tree `5ccecf06a8e144e5260f4991047db6fa36f464a1` exactly matches source. +The artifact names the tested PR merge, not a different runtime build. + +Local product tests, typecheck, build, dependency installation and suites: +**NOT RUN**, per owner restriction. Every commit/push used `--no-verify`. +The only local execution was a static Node file server for the CI-built artifact +and browser QA through the already installed Playwright dependency. No proxy +runtime, provider audio request, personal recording or microphone was used. + +## Browser matrix + +Invocation: static artifact server, then `.tmp/audio-browser-run.mjs` driving +`/#integrations/keys` with intercepted synthetic management/audio routes. +Installed agbrowse lacked its documented script command, so the existing +agbrowse Playwright dependency drove a separate CDP browser on port 9231. +No new browser dependency was installed. + +| Scenario | Observed result | +| --- | --- | +| Upload synthetic file | Expected transcript; typed data key only, no management/CSRF headers | +| Copy transcript | Actual browser clipboard contained the exact transcript | +| Live connect/disconnect | session.update, session.started with ID, session.close; no audio frames | +| HTTP 401 | Localized error, raw provider material absent | +| Cancel slow response | No late transcript published | +| Leave/re-enter API tab | Pending resources released, temporary key cleared | +| Storage inspection | No typed key in localStorage/sessionStorage | +| Keyboard | Key input -> file input follows Tab order | +| 1440/1024/768/390/320 | No audio-control overflow; settled screenshots read back | +| Korean 1440/390/320 | Labels fit; no new-section overlap or clipped Korean text | +| Runtime | No page JavaScript errors; no external network requests | + +Initial captures exposed mobile top-bar overlap and a two-line tablet section +strip. Both were fixed; final screenshots below depict the corrected source. +Mobile scroll-spy uses the same 108px reading line as section positioning. + +![Desktop audio controls](screenshots/audio-1440-light.png) +![Mobile audio controls](screenshots/audio-390-light.png) +![Narrow Korean audio controls](screenshots/audio-320-ko.png) + +`screenshots/transcription-layer-baseline.png` separately renders the bottom PR's +own CI artifact (run 34687369123, merge d9771b3133863f5be1b89213029b0a8037dbcb89, +GUI tree 999781a53536c82ccfbfd376083e945bf388c55d). That layer changes GUI test +fixtures and asset provenance only; this capture does not claim the audio UI +exists in the bottom layer. + +## Review and remaining limits + +Inherited security review closed metadata projection and post-readiness failures. +Inherited component review closed endpoint styling, replacement-race and cache +observation coverage. Subsequent xai/grok-4.6 review closed idle status and mobile +scroll-spy alignment. Source verdicts: PASS. Rendered evidence is separate from +source review; neither establishes real provider availability. + +Unrelated journal restore failures remain recorded in `021_streaming_checks.md`. +Old/superseded or cancelled whole runs are not passing final-head evidence. No +whole-suite-green or merge-readiness claim is made. PRs remain open for review. diff --git a/devlog/_plan/260912_audio_apis_stack/screenshots/audio-1440-light.png b/devlog/_plan/260912_audio_apis_stack/screenshots/audio-1440-light.png new file mode 100644 index 0000000000000000000000000000000000000000..81a579bb82bf49107d7e7fca242b152613999ba2 GIT binary patch literal 177047 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%0A2dQ(BJd_6Wy^`FKFG;vr5SWwQG7eQOeJ1B=6 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

jxqNH^yWiBO8BQp!B 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

g3W-Y*LD^&=AxriFRcjM15>smH8&6rRO!-Zh*JP zjBot0N_cykILAzCjz8YQhs9dWDt{cc#V#~}I(Vbzx5Q*Zmd@n8NI%9I3+3Cm(j(

AoR>zm5&)ylkp~=gS4RkS#DpNi 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≫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