{"repo": "abdulrhman-io/abdulrhman-io-ide", "files": {"ai-work-ide/packages/ai-streaming/src/providerAdapters.ts": {"27578d84d1b3cf7f2fbcf169b50975f2691892d5": {"t": 1785746407.8589363, "n": 14077, "h": "672f6aebc8ffaf70", "c": "/**\n * Provider adapters — normalize streaming across OpenAI, Claude, Gemini,\n * DeepSeek, Ollama, and OpenAI-compatible endpoints.\n *\n * Design: each adapter yields async token strings. No secrets are stored here;\n * callers inject credentials via StreamProviderConfig.\n */\n\nimport type { StreamMessage, StreamProviderId } from './streamEvents.js';\n\nexport interface StreamProviderConfig {\n provider: StreamProviderId;\n model: string;\n /** Base URL override (Ollama, OpenRouter, Azure, custom) */\n baseUrl?: string;\n /** API key — never logged */\n apiKey?: string;\n temperature?: number;\n maxTokens?: number;\n headers?: Record;\n /** Optional fetch implementation (for tests / Electron) */\n fetchImpl?: typeof fetch;\n}\n\nexport interface ProviderStreamRequest {\n messages: StreamMessage[];\n model: string;\n temperature?: number;\n maxTokens?: number;\n signal: AbortSignal;\n}\n\nexport interface ProviderAdapter {\n id: StreamProviderId;\n /**\n * Open a streaming completion. Yields content deltas (tokens/chunks).\n * Must respect AbortSignal and throw on hard failures.\n */\n stream(\n config: StreamProviderConfig,\n request: ProviderStreamRequest,\n ): AsyncGenerator;\n\n /**\n * Optional: Transcribe audio using the provider's STT service.\n */\n transcribeAudio?(config: StreamProviderConfig, blob: Blob): Promise;\n}\n\nfunction getFetch(config: StreamProviderConfig): typeof fetch {\n return config.fetchImpl ?? globalThis.fetch.bind(globalThis);\n}\n\nfunction redactError(err: unknown): string {\n const msg = err instanceof Error ? err.message : String(err);\n return msg.replace(/sk-[a-zA-Z0-9_-]+/g, 'sk-***').replace(/Bearer\\s+\\S+/gi, 'Bearer ***');\n}\n\nasync function* parseSseJsonLines(\n body: ReadableStream,\n extract: (json: any) => string | null,\n signal: AbortSignal,\n): AsyncGenerator {\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let finishReason = 'unknown';\n\n try {\n while (true) {\n if (signal.aborted) {\n finishReason = 'cancelled';\n break;\n }\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split(/\\r?\\n/);\n buffer = lines.pop() ?? '';\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(':')) continue;\n if (!trimmed.startsWith('data:')) continue;\n const data = trimmed.slice(5).trim();\n if (data === '[DONE]') {\n finishReason = finishReason === 'unknown' ? 'stop' : finishReason;\n return { finishReason };\n }\n try {\n const json = JSON.parse(data);\n const fr = json.choices?.[0]?.finish_reason ?? json.stop_reason ?? json.finishReason;\n if (fr) finishReason = String(fr);\n const token = extract(json);\n if (token) yield token;\n } catch {\n // skip malformed SSE chunks\n }\n }\n }\n } finally {\n try {\n reader.releaseLock();\n } catch {\n /* ignore */\n }\n }\n return { finishReason };\n}\n\n/** OpenAI + DeepSeek + OpenAI-compatible (shared chat.completions stream shape) */\nexport const openAiCompatibleAdapter: ProviderAdapter = {\n id: 'openai-compatible',\n async *stream(config, request) {\n const fetchFn = getFetch(config);\n const base = (config.baseUrl || 'https://api.openai.com/v1').replace(/\\/$/, '');\n const url = `${base}/chat/completions`;\n const headers: Record = {\n 'Content-Type': 'application/json',\n ...(config.headers ?? {}),\n };\n if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;\n\n let res: Response;\n try {\n res = await fetchFn(url, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: request.model,\n messages: request.messages.map((m) => {\n if (Array.isArray(m.content)) {\n const hasImages = m.content.some((p: any) => p.type === 'image_url');\n if (hasImages && !config.model?.toLowerCase().includes('vision') && !config.model?.toLowerCase().includes('gpt-4o') && !config.model?.toLowerCase().includes('claude-3-5') && !config.model?.toLowerCase().includes('gemini-1.5')) {\n throw new Error('unsupported_multimodal_model: The selected model does not support image attachments. Please use a vision-capable model.');\n }\n }\n return {\n role: m.role,\n content: m.content,\n ...(m.name ? { name: m.name } : {}),\n };\n }),\n stream: true,\n temperature: request.temperature ?? config.temperature ?? 0.2,\n max_tokens: request.maxTokens ?? config.maxTokens,\n }),\n signal: request.signal,\n });\n } catch (err) {\n throw new Error(`openai_stream_connect_failed: ${redactError(err)}`);\n }\n\n if (!res.ok) {\n const body = await res.text().catch(() => '');\n throw new Error(`openai_stream_http_${res.status}: ${body.slice(0, 200)}`);\n }\n if (!res.body) throw new Error('openai_stream_no_body');\n\n return yield* parseSseJsonLines(\n res.body,\n (json) => {\n const delta = json.choices?.[0]?.delta?.content;\n return typeof delta === 'string' && delta.length > 0 ? delta : null;\n },\n request.signal,\n );\n },\n\n async transcribeAudio(config, blob) {\n const fetchFn = getFetch(config);\n const base = (config.baseUrl || 'https://api.openai.com/v1').replace(/\\/$/, '');\n const url = `${base}/audio/transcriptions`;\n const headers: Record = {\n ...(config.headers ?? {}),\n };\n if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;\n\n const formData = new FormData();\n formData.append('file', blob, 'audio.webm');\n formData.append('model', 'whisper-1');\n\n let res: Response;\n try {\n res = await fetchFn(url, {\n method: 'POST',\n headers,\n body: formData,\n });\n } catch (err) {\n throw new Error(`openai_transcribe_connect_failed: ${redactError(err)}`);\n }\n\n if (!res.ok) {\n const body = await res.text().catch(() => '');\n throw new Error(`openai_transcribe_http_${res.status}: ${body.slice(0, 200)}`);\n }\n\n const data = await res.json() as any;\n return data.text || '';\n },\n};\n\nexport const openAiAdapter: ProviderAdapter = {\n id: 'openai',\n stream(config, request) {\n return openAiCompatibleAdapter.stream(\n { ...config, baseUrl: config.baseUrl || 'https://api.openai.com/v1' },\n request,\n );\n },\n transcribeAudio(config, blob) {\n return openAiCompatibleAdapter.transcribeAudio!(\n { ...config, baseUrl: config.baseUrl || 'https://api.openai.com/v1' },\n blob,\n );\n },\n};\n\nexport const deepSeekAdapter: ProviderAdapter = {\n id: 'deepseek',\n stream(config, request) {\n return openAiCompatibleAdapter.stream(\n { ...config, baseUrl: config.baseUrl || 'https://api.deepseek.com/v1' },\n request,\n );\n },\n};\n\nexport const ollamaAdapter: ProviderAdapter = {\n id: 'ollama',\n stream(config, request) {\n return openAiCompatibleAdapter.stream(\n { ...config, baseUrl: config.baseUrl || 'http://127.0.0.1:11434/v1' },\n request,\n );\n },\n};\n\n/** Anthropic Claude Messages API streaming */\nexport const claudeAdapter: ProviderAdapter = {\n id: 'claude',\n async *stream(config, request) {\n const fetchFn = getFetch(config);\n const base = (config.baseUrl || 'https://api.anthropic.com').replace(/\\/$/, '');\n const url = `${base}/v1/messages`;\n\n const system = request.messages.filter((m) => m.role === 'system').map((m) => m.content).join('\\n');\n const messages = request.messages\n .filter((m) => m.role === 'user' || m.role === 'assistant')\n .map((m) => {\n if (Array.isArray(m.content)) {\n return {\n role: m.role,\n content: m.content.map((p: any) => {\n if (p.type === 'image_url' && p.image_url?.url) {\n const match = p.image_url.url.match(/^data:(image\\/[a-zA-Z+]+);base64,(.+)$/);\n if (match) {\n return {\n type: 'image',\n source: {\n type: 'base64',\n media_type: match[1],\n data: match[2],\n },\n };\n }\n }\n return p;\n }),\n };\n }\n return { role: m.role, content: m.content };\n });\n\n const headers: Record = {\n 'Content-Type': 'application/json',\n 'anthropic-version': '2023-06-01',\n ...(config.headers ?? {}),\n };\n if (config.apiKey) headers['x-api-key'] = config.apiKey;\n\n let res: Response;\n try {\n res = await fetchFn(url, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: request.model,\n max_tokens: request.maxTokens ?? config.maxTokens ?? 4096,\n temperature: request.temperature ?? config.temperature ?? 0.2,\n system: system || undefined,\n messages,\n stream: true,\n }),\n signal: request.signal,\n });\n } catch (err) {\n throw new Error(`claude_stream_connect_failed: ${redactError(err)}`);\n }\n\n if (!res.ok) {\n const body = await res.text().catch(() => '');\n throw new Error(`claude_stream_http_${res.status}: ${body.slice(0, 200)}`);\n }\n if (!res.body) throw new Error('claude_stream_no_body');\n\n return yield* parseSseJsonLines(\n res.body,\n (json) => {\n if (json.type === 'content_block_delta' && json.delta?.type === 'text_delta') {\n return typeof json.delta.text === 'string' ? json.delta.text : null;\n }\n if (json.type === 'content_block_delta' && typeof json.delta?.text === 'string') {\n return json.delta.text;\n }\n return null;\n },\n request.signal,\n );\n },\n};\n\n/** Google Gemini generateContent stream */\nexport const geminiAdapter: ProviderAdapter = {\n id: 'gemini',\n async *stream(config, request) {\n const fetchFn = getFetch(config);\n const model = request.model;\n const base = (config.baseUrl || 'https://generativelanguage.googleapis.com/v1beta').replace(/\\/$/, '');\n const keyQ = config.apiKey ? `?key=${encodeURIComponent(config.apiKey)}` : '';\n const url = `${base}/models/${encodeURIComponent(model)}:streamGenerateContent${keyQ}&alt=sse`;\n\n const system = request.messages.filter((m) => m.role === 'system').map((m) => m.content).join('\\n');\n const contents = request.messages\n .filter((m) => m.role === 'user' || m.role === 'assistant')\n .map((m) => {\n let parts: any[] = [];\n if (Array.isArray(m.content)) {\n parts = m.content.map((p: any) => {\n if (p.type === 'text') return { text: p.text };\n if (p.type === 'image_url' && p.image_url?.url) {\n const match = p.image_url.url.match(/^data:(image\\/[a-zA-Z+]+);base64,(.+)$/);\n if (match) {\n return {\n inlineData: {\n mimeType: match[1],\n data: match[2],\n },\n };\n }\n }\n return { text: '' };\n });\n } else {\n parts = [{ text: m.content }];\n }\n return {\n role: m.role === 'assistant' ? 'model' : 'user',\n parts,\n };\n });\n\n let res: Response;\n try {\n res = await fetchFn(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...(config.headers ?? {}),\n },\n body: JSON.stringify({\n contents,\n systemInstruction: system ? { parts: [{ text: system }] } : undefined,\n generationConfig: {\n temperature: request.temperature ?? config.temperature ?? 0.2,\n maxOutputTokens: request.maxTokens ?? config.maxTokens,\n },\n }),\n signal: request.signal,\n });\n } catch (err) {\n throw new Error(`gemini_stream_connect_failed: ${redactError(err)}`);\n }\n\n if (!res.ok) {\n const body = await res.text().catch(() => '');\n throw new Error(`gemini_stream_http_${res.status}: ${body.slice(0, 200)}`);\n }\n if (!res.body) throw new Error('gemini_stream_no_body');\n\n return yield* parseSseJsonLines(\n res.body,\n (json) => {\n const text = json.candidates?.[0]?.content?.parts?.[0]?.text;\n return typeof text === 'string' && text.length > 0 ? text : null;\n },\n request.signal,\n );\n },\n};\n\nconst ADAPTERS: Record = {\n openai: openAiAdapter,\n claude: claudeAdapter,\n gemini: geminiAdapter,\n deepseek: deepSeekAdapter,\n ollama: ollamaAdapter,\n 'openai-compatible': openAiCompatibleAdapter,\n};\n\nexport function getProviderAdapter(provider: StreamProviderId): ProviderAdapter {\n const key = String(provider || '').toLowerCase();\n if (ADAPTERS[key]) return ADAPTERS[key];\n // Unknown providers fall back to OpenAI-compatible shape\n return openAiCompatibleAdapter;\n}\n\nexport function listSupportedProviders(): StreamProviderId[] {\n return Object.keys(ADAPTERS);\n}\n\n/**\n * Fake adapter for unit tests — yields tokens with optional delay.\n */\nexport function createFakeAdapter(tokens: string[], delayMs = 0): ProviderAdapter {\n return {\n id: 'fake',\n async *stream(_config, request) {\n for (const t of tokens) {\n if (request.signal.aborted) return { finishReason: 'cancelled' };\n if (delayMs > 0) {\n await new Promise((r) => setTimeout(r, delayMs));\n }\n if (request.signal.aborted) return { finishReason: 'cancelled' };\n yield t;\n }\n return { finishReason: 'stop' };\n },\n };\n}\n"}, "6d85aeeddb9200a1cbcdeedf89f0cac34a6a45ed": {"t": 1785763583.1506352, "n": 14077, "h": "672f6aebc8ffaf70", "c": "/**\n * Provider adapters — normalize streaming across OpenAI, Claude, Gemini,\n * DeepSeek, Ollama, and OpenAI-compatible endpoints.\n *\n * Design: each adapter yields async token strings. No secrets are stored here;\n * callers inject credentials via StreamProviderConfig.\n */\n\nimport type { StreamMessage, StreamProviderId } from './streamEvents.js';\n\nexport interface StreamProviderConfig {\n provider: StreamProviderId;\n model: string;\n /** Base URL override (Ollama, OpenRouter, Azure, custom) */\n baseUrl?: string;\n /** API key — never logged */\n apiKey?: string;\n temperature?: number;\n maxTokens?: number;\n headers?: Record;\n /** Optional fetch implementation (for tests / Electron) */\n fetchImpl?: typeof fetch;\n}\n\nexport interface ProviderStreamRequest {\n messages: StreamMessage[];\n model: string;\n temperature?: number;\n maxTokens?: number;\n signal: AbortSignal;\n}\n\nexport interface ProviderAdapter {\n id: StreamProviderId;\n /**\n * Open a streaming completion. Yields content deltas (tokens/chunks).\n * Must respect AbortSignal and throw on hard failures.\n */\n stream(\n config: StreamProviderConfig,\n request: ProviderStreamRequest,\n ): AsyncGenerator;\n\n /**\n * Optional: Transcribe audio using the provider's STT service.\n */\n transcribeAudio?(config: StreamProviderConfig, blob: Blob): Promise;\n}\n\nfunction getFetch(config: StreamProviderConfig): typeof fetch {\n return config.fetchImpl ?? globalThis.fetch.bind(globalThis);\n}\n\nfunction redactError(err: unknown): string {\n const msg = err instanceof Error ? err.message : String(err);\n return msg.replace(/sk-[a-zA-Z0-9_-]+/g, 'sk-***').replace(/Bearer\\s+\\S+/gi, 'Bearer ***');\n}\n\nasync function* parseSseJsonLines(\n body: ReadableStream,\n extract: (json: any) => string | null,\n signal: AbortSignal,\n): AsyncGenerator {\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n let finishReason = 'unknown';\n\n try {\n while (true) {\n if (signal.aborted) {\n finishReason = 'cancelled';\n break;\n }\n const { done, value } = await reader.read();\n if (done) break;\n buffer += decoder.decode(value, { stream: true });\n const lines = buffer.split(/\\r?\\n/);\n buffer = lines.pop() ?? '';\n\n for (const line of lines) {\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(':')) continue;\n if (!trimmed.startsWith('data:')) continue;\n const data = trimmed.slice(5).trim();\n if (data === '[DONE]') {\n finishReason = finishReason === 'unknown' ? 'stop' : finishReason;\n return { finishReason };\n }\n try {\n const json = JSON.parse(data);\n const fr = json.choices?.[0]?.finish_reason ?? json.stop_reason ?? json.finishReason;\n if (fr) finishReason = String(fr);\n const token = extract(json);\n if (token) yield token;\n } catch {\n // skip malformed SSE chunks\n }\n }\n }\n } finally {\n try {\n reader.releaseLock();\n } catch {\n /* ignore */\n }\n }\n return { finishReason };\n}\n\n/** OpenAI + DeepSeek + OpenAI-compatible (shared chat.completions stream shape) */\nexport const openAiCompatibleAdapter: ProviderAdapter = {\n id: 'openai-compatible',\n async *stream(config, request) {\n const fetchFn = getFetch(config);\n const base = (config.baseUrl || 'https://api.openai.com/v1').replace(/\\/$/, '');\n const url = `${base}/chat/completions`;\n const headers: Record = {\n 'Content-Type': 'application/json',\n ...(config.headers ?? {}),\n };\n if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;\n\n let res: Response;\n try {\n res = await fetchFn(url, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: request.model,\n messages: request.messages.map((m) => {\n if (Array.isArray(m.content)) {\n const hasImages = m.content.some((p: any) => p.type === 'image_url');\n if (hasImages && !config.model?.toLowerCase().includes('vision') && !config.model?.toLowerCase().includes('gpt-4o') && !config.model?.toLowerCase().includes('claude-3-5') && !config.model?.toLowerCase().includes('gemini-1.5')) {\n throw new Error('unsupported_multimodal_model: The selected model does not support image attachments. Please use a vision-capable model.');\n }\n }\n return {\n role: m.role,\n content: m.content,\n ...(m.name ? { name: m.name } : {}),\n };\n }),\n stream: true,\n temperature: request.temperature ?? config.temperature ?? 0.2,\n max_tokens: request.maxTokens ?? config.maxTokens,\n }),\n signal: request.signal,\n });\n } catch (err) {\n throw new Error(`openai_stream_connect_failed: ${redactError(err)}`);\n }\n\n if (!res.ok) {\n const body = await res.text().catch(() => '');\n throw new Error(`openai_stream_http_${res.status}: ${body.slice(0, 200)}`);\n }\n if (!res.body) throw new Error('openai_stream_no_body');\n\n return yield* parseSseJsonLines(\n res.body,\n (json) => {\n const delta = json.choices?.[0]?.delta?.content;\n return typeof delta === 'string' && delta.length > 0 ? delta : null;\n },\n request.signal,\n );\n },\n\n async transcribeAudio(config, blob) {\n const fetchFn = getFetch(config);\n const base = (config.baseUrl || 'https://api.openai.com/v1').replace(/\\/$/, '');\n const url = `${base}/audio/transcriptions`;\n const headers: Record = {\n ...(config.headers ?? {}),\n };\n if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;\n\n const formData = new FormData();\n formData.append('file', blob, 'audio.webm');\n formData.append('model', 'whisper-1');\n\n let res: Response;\n try {\n res = await fetchFn(url, {\n method: 'POST',\n headers,\n body: formData,\n });\n } catch (err) {\n throw new Error(`openai_transcribe_connect_failed: ${redactError(err)}`);\n }\n\n if (!res.ok) {\n const body = await res.text().catch(() => '');\n throw new Error(`openai_transcribe_http_${res.status}: ${body.slice(0, 200)}`);\n }\n\n const data = await res.json() as any;\n return data.text || '';\n },\n};\n\nexport const openAiAdapter: ProviderAdapter = {\n id: 'openai',\n stream(config, request) {\n return openAiCompatibleAdapter.stream(\n { ...config, baseUrl: config.baseUrl || 'https://api.openai.com/v1' },\n request,\n );\n },\n transcribeAudio(config, blob) {\n return openAiCompatibleAdapter.transcribeAudio!(\n { ...config, baseUrl: config.baseUrl || 'https://api.openai.com/v1' },\n blob,\n );\n },\n};\n\nexport const deepSeekAdapter: ProviderAdapter = {\n id: 'deepseek',\n stream(config, request) {\n return openAiCompatibleAdapter.stream(\n { ...config, baseUrl: config.baseUrl || 'https://api.deepseek.com/v1' },\n request,\n );\n },\n};\n\nexport const ollamaAdapter: ProviderAdapter = {\n id: 'ollama',\n stream(config, request) {\n return openAiCompatibleAdapter.stream(\n { ...config, baseUrl: config.baseUrl || 'http://127.0.0.1:11434/v1' },\n request,\n );\n },\n};\n\n/** Anthropic Claude Messages API streaming */\nexport const claudeAdapter: ProviderAdapter = {\n id: 'claude',\n async *stream(config, request) {\n const fetchFn = getFetch(config);\n const base = (config.baseUrl || 'https://api.anthropic.com').replace(/\\/$/, '');\n const url = `${base}/v1/messages`;\n\n const system = request.messages.filter((m) => m.role === 'system').map((m) => m.content).join('\\n');\n const messages = request.messages\n .filter((m) => m.role === 'user' || m.role === 'assistant')\n .map((m) => {\n if (Array.isArray(m.content)) {\n return {\n role: m.role,\n content: m.content.map((p: any) => {\n if (p.type === 'image_url' && p.image_url?.url) {\n const match = p.image_url.url.match(/^data:(image\\/[a-zA-Z+]+);base64,(.+)$/);\n if (match) {\n return {\n type: 'image',\n source: {\n type: 'base64',\n media_type: match[1],\n data: match[2],\n },\n };\n }\n }\n return p;\n }),\n };\n }\n return { role: m.role, content: m.content };\n });\n\n const headers: Record = {\n 'Content-Type': 'application/json',\n 'anthropic-version': '2023-06-01',\n ...(config.headers ?? {}),\n };\n if (config.apiKey) headers['x-api-key'] = config.apiKey;\n\n let res: Response;\n try {\n res = await fetchFn(url, {\n method: 'POST',\n headers,\n body: JSON.stringify({\n model: request.model,\n max_tokens: request.maxTokens ?? config.maxTokens ?? 4096,\n temperature: request.temperature ?? config.temperature ?? 0.2,\n system: system || undefined,\n messages,\n stream: true,\n }),\n signal: request.signal,\n });\n } catch (err) {\n throw new Error(`claude_stream_connect_failed: ${redactError(err)}`);\n }\n\n if (!res.ok) {\n const body = await res.text().catch(() => '');\n throw new Error(`claude_stream_http_${res.status}: ${body.slice(0, 200)}`);\n }\n if (!res.body) throw new Error('claude_stream_no_body');\n\n return yield* parseSseJsonLines(\n res.body,\n (json) => {\n if (json.type === 'content_block_delta' && json.delta?.type === 'text_delta') {\n return typeof json.delta.text === 'string' ? json.delta.text : null;\n }\n if (json.type === 'content_block_delta' && typeof json.delta?.text === 'string') {\n return json.delta.text;\n }\n return null;\n },\n request.signal,\n );\n },\n};\n\n/** Google Gemini generateContent stream */\nexport const geminiAdapter: ProviderAdapter = {\n id: 'gemini',\n async *stream(config, request) {\n const fetchFn = getFetch(config);\n const model = request.model;\n const base = (config.baseUrl || 'https://generativelanguage.googleapis.com/v1beta').replace(/\\/$/, '');\n const keyQ = config.apiKey ? `?key=${encodeURIComponent(config.apiKey)}` : '';\n const url = `${base}/models/${encodeURIComponent(model)}:streamGenerateContent${keyQ}&alt=sse`;\n\n const system = request.messages.filter((m) => m.role === 'system').map((m) => m.content).join('\\n');\n const contents = request.messages\n .filter((m) => m.role === 'user' || m.role === 'assistant')\n .map((m) => {\n let parts: any[] = [];\n if (Array.isArray(m.content)) {\n parts = m.content.map((p: any) => {\n if (p.type === 'text') return { text: p.text };\n if (p.type === 'image_url' && p.image_url?.url) {\n const match = p.image_url.url.match(/^data:(image\\/[a-zA-Z+]+);base64,(.+)$/);\n if (match) {\n return {\n inlineData: {\n mimeType: match[1],\n data: match[2],\n },\n };\n }\n }\n return { text: '' };\n });\n } else {\n parts = [{ text: m.content }];\n }\n return {\n role: m.role === 'assistant' ? 'model' : 'user',\n parts,\n };\n });\n\n let res: Response;\n try {\n res = await fetchFn(url, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/json',\n ...(config.headers ?? {}),\n },\n body: JSON.stringify({\n contents,\n systemInstruction: system ? { parts: [{ text: system }] } : undefined,\n generationConfig: {\n temperature: request.temperature ?? config.temperature ?? 0.2,\n maxOutputTokens: request.maxTokens ?? config.maxTokens,\n },\n }),\n signal: request.signal,\n });\n } catch (err) {\n throw new Error(`gemini_stream_connect_failed: ${redactError(err)}`);\n }\n\n if (!res.ok) {\n const body = await res.text().catch(() => '');\n throw new Error(`gemini_stream_http_${res.status}: ${body.slice(0, 200)}`);\n }\n if (!res.body) throw new Error('gemini_stream_no_body');\n\n return yield* parseSseJsonLines(\n res.body,\n (json) => {\n const text = json.candidates?.[0]?.content?.parts?.[0]?.text;\n return typeof text === 'string' && text.length > 0 ? text : null;\n },\n request.signal,\n );\n },\n};\n\nconst ADAPTERS: Record = {\n openai: openAiAdapter,\n claude: claudeAdapter,\n gemini: geminiAdapter,\n deepseek: deepSeekAdapter,\n ollama: ollamaAdapter,\n 'openai-compatible': openAiCompatibleAdapter,\n};\n\nexport function getProviderAdapter(provider: StreamProviderId): ProviderAdapter {\n const key = String(provider || '').toLowerCase();\n if (ADAPTERS[key]) return ADAPTERS[key];\n // Unknown providers fall back to OpenAI-compatible shape\n return openAiCompatibleAdapter;\n}\n\nexport function listSupportedProviders(): StreamProviderId[] {\n return Object.keys(ADAPTERS);\n}\n\n/**\n * Fake adapter for unit tests — yields tokens with optional delay.\n */\nexport function createFakeAdapter(tokens: string[], delayMs = 0): ProviderAdapter {\n return {\n id: 'fake',\n async *stream(_config, request) {\n for (const t of tokens) {\n if (request.signal.aborted) return { finishReason: 'cancelled' };\n if (delayMs > 0) {\n await new Promise((r) => setTimeout(r, delayMs));\n }\n if (request.signal.aborted) return { finishReason: 'cancelled' };\n yield t;\n }\n return { finishReason: 'stop' };\n },\n };\n}\n"}}}}