import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; import { configuredRuntimeHome } from '../config/env.ts'; import { SecretStore } from '../control-plane/secrets.ts'; import { ProviderResponseError, ProviderUnavailable, providerError, retryProvider, type ProviderFailureCode } from '../harness/retry.ts'; import { budget } from '../harness/budget.ts'; import { bounded, type Trace } from '../harness/trace.ts'; import type { ModelRequestOptions, ProviderAdapter, ResolvedModelSelection } from './types.ts'; import { ModelAdapterError } from './types.ts'; import { applyOutputBudget, resolveOutputBudget } from './output-budget.ts'; const PROVIDER_SPECIFIC_KEYS = ['thinking', 'reasoning_effort', 'reasoning', 'enable_thinking', 'thinking_budget']; /** Options that are safe and have the same meaning across the five transports. */ export const COMMON_REQUEST_OPTIONS = [ 'temperature', 'top_p', 'max_tokens', 'max_output_tokens', 'max_completion_tokens', 'presence_penalty', 'frequency_penalty', 'seed', ] as const; function assertOptionKeys(adapter: ProviderAdapter, options: ModelRequestOptions) { const allowed = new Set([...COMMON_REQUEST_OPTIONS, ...adapter.optionKeys]); for (const key of Object.keys(options)) { if (!allowed.has(key)) throw new ModelAdapterError('unsupported_request_option', `Request option is not supported by ${adapter.type}: ${key}`, key); } } function sanitizedBody(body: Record) { const copy = { ...body }; for (const key of PROVIDER_SPECIFIC_KEYS) delete copy[key]; return copy; } export function applyCommonRequestOptions(body: Record, options: ModelRequestOptions) { for (const key of COMMON_REQUEST_OPTIONS) { if (options[key] !== undefined) body[key] = options[key]; } return body; } export function optionString(options: ModelRequestOptions, key: string) { const value = options[key]; if (value === undefined) return undefined; if (typeof value !== 'string' || value.trim() === '') throw new ModelAdapterError('invalid_configuration', `${key} must be a non-empty string.`, key); return value.trim(); } export function optionBoolean(options: ModelRequestOptions, key: string) { const value = options[key]; if (value === undefined) return undefined; if (typeof value !== 'boolean') throw new ModelAdapterError('invalid_configuration', `${key} must be a boolean.`, key); return value; } export function optionNumber(options: ModelRequestOptions, key: string) { const value = options[key]; if (value === undefined) return undefined; if (typeof value !== 'number' || !Number.isFinite(value)) throw new ModelAdapterError('invalid_configuration', `${key} must be a finite number.`, key); return value; } function hasSseDoneMarker(raw: string) { return /^data:\s*\[DONE\]\s*$/m.test(raw); } function numericStatus(value: unknown) { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 400 && value <= 599 ? value : undefined; } function failureCodeForStatus(status: number | undefined): ProviderFailureCode { if (status === 401 || status === 403) return 'provider_auth_failed'; if (status === 408 || status === 429 || (status !== undefined && status >= 500)) return 'provider_upstream_unavailable'; if (status !== undefined && status >= 400) return 'provider_request_rejected'; return 'provider_protocol_error'; } function retryableStatus(status: number | undefined) { return status === 408 || status === 429 || (status !== undefined && status >= 500); } function retryAfterDelay(value: string | null) { if (!value) return undefined; const seconds = Number(value); if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); const date = Date.parse(value); return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined; } function providerResponseError(raw: string, responseStatus: number, responseRetryAfterMs?: number) { let payload: unknown; try { payload = JSON.parse(raw); } catch { payload = undefined; } if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return undefined; const envelope = (payload as { error?: unknown }).error; if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) return undefined; const error = envelope as { code?: unknown; status?: unknown; message?: unknown; type?: unknown }; const upstreamStatus = numericStatus(error.status) ?? numericStatus(error.code) ?? numericStatus(responseStatus); const failureCode = failureCodeForStatus(upstreamStatus); const upstreamMessage = typeof error.message === 'string' ? error.message.slice(0, 1000) : undefined; const upstreamCode = typeof error.code === 'string' || typeof error.code === 'number' ? error.code : undefined; return new ProviderResponseError(failureCode, upstreamMessage ?? 'Provider returned an error envelope', { status: upstreamStatus ?? (responseStatus >= 400 ? responseStatus : undefined), upstreamCode, upstreamMessage, retryable: retryableStatus(upstreamStatus), retryAfterMs: responseRetryAfterMs, }); } function providerCredentialError() { // Deliberately omit the original error: provider errors and traces must not // become an accidental secret/configuration oracle. return new ProviderUnavailable('Provider credential is unavailable', { failureCode: 'provider_auth_failed', retryable: false }); } function assertUrl(value: string) { try { const url = new URL(value); if (!['http:', 'https:'].includes(url.protocol)) throw new Error('unsupported protocol'); } catch { throw new ModelAdapterError('invalid_configuration', 'Provider base URL must be an absolute HTTP(S) URL.', 'base_url'); } } /** * Shared OpenAI-compatible HTTP transport. It buffers one response before the * SDK sees it, so a broken stream retries the same HTTP exchange rather than * replaying the surrounding tool loop. */ export function createOpenAICompatibleModel(trace: Trace, task: string, selection: ResolvedModelSelection, adapter: ProviderAdapter) { const providerOptions = selection.provider.requestOptions ?? {}; const outputBudget = resolveOutputBudget(providerOptions, selection.model.maxOutputTokens); assertOptionKeys(adapter, providerOptions); assertUrl(selection.provider.baseUrl); if (!selection.model.identifier.trim()) throw new ModelAdapterError('invalid_configuration', 'Provider model identifier cannot be empty.', 'model_identifier'); if (selection.provider.enabled === false || selection.model.enabled === false) { throw new ProviderUnavailable('Configured provider or model is unavailable', { failureCode: 'provider_configuration_error', retryable: false }); } const runtimeHome = selection.runtimeHome ?? configuredRuntimeHome(); const secrets = new SecretStore(runtimeHome); let requestIndex = 0; let unavailable = false; const transport: typeof fetch = async (input, init) => { const request = ++requestIndex; const body = applyOutputBudget(adapter.transformRequestBody(sanitizedBody(JSON.parse(String(init?.body ?? '{}')) as Record), providerOptions), outputBudget); const bodyText = JSON.stringify(body); const wire = bounded(body); trace.emit('model_request', { task, provider: selection.provider.type, provider_id: selection.provider.id, model: selection.model.identifier, request, body_chars: wire.chars, body_sha256: wire.sha256, output_budget: outputBudget.effective, output_budget_source: outputBudget.source, output_budget_wire_key: outputBudget.wireKey, body_truncated: wire.truncated, body_excerpt: wire.excerpt }); let apiKey: string; try { if (!selection.provider.credentialRef) throw new Error('missing credential reference'); // Read at model-call time, not while resolving or creating the model. A // stable slot can therefore be rotated without changing a snapshot. apiKey = await secrets.read(selection.provider.credentialRef, selection.env ?? process.env); trace.secret(apiKey); } catch { unavailable = true; throw providerCredentialError(); } let attempt = 0; try { return await retryProvider(async () => { const started = Date.now(); attempt++; trace.emit('provider_attempt', { task, provider: selection.provider.type, provider_id: selection.provider.id, model: selection.model.identifier, request, attempt }); try { const headers = new Headers(init?.headers); headers.set('Authorization', `Bearer ${apiKey}`); for (const [name, value] of Object.entries(adapter.requestHeaders?.(providerOptions) ?? {})) headers.set(name, value); const response = await fetch(input, { ...init, headers, body: bodyText, signal: AbortSignal.any([AbortSignal.timeout(budget.providerMs), ...(init?.signal ? [init.signal] : [])]) }); const raw = await response.text(); const responseWire = bounded(raw); trace.emit('model_response', { task, provider: selection.provider.type, provider_id: selection.provider.id, model: selection.model.identifier, request, attempt, status: response.status, duration_ms: Date.now() - started, raw_chars: responseWire.chars, raw_sha256: responseWire.sha256, raw_truncated: responseWire.truncated, raw_excerpt: responseWire.excerpt }); const responseRetryAfterMs = retryAfterDelay(response.headers.get('retry-after')); const envelopeError = providerResponseError(raw, response.status, responseRetryAfterMs); if (envelopeError) throw envelopeError; if (!response.ok) throw new ProviderResponseError(failureCodeForStatus(response.status), 'Provider HTTP error', { status: response.status, retryable: retryableStatus(response.status), retryAfterMs: responseRetryAfterMs, }); if (body.stream && !hasSseDoneMarker(raw)) throw Object.assign(new Error('Incomplete provider stream'), { code: 'ECONNRESET', retryAfter: response.headers.get('retry-after') ?? undefined, }); return new Response(raw, { status: response.status, statusText: response.statusText, headers: response.headers }); } catch (error) { trace.emit('provider_error', { task, provider: selection.provider.type, provider_id: selection.provider.id, model: selection.model.identifier, request, attempt, duration_ms: Date.now() - started, ...providerError(error) }); throw error; } }, { signal: init?.signal ?? undefined, onRetry: event => trace.emit('provider_retry', { task, provider: selection.provider.type, provider_id: selection.provider.id, model: selection.model.identifier, request, ...event }) }); } catch (error) { if (error instanceof ProviderUnavailable) unavailable = true; throw error; } }; const headers = adapter.requestHeaders?.(providerOptions); const provider = createOpenAICompatible({ name: adapter.apiName, baseURL: selection.provider.baseUrl, headers, fetch: transport, includeUsage: true, supportsStructuredOutputs: true, }); return { model: provider.chatModel(selection.model.identifier), isUnavailable: () => unavailable, adapter: adapter.type }; }