Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 7 additions & 21 deletions src/core/application/services/ModelService.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import providerRepository from '../../domain/repositories/ProviderRepository.js';
import { LLMClientFactory, CompletionOptions, Message } from '../../infrastructure/adapters/llm/LLMClient.js';
import { NO_SUFFIX, resolveSuffix } from '../../../shared/providerSuffix.js';
import sharp from 'sharp';


Expand All @@ -11,9 +12,8 @@ const getLLMClient = async (providerId: number) => {
throw new Error('服务商不存在');
}

// 获取 base_url 和 suffix
let baseUrl = provider.base_url || '';
let suffix = provider.suffix || '';
const isExactEndpoint = provider.suffix === NO_SUFFIX;

// 根据 provider 类型设置默认值
if (!baseUrl) {
Expand All @@ -24,30 +24,16 @@ const getLLMClient = async (providerId: number) => {
}
}

if (!suffix) {
switch (provider.type) {
case 'openai':
suffix = '/chat/completions';
break;
case 'openai-responses':
suffix = '/responses';
break;
case 'gemini':
suffix = '/models';
break;
case 'anthropic':
suffix = '/messages';
break;
case 'ollama':
suffix = '/chat';
break;
}
// 客户端会把空地址回退到官方端点,不能把自定义网关的 Key 发过去
if (isExactEndpoint && !baseUrl) {
throw new Error('选择「无后缀」时必须填写 API 地址');
}

return LLMClientFactory.createClient(
provider.type || '',
provider.api_key || '',
`${baseUrl}${suffix}`
`${baseUrl}${resolveSuffix(provider.type, provider.suffix)}`,
{ exactEndpoint: isExactEndpoint }
);
}

Expand Down
34 changes: 30 additions & 4 deletions src/core/application/services/__tests__/ModelService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ describe('Model Logic', () => {
expect(mockLLMClientFactory.createClient).toHaveBeenCalledWith(
'openai',
'sk-test-key',
'https://api.openai.com/v1/chat/completions'
'https://api.openai.com/v1/chat/completions',
{ exactEndpoint: false }
)
})

Expand Down Expand Up @@ -100,7 +101,8 @@ describe('Model Logic', () => {
expect(mockLLMClientFactory.createClient).toHaveBeenCalledWith(
'ollama',
'',
'http://localhost:11434/api/chat'
'http://localhost:11434/api/chat',
{ exactEndpoint: false }
)
})

Expand Down Expand Up @@ -137,7 +139,8 @@ describe('Model Logic', () => {
expect(mockLLMClientFactory.createClient).toHaveBeenCalledWith(
testCase.type,
'test-key',
`https://api.test.com${testCase.expectedSuffix}`
`https://api.test.com${testCase.expectedSuffix}`,
{ exactEndpoint: false }
)
}
})
Expand Down Expand Up @@ -166,7 +169,30 @@ describe('Model Logic', () => {
expect(mockLLMClientFactory.createClient).toHaveBeenCalledWith(
'openai',
'test-key',
'https://api.custom.com/custom/endpoint'
'https://api.custom.com/custom/endpoint',
{ exactEndpoint: false }
)
})

it('should pass the base URL as the exact endpoint for the no-suffix marker', async () => {
mockProviderRepository.findById.mockResolvedValue({
id: 1,
type: 'gemini',
api_key: 'test-key',
base_url: 'https://gateway.test/full/endpoint',
suffix: 'none'
})
mockLLMClientFactory.createClient.mockResolvedValue({
completion: vi.fn().mockResolvedValue({ content: 'response' })
})

await modelLogic.completion(1, { messages: [], model: 'test' })

expect(mockLLMClientFactory.createClient).toHaveBeenCalledWith(
'gemini',
'test-key',
'https://gateway.test/full/endpoint',
{ exactEndpoint: true }
)
})
})
Expand Down
49 changes: 47 additions & 2 deletions src/core/application/services/__tests__/OllamaProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,11 +73,11 @@ const fakeOllamaServer = vi.fn(async (input: string | URL, init?: RequestInit):
describe('Ollama protocol consistency', () => {
let modelService: any

const useProvider = (base_url: string, suffix: string | null) => {
const useProvider = (base_url: string, suffix: string | null, api_key = '') => {
mockProviderRepository.findById.mockResolvedValue({
id: 1,
type: 'ollama',
api_key: '',
api_key,
base_url,
suffix
})
Expand Down Expand Up @@ -158,6 +158,51 @@ describe('Ollama protocol consistency', () => {
})
})

describe('reverse proxy with Bearer authentication (issue #89)', () => {
const API_KEY = 'proxy-key'

const testConnection = () =>
modelService.completion(1, {
model: 'llama3.2-vision',
maxTokens: 8,
messages: [{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: `data:image/png;base64,${PAGE_IMAGE_BASE64}` } },
{ type: 'text', text: 'Identify the largest letter.' }
]
}]
})

beforeEach(() => {
global.fetch = vi.fn(async (input: string | URL, init?: RequestInit) =>
new Headers(init?.headers).get('Authorization') === `Bearer ${API_KEY}`
? fakeOllamaServer(input, init)
: new Response('unauthorized', { status: 401, statusText: 'Unauthorized' })
) as unknown as typeof fetch
})

it.each([
['page conversion', convertPage],
['connection test', testConnection]
])('authenticates the %s', async (_name, request) => {
useProvider(`${HOST}/api`, '', API_KEY)

const result = await request()

expect(result.content).toBe(MARKDOWN)
})

it('sends no Authorization header when no key is configured', async () => {
useProvider(`${HOST}/api`, '')

await expect(convertPage()).rejects.toThrow(/HTTP 401/)

const init = vi.mocked(global.fetch).mock.calls[0][1]
expect(new Headers(init?.headers).has('Authorization')).toBe(false)
})
})

