Skip to content
Merged
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
10 changes: 9 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ db_mode=json
########################################
# LLM Core Settings
########################################
LLM_PROVIDER=gemini # gemini | openai | claude | grok | ollama | openrouter | minimax
LLM_PROVIDER=gemini # gemini | openai | claude | grok | ollama | openrouter | minimax | apiroute
EMB_PROVIDER=openai # openai | gemini | ollama etc.
LLM_TEMP=1
LLM_MAXTOK=16384
Expand Down Expand Up @@ -68,6 +68,14 @@ GROK_BASE=https://api.x.ai/v1
MINIMAX_API_KEY=
MINIMAX_MODEL=MiniMax-M3

########################################
# API Route (https://www.api-route.com)
########################################
API_ROUTE_API_KEY=
API_ROUTE_MODEL=gpt-5.4-mini
API_ROUTE_BASE_URL="https://www.api-route.com/v1"


########################################
# Ollama (local models)
########################################
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ The platform provides a modern interface for students, educators, and researcher

### Supported AI Models

- Google Gemini • OpenAI GPT • Anthropic Claude • xAI Grok • [MiniMax](https://www.minimax.io/) • Ollama (local) • OpenRouter
- Google Gemini • OpenAI GPT • Anthropic Claude • xAI Grok • [MiniMax](https://www.minimax.io/) • [API Route](https://www.api-route.com) • Ollama (local) • OpenRouter

### Embedding Providers

Expand Down
3 changes: 3 additions & 0 deletions backend/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export const config = {
grok_base: process.env.GROK_BASE || 'https://api.x.ai/v1',
minimax: process.env.MINIMAX_API_KEY || '',
minimax_model: process.env.MINIMAX_MODEL || 'MiniMax-M3',
apiroute: process.env.API_ROUTE_API_KEY || process.env.APIROUTE_API_KEY || '',
apiroute_model: process.env.API_ROUTE_MODEL || process.env.APIROUTE_MODEL || 'gpt-5.4-mini',
apiroute_base: process.env.API_ROUTE_BASE_URL || 'https://www.api-route.com/v1',
ollama: {
model: process.env.OLLAMA_MODEL || 'llama4',
embedModel: process.env.OLLAMA_EMBED_MODEL || '',
Expand Down
77 changes: 77 additions & 0 deletions backend/src/utils/llm/models/__tests__/apiroute-factory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

// Mock all provider modules
vi.mock('../ollama', () => ({ makeLLM: vi.fn(), makeEmbeddings: vi.fn() }))
vi.mock('../gemini', () => ({ makeLLM: vi.fn(), makeEmbeddings: vi.fn() }))
vi.mock('../openai', () => ({
makeLLM: vi.fn().mockReturnValue({ invoke: vi.fn(), call: vi.fn() }),
makeEmbeddings: vi.fn().mockReturnValue({ embedDocuments: vi.fn(), embedQuery: vi.fn() }),
}))
vi.mock('../grok', () => ({ makeLLM: vi.fn(), makeEmbeddings: vi.fn() }))
vi.mock('../claude', () => ({ makeLLM: vi.fn(), makeEmbeddings: vi.fn() }))
vi.mock('../openrouter', () => ({ makeLLM: vi.fn(), makeEmbeddings: vi.fn() }))
vi.mock('../minimax', () => ({ makeLLM: vi.fn(), makeEmbeddings: vi.fn() }))
vi.mock('../apiroute', () => ({
makeLLM: vi.fn().mockReturnValue({ invoke: vi.fn(), call: vi.fn() }),
makeEmbeddings: vi.fn().mockReturnValue({
embedDocuments: vi.fn(),
embedQuery: vi.fn(),
}),
}))

// Mock config
vi.mock('../../../../config/env', () => ({
config: { provider: 'apiroute', embeddings_provider: '' },
}))

import { makeModels } from '../index'
import * as apiroute from '../apiroute'
import { config } from '../../../../config/env'

describe('Factory integration – API Route provider', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('should select API Route provider when config.provider is "apiroute"', () => {
;(config as any).provider = 'apiroute'
const { llm, embeddings } = makeModels()

expect(apiroute.makeLLM).toHaveBeenCalledWith(config)
expect(apiroute.makeEmbeddings).toHaveBeenCalledWith(config)
expect(llm).toBeDefined()
expect(embeddings).toBeDefined()
})

it('should support alias "api_route" and "api-route"', () => {
;(config as any).provider = 'api_route'
makeModels()
expect(apiroute.makeLLM).toHaveBeenCalledWith(config)

vi.clearAllMocks()
;(config as any).provider = 'api-route'
makeModels()
expect(apiroute.makeLLM).toHaveBeenCalledWith(config)
})

it('should fallback to embeddings_provider when API Route embeddings throw', () => {
;(config as any).provider = 'apiroute'
;(config as any).embeddings_provider = 'openai'
;(apiroute.makeEmbeddings as ReturnType<typeof vi.fn>).mockImplementationOnce(() => {
throw new Error('No API Route key')
})

const { llm, embeddings } = makeModels()

expect(apiroute.makeLLM).toHaveBeenCalled()
expect(llm).toBeDefined()
expect(embeddings).toBeDefined()
})

it('should not select API Route when provider is "openai"', () => {
;(config as any).provider = 'openai'
makeModels()

expect(apiroute.makeLLM).not.toHaveBeenCalled()
})
})
183 changes: 183 additions & 0 deletions backend/src/utils/llm/models/__tests__/apiroute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'

// Mock @langchain/openai before importing apiroute module
vi.mock('@langchain/openai', () => {
const MockChatOpenAI = vi.fn(function (this: any, opts: any) {
this.invoke = vi.fn().mockResolvedValue({ content: 'mock response' })
this._opts = opts
})
const MockOpenAIEmbeddings = vi.fn(function (this: any, opts: any) {
this.embedDocuments = vi.fn().mockResolvedValue([[0.1, 0.2]])
this.embedQuery = vi.fn().mockResolvedValue([0.1, 0.2])
this._opts = opts
})
return { ChatOpenAI: MockChatOpenAI, OpenAIEmbeddings: MockOpenAIEmbeddings }
})

import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai'
import { makeLLM, makeEmbeddings } from '../apiroute'

describe('API Route LLM provider', () => {
beforeEach(() => {
vi.clearAllMocks()
})

describe('makeLLM', () => {
it('should create a ChatOpenAI instance with API Route defaults', () => {
const cfg = { apiroute: 'test-api-key' }
const llm = makeLLM(cfg)

expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
model: 'gpt-5.4-mini',
apiKey: 'test-api-key',
configuration: { baseURL: 'https://www.api-route.com/v1' },
}),
)
expect(llm).toHaveProperty('invoke')
expect(llm).toHaveProperty('call')
})

it('should use configured model when provided', () => {
const cfg = { apiroute: 'key', apiroute_model: 'deepseek-chat' }
makeLLM(cfg)

expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
model: 'deepseek-chat',
}),
)
})

it('should use custom baseURL when provided', () => {
const cfg = { apiroute: 'key', apiroute_base: 'https://custom.api-route.com/v1' }
makeLLM(cfg)

expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
configuration: { baseURL: 'https://custom.api-route.com/v1' },
}),
)
})

