Cadenza
EN

接入提供者

模型目录、七级思考强度、BYOK 密钥与 route handler——客户端选什么,服务端就按 preset 翻成哪家的参数

接入一家 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 兜底,都没有就回 401 byok_missing,客户端收到后打开 ByokKeyDialog。

模型目录

Catalog 是不可变对象:providers / models 两个数组,加 getProvider(id) / getModel('provider/id') 与 withProvider(p) / withoutProvider(id)(返回新目录)。 页顶 demo 用 defaultCatalog.withProvider(local) 加了一行自定义 provider。

id标签keyRequired / runtime备注
openaiOpenAItrue / node—
anthropicAnthropictrue / node思考强度按世代分四段,见下
geminiGoogle Geminitrue / node—
openrouterOpenRoutertrue / node模型 id 自带 vendor/model;目录是建议清单,不是白名单
grokxAI Groktrue / nodegrok-build-* 不发推理参数
groqGroqtrue / node—
mistralMistraltrue / nodeadapter 没有 thinking 设置项,全部 reasoning: false
vercel-gatewayVercel AI Gatewaytrue / node无定价与上下文数据
llmgatewayLLM Gatewaytrue / node—
bedrockAmazon Bedrocktrue / nodebearer key;Converse 路径无推理参数,无定价
vertexVertex AIfalse / node复用 gemini 的模型表;ADC 或 express key 由 adapter 读 env
ollamaOllamafalse / local本地模型无定价;x-byok-ollama 头的值是 host URL;目录外的 tag 也放行
deepseekDeepSeektrue / node型号、价格与档位抄自 pi-ai 的生成表(TanStack 没有 DeepSeek adapter);走 Responses 端点,带内建联网搜索

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 片段的逐格真源在 思考强度。

idcreate(model, key)env(按顺序)thinking 片段备注
openaicreateOpenaiChat(model, key)OPENAI_API_KEY{ reasoning: { effort, summary: 'auto' } }—
anthropiccreateAnthropicChat(model, key)ANTHROPIC_API_KEY{ thinking: {…} },按世代四段—
geminicreateGeminiChat(model, key)GOOGLE_API_KEY → GEMINI_API_KEY{ thinkingConfig: {…} }—
openroutercreateOpenRouterText(model, key)OPENROUTER_API_KEY{ reasoning: { effort } },七级原样目录外的 vendor/model 不放行(无 discoverModels)
grokcreateGrokText(model, key)XAI_API_KEY{ reasoning: { effort } },三档grok-build-* 永远 {}
groqcreateGroqText(model, key)GROQ_API_KEY{ reasoning_effort, reasoning_format: 'parsed' },三档只对 reasoning: true 的模型发;不发 include_reasoning(与 reasoning_format 互斥)
mistralcreateMistralText(model, key)MISTRAL_API_KEY{}没有 thinking 设置项
vercel-gatewaycreateVercelGatewayText(model, key, { api: 'chat' })AI_GATEWAY_API_KEY → VERCEL_OIDC_TOKEN{ reasoning: { effort }, include_reasoning: true },三档钉在 Chat Completions 路径——工厂默认的 Responses 路径没有 reasoning 键
llmgatewaycreateLLMGatewayText(model, key)LLM_GATEWAY_API_KEY{ reasoning_effort },七级原样该包没有 ./byok,byok 由本包定义
bedrockcreateBedrockConverse(model, key)BEDROCK_API_KEY → AWS_BEARER_TOKEN_BEDROCK{}只做 bearer;SigV4 是部署级配置,不走 BYOK;官方 bedrockByok 无 env,本包补上
vertexvertexText(model, key ? { apiKey: key } : undefined)GOOGLE_VERTEX_API_KEY;否则 project + location(见下)同 gemini(复用函数)keyRequired: false;env 全缺时 create 抛 VertexAuthError
ollamakey ? createOllamaChat(model, key) : ollamaText(model)OLLAMA_HOST(host,不是 key;缺省 http://localhost:11434){ think }runtime: 'local',Vercel 上剔除;discoverModels = GET {host}/api/tags
deepseeknew DeepSeekResponsesAdapter(client, model)——compatible Responses adapter 的子类DEEPSEEK_API_KEY{ reasoning: { effort } },七档原样由 openaiCompatiblePreset({ api: 'responses' }) 组成,只换 adapter;discoverModels = GET /models。DeepSeek 同一个 host 下三套协议能力不等:只有 /responses 有内建 web_search,也只有它把推理发成标准的 response.reasoning_text.delta(基类已解析)。子类覆盖两个钩子:mapOptionsToRequest(基类的 Responses 转换器把每个 tool 都压成 { type: 'function' },改走按 metadata.__kind 分派的那个,deepseekWebSearch() 才能以 { type: 'web_search' } 上线);convertMessagesToInput(把 assistant 的文本挪到它的 function_call 之前,见下)
openai-compatibleopenaiCompatibleText(model, { baseURL, apiKey: key, name })config.env{ reasoning_effort },三档;config.thinking 可换openaiCompatiblePreset(config);discoverModels = GET {baseURL}/models
byteplus抛 ErrorARK_API_KEY → BYTEPLUS_API_KEY{}占位;models: [],不入 defaultCatalog

思考强度

七级 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):

