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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,10 @@ Common commands include `/start`, `/help`, `/pair`, `/new`, `/sessions`, `/use`,
<img src="./src/renderer/src/assets/llm-icons/requesty.png" width="50" height="50" alt="Requesty Icon"><br/>
<a href="https://www.requesty.ai/">Requesty</a>
</td>
<td>
<img src="./src/renderer/src/assets/llm-icons/litellm.svg" width="50" height="50" alt="LiteLLM Icon"><br/>
<a href="https://www.litellm.ai/">LiteLLM</a>
</td>
<td>
<img src="./src/renderer/src/assets/llm-icons/opper.svg" width="50" height="50" alt="Opper Icon"><br/>
<a href="https://opper.ai/">Opper</a>
Expand Down
14 changes: 14 additions & 0 deletions src/main/provider/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,20 @@ const PROVIDER_DEFAULTS: LLM_PROVIDER_BASE[] = [
models: 'https://www.requesty.ai/models'
}
},
{
id: 'litellm',
name: 'LiteLLM',
apiType: 'openai-completions',
apiKey: '',
baseUrl: 'http://localhost:4000/v1',
enable: false,
websites: {
official: 'https://www.litellm.ai/',
apiKey: 'https://docs.litellm.ai/docs/proxy/virtual_keys',
docs: 'https://docs.litellm.ai/docs/simple_proxy',
models: 'https://docs.litellm.ai/docs/providers'
}
},
{
id: 'opper',
name: 'Opper',
Expand Down
1 change: 1 addition & 0 deletions src/main/provider/providerImportService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ const PROVIDER_ID_ALIASES: Record<string, string> = {
ppinfra: 'ppio',
openrouter: 'openrouter',
requesty: 'requesty',
litellm: 'litellm',
demonroute: 'demonroute',
orcarouter: 'orcarouter',
synthorai: 'synthorai',
Expand Down
11 changes: 11 additions & 0 deletions src/main/provider/providerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type AiSdkModelSourceStrategy =
| 'new-api'
| 'openrouter'
| 'requesty'
| 'litellm'
| 'demonroute'
| 'ppio'
| 'groq'
Expand All @@ -34,6 +35,7 @@ export type AiSdkModelSourceStrategy =

export type AiSdkKeyStatusStrategy =
| 'none'
| 'litellm'
| 'openrouter'
| 'deepseek'
| 'ppio'
Expand Down Expand Up @@ -594,6 +596,15 @@ const PROVIDER_ID_REGISTRY = new Map<string, AiSdkProviderDefinition>([
credentialStrategy: 'api-key'
})
],
[
'litellm',
// Self-hosted proxy: the key is optional (master or virtual key only when auth is on).
createDefinition({
...OPENAI_BASE,
modelSource: 'litellm',
keyStatusStrategy: 'litellm'
})
],
[
'demonroute',
createDefinition({
Expand Down
142 changes: 142 additions & 0 deletions src/main/provider/providers/aiSdkProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,24 @@ function toModelRecordArray(payload: unknown): Array<Record<string, unknown>> {
return []
}

// LiteLLM `model_info.mode` -> the DeepChat model type whose route the proxy also serves.
// `responses` aliases are served on chat completions too.
const LITELLM_MODE_TYPES: Record<string, ModelType | undefined> = {
chat: ModelType.Chat,
responses: ModelType.Chat,
embedding: ModelType.Embedding,
image_generation: ModelType.ImageGeneration,
video_generation: ModelType.VideoGeneration,
audio_speech: ModelType.TTS
}

// LiteLLM `litellm_provider` -> model group, e.g. `vertex_ai-language-models` -> `vertex_ai` and
// `text-completion-codestral` -> `codestral`.
function toLiteLLMProviderGroup(value: unknown): string {
if (typeof value !== 'string' || !value) return 'default'
return value.replace(/^text-completion-/i, '').split('-')[0] || 'default'
}

function toPositiveFiniteNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
}
Expand Down Expand Up @@ -1741,6 +1759,8 @@ export class AiSdkProvider extends BaseLLMProvider {
return this.fetchOpenAiDerivedModels(strategy)
case 'requesty':
return this.fetchRequestyModels()
case 'litellm':
return this.fetchLiteLLMModels()
case 'demonroute':
return this.fetchDemonRouteModels()
case 'bedrock':
Expand Down Expand Up @@ -1978,6 +1998,91 @@ export class AiSdkProvider extends BaseLLMProvider {
return models
}

/** LiteLLM management routes (`/model/info`, `/key/info`) live at the proxy root, not `/v1`. */
private getLiteLLMProxyRoot(): string {
return (this.provider.baseUrl || '').replace(/\/+$/, '').replace(/\/v1$/i, '')
}

/**
* LiteLLM model names are aliases chosen by whoever runs the proxy, so the shared model catalog
* cannot describe them. The proxy's `/model/info` carries the routed model's mode, limits and
* capabilities per alias; `/models` (ids only) is the fallback when the proxy returns no model
* records or the key cannot read them (401, 403, 404). Other errors are surfaced.
*/
private async fetchLiteLLMModels(): Promise<MODEL_META[]> {
const timeout = this.getModelFetchTimeout()
const infoUrl = `${this.getLiteLLMProxyRoot()}/model/info`
let records: Array<Record<string, unknown>>
try {
records = toModelRecordArray(
await this.requestProviderJson<unknown>(infoUrl, { method: 'GET' }, timeout)
)
} catch (error) {
if (!(error instanceof ProviderHttpError) || ![401, 403, 404].includes(error.status ?? 0)) {
throw error
}
console.warn('LiteLLM model info unavailable, falling back to /models:', error.status)
records = []
}

// Single-model CLI proxies return a data object with a wildcard name; /models has callable IDs.
if (records.length === 0) {
const listed = await this.fetchDefaultOpenAIModels({ timeout })
return listed.filter((model) => !model.id.includes('*'))
}

const models = new Map<string, MODEL_META>()
for (const record of records) {
const modelId = typeof record.model_name === 'string' ? record.model_name : ''
const info =
record.model_info && typeof record.model_info === 'object'
? (record.model_info as Record<string, unknown>)
: {}
// Modes without a DeepChat route (rerank, audio_transcription, moderation, ...) are skipped,
// and so are wildcard routes (`*`, `openai/*`), which are patterns rather than callable names.
const type = LITELLM_MODE_TYPES[typeof info.mode === 'string' ? info.mode : 'chat']
if (!modelId || modelId.includes('*') || !type) {
continue
}
const contextLength = toPositiveFiniteNumber(info.max_input_tokens)
const maxTokens = toPositiveFiniteNumber(info.max_output_tokens)
const existing = models.get(modelId)
if (existing) {
// Deployments of one alias are load balanced, so a request can land on any of them: keep
// the smallest limits and only the capabilities every deployment reports.
if (contextLength !== undefined) {
existing.contextLength = Math.min(existing.contextLength ?? contextLength, contextLength)
}
if (maxTokens !== undefined) {
existing.maxTokens = Math.min(existing.maxTokens ?? maxTokens, maxTokens)
}
existing.vision = existing.vision === true && info.supports_vision === true
existing.functionCall =
existing.functionCall === true && info.supports_function_calling === true
existing.reasoning = existing.reasoning === true && info.supports_reasoning === true
continue
}
models.set(modelId, {
id: modelId,
name: modelId,
group: toLiteLLMProviderGroup(info.litellm_provider),
providerId: this.provider.id,
isCustom: false,
type,
...(contextLength !== undefined ? { contextLength } : {}),
...(maxTokens !== undefined ? { maxTokens } : {}),
...(typeof info.supports_vision === 'boolean' ? { vision: info.supports_vision } : {}),
...(typeof info.supports_function_calling === 'boolean'
? { functionCall: info.supports_function_calling }
: {}),
...(typeof info.supports_reasoning === 'boolean'
? { reasoning: info.supports_reasoning }
: {})
})
}
return [...models.values()]
}

/**
* DemonRoute exposes a standard OpenAI-compatible model catalog at `{baseUrl}/models`.
* The catalog request is authenticated and doubles as the key check.
Expand Down Expand Up @@ -2250,6 +2355,43 @@ export class AiSdkProvider extends BaseLLMProvider {
}
return keyStatus
}
case 'litellm': {
// Spend and budget exist for virtual keys only; a keyless proxy or the master key has none.
if (!this.provider.apiKey) return null
const { signal, dispose } = this.createModelRequestSignal({
timeout: this.getModelFetchTimeout()
})
try {
const response = await this.fetchProvider(`${this.getLiteLLMProxyRoot()}/key/info`, {
method: 'GET',
headers: { Authorization: `Bearer ${this.provider.apiKey}` },
signal
})
if (response.status === 404) return null
if (!response.ok) {
const errorText = await response.text()
throw new Error(
`LiteLLM key check failed: ${response.status} ${response.statusText} - ${errorText}`
)
}
const payload = (await response.json()) as {
info?: { spend?: number | null; max_budget?: number | null }
}
// Per-request spend is often below a cent; keep enough digits to show it.
const usd = (value: number) => `$${value.toFixed(Math.abs(value) < 0.01 ? 6 : 2)}`
const spend = Number(payload.info?.spend ?? 0)
const keyStatus: KeyStatus = { usage: usd(spend) }
const budget = payload.info?.max_budget
if (typeof budget === 'number') {
const remaining = budget - spend
keyStatus.limit_remaining = usd(remaining)
keyStatus.remainNum = remaining
}
return keyStatus
} finally {
dispose()
}
}
case 'deepseek': {
const response = await this.fetchProvider('https://api.deepseek.com/user/balance', {
method: 'GET',
Expand Down
2 changes: 1 addition & 1 deletion src/renderer/settings/components/ProviderApiConfig.vue
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ watch(
keyStatusError.value = ''
if (
!storedApiKey ||
!['ppio', 'openrouter', 'deepseek', '302ai', 'cherryin'].includes(providerId)
!['ppio', 'openrouter', 'deepseek', '302ai', 'cherryin', 'litellm'].includes(providerId)
)
return
try {
Expand Down
17 changes: 17 additions & 0 deletions src/renderer/src/assets/llm-icons/litellm.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/renderer/src/components/icons/modelIconRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import kimiColorIcon from '@/assets/llm-icons/kimi-color.svg?url'
import moonshotColorIcon from '@/assets/llm-icons/moonshot.svg?url'
import openrouterColorIcon from '@/assets/llm-icons/openrouter.svg?url'
import requestyColorIcon from '@/assets/llm-icons/requesty.png?url'
import litellmColorIcon from '@/assets/llm-icons/litellm.svg?url'
import opperColorIcon from '@/assets/llm-icons/opper.svg?url'
import demonrouteColorIcon from '@/assets/llm-icons/demonroute.svg?url'
import orcarouterColorIcon from '@/assets/llm-icons/orcarouter.svg?url'
Expand Down Expand Up @@ -189,6 +190,7 @@ export const modelIcons = {
moonshot: moonshotColorIcon,
openrouter: openrouterColorIcon,
requesty: requestyColorIcon,
litellm: litellmColorIcon,
opper: opperColorIcon,
demonroute: demonrouteColorIcon,
orcarouter: orcarouterColorIcon,
Expand Down
Loading