(
callbacks: VoiceStreamCallbacks,
options?: { language?: string; keyterms?: string[] },
)
| 109 | // ─── Connection ──────────────────────────────────────────────────────── |
| 110 | |
| 111 | export async function connectVoiceStream( |
| 112 | callbacks: VoiceStreamCallbacks, |
| 113 | options?: { language?: string; keyterms?: string[] }, |
| 114 | ): Promise<VoiceStreamConnection | null> { |
| 115 | // Ensure OAuth token is fresh before connecting |
| 116 | await checkAndRefreshOAuthTokenIfNeeded() |
| 117 | |
| 118 | const tokens = getClaudeAIOAuthTokens() |
| 119 | if (!tokens?.accessToken) { |
| 120 | logForDebugging('[voice_stream] No OAuth token available') |
| 121 | return null |
| 122 | } |
| 123 | |
| 124 | // voice_stream is a private_api route, but /api/ws/ is also exposed on |
| 125 | // the api.anthropic.com listener (service_definitions.yaml private-api: |
| 126 | // visibility.external: true). We target that host instead of claude.ai |
| 127 | // because the claude.ai CF zone uses TLS fingerprinting and challenges |
| 128 | // non-browser clients (anthropics/claude-code#34094). Same private-api |
| 129 | // pod, same OAuth Bearer auth — just a CF zone that doesn't block us. |
| 130 | // Desktop dictation still uses claude.ai (Swift URLSession has a |
| 131 | // browser-class JA3 fingerprint, so CF lets it through). |
| 132 | const wsBaseUrl = |
| 133 | process.env.VOICE_STREAM_BASE_URL || |
| 134 | getOauthConfig() |
| 135 | .BASE_API_URL.replace('https://', 'wss://') |
| 136 | .replace('http://', 'ws://') |
| 137 | |
| 138 | if (process.env.VOICE_STREAM_BASE_URL) { |
| 139 | logForDebugging( |
| 140 | `[voice_stream] Using VOICE_STREAM_BASE_URL override: ${process.env.VOICE_STREAM_BASE_URL}`, |
| 141 | ) |
| 142 | } |
| 143 | |
| 144 | const params = new URLSearchParams({ |
| 145 | encoding: 'linear16', |
| 146 | sample_rate: '16000', |
| 147 | channels: '1', |
| 148 | endpointing_ms: '300', |
| 149 | utterance_end_ms: '1000', |
| 150 | language: options?.language ?? 'en', |
| 151 | }) |
| 152 | |
| 153 | // Route through conversation-engine with Deepgram Nova 3 (bypassing |
| 154 | // the server's project_bell_v2_config GrowthBook gate). The server |
| 155 | // side is anthropics/anthropic#278327 + #281372; this lets us ramp |
| 156 | // clients independently. |
| 157 | const isNova3 = getFeatureValue_CACHED_MAY_BE_STALE( |
| 158 | 'tengu_cobalt_frost', |
| 159 | false, |
| 160 | ) |
| 161 | if (isNova3) { |
| 162 | params.set('use_conversation_engine', 'true') |
| 163 | params.set('stt_provider', 'deepgram-nova3') |
| 164 | logForDebugging('[voice_stream] Nova 3 gate enabled (tengu_cobalt_frost)') |
| 165 | } |
| 166 | |
| 167 | // Append keyterms as query params — the voice_stream proxy forwards |
| 168 | // these to the STT service which applies appropriate boosting. |
no test coverage detected