preset片段off不可关
openai{ reasoning: { effort, summary: 'auto' } };minimal → 'minimal',low / medium / high 同名,xhigh / max → 'high';非推理模型 {}{ reasoning: { effort: 'none' } }—
anthropic · budget 代(opus-5 / opus-5-fast / opus-4-5 / sonnet-4-5 / haiku-4-5 / opus-4-1){ thinking: { type: 'enabled', budget_tokens } };1024 / 4096 / 16000 / 32000 / 48000 / 64000{ thinking: { type: 'disabled' } }—
anthropic · 4.6(opus-4-6 / sonnet-4-6){ thinking: { type: 'adaptive', display: 'summarized' } },无分档;目录 thinkingLevels: ['off', 'medium']{ thinking: { type: 'disabled' } }—
anthropic · 4.7+ / Sonnet 5(opus-4-7 / opus-4-8 / sonnet-5)adaptive + output_config: { effort };minimal → 'low',其余同名{ thinking: { type: 'disabled' } }—
anthropic · Fable 5(fable-5)同上目录 thinkingLevels 从 'low' 起,off / minimal 被 clamp 到 'low';preset 自身也把 off 落到 'low'是
gemini 3.x{ thinkingConfig: { includeThoughts: true, thinkingLevel } };MINIMAL / LOW / MEDIUM / HIGH,high / xhigh / max → HIGH{}(不发)—
gemini 2.5{ thinkingConfig: { includeThoughts: true, thinkingBudget } };1024 / 4096 / 8192 / 16384 / 20480 / 24576{ thinkingConfig: { thinkingBudget: 0 } }—
openrouter{ reasoning: { effort: level } },七级原样透传{ reasoning: { enabled: false } }—
grok{ reasoning: { effort } },三档;grok-build-* 永远 {}{ reasoning: { effort: 'none' } }—
groq{ reasoning_effort, reasoning_format: 'parsed' },三档;非推理模型 {}{ reasoning_effort: 'none' }—
vercel-gateway{ reasoning: { effort }, include_reasoning: true },三档{}—
llmgateway{ reasoning_effort: level },七级原样{}—
vertex同 gemini(geminiThinking 复用)同 gemini—
ollamagpt-oss 系 { think: 'low' | 'medium' | 'high' },三档;其它 { think: true }{ think: false }—
deepseek{ reasoning: { effort: level } },七档原样(deepseekResponsesThinking);非推理模型 {}{ reasoning: { effort: 'none' } }Responses 端点只有一个 reasoning.effort(summary 不支持)。Chat Completions 方言的 deepseekThinking({ thinking: { type }, reasoning_effort },minimal / medium 折到 low、xhigh 折到 high)仍然导出,给同方言的其它端点用
openai-compatible{ reasoning_effort },三档;非推理模型 {};config.thinking 可整个换掉{}—
mistral / bedrock{}(noThinking);目录里全部 reasoning: false,档位只有 off{}—

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

