From 9621a9354b7b2223ac8bae01e1b9e129e5d446b2 Mon Sep 17 00:00:00 2001 From: Nirupam Bhowmick <48842933+heydryft@users.noreply.github.com> Date: Sun, 25 Oct 2026 07:28:58 +0000 Subject: [PATCH 1/4] feat(provider): add Runcrate --- README.jp.md | 4 +++ README.md | 4 +++ README.zh.md | 4 +++ src/main/provider/defaults.ts | 15 +++++++++ src/main/provider/providerRegistry.ts | 12 +++++++ .../src/assets/llm-icons/runcrate.svg | 1 + .../src/components/icons/modelIconRegistry.ts | 3 ++ .../provider/basicApiKeyProviders.test.ts | 33 +++++++++++++++++++ test/main/provider/defaultProviders.test.ts | 17 ++++++++++ 9 files changed, 93 insertions(+) create mode 100644 src/renderer/src/assets/llm-icons/runcrate.svg diff --git a/README.jp.md b/README.jp.md index 33efe2ee3..4aab4223b 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 689bb8012..1f789e270 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 7e6cb1ed2..6780f2989 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 51b6f586e..d8657bdf6 100644 --- a/src/main/provider/defaults.ts +++ b/src/main/provider/defaults.ts @@ -650,6 +650,21 @@ 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&utm_medium=directory&utm_campaign=gpu_pricing', + apiKey: 'https://www.runcrate.ai/dashboard/api-keys', + docs: 'https://www.runcrate.ai/docs', + models: 'https://www.runcrate.ai/inference' + } + }, { id: 'aistupidlevel', name: 'AI Stupid Level', diff --git a/src/main/provider/providerRegistry.ts b/src/main/provider/providerRegistry.ts index 561e1d1a2..8174243f9 100644 --- a/src/main/provider/providerRegistry.ts +++ b/src/main/provider/providerRegistry.ts @@ -611,6 +611,18 @@ const PROVIDER_ID_REGISTRY = new Map([ credentialStrategy: 'api-key' }) ], + [ + 'runcrate', + createDefinition({ + ...OPENAI_BASE, + 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/renderer/src/assets/llm-icons/runcrate.svg b/src/renderer/src/assets/llm-icons/runcrate.svg new file mode 100644 index 000000000..6d083c98f --- /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 b62ab381c..80dbe4dad 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 9fba0738a..d1087545e 100644 --- a/test/main/provider/basicApiKeyProviders.test.ts +++ b/test/main/provider/basicApiKeyProviders.test.ts @@ -1689,6 +1689,39 @@ 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: 'openai', + 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('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 25cce89c1..103cba18d 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', + docs: 'https://www.runcrate.ai/docs', + 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({ From 60646b22c6acf2d281999d290b6709458f7d10cb Mon Sep 17 00:00:00 2001 From: Nirupam Bhowmick <48842933+heydryft@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:31:17 +0000 Subject: [PATCH 2/4] feat(provider): list only Runcrate text chat models Add a runcrate modelSource that keeps text-in, text-out models from the public /v1/models catalog and maps context_length, max_output_length and supported_features. Use utm_source=deepchat on every Runcrate link. --- README.jp.md | 2 +- README.md | 2 +- README.zh.md | 2 +- src/main/provider/defaults.ts | 9 ++-- src/main/provider/providerRegistry.ts | 2 + src/main/provider/providers/aiSdkProvider.ts | 49 +++++++++++++++++ .../provider/basicApiKeyProviders.test.ts | 53 ++++++++++++++++++- test/main/provider/defaultProviders.test.ts | 4 +- 8 files changed, 112 insertions(+), 11 deletions(-) diff --git a/README.jp.md b/README.jp.md index 4aab4223b..0d9574a12 100644 --- a/README.jp.md +++ b/README.jp.md @@ -462,7 +462,7 @@ DeepChatはメッセージアプリからリモート操作できるため、デ Runcrate Icon
- Runcrate + Runcrate diff --git a/README.md b/README.md index 1f789e270..cdac50c61 100644 --- a/README.md +++ b/README.md @@ -465,7 +465,7 @@ Common commands include `/start`, `/help`, `/pair`, `/new`, `/sessions`, `/use`, Runcrate Icon
- Runcrate + Runcrate diff --git a/README.zh.md b/README.zh.md index 6780f2989..c0d31e190 100644 --- a/README.zh.md +++ b/README.zh.md @@ -459,7 +459,7 @@ DeepChat 可以通过聊天软件远程控制,让你离开桌面后也能继 Runcrate Icon
- Runcrate + Runcrate diff --git a/src/main/provider/defaults.ts b/src/main/provider/defaults.ts index d8657bdf6..58c09115f 100644 --- a/src/main/provider/defaults.ts +++ b/src/main/provider/defaults.ts @@ -658,11 +658,10 @@ const PROVIDER_DEFAULTS: LLM_PROVIDER_BASE[] = [ baseUrl: 'https://api.runcrate.ai/v1', enable: false, websites: { - official: - 'https://www.runcrate.ai/?utm_source=deepchat&utm_medium=directory&utm_campaign=gpu_pricing', - apiKey: 'https://www.runcrate.ai/dashboard/api-keys', - docs: 'https://www.runcrate.ai/docs', - models: 'https://www.runcrate.ai/inference' + 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' } }, { diff --git a/src/main/provider/providerRegistry.ts b/src/main/provider/providerRegistry.ts index 8174243f9..608aefe28 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' @@ -615,6 +616,7 @@ const PROVIDER_ID_REGISTRY = new Map([ 'runcrate', createDefinition({ ...OPENAI_BASE, + modelSource: 'runcrate', checkStrategy: 'generate-text', credentialStrategy: 'api-key', checkModelId: 'deepseek-ai/DeepSeek-V4.1-Flash', diff --git a/src/main/provider/providers/aiSdkProvider.ts b/src/main/provider/providers/aiSdkProvider.ts index 74eda8628..fb92e34e6 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,53 @@ 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 && !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/test/main/provider/basicApiKeyProviders.test.ts b/test/main/provider/basicApiKeyProviders.test.ts index d1087545e..01e4dd98d 100644 --- a/test/main/provider/basicApiKeyProviders.test.ts +++ b/test/main/provider/basicApiKeyProviders.test.ts @@ -1696,7 +1696,7 @@ describe('basic API-key provider registrations', () => { // Runcrate's /v1/models answers without a key, so listing models cannot validate one. expect(resolveAiSdkProviderDefinition(config)).toMatchObject({ runtimeKind: 'openai-compatible', - modelSource: 'openai', + modelSource: 'runcrate', checkStrategy: 'generate-text', credentialStrategy: 'api-key', checkModelId: 'deepseek-ai/DeepSeek-V4.1-Flash' @@ -1722,6 +1722,57 @@ describe('basic API-key provider registrations', () => { ) }) + 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-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 103cba18d..e0542b587 100644 --- a/test/main/provider/defaultProviders.test.ts +++ b/test/main/provider/defaultProviders.test.ts @@ -143,8 +143,8 @@ describe('DEFAULT_PROVIDERS', () => { baseUrl: 'https://api.runcrate.ai/v1', enable: false, websites: expect.objectContaining({ - apiKey: 'https://www.runcrate.ai/dashboard/api-keys', - docs: 'https://www.runcrate.ai/docs', + 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' }) }) From 84854f5ca5c87d7bdcf29f8a4353928b6891b14c Mon Sep 17 00:00:00 2001 From: Nirupam Bhowmick <48842933+heydryft@users.noreply.github.com> Date: Mon, 26 Oct 2026 08:52:59 +0000 Subject: [PATCH 3/4] fix(provider): require text input for Runcrate chat models --- src/main/provider/providers/aiSdkProvider.ts | 2 +- test/main/provider/basicApiKeyProviders.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/provider/providers/aiSdkProvider.ts b/src/main/provider/providers/aiSdkProvider.ts index fb92e34e6..5d48a2a1c 100644 --- a/src/main/provider/providers/aiSdkProvider.ts +++ b/src/main/provider/providers/aiSdkProvider.ts @@ -2156,7 +2156,7 @@ export class AiSdkProvider extends BaseLLMProvider { if ( !modelId || !outputModalities?.includes('text') || - (inputModalities && !inputModalities.includes('text')) + !inputModalities?.includes('text') ) { continue } diff --git a/test/main/provider/basicApiKeyProviders.test.ts b/test/main/provider/basicApiKeyProviders.test.ts index 01e4dd98d..175615c49 100644 --- a/test/main/provider/basicApiKeyProviders.test.ts +++ b/test/main/provider/basicApiKeyProviders.test.ts @@ -1744,6 +1744,7 @@ describe('basic API-key provider registrations', () => { input_modalities: ['audio'], output_modalities: ['text'] }, + { id: 'missing-input-modalities', output_modalities: ['text'] }, { id: 'missing-modalities' } ] }) From 27323b6363a884b85762f1f9825840621effa355 Mon Sep 17 00:00:00 2001 From: Nirupam Bhowmick <48842933+heydryft@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:29:24 +0000 Subject: [PATCH 4/4] style(provider): format Runcrate catalog guard --- src/main/provider/providers/aiSdkProvider.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/provider/providers/aiSdkProvider.ts b/src/main/provider/providers/aiSdkProvider.ts index 5d48a2a1c..ea30e7bae 100644 --- a/src/main/provider/providers/aiSdkProvider.ts +++ b/src/main/provider/providers/aiSdkProvider.ts @@ -2153,11 +2153,7 @@ export class AiSdkProvider extends BaseLLMProvider { 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') - ) { + if (!modelId || !outputModalities?.includes('text') || !inputModalities?.includes('text')) { continue } const features = toStrings(model.supported_features)