describe('misconfigured endpoints', () => {
it('reports the endpoint instead of returning empty content for a non-chat response', async () => {
useProvider(`${HOST}/api`, '/generate')
Expand Down
82 changes: 82 additions & 0 deletions src/core/application/services/__tests__/ProviderEndpoint.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NO_SUFFIX } from '../../../../shared/providerSuffix.js'

/**
* The URL each provider type really requests, for each stored suffix.
*
* Only the database, sharp and fetch are mocked: ModelService builds the URL and
* the real clients may extend it, so testing either side alone cannot catch a
* suffix that does not reach the wire (issue #89).
*/

const mockProviderRepository = { findById: vi.fn() }

vi.mock('../../../domain/repositories/ProviderRepository.js', () => ({
default: mockProviderRepository
}))

vi.mock('sharp', () => ({
default: vi.fn(() => ({ toBuffer: vi.fn().mockResolvedValue(Buffer.from('page')) }))
}))

const BASE = 'https://proxy.test/v1/my-endpoint'
const MODEL = 'some-model'

// A body every client can parse, each reads only the fields of its own protocol
const ANY_PROTOCOL_BODY = {
choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }],
output: [{ content: 'ok' }],
content: [{ type: 'text', text: 'ok' }],
candidates: [{ content: { parts: [{ text: 'ok' }] }, finishReason: 'STOP' }]
}

const CASES = [
{ type: 'openai', defaultUrl: `${BASE}/chat/completions` },
{ type: 'openai-responses', defaultUrl: `${BASE}/responses` },
{ type: 'anthropic', defaultUrl: `${BASE}/messages` },
{ type: 'gemini', defaultUrl: `${BASE}/models/${MODEL}:generateContent` }
]

describe('Provider request URL', () => {
let modelService: any
const fetchMock = vi.fn()

const requestedUrl = async (type: string, suffix: string | null, base_url = BASE) => {
mockProviderRepository.findById.mockResolvedValue({ id: 1, type, api_key: 'key', base_url, suffix })
const result = await modelService.completion(1, {
model: MODEL,
messages: [{ role: 'user', content: { type: 'text', text: 'hi' } }]
})
expect(result.content).toBe('ok')
return String(fetchMock.mock.calls[0][0])
}

beforeEach(async () => {
fetchMock.mockReset()
fetchMock.mockImplementation(async () => new Response(JSON.stringify(ANY_PROTOCOL_BODY), { status: 200 }))
global.fetch = fetchMock as unknown as typeof fetch
vi.spyOn(console, 'log').mockImplementation(() => {})
modelService = (await import('../ModelService.js')).default
})

describe.each(CASES)('$type', ({ type, defaultUrl }) => {
it('requests exactly the base URL when "no suffix" is selected', async () => {
expect(await requestedUrl(type, NO_SUFFIX)).toBe(BASE)
})

it.each([[''], [null]])('keeps the default URL of an existing provider with suffix %j', async (suffix) => {
expect(await requestedUrl(type, suffix)).toBe(defaultUrl)
})

it('rejects "no suffix" without a base URL instead of calling the official endpoint', async () => {
mockProviderRepository.findById.mockResolvedValue({ id: 1, type, api_key: 'key', base_url: '', suffix: NO_SUFFIX })

await expect(modelService.completion(1, { model: MODEL, messages: [] })).rejects.toThrow('无后缀')
expect(fetchMock).not.toHaveBeenCalled()
})
})

it('still adds the model path to a custom Gemini suffix', async () => {
expect(await requestedUrl('gemini', '/custom')).toBe(`${BASE}/custom/${MODEL}:generateContent`)
})
})
7 changes: 5 additions & 2 deletions src/core/infrastructure/adapters/llm/GeminiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ import {
* Google Gemini客户端实现
*/
export class GeminiClient extends LLMClient {
constructor(apiKey: string, baseUrl?: string) {
/**
* @param exactEndpoint baseUrl 已是完整地址(如网关端点),不再追加模型路径
*/
constructor(apiKey: string, baseUrl?: string, private readonly exactEndpoint: boolean = false) {
super(apiKey, baseUrl || 'https://generativelanguage.googleapis.com/v1beta/models');
}

Expand All @@ -26,7 +29,7 @@ export class GeminiClient extends LLMClient {

const modelName = normalizedOptions.model || 'gemini-1.5-pro';
// 使用 v1beta 端点,key 通过 header 传递
const endpoint = `${this.baseUrl}/${modelName}:generateContent`;
const endpoint = this.exactEndpoint ? this.baseUrl : `${this.baseUrl}/${modelName}:generateContent`;

// 将消息转换为Gemini格式
const geminiContents = this.convertMessagesToGeminiFormat(normalizedOptions.messages);
Expand Down
11 changes: 9 additions & 2 deletions src/core/infrastructure/adapters/llm/LLMClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,15 @@ export class LLMClientFactory {
* @param type LLM client type
* @param apiKey API key
* @param baseUrl Base URL (some services need custom URL)
* @param options.exactEndpoint baseUrl is the complete endpoint, the client must not extend it.
* Only Gemini extends its URL (with the model path); the other clients always post to baseUrl.
*/
static async createClient(type: string, apiKey: string, baseUrl?: string): Promise<LLMClient> {
static async createClient(
type: string,
apiKey: string,
baseUrl?: string,
options: { exactEndpoint?: boolean } = {}
): Promise<LLMClient> {
switch (type) {
case 'openai': {
const OpenAIModule = await import('./OpenAIClient.js');
Expand All @@ -110,7 +117,7 @@ export class LLMClientFactory {
}
case 'gemini': {
const GeminiModule = await import('./GeminiClient.js');
return new GeminiModule.GeminiClient(apiKey, baseUrl || '');
return new GeminiModule.GeminiClient(apiKey, baseUrl || '', options.exactEndpoint);
}
case 'anthropic': {
const AnthropicModule = await import('./AnthropicClient.js');
Expand Down
4 changes: 4 additions & 0 deletions src/core/infrastructure/adapters/llm/OllamaClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,14 @@ export class OllamaClient extends LLMClient {
requestBody.format = 'json';
}

// 本地 Ollama 无需鉴权,只有配置了 Key(如带鉴权的反向代理)才发送
const apiKey = normalizedOptions.apiKey || this.apiKey;

const response = await fetch(`${this.baseUrl}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { 'Authorization': `Bearer ${apiKey}` } : {}),
'X-Title': 'MarkPDFdown',
'HTTP-Referer': 'https://github.com/MarkPDFdown'
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ describe('GeminiClient', () => {
})
})

describe('Endpoint', () => {
const request = { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Hi' } }], model: 'gemini-pro' }

it('should add the model path to the base URL by default', async () => {
mockFetchSuccess(createMockGeminiResponse('ok'))

await new GeminiClient('key', 'https://gateway.test/models').completion(request)

expect((global.fetch as any).mock.calls[0][0]).toBe('https://gateway.test/models/gemini-pro:generateContent')
})

it('should request the base URL unchanged for an exact endpoint', async () => {
mockFetchSuccess(createMockGeminiResponse('ok'))

await new GeminiClient('key', 'https://gateway.test/full/endpoint', true).completion(request)

expect((global.fetch as any).mock.calls[0][0]).toBe('https://gateway.test/full/endpoint')
})
})

describe('Message Format Conversion', () => {
it('should convert simple text message to Gemini format', async () => {
const mockResponse = createMockGeminiResponse('Test response')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ describe('LLMClientFactory', () => {
expect(client.constructor.name).toBe('GeminiClient')
})

it('should pass exactEndpoint to the Gemini client', async () => {
const exact = await LLMClientFactory.createClient('gemini', 'key', 'https://gateway.test', { exactEndpoint: true })
const regular = await LLMClientFactory.createClient('gemini', 'key', 'https://gateway.test')

expect(exact['exactEndpoint' as keyof typeof exact]).toBe(true)
expect(regular['exactEndpoint' as keyof typeof regular]).toBe(false)
})

it('should create Ollama client', async () => {
const client = await LLMClientFactory.createClient('ollama', '', 'http://localhost:11434')
expect(client).toBeDefined()
Expand Down
Loading
Loading