选项类型默认值说明
providersreadonly ProviderPreset[]——
defaultModelstring—'provider/model';请求不带 provider / model 时用
systemPromptsSystemPrompt[] | ((selection: Selection) => SystemPrompt[])—只由服务端配置
toolsReadonlyArray<AnyTool> | ((selection: Selection) => ReadonlyArray<AnyTool>)[]与客户端工具 mergeAgentTools,同名服务端优先。有厂商内建工具时必须用函数形式,见下
middlewareArray<ChatMiddleware<TContext>>[]—
context(request: Request) => TContext | Promise<TContext>——
agentLoopStrategyAgentLoopStrategyTanStack 默认不放开给客户端
onSelect(selection: Selection, request: Request) => Selection | Response—检查或替换选择;返回 Response 即拒绝
persistenceAIPersistence<ChatTranscriptStores>—见 服务端持久化;类型写成 unknown,peer 按需 import
authorizeReconstructChatOptions['authorize']—只与 persistence 一起读
durability(request: Request) => StreamDurability—断线续流;docs 不开
maxBodyBytesnumber4 * 1024 * 1024超过回 413
ollamaHostsreadonly string[]loopback + RFC 1918x-byok-ollama 允许指向的 host
debugDebugOption—透传给 chat() 与 SSE

厂商内建工具

普通工具(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。

providerenv(按顺序)说明
openaiOPENAI_API_KEY—
anthropicANTHROPIC_API_KEY—
geminiGOOGLE_API_KEY → GEMINI_API_KEY—
openrouterOPENROUTER_API_KEY—
grokXAI_API_KEY—
groqGROQ_API_KEY—
mistralMISTRAL_API_KEY—
vercel-gatewayAI_GATEWAY_API_KEY → VERCEL_OIDC_TOKEN—
llmgatewayLLM_GATEWAY_API_KEY—
bedrockBEDROCK_API_KEY → AWS_BEARER_TOKEN_BEDROCKbearer token,不做 SigV4
vertexGOOGLE_VERTEX_API_KEYkeyRequired: false;coverage 特判:有这个 key,或(GOOGLE_CLOUD_PROJECT / GOOGLE_VERTEX_PROJECT 任一 且 GOOGLE_CLOUD_LOCATION / GOOGLE_VERTEX_LOCATION 任一);adapter 自己读这些
ollamaOLLAMA_HOST(adapter 读;不是 byok.env)keyRequired: false,总是覆盖;x-byok-ollama 头是 host URL,覆盖 env;discoverModels(null) 也用同一个缺省 host
deepseekDEEPSEEK_API_KEY—
openai-compatibleconfig.env由 openaiCompatiblePreset 的调用方给
byteplusARK_API_KEY → BYTEPLUS_API_KEY占位

自定义 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 的四个字段:

字段类型默认值说明
clientsMCPToolSource[]—发现工具的来源
connection'close' | 'keep-alive''close'run 结束(agent 循环跑完、流排空)后关掉每个连接;'keep-alive' 由你管生命周期,跨请求复用
lazyToolsbooleanfalse转发为 tools({ lazy: true }),延后拉取 schema
onDiscoveryError(error, source) => void | Promise<void>重新抛出单个来源发现失败时:正常返回则跳过它继续,抛出则整次 chat() 失败

若为 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。

属性出现在值出现时机
data-providerbyok-key-dialog-providerprovider id总是
data-key-statusbyok-key-dialog-provider"empty" | "set" | "locked" | "error"镜像 client 里这家的 KeyStatus.state
data-server-keybyok-key-dialog-provider空串!provider.keyRequired 或 coverage[id] === true
data-slot是什么
byok-key-dialogDialogPopup(Dialog 家族)
byok-key-dialog-provider一行:Field + 密码 Input + 保存 / 清除图标按钮

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):

导出签名 / 说明
ProviderPresetProvider & { create(model: string, key: string | null): AnyTextAdapter; thinking(level: ThinkingLevel, model: Model): Record<string, unknown>; discoverModels?(key: string | null): Promise<Model[]> }
definePreset(preset)恒等 + 校验 byok.id === id
createChatHandler(options)(ChatHandlerOptions) => { POST, GET },见 Route handler
ChatHandlerOptions / ChatHandler上表 / { POST, GET }
createCatalogHandler(presets, options?)=> { GET }:{ providers, coverage, generatedAt };options.ollamaHosts 同 createChatHandler
ollamaHostAllowed(host, allow?)x-byok-ollama 的准入判定:解析地址,缺省只放行 loopback 与 RFC 1918
pickSelection(forwardedProps, presets, { defaultModel? })=> Selection | Response;Selection = { preset, model, thinking, search }
resolveThinking(preset, model, level)clamp 后交给 preset.thinking;非推理模型关着时 {}
openaiThinking / anthropicThinking / geminiThinking / openrouterThinking / grokThinking / groqThinking / vercelGatewayThinking / llmgatewayThinking / ollamaThinking / openaiCompatibleThinking / deepseekThinking / deepseekResponsesThinking / noThinking(level, model) => fragment,各 preset 的映射函数(vertex 复用 geminiThinking,mistral / bedrock / byteplus 用 noThinking)
EFFORT_3{ minimal: 'low', low: 'low', medium: 'medium', high: 'high', xhigh: 'high', max: 'high' }
转出自 @tanstack/aichat / chatParamsFromRequest / maxIterations / memoryStream / mergeAgentTools / toolDefinition / toServerSentEventsResponse
转出自 @tanstack/ai/byokdefineByokProvider
转出自 @tanstack/ai/byok/serverbyokMissing / getByokKey

