feat(providers): support OpenAI-compatible / Azure / Google Gemini endpoints (closes #16)
The hosted-API BYOK fallback was hard-wired to api.anthropic.com. Issue #16 asks for Azure (Microsoft OpenAI hosting Anthropic-style models) but the same plumbing unlocks every common BYOK target — OpenRouter, LiteLLM, DeepSeek, Groq, Together, Mistral, plus Google Gemini direct. Provider model: - New `provider: 'anthropic' | 'openai' | 'azure' | 'google'` discriminator on AppConfig (defaults to 'anthropic' so existing localStorage configs migrate seamlessly). - src/providers/model.ts routes to one of four streaming clients: anthropic.ts (existing SDK path), openai.ts (new SSE pump shared with azure.ts), azure.ts (deployment URL + api-key header), google.ts (Generative Language streamGenerateContent). - src/providers/presets.ts ships per-provider defaults (baseUrl, model suggestions, api-key placeholder, Azure api-version flag) so the UI can stay declarative. UI: - SettingsDialog now shows a provider picker on the Hosted-API tab and surfaces an Azure-only api-version field. Provider switches preserve any non-empty user values. - EntryView / AvatarMenu env meta line shows the active provider label. - en + zh-CN locales updated; README + README.zh-CN document every provider, with explicit guidance to reach AWS Bedrock / GCP Vertex Anthropic models via a server-side LiteLLM proxy (signing belongs on the server, not the browser). Why an OpenAI-compatible adapter rather than a native Bedrock/Vertex client: AWS SigV4 and GCP service-account JWTs aren't safe to do from a browser holding long-lived BYOK credentials. LiteLLM (or any Anthropic/OpenAI-compatible proxy) sidesteps that and is the same path lobe-chat uses for Bedrock/Vertex.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import { providerLabel } from '../providers/presets';
|
||||
import { AgentIcon } from './AgentIcon';
|
||||
import { Icon } from './Icon';
|
||||
import type { AgentInfo, AppConfig, ExecMode } from '../types';
|
||||
@@ -82,11 +83,11 @@ export function AvatarMenu({
|
||||
<span className="who">
|
||||
{config.mode === 'daemon'
|
||||
? t('avatar.localCli')
|
||||
: t('avatar.anthropicApi')}
|
||||
: providerLabel(config.provider)}
|
||||
</span>
|
||||
<span className="where">
|
||||
{config.mode === 'api'
|
||||
? safeHost(config.baseUrl)
|
||||
? `${config.model}${config.baseUrl ? ` · ${safeHost(config.baseUrl)}` : ''}`
|
||||
: currentAgent
|
||||
? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}`
|
||||
: t('avatar.noAgentSelected')}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import { providerLabel } from '../providers/presets';
|
||||
import type {
|
||||
AgentInfo,
|
||||
AppConfig,
|
||||
@@ -82,16 +83,17 @@ export function EntryView({
|
||||
|
||||
const envMetaLine = useMemo(() => {
|
||||
if (config.mode === 'api') {
|
||||
const provider = providerLabel(config.provider);
|
||||
try {
|
||||
return `${config.model} · ${new URL(config.baseUrl).host}`;
|
||||
return `${provider} · ${config.model} · ${new URL(config.baseUrl).host}`;
|
||||
} catch {
|
||||
return config.model;
|
||||
return `${provider} · ${config.model}`;
|
||||
}
|
||||
}
|
||||
return currentAgent
|
||||
? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}`
|
||||
: t('settings.noAgentSelected');
|
||||
}, [config.mode, config.model, config.baseUrl, currentAgent, t]);
|
||||
}, [config.mode, config.model, config.baseUrl, config.provider, currentAgent, t]);
|
||||
|
||||
// 'Use this prompt' on an example card is a fast path — skip the form and
|
||||
// create the project immediately with sane defaults derived from the skill,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createArtifactParser } from '../artifacts/parser';
|
||||
import { useT } from '../i18n';
|
||||
import { streamMessage } from '../providers/anthropic';
|
||||
import { streamViaDaemon } from '../providers/daemon';
|
||||
import { streamModel } from '../providers/model';
|
||||
import {
|
||||
fetchDesignSystem,
|
||||
fetchProjectFiles,
|
||||
@@ -501,7 +501,7 @@ export function ProjectView({
|
||||
});
|
||||
} else {
|
||||
pushEvent({ kind: 'status', label: 'requesting', detail: config.model });
|
||||
void streamMessage(config, systemPrompt, nextHistory, controller.signal, {
|
||||
void streamModel(config, systemPrompt, nextHistory, controller.signal, {
|
||||
onDelta: (delta) => {
|
||||
handlers.onDelta(delta);
|
||||
handlers.onAgentEvent({ kind: 'text', text: delta });
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { LOCALE_LABEL, LOCALES, useI18n } from '../i18n';
|
||||
import type { Locale } from '../i18n';
|
||||
import { PROVIDER_ORDER, PROVIDER_PRESETS } from '../providers/presets';
|
||||
import { AgentIcon } from './AgentIcon';
|
||||
import type { AgentInfo, AppConfig, ExecMode } from '../types';
|
||||
import type { AgentInfo, AppConfig, ExecMode, ModelProvider } from '../types';
|
||||
|
||||
interface Props {
|
||||
initial: AppConfig;
|
||||
@@ -14,12 +15,6 @@ interface Props {
|
||||
onRefreshAgents: () => void;
|
||||
}
|
||||
|
||||
const SUGGESTED_MODELS = [
|
||||
'claude-opus-4-5',
|
||||
'claude-sonnet-4-5',
|
||||
'claude-haiku-4-5',
|
||||
];
|
||||
|
||||
export function SettingsDialog({
|
||||
initial,
|
||||
agents,
|
||||
@@ -48,10 +43,38 @@ export function SettingsDialog({
|
||||
|
||||
const setMode = (mode: ExecMode) => setCfg((c) => ({ ...c, mode }));
|
||||
|
||||
// Switching providers swaps in that provider's defaults, but preserves
|
||||
// any non-empty values the user already typed — they may have a custom
|
||||
// baseUrl (e.g. an OpenRouter URL while staying on the openai provider)
|
||||
// they don't want clobbered. Empty fields fall back to the preset.
|
||||
const setProvider = (provider: ModelProvider) => {
|
||||
setCfg((c) => {
|
||||
if (c.provider === provider) return c;
|
||||
const preset = PROVIDER_PRESETS[provider];
|
||||
return {
|
||||
...c,
|
||||
provider,
|
||||
baseUrl: c.baseUrl?.trim() ? c.baseUrl : preset.baseUrl,
|
||||
model: c.model?.trim() ? c.model : preset.defaultModel,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const activePreset = PROVIDER_PRESETS[cfg.provider];
|
||||
|
||||
const canSave =
|
||||
cfg.mode === 'daemon'
|
||||
? Boolean(cfg.agentId && agents.find((a) => a.id === cfg.agentId)?.available)
|
||||
: Boolean(cfg.apiKey.trim() && cfg.model.trim() && cfg.baseUrl.trim());
|
||||
: Boolean(
|
||||
cfg.apiKey.trim() &&
|
||||
cfg.model.trim() &&
|
||||
// Azure has no global default base URL — require the user to
|
||||
// paste their resource endpoint. Other providers ship a usable
|
||||
// default so a blank field falls back to the preset.
|
||||
(cfg.provider === 'azure'
|
||||
? cfg.baseUrl.trim().length > 0
|
||||
: true),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
@@ -187,14 +210,41 @@ export function SettingsDialog({
|
||||
) : (
|
||||
<section className="settings-section">
|
||||
<div className="section-head">
|
||||
<h3>{t('settings.apiSection')}</h3>
|
||||
<div>
|
||||
<h3>{t('settings.apiSection')}</h3>
|
||||
<p className="hint">{t('settings.providerHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="seg-control"
|
||||
role="tablist"
|
||||
aria-label={t('settings.providerLabel')}
|
||||
>
|
||||
{PROVIDER_ORDER.map((id) => {
|
||||
const preset = PROVIDER_PRESETS[id];
|
||||
const active = cfg.provider === id;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
className={'seg-btn' + (active ? ' active' : '')}
|
||||
onClick={() => setProvider(id)}
|
||||
title={preset.blurb}
|
||||
>
|
||||
<span className="seg-title">{preset.label}</span>
|
||||
<span className="seg-meta">{preset.blurb}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<label className="field">
|
||||
<span className="field-label">{t('settings.apiKey')}</span>
|
||||
<div className="field-row">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder="sk-ant-..."
|
||||
placeholder={activePreset.apiKeyPlaceholder}
|
||||
value={cfg.apiKey}
|
||||
onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })}
|
||||
autoFocus
|
||||
@@ -217,10 +267,11 @@ export function SettingsDialog({
|
||||
type="text"
|
||||
value={cfg.model}
|
||||
list="suggested-models"
|
||||
placeholder={activePreset.defaultModel}
|
||||
onChange={(e) => setCfg({ ...cfg, model: e.target.value })}
|
||||
/>
|
||||
<datalist id="suggested-models">
|
||||
{SUGGESTED_MODELS.map((m) => (
|
||||
{activePreset.modelSuggestions.map((m) => (
|
||||
<option value={m} key={m} />
|
||||
))}
|
||||
</datalist>
|
||||
@@ -230,10 +281,26 @@ export function SettingsDialog({
|
||||
<input
|
||||
type="text"
|
||||
value={cfg.baseUrl}
|
||||
placeholder={activePreset.baseUrl || 'https://...'}
|
||||
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
{activePreset.needsApiVersion ? (
|
||||
<label className="field">
|
||||
<span className="field-label">{t('settings.apiVersion')}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={cfg.apiVersion ?? ''}
|
||||
placeholder="2024-08-01-preview"
|
||||
onChange={(e) =>
|
||||
setCfg({ ...cfg, apiVersion: e.target.value })
|
||||
}
|
||||
/>
|
||||
<span className="hint">{t('settings.apiVersionHint')}</span>
|
||||
</label>
|
||||
) : null}
|
||||
<p className="hint">{t('settings.apiHint')}</p>
|
||||
<p className="hint">{t('settings.proxyHint')}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user