diff --git a/scripts/orcarouter-live-test.mjs b/scripts/orcarouter-live-test.mjs
new file mode 100644
index 0000000000..b45538270d
--- /dev/null
+++ b/scripts/orcarouter-live-test.mjs
@@ -0,0 +1,199 @@
+#!/usr/bin/env node
+/**
+ * OrcaRouter live integration test.
+ *
+ * Drives the OrcaRouter HTTP API the same way the Chatbox `OrcaRouter`
+ * model class does at runtime: OpenAI-compatible `/chat/completions`
+ * against `https://api.orcarouter.ai/v1` with `HTTP-Referer` and `X-Title`
+ * attribution headers.
+ *
+ * Get an API key at https://www.orcarouter.ai/console (format: sk-orca-...).
+ *
+ * Usage (bash / zsh):
+ * ORCAROUTER_API_KEY=sk-orca-xxx node scripts/orcarouter-live-test.mjs
+ *
+ * Usage (PowerShell on Windows):
+ * $env:ORCAROUTER_API_KEY = 'sk-orca-xxx'
+ * node scripts/orcarouter-live-test.mjs
+ *
+ * Usage (cmd.exe on Windows):
+ * set ORCAROUTER_API_KEY=sk-orca-xxx
+ * node scripts/orcarouter-live-test.mjs
+ *
+ * Covers:
+ * 1. Non-stream chat with orcarouter/auto
+ * 2. Streaming chat with openai/gpt-5.5
+ * 3. Reasoning model (anthropic/claude-opus-4.7) — no temperature
+ * 4. Invalid API key → 401 path
+ * 5. Invalid model → error path
+ * 6. /v1/models discovery endpoint
+ */
+
+const API_BASE = process.env.ORCAROUTER_API_BASE_URL || 'https://api.orcarouter.ai/v1'
+const API_KEY = process.env.ORCAROUTER_API_KEY
+
+if (!API_KEY) {
+ console.error('ERROR: set ORCAROUTER_API_KEY (get one at https://www.orcarouter.ai/console)')
+ process.exit(1)
+}
+
+const baseHeaders = {
+ Authorization: `Bearer ${API_KEY}`,
+ 'Content-Type': 'application/json',
+ 'HTTP-Referer': 'https://www.orcarouter.ai/',
+ 'X-Title': 'Chatbox',
+}
+
+const results = []
+
+function pass(name, info) {
+ results.push({ name, ok: true, info })
+ console.log(`PASS ${name}${info ? ' — ' + info : ''}`)
+}
+
+function fail(name, err) {
+ results.push({ name, ok: false, info: String(err) })
+ console.log(`FAIL ${name} — ${err}`)
+}
+
+async function chat({ model, messages, stream = false, extraBody = {} }) {
+ const res = await fetch(`${API_BASE}/chat/completions`, {
+ method: 'POST',
+ headers: baseHeaders,
+ body: JSON.stringify({ model, messages, stream, ...extraBody }),
+ })
+ return res
+}
+
+async function caseNonStream() {
+ const name = '1. non-stream chat (orcarouter/auto)'
+ try {
+ const res = await chat({
+ model: 'orcarouter/auto',
+ messages: [{ role: 'user', content: 'Reply with the single word: ready' }],
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`)
+ const json = await res.json()
+ const text = json.choices?.[0]?.message?.content ?? ''
+ pass(name, `model=${json.model || '?'}; reply="${text.trim().slice(0, 60)}"`)
+ } catch (err) {
+ fail(name, err.message || err)
+ }
+}
+
+async function caseStream() {
+ const name = '2. streaming chat (openai/gpt-5.5)'
+ try {
+ const res = await chat({
+ model: 'openai/gpt-5.5',
+ messages: [{ role: 'user', content: 'Count 1 to 3 separated by commas.' }],
+ stream: true,
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`)
+ const reader = res.body.getReader()
+ const decoder = new TextDecoder()
+ let chunks = 0
+ let buffer = ''
+ let collected = ''
+ while (true) {
+ const { value, done } = await reader.read()
+ if (done) break
+ chunks++
+ buffer += decoder.decode(value, { stream: true })
+ for (const line of buffer.split('\n')) {
+ if (line.startsWith('data: ') && !line.includes('[DONE]')) {
+ try {
+ const obj = JSON.parse(line.slice(6))
+ collected += obj.choices?.[0]?.delta?.content ?? ''
+ } catch {}
+ }
+ }
+ buffer = buffer.endsWith('\n') ? '' : buffer.split('\n').pop()
+ }
+ if (chunks < 2) throw new Error(`only got ${chunks} chunk(s), expected stream`)
+ pass(name, `chunks=${chunks}; collected="${collected.trim().slice(0, 60)}"`)
+ } catch (err) {
+ fail(name, err.message || err)
+ }
+}
+
+async function caseReasoning() {
+ const name = '3. reasoning model (anthropic/claude-opus-4.7) without temperature'
+ try {
+ const res = await chat({
+ model: 'anthropic/claude-opus-4.7',
+ messages: [{ role: 'user', content: 'What is 2+2? Reply with just the number.' }],
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`)
+ const json = await res.json()
+ const text = json.choices?.[0]?.message?.content ?? ''
+ pass(name, `reply="${text.trim().slice(0, 60)}"`)
+ } catch (err) {
+ fail(name, err.message || err)
+ }
+}
+
+async function caseBadKey() {
+ const name = '4. invalid API key → 401'
+ try {
+ const res = await fetch(`${API_BASE}/chat/completions`, {
+ method: 'POST',
+ headers: { ...baseHeaders, Authorization: 'Bearer sk-orca-invalid-123' },
+ body: JSON.stringify({
+ model: 'orcarouter/auto',
+ messages: [{ role: 'user', content: 'hi' }],
+ }),
+ })
+ if (res.status === 401) {
+ pass(name, `HTTP 401 as expected`)
+ } else {
+ fail(name, `expected 401, got ${res.status}`)
+ }
+ } catch (err) {
+ fail(name, err.message || err)
+ }
+}
+
+async function caseBadModel() {
+ const name = '5. invalid model → error'
+ try {
+ const res = await chat({
+ model: 'does-not-exist/model-x',
+ messages: [{ role: 'user', content: 'hi' }],
+ })
+ if (!res.ok) {
+ pass(name, `HTTP ${res.status} as expected`)
+ } else {
+ fail(name, `expected error, got 200`)
+ }
+ } catch (err) {
+ fail(name, err.message || err)
+ }
+}
+
+async function caseListModels() {
+ const name = '6. GET /v1/models'
+ try {
+ const res = await fetch(`${API_BASE}/models`, {
+ method: 'GET',
+ headers: { Authorization: `Bearer ${API_KEY}` },
+ })
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ const json = await res.json()
+ const count = Array.isArray(json.data) ? json.data.length : 0
+ if (count === 0) throw new Error('empty data array')
+ pass(name, `${count} models listed`)
+ } catch (err) {
+ fail(name, err.message || err)
+ }
+}
+
+const cases = [caseNonStream, caseStream, caseReasoning, caseBadKey, caseBadModel, caseListModels]
+
+console.log(`OrcaRouter live test — base=${API_BASE}`)
+console.log('')
+for (const c of cases) await c()
+console.log('')
+const passed = results.filter((r) => r.ok).length
+console.log(`Summary: ${passed}/${results.length} passed`)
+process.exit(passed === results.length ? 0 : 1)
diff --git a/src/renderer/components/icons/ProviderIcon.tsx b/src/renderer/components/icons/ProviderIcon.tsx
index 11886cf5ae..0fcf642d82 100644
--- a/src/renderer/components/icons/ProviderIcon.tsx
+++ b/src/renderer/components/icons/ProviderIcon.tsx
@@ -1,4 +1,5 @@
import { type ModelProvider, ModelProviderEnum } from '@shared/types';
+import orcarouterLogo from '../../static/icons/providers/orcarouter.png';
export default function ProviderIcon(props: { className?: string; size?: number; provider: ModelProvider | string }) {
const { className, size = 24, provider } = props
@@ -96,6 +97,9 @@ export default function ProviderIcon(props: { className?: string; size?: number;
>
)}
+ {provider === ModelProviderEnum.OrcaRouter && (
+
+ )}
{provider === ModelProviderEnum.MistralAI && (
<>
diff --git a/src/renderer/static/icons/providers/orcarouter.png b/src/renderer/static/icons/providers/orcarouter.png
new file mode 100644
index 0000000000..51cfc3148f
Binary files /dev/null and b/src/renderer/static/icons/providers/orcarouter.png differ
diff --git a/src/shared/model-registry/provider-mapping.ts b/src/shared/model-registry/provider-mapping.ts
index b347ff4038..a8b36b49f8 100644
--- a/src/shared/model-registry/provider-mapping.ts
+++ b/src/shared/model-registry/provider-mapping.ts
@@ -17,6 +17,7 @@ export const PROVIDER_ID_MAP: Record = {
'mistral-ai': 'mistral',
perplexity: 'perplexity',
openrouter: 'openrouter',
+ orcarouter: 'orcarouter',
minimax: 'minimax',
'minimax-cn': 'minimax-cn',
moonshot: 'moonshotai',
diff --git a/src/shared/providers/definitions/models/orcarouter.test.ts b/src/shared/providers/definitions/models/orcarouter.test.ts
new file mode 100644
index 0000000000..634d44d86c
--- /dev/null
+++ b/src/shared/providers/definitions/models/orcarouter.test.ts
@@ -0,0 +1,65 @@
+import type { ModelDependencies } from '@shared/types/adapters'
+import type { ProviderModelInfo } from '@shared/types/settings'
+import type { SentryScope } from '@shared/utils/sentry_adapter'
+import { describe, expect, it, vi } from 'vitest'
+import OrcaRouter from './orcarouter'
+
+function createDependencies(): ModelDependencies {
+ return {
+ request: {
+ apiRequest: vi.fn(),
+ fetchWithOptions: vi.fn(),
+ },
+ storage: {
+ saveImage: vi.fn(),
+ getImage: vi.fn(),
+ },
+ sentry: {
+ captureException: vi.fn(),
+ withScope: vi.fn((callback: (scope: SentryScope) => void) =>
+ callback({
+ setTag: vi.fn(),
+ setExtra: vi.fn(),
+ })
+ ),
+ },
+ getRemoteConfig: vi.fn().mockReturnValue({ setting_chatboxai_first: false }),
+ }
+}
+
+function createOrcaRouter(modelId = 'orcarouter/auto') {
+ const model: ProviderModelInfo = { modelId, type: 'chat' }
+ return new OrcaRouter(
+ {
+ apiKey: 'sk-orca-test',
+ model,
+ temperature: 0.7,
+ topP: 0.9,
+ maxOutputTokens: 1024,
+ stream: true,
+ },
+ createDependencies()
+ )
+}
+
+describe('OrcaRouter model', () => {
+ it('pins apiHost to https://api.orcarouter.ai/v1', () => {
+ const o = createOrcaRouter()
+ expect(o.options.apiHost).toBe('https://api.orcarouter.ai/v1')
+ })
+
+ it('exposes the provider name OrcaRouter', () => {
+ const o = createOrcaRouter()
+ expect(o.name).toBe('OrcaRouter')
+ })
+
+ it('passes constructor options through to the model class', () => {
+ const o = createOrcaRouter('openai/gpt-5.5')
+ expect(o.options.apiKey).toBe('sk-orca-test')
+ expect(o.options.model.modelId).toBe('openai/gpt-5.5')
+ expect(o.options.temperature).toBe(0.7)
+ expect(o.options.topP).toBe(0.9)
+ expect(o.options.maxOutputTokens).toBe(1024)
+ expect(o.options.stream).toBe(true)
+ })
+})
diff --git a/src/shared/providers/definitions/models/orcarouter.ts b/src/shared/providers/definitions/models/orcarouter.ts
new file mode 100644
index 0000000000..fbee44a8ff
--- /dev/null
+++ b/src/shared/providers/definitions/models/orcarouter.ts
@@ -0,0 +1,53 @@
+import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
+import { extractReasoningMiddleware, wrapLanguageModel } from 'ai'
+import OpenAICompatible, { type OpenAICompatibleSettings } from '../../../models/openai-compatible'
+import { createFetchWithProxy } from '../../../models/utils/fetch-proxy'
+import type { ModelDependencies } from '../../../types/adapters'
+
+interface Options extends OpenAICompatibleSettings {}
+
+export default class OrcaRouter extends OpenAICompatible {
+ public name = 'OrcaRouter'
+ public options: Options
+
+ constructor(options: Omit, dependencies: ModelDependencies) {
+ const apiHost = 'https://api.orcarouter.ai/v1'
+ super(
+ {
+ apiKey: options.apiKey,
+ apiHost,
+ model: options.model,
+ temperature: options.temperature,
+ topP: options.topP,
+ maxOutputTokens: options.maxOutputTokens,
+ stream: options.stream,
+ },
+ dependencies
+ )
+ this.options = {
+ ...options,
+ apiHost,
+ }
+ }
+
+ protected getProvider() {
+ return createOpenAICompatible({
+ name: this.name,
+ apiKey: this.options.apiKey,
+ baseURL: this.options.apiHost,
+ headers: {
+ 'HTTP-Referer': 'https://www.orcarouter.ai/',
+ 'X-Title': 'Chatbox',
+ },
+ fetch: createFetchWithProxy(this.options.useProxy, this.dependencies),
+ })
+ }
+
+ protected getChatModel() {
+ const provider = this.getProvider()
+ return wrapLanguageModel({
+ model: provider.languageModel(this.options.model.modelId),
+ middleware: extractReasoningMiddleware({ tagName: 'think' }),
+ })
+ }
+}
diff --git a/src/shared/providers/definitions/orcarouter.ts b/src/shared/providers/definitions/orcarouter.ts
new file mode 100644
index 0000000000..ba305ee2f8
--- /dev/null
+++ b/src/shared/providers/definitions/orcarouter.ts
@@ -0,0 +1,104 @@
+import { ModelProviderEnum, ModelProviderType } from '../../types'
+import { defineProvider } from '../registry'
+import OrcaRouter from './models/orcarouter'
+
+export const orcaRouterProvider = defineProvider({
+ id: ModelProviderEnum.OrcaRouter,
+ name: 'OrcaRouter',
+ type: ModelProviderType.OpenAI,
+ modelsDevProviderId: 'orcarouter',
+ curatedModelIds: [
+ 'orcarouter/auto',
+ 'openai/gpt-5.5',
+ 'google/gemini-3.5-flash',
+ 'anthropic/claude-opus-4.7',
+ 'grok/grok-4.3',
+ 'deepseek/deepseek-v4-pro',
+ 'minimax/minimax-m2.7',
+ 'qwen/qwen3.7-max',
+ ],
+ urls: {
+ website: 'https://www.orcarouter.ai/',
+ apiKey: 'https://www.orcarouter.ai/console',
+ docs: 'https://docs.orcarouter.ai',
+ models: 'https://www.orcarouter.ai/models',
+ },
+ defaultSettings: {
+ apiHost: 'https://api.orcarouter.ai/v1',
+ models: [
+ {
+ modelId: 'orcarouter/auto',
+ nickname: 'OrcaRouter Auto',
+ capabilities: ['tool_use', 'vision'],
+ contextWindow: 128_000,
+ maxOutput: 16_384,
+ },
+ {
+ modelId: 'openai/gpt-5.5',
+ nickname: 'GPT-5.5',
+ capabilities: ['tool_use', 'reasoning', 'vision'],
+ contextWindow: 400_000,
+ maxOutput: 128_000,
+ },
+ {
+ modelId: 'google/gemini-3.5-flash',
+ nickname: 'Gemini 3.5 Flash',
+ capabilities: ['tool_use', 'reasoning', 'vision'],
+ contextWindow: 1_048_576,
+ maxOutput: 65_536,
+ },
+ {
+ modelId: 'anthropic/claude-opus-4.7',
+ nickname: 'Claude Opus 4.7',
+ capabilities: ['tool_use', 'reasoning', 'vision'],
+ contextWindow: 1_000_000,
+ maxOutput: 128_000,
+ },
+ {
+ modelId: 'grok/grok-4.3',
+ nickname: 'Grok 4.3',
+ capabilities: ['tool_use', 'reasoning'],
+ contextWindow: 256_000,
+ maxOutput: 64_000,
+ },
+ {
+ modelId: 'deepseek/deepseek-v4-pro',
+ nickname: 'DeepSeek V4 Pro',
+ capabilities: ['tool_use', 'reasoning'],
+ contextWindow: 163_840,
+ maxOutput: 163_840,
+ },
+ {
+ modelId: 'minimax/minimax-m2.7',
+ nickname: 'MiniMax M2.7',
+ capabilities: ['tool_use', 'reasoning'],
+ contextWindow: 204_800,
+ maxOutput: 131_072,
+ },
+ {
+ modelId: 'qwen/qwen3.7-max',
+ nickname: 'Qwen 3.7 Max',
+ capabilities: ['tool_use', 'reasoning'],
+ contextWindow: 1_048_576,
+ maxOutput: 65_536,
+ },
+ ],
+ },
+ createModel: (config) => {
+ return new OrcaRouter(
+ {
+ apiKey: config.providerSetting.apiKey || '',
+ model: config.model,
+ temperature: config.settings.temperature,
+ topP: config.settings.topP,
+ maxOutputTokens: config.settings.maxTokens,
+ stream: config.settings.stream,
+ },
+ config.dependencies
+ )
+ },
+ getDisplayName: (modelId, providerSettings) => {
+ const nickname = providerSettings?.models?.find((m) => m.modelId === modelId)?.nickname
+ return `OrcaRouter (${nickname || modelId})`
+ },
+})
diff --git a/src/shared/providers/index.ts b/src/shared/providers/index.ts
index ebfa92a3ea..250426b360 100644
--- a/src/shared/providers/index.ts
+++ b/src/shared/providers/index.ts
@@ -17,6 +17,7 @@ import './definitions/minimax'
import './definitions/moonshot'
import './definitions/siliconflow'
import './definitions/openrouter'
+import './definitions/orcarouter'
import './definitions/ollama'
import './definitions/lmstudio'
import './definitions/azure'
diff --git a/src/shared/types/provider.ts b/src/shared/types/provider.ts
index 85eb01d760..387b84c9e1 100644
--- a/src/shared/types/provider.ts
+++ b/src/shared/types/provider.ts
@@ -25,6 +25,7 @@ export enum ModelProviderEnum {
Perplexity = 'perplexity',
XAI = 'xAI',
OpenRouter = 'openrouter',
+ OrcaRouter = 'orcarouter',
Bedrock = 'bedrock',
Custom = 'custom',
}