import { createOpenAI } from '@ai-sdk/openai'; import { createAnthropic } from '@ai-sdk/anthropic'; import { createGoogle } from '@ai-sdk/google'; import type { ProviderConfig, ProviderStyle } from '../types'; import { resolveOpenAIApi, resolveProviderStyle } from '../types'; import { normalizeAnthropicSdkBaseURL } from '../anthropicCompatBaseUrl'; import { normalizeOllamaSdkBaseURL } from '../ollamaCompatBaseUrl'; export { normalizeOllamaSdkBaseURL }; import { applyOpenAIChatContinuationToBody, extractProviderContinuationFromRawChunk, mergeProviderContinuation, rawOpenAIChatChunkHasToolCalls, repairOpenAIChatToolResultPairsInBody, type OpenAIChatAssistantFields, } from '../providerContinuation'; export interface ProviderRequestContext { getOpenAIChatAssistantFields?: () => Array; streamIdleTimeoutMs?: number; } /** * Bridge API subset used for SDK fetch adapter. */ interface BridgeAPI { aiFetch( url: string, method: string, headers: Record, body?: string, providerId?: string, ): Promise<{ ok: boolean; status: number; data: string; error?: string; }>; aiChatStream( requestId: string, url: string, headers: Record, body: string, providerId?: string, idleTimeoutMs?: number, ): Promise<{ ok: boolean; statusCode?: number; statusText?: string; error?: string; aborted?: boolean }>; onAiStreamData(requestId: string, cb: (data: string) => void): () => void; onAiStreamEnd(requestId: string, cb: () => void): () => void; onAiStreamError(requestId: string, cb: (error: string) => void): () => void; aiChatCancel(requestId: string): Promise; } function getBridge(): BridgeAPI | null { const w = window as unknown as { netcatty?: BridgeAPI }; return w.netcatty ?? null; } /** * Detect whether a request is likely a streaming request. * AI SDK streaming requests use POST with `"stream": true` in the body. */ function isStreamingRequest(init?: RequestInit): boolean { if (!init?.body) return false; try { const bodyStr = typeof init.body === 'string' ? init.body : null; if (!bodyStr) return false; const parsed = JSON.parse(bodyStr); return parsed.stream === true; } catch { return false; } } function mergeOpenAIChatAssistantFields( current: OpenAIChatAssistantFields | undefined, incoming: OpenAIChatAssistantFields | undefined, ): OpenAIChatAssistantFields | undefined { return mergeProviderContinuation( { openAIChatAssistantFields: current }, { openAIChatAssistantFields: incoming }, )?.openAIChatAssistantFields; } function createOpenAIChatStreamFieldCapture( requestContext?: ProviderRequestContext, ): (data: string) => void { const assistantFields = requestContext?.getOpenAIChatAssistantFields?.(); if (!assistantFields) return () => undefined; let streamFieldIndex: number | undefined; let pendingFields: OpenAIChatAssistantFields | undefined; const ensureStreamFieldSlot = (): number => { if (streamFieldIndex !== undefined) return streamFieldIndex; streamFieldIndex = assistantFields.length; assistantFields.push(undefined); return streamFieldIndex; }; const flushPendingFields = (fieldIndex: number) => { if (!pendingFields) return; assistantFields[fieldIndex] = mergeOpenAIChatAssistantFields( assistantFields[fieldIndex], pendingFields, ); pendingFields = undefined; }; return (data: string) => { const continuation = extractProviderContinuationFromRawChunk(data); const fields = continuation?.openAIChatAssistantFields; if (fields) { pendingFields = mergeOpenAIChatAssistantFields(pendingFields, fields); if (streamFieldIndex !== undefined) { flushPendingFields(streamFieldIndex); } } if (rawOpenAIChatChunkHasToolCalls(data)) { flushPendingFields(ensureStreamFieldSlot()); } }; } function createOpenAIChatToolCallNormalizer(requestId: string): (data: string) => string { const toolCallIdsByChoiceAndIndex = new Map(); const toolCallNamesByChoiceAndIndex = new Map(); const pendingToolCallsByChoiceAndIndex = new Map>(); // Full concatenation of every arguments fragment seen for a tool call key, // so a tool call the SDK has not seen yet can be forwarded self-describing. const argumentsSeenByKey = new Map(); // Keys forwarded inside choices array position 0 — the only element the // AI SDK reads. Tool calls living at position > 0 are invisible to it. const sdkVisibleKeys = new Set(); const requestIdToken = requestId.replace(/[^a-zA-Z0-9_-]/g, '_'); return (data: string): string => { if (!data || data.trim() === '[DONE]') return data; let parsed: unknown; try { parsed = JSON.parse(data); } catch { return data; } if (!parsed || typeof parsed !== 'object' || !Array.isArray((parsed as Record).choices)) { return data; } let changed = false; const normalizedChoices = ((parsed as Record).choices as unknown[]).map((choice, choicePosition) => { if (!choice || typeof choice !== 'object') return choice; const choiceRecord = choice as Record; const delta = choiceRecord.delta; if (!delta || typeof delta !== 'object') return choice; const deltaRecord = delta as Record; if (!Array.isArray(deltaRecord.tool_calls)) return choice; const choiceIndex = typeof choiceRecord.index === 'number' ? choiceRecord.index : choicePosition; let deltaChanged = false; const normalizedToolCalls: unknown[] = []; for (const [toolCallPosition, toolCall] of deltaRecord.tool_calls.entries()) { if (!toolCall || typeof toolCall !== 'object') { normalizedToolCalls.push(toolCall); continue; } const toolCallRecord = toolCall as Record; const toolCallIndex = typeof toolCallRecord.index === 'number' ? toolCallRecord.index : toolCallPosition; const key = `${choiceIndex}:${toolCallIndex}`; const recordFn = toolCallRecord.function; const recordArguments = recordFn && typeof recordFn === 'object' ? (recordFn as Record).arguments : undefined; if (typeof recordArguments === 'string') { argumentsSeenByKey.set(key, (argumentsSeenByKey.get(key) ?? '') + recordArguments); } const existingId = toolCallIdsByChoiceAndIndex.get(key); const pendingToolCall = pendingToolCallsByChoiceAndIndex.get(key); const candidateToolCall = pendingToolCall ? mergeOpenAIChatToolCallDeltas(pendingToolCall, toolCallRecord) : toolCallRecord; if (existingId) { const rememberedName = toolCallNamesByChoiceAndIndex.get(key); const needsFullArguments = choicePosition === 0 && !sdkVisibleKeys.has(key); let normalizedToolCall = normalizeOpenAIChatToolCall( toolCallRecord, existingId, rememberedName, ); if (needsFullArguments) { normalizedToolCall = injectFullOpenAIChatToolCallArguments( normalizedToolCall, argumentsSeenByKey.get(key), ); } if (choicePosition === 0) { sdkVisibleKeys.add(key); } if ( normalizedToolCall.id === toolCallRecord.id && normalizedToolCall.type === toolCallRecord.type && normalizedToolCall.function === toolCallRecord.function && (!rememberedName || hasFunctionName(toolCallRecord)) ) { normalizedToolCalls.push(toolCall); } else { changed = true; deltaChanged = true; normalizedToolCalls.push(normalizedToolCall); } continue; } if (!hasFunctionName(candidateToolCall)) { pendingToolCallsByChoiceAndIndex.set(key, candidateToolCall); changed = true; deltaChanged = true; continue; } const toolCallId = typeof candidateToolCall.id === 'string' && candidateToolCall.id ? candidateToolCall.id : `call_netcatty_${requestIdToken}_${choiceIndex}_${toolCallIndex}`; toolCallIdsByChoiceAndIndex.set(key, toolCallId); const candidateFunction = candidateToolCall.function; if (candidateFunction && typeof candidateFunction === 'object') { toolCallNamesByChoiceAndIndex.set( key, (candidateFunction as Record).name as string, ); } pendingToolCallsByChoiceAndIndex.delete(key); let normalizedToolCall = normalizeOpenAIChatToolCall(candidateToolCall, toolCallId); if (choicePosition === 0 && !sdkVisibleKeys.has(key)) { normalizedToolCall = injectFullOpenAIChatToolCallArguments( normalizedToolCall, argumentsSeenByKey.get(key), ); } if (choicePosition === 0) { sdkVisibleKeys.add(key); } if ( candidateToolCall === toolCallRecord && toolCallId === toolCallRecord.id && normalizedToolCall.type === toolCallRecord.type && normalizedToolCall.function === toolCallRecord.function ) { normalizedToolCalls.push(toolCall); continue; } changed = true; deltaChanged = true; normalizedToolCalls.push(normalizedToolCall); } if (!deltaChanged) return choice; return { ...choiceRecord, delta: { ...deltaRecord, tool_calls: normalizedToolCalls, }, }; }); if (!changed) return data; return JSON.stringify({ ...(parsed as Record), choices: normalizedChoices, }); }; } function mergeOpenAIChatToolCallDeltas( current: Record, incoming: Record, ): Record { const currentFn = current.function; const incomingFn = incoming.function; const currentFunction = currentFn && typeof currentFn === 'object' ? currentFn as Record : undefined; const incomingFunction = incomingFn && typeof incomingFn === 'object' ? incomingFn as Record : undefined; const mergedFunction: Record = { ...(currentFunction ?? {}), ...(incomingFunction ?? {}), }; if ( typeof currentFunction?.name === 'string' && currentFunction.name && incomingFunction?.name === '' ) { mergedFunction.name = currentFunction.name; } const currentArgs = currentFunction?.arguments; const incomingArgs = incomingFunction?.arguments; if (typeof currentArgs === 'string' && typeof incomingArgs === 'string') { mergedFunction.arguments = currentArgs + incomingArgs; } return { ...current, ...incoming, function: mergedFunction, }; } function normalizeOpenAIChatToolCall( toolCall: Record, toolCallId: string, rememberedName?: string, ): Record { const normalized = { ...toolCall, id: toolCallId }; if ( normalized.type === '' || normalized.type == null || (typeof normalized.type === 'string' && normalized.type !== 'function') ) { normalized.type = 'function'; } const fn = normalized.function; if (fn && typeof fn === 'object') { const fnRecord = fn as Record; if (!hasFunctionName({ function: fnRecord })) { const normalizedFunction = { ...fnRecord }; if (rememberedName) { normalizedFunction.name = rememberedName; } else if (fnRecord.name === '' || fnRecord.name === null) { delete normalizedFunction.name; } normalized.function = normalizedFunction; } } else if (rememberedName) { normalized.function = { name: rememberedName }; } return normalized; } function injectFullOpenAIChatToolCallArguments( toolCall: Record, fullArguments: string | undefined, ): Record { if (fullArguments === undefined) return toolCall; const fn = toolCall.function; const fnRecord = fn && typeof fn === 'object' ? fn as Record : undefined; if (fnRecord?.arguments === fullArguments) return toolCall; return { ...toolCall, function: { ...(fnRecord ?? {}), arguments: fullArguments }, }; } function hasFunctionName(toolCall: Record): boolean { const fn = toolCall.function; return Boolean( fn && typeof fn === 'object' && typeof (fn as Record).name === 'string' && (fn as Record).name, ); } /** * Extract headers as a plain Record from various header formats. */ function extractHeaders(headers?: HeadersInit): Record { const result: Record = {}; if (!headers) return result; if (headers instanceof Headers) { headers.forEach((value, key) => { result[key] = value; }); } else if (Array.isArray(headers)) { for (const [key, value] of headers) { result[key] = value; } } else { Object.assign(result, headers); } return result; } /** * Create a fetch function compatible with the Vercel AI SDK that routes * requests through the Electron IPC bridge to avoid CORS. * * - Non-streaming requests: uses `window.netcatty.aiFetch()` and returns a `Response`. * - Streaming requests: uses `window.netcatty.aiChatStream()` and returns a * `Response` with a `ReadableStream` body. * - Falls back to `globalThis.fetch` if the bridge is unavailable. */ /** Placeholder API key used by the renderer; main process replaces it with the real key. */ export const API_KEY_PLACEHOLDER = '__IPC_SECURED__'; function toSafeStatusText(message: string, fallback: string): string { const normalized = message .replace(/[\r\n\t]+/g, ' ') .replace(/\s+/g, ' ') .trim(); if (!normalized) return fallback; const byteStringSafe = Array.from(normalized, (char) => { const code = char.charCodeAt(0); if (code < 0x20 || code === 0x7f || code > 0xff) return '?'; return char; }).join(''); return byteStringSafe.slice(0, 120) || fallback; } export function createBridgeFetchForSDK( providerId?: string, requestContext?: ProviderRequestContext, ): typeof globalThis.fetch { return async ( input: string | URL | Request, init?: RequestInit, ): Promise => { const bridge = getBridge(); if (!bridge) { return globalThis.fetch(input, init); } // Resolve URL string let url: string; let resolvedInit = init; if (input instanceof Request) { url = input.url; // Merge Request properties with init overrides if (!resolvedInit) { resolvedInit = { method: input.method, headers: extractHeaders(input.headers), body: input.body ? await new Response(input.body).text() : undefined, }; } } else { url = input instanceof URL ? input.toString() : input; } const method = resolvedInit?.method || 'GET'; const headers = extractHeaders(resolvedInit?.headers); const body = resolvedInit?.body != null ? String(resolvedInit.body) : undefined; const requestBody = body != null ? repairOpenAIChatToolResultPairsInBody(applyOpenAIChatContinuationToBody( body, requestContext?.getOpenAIChatAssistantFields?.() ?? [], )) : undefined; // Streaming path if (isStreamingRequest(resolvedInit)) { const requestId = `sdk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; const captureOpenAIChatFields = createOpenAIChatStreamFieldCapture(requestContext); const normalizeOpenAIChatToolCalls = createOpenAIChatToolCallNormalizer(requestId); // Set up IPC event listeners BEFORE starting the stream to avoid // missing early events. const encoder = new TextEncoder(); let streamController: ReadableStreamDefaultController | null = null; const pendingChunks: Uint8Array[] = []; let pendingClose = false; let pendingError: Error | null = null; let cleanedUp = false; const enqueueChunk = (chunk: Uint8Array) => { if (streamController) { streamController.enqueue(chunk); return; } pendingChunks.push(chunk); }; const closeStream = () => { if (streamController) { try { streamController.close(); } catch { /* already closed */ } return; } pendingClose = true; }; const errorStream = (error: Error) => { if (streamController) { try { streamController.error(error); } catch { /* already errored */ } return; } pendingError = error; }; const flushPendingStreamEvents = (controller: ReadableStreamDefaultController) => { for (const chunk of pendingChunks.splice(0)) { controller.enqueue(chunk); } if (pendingError) { controller.error(pendingError); return; } if (pendingClose) { controller.close(); } }; const unsubData = bridge.onAiStreamData(requestId, (data: string) => { const normalizedData = normalizeOpenAIChatToolCalls(data); captureOpenAIChatFields(normalizedData); // Re-wrap as SSE so the SDK can parse it enqueueChunk(encoder.encode(`data: ${normalizedData}\n\n`)); }); const unsubEnd = bridge.onAiStreamEnd(requestId, () => { closeStream(); cleanup(); }); const unsubError = bridge.onAiStreamError( requestId, (error: string) => { errorStream(new Error(error)); cleanup(); }, ); const cleanup = () => { if (cleanedUp) return; cleanedUp = true; unsubData(); unsubEnd(); unsubError(); }; // Handle abort if (resolvedInit?.signal) { resolvedInit.signal.addEventListener( 'abort', () => { bridge.aiChatCancel(requestId).catch(() => {}); errorStream(new DOMException('Aborted', 'AbortError')); cleanup(); }, { once: true }, ); } // Start the stream — resolves once HTTP response headers arrive, // returning the real status code. const result = await bridge.aiChatStream( requestId, url, headers, requestBody || '', providerId, requestContext?.streamIdleTimeoutMs, ); if (!result.ok) { cleanup(); // Cancel during proxy lookup / request start must stay an AbortError, // not a synthetic 502 that the AI SDK treats as a provider failure. if (result.aborted || resolvedInit?.signal?.aborted) { throw new DOMException('Aborted', 'AbortError'); } const errorMessage = result.error || 'Stream request failed'; const jsonBody = JSON.stringify({ error: { message: errorMessage } }); return new Response(jsonBody, { status: 502, statusText: toSafeStatusText(errorMessage, 'Bad Gateway'), headers: { 'content-type': 'application/json' }, }); } // If the server returned a non-2xx status, return the error details // as a JSON body in OpenAI-compatible format so the AI SDK's // failedResponseHandler can extract the message properly. // Also set a safe ASCII statusText as fallback for non-OpenAI SDK providers. const statusCode = result.statusCode ?? 200; if (statusCode < 200 || statusCode >= 300) { cleanup(); const errorDetail = result.statusText || `HTTP ${statusCode}`; const jsonBody = JSON.stringify({ error: { message: errorDetail } }); return new Response(jsonBody, { status: statusCode, statusText: toSafeStatusText(errorDetail, `Error ${statusCode}`), headers: { 'content-type': 'application/json' }, }); } const stream = new ReadableStream({ start(controller) { streamController = controller; flushPendingStreamEvents(controller); }, }); return new Response(stream, { status: statusCode, statusText: result.statusText ?? 'OK', headers: { 'content-type': 'text/event-stream' }, }); } // Non-streaming path const result = await bridge.aiFetch(url, method, headers, requestBody, providerId); return new Response(result.data, { status: result.status, statusText: result.ok ? 'OK' : 'Error', headers: { 'content-type': 'application/json' }, }); }; } /** * Create a Vercel AI SDK model instance from a ProviderConfig. * * API keys are NOT sent to the SDK in plaintext. Instead, a placeholder * token is used so the SDK builds proper auth headers, and the main * process replaces the placeholder with the real decrypted key before * making the HTTP request. */ /** * Apply per-vendor URL and apiKey quirks on top of the style-based * wire-protocol routing. Exported so it can be unit-tested without spinning * up the Vercel AI SDK clients. * * The URL fallback fires regardless of style — the user picked this * providerId for a reason, even if they overrode the wire format. The * ollama `'ollama'` throwaway apiKey is only for unauthenticated local * OpenAI-compat servers: Anthropic/Google need a real key, and Ollama * Cloud must keep the IPC placeholder so the main process can inject * the decrypted cloud key. */ export function resolveProviderEndpoint( config: ProviderConfig, style: ProviderStyle, safeApiKey: string | undefined, ): { baseURL: string | undefined; apiKey: string | undefined } { let baseURL = config.baseURL; let apiKey = safeApiKey; if (config.providerId === 'ollama') { baseURL = normalizeOllamaSdkBaseURL(baseURL || 'http://localhost:11434/v1'); if (style === 'openai' && !apiKey) { apiKey = 'ollama'; } } else if (config.providerId === 'openrouter') { baseURL = baseURL || 'https://openrouter.ai/api/v1'; } // @ai-sdk/anthropic expects baseURL to include /v1 (then appends /messages). // Bare Claude Code style hosts get /v1 so chat matches probe/discovery. // Custom path prefixes (e.g. …/anthropic) are left alone — they already // complete the SDK base and must not become …/anthropic/v1. if (style === 'anthropic' && baseURL) { baseURL = normalizeAnthropicSdkBaseURL(baseURL); } return { baseURL, apiKey }; } export function createModelFromConfig( config: ProviderConfig, requestContext?: ProviderRequestContext, ) { // Use placeholder API key — the main process will inject the real key const safeApiKey = config.apiKey ? API_KEY_PLACEHOLDER : undefined; const customFetch = createBridgeFetchForSDK(config.id, requestContext); const modelId = config.defaultModel || ''; const style = resolveProviderStyle(config); const { baseURL, apiKey } = resolveProviderEndpoint(config, style, safeApiKey); switch (style) { case 'openai': { const openai = createOpenAI({ apiKey, baseURL, fetch: customFetch, }); // Chat Completions stays the default so OpenAI-compatible proxies keep // working. Responses is opt-in for relays that cache better on /v1/responses. return resolveOpenAIApi(config) === 'responses' ? openai.responses(modelId) : openai.chat(modelId); } case 'anthropic': return createAnthropic({ apiKey, baseURL, fetch: customFetch, })(modelId); case 'google': return createGoogle({ apiKey, baseURL, fetch: customFetch, })(modelId); default: { const _exhaustive: never = style; throw new Error(`Unsupported provider style: ${_exhaustive}`); } } }