@gedatou/cadenza-ai/providers/<id>(每个文件只 import 自家 adapter):

子路径导出引入的 peer
providers/openaiopenai@tanstack/ai-openai
providers/anthropicanthropic@tanstack/ai-anthropic
providers/geminigemini@tanstack/ai-gemini
providers/openrouteropenrouter@tanstack/ai-openrouter
providers/grokgrok@tanstack/ai-grok
providers/groqgroq@tanstack/ai-groq
providers/mistralmistral@tanstack/ai-mistral
providers/vercel-gatewayvercelGateway@tanstack/ai-vercel-gateway
providers/llmgatewayllmgateway@tanstack/ai-llmgateway
providers/bedrockbedrock@tanstack/ai-bedrock
providers/vertexvertex@tanstack/ai-vertex
providers/ollamaollama、discoverOllamaModels(host)@tanstack/ai-ollama
providers/openai-compatibleopenaiCompatiblePreset(config)、类型 OpenAICompatibleConfig@tanstack/ai-openai(/compatible 子路径)
providers/deepseekdeepseek、deepseekWebSearch()@tanstack/ai-openai(/compatible 与 /tools 子路径)
providers/byteplusbyteplus(占位)无

脚本化传输里的 byokMissing(provider) 见 脚本化传输。

Props

顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。

ByokKeyDialog

一个 ByokClient 的密钥对话框。订阅 client,snapshot.prompt 一出现就打开自己 (prepare() 因缺 key 阻断,或服务端回了 byok_missing),并聚焦那家的输入框; snapshot.locked 时页脚多一个 Unlock 按钮。

Prop类型默认值说明
byokByokClient——
catalogCatalog—行的标签与 keyRequired 从这里来
coverageRecord<string, boolean>—useServerCoverage(byok).coverage;ByokClient 不外露自己的覆盖表,所以单独告诉对话框
defaultOpenbooleanfalse—
openboolean—受控
onOpenChange(open: boolean, details: DialogChangeEventDetails | ChangeEventDetails<'none' | 'confirm'>) => void—程序性打开(client 的提示)带 reason: 'none';Confirm 按钮 / 行内 Enter 关闭时带 reason: 'confirm'——恢复被拒的发送就在这里做;details.cancel() 拦下
labelsPartial<ByokKeyDialogLabels>DEFAULT_BYOK_KEY_DIALOG_LABELS—
childrenReactNode目录里每家一个 ByokKeyDialogProvider—

ByokKeyDialogLabels 与默认英文:

键默认出现在
titleAPI keys标题
descriptionKeys stay in this browser and are sent per request in a header.描述
save / clearSave / Clear两个图标按钮的 aria-label
unlockUnlock页脚按钮,locked 时
closeClose页脚按钮
confirmConfirm页脚主按钮:保存所有填了草稿的行再关闭;任一行输入框里按 Enter 等价
serverKeyServer key「服务端已有 key」图标的 aria-label

ByokKeyDialogProvider

一家的行:FieldLabel = provider.label,密码 Input,保存 / 清除图标按钮。 provider 不在 catalog 里时抛错。输入框在 snapshot.prompt.provider 命中时自动聚焦, 有 key 时 placeholder 显示 masked;保存调 byok.update(id, key)(草稿为空时禁用), 清除调 byok.clear(id)(empty 时禁用),error 态在行下显示 message。

Prop类型默认值说明
providerstring—目录里的 provider id
childrenReactNode—追加在行尾——例如一个 OAuth / PKCE 按钮
classNamestring—落在行上

5 个类型一并导出:ByokKeyDialogProps / ByokKeyDialogProviderProps / ByokKeyDialogLabels / CreateByokOptions / ServerCoverage。