diff --git a/README.jp.md b/README.jp.md index 33efe2ee3b..0d9574a125 100644 --- a/README.jp.md +++ b/README.jp.md @@ -460,6 +460,10 @@ DeepChatはメッセージアプリからリモート操作できるため、デ AI Stupid Level Icon
AI Stupid Level + + Runcrate Icon
+ Runcrate + diff --git a/README.md b/README.md index 689bb80120..cdac50c619 100644 --- a/README.md +++ b/README.md @@ -463,6 +463,10 @@ Common commands include `/start`, `/help`, `/pair`, `/new`, `/sessions`, `/use`, AI Stupid Level Icon
AI Stupid Level + + Runcrate Icon
+ Runcrate + diff --git a/README.zh.md b/README.zh.md index 7e6cb1ed20..c0d31e190e 100644 --- a/README.zh.md +++ b/README.zh.md @@ -457,6 +457,10 @@ DeepChat 可以通过聊天软件远程控制,让你离开桌面后也能继 AI Stupid Level Icon
AI Stupid Level + + Runcrate Icon
+ Runcrate + diff --git a/src/main/provider/defaults.ts b/src/main/provider/defaults.ts index 51b6f586e1..58c09115f5 100644 --- a/src/main/provider/defaults.ts +++ b/src/main/provider/defaults.ts @@ -650,6 +650,20 @@ const PROVIDER_DEFAULTS: LLM_PROVIDER_BASE[] = [ models: 'https://opper.ai/models' } }, + { + id: 'runcrate', + name: 'Runcrate', + apiType: 'openai-completions', + apiKey: '', + baseUrl: 'https://api.runcrate.ai/v1', + enable: false, + websites: { + official: 'https://www.runcrate.ai/?utm_source=deepchat', + apiKey: 'https://www.runcrate.ai/dashboard/api-keys?utm_source=deepchat', + docs: 'https://www.runcrate.ai/docs?utm_source=deepchat', + models: 'https://www.runcrate.ai/inference?utm_source=deepchat' + } + }, { id: 'aistupidlevel', name: 'AI Stupid Level', diff --git a/src/main/provider/providerRegistry.ts b/src/main/provider/providerRegistry.ts index 561e1d1a2e..608aefe28b 100644 --- a/src/main/provider/providerRegistry.ts +++ b/src/main/provider/providerRegistry.ts @@ -12,6 +12,7 @@ export type AiSdkBehaviorPreset = export type AiSdkModelSourceStrategy = | 'openai' | 'apimodels' + | 'runcrate' | 'apimart' | 'openai-codex' | 'opencode-go' @@ -611,6 +612,19 @@ const PROVIDER_ID_REGISTRY = new Map([ credentialStrategy: 'api-key' }) ], + [ + 'runcrate', + createDefinition({ + ...OPENAI_BASE, + modelSource: 'runcrate', + checkStrategy: 'generate-text', + credentialStrategy: 'api-key', + checkModelId: 'deepseek-ai/DeepSeek-V4.1-Flash', + checkPrompt: 'Hello', + checkTemperature: 0.2, + checkMaxTokens: 16 + }) + ], [ 'poe', createDefinition({ diff --git a/src/main/provider/providers/aiSdkProvider.ts b/src/main/provider/providers/aiSdkProvider.ts index 74eda8628b..ea30e7bae6 100644 --- a/src/main/provider/providers/aiSdkProvider.ts +++ b/src/main/provider/providers/aiSdkProvider.ts @@ -1782,6 +1782,8 @@ export class AiSdkProvider extends BaseLLMProvider { } return models } + case 'runcrate': + return this.fetchRuncrateModels() case 'bedrock': return this.fetchBedrockModels() case 'new-api': @@ -2135,6 +2137,49 @@ export class AiSdkProvider extends BaseLLMProvider { return models } + /** + * Runcrate's public catalog also lists image, video, audio and embedding models. + * Keep the text-in, text-out models and read the limits and features it publishes. + */ + private async fetchRuncrateModels(): Promise { + const records = await this.fetchOpenAIModelRecords({ timeout: this.getModelFetchTimeout() }) + const toStrings = (value: unknown): string[] | undefined => + Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : undefined + + const models: MODEL_META[] = [] + for (const model of records) { + const modelId = typeof model.id === 'string' ? model.id.trim() : '' + const outputModalities = toStrings(model.output_modalities) + const inputModalities = toStrings(model.input_modalities) + if (!modelId || !outputModalities?.includes('text') || !inputModalities?.includes('text')) { + continue + } + const features = toStrings(model.supported_features) + const contextLength = toPositiveFiniteNumber(model.context_length) + const maxTokens = toPositiveFiniteNumber(model.max_output_length) + models.push({ + id: modelId, + name: modelId, + group: 'default', + providerId: this.provider.id, + isCustom: false, + type: ModelType.Chat, + ...(contextLength !== undefined ? { contextLength } : {}), + ...(maxTokens !== undefined ? { maxTokens } : {}), + ...(features + ? { functionCall: features.includes('tools'), reasoning: features.includes('reasoning') } + : {}), + ...(inputModalities ? { vision: inputModalities.includes('image') } : {}), + ...(typeof model.owned_by === 'string' && model.owned_by.trim() + ? { ownedBy: model.owned_by.trim() } + : {}) + }) + } + return models + } + private async fetchBedrockModels(): Promise { const provider = this.provider as AWS_BEDROCK_PROVIDER const credential = provider.credential diff --git a/src/renderer/src/assets/llm-icons/runcrate.svg b/src/renderer/src/assets/llm-icons/runcrate.svg new file mode 100644 index 0000000000..6d083c98f5 --- /dev/null +++ b/src/renderer/src/assets/llm-icons/runcrate.svg @@ -0,0 +1 @@ +Runcrate diff --git a/src/renderer/src/components/icons/modelIconRegistry.ts b/src/renderer/src/components/icons/modelIconRegistry.ts index b62ab381cc..80dbe4dad4 100644 --- a/src/renderer/src/components/icons/modelIconRegistry.ts +++ b/src/renderer/src/components/icons/modelIconRegistry.ts @@ -54,6 +54,7 @@ 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 aistupidlevelIcon from '@/assets/llm-icons/aistupidlevel.svg?url' +import runcrateIcon from '@/assets/llm-icons/runcrate.svg?url' import demonrouteColorIcon from '@/assets/llm-icons/demonroute.svg?url' import orcarouterColorIcon from '@/assets/llm-icons/orcarouter.svg?url' import synthoraiColorIcon from '@/assets/llm-icons/synthorai.svg?url' @@ -200,6 +201,7 @@ export const modelIcons = { litellm: litellmColorIcon, opper: opperColorIcon, aistupidlevel: aistupidlevelIcon, + runcrate: runcrateIcon, demonroute: demonrouteColorIcon, orcarouter: orcarouterColorIcon, synthorai: synthoraiColorIcon, @@ -282,6 +284,7 @@ const monoIconUrls = new Set([ atlascloudIcon, opperColorIcon, aistupidlevelIcon, + runcrateIcon, demonrouteColorIcon, githubColorIcon, qiniuIcon, diff --git a/test/main/provider/basicApiKeyProviders.test.ts b/test/main/provider/basicApiKeyProviders.test.ts index 9fba0738a5..175615c496 100644 --- a/test/main/provider/basicApiKeyProviders.test.ts +++ b/test/main/provider/basicApiKeyProviders.test.ts @@ -1689,6 +1689,91 @@ describe('basic API-key provider registrations', () => { ) }) + it('checks Runcrate with a chat model instead of its public model list', async () => { + const defaults = DEFAULT_PROVIDERS.find((provider) => provider.id === 'runcrate')! + expect(defaults).toBeDefined() + const config = { ...defaults, apiKey: 'test-key' } + // Runcrate's /v1/models answers without a key, so listing models cannot validate one. + expect(resolveAiSdkProviderDefinition(config)).toMatchObject({ + runtimeKind: 'openai-compatible', + modelSource: 'runcrate', + checkStrategy: 'generate-text', + credentialStrategy: 'api-key', + checkModelId: 'deepseek-ai/DeepSeek-V4.1-Flash' + }) + const provider = new AiSdkProvider(config, createProviderSettings()) + ;(provider as any).isInitialized = true + + await expect(provider.check()).resolves.toEqual({ isOk: true, errorMsg: null }) + expect(mockRunAiSdkGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + providerKind: 'openai-compatible', + provider: expect.objectContaining({ + id: 'runcrate', + apiKey: 'test-key', + baseUrl: 'https://api.runcrate.ai/v1' + }) + }), + [{ role: 'user', content: 'Hello' }], + 'deepseek-ai/DeepSeek-V4.1-Flash', + expect.any(Object), + 0.2, + 16 + ) + }) + + it('lists only text chat models from the public Runcrate catalog', async () => { + const defaults = DEFAULT_PROVIDERS.find(({ id }) => id === 'runcrate')! + const fetchMock = vi.fn().mockResolvedValue( + Response.json({ + object: 'list', + data: [ + { + id: 'Qwen/Qwen3-Next-80B-A3B-Instruct', + owned_by: 'Qwen', + input_modalities: ['text'], + output_modalities: ['text'], + context_length: 262144, + max_output_length: 235929, + supported_features: ['tools', 'json_mode'] + }, + { id: 'Qwen/Qwen-Image', input_modalities: ['text'], output_modalities: ['image'] }, + { id: 'BAAI/bge-m3', input_modalities: ['text'], output_modalities: ['embeddings'] }, + { + id: 'openai/whisper-large-v3-turbo', + input_modalities: ['audio'], + output_modalities: ['text'] + }, + { id: 'missing-input-modalities', output_modalities: ['text'] }, + { id: 'missing-modalities' } + ] + }) + ) + global.fetch = fetchMock + const provider = new AiSdkProvider(defaults, createProviderSettings()) + + expect(await provider.fetchModels({ suppressErrors: false })).toEqual([ + { + id: 'Qwen/Qwen3-Next-80B-A3B-Instruct', + name: 'Qwen/Qwen3-Next-80B-A3B-Instruct', + group: 'default', + providerId: 'runcrate', + isCustom: false, + type: 'chat', + contextLength: 262144, + maxTokens: 235929, + functionCall: true, + reasoning: false, + vision: false, + ownedBy: 'Qwen' + } + ]) + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.runcrate.ai/v1/models', + expect.objectContaining({ method: 'GET' }) + ) + }) + it('routes OpenCode Go chat completions models through OpenAI-compatible runtime', async () => { const provider = new AiSdkProvider( createProvider({ diff --git a/test/main/provider/defaultProviders.test.ts b/test/main/provider/defaultProviders.test.ts index 25cce89c1b..e0542b587b 100644 --- a/test/main/provider/defaultProviders.test.ts +++ b/test/main/provider/defaultProviders.test.ts @@ -134,6 +134,23 @@ describe('DEFAULT_PROVIDERS', () => { ) }) + it('includes Runcrate as a disabled built-in OpenAI-compatible provider', () => { + expect(DEFAULT_PROVIDERS).toContainEqual( + expect.objectContaining({ + id: 'runcrate', + name: 'Runcrate', + apiType: 'openai-completions', + baseUrl: 'https://api.runcrate.ai/v1', + enable: false, + websites: expect.objectContaining({ + apiKey: 'https://www.runcrate.ai/dashboard/api-keys?utm_source=deepchat', + docs: 'https://www.runcrate.ai/docs?utm_source=deepchat', + defaultBaseUrl: 'https://api.runcrate.ai/v1' + }) + }) + ) + }) + it('includes AI Stupid Level as a disabled built-in OpenAI-compatible provider', () => { expect(DEFAULT_PROVIDERS).toContainEqual( expect.objectContaining({