Skip to content
Open
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
199 changes: 199 additions & 0 deletions scripts/orcarouter-live-test.mjs
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions src/renderer/components/icons/ProviderIcon.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -96,6 +97,9 @@ export default function ProviderIcon(props: { className?: string; size?: number;
<path d="M16.804 1.957l7.22 4.105v.087L16.73 10.21l.017-2.117-.821-.03c-1.059-.028-1.611.002-2.268.11-1.064.175-2.038.577-3.147 1.352L8.345 11.03c-.284.195-.495.336-.68.455l-.515.322-.397.234.385.23.53.338c.476.314 1.17.796 2.701 1.866 1.11.775 2.083 1.177 3.147 1.352l.3.045c.694.091 1.375.094 2.825.033l.022-2.159 7.22 4.105v.087L16.589 22l.014-1.862-.635.022c-1.386.042-2.137.002-3.138-.162-1.694-.28-3.26-.926-4.881-2.059l-2.158-1.5a21.997 21.997 0 00-.755-.498l-.467-.28a55.927 55.927 0 00-.76-.43C2.908 14.73.563 14.116 0 14.116V9.888l.14.004c.564-.007 2.91-.622 3.809-1.124l1.016-.58.438-.274c.428-.28 1.072-.726 2.686-1.853 1.621-1.133 3.186-1.78 4.881-2.059 1.152-.19 1.974-.213 3.814-.138l.02-1.907z"></path>
</>
)}
{provider === ModelProviderEnum.OrcaRouter && (
<image href={orcarouterLogo} x="0" y="0" width="24" height="24" preserveAspectRatio="xMidYMid meet" />
)}
{provider === ModelProviderEnum.MistralAI && (
<>
<path d="M17.143 3.429v3.428h-3.429v3.429h-3.428V6.857H6.857V3.43H3.43v13.714H0v3.428h10.286v-3.428H6.857v-3.429h3.429v3.429h3.429v-3.429h3.428v3.429h-3.428v3.428H24v-3.428h-3.43V3.429z"/>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/shared/model-registry/provider-mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const PROVIDER_ID_MAP: Record<string, string> = {
'mistral-ai': 'mistral',
perplexity: 'perplexity',
openrouter: 'openrouter',
orcarouter: 'orcarouter',
minimax: 'minimax',
'minimax-cn': 'minimax-cn',
moonshot: 'moonshotai',
Expand Down
65 changes: 65 additions & 0 deletions src/shared/providers/definitions/models/orcarouter.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
53 changes: 53 additions & 0 deletions src/shared/providers/definitions/models/orcarouter.ts
Original file line number Diff line number Diff line change
@@ -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<Options, 'apiHost'>, 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' }),
})
}
}
Loading