1 Commits

Author SHA1 Message Date
pftom 32f5f857d2 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.
2026-04-29 07:44:00 +08:00
16 changed files with 636 additions and 48 deletions
+29 -7
View File
@@ -39,7 +39,7 @@ OD stands on four open-source shoulders:
| | What you get | | | What you get |
|---|---| |---|---|
| **Coding agents supported** | Claude Code · Codex CLI · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Anthropic API (BYOK fallback) | | **Coding agents supported** | Claude Code · Codex CLI · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Hosted-API BYOK fallback (Anthropic · OpenAI-compatible · Azure · Google Gemini, plus AWS Bedrock & GCP Vertex via proxy) |
| **Design systems built-in** | **71** — 2 hand-authored starters + 69 product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, …) imported from [`awesome-design-md`][acd2] | | **Design systems built-in** | **71** — 2 hand-authored starters + 69 product systems (Linear, Stripe, Vercel, Airbnb, Tesla, Notion, Anthropic, Apple, Cursor, Supabase, Figma, …) imported from [`awesome-design-md`][acd2] |
| **Skills built-in** | **19** — prototype, deck, mobile, dashboard, pricing, docs, blog, SaaS landing, plus 10 document/work-product templates (PM spec, weekly update, OKRs, runbook, kanban, …) | | **Skills built-in** | **19** — prototype, deck, mobile, dashboard, pricing, docs, blog, SaaS landing, plus 10 document/work-product templates (PM spec, weekly update, OKRs, runbook, kanban, …) |
| **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm) — each ships a deterministic OKLch palette + font stack | | **Visual directions** | 5 curated schools (Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm) — each ships a deterministic OKLch palette + font stack |
@@ -180,7 +180,7 @@ Adding a skill takes one folder. Read [`docs/skills-protocol.md`](docs/skills-pr
### 1 · We don't ship an agent. Yours is good enough. ### 1 · We don't ship an agent. Yours is good enough.
The daemon scans your `PATH` for [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), and [`qwen`](https://github.com/QwenLM/qwen-code) on startup. Whichever it finds becomes the design engine — driven via stdio, with one adapter per CLI. Inspired by [`multica`](https://github.com/multica-ai/multica) and [`cc-switch`](https://github.com/farion1231/cc-switch). No CLI? `Anthropic API · BYOK` is the same pipeline minus the spawn. The daemon scans your `PATH` for [`claude`](https://docs.anthropic.com/en/docs/claude-code), [`codex`](https://github.com/openai/codex), [`cursor-agent`](https://www.cursor.com/cli), [`gemini`](https://github.com/google-gemini/gemini-cli), [`opencode`](https://opencode.ai/), and [`qwen`](https://github.com/QwenLM/qwen-code) on startup. Whichever it finds becomes the design engine — driven via stdio, with one adapter per CLI. Inspired by [`multica`](https://github.com/multica-ai/multica) and [`cc-switch`](https://github.com/farion1231/cc-switch). No CLI? The `Hosted API · BYOK` fallback streams directly from the browser to **Anthropic**, any **OpenAI-compatible** endpoint (OpenRouter / LiteLLM / DeepSeek / Groq / Together / Mistral …), **Azure OpenAI**, or **Google Gemini** — pick the provider in Settings, paste a key, go. AWS Bedrock and GCP Vertex Anthropic models work the same way through a server-side LiteLLM (or equivalent) proxy pointed at the `Anthropic` provider, since SigV4 / GCP JWT signing belongs on the server, not the browser.
### 2 · Skills are files, not plugins. ### 2 · Skills are files, not plugins.
@@ -227,8 +227,10 @@ Every layer is composable. Every layer is a file you can edit. Read [`src/prompt
│ /api/* (proxied in dev) │ direct (BYOK) │ /api/* (proxied in dev) │ direct (BYOK)
▼ ▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ Local daemon │ │ Anthropic SDK │ Local daemon │ │ Hosted-API router
│ (Express + SQLite) │ │ (browser fallback) │ (Express + SQLite) │ │ Anthropic · OpenAI-
│ │ │ compatible · Azure │
│ │ │ · Google Gemini │
│ │ └──────────────────────┘ │ │ └──────────────────────┘
│ /api/agents │ │ /api/agents │
│ /api/skills │ │ /api/skills │
@@ -268,7 +270,7 @@ The first load:
1. Detects which agent CLIs you have on `PATH` and picks one automatically. 1. Detects which agent CLIs you have on `PATH` and picks one automatically.
2. Loads 19 skills + 71 design systems. 2. Loads 19 skills + 71 design systems.
3. Pops the welcome dialog so you can paste an Anthropic key (only needed for the BYOK fallback path). 3. Pops the welcome dialog so you can pick a hosted-API provider — **Anthropic**, **OpenAI-compatible** (OpenRouter / LiteLLM / DeepSeek / Groq / Together / Mistral / OpenAI), **Azure OpenAI**, or **Google Gemini** — and paste the matching key (only needed for the BYOK fallback path; for AWS Bedrock or GCP Vertex Anthropic models, run a server-side LiteLLM proxy and point the `Anthropic` provider at it).
4. **Auto-creates `./.od/`** — the local runtime folder for the SQLite project DB, per-project artifacts, and saved renders. There is no `od init` step; the daemon `mkdir`s everything it needs on boot. 4. **Auto-creates `./.od/`** — the local runtime folder for the SQLite project DB, per-project artifacts, and saved renders. There is no `od init` step; the daemon `mkdir`s everything it needs on boot.
Type a prompt, hit **Send**, watch the question form arrive, fill it, watch the todo card stream, watch the artifact render. Click **Save to disk** or download as a project ZIP. Type a prompt, hit **Send**, watch the question form arrive, fill it, watch the todo card stream, watch the artifact render. Click **Save to disk** or download as a project ZIP.
@@ -334,7 +336,12 @@ open-design/
│ │ └── zip.ts ← project archive │ │ └── zip.ts ← project archive
│ ├── providers/ │ ├── providers/
│ │ ├── daemon.ts ← /api/chat SSE stream consumer │ │ ├── daemon.ts ← /api/chat SSE stream consumer
│ │ ├── anthropic.ts ← BYOK Anthropic SDK path │ │ ├── model.ts ← BYOK provider router (anthropic / openai / azure / google)
│ │ ├── anthropic.ts ← Anthropic SDK path (also covers any Anthropic-compatible proxy)
│ │ ├── openai.ts ← OpenAI-compatible SSE (OpenRouter / LiteLLM / DeepSeek / Groq / Together)
│ │ ├── azure.ts ← Azure OpenAI deployment URLs + api-key header
│ │ ├── google.ts ← Google Generative Language streamGenerateContent
│ │ ├── presets.ts ← per-provider defaults shown in Settings
│ │ └── registry.ts ← /api/agents, /api/skills, /api/design-systems │ │ └── registry.ts ← /api/agents, /api/skills, /api/design-systems
│ └── state/ ← config + projects (localStorage + daemon-backed) │ └── state/ ← config + projects (localStorage + daemon-backed)
@@ -499,10 +506,25 @@ Auto-detected from `PATH` on daemon boot. No config required.
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | line-buffered | `gemini -p` | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | line-buffered | `gemini -p` |
| [OpenCode](https://opencode.ai/) | `opencode` | line-buffered | `opencode run` | | [OpenCode](https://opencode.ai/) | `opencode` | line-buffered | `opencode run` |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | line-buffered | `qwen -p` | | [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | line-buffered | `qwen -p` |
| Anthropic API · BYOK | n/a | SSE direct | Browser fallback when no CLI is on PATH | | Hosted API · BYOK | n/a | SSE direct | Browser fallback when no CLI is on PATH — pick any of the providers below |
Adding a new CLI is one entry in [`daemon/agents.js`](daemon/agents.js). Streaming format is one of `claude-stream-json` (typed events) or `plain` (raw text). Adding a new CLI is one entry in [`daemon/agents.js`](daemon/agents.js). Streaming format is one of `claude-stream-json` (typed events) or `plain` (raw text).
### Hosted-API providers (BYOK fallback)
When no CLI is detected, OD streams directly from the browser to a hosted endpoint. Pick one in **Settings → Hosted API**, paste a key, optionally tweak the base URL.
| Provider | Wire format | What it covers |
|---|---|---|
| **Anthropic** | `@anthropic-ai/sdk` | `api.anthropic.com`, plus any Anthropic-compatible proxy (LiteLLM, custom gateways, **AWS Bedrock** & **GCP Vertex** via a server-side proxy) |
| **OpenAI-compatible** | `/chat/completions` SSE | OpenAI proper, [OpenRouter](https://openrouter.ai), [LiteLLM proxy](https://docs.litellm.ai/), [DeepSeek](https://platform.deepseek.com/), [Groq](https://groq.com/), [Together](https://together.ai/), [Mistral](https://mistral.ai/), and any other OpenAI-shaped endpoint |
| **Azure OpenAI** | `/openai/deployments/<deployment>/chat/completions` SSE + `api-key` header | Azure-hosted OpenAI deployments. Base URL is the resource endpoint, Model is the deployment name, plus the Azure `api-version` |
| **Google Gemini** | `:streamGenerateContent?alt=sse` | Google Generative Language API direct (Gemini family) |
**On AWS Bedrock & GCP Vertex with Anthropic models:** Both require credential signing (SigV4 / GCP service-account JWT) which is unsafe to do from a browser with long-lived BYOK credentials. The recommended path is to run a server-side proxy ([LiteLLM](https://docs.litellm.ai/) works well — it speaks Anthropic-compatible *and* OpenAI-compatible) and point either the `Anthropic` or `OpenAI-compatible` provider at the proxy URL. The signing stays on the server where it belongs.
Adding a fifth wire format is mechanical: a row in `ModelProvider`, an entry in [`src/providers/presets.ts`](src/providers/presets.ts), a `stream<X>` function alongside [`anthropic.ts` / `openai.ts` / `azure.ts` / `google.ts`](src/providers/), one more `case` in [`src/providers/model.ts`](src/providers/model.ts).
## References & lineage ## References & lineage
Every external project this repo borrows from. Each link goes to the source so you can verify the provenance. Every external project this repo borrows from. Each link goes to the source so you can verify the provenance.
+29 -7
View File
@@ -39,7 +39,7 @@ OD 站在四个开源项目的肩膀上:
| | 你拿到的 | | | 你拿到的 |
|---|---| |---|---|
| **支持的 coding agent** | Claude Code · Codex CLI · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · Anthropic APIBYOK 兜底 | | **支持的 coding agent** | Claude Code · Codex CLI · Cursor Agent · Gemini CLI · OpenCode · Qwen Code · 托管 API · BYOK 兜底(Anthropic · OpenAI 兼容 · Azure · Google GeminiAWS Bedrock 与 GCP Vertex 通过代理接入 |
| **内置 design system** | **71 套** —— 2 套手写起手 + 69 套从 [`awesome-design-md`][acd2] 导入的产品系统(Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma…) | | **内置 design system** | **71 套** —— 2 套手写起手 + 69 套从 [`awesome-design-md`][acd2] 导入的产品系统(Linear、Stripe、Vercel、Airbnb、Tesla、Notion、Anthropic、Apple、Cursor、Supabase、Figma…) |
| **内置 skill** | **19 个** —— 原型 / deck / 移动端 / dashboard / pricing / docs / blog / SaaS landing,外加 10 个文档与办公产物模板(PM 规范、周报、OKR、runbook、看板…) | | **内置 skill** | **19 个** —— 原型 / deck / 移动端 / dashboard / pricing / docs / blog / SaaS landing,外加 10 个文档与办公产物模板(PM 规范、周报、OKR、runbook、看板…) |
| **视觉方向** | 5 套精选流派(Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm),每一套自带 OKLch 色板 + 字体栈 | | **视觉方向** | 5 套精选流派(Editorial Monocle · Modern Minimal · Tech Utility · Brutalist · Soft Warm),每一套自带 OKLch 色板 + 字体栈 |
@@ -180,7 +180,7 @@ OD 站在四个开源项目的肩膀上:
### 1 · 我们不带 agent,你的就够好 ### 1 · 我们不带 agent,你的就够好
Daemon 启动时扫 `PATH`,找 [`claude`](https://docs.anthropic.com/en/docs/claude-code)、[`codex`](https://github.com/openai/codex)、[`cursor-agent`](https://www.cursor.com/cli)、[`gemini`](https://github.com/google-gemini/gemini-cli)、[`opencode`](https://opencode.ai/)、[`qwen`](https://github.com/QwenLM/qwen-code)。哪个在就用哪个 —— 通过 stdio 驱动,每个 CLI 一个 adapter。灵感来自 [`multica`](https://github.com/multica-ai/multica) 和 [`cc-switch`](https://github.com/farion1231/cc-switch)。一个 CLI 都没有?`Anthropic API · BYOK` 就是同一条管线减去 spawn。 Daemon 启动时扫 `PATH`,找 [`claude`](https://docs.anthropic.com/en/docs/claude-code)、[`codex`](https://github.com/openai/codex)、[`cursor-agent`](https://www.cursor.com/cli)、[`gemini`](https://github.com/google-gemini/gemini-cli)、[`opencode`](https://opencode.ai/)、[`qwen`](https://github.com/QwenLM/qwen-code)。哪个在就用哪个 —— 通过 stdio 驱动,每个 CLI 一个 adapter。灵感来自 [`multica`](https://github.com/multica-ai/multica) 和 [`cc-switch`](https://github.com/farion1231/cc-switch)。一个 CLI 都没有?`托管 API · BYOK` 就是同一条管线减去 spawn —— 浏览器直连 **Anthropic**、任意 **OpenAI 兼容**端点(OpenRouter / LiteLLM / DeepSeek / Groq / Together / Mistral …)、**Azure OpenAI**、或 **Google Gemini**。在 Settings 里选渠道、贴 Key、走起。AWS Bedrock 和 GCP Vertex 上的 Anthropic 模型按同样的方式接入:服务端跑一个 LiteLLM(或同类)代理,再把 `Anthropic` 渠道指向它 —— SigV4 / GCP JWT 签名应留在服务器,不放进浏览器
### 2 · Skill 是文件,不是插件 ### 2 · Skill 是文件,不是插件
@@ -227,8 +227,10 @@ DISCOVERY 指令 turn-1 表单、turn-2 品牌分支、TodoWrite、
│ /api/* dev 走代理) │ direct (BYOK) │ /api/* dev 走代理) │ direct (BYOK)
▼ ▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ 本地 daemon │ │ Anthropic SDK │ 本地 daemon │ │ 托管 API 路由器
Express + SQLite)│ │ (浏览器兜底) Express + SQLite)│ │ Anthropic · OpenAI
│ │ │ 兼容 · Azure · Google│
│ │ │ Gemini │
│ │ └──────────────────────┘ │ │ └──────────────────────┘
│ /api/agents │ │ /api/agents │
│ /api/skills │ │ /api/skills │
@@ -268,7 +270,7 @@ open http://localhost:5173
1. 检测你 `PATH` 上有哪些 agent CLI,自动选一个。 1. 检测你 `PATH` 上有哪些 agent CLI,自动选一个。
2. 加载 19 个 skill + 71 套 design system。 2. 加载 19 个 skill + 71 套 design system。
3. 弹欢迎对话框,让你贴 Anthropic key(仅 BYOK 兜底路径需要)。 3. 弹欢迎对话框,让你挑一个托管 API 渠道 —— **Anthropic**、**OpenAI 兼容**OpenRouter / LiteLLM / DeepSeek / Groq / Together / Mistral / OpenAI)、**Azure OpenAI** 或 **Google Gemini** —— 并贴上对应的 Key(仅 BYOK 兜底路径需要;要在浏览器里调 AWS Bedrock / GCP Vertex 上的 Anthropic 模型,建议在服务器跑一个 LiteLLM 代理,再把 `Anthropic` 渠道指向它)。
4. **自动创建 `./.od/`** —— 本地运行时目录,存放 SQLite 项目库、各项目工作区、保存下来的 artifact。**没有** `od init` 这一步,daemon 启动时会自己 `mkdir` 4. **自动创建 `./.od/`** —— 本地运行时目录,存放 SQLite 项目库、各项目工作区、保存下来的 artifact。**没有** `od init` 这一步,daemon 启动时会自己 `mkdir`
输入需求,回车,看 question form 跳出来,填,看 todo 卡片流动,看 artifact 渲染。点 **Save to disk** 或导出整个项目 ZIP。 输入需求,回车,看 question form 跳出来,填,看 todo 卡片流动,看 artifact 渲染。点 **Save to disk** 或导出整个项目 ZIP。
@@ -334,7 +336,12 @@ open-design/
│ │ └── zip.ts ← 项目打包 │ │ └── zip.ts ← 项目打包
│ ├── providers/ │ ├── providers/
│ │ ├── daemon.ts ← /api/chat SSE 流消费者 │ │ ├── daemon.ts ← /api/chat SSE 流消费者
│ │ ├── anthropic.ts ← BYOK Anthropic SDK 路径 │ │ ├── model.ts ← BYOK 渠道路由(anthropic / openai / azure / google
│ │ ├── anthropic.ts ← Anthropic SDK 路径(也涵盖任意 Anthropic 兼容代理)
│ │ ├── openai.ts ← OpenAI 兼容 SSEOpenRouter / LiteLLM / DeepSeek / Groq / Together
│ │ ├── azure.ts ← Azure OpenAI 部署 URL + api-key 头
│ │ ├── google.ts ← Google Generative Language streamGenerateContent
│ │ ├── presets.ts ← Settings 中各渠道的默认值
│ │ └── registry.ts ← /api/agents、/api/skills、/api/design-systems │ │ └── registry.ts ← /api/agents、/api/skills、/api/design-systems
│ └── state/ ← config + projectslocalStorage + daemon 持久化) │ └── state/ ← config + projectslocalStorage + daemon 持久化)
@@ -499,10 +506,25 @@ Daemon 启动时从 `PATH` 自动检测,无需配置。
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | line-buffered | `gemini -p` | | [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | line-buffered | `gemini -p` |
| [OpenCode](https://opencode.ai/) | `opencode` | line-buffered | `opencode run` | | [OpenCode](https://opencode.ai/) | `opencode` | line-buffered | `opencode run` |
| [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | line-buffered | `qwen -p` | | [Qwen Code](https://github.com/QwenLM/qwen-code) | `qwen` | line-buffered | `qwen -p` |
| Anthropic API · BYOK | n/a | SSE 直连 | 没装任何 CLI 时的浏览器兜底 | | 托管 API · BYOK | n/a | SSE 直连 | 没装任何 CLI 时的浏览器兜底 —— 在下方任意一个渠道里挑一个 |
加一个新 CLI = 在 [`daemon/agents.js`](daemon/agents.js) 里加一项。流式格式从 `claude-stream-json`(类型化事件)和 `plain`(原始文本)两种里选一个。 加一个新 CLI = 在 [`daemon/agents.js`](daemon/agents.js) 里加一项。流式格式从 `claude-stream-json`(类型化事件)和 `plain`(原始文本)两种里选一个。
### 托管 API 渠道(BYOK 兜底)
没检测到 CLI 时,OD 会从浏览器直连一个托管端点。在 **Settings → 托管 API** 里挑一个渠道,贴 Key,按需改 Base URL。
| 渠道 | 报文格式 | 覆盖范围 |
|---|---|---|
| **Anthropic** | `@anthropic-ai/sdk` | `api.anthropic.com`,以及任意 Anthropic 兼容代理(LiteLLM、自建网关、**AWS Bedrock** 与 **GCP Vertex** 通过服务端代理接入) |
| **OpenAI 兼容** | `/chat/completions` SSE | OpenAI 官方、[OpenRouter](https://openrouter.ai)、[LiteLLM 代理](https://docs.litellm.ai/)、[DeepSeek](https://platform.deepseek.com/)、[Groq](https://groq.com/)、[Together](https://together.ai/)、[Mistral](https://mistral.ai/),以及任意 OpenAI 形态的端点 |
| **Azure OpenAI** | `/openai/deployments/<deployment>/chat/completions` SSE + `api-key` 头 | Azure 托管的 OpenAI 部署。Base URL 是资源终结点,Model 是部署名,再加 Azure 的 `api-version` |
| **Google Gemini** | `:streamGenerateContent?alt=sse` | Google Generative Language API 直连(Gemini 系列) |
**关于 AWS Bedrock 与 GCP Vertex 上的 Anthropic 模型:**两者都需要凭证签名(SigV4 / GCP service-account JWT),用浏览器里长期存放的 BYOK 凭证去签是不安全的。推荐做法:在服务器端跑一个代理([LiteLLM](https://docs.litellm.ai/) 同时支持 Anthropic 兼容和 OpenAI 兼容),把 `Anthropic``OpenAI 兼容` 渠道的 Base URL 指向代理,签名留在服务器端。
加第五种报文格式很机械:`ModelProvider` 里加一行、[`src/providers/presets.ts`](src/providers/presets.ts) 里加一项、和 [`anthropic.ts` / `openai.ts` / `azure.ts` / `google.ts`](src/providers/) 一起放一个 `stream<X>` 函数、[`src/providers/model.ts`](src/providers/model.ts) 里再加一个 `case`
## 引用与师承 ## 引用与师承
每一个被借鉴的开源项目都列在这里。点链接可以验证师承。 每一个被借鉴的开源项目都列在这里。点链接可以验证师承。
+3 -2
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { providerLabel } from '../providers/presets';
import { AgentIcon } from './AgentIcon'; import { AgentIcon } from './AgentIcon';
import { Icon } from './Icon'; import { Icon } from './Icon';
import type { AgentInfo, AppConfig, ExecMode } from '../types'; import type { AgentInfo, AppConfig, ExecMode } from '../types';
@@ -82,11 +83,11 @@ export function AvatarMenu({
<span className="who"> <span className="who">
{config.mode === 'daemon' {config.mode === 'daemon'
? t('avatar.localCli') ? t('avatar.localCli')
: t('avatar.anthropicApi')} : providerLabel(config.provider)}
</span> </span>
<span className="where"> <span className="where">
{config.mode === 'api' {config.mode === 'api'
? safeHost(config.baseUrl) ? `${config.model}${config.baseUrl ? ` · ${safeHost(config.baseUrl)}` : ''}`
: currentAgent : currentAgent
? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}` ? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}`
: t('avatar.noAgentSelected')} : t('avatar.noAgentSelected')}
+5 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { providerLabel } from '../providers/presets';
import type { import type {
AgentInfo, AgentInfo,
AppConfig, AppConfig,
@@ -82,16 +83,17 @@ export function EntryView({
const envMetaLine = useMemo(() => { const envMetaLine = useMemo(() => {
if (config.mode === 'api') { if (config.mode === 'api') {
const provider = providerLabel(config.provider);
try { try {
return `${config.model} · ${new URL(config.baseUrl).host}`; return `${provider} · ${config.model} · ${new URL(config.baseUrl).host}`;
} catch { } catch {
return config.model; return `${provider} · ${config.model}`;
} }
} }
return currentAgent return currentAgent
? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}` ? `${currentAgent.name}${currentAgent.version ? ` · ${currentAgent.version}` : ''}`
: t('settings.noAgentSelected'); : 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 // '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, // 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createArtifactParser } from '../artifacts/parser'; import { createArtifactParser } from '../artifacts/parser';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { streamMessage } from '../providers/anthropic';
import { streamViaDaemon } from '../providers/daemon'; import { streamViaDaemon } from '../providers/daemon';
import { streamModel } from '../providers/model';
import { import {
fetchDesignSystem, fetchDesignSystem,
fetchProjectFiles, fetchProjectFiles,
@@ -501,7 +501,7 @@ export function ProjectView({
}); });
} else { } else {
pushEvent({ kind: 'status', label: 'requesting', detail: config.model }); pushEvent({ kind: 'status', label: 'requesting', detail: config.model });
void streamMessage(config, systemPrompt, nextHistory, controller.signal, { void streamModel(config, systemPrompt, nextHistory, controller.signal, {
onDelta: (delta) => { onDelta: (delta) => {
handlers.onDelta(delta); handlers.onDelta(delta);
handlers.onAgentEvent({ kind: 'text', text: delta }); handlers.onAgentEvent({ kind: 'text', text: delta });
+78 -11
View File
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { LOCALE_LABEL, LOCALES, useI18n } from '../i18n'; import { LOCALE_LABEL, LOCALES, useI18n } from '../i18n';
import type { Locale } from '../i18n'; import type { Locale } from '../i18n';
import { PROVIDER_ORDER, PROVIDER_PRESETS } from '../providers/presets';
import { AgentIcon } from './AgentIcon'; import { AgentIcon } from './AgentIcon';
import type { AgentInfo, AppConfig, ExecMode } from '../types'; import type { AgentInfo, AppConfig, ExecMode, ModelProvider } from '../types';
interface Props { interface Props {
initial: AppConfig; initial: AppConfig;
@@ -14,12 +15,6 @@ interface Props {
onRefreshAgents: () => void; onRefreshAgents: () => void;
} }
const SUGGESTED_MODELS = [
'claude-opus-4-5',
'claude-sonnet-4-5',
'claude-haiku-4-5',
];
export function SettingsDialog({ export function SettingsDialog({
initial, initial,
agents, agents,
@@ -48,10 +43,38 @@ export function SettingsDialog({
const setMode = (mode: ExecMode) => setCfg((c) => ({ ...c, mode })); 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 = const canSave =
cfg.mode === 'daemon' cfg.mode === 'daemon'
? Boolean(cfg.agentId && agents.find((a) => a.id === cfg.agentId)?.available) ? 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 ( return (
<div className="modal-backdrop" onClick={onClose}> <div className="modal-backdrop" onClick={onClose}>
@@ -187,14 +210,41 @@ export function SettingsDialog({
) : ( ) : (
<section className="settings-section"> <section className="settings-section">
<div className="section-head"> <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> </div>
<label className="field"> <label className="field">
<span className="field-label">{t('settings.apiKey')}</span> <span className="field-label">{t('settings.apiKey')}</span>
<div className="field-row"> <div className="field-row">
<input <input
type={showApiKey ? 'text' : 'password'} type={showApiKey ? 'text' : 'password'}
placeholder="sk-ant-..." placeholder={activePreset.apiKeyPlaceholder}
value={cfg.apiKey} value={cfg.apiKey}
onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })} onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })}
autoFocus autoFocus
@@ -217,10 +267,11 @@ export function SettingsDialog({
type="text" type="text"
value={cfg.model} value={cfg.model}
list="suggested-models" list="suggested-models"
placeholder={activePreset.defaultModel}
onChange={(e) => setCfg({ ...cfg, model: e.target.value })} onChange={(e) => setCfg({ ...cfg, model: e.target.value })}
/> />
<datalist id="suggested-models"> <datalist id="suggested-models">
{SUGGESTED_MODELS.map((m) => ( {activePreset.modelSuggestions.map((m) => (
<option value={m} key={m} /> <option value={m} key={m} />
))} ))}
</datalist> </datalist>
@@ -230,10 +281,26 @@ export function SettingsDialog({
<input <input
type="text" type="text"
value={cfg.baseUrl} value={cfg.baseUrl}
placeholder={activePreset.baseUrl || 'https://...'}
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value })} onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value })}
/> />
</label> </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.apiHint')}</p>
<p className="hint">{t('settings.proxyHint')}</p>
</section> </section>
)} )}
+16 -8
View File
@@ -49,15 +49,15 @@ export const en: Dict = {
'settings.kicker': 'Settings', 'settings.kicker': 'Settings',
'settings.title': 'Execution & model', 'settings.title': 'Execution & model',
'settings.subtitle': 'settings.subtitle':
'Choose between a local code-agent CLI and the Anthropic API (BYOK). Your API key is stored only in this browser.', 'Choose between a local code-agent CLI and a hosted model provider (BYOK). Anthropic, OpenAI-compatible (OpenRouter / LiteLLM / DeepSeek / Groq …), Azure OpenAI, and Google Gemini are supported. Your API key is stored only in this browser.',
'settings.modeAria': 'Execution mode', 'settings.modeAria': 'Execution mode',
'settings.modeDaemon': 'Local CLI', 'settings.modeDaemon': 'Local CLI',
'settings.modeDaemonHelp': 'Run via a code-agent CLI on your machine', 'settings.modeDaemonHelp': 'Run via a code-agent CLI on your machine',
'settings.modeDaemonOffline': 'Daemon is not running', 'settings.modeDaemonOffline': 'Daemon is not running',
'settings.modeDaemonOfflineMeta': 'daemon offline', 'settings.modeDaemonOfflineMeta': 'daemon offline',
'settings.modeDaemonInstalledMeta': '{count} installed', 'settings.modeDaemonInstalledMeta': '{count} installed',
'settings.modeApi': 'Anthropic API', 'settings.modeApi': 'Hosted API',
'settings.modeApiMeta': 'BYOK', 'settings.modeApiMeta': 'BYOK · multi-provider',
'settings.codeAgent': 'Code agent', 'settings.codeAgent': 'Code agent',
'settings.codeAgentHint': 'settings.codeAgentHint':
'Detected by scanning your PATH. Pick the CLI you want generations to flow through.', 'Detected by scanning your PATH. Pick the CLI you want generations to flow through.',
@@ -65,7 +65,7 @@ export const en: Dict = {
'settings.rescanTitle': 'Re-scan PATH', 'settings.rescanTitle': 'Re-scan PATH',
'settings.noAgentsDetected': 'settings.noAgentsDetected':
'No agents detected yet. Install one of Claude Code, Codex, Gemini CLI, OpenCode, Cursor Agent, or Qwen, then click Rescan.', 'No agents detected yet. Install one of Claude Code, Codex, Gemini CLI, OpenCode, Cursor Agent, or Qwen, then click Rescan.',
'settings.apiSection': 'Anthropic API', 'settings.apiSection': 'Model endpoint',
'settings.apiKey': 'API key', 'settings.apiKey': 'API key',
'settings.showKey': 'Show key', 'settings.showKey': 'Show key',
'settings.hideKey': 'Hide key', 'settings.hideKey': 'Hide key',
@@ -75,11 +75,19 @@ export const en: Dict = {
'settings.baseUrl': 'Base URL', 'settings.baseUrl': 'Base URL',
'settings.apiHint': 'settings.apiHint':
'Calls go directly from this browser to the base URL you set. No proxy. The key never leaves localStorage.', 'Calls go directly from this browser to the base URL you set. No proxy. The key never leaves localStorage.',
'settings.providerLabel': 'Provider',
'settings.providerHint':
'Pick the wire format. Anthropic also covers any Anthropic-compatible proxy. OpenAI-compatible covers OpenRouter, LiteLLM, DeepSeek, Groq, Together, etc.',
'settings.apiVersion': 'API version',
'settings.apiVersionHint':
'Azure REST api-version (e.g. 2024-08-01-preview). Leave blank to use the default.',
'settings.proxyHint':
'Tip: For AWS Bedrock or Google Vertex with Anthropic models, run a server-side proxy (LiteLLM works well) and point the Anthropic provider at it — credential signing belongs on the server, not the browser.',
'settings.skipForNow': 'Skip for now', 'settings.skipForNow': 'Skip for now',
'settings.getStarted': 'Get started', 'settings.getStarted': 'Get started',
'settings.envConfigure': 'Configure execution mode', 'settings.envConfigure': 'Configure execution mode',
'settings.localCli': 'Local CLI', 'settings.localCli': 'Local CLI',
'settings.anthropicApi': 'Anthropic API', 'settings.anthropicApi': 'Hosted API',
'settings.noAgentSelected': 'no agent selected', 'settings.noAgentSelected': 'no agent selected',
'settings.language': 'Language', 'settings.language': 'Language',
'settings.languageHint': 'Switch the interface language. Saved to this browser.', 'settings.languageHint': 'Switch the interface language. Saved to this browser.',
@@ -200,9 +208,9 @@ export const en: Dict = {
'avatar.title': 'Account & settings', 'avatar.title': 'Account & settings',
'avatar.localCli': 'Local CLI', 'avatar.localCli': 'Local CLI',
'avatar.anthropicApi': 'Anthropic API', 'avatar.anthropicApi': 'Hosted API',
'avatar.useLocal': 'Use Local CLI', 'avatar.useLocal': 'Use Local CLI',
'avatar.useApi': 'Use Anthropic API', 'avatar.useApi': 'Use hosted API',
'avatar.codeAgent': 'Code agent', 'avatar.codeAgent': 'Code agent',
'avatar.rescan': 'Rescan PATH', 'avatar.rescan': 'Rescan PATH',
'avatar.settings': 'Settings', 'avatar.settings': 'Settings',
@@ -411,7 +419,7 @@ export const en: Dict = {
'agentPicker.modeChoose': 'Choose execution mode', 'agentPicker.modeChoose': 'Choose execution mode',
'agentPicker.localCli': 'Local CLI', 'agentPicker.localCli': 'Local CLI',
'agentPicker.daemonOff': 'daemon off', 'agentPicker.daemonOff': 'daemon off',
'agentPicker.byok': 'Anthropic API · BYOK', 'agentPicker.byok': 'Hosted API · BYOK',
'agentPicker.selectAgent': 'Select a detected code-agent CLI', 'agentPicker.selectAgent': 'Select a detected code-agent CLI',
'agentPicker.noAgents': 'no agents on PATH', 'agentPicker.noAgents': 'no agents on PATH',
'agentPicker.notInstalled': 'not installed', 'agentPicker.notInstalled': 'not installed',
+16 -8
View File
@@ -49,22 +49,22 @@ export const zhCN: Dict = {
'settings.kicker': '设置', 'settings.kicker': '设置',
'settings.title': '执行模式与模型', 'settings.title': '执行模式与模型',
'settings.subtitle': 'settings.subtitle':
'在本机的代码代理 CLI 与 Anthropic API(自带 Key)之间切换。API Key 只保存在当前浏览器中。', '在本机的代码代理 CLI 与托管模型 API(自带 Key)之间切换。支持 Anthropic、OpenAI 兼容(OpenRouter / LiteLLM / DeepSeek / Groq 等)、Azure OpenAI 与 Google Gemini。API Key 只保存在当前浏览器中。',
'settings.modeAria': '执行模式', 'settings.modeAria': '执行模式',
'settings.modeDaemon': '本机 CLI', 'settings.modeDaemon': '本机 CLI',
'settings.modeDaemonHelp': '通过本机的代码代理 CLI 执行', 'settings.modeDaemonHelp': '通过本机的代码代理 CLI 执行',
'settings.modeDaemonOffline': '后台守护进程未运行', 'settings.modeDaemonOffline': '后台守护进程未运行',
'settings.modeDaemonOfflineMeta': '守护进程未运行', 'settings.modeDaemonOfflineMeta': '守护进程未运行',
'settings.modeDaemonInstalledMeta': '已安装 {count} 个', 'settings.modeDaemonInstalledMeta': '已安装 {count} 个',
'settings.modeApi': 'Anthropic API', 'settings.modeApi': '托管 API',
'settings.modeApiMeta': '自带 Key', 'settings.modeApiMeta': '自带 Key · 多渠道',
'settings.codeAgent': '代码代理', 'settings.codeAgent': '代码代理',
'settings.codeAgentHint': '通过扫描 PATH 自动检测,选择你希望使用的 CLI。', 'settings.codeAgentHint': '通过扫描 PATH 自动检测,选择你希望使用的 CLI。',
'settings.rescan': '↻ 重新扫描', 'settings.rescan': '↻ 重新扫描',
'settings.rescanTitle': '重新扫描 PATH', 'settings.rescanTitle': '重新扫描 PATH',
'settings.noAgentsDetected': 'settings.noAgentsDetected':
'尚未检测到任何代理。请安装 Claude Code、Codex、Gemini CLI、OpenCode、Cursor Agent 或 Qwen 中的一个,然后点击「重新扫描」。', '尚未检测到任何代理。请安装 Claude Code、Codex、Gemini CLI、OpenCode、Cursor Agent 或 Qwen 中的一个,然后点击「重新扫描」。',
'settings.apiSection': 'Anthropic API', 'settings.apiSection': '模型端点',
'settings.apiKey': 'API Key', 'settings.apiKey': 'API Key',
'settings.showKey': '显示 Key', 'settings.showKey': '显示 Key',
'settings.hideKey': '隐藏 Key', 'settings.hideKey': '隐藏 Key',
@@ -74,11 +74,19 @@ export const zhCN: Dict = {
'settings.baseUrl': 'Base URL', 'settings.baseUrl': 'Base URL',
'settings.apiHint': 'settings.apiHint':
'请求会从当前浏览器直连你设置的 Base URL,无中转代理。Key 只存放在 localStorage。', '请求会从当前浏览器直连你设置的 Base URL,无中转代理。Key 只存放在 localStorage。',
'settings.providerLabel': '渠道',
'settings.providerHint':
'选择请求格式。Anthropic 也涵盖任意 Anthropic 兼容代理;OpenAI 兼容涵盖 OpenRouter、LiteLLM、DeepSeek、Groq、Together 等。',
'settings.apiVersion': 'API version',
'settings.apiVersionHint':
'Azure 的 api-version 查询参数(如 2024-08-01-preview)。留空则使用默认值。',
'settings.proxyHint':
'提示:若要在浏览器里使用 AWS Bedrock 或 Google Vertex 上的 Anthropic 模型,建议在服务器端跑一个 LiteLLM 代理,再把 Anthropic 渠道指向它 —— 凭证签名应留在服务器,不放进浏览器。',
'settings.skipForNow': '暂时跳过', 'settings.skipForNow': '暂时跳过',
'settings.getStarted': '开始使用', 'settings.getStarted': '开始使用',
'settings.envConfigure': '配置执行模式', 'settings.envConfigure': '配置执行模式',
'settings.localCli': '本机 CLI', 'settings.localCli': '本机 CLI',
'settings.anthropicApi': 'Anthropic API', 'settings.anthropicApi': '托管 API',
'settings.noAgentSelected': '尚未选择代理', 'settings.noAgentSelected': '尚未选择代理',
'settings.language': '界面语言', 'settings.language': '界面语言',
'settings.languageHint': '切换界面语言,设置仅保存在当前浏览器。', 'settings.languageHint': '切换界面语言,设置仅保存在当前浏览器。',
@@ -197,9 +205,9 @@ export const zhCN: Dict = {
'avatar.title': '账户与设置', 'avatar.title': '账户与设置',
'avatar.localCli': '本机 CLI', 'avatar.localCli': '本机 CLI',
'avatar.anthropicApi': 'Anthropic API', 'avatar.anthropicApi': '托管 API',
'avatar.useLocal': '使用本机 CLI', 'avatar.useLocal': '使用本机 CLI',
'avatar.useApi': '使用 Anthropic API', 'avatar.useApi': '使用托管 API',
'avatar.codeAgent': '代码代理', 'avatar.codeAgent': '代码代理',
'avatar.rescan': '重新扫描 PATH', 'avatar.rescan': '重新扫描 PATH',
'avatar.settings': '设置', 'avatar.settings': '设置',
@@ -400,7 +408,7 @@ export const zhCN: Dict = {
'agentPicker.modeChoose': '选择执行模式', 'agentPicker.modeChoose': '选择执行模式',
'agentPicker.localCli': '本机 CLI', 'agentPicker.localCli': '本机 CLI',
'agentPicker.daemonOff': '守护进程未运行', 'agentPicker.daemonOff': '守护进程未运行',
'agentPicker.byok': 'Anthropic API · 自带 Key', 'agentPicker.byok': '托管 API · 自带 Key',
'agentPicker.selectAgent': '选择已检测到的代码代理 CLI', 'agentPicker.selectAgent': '选择已检测到的代码代理 CLI',
'agentPicker.noAgents': 'PATH 中未发现代理', 'agentPicker.noAgents': 'PATH 中未发现代理',
'agentPicker.notInstalled': '未安装', 'agentPicker.notInstalled': '未安装',
+5
View File
@@ -85,6 +85,11 @@ export interface Dict {
'settings.model': string; 'settings.model': string;
'settings.baseUrl': string; 'settings.baseUrl': string;
'settings.apiHint': string; 'settings.apiHint': string;
'settings.providerLabel': string;
'settings.providerHint': string;
'settings.apiVersion': string;
'settings.apiVersionHint': string;
'settings.proxyHint': string;
'settings.skipForNow': string; 'settings.skipForNow': string;
'settings.getStarted': string; 'settings.getStarted': string;
'settings.envConfigure': string; 'settings.envConfigure': string;
+56
View File
@@ -0,0 +1,56 @@
/**
* Azure OpenAI streaming client. Wire format is OpenAI's (chat.completions
* SSE), but the URL embeds the deployment name and an api-version query
* string, and auth uses the `api-key` header rather than `Authorization:
* Bearer`. We reuse streamChatCompletions() from openai.ts for the SSE
* pump and only diverge on URL + headers.
*/
import type { AppConfig, ChatMessage } from '../types';
import type { StreamHandlers } from './anthropic';
import { streamChatCompletions } from './openai';
const DEFAULT_API_VERSION = '2024-08-01-preview';
export async function streamAzure(
cfg: AppConfig,
system: string,
history: ChatMessage[],
signal: AbortSignal,
handlers: StreamHandlers,
): Promise<void> {
if (!cfg.apiKey) {
handlers.onError(new Error('Missing Azure key — open Settings and paste one in.'));
return;
}
if (!cfg.baseUrl) {
handlers.onError(
new Error('Missing Azure endpoint — set Base URL to https://<resource>.openai.azure.com.'),
);
return;
}
if (!cfg.model) {
handlers.onError(
new Error('Missing Azure deployment — set Model to your deployment name.'),
);
return;
}
const apiVersion = (cfg.apiVersion?.trim() || DEFAULT_API_VERSION);
const url = buildAzureUrl(cfg.baseUrl, cfg.model, apiVersion);
const body = {
stream: true,
max_tokens: 8192,
messages: [
...(system ? [{ role: 'system', content: system }] : []),
...history.map((m) => ({ role: m.role, content: m.content })),
],
};
await streamChatCompletions(url, cfg.apiKey, body, signal, handlers, 'azure');
}
function buildAzureUrl(baseUrl: string, deployment: string, apiVersion: string): string {
const base = baseUrl.replace(/\/+$/, '');
return `${base}/openai/deployments/${encodeURIComponent(deployment)}/chat/completions?api-version=${encodeURIComponent(apiVersion)}`;
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Google Generative Language API streaming client (Gemini direct). The
* REST surface is at generativelanguage.googleapis.com and accepts an
* api key in the query string. We hit `:streamGenerateContent` with
* `alt=sse` so the response arrives as a server-sent event stream we
* can pump like the OpenAI one.
*/
import type { AppConfig, ChatMessage } from '../types';
import type { StreamHandlers } from './anthropic';
export async function streamGoogle(
cfg: AppConfig,
system: string,
history: ChatMessage[],
signal: AbortSignal,
handlers: StreamHandlers,
): Promise<void> {
if (!cfg.apiKey) {
handlers.onError(new Error('Missing API key — open Settings and paste one in.'));
return;
}
if (!cfg.model) {
handlers.onError(new Error('Missing model — set one in Settings.'));
return;
}
const base = (cfg.baseUrl || 'https://generativelanguage.googleapis.com').replace(/\/+$/, '');
const url = `${base}/v1beta/models/${encodeURIComponent(cfg.model)}:streamGenerateContent?alt=sse&key=${encodeURIComponent(cfg.apiKey)}`;
const contents = history.map((m) => ({
role: m.role === 'assistant' ? 'model' : 'user',
parts: [{ text: m.content }],
}));
const body: Record<string, unknown> = { contents };
if (system) {
body.systemInstruction = { role: 'system', parts: [{ text: system }] };
}
let acc = '';
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
});
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => '');
handlers.onError(new Error(`upstream ${resp.status}: ${text || 'no body'}`));
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, idx).trim();
buf = buf.slice(idx + 2);
if (!frame) continue;
for (const line of frame.split('\n')) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
const delta = extractGeminiText(parsed);
if (delta) {
acc += delta;
handlers.onDelta(delta);
}
}
}
}
handlers.onDone(acc);
} catch (err) {
if ((err as Error).name === 'AbortError') return;
handlers.onError(err instanceof Error ? err : new Error(String(err)));
}
}
function extractGeminiText(payload: unknown): string {
if (!payload || typeof payload !== 'object') return '';
const candidates = (payload as { candidates?: unknown }).candidates;
if (!Array.isArray(candidates) || candidates.length === 0) return '';
const first = candidates[0] as { content?: { parts?: Array<{ text?: unknown }> } };
const parts = first?.content?.parts;
if (!Array.isArray(parts)) return '';
let out = '';
for (const p of parts) {
if (typeof p?.text === 'string') out += p.text;
}
return out;
}
+32
View File
@@ -0,0 +1,32 @@
/**
* BYOK model router. Picks a streaming client based on cfg.provider so
* the rest of the app can stay provider-agnostic. Adding a fifth provider
* later means: add an entry to ModelProvider, add a presets row, add a
* `stream<X>` function, and one more `case` here.
*/
import type { AppConfig, ChatMessage } from '../types';
import type { StreamHandlers } from './anthropic';
import { streamMessage as streamAnthropic } from './anthropic';
import { streamAzure } from './azure';
import { streamGoogle } from './google';
import { streamOpenAI } from './openai';
export async function streamModel(
cfg: AppConfig,
system: string,
history: ChatMessage[],
signal: AbortSignal,
handlers: StreamHandlers,
): Promise<void> {
switch (cfg.provider) {
case 'openai':
return streamOpenAI(cfg, system, history, signal, handlers);
case 'azure':
return streamAzure(cfg, system, history, signal, handlers);
case 'google':
return streamGoogle(cfg, system, history, signal, handlers);
case 'anthropic':
default:
return streamAnthropic(cfg, system, history, signal, handlers);
}
}
+135
View File
@@ -0,0 +1,135 @@
/**
* OpenAI-compatible streaming client. Covers any endpoint that speaks the
* `/chat/completions` SSE wire format OpenAI proper, OpenRouter,
* LiteLLM proxy, DeepSeek, Groq, Together, Mistral. Azure has its own
* URL shape and lives in azure.ts.
*
* Browser fetch is fine here for the same BYOK reason streamMessage()
* uses dangerouslyAllowBrowser: this is a local-first tool, the key is
* the user's, it never leaves their machine. Move to a server proxy if
* you ever ship a hosted build.
*/
import type { AppConfig, ChatMessage } from '../types';
import type { StreamHandlers } from './anthropic';
export async function streamOpenAI(
cfg: AppConfig,
system: string,
history: ChatMessage[],
signal: AbortSignal,
handlers: StreamHandlers,
): Promise<void> {
if (!cfg.apiKey) {
handlers.onError(new Error('Missing API key — open Settings and paste one in.'));
return;
}
if (!cfg.baseUrl) {
handlers.onError(new Error('Missing base URL — open Settings and set one.'));
return;
}
const url = joinUrl(cfg.baseUrl, '/chat/completions');
const body = {
model: cfg.model,
stream: true,
max_tokens: 8192,
messages: [
...(system ? [{ role: 'system', content: system }] : []),
...history.map((m) => ({ role: m.role, content: m.content })),
],
};
await streamChatCompletions(url, cfg.apiKey, body, signal, handlers, 'bearer');
}
// Shared SSE pump between the OpenAI and Azure clients — they only differ
// in URL shape and auth header.
export async function streamChatCompletions(
url: string,
apiKey: string,
body: Record<string, unknown>,
signal: AbortSignal,
handlers: StreamHandlers,
auth: 'bearer' | 'azure',
): Promise<void> {
let acc = '';
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (auth === 'bearer') headers['Authorization'] = `Bearer ${apiKey}`;
else headers['api-key'] = apiKey;
const resp = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal,
});
if (!resp.ok || !resp.body) {
const text = await resp.text().catch(() => '');
handlers.onError(new Error(`upstream ${resp.status}: ${text || 'no body'}`));
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
// Frames are separated by a blank line. Split on \n\n; the trailing
// partial frame stays in buf for the next iteration.
let idx: number;
while ((idx = buf.indexOf('\n\n')) !== -1) {
const frame = buf.slice(0, idx).trim();
buf = buf.slice(idx + 2);
if (!frame) continue;
// Each frame is one or more `data: ...` lines plus optional
// `event:` / comments. We only care about `data:` payloads.
for (const line of frame.split('\n')) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (!payload || payload === '[DONE]') continue;
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
continue;
}
const delta = extractDelta(parsed);
if (delta) {
acc += delta;
handlers.onDelta(delta);
}
}
}
}
handlers.onDone(acc);
} catch (err) {
if ((err as Error).name === 'AbortError') return;
handlers.onError(err instanceof Error ? err : new Error(String(err)));
}
}
function extractDelta(payload: unknown): string {
if (!payload || typeof payload !== 'object') return '';
const choices = (payload as { choices?: unknown }).choices;
if (!Array.isArray(choices) || choices.length === 0) return '';
const first = choices[0] as { delta?: { content?: unknown }; text?: unknown };
if (first?.delta && typeof first.delta.content === 'string') {
return first.delta.content;
}
// Some legacy / completion-style proxies emit `text` instead of delta.
if (typeof first?.text === 'string') return first.text;
return '';
}
function joinUrl(base: string, path: string): string {
const b = base.replace(/\/+$/, '');
const p = path.startsWith('/') ? path : `/${path}`;
return `${b}${p}`;
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Provider presets the BYOK side of the app supports four wire formats
* (Anthropic-native, OpenAI-compatible, Azure OpenAI, Google Generative
* Language). Each one ships with a default base URL and a short list of
* suggested model ids so the SettingsDialog datalist gives the user a
* head-start. The presets stay deliberately conservative: a user pointing
* an `openai` provider at LiteLLM / OpenRouter / DeepSeek just types a
* different baseUrl + model, no code change required.
*
* AWS Bedrock and Google Vertex aren't first-class providers here. Both
* require credential signing (SigV4 for AWS, GCP service-account JWT for
* Vertex) which is unsafe to do from the browser with long-lived BYOK
* credentials. The recommended path is to run LiteLLM (or a similar
* proxy) server-side and point the `anthropic` or `openai` provider at
* that proxy's URL the provider chooser surfaces this guidance.
*/
import type { ModelProvider } from '../types';
export interface ProviderPreset {
id: ModelProvider;
// Display name shown in the chooser and the env meta line.
label: string;
// Short marketing-style line shown under the provider card.
blurb: string;
// Default base URL preloaded into the form when the user picks this
// provider for the first time. Empty string means "the user must fill
// it in" (Azure has no global default).
baseUrl: string;
// Suggested model id (datalist anchor). The user can type anything.
defaultModel: string;
// Suggestions surfaced in the model field's <datalist>.
modelSuggestions: string[];
// Placeholder hint for the api key field.
apiKeyPlaceholder: string;
// Whether the provider requires the Azure-specific apiVersion field.
needsApiVersion?: boolean;
}
export const PROVIDER_PRESETS: Record<ModelProvider, ProviderPreset> = {
anthropic: {
id: 'anthropic',
label: 'Anthropic',
blurb: 'Direct to api.anthropic.com or any Anthropic-compatible proxy (LiteLLM, AWS Bedrock / GCP Vertex via proxy).',
baseUrl: 'https://api.anthropic.com',
defaultModel: 'claude-sonnet-4-5',
modelSuggestions: [
'claude-opus-4-5',
'claude-sonnet-4-5',
'claude-haiku-4-5',
'claude-3-5-sonnet-latest',
],
apiKeyPlaceholder: 'sk-ant-...',
},
openai: {
id: 'openai',
label: 'OpenAI-compatible',
blurb: 'Any OpenAI /chat/completions endpoint — OpenAI, OpenRouter, LiteLLM proxy, DeepSeek, Groq, Together, Mistral.',
baseUrl: 'https://api.openai.com/v1',
defaultModel: 'gpt-4o-mini',
modelSuggestions: [
'gpt-4o',
'gpt-4o-mini',
'anthropic/claude-3.5-sonnet',
'google/gemini-2.0-flash',
'deepseek/deepseek-chat',
'meta-llama/llama-3.3-70b-instruct',
],
apiKeyPlaceholder: 'sk-...',
},
azure: {
id: 'azure',
label: 'Azure OpenAI',
blurb: 'Azure-hosted deployments. Base URL is your resource endpoint; Model is the deployment name.',
baseUrl: '',
defaultModel: '',
modelSuggestions: [],
apiKeyPlaceholder: 'azure key',
needsApiVersion: true,
},
google: {
id: 'google',
label: 'Google Gemini',
blurb: 'Google Generative Language API — Gemini family, key from aistudio.google.com.',
baseUrl: 'https://generativelanguage.googleapis.com',
defaultModel: 'gemini-2.0-flash',
modelSuggestions: [
'gemini-2.0-flash',
'gemini-2.0-flash-lite',
'gemini-1.5-pro',
'gemini-1.5-flash',
],
apiKeyPlaceholder: 'AIza...',
},
};
export const PROVIDER_ORDER: ModelProvider[] = [
'anthropic',
'openai',
'azure',
'google',
];
// True when the provider's wire format expects a deployment-specific URL
// rather than a generic baseUrl + path. Today only Azure qualifies — kept
// as a helper so callers don't have to memorize that.
export function providerLabel(provider: ModelProvider): string {
return PROVIDER_PRESETS[provider]?.label ?? provider;
}
+2
View File
@@ -4,9 +4,11 @@ const STORAGE_KEY = 'open-design:config';
export const DEFAULT_CONFIG: AppConfig = { export const DEFAULT_CONFIG: AppConfig = {
mode: 'daemon', mode: 'daemon',
provider: 'anthropic',
apiKey: '', apiKey: '',
baseUrl: 'https://api.anthropic.com', baseUrl: 'https://api.anthropic.com',
model: 'claude-sonnet-4-5', model: 'claude-sonnet-4-5',
apiVersion: '',
agentId: null, agentId: null,
skillId: null, skillId: null,
designSystemId: null, designSystemId: null,
+15
View File
@@ -1,10 +1,25 @@
export type ExecMode = 'daemon' | 'api'; export type ExecMode = 'daemon' | 'api';
// Which BYOK model endpoint to talk to in `mode === 'api'`. Each provider
// has its own request shape — see src/providers/{anthropic,openai,azure,
// google}.ts for the wire details. AWS Bedrock and Google Vertex are
// reached via the `anthropic` provider pointed at an Anthropic-compatible
// proxy (e.g. LiteLLM), which keeps signing on the server where the
// long-lived AWS / GCP credentials belong.
export type ModelProvider = 'anthropic' | 'openai' | 'azure' | 'google';
export interface AppConfig { export interface AppConfig {
mode: ExecMode; mode: ExecMode;
// Active provider when `mode === 'api'`. Older configs that predate the
// multi-provider rework default to 'anthropic' on load.
provider: ModelProvider;
apiKey: string; apiKey: string;
baseUrl: string; baseUrl: string;
model: string; model: string;
// Azure OpenAI only — the api-version query string the Azure REST
// surface requires (e.g. '2024-08-01-preview'). Ignored by every other
// provider so the same config can round-trip through localStorage.
apiVersion?: string;
agentId: string | null; agentId: string | null;
skillId: string | null; skillId: string | null;
designSystemId: string | null; designSystemId: string | null;