@@ -52,6 +52,37 @@ export function createOpenAiCompatibleAi(opts: { baseUrl: string; apiKey?: strin
5252 } ;
5353}
5454
55+ /** Native Anthropic Messages API (BYOK — bills your Anthropic API key; distinct from the claude-code
56+ * subscription path). The system message becomes the top-level `system` param; the rest map to user/assistant. */
57+ export function createAnthropicAi ( opts : { apiKey : string ; model ?: string | undefined ; baseUrl ?: string | undefined } ) : SelfHostAi {
58+ const base = ( opts . baseUrl ?? "https://api.anthropic.com" ) . replace ( / \/ + $ / , "" ) ;
59+ return {
60+ async run ( model , options ) {
61+ const msgs = toMessages ( options ) ;
62+ const system =
63+ msgs
64+ . filter ( ( m ) => m . role === "system" )
65+ . map ( ( m ) => m . content )
66+ . join ( "\n\n" ) || undefined ;
67+ const messages = msgs . filter ( ( m ) => m . role !== "system" ) . map ( ( m ) => ( { role : m . role === "assistant" ? "assistant" : "user" , content : m . content } ) ) ;
68+ const res = await fetch ( `${ base } /v1/messages` , {
69+ method : "POST" ,
70+ headers : { "content-type" : "application/json" , "x-api-key" : opts . apiKey , "anthropic-version" : "2023-06-01" } ,
71+ body : JSON . stringify ( { model : resolveModel ( opts . model , model , "claude-sonnet-4-6" ) , max_tokens : options . max_tokens ?? 1024 , ...( system ? { system } : { } ) , messages } ) ,
72+ signal : AbortSignal . timeout ( 120_000 ) ,
73+ } ) ;
74+ if ( ! res . ok ) throw new Error ( `anthropic_http_${ res . status } ` ) ;
75+ const data = ( await res . json ( ) ) as { content ?: Array < { type : string ; text ?: string } > } ;
76+ return {
77+ response : ( data . content ?? [ ] )
78+ . filter ( ( c ) => c . type === "text" )
79+ . map ( ( c ) => c . text ?? "" )
80+ . join ( "" ) ,
81+ } ;
82+ } ,
83+ } ;
84+ }
85+
5586// ── Subscription CLI providers (#979) — locally-authenticated `claude` / `codex` as a subprocess ──────────
5687// SECURITY: the child env DELETES the billable API keys so a misconfigured CLI cannot silently bill the
5788// metered API instead of using the subscription OAuth token. The CLI runs read-only / no extra tools. Any
@@ -169,14 +200,63 @@ export function createCodexAi(parentEnv: Record<string, string | undefined>, spa
169200 } ;
170201}
171202
172- /** Pick the self-host AI provider from env (AI_PROVIDER). Returns undefined when unconfigured. */
173- export function createSelfHostAi ( env : Record < string , string | undefined > ) : SelfHostAi | undefined {
174- const provider = ( env . AI_PROVIDER ?? "" ) . trim ( ) . toLowerCase ( ) ;
175- if ( ! provider ) return undefined ;
176- if ( provider === "ollama" || provider === "openai-compatible" || provider === "openai" ) {
177- return createOpenAiCompatibleAi ( { baseUrl : env . AI_BASE_URL ?? "http://localhost:11434/v1" , apiKey : env . AI_API_KEY , model : configuredModel ( env ) } ) ;
203+ /** Try each provider in order until one returns; if all throw, rethrow the last error so the caller degrades
204+ * (AI summary → "unavailable"; the review still runs deterministically). The fallback chain is what makes a
205+ * BYOK setup robust — e.g. AI_PROVIDER="anthropic,ollama" uses the API first and a local model if it's down. */
206+ export function createChainAi ( providers : Array < { name : string ; ai : SelfHostAi } > ) : SelfHostAi {
207+ return {
208+ async run ( model , options ) {
209+ let lastError : unknown = new Error ( "no_ai_providers" ) ;
210+ for ( const p of providers ) {
211+ try {
212+ return await p . ai . run ( model , options ) ;
213+ } catch ( error ) {
214+ lastError = error ;
215+ console . error ( JSON . stringify ( { level : "warn" , event : "selfhost_ai_provider_failed" , provider : p . name , error : error instanceof Error ? error . message : "unknown" } ) ) ;
216+ }
217+ }
218+ throw lastError instanceof Error ? lastError : new Error ( "all_ai_providers_failed" ) ;
219+ } ,
220+ } ;
221+ }
222+
223+ /** Build one provider adapter by name (BYO credentials read from provider-specific env, then the generic
224+ * AI_API_KEY). Returns undefined when its required credential is missing. */
225+ export function buildProvider ( name : string , env : Record < string , string | undefined > ) : SelfHostAi | undefined {
226+ switch ( name ) {
227+ case "ollama" :
228+ case "openai-compatible" :
229+ case "openai" :
230+ return createOpenAiCompatibleAi ( {
231+ baseUrl : env . AI_BASE_URL ?? ( name === "openai" ? "https://api.openai.com/v1" : "http://localhost:11434/v1" ) ,
232+ apiKey : env . AI_API_KEY ?? env . OPENAI_API_KEY ,
233+ model : configuredModel ( env ) ,
234+ } ) ;
235+ case "anthropic" : {
236+ const apiKey = env . ANTHROPIC_API_KEY ?? env . AI_API_KEY ;
237+ return apiKey ? createAnthropicAi ( { apiKey, model : configuredModel ( env ) , baseUrl : env . AI_BASE_URL } ) : undefined ;
238+ }
239+ case "claude-code" :
240+ return createClaudeCodeAi ( env ) ;
241+ case "codex" :
242+ return createCodexAi ( env ) ;
243+ default :
244+ return undefined ;
178245 }
179- if ( provider === "claude-code" ) return createClaudeCodeAi ( env ) ;
180- if ( provider === "codex" ) return createCodexAi ( env ) ;
181- return undefined ;
246+ }
247+
248+ /** Select the self-host AI provider(s) from AI_PROVIDER. A comma-separated list builds a fallback chain
249+ * (first to succeed wins). Returns undefined when unconfigured or no provider has its credential. */
250+ export function createSelfHostAi ( env : Record < string , string | undefined > ) : SelfHostAi | undefined {
251+ const raw = ( env . AI_PROVIDER ?? "" ) . trim ( ) . toLowerCase ( ) ;
252+ if ( ! raw ) return undefined ;
253+ const providers = raw
254+ . split ( "," )
255+ . map ( ( s ) => s . trim ( ) )
256+ . filter ( Boolean )
257+ . map ( ( name ) => ( { name, ai : buildProvider ( name , env ) } ) )
258+ . filter ( ( p ) : p is { name : string ; ai : SelfHostAi } => Boolean ( p . ai ) ) ;
259+ if ( providers . length === 0 ) return undefined ;
260+ if ( providers . length === 1 ) return providers [ 0 ] ?. ai ;
261+ return createChainAi ( providers ) ;
182262}
0 commit comments