it('should clamp temperature to [0, 2]', () => {
const cfg = { apiroute: 'key', temp: 3.5 }
makeLLM(cfg)

expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 2,
}),
)
})

it('should handle temperature of 0', () => {
const cfg = { apiroute: 'key', temp: 0 }
makeLLM(cfg)

expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0,
}),
)
})

it('should default temperature to 0.7 when not set', () => {
const cfg = { apiroute: 'key' }
makeLLM(cfg)

expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0.7,
}),
)
})

it('should pass max_tokens from config', () => {
const cfg = { apiroute: 'key', max_tokens: 4096 }
makeLLM(cfg)

expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
maxTokens: 4096,
}),
)
})

it('should read API_ROUTE_API_KEY from env when cfg.apiroute is empty', () => {
process.env.API_ROUTE_API_KEY = 'env-api-route-key'
const cfg = {} as any
makeLLM(cfg)

expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'env-api-route-key',
}),
)
delete process.env.API_ROUTE_API_KEY
})

it('should return an object with invoke and call methods', async () => {
const cfg = { apiroute: 'key' }
const llm = makeLLM(cfg)

expect(typeof llm.invoke).toBe('function')
expect(typeof llm.call).toBe('function')

const result = await llm.invoke([{ role: 'user', content: 'Hello' }])
expect(result).toEqual({ content: 'mock response' })
})

