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
13 changes: 5 additions & 8 deletions src/main/apiServer/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { CacheService } from '@main/services/CacheService'
import { loggerService } from '@main/services/LoggerService'
import { reduxService } from '@main/services/ReduxService'
import { isSiliconAnthropicCompatibleModel } from '@shared/config/providers'
import type { ApiModel, Model, Provider } from '@types'
import type { ApiModel, Model, Provider, ProviderType } from '@types'

const logger = loggerService.withContext('ApiServerUtils')

Expand All @@ -28,10 +28,9 @@ export async function getAvailableProviders(): Promise<Provider[]> {
return []
}

// Support OpenAI and Anthropic type providers for API server
const supportedProviders = providers.filter(
(p: Provider) => p.enabled && (p.type === 'openai' || p.type === 'anthropic')
)
// Support OpenAI-compatible and Anthropic-compatible providers for API server
const supportedTypes: ProviderType[] = ['openai', 'anthropic', 'ollama', 'new-api']
Comment thread
DeJeune marked this conversation as resolved.
Comment thread
DeJeune marked this conversation as resolved.
const supportedProviders = providers.filter((p: Provider) => p.enabled && supportedTypes.includes(p.type))

// Cache the filtered results
CacheService.set(PROVIDERS_CACHE_KEY, supportedProviders, PROVIDERS_CACHE_TTL)
Expand Down Expand Up @@ -286,12 +285,10 @@ export const getProviderAnthropicModelChecker = (providerId: string): ((m: Model
case 'cherryin':
case 'new-api':
return (m: Model) => m.endpoint_type === 'anthropic'
case 'aihubmix':
return (m: Model) => m.id.includes('claude')
case 'silicon':
return (m: Model) => isSiliconAnthropicCompatibleModel(m.id)
default:
// allow all models when checker not configured
// allow all models when checker not configured (aihubmix, ollama, etc.)
Comment thread
DeJeune marked this conversation as resolved.
return () => true
}
}
36 changes: 26 additions & 10 deletions src/main/services/agents/BaseService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { ModelValidationError } from '@main/apiServer/utils'
import { validateModelId } from '@main/apiServer/utils'
import { getDataPath } from '@main/utils'
import { buildFunctionCallToolName } from '@shared/mcp'
import type { AgentType, MCPTool, SlashCommand, Tool } from '@types'
import type { AgentType, MCPTool, SlashCommand, SystemProviderId, Tool } from '@types'
import { objectKeys } from '@types'
import fs from 'fs'
import path from 'path'
Expand Down Expand Up @@ -275,7 +275,12 @@ export abstract class BaseService {
}

