diff --git a/.env.example b/.env.example index b5a4c0c..94f031c 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,6 @@ ANTHROPIC_API_KEY= # OPENAI_MODEL=gpt-4o # Gemini (LLM_PROVIDER=gemini) -# GOOGLE_BASE_URL must use HTTPS if set (proxy hosts are allowed). # GOOGLE_BASE_URL=https://generativelanguage.googleapis.com # GOOGLE_API_KEY= # GOOGLE_MODEL=gemini-2.5-flash diff --git a/lib/llm/anthropic.js b/lib/llm/anthropic.js index 1079fba..69f49b7 100644 --- a/lib/llm/anthropic.js +++ b/lib/llm/anthropic.js @@ -7,7 +7,7 @@ */ import Anthropic from '@anthropic-ai/sdk'; -import { optionalHttpsBaseUrl } from './base-url.js'; +import { optionalBaseUrl } from './base-url.js'; import { logLlmFailure, logLlmRequest } from './log.js'; export const DEFAULT_ANTHROPIC_MODEL = 'claude-sonnet-4-6'; @@ -46,7 +46,7 @@ export function createAnthropicProvider(env = process.env) { throw err; } - const baseURL = optionalHttpsBaseUrl(env.ANTHROPIC_BASE_URL, 'ANTHROPIC_BASE_URL'); + const baseURL = optionalBaseUrl(env.ANTHROPIC_BASE_URL, 'ANTHROPIC_BASE_URL'); const model = normalizeAnthropicModelId(env.ANTHROPIC_MODEL) || DEFAULT_ANTHROPIC_MODEL; const client = new Anthropic({ diff --git a/lib/llm/base-url.js b/lib/llm/base-url.js index dfdd086..d5e5d98 100644 --- a/lib/llm/base-url.js +++ b/lib/llm/base-url.js @@ -1,12 +1,12 @@ /** - * Optional HTTPS base URL. Blank or absent values stay undefined so the SDK - * default is used. Any configured value must be an https URL. + * Optional HTTP(S) base URL. Blank or absent values stay undefined so the SDK + * default is used. Any configured value must be an http or https URL. * * @param {unknown} value * @param {string} envName * @returns {string | undefined} */ -export function optionalHttpsBaseUrl(value, envName) { +export function optionalBaseUrl(value, envName) { if (typeof value !== 'string') return undefined; const trimmed = value.trim(); if (!trimmed) return undefined; @@ -15,14 +15,14 @@ export function optionalHttpsBaseUrl(value, envName) { try { parsed = new URL(trimmed); } catch { - const err = new Error(`${envName} must be an https URL`); - err.code = 'LLM_INSECURE_BASE_URL'; + const err = new Error(`${envName} must be an http or https URL`); + err.code = 'LLM_INVALID_BASE_URL'; throw err; } - if (parsed.protocol !== 'https:') { - const err = new Error(`${envName} must use HTTPS`); - err.code = 'LLM_INSECURE_BASE_URL'; + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + const err = new Error(`${envName} must be an http or https URL`); + err.code = 'LLM_INVALID_BASE_URL'; throw err; } diff --git a/lib/llm/gemini.js b/lib/llm/gemini.js index 7beb7d4..eb04430 100644 --- a/lib/llm/gemini.js +++ b/lib/llm/gemini.js @@ -7,7 +7,7 @@ */ import { GoogleGenAI } from '@google/genai'; -import { optionalHttpsBaseUrl } from './base-url.js'; +import { optionalBaseUrl } from './base-url.js'; import { logLlmFailure, logLlmRequest } from './log.js'; export const DEFAULT_GEMINI_MODEL = 'gemini-2.5-flash'; @@ -61,7 +61,7 @@ export function createGeminiProvider(env = process.env) { throw err; } - const baseURL = optionalHttpsBaseUrl(env.GOOGLE_BASE_URL, 'GOOGLE_BASE_URL'); + const baseURL = optionalBaseUrl(env.GOOGLE_BASE_URL, 'GOOGLE_BASE_URL'); const model = normalizeGeminiModelId(env.GOOGLE_MODEL) || DEFAULT_GEMINI_MODEL; const client = new GoogleGenAI({ diff --git a/lib/llm/openai.js b/lib/llm/openai.js index 18be859..f96d7f4 100644 --- a/lib/llm/openai.js +++ b/lib/llm/openai.js @@ -7,7 +7,7 @@ */ import OpenAI from 'openai'; -import { optionalHttpsBaseUrl } from './base-url.js'; +import { optionalBaseUrl } from './base-url.js'; import { logLlmFailure, logLlmRequest } from './log.js'; export const DEFAULT_OPENAI_MODEL = 'gpt-4o'; @@ -47,7 +47,7 @@ export function createOpenAiProvider(env = process.env) { throw err; } - const baseURL = optionalHttpsBaseUrl(env.OPENAI_BASE_URL, 'OPENAI_BASE_URL'); + const baseURL = optionalBaseUrl(env.OPENAI_BASE_URL, 'OPENAI_BASE_URL'); const model = normalizeOpenAiModelId(env.OPENAI_MODEL) || DEFAULT_OPENAI_MODEL; const client = new OpenAI({ diff --git a/tests/llm-base-url.test.js b/tests/llm-base-url.test.js index 8c67b4e..4348a49 100644 --- a/tests/llm-base-url.test.js +++ b/tests/llm-base-url.test.js @@ -1,48 +1,56 @@ import { describe, it, expect } from 'vitest'; -import { optionalHttpsBaseUrl } from '../lib/llm/base-url.js'; +import { optionalBaseUrl } from '../lib/llm/base-url.js'; import { createOpenAiProvider } from '../lib/llm/openai.js'; import { createAnthropicProvider } from '../lib/llm/anthropic.js'; import { createGeminiProvider } from '../lib/llm/gemini.js'; -describe('optionalHttpsBaseUrl', () => { +describe('optionalBaseUrl', () => { it('returns undefined for absent or blank values', () => { - expect(optionalHttpsBaseUrl(undefined, 'OPENAI_BASE_URL')).toBeUndefined(); - expect(optionalHttpsBaseUrl('', 'OPENAI_BASE_URL')).toBeUndefined(); - expect(optionalHttpsBaseUrl(' ', 'OPENAI_BASE_URL')).toBeUndefined(); + expect(optionalBaseUrl(undefined, 'OPENAI_BASE_URL')).toBeUndefined(); + expect(optionalBaseUrl('', 'OPENAI_BASE_URL')).toBeUndefined(); + expect(optionalBaseUrl(' ', 'OPENAI_BASE_URL')).toBeUndefined(); }); it('trims a valid https URL', () => { - expect(optionalHttpsBaseUrl(' https://api.example.test/v1 ', 'OPENAI_BASE_URL')) + expect(optionalBaseUrl(' https://api.example.test/v1 ', 'OPENAI_BASE_URL')) .toBe('https://api.example.test/v1'); }); - it('rejects http and non-URL values', () => { - expect(() => optionalHttpsBaseUrl('http://api.example.test', 'OPENAI_BASE_URL')) - .toThrow(/OPENAI_BASE_URL must use HTTPS/); - expect(() => optionalHttpsBaseUrl('not-a-url', 'ANTHROPIC_BASE_URL')) - .toThrow(/ANTHROPIC_BASE_URL must be an https URL/); + it('accepts an http URL', () => { + expect(optionalBaseUrl('http://llm-proxy.internal/v1', 'OPENAI_BASE_URL')) + .toBe('http://llm-proxy.internal/v1'); + }); + + it('rejects non-URL and non-http(s) values', () => { + expect(() => optionalBaseUrl('not-a-url', 'ANTHROPIC_BASE_URL')) + .toThrow(/ANTHROPIC_BASE_URL must be an http or https URL/); + expect(() => optionalBaseUrl('ftp://api.example.test', 'OPENAI_BASE_URL')) + .toThrow(/OPENAI_BASE_URL must be an http or https URL/); }); }); -describe('provider base URL guards', () => { - it('rejects a non-HTTPS OPENAI_BASE_URL before constructing the client', () => { - expect(() => createOpenAiProvider({ +describe('provider base URL', () => { + it('accepts an http OPENAI_BASE_URL', () => { + const llm = createOpenAiProvider({ OPENAI_API_KEY: 'sk-test', OPENAI_BASE_URL: 'http://localhost:8080', - })).toThrow(/OPENAI_BASE_URL must use HTTPS/); + }); + expect(llm.name).toBe('openai'); }); - it('rejects a non-HTTPS ANTHROPIC_BASE_URL before constructing the client', () => { - expect(() => createAnthropicProvider({ + it('accepts an http ANTHROPIC_BASE_URL', () => { + const llm = createAnthropicProvider({ ANTHROPIC_API_KEY: 'sk-test', ANTHROPIC_BASE_URL: 'http://localhost:8080', - })).toThrow(/ANTHROPIC_BASE_URL must use HTTPS/); + }); + expect(llm.name).toBe('anthropic'); }); - it('rejects a non-HTTPS GOOGLE_BASE_URL before constructing the client', () => { - expect(() => createGeminiProvider({ + it('accepts an http GOOGLE_BASE_URL', () => { + const llm = createGeminiProvider({ GOOGLE_API_KEY: 'sk-test', GOOGLE_BASE_URL: 'http://localhost:8080', - })).toThrow(/GOOGLE_BASE_URL must use HTTPS/); + }); + expect(llm.name).toBe('gemini'); }); });