it('should handle negative temperature by clamping to 0', () => {
const cfg = { apiroute: 'key', temp: -1 }
makeLLM(cfg)

expect(ChatOpenAI).toHaveBeenCalledWith(
expect.objectContaining({
temperature: 0,
}),
)
})
})

describe('makeEmbeddings', () => {
it('should create embeddings with API Route baseURL and large model default', () => {
const cfg = { apiroute: 'test-key' }
const emb = makeEmbeddings(cfg)

expect(OpenAIEmbeddings).toHaveBeenCalledWith(
expect.objectContaining({
model: 'text-embedding-3-large',
apiKey: 'test-key',
configuration: { baseURL: 'https://www.api-route.com/v1' },
}),
)
expect(emb).toHaveProperty('embedDocuments')
expect(emb).toHaveProperty('embedQuery')
})

it('should use configured embed model', () => {
const cfg = { apiroute: 'key', openai_embed_model: 'text-embedding-3-small' }
makeEmbeddings(cfg)

expect(OpenAIEmbeddings).toHaveBeenCalledWith(
expect.objectContaining({
model: 'text-embedding-3-small',
}),
)
})

it('should read API_ROUTE_API_KEY or OPENAI_API_KEY from env when cfg.apiroute is empty', () => {
process.env.API_ROUTE_API_KEY = 'env-route-key'
const cfg = {} as any
makeEmbeddings(cfg)

expect(OpenAIEmbeddings).toHaveBeenCalledWith(
expect.objectContaining({
apiKey: 'env-route-key',
}),
)
delete process.env.API_ROUTE_API_KEY
})
})
})
23 changes: 23 additions & 0 deletions backend/src/utils/llm/models/apiroute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ChatOpenAI, OpenAIEmbeddings } from '@langchain/openai'
import { wrapChat } from './util'
import type { MkLLM, MkEmb, EmbeddingsLike } from './types'

export const makeLLM: MkLLM = (cfg: any) => {
const temp = cfg.temp ?? 0.7
const m = new ChatOpenAI({
model: cfg.apiroute_model || 'gpt-5.4-mini',
apiKey: cfg.apiroute || process.env.API_ROUTE_API_KEY || '',
configuration: { baseURL: cfg.apiroute_base || 'https://www.api-route.com/v1' },
temperature: Math.max(0, Math.min(temp, 2)),
maxTokens: cfg.max_tokens,
})
return wrapChat(m)
}

export const makeEmbeddings: MkEmb = (cfg: any): EmbeddingsLike => {
return new OpenAIEmbeddings({
model: cfg.openai_embed_model || 'text-embedding-3-large',
apiKey: cfg.apiroute || process.env.API_ROUTE_API_KEY || process.env.OPENAI_API_KEY || '',
configuration: { baseURL: cfg.apiroute_base || 'https://www.api-route.com/v1' },
})
}
5 changes: 5 additions & 0 deletions backend/src/utils/llm/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as grok from './grok'
import * as claude from './claude'
import * as openrouter from './openrouter'
import * as minimax from './minimax'
import * as apiroute from './apiroute'
import { config } from '../../../config/env'
import type { EmbeddingsLike, LLM } from './types'

Expand All @@ -19,6 +20,10 @@ function pick(p: string) {
case 'claude': return claude
case 'openrouter': return openrouter
case 'minimax': return minimax
case 'apiroute':
case 'api_route':
case 'api-route':
return apiroute
default: return gemini
}
}
Expand Down