mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 06:32:24 +00:00
feat(providers): add OrcaRouter as built-in cloud provider (#2187)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
b0fe42b0af
commit
ee34387f2e
@@ -112,6 +112,10 @@ const BUILTIN_PROVIDER_META: Record<string, { tone: string; label: string }> = {
|
||||
label: 'OpenRouter',
|
||||
tone: 'bg-slate-100 dark:bg-slate-500/15 ring-slate-300 text-slate-900 dark:text-slate-100',
|
||||
},
|
||||
orcarouter: {
|
||||
label: 'OrcaRouter',
|
||||
tone: 'bg-sky-50 dark:bg-sky-500/10 ring-sky-200 text-sky-900 dark:text-sky-100',
|
||||
},
|
||||
custom: {
|
||||
label: 'Custom',
|
||||
tone: 'bg-stone-100 dark:bg-neutral-800 ring-stone-300 text-stone-900 dark:text-neutral-100',
|
||||
@@ -548,7 +552,9 @@ const ProviderKeyDialog = ({
|
||||
? 'sk-ant-...'
|
||||
: slug === 'openrouter'
|
||||
? 'sk-or-...'
|
||||
: 'your-api-key';
|
||||
: slug === 'orcarouter'
|
||||
? 'sk-orca-...'
|
||||
: 'your-api-key';
|
||||
|
||||
const fieldLabel = isLocalRuntime ? 'Endpoint URL' : t('settings.ai.apiKeyFieldLabel');
|
||||
const helper = isLocalRuntime
|
||||
@@ -2063,46 +2069,50 @@ const AIPanel = ({ embedded = false }: AIPanelProps = {}) => {
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Built-in cloud providers — openai/anthropic/openrouter/custom */}
|
||||
{(['openai', 'anthropic', 'openrouter', 'custom'] as const).map(slug => {
|
||||
const meta = BUILTIN_PROVIDER_META[slug];
|
||||
const label = meta?.label ?? slug;
|
||||
const existing = draft.cloudProviders.find(cp => cp.slug === slug);
|
||||
const enabled = !!existing;
|
||||
return (
|
||||
<ProviderToggleChip
|
||||
key={slug}
|
||||
slug={slug}
|
||||
label={label}
|
||||
enabled={enabled}
|
||||
busy={busyAction === `toggle-${slug}`}
|
||||
onToggle={() => {
|
||||
if (enabled && existing) {
|
||||
// Toggle OFF: remove the provider + scrub any
|
||||
// routing entries that pin to it.
|
||||
const remaining = draft.cloudProviders.filter(cp => cp.id !== existing.id);
|
||||
const nextRouting = Object.fromEntries(
|
||||
Object.entries(draft.routing).map(([wid, ref]) => [
|
||||
wid,
|
||||
ref.kind === 'cloud' && ref.providerSlug === existing.slug
|
||||
? ({ kind: 'openhuman' } as const)
|
||||
: ref,
|
||||
])
|
||||
) as typeof draft.routing;
|
||||
setDraft({ ...draft, cloudProviders: remaining, routing: nextRouting });
|
||||
} else if (slug === 'custom') {
|
||||
// Custom providers need slug + endpoint + label, not
|
||||
// just an API key — defer to the full editor modal.
|
||||
setEditing('new');
|
||||
} else {
|
||||
// Toggle ON: open the API-key popup. The chip
|
||||
// only flips after the dialog saves.
|
||||
setKeyDialogFor(slug);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{/* Built-in cloud providers — openai/anthropic/openrouter/orcarouter/custom */}
|
||||
{(['openai', 'anthropic', 'openrouter', 'orcarouter', 'custom'] as const).map(
|
||||
slug => {
|
||||
const meta = BUILTIN_PROVIDER_META[slug];
|
||||
const label = meta?.label ?? slug;
|
||||
const existing = draft.cloudProviders.find(cp => cp.slug === slug);
|
||||
const enabled = !!existing;
|
||||
return (
|
||||
<ProviderToggleChip
|
||||
key={slug}
|
||||
slug={slug}
|
||||
label={label}
|
||||
enabled={enabled}
|
||||
busy={busyAction === `toggle-${slug}`}
|
||||
onToggle={() => {
|
||||
if (enabled && existing) {
|
||||
// Toggle OFF: remove the provider + scrub any
|
||||
// routing entries that pin to it.
|
||||
const remaining = draft.cloudProviders.filter(
|
||||
cp => cp.id !== existing.id
|
||||
);
|
||||
const nextRouting = Object.fromEntries(
|
||||
Object.entries(draft.routing).map(([wid, ref]) => [
|
||||
wid,
|
||||
ref.kind === 'cloud' && ref.providerSlug === existing.slug
|
||||
? ({ kind: 'openhuman' } as const)
|
||||
: ref,
|
||||
])
|
||||
) as typeof draft.routing;
|
||||
setDraft({ ...draft, cloudProviders: remaining, routing: nextRouting });
|
||||
} else if (slug === 'custom') {
|
||||
// Custom providers need slug + endpoint + label, not
|
||||
// just an API key — defer to the full editor modal.
|
||||
setEditing('new');
|
||||
} else {
|
||||
// Toggle ON: open the API-key popup. The chip
|
||||
// only flips after the dialog saves.
|
||||
setKeyDialogFor(slug);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)}
|
||||
|
||||
{/* LM Studio + Ollama — local runtimes stored with a slug of
|
||||
"lmstudio" / "ollama" so they're distinct from generic custom. */}
|
||||
@@ -2508,7 +2518,7 @@ const CloudProviderEditor = ({
|
||||
const { t } = useT();
|
||||
const defaultSlug: string =
|
||||
initial?.slug ??
|
||||
(['openai', 'anthropic', 'openrouter', 'custom'] as const).find(
|
||||
(['openai', 'anthropic', 'openrouter', 'orcarouter', 'custom'] as const).find(
|
||||
s => !existingSlugs.includes(s)
|
||||
) ??
|
||||
'custom';
|
||||
@@ -2554,7 +2564,7 @@ const CloudProviderEditor = ({
|
||||
}}
|
||||
disabled={!!initial}
|
||||
className="mt-1 w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 disabled:opacity-60 focus:border-primary-400 focus:outline-none focus:ring-1 focus:ring-primary-200">
|
||||
{(['openai', 'anthropic', 'openrouter', 'custom'] as const)
|
||||
{(['openai', 'anthropic', 'openrouter', 'orcarouter', 'custom'] as const)
|
||||
.filter(s => s === slug || !existingSlugs.includes(s))
|
||||
.map(s => (
|
||||
<option key={s} value={s}>
|
||||
@@ -2669,6 +2679,8 @@ function defaultEndpointFor(slug: string): string {
|
||||
return 'https://api.anthropic.com/v1';
|
||||
case 'openrouter':
|
||||
return 'https://openrouter.ai/api/v1';
|
||||
case 'orcarouter':
|
||||
return 'https://api.orcarouter.ai/v1';
|
||||
case 'ollama':
|
||||
// Ollama exposes an OpenAI-compatible endpoint at /v1; the bare host is
|
||||
// also accepted by the Rust factory (it appends /v1 internally for chat).
|
||||
|
||||
@@ -24,7 +24,13 @@ export interface ModelRoute {
|
||||
export type AuthStyle = 'bearer' | 'anthropic' | 'openhuman_jwt' | 'none';
|
||||
|
||||
/** @deprecated Use AuthStyle. Kept for back-compat with old wire format. */
|
||||
export type CloudProviderType = 'openhuman' | 'openai' | 'anthropic' | 'openrouter' | 'custom';
|
||||
export type CloudProviderType =
|
||||
| 'openhuman'
|
||||
| 'openai'
|
||||
| 'anthropic'
|
||||
| 'openrouter'
|
||||
| 'orcarouter'
|
||||
| 'custom';
|
||||
|
||||
/**
|
||||
* Endpoint config for one cloud LLM provider (new slug-keyed shape).
|
||||
|
||||
@@ -174,6 +174,7 @@ fn legacy_label_for(type_str: &str) -> &'static str {
|
||||
"openai" => "OpenAI",
|
||||
"anthropic" => "Anthropic",
|
||||
"openrouter" => "OpenRouter",
|
||||
"orcarouter" => "OrcaRouter",
|
||||
"custom" => "Custom",
|
||||
_ => "Custom",
|
||||
}
|
||||
@@ -186,6 +187,7 @@ fn legacy_default_endpoint(type_str: &str) -> &'static str {
|
||||
"openai" => "https://api.openai.com/v1",
|
||||
"anthropic" => "https://api.anthropic.com/v1",
|
||||
"openrouter" => "https://openrouter.ai/api/v1",
|
||||
"orcarouter" => "https://api.orcarouter.ai/v1",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
@@ -243,6 +245,7 @@ pub enum CloudProviderType {
|
||||
Openai,
|
||||
Anthropic,
|
||||
Openrouter,
|
||||
Orcarouter,
|
||||
Custom,
|
||||
}
|
||||
|
||||
@@ -254,6 +257,7 @@ impl CloudProviderType {
|
||||
Self::Openai => "https://api.openai.com/v1",
|
||||
Self::Anthropic => "https://api.anthropic.com/v1",
|
||||
Self::Openrouter => "https://openrouter.ai/api/v1",
|
||||
Self::Orcarouter => "https://api.orcarouter.ai/v1",
|
||||
Self::Custom => "",
|
||||
}
|
||||
}
|
||||
@@ -265,6 +269,7 @@ impl CloudProviderType {
|
||||
Self::Openai => "OpenAI",
|
||||
Self::Anthropic => "Anthropic",
|
||||
Self::Openrouter => "OpenRouter",
|
||||
Self::Orcarouter => "OrcaRouter",
|
||||
Self::Custom => "Custom",
|
||||
}
|
||||
}
|
||||
@@ -276,6 +281,7 @@ impl CloudProviderType {
|
||||
Self::Openai => "openai",
|
||||
Self::Anthropic => "anthropic",
|
||||
Self::Openrouter => "openrouter",
|
||||
Self::Orcarouter => "orcarouter",
|
||||
Self::Custom => "custom",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
|
||||
"provider.ollama",
|
||||
"provider.openai",
|
||||
"provider.openrouter",
|
||||
"provider.orcarouter",
|
||||
"channel.dingtalk",
|
||||
"channel.discord",
|
||||
"channel.lark",
|
||||
|
||||
@@ -204,6 +204,7 @@ pub struct Config {
|
||||
// build OpenAiCompatibleProvider with Bearer auth
|
||||
// "anthropic:<model>" → type=anthropic; Bearer auth on the compat endpoint
|
||||
// "openrouter:<model>" → type=openrouter; Bearer auth
|
||||
// "orcarouter:<model>" → type=orcarouter; Bearer auth (e.g. "orcarouter:orcarouter/auto")
|
||||
// "custom:<model>" → type=custom; Bearer auth
|
||||
// "ollama:<model>" → local Ollama at config.local_ai.base_url
|
||||
//
|
||||
|
||||
@@ -114,6 +114,39 @@ fn openrouter_slug_model() {
|
||||
assert_eq!(model, "meta-llama/llama-3.1-8b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orcarouter_slug_model() {
|
||||
let mut config = Config::default();
|
||||
config.cloud_providers.push(CloudProviderCreds {
|
||||
id: "p_oc".to_string(),
|
||||
slug: "orcarouter".to_string(),
|
||||
label: "OrcaRouter".to_string(),
|
||||
endpoint: "https://api.orcarouter.ai/v1".to_string(),
|
||||
auth_style: AuthStyle::Bearer,
|
||||
default_model: Some("orcarouter/auto".to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
let (_, model) =
|
||||
create_chat_provider_from_string("agentic", "orcarouter:orcarouter/auto", &config)
|
||||
.expect("orcarouter:<model> must build");
|
||||
assert_eq!(model, "orcarouter/auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orcarouter_legacy_type_seeds_defaults() {
|
||||
use crate::openhuman::config::schema::cloud_providers::migrate_legacy_fields;
|
||||
let mut entry = CloudProviderCreds {
|
||||
id: "p_oc_legacy".to_string(),
|
||||
legacy_type: Some("orcarouter".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
migrate_legacy_fields(&mut entry);
|
||||
assert_eq!(entry.slug, "orcarouter");
|
||||
assert_eq!(entry.label, "OrcaRouter");
|
||||
assert_eq!(entry.endpoint, "https://api.orcarouter.ai/v1");
|
||||
assert_eq!(entry.auth_style, AuthStyle::Bearer);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_prefix() {
|
||||
let config = Config::default();
|
||||
|
||||
Reference in New Issue
Block a user