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.jp.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,10 @@ DeepChatはメッセージアプリからリモート操作できるため、デ
<img src="./src/renderer/src/assets/llm-icons/aistupidlevel.svg" width="50" height="50" alt="AI Stupid Level Icon"><br/>
<a href="https://aistupidlevel.info/">AI Stupid Level</a>
</td>
<td>
<img src="./src/renderer/src/assets/llm-icons/runcrate.svg" width="50" height="50" alt="Runcrate Icon"><br/>
<a href="https://www.runcrate.ai/?utm_source=deepchat">Runcrate</a>
</td>
</tr>

</table>
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,10 @@ Common commands include `/start`, `/help`, `/pair`, `/new`, `/sessions`, `/use`,
<img src="./src/renderer/src/assets/llm-icons/aistupidlevel.svg" width="50" height="50" alt="AI Stupid Level Icon"><br/>
<a href="https://aistupidlevel.info/">AI Stupid Level</a>
</td>
<td>
<img src="./src/renderer/src/assets/llm-icons/runcrate.svg" width="50" height="50" alt="Runcrate Icon"><br/>
<a href="https://www.runcrate.ai/?utm_source=deepchat">Runcrate</a>
</td>
</tr>

</table>
Expand Down
4 changes: 4 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,10 @@ DeepChat 可以通过聊天软件远程控制,让你离开桌面后也能继
<img src="./src/renderer/src/assets/llm-icons/aistupidlevel.svg" width="50" height="50" alt="AI Stupid Level Icon"><br/>
<a href="https://aistupidlevel.info/">AI Stupid Level</a>
</td>
<td>
<img src="./src/renderer/src/assets/llm-icons/runcrate.svg" width="50" height="50" alt="Runcrate Icon"><br/>
<a href="https://www.runcrate.ai/?utm_source=deepchat">Runcrate</a>
</td>
</tr>

</table>
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 @@ -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',
Expand Down
14 changes: 14 additions & 0 deletions src/main/provider/providerRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type AiSdkBehaviorPreset =
export type AiSdkModelSourceStrategy =
| 'openai'
| 'apimodels'
| 'runcrate'
| 'apimart'
| 'openai-codex'
| 'opencode-go'
Expand Down Expand Up @@ -611,6 +612,19 @@ const PROVIDER_ID_REGISTRY = new Map<string, AiSdkProviderDefinition>([
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({
Expand Down
45 changes: 45 additions & 0 deletions src/main/provider/providers/aiSdkProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1782,6 +1782,8 @@ export class AiSdkProvider extends BaseLLMProvider {
}
return models
}
case 'runcrate':
return this.fetchRuncrateModels()
case 'bedrock':
return this.fetchBedrockModels()
case 'new-api':
Expand Down Expand Up @@ -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<MODEL_META[]> {
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<MODEL_META[]> {
const provider = this.provider as AWS_BEDROCK_PROVIDER
const credential = provider.credential
Expand Down
1 change: 1 addition & 0 deletions src/renderer/src/assets/llm-icons/runcrate.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src/renderer/src/components/icons/modelIconRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -200,6 +201,7 @@ export const modelIcons = {
litellm: litellmColorIcon,
opper: opperColorIcon,
aistupidlevel: aistupidlevelIcon,
runcrate: runcrateIcon,
demonroute: demonrouteColorIcon,
orcarouter: orcarouterColorIcon,
synthorai: synthoraiColorIcon,
Expand Down Expand Up @@ -282,6 +284,7 @@ const monoIconUrls = new Set<string>([
atlascloudIcon,
opperColorIcon,
aistupidlevelIcon,
runcrateIcon,
demonrouteColorIcon,
githubColorIcon,
qiniuIcon,
Expand Down
85 changes: 85 additions & 0 deletions test/main/provider/basicApiKeyProviders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
17 changes: 17 additions & 0 deletions test/main/provider/defaultProviders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down