接入一家 provider 是两半事:目录是纯数据(Provider / Model),客户端与服务端共用,
defaultCatalog 里有 13 家;preset 只在服务端,等于目录数据加上「建 adapter」与
「把思考强度翻成这家的 modelOptions」两个函数(可选第三个:在线发现模型)。13 家各有
preset,在 @gedatou/cadenza-ai/providers/<id>;此外还有两个不在目录里的入口——
openaiCompatiblePreset(config) 接任何 OpenAI 兼容端点,byteplus
是占位(create() 抛错,等 @tanstack/ai-byteplus 成为 peer)。
密钥走 TanStack AI 的 BYOK 形态:浏览器持有 key,每次请求以 x-byok-<id> 头送到你的
route handler;服务端只用不存,缺 key 回 401,客户端弹对话框。
使用
服务端一个 route handler,装到哪家 provider 就装哪家的 adapter(都是可选 peer):
pnpm add @gedatou/cadenza-ai @tanstack/ai-openai// app/api/ai/chat/route.ts
import { openai } from '@gedatou/cadenza-ai/providers/openai'
import { createChatHandler } from '@gedatou/cadenza-ai/server'
export const { POST, GET } = createChatHandler({ providers: [openai], defaultModel: 'openai/gpt-5.2' })客户端把 ByokClient 交给 useChat,对话框订阅同一个 client:
import {
ByokKeyDialog,
createByok,
defaultCatalog,
fetchServerSentEvents,
useChat,
useModelSelection,
useServerCoverage,
} from '@gedatou/cadenza-ai'function Chat(): ReactElement {
const byok = useMemo(() => createByok({ catalog: defaultCatalog }), [])
const { coverage } = useServerCoverage(byok)
const { selection, forwardedProps } = useModelSelection()
const chat = useChat({
connection: fetchServerSentEvents('/api/ai/chat'),
byok,
byokProvider: () => selection.provider,
forwardedProps,
})
return (
<>
{/* TranscriptProvider / Transcript / Composer — see /docs/ai/conversation */}
<ByokKeyDialog byok={byok} catalog={defaultCatalog} coverage={coverage} />
</>
)
}思路
- 目录是数据,两端共用。
defaultCatalog由 13 个Provider组成,每个带models、byok(头名与 env 名)、keyRequired与runtime;不 import 任何 adapter, 浏览器包里没有服务端代码。 - preset 只在服务端。
ProviderPreset = Provider + create(model, key) + thinking(level, model) + discoverModels?(key),从./providers/<id>导入才会带上 adapter——每个文件只 import 自家的@tanstack/ai-<id>,装几家就打包几家。 - 选择只走四个键。 客户端
forwardedProps里的provider/model/thinking/search是服务端唯一读取的内容:provider 必须在providers里,model 必须在 preset 的目录里 (带discoverModels的 ollama / openai-compatible 放行目录外的 id),thinking 不合法按'off',search还要模型自己声明了Model.search才算数;其余键一概不看,也不会 spread 进chat()。能力判定一律由目录说了算,请求说什么不算数。 - 密钥归浏览器。
ByokClient持有 key,useChat({ byok, byokProvider })每次请求给当前 provider 盖x-byok-<id>头;服务端getByokKey头优先、env 兜底,都没有就回 401byok_missing,客户端收到后打开ByokKeyDialog。
模型目录
Catalog 是不可变对象:providers / models 两个数组,加 getProvider(id) /
getModel('provider/id') 与 withProvider(p) / withoutProvider(id)(返回新目录)。
页顶 demo 用 defaultCatalog.withProvider(local) 加了一行自定义 provider。
Model 的字段:id / name / provider / input(模态数组)/ reasoning /
contextWindow? / maxOutputTokens? / cost?(美元 / 百万 token,含 cacheRead /
cacheWrite)/ thinkingLevels?(缺省 = reasoning ? THINKING_LEVELS : ['off'])/
search?(模型有厂商侧联网搜索,缺省 false)。
modelRef(model) 拼出 'openai/gpt-5.2',parseModelRef(ref) 只切第一个斜杠——
OpenRouter 与 Vercel Gateway 的模型 id 自己就含斜杠。费用估算见
用量与费用。
每张目录表的文件头都记了它抄自哪个版本的 adapter(sourceVersion),升级 adapter 时 diff 那张表。
页顶 demo 的源码:
预设
每家一个文件,import { <id> } from '@gedatou/cadenza-ai/providers/<id>'(vercel-gateway
导出名是 vercelGateway)。create(model, key) 收到的 key 是 getByokKey 解析出的头或
env;keyRequired: false 的两家会收到 null。thinking 片段的逐格真源在
思考强度。
思考强度
七级 ThinkingLevel,顺序即强度:
THINKING_LEVELS // ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']supportedThinkingLevels(model):非推理模型只有['off'];推理模型是model.thinkingLevels ?? THINKING_LEVELS。clampThinkingLevel(model, level):不支持的档位向下取最近支持档;目标低于模型下限时 取下限(Fable 5 这类不可关的模型最低'low')。- 服务端
pickSelection读forwardedProps.thinking(不在七级里按'off')并 clamp;resolveThinking在「clamp 后为off且模型不推理」时什么都不发,否则交给preset.thinking(level, model)。
各 preset 的映射(off 一列是关闭时真正发出的片段;三档 = EFFORT_3,minimal → low,
xhigh / max → high):
Anthropic 的四段分代按模型 id 精确匹配,不按前缀猜(opus-5 是 budget 代)。
./server 另导出 EFFORT_3——三档 effort 的折叠表,grok / groq / vercel-gateway /
ollama gpt-oss / openai-compatible 都经它折叠。客户端的选择器(ModelPicker /
ThinkingLevelPicker)见 输入区。
密钥
ByokKeyDialog 订阅一个 ByokClient,client 一提示就自己打开,并聚焦那家的输入框。
createByok({ persistent?, storage?, catalog? })是带默认值的defineByok:默认memoryStorage()——key 只在这个 tab 的内存里,刷新即失;persistent: true换成defaultByokStorage()——能用 WebAuthn 的安全上下文里就是passkeyStorage():密钥环用 passkey(PRF) 派生的 AES-256-GCM 加密后存进 IndexedDB,刷新后处于locked,byok.unlock()(对话框的 Unlock 按钮, 一次生物识别 / PIN)解锁,解锁成功对话框以reason: 'confirm'关闭;浏览器做不到时自动退回内存并在byok.storage.warning里说明,对话框把storage.label/warning原样显示出来。storage传你自己的KeyringStorage时优先。persistent是页面级设置:useChat只在创建 client 时接byok, 改动要连同会话一起重挂。传catalog时,keyRequired: false的 provider(vertex / ollama) 立即标为服务端已覆盖,prepare()不再为它们阻断。useServerCoverage(byok, url?)挂载时fetch('/api/ai/catalog')一次,把返回的coverage喂给byok.setServerCoverage(),并返回{ coverage, providers, error }——coverage传给对话框,行上出现「服务端已有 key」图标(data-server-key)。- 每次请求的头:
useChat({ byok, byokProvider })发送前调byok.prepare(provider)—— 没 key 又没被服务端覆盖就request(provider, 'missing')并阻断;有 key 就盖上x-byok-<id>。byokProvider返回当前 provider id,永远能解析出来。 - 四种
KeyStatus:empty/set(带masked)/locked(带masked)/error(带masked与message,行下显示FieldError)。 - 服务端拒收:route handler 找不到 key 时回 401
{ error: { type: 'byok_missing', provider } }, 客户端翻成ByokMissingError→byok.request(provider, 'missing')→ 对话框打开 (onOpenChange的reason是'none')。本节 demo 就是用脚本化传输的byokMissing('openai')走这条路。 - Ollama 的头:
x-byok-ollama的值不是 key,是 host URL;服务端按ollamaHosts白名单校验(默认 loopback 与私网段)。 - Sign in with OpenRouter:
ByokKeyDialogProvider的children追加在行尾,就是给 OAuth / PKCE 按钮留的位置。OpenRouter 的 PKCE 由@tanstack/ai-openrouter/pkce提供, docs 站直接依赖这个 adapter 包,库本身不 import:startOpenRouterPkceLogin()在sessionStorage存下 code verifier 后跳去 openrouter.ai,回来时 URL 带?code=;页面挂载时completeOpenRouterPkceIntoByok(byok)用 verifier 换 key,byok.update('openrouter', key)写进密钥环并清掉 URL 里的code。ByokClient直接满足它要的OpenRouterByokStore(只需update)。这个交换不可重入——code 一次性、verifier 换完即清,StrictMode 下要自己挡住 第二次 effect。试玩 页的 openrouter 行就是这样接的。
Route handler
docs 站的 /api/ai/chat 原文——Playground 就跑在它上面:
// docs/app/api/ai/chat/route.ts
import { anthropic } from '@gedatou/cadenza-ai/providers/anthropic'
import { bedrock } from '@gedatou/cadenza-ai/providers/bedrock'
import { deepseek, deepseekWebSearch } from '@gedatou/cadenza-ai/providers/deepseek'
import { gemini } from '@gedatou/cadenza-ai/providers/gemini'
import { grok } from '@gedatou/cadenza-ai/providers/grok'
import { groq } from '@gedatou/cadenza-ai/providers/groq'
import { llmgateway } from '@gedatou/cadenza-ai/providers/llmgateway'
import { mistral } from '@gedatou/cadenza-ai/providers/mistral'
import { ollama } from '@gedatou/cadenza-ai/providers/ollama'
import { openai } from '@gedatou/cadenza-ai/providers/openai'
import { openrouter } from '@gedatou/cadenza-ai/providers/openrouter'
import { vercelGateway } from '@gedatou/cadenza-ai/providers/vercel-gateway'
import { vertex } from '@gedatou/cadenza-ai/providers/vertex'
import { createChatHandler, toolDefinition } from '@gedatou/cadenza-ai/server'
import { z } from 'zod'
// Playground is pure BYOK (spec Q1): keys arrive per request in `x-byok-<provider>`
// headers; nothing is read from the deployment's env. `ollama` is dropped by the
// handler itself on Vercel (`runtime: 'local'`); `byteplus` is a placeholder and
// `openaiCompatiblePreset` needs a consumer's endpoint, so neither is wired.
export const maxDuration = 300
const getTime = toolDefinition({
name: 'get_time',
description: 'Current time in a timezone',
inputSchema: z.object({ tz: z.string() }),
}).server(async ({ tz }) => ({ iso: new Date().toLocaleString('en-US', { timeZone: tz }) }))
export const { POST, GET } = createChatHandler({
providers: [openai, anthropic, gemini, openrouter, grok, groq, mistral, vercelGateway, llmgateway, bedrock, vertex, ollama, deepseek],
defaultModel: 'openai/gpt-5.2',
systemPrompts: ['You are the cadenza docs playground assistant. Keep answers short.'],
// The function form, because `deepseekWebSearch()` is a provider tool: any
// adapter that does not know the brand degrades it into a schema-less
// function call nothing can execute, so it must only reach DeepSeek. And it
// only goes out when the user asked for it — `pickSelection` has already
// checked `Model.search`, so a request cannot switch on what the model lacks.
tools: sel => sel.preset.id === 'deepseek' && sel.search ? [getTime, deepseekWebSearch()] : [getTime],
})// docs/app/api/ai/catalog/route.ts
import { anthropic } from '@gedatou/cadenza-ai/providers/anthropic'
// … the same thirteen imports as ./chat
import { createCatalogHandler } from '@gedatou/cadenza-ai/server'
import process from 'node:process'
// Same list as ./chat; the catalog handler does not drop `ollama` on Vercel by
// itself, so the Playground builds its catalog from what the server reports.
const onVercel = process.env.VERCEL === '1'
const presets = [openai, anthropic, gemini, openrouter, grok, groq, mistral, vercelGateway, llmgateway, bedrock, vertex, ollama, deepseek]
export const { GET } = createCatalogHandler(presets.filter(p => !(onVercel && p.runtime === 'local')))Playground 的目录就是 useServerCoverage(byok).providers 建出来的(createCatalog(providers)),
所以本地 dev 看到 13 家、Vercel 上 12 家,客户端不用再抄一遍清单。
POST 的生命周期:content-length 超过 maxBodyBytes → 413;chatParamsFromRequest
解析(坏请求 → 400);pickSelection 解析三键(→ 400 unknown_provider /
unknown_model);onSelect 钩子;getByokKey(缺 → 401 byok_missing);ollama host
白名单;preset.create(model, key);resolveThinking;chat({...}) →
toServerSentEventsResponse,客户端断开即 abort。GET 只在 persistence /
durability 配置了才有内容(?threadId= 重建、?runId= 续流),否则 404。
process.env.VERCEL === '1' 时 runtime: 'local' 的 preset 被剔除。
createCatalogHandler(presets) 的 GET 返回 { providers, coverage, generatedAt }:
providers 是 preset 去掉三个函数后的纯数据,coverage 是每家「服务端能否自己出 key」
的布尔表。带 ?refresh=1&provider=<id> 时改调 preset.discoverModels(key)(key 与 chat
一样:头优先、env 兜底)并返回 { provider, models };preset 不认识 → 400
unknown_provider,没有 discoverModels → 400 discover_unsupported,上游失败 → 502
discover_failed(上游的错误文本不回显)。
x-byok-ollama 携带的是 host URL 而不是密钥,而 ?refresh=1&provider=ollama 会去 fetch 它,
所以这条路径和 chat 一样过 ollamaHostAllowed(缺省只放行 loopback 与 RFC 1918),不合规回 400
host_not_allowed。自建 handler 时两处都要设 ollamaHosts:
createCatalogHandler(presets, { ollamaHosts }) 与 createChatHandler({ ollamaHosts })——
少设一处就等于把那条路径敞开。判定是解析地址而不是比字符串前缀:10.attacker.com 是一个普通域名,
它的主人想让它解析到哪就到哪。
ChatHandlerOptions
厂商内建工具
普通工具(toolDefinition(...).server(...))跨 provider 通用,平铺成数组给 tools 就行。
厂商内建工具不一样:它带着厂商烙印(一个私有的 metadata.__kind),认得它的 adapter 会把它
转成 { type: 'web_search' } 这类原生形状交给厂商服务端执行;不认得的 adapter 会静默地把它
降级成一个没有 schema、服务端也没有执行器的函数调用。所以只要数组里有一个内建工具,tools
就必须用函数形式,按选择过滤:
import { deepseek, deepseekWebSearch } from '@gedatou/cadenza-ai/providers/deepseek'
export const { POST, GET } = createChatHandler({
providers: [openai, anthropic, deepseek],
tools: sel => sel.preset.id === 'deepseek' && sel.search ? [getTime, deepseekWebSearch()] : [getTime],
})deepseekWebSearch() 是 DeepSeek 的服务端联网搜索:模型自己搜索、打开、读页面(服务端自动
续跑上限 10 轮)再回答,我们这边不执行任何东西,也不需要第二个密钥——它记在用户自己那把
DeepSeek key 上。
要不要挂这把工具,交给用户:Model.search 标出哪些模型有这个能力,
SearchToggle 按能力显隐地给出开关,答案经
forwardedProps.search 到 pickSelection,服务端在 Selection.search 上读到。pickSelection
自己会用 Model.search 兜一层,所以伪造的请求打不开模型没有的能力。挂上之后开不开搜索仍由
模型决定,所以只在它真去搜时才产生搜索费用。
每一轮搜索会以一张工具卡片出现在回答里,名字是 web_search,输入是 DeepSeek 记录的那次动作
(search / open_page / find_in_page)。这些调用标了 metadata.providerExecuted:agent loop
认得这个约定,不会去找本地执行器,卡片也不会一直转圈。
为什么要这么映射:上游的 Responses adapter 只认 function_call 输出项,别的一概丢弃——而丢弃不只是
少了点信息。客户端的流处理器按消息存一份文本缓冲,只在 TOOL_CALL_START 时清零;但只要尾部落进
别的 part 类型,它就新开一个文本 part 并把整份缓冲塞进去。DeepSeek 的搜索在一次响应里自动续跑最多
10 轮(推理 → 文本 → 搜索 → 推理 → 文本),搜索一旦隐形,推理负责切分而没有任何信号负责清零,于是每段
文本都把前面所有段重复一遍。把搜索报成工具调用,既是忠实的映射,也把段落边界还了回来。@ai-sdk/openai
对同一批 wire 事件做的是同一件事。
剩下的缺口:Sources 折叠块对 DeepSeek 目前是空的。output_text.annotations(url 引用)被上游
adapter 丢弃,所以 sourcesOf 唯一读得到的就是我们合成的那条工具结果,而里面放的是 DeepSeek 记录的
那次动作;它只从数组、或带 results 键的对象里取 source,我们观测到的动作形状两者都不是。
这是对已观测形状下的结论,不是对协议的保证:OpenAI 的同名端点就有 web_search_call.action.sources
这个 include 选项。DeepSeek 的动作对象哪天带上结果字段,这一段要重新量。
历史重放的顺序
Responses 是无状态的:每一轮都要把之前的 function_call 连同它的 function_call_output 一起回传。
基类把一条 assistant 消息发成 [...function_call, message],而工具结果作为后一条消息到达,于是
「先调工具、再回答」的一轮会重放成 function_call → message → function_call_output。DeepSeek 文档把
function_call 定义为「归并到相邻的 assistant 消息」——调用被并进它后面那条消息、该轮就此关闭,
排在后面的 output 已无调用可配,于是只要上一轮用过工具,用户的下一个问题必定 400 No tool output found for tool call <call_id>。
DeepSeekResponsesAdapter 覆盖 convertMessagesToInput,重排成
[assistant 文本, 普通调用, 它们的输出, web_search_call]:调用与输出因此永远相邻,
而入站那次改写不会漏到线上——历史里被改写成 function_call 的内建搜索,按
metadata.providerExecuted 加工具名两个条件认出来(只看 metadata 会把将来别的厂商内建工具
也还原成搜索;只看名字会撞上消费者自己叫 web_search 的函数,那个没有这份 metadata),
还原成 { type: 'web_search_call', id, action, status },配对的那条 output 一并丢掉。
这一步不是可选的:DeepSeek 只对原样回传的 web_search_call 「自动恢复搜索结果」,
递给它改写后的 function_call 就等于把搜到的内容扔了,之后每次追问模型都会重搜一遍(实测)。
搜索项必须排在输出之后——夹在调用和它的输出之间会和 assistant 消息一样切断配对(实测 400)。2026-08-30 对真实接口实测:楔入必失败、相邻必成功,与是否带 item id、
是否回传 reasoning 项都无关;并行调用([msg, fc1, fc2, out1, out2])同样通过。
Chat Completions 没有这个问题——那边 content 和 tool_calls 挂在同一个 assistant 对象上,根本没有
顺序可错。把 DeepSeek 挪到 Responses 才暴露出来。
环境变量
getByokKey(request, provider) 头优先,然后按 byok.env 的顺序读 env;
createCatalogHandler 的 coverage 也按同一张表判断。docs 的部署一个都没设,所以是纯 BYOK。
自定义 provider
任何说 OpenAI 协议(Chat Completions / Responses)的端点,一个 openaiCompatiblePreset(config)
就是完整的 preset——byok 从 id / env 生成,create 走 @tanstack/ai-openai/compatible
的 openaiCompatibleText,thinking 缺省是 openaiCompatibleThinking(推理模型发
reasoning_effort),discoverModels 是 GET {baseURL}/models。以 DeepSeek 为例:
// app/api/ai/chat/route.ts
import { openaiCompatiblePreset } from '@gedatou/cadenza-ai/providers/openai-compatible'
import { createChatHandler } from '@gedatou/cadenza-ai/server'
export const deepseek = openaiCompatiblePreset({
id: 'deepseek', // the `x-byok-deepseek` slug: /^[a-z][a-z0-9-]{0,63}$/
label: 'DeepSeek',
baseURL: 'https://api.deepseek.com/v1',
env: 'DEEPSEEK_API_KEY',
models: [
{ id: 'deepseek-chat', name: 'DeepSeek Chat', provider: 'deepseek', input: ['text'], reasoning: false, contextWindow: 128_000 },
{ id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', provider: 'deepseek', input: ['text'], reasoning: true, contextWindow: 128_000 },
],
})
export const { POST, GET } = createChatHandler({ providers: [deepseek] })// app/api/ai/catalog/route.ts — the same preset, so the browser learns about it
import { createCatalogHandler } from '@gedatou/cadenza-ai/server'
import { deepseek } from '../chat/route'
export const { GET } = createCatalogHandler([deepseek])// client — build the catalog from what the server reports (Playground does the same)
const byok = useMemo(() => createByok(), [])
const { coverage, providers } = useServerCoverage(byok)
const catalog = useMemo(() => (providers ? createCatalog(providers) : defaultCatalog), [providers])
const { selection, forwardedProps } = useModelSelection({ catalog })
// … <ByokKeyDialog byok={byok} catalog={catalog} coverage={coverage} />不想等服务端回目录,也可以把同一份 models 写成 Provider 放在两端共用的文件里,客户端
defaultCatalog.withProvider(deepseek)——目录是纯数据,两条路都成立。
OpenAICompatibleConfig 的字段:id / label / baseURL / models 必填;env
(string | string[],getByokKey 的兜底顺序)、thinking(整个换掉映射函数)、
name(adapter 在事件里报的名字,缺省 = id)、api 可选。
api 是端点说哪套 OpenAI 协议:缺省 'chat-completions'({baseURL}/chat/completions),
'responses' 打 {baseURL}/responses。少数兼容厂商两套都实现,而且能力并不对齐——DeepSeek
的内建 web_search 和标准推理事件就只在 Responses 那套上。
不是 OpenAI 协议的端点走底层的 definePreset:目录数据(Provider)加上 create(model, key)
与 thinking(level, model),可选 discoverModels(key)。definePreset 校验
byok.id === id(头名由 id 生成),其余是恒等;create 收到的 key 在 keyRequired: false
时可能是 null,thinking 收到的是 clamp 后的档位;给了 discoverModels 后目录外的 id
才放行。./server 导出的各家映射函数(openaiThinking … noThinking)可直接复用给同协议
的端点。
MCP
chat({ mcp }) 是 @tanstack/ai 的接口:clients 收一组 MCPToolSource——
{ tools({ lazy? }), close(), readResource?(uri) } 的结构类型,@tanstack/ai-mcp 的
MCPClient / MCPClients 按形状满足——chat() 自己发现工具、执行调用,run 结束时按
connection 策略处理连接。createChatHandler 没有 mcp 选项;要接 MCP,用 ./server
转出的 chat() 自己写 handler——这条路绕过了选择解析与 BYOK。
import { chat, chatParamsFromRequest, toServerSentEventsResponse } from '@gedatou/cadenza-ai/server'
import { createOpenaiChat } from '@tanstack/ai-openai'
export async function POST(request: Request): Promise<Response> {
const params = await chatParamsFromRequest(request)
const stream = chat({
adapter: createOpenaiChat('gpt-5.2', process.env.OPENAI_API_KEY ?? ''),
messages: params.messages,
// `clients` is any `MCPToolSource` — TanStack AI's `@tanstack/ai-mcp` MCPClient fits by shape.
mcp: { clients: [mcpClient], connection: 'keep-alive' },
})
return toServerSentEventsResponse(stream)
}ChatMCPOptions 的四个字段:
若为 docs 站配了服务端 key(getByokKey 的 env 兜底),任何人都能免费调用这条路由,
需要加一层按 IP 限流的中间件——本站纯 BYOK,不做。
不接入的 harness
harness 类 adapter(claude-code / acp / codex / opencode / grok-build)需要
@tanstack/ai-sandbox,且 key 只从宿主 process.env 复制——BYOK 走不通,所以没有 preset。
同样只转发不做页面的还有:useRealtimeChat(实时语音)、useGenerateImage 等生成家族 hook、
useMcpAppBridge(MCP Apps 宿主)与 live 订阅的 connectionStatus;它们出现在
导出的类型 里,仅此而已。
状态与 className
目录与 preset 是数据,没有 DOM;这一节只关乎 ByokKeyDialog。
ByokKeyDialog 没有 className——外壳是 Dialog 家族,样式钩子是 data-slot;
ByokKeyDialogProvider 的 className 是 string,落在行上。
导出的类型
root 入口(目录部分见 会话):
// BYOK:转出自 @tanstack/ai-client/byok
import type { ByokClient, ByokPrompt, ByokSnapshot, KeyringStorage, KeyStatus } from '@gedatou/cadenza-ai'
import { defaultByokStorage, defineByok, isPasskeyStorageSupported, memoryStorage, passkeyStorage } from '@gedatou/cadenza-ai'
// 门面
import type { ByokKeyDialogLabels, ByokKeyDialogProps, ByokKeyDialogProviderProps, CreateByokOptions, ServerCoverage } from '@gedatou/cadenza-ai'
import { createByok, DEFAULT_BYOK_KEY_DIALOG_LABELS, useByok, useServerCoverage } from '@gedatou/cadenza-ai'@gedatou/cadenza-ai/server(无 'use client',只给 route handler):
@gedatou/cadenza-ai/providers/<id>(每个文件只 import 自家 adapter):
脚本化传输里的 byokMissing(provider) 见 脚本化传输。
Props
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。
ByokKeyDialog
一个 ByokClient 的密钥对话框。订阅 client,snapshot.prompt 一出现就打开自己
(prepare() 因缺 key 阻断,或服务端回了 byok_missing),并聚焦那家的输入框;
snapshot.locked 时页脚多一个 Unlock 按钮。
ByokKeyDialogLabels 与默认英文:
ByokKeyDialogProvider
一家的行:FieldLabel = provider.label,密码 Input,保存 / 清除图标按钮。
provider 不在 catalog 里时抛错。输入框在 snapshot.prompt.provider 命中时自动聚焦,
有 key 时 placeholder 显示 masked;保存调 byok.update(id, key)(草稿为空时禁用),
清除调 byok.clear(id)(empty 时禁用),error 态在行下显示 message。
5 个类型一并导出:ByokKeyDialogProps / ByokKeyDialogProviderProps / ByokKeyDialogLabels /
CreateByokOptions / ServerCoverage。