/**
* Validate agent model configuration
* Validate agent model configuration.
*
* **Side effect**: For local providers that don't require a real API key
* (e.g. ollama, lmstudio), this method sets `provider.apiKey` to the
* provider ID as a placeholder so downstream SDK calls don't reject the
* request. Callers should be aware that the provider object may be mutated.
*/
protected async validateAgentModels(
agentType: AgentType,
Expand All @@ -286,6 +291,10 @@ export abstract class BaseService {
return
}

// Local providers that don't require a real API key (use placeholder).
Comment thread
DeJeune marked this conversation as resolved.
// Note: lmstudio doesn't support Anthropic API format, only ollama does.
const localProvidersWithoutApiKey: readonly string[] = ['ollama', 'lmstudio'] satisfies SystemProviderId[]

for (const [field, rawValue] of entries) {
if (rawValue === undefined || rawValue === null) {
continue
Expand All @@ -304,15 +313,22 @@ export abstract class BaseService {
throw new AgentModelValidationError({ agentType, field, model: modelValue }, detail)
}

const requiresApiKey = !localProvidersWithoutApiKey.includes(validation.provider.id)

if (!validation.provider.apiKey) {
throw new AgentModelValidationError(
{ agentType, field, model: modelValue },
{
type: 'invalid_format',
message: `Provider '${validation.provider.id}' is missing an API key`,
code: 'provider_api_key_missing'
}
)
if (requiresApiKey) {
throw new AgentModelValidationError(
{ agentType, field, model: modelValue },
{
type: 'invalid_format',
message: `Provider '${validation.provider.id}' is missing an API key`,
code: 'provider_api_key_missing'
}
)
} else {
// Use provider id as placeholder API key for providers that don't require one
validation.provider.apiKey = validation.provider.id
Comment thread
DeJeune marked this conversation as resolved.
Comment thread
DeJeune marked this conversation as resolved.
}
}
}
}
Expand Down
63 changes: 61 additions & 2 deletions src/main/services/agents/tests/BaseService.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import type { Tool } from '@types'
import type { AgentType, Tool } from '@types'
import { describe, expect, it, vi } from 'vitest'

import type { AgentModelField } from '../errors'

vi.mock('@main/apiServer/services/mcp', () => ({
mcpApiService: {
getServerInfo: vi.fn()
}
}))

const mockValidateModelId = vi.fn()
vi.mock('@main/apiServer/utils', () => ({
validateModelId: vi.fn()
validateModelId: (...args: unknown[]) => mockValidateModelId(...args)
}))

import { BaseService } from '../BaseService'
import { AgentModelValidationError } from '../errors'

class TestBaseService extends BaseService {
public normalize(
Expand All @@ -21,6 +25,13 @@ class TestBaseService extends BaseService {
): string[] | undefined {
return this.normalizeAllowedTools(allowedTools, tools, legacyIdMap)
}

public async validateModels(
agentType: AgentType,
models: Partial<Record<AgentModelField, string | undefined>>
): Promise<void> {
return this.validateAgentModels(agentType, models)
}
}

const buildMcpTool = (id: string): Tool => ({
Expand Down Expand Up @@ -89,3 +100,51 @@ describe('BaseService.normalizeAllowedTools', () => {
expect(service.normalize(allowedTools, tools)).toEqual(allowedTools)
})
})

describe('BaseService.validateAgentModels', () => {
const service = new TestBaseService()

it('throws error when regular provider is missing API key', async () => {
mockValidateModelId.mockResolvedValue({
valid: true,
provider: { id: 'openai', apiKey: '' }
})

await expect(service.validateModels('claude-code', { model: 'openai:gpt-4' })).rejects.toThrow(
AgentModelValidationError
)
})

it('does not throw for ollama provider without API key and sets placeholder', async () => {
const provider = { id: 'ollama', apiKey: '' }
mockValidateModelId.mockResolvedValue({
valid: true,
provider
})

await expect(service.validateModels('claude-code', { model: 'ollama:llama3' })).resolves.not.toThrow()
expect(provider.apiKey).toBe('ollama')
})

it('does not throw for lmstudio provider without API key and sets placeholder', async () => {
const provider = { id: 'lmstudio', apiKey: '' }
mockValidateModelId.mockResolvedValue({
valid: true,
provider
})

await expect(service.validateModels('claude-code', { model: 'lmstudio:model' })).resolves.not.toThrow()
expect(provider.apiKey).toBe('lmstudio')
})

it('does not modify API key when provider already has one', async () => {
const provider = { id: 'openai', apiKey: 'sk-existing-key' }
mockValidateModelId.mockResolvedValue({
valid: true,
provider
})

await expect(service.validateModels('claude-code', { model: 'openai:gpt-4' })).resolves.not.toThrow()
expect(provider.apiKey).toBe('sk-existing-key')
Comment thread
DeJeune marked this conversation as resolved.
})
})
1 change: 1 addition & 0 deletions src/renderer/src/config/providers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ export const SYSTEM_PROVIDERS_CONFIG: Record<SystemProviderId, SystemProvider> =
type: 'ollama',
apiKey: '',
apiHost: 'http://localhost:11434',
anthropicApiHost: 'http://localhost:11434',
models: SYSTEM_MODELS.ollama,
isSystem: true,
enabled: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ const ANTHROPIC_COMPATIBLE_PROVIDER_IDS = [
SystemProviderIds.dmxapi,
SystemProviderIds.mimo,
SystemProviderIds.openrouter,
SystemProviderIds.tokenflux
SystemProviderIds.tokenflux,
SystemProviderIds.ollama
] as const
type AnthropicCompatibleProviderId = (typeof ANTHROPIC_COMPATIBLE_PROVIDER_IDS)[number]

Expand Down
9 changes: 9 additions & 0 deletions src/renderer/src/store/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3216,6 +3216,7 @@ const migrateConfig = {
if (state.paintings && !state.paintings.ppio_edit) {
state.paintings.ppio_edit = []
}
logger.info('migrate 196 success')
return state
} catch (error) {
logger.error('migrate 196 error', error as Error)
Expand All @@ -3227,6 +3228,7 @@ const migrateConfig = {
if (state.openclaw.gatewayPort === 18789) {
state.openclaw.gatewayPort = 18790
}
logger.info('migrate 197 success')
return state
} catch (error) {
logger.error('migrate 197 error', error as Error)
Expand Down Expand Up @@ -3258,6 +3260,12 @@ const migrateConfig = {
},
'200': (state: RootState) => {
try {
state.llm.providers.forEach((provider) => {
if (provider.type === 'ollama') {
provider.anthropicApiHost = provider.apiHost || 'http://localhost:11434'
}
})

// Migrate minimax app id to hailuo
if (state.minapps) {
const lists: Array<'enabled' | 'disabled' | 'pinned'> = ['enabled', 'disabled', 'pinned']
Expand All @@ -3280,6 +3288,7 @@ const migrateConfig = {
provider.type = 'openai-response'
}
})

return state
} catch (error) {
logger.error('migrate 200 error', error as Error)
Expand Down
Loading