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:
pftom
2026-04-29 07:44:00 +08:00
parent 94941f59a9
commit 32f5f857d2
16 changed files with 636 additions and 48 deletions
+3 -2
View File
@@ -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')}
+5 -3
View File
@@ -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,
+2 -2
View File
@@ -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 });
+78 -11
View File
@@ -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>
)}