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', }