commit 5987b2a1f2f6ea49cbc71072e84862856edb14d4 Author: InkerBot Date: Sun Aug 23 02:12:08 2026 +0800 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3cb3f42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/.gradle +/.idea +/build +/run diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..63d0cee --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("java") +} + +group = "com.bytenya.ztoken" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +dependencies { + testImplementation(platform("org.junit:junit-bom:6.0.0")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..8d323d6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,43 @@ +# AI API 中转站 — 资料索引 + +本目录收集各 AI provider 的 API 规格与"怪癖"(quirks),用于实现一个多格式兼容的中转站(gateway)。 + +**资料采集时间:2026-08-12。** 各家文档变动频繁(尤其是模型名与 thinking 相关参数),落地实现前请对照文件头部的来源 URL 复核。 + +## 文件清单 + +| 文件 | 内容 | +| --- | --- | +| **[api-design.md](./api-design.md)** | **本项目的 API 契约设计**(双模板入口、IR、能力降级、thinking 桥接)— 其余文件是它的调研依据 | +| **[transform-spec.md](./transform-spec.md)** | **转写方案 v0.3**(四家求同存异、上游 Profile、请求/响应改写管线 R1–R9 / S1–S9、推理内容可信度分级)— api-design 的落地层 | +| [openai.md](./openai.md) | OpenAI Chat Completions + Responses API 规格与怪癖 | +| [anthropic.md](./anthropic.md) | Anthropic Messages API 规格与怪癖 | +| [deepseek.md](./deepseek.md) | DeepSeek API 规格与怪癖 | +| [kimi.md](./kimi.md) | Kimi(月之暗面 Moonshot)API 规格与怪癖,**含 Kimi For Coding 独立产品线对比**(§17) | +| [china-providers.md](./china-providers.md) | 国产厂商(通义/GLM/Kimi/豆包/文心/混元/MiniMax/星火)的怪癖 | +| [cross-provider-mapping.md](./cross-provider-mapping.md) | 三家之间的字段/语义映射表,含流式事件映射 | +| [compatibility-layers.md](./compatibility-layers.md) | 各家官方提供的"他家格式兼容层",可作为中转站行为的参考实现 | +| [errors-and-limits.md](./errors-and-limits.md) | 错误码、限流、超时、请求体积上限对照 | + +## 中转站要处理的核心难题(速览) + +1. **消息模型不同构**:OpenAI 是扁平 `messages[]` + `role: system`;Anthropic 是 `messages[]` + 独立顶层 `system`,且 content 是 block 数组。转换必然有损。 +2. **推理内容(thinking/reasoning)不可互换**:Anthropic 的 `thinking` 文本只是**摘要**,真正的推理内容加密在 `signature` 里,服务端靠解密它重建思考——所以"翻译成字符串再翻译回来"是死路,只能把密文当 opaque blob 原样搬运。OpenAI 用 `encrypted_content`,DeepSeek 用明文 `reasoning_content`。三者回传规则均不同,详见 [cross-provider-mapping.md](./cross-provider-mapping.md) §7。 +3. **工具调用 ID 与结构**:Anthropic `tool_use.id` / `tool_result.tool_use_id`(在 user 消息里);OpenAI Chat 用 `tool_calls[].id` / `role: tool` 消息;OpenAI Responses 用 `call_id`。 +4. **流式协议不同**:OpenAI 是增量 delta 的 `chat.completion.chunk` + `[DONE]`;Anthropic 是有状态的 block 事件机(`content_block_start/delta/stop`),且 `message_delta.usage` 是**累计值**。 +5. **未支持参数的处理策略**:三家普遍**静默忽略**而非报错。中转站应明确选择"静默忽略"还是"显式报错",并保持一致。 + +**国产厂商的坑是另一个维度**——不在语义层而在协议层:HTTP 200 里藏业务错误(MiniMax `base_resp`)、流式默认返回全量累积文本(阿里 `incremental_output`)、六种互不相同的鉴权方式、`finish_reason: "sensitive"` 打破 OpenAI 枚举。详见 [china-providers.md](./china-providers.md),其中 §0 和 §9 是跨厂商的共性结论。 + +**还有第三个维度:同一家 API 内部按模型/按入口分裂。** Kimi 是典型——`temperature` 在新模型上写死 1.0 且**传其他值直接 400**(OpenAI 客户端的默认值开箱打不通),但**同样的参数走它自家的 Anthropic 入口却被静默忽略**;流式 `usage` 的位置由 `stream_options.include_usage` 决定(不传就藏在 `choices[0].usage`)。即"合法请求体取决于 `model` 和入口"。详见 [kimi.md](./kimi.md),§16 是可直接落地的接入 checklist。 + +**第四个维度是文档可信度。** Kimi 已用真实 key 实测,**10 条文档明确写下的规则被推翻**(含"K3 思考恒开不可关"这样的核心设定、以及官方 Claude Code 配置页照抄会 404 的模型名),见 [kimi.md](./kimi.md) §14。教训是:涉及"报错/不支持/必须"的文档表述,落地前一律实测——**两个方向的错都有**(文档说不能实际能、文档说能实际不能)。 + +**第五个维度:一个"厂商"可能是多个上游。** Kimi 的 `platform`(`api.moonshot.cn`)和 **Kimi For Coding**(`api.kimi.com/coding`)共用推理层却是两套接入实现——模型名、鉴权、限流、端点、错误语义、tool_call id 格式全不同,key 互不通用,且 coding 侧**不校验模型名**(传 `gpt-4` 返回 200 并原样回显)。中转站必须按两个上游配置。见 [kimi.md](./kimi.md) §17。 + +## 未覆盖 / 待补 + +- Google Gemini(`generateContent`)— 用户未列入,尚未收集。 +- 零一万物 Yi、百川、阶跃星辰 StepFun、商汤日日新。 +- OpenAI Realtime API、Batch API、Embeddings 等非 chat 端点。 +- DeepSeek `multi_round_chat`、`token_usage`、`rate_limit` 页面(内容与其他页重合度高,未单独抓取)。 diff --git a/docs/anthropic.md b/docs/anthropic.md new file mode 100644 index 0000000..8b804b0 --- /dev/null +++ b/docs/anthropic.md @@ -0,0 +1,306 @@ +# Anthropic Claude Messages API + +> 来源: +> - https://platform.claude.com/docs/en/api/messages +> - https://platform.claude.com/docs/en/build-with-claude/streaming +> - https://platform.claude.com/docs/en/api/errors +> - https://platform.claude.com/docs/en/build-with-claude/handling-stop-reasons +> - https://platform.claude.com/docs/en/build-with-claude/prompt-caching +> - https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview +> +> 采集于 2026-08-12。注意 `docs.anthropic.com` 已 301 到 `platform.claude.com`。 + +端点:`POST https://api.anthropic.com/v1/messages` +必需 header:`x-api-key`、`anthropic-version: 2023-06-01`、`content-type: application/json`。 + +--- + +## 1. 请求参数 + +必填:`model`、`messages`、**`max_tokens`**(与 OpenAI 不同,这里是必填)。 + +| 参数 | 类型 | 说明 | +| --- | --- | --- | +| `model` | string | 如 `claude-opus-5` | +| `messages` | array\ | | +| `max_tokens` | number | **必填** | +| `system` | string \| array\ | **顶层独立字段**,不是 messages 里的一条 | +| `temperature` | number | **0.0 ~ 1.0**,默认 1.0(不是 OpenAI 的 0~2) | +| `top_p` | number | | +| `top_k` | number | OpenAI 无此参数 | +| `stop_sequences` | array\ | | +| `stream` | boolean | | +| `tools` | array\ | | +| `tool_choice` | ToolChoice | | +| `thinking` | ThinkingConfigParam | 见 §5 | +| `metadata` | Metadata | | +| `cache_control` | CacheControlEphemeral | 顶层写法 = 自动缓存 | +| `container` | string | 代码执行容器 | +| `inference_geo` | string | 推理地理区域 | +| `output_config` | OutputConfig | 含 `effort`、`format`(结构化输出) | +| `service_tier` | `"auto"` \| `"standard_only"` | | + +Header 参数:`anthropic-user-profile-id`(可选)。 + +### MessageParam + +```json +{"role": "user" | "assistant" | "system", "content": "string" | [ContentBlockParam]} +``` + +--- + +## 2. Content Block 类型 + +### 输入侧(ContentBlockParam) + +| type | 关键字段 | +| --- | --- | +| `text` | `text`, `cache_control`, `citations` | +| `image` | `source`: `{type:"base64", media_type: image/jpeg\|png\|gif\|webp, data}` 或 `{type:"url", url}` | +| `document` | `source`: base64(PDF) / text / url / content;`title`, `context`, `citations:{enabled}` | +| `tool_use` | `id`, `name`, `input`, `caller` | +| `tool_result` | `tool_use_id`, `content`, `is_error` | +| `thinking` | `thinking`, `signature` | +| `search_result` | `title`, `source`, `content[]`, `citations` | +| `server_tool_use` | `id`, `name`(web_search/web_fetch/code_execution/bash_code_execution/text_editor_code_execution/tool_search_tool_regex/tool_search_tool_bm25), `input` | +| `mid_conv_system` | `content: [TextBlockParam]` — 会话中途插入 system 指令 | +| `container_upload` | `file_id` | + +### 输出侧(ContentBlock) + +`text`、`thinking`、`redacted_thinking`(`{type, data}`)、`tool_use`、`server_tool_use`、`web_search_tool_result`、`web_fetch_tool_result`、`code_execution_tool_result`、`bash_code_execution_tool_result`、`text_editor_code_execution_tool_result`、`tool_search_tool_result`、`container_upload`。 + +--- + +## 3. 响应结构 + +```json +{ + "id": "msg_...", + "type": "message", + "role": "assistant", + "model": "claude-opus-5", + "content": [ContentBlock], + "stop_reason": "end_turn", + "stop_sequence": null, + "stop_details": {"type": "refusal", "category": "...", "explanation": "..."}, + "usage": {...}, + "container": {"id": "...", "expires_at": "..."} +} +``` + +### stop_reason + +| 值 | 含义 | 处理 | +| --- | --- | --- | +| `end_turn` | 自然结束 | 直接用 | +| `max_tokens` | 达到 `max_tokens` | 截断,可续写 | +| `stop_sequence` | 命中 `stop_sequences` | 见 `stop_sequence` 字段 | +| `tool_use` | 调用了**客户端**工具 | 执行后回 `tool_result` | +| `pause_turn` | **服务端**工具采样循环达到迭代上限(默认 10 次) | 把整个 response.content 作为 assistant 消息发回去继续,**工具定义必须保持一致** | +| `refusal` | 安全拒绝 | 读 `stop_details.category` | +| `model_context_window_exceeded` | 响应填满上下文窗口(先于 max_tokens 触发) | 视作截断 | + +`stop_details.category` 取值:`cyber` | `bio` | `frontier_llm` | `reasoning_extraction` | `general_harms`。 + +### usage + +```json +{ + "input_tokens": 50, + "output_tokens": 503, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 100000, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + "output_tokens_details": {"thinking_tokens": 0}, + "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, + "inference_geo": "...", + "service_tier": "standard" | "priority" | "batch" +} +``` + +**`input_tokens` 只统计最后一个缓存断点之后的未缓存 token**: +`total = input_tokens + cache_read_input_tokens + cache_creation_input_tokens`。 +直接把 `input_tokens` 映射到 OpenAI 的 `prompt_tokens` 会严重低报。 + +### Token 计数端点 + +`POST /v1/messages/count_tokens`,参数同 create(不需要 `max_tokens`),返回 `input_tokens`、`cache_creation_input_tokens`、`cache_read_input_tokens`。 + +--- + +## 4. 流式(SSE) + +事件流固定顺序: + +1. `message_start` — 含一个 `content` 为空的 Message 对象 +2. 若干组 content block:`content_block_start` → N × `content_block_delta` → `content_block_stop`,每组带 `index` +3. 一个或多个 `message_delta` — 顶层变更(`stop_reason`、`usage`) +4. `message_stop` + +其间可穿插任意数量的 `ping` 事件,以及 `error` 事件。 + +### delta 类型 + +| delta.type | 字段 | 用于 | +| --- | --- | --- | +| `text_delta` | `text` | text block | +| `input_json_delta` | `partial_json` | tool_use 的 `input`,**是部分 JSON 字符串**,累加后在 `content_block_stop` 时解析 | +| `thinking_delta` | `thinking` | thinking block | +| `signature_delta` | `signature` | thinking block,**在 `content_block_stop` 之前**发一次 | + +### 流式怪癖 + +- **`message_delta.usage` 的 token 计数是累计值**,不是增量。做统计时不要相加。 +- 流式返回 200 之后仍可能中途发 `error` 事件(例如 `overloaded_error`,对应非流式的 HTTP 529)。错误处理不能只看 HTTP 状态码。 +- `content_block_delta` 的 `index` 对应最终 `content` 数组下标,**不保证连续到达顺序之外的假设**,但同一 block 的 delta 是有序的。 +- **server-side fallback** 时会在模型边界插入一个 `fallback` content block:只有 `content_block_start` + `content_block_stop`,中间**没有任何 delta**。解析器不能假设每个 block 至少有一个 delta。 +- 工具调用时,模型一次只能吐出一个完整的 key-value,因此**事件之间可能有明显停顿**。 +- `thinking` 配置为 `display: "omitted"` 时,thinking block 会开启、只收到一个 `signature_delta`、然后关闭,**没有 `thinking_delta`**。 +- 新事件类型会随时新增,解析器必须优雅忽略未知事件(官方 versioning policy 明确要求)。 + +### 流式中断恢复 + +- Claude 4.5 及更早:把已收到的部分内容作为 assistant 消息前缀(prefill)续写。 +- **Claude 4.6 及更晚:不支持 prefill**,改为加一条 user 消息说明"上次回复被中断于 X,请继续"。 +- `tool_use` 和 thinking block **无法部分恢复**,只能从最后一个完整 text block 续。 + +--- + +## 5. Thinking 的怪癖(最大坑区) + +Anthropic 在不同模型代际上换过三种 thinking 配置,且**互相排斥**: + +| 模型 | 可用配置 | 报错情况 | +| --- | --- | --- | +| Claude 4.5 及更早 | `thinking: {"type":"enabled", "budget_tokens":N}` | 传 `type:"adaptive"` → 400 `adaptive thinking is not supported on this model` | +| Claude 4.7 及以后 | `thinking: {"type":"adaptive", "display":...}` + `output_config.effort` | 传 `type:"enabled"` → 400 `"thinking.type.enabled" is not supported for this model` | +| Claude Fable 5 / Mythos 5 / Mythos Preview | thinking **恒开**,无法关闭 | 传 `type:"disabled"` → 400。且该错误信息建议的 `"thinking.type.enabled"` 在 Fable 5 / Mythos 5 上**同样被拒**——正确做法是**完全省略 `thinking` 参数** | + +`display` 取值:`summarized` | `omitted`(`omitted` 用于"开启思考但不返回思考内容")。 + +### signature 的真实机制(关键) + +> 来源:https://platform.claude.com/docs/en/build-with-claude/thinking 的 "Thinking encryption" 章节 + +**`signature` 名为签名,实为加密载荷。** 官方原文: + +> "Full thinking content is encrypted and returned in the `signature` field on each thinking block. The API uses the signature to verify that thinking blocks were generated by Claude when you pass them back." + +也就是说它身兼两职:**承载完整原始思考内容的密文** + **来源真实性校验**。 + +由此推出几个反直觉的事实: + +1. **`thinking` 字段里的文本永远不是原始思考链**,而是摘要(summarized)。官方明确:"what you see is never the raw chain of thought"。**没有任何 `display` 设置能返回原始 CoT。** +2. **真正的推理内容在 `signature` 里。** 回传时服务端**解密 `signature` 来重建原始 thinking** 用于构造 prompt——它不读你传的 `thinking` 文本。 +3. **`display: "omitted"` 时 `thinking` 为空串,但 `signature` 照常携带完整加密思考。** 且 `signature` 的值在 `summarized` 和 `omitted` 两种模式下**完全相同**,中途切换 `display` 是被支持的。 +4. 因此 `display: "omitted"` 省的是**延迟不是钱**——完整思考 token 照常计费,只是服务端跳过流式下发思考文本,首个 text token 更快到达。 + +其他约束: + +- **`signature` 是 opaque 的,官方明令不要解析或解释它。** +- Claude 4 及以后模型的 `signature` **显著更长**。若中转站对单字段长度有限制(数据库列宽、日志截断),这是个真实的踩坑点。 +- **`signature` 跨平台通用**:Claude API、Amazon Bedrock、Google Vertex AI 三方生成的值可以互换使用。对多云路由的中转站是好消息。 +- `redacted_thinking` 是**另一种 block type**(安全红线内容被编辑时返回),加密内容在 `data` 字段,同样 opaque、同样必须原样回传。它与 `display: "omitted"` 是两回事。 +- Claude Fable 5 / Mythos 5 **从不返回原始思考链**,返回的是常规 `thinking` block 而非 `redacted_thinking`。 + +### 回传规则(分级,不是一刀切) + +我最初的理解过严。官方的实际分级是: + +| 场景 | 要求 | +| --- | --- | +| **工具使用轮次内** | **必须**回传 thinking block | +| 跨轮次(非工具) | **建议**全部回传 | +| 非工具场景 | **允许**省略历史轮次的 thinking | + +但只要你选择回传,就**不能改**:最近一条 assistant 消息里连续的 `thinking` block 序列必须与模型原始生成的完全一致——不能重排、编辑、部分丢弃,`redacted_thinking` 也算在内。否则 400: + +``` +`thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified +``` + +**唯一的例外**:`display: "omitted"` 的块里,你写进那个空 `thinking` 字段的任何文本会被**忽略**而不是报错(因为服务端根本不读它,只解密 signature)。 + +**不需要自己修剪历史 thinking。** 全部传回去,API 会自动过滤,只保留维持推理连续性所需的块,并且**只对实际展示给 Claude 的块计费**。 + +### 保留策略按模型分两档 + +| 档位 | 模型 | +| --- | --- | +| **保留所有历史轮次** | Opus 4.5 及以后的 Opus、Sonnet 4.6 及以后的 Sonnet、Fable 5、Mythos 5、Mythos Preview | +| **只保留最后一轮** | 更早的 Opus/Sonnet,以及**所有 Haiku**(含 Haiku 4.5)。传回更早的块 API 会自动剥离 | + +在 keep-all 模型上,长对话的历史 thinking block 会**持续占用输入 token**(从缓存读取时也计入 usage)。可用 `clear_thinking_20251015` 这个 context-editing 策略覆盖默认行为。 + +### 换模型必须剥离 thinking + +**thinking block 与生成它的模型绑定。** 会话中途切换模型(例如 refusal fallback 之后)时,必须从历史 assistant 轮次中剥离 `thinking` 和 `redacted_thinking`。 + +危险之处:**其他模型不会拒绝,而是静默忽略**——但被忽略的块**照样计入输入 token**。中转站做模型路由/故障转移时如果不清理,用户会为完全无用的内容付费,且不会收到任何错误提示。 + +### 对中转站的含义 + +好消息比我最初判断的多:signature 是 opaque 且跨平台通用的 blob,中转站**只需原样搬运,不需要理解它**。 + +可行的做法: +- 网关侧按会话存储上游返回的原始 content block 数组(thinking 的 `signature` 和 redacted 的 `data` 作为 opaque blob),下游只暴露脱敏后的摘要文本;下一轮用存储的原始块重建上游请求。 +- 非工具场景可以直接丢弃 thinking 块,合法且省上下文。 +- **绝不能**用"把 thinking 文本转成 `reasoning_content` 字符串再转回来"的方式——转回来的是摘要文本、且没有 signature,Anthropic 侧必然 400 或丢失推理。 + +--- + +## 6. Prompt Caching 的怪癖 + +缓存前缀层级顺序固定:**`tools` → `system` → `messages`**。改动某一层会使该层**及之后所有层**失效。 + +| 改动 | tools 失效 | system 失效 | messages 失效 | +| --- | --- | --- | --- | +| 工具定义 | ✘ | ✘ | ✘ | +| web search / citations 开关 | ✓保留 | ✘ | ✘ | +| speed 设置 | ✓保留 | ✘ | ✘ | +| `tool_choice` | ✓保留 | ✓保留 | ✘ | +| 增删图片 | ✓保留 | ✓保留 | ✘ | +| thinking 配置 | 模型相关 | 模型相关 | ✘ | + +- **最多 4 个显式 cache breakpoint**。顶层 `cache_control` 的自动缓存**占用 1 个槽位**,所以自动+显式并用时显式最多 3 个。 +- **最小可缓存 token 数按模型不同**,低于阈值时静默不缓存、**不报错**: + +| 模型 | 最小 token | +| --- | --- | +| Opus 5 / Fable 5 / Mythos 5 | 512 | +| Opus 4.8 / Sonnet 5 / Sonnet 4.6 / Sonnet 4.5 / Opus 4.1 | 1,024 | +| Opus 4.7 / Mythos Preview / Haiku 3.5 | 2,048 | +| Opus 4.6 / Opus 4.5 / Haiku 4.5 | 4,096 | + +- TTL:默认 `5m`,可设 `{"type":"ephemeral","ttl":"1h"}`。**1h 的断点必须出现在 5m 断点之前**。 +- 定价倍率(相对基础输入价):5m 写入 **1.25x**,1h 写入 **2.0x**,缓存读取 **0.1x**。 +- 缓存查找**最多回溯 20 个 block**,超出则必须重建缓存。 +- 并发请求时,缓存条目要等第一个响应**开始**后才可用——同时打多个相同前缀的请求不会共享缓存。 +- `thinking` block 不能显式打 `cache_control`,但会随其他内容一起被缓存。 + +--- + +## 7. Tool use 的怪癖 + +- 工具定义用 **`input_schema`**(不是 OpenAI 的 `parameters`),且没有 `function` 外层包装:`{name, description, input_schema}`。 +- `tool_result` 必须放在 **user 消息**的 content 数组里,通过 `tool_use_id` 关联。OpenAI 用的是独立的 `role: "tool"` 消息。 +- `tool_choice` 取值:`{"type":"auto"}` | `{"type":"any"}` | `{"type":"tool","name":"x"}` | `{"type":"none"}`,另有 `disable_parallel_tool_use: true`(对应 OpenAI 的 `parallel_tool_calls: false`)。注意是 **`any`** 不是 OpenAI 的 `required`。 +- 错误用 `tool_result` 的 `is_error: true` 表达,不是抛异常。 +- **不要在 `tool_result` 之后同一条 user 消息里追加 text block**——官方明确指出这会导致 Claude 返回空响应(`stop_reason: end_turn` 但 content 为空)。 +- `strict: true` 可加在自定义工具定义上以保证 schema 一致(与 OpenAI 的 strict 语义相近,但配置位置不同)。 +- **tools 会消耗额外的系统提示 token**,随模型和 `tool_choice` 而变。例如 Opus 5:`auto`/`none` 为 286 tokens,`any`/`tool` 为 406 tokens。中转站做 token 预估时必须计入。 +- 服务端工具(`web_search` 等)会在**同一次响应内**返回结果 block,无需客户端执行;但若它和客户端工具在同一组并行调用里,行为会变(见 `pause_turn`)。 + +--- + +## 8. 其他常见 400 + +- **Prefill 不支持**:Claude 4.6 及以后、Mythos Preview 不允许最后一条是 assistant 消息作为前缀。 + ``` + This model does not support assistant message prefill. The conversation must end with a user message. + ``` + 替代方案:结构化输出 / system 指令 / `output_config.format`。 + 这条对中转站影响很大——**DeepSeek 的 prefix completion、OpenAI 的部分模式无法映射到新 Claude 模型**。 +- `temperature` 上限是 **1.0**。OpenAI 侧 0~2 的值需要 clamp(Anthropic 官方兼容层的做法是 >1 一律截断为 1)。 diff --git a/docs/api-design.md b/docs/api-design.md new file mode 100644 index 0000000..14d207d --- /dev/null +++ b/docs/api-design.md @@ -0,0 +1,551 @@ +# zend-token API 设计 + +> 状态:设计草案 v0.2 · 2026-08-12 +> 依据:[openai.md](./openai.md)、[anthropic.md](./anthropic.md)、[deepseek.md](./deepseek.md)、[china-providers.md](./china-providers.md)、[cross-provider-mapping.md](./cross-provider-mapping.md) +> +> v0.2 变更:单一 IR → **双规范模型**(§3);`/v1/responses` 提升为一等入口。 +> +> **后续**:本文的落地层是 [transform-spec.md](./transform-spec.md)(转写方案 v0.3),它在双规范模型之上补齐了上游 Profile、改写管线与 Kimi 实测结论。以下两处已被 v0.3 修正:§8.1 的"Claude 族上游一律 opaque 搬运 signature"只对 **Anthropic 官方真机**成立(见 transform-spec §7.1 的可信度分级);§6 的能力矩阵需按**上游身份三元组**而非 provider 划分(同一家的两个入口行为可能相反)。 + +本文只定义**对外 API 契约与桥接语义**,不涉及技术栈、存储、部署选型。 + +--- + +## 1. 设计原则 + +1. **双族对等** — OpenAI 族与 Claude 族是**同等公民**,不是"主格式 + 转换层"。 +2. **同族零损** — 同族内的入口/上游组合不经过跨族转换,不付出任何有损代价。 +3. **有损集中** — 所有跨族有损只发生在**一处**(§5 跨族桥),集中实现、集中测试、集中记录。 +4. **降级必须可见** — 上游能力不足导致的任何语义变化都要可观测。**静默忽略是各家的通病,我们不重复它**(§7)。 +5. **上游怪癖不外泄** — MiniMax 的 `base_resp`、阿里的全量流、智谱的 `sensitive` 都在网关内消化。 +6. **不发明第三种方言** — 扩展一律走命名空间(`ztoken` 对象 / `X-Ztoken-*` header),不污染标准字段。 + +--- + +## 2. 端点总览 + +| 端点 | 族 | 说明 | +| --- | --- | --- | +| `POST /v1/chat/completions` | OpenAI | 生态最广 | +| `POST /v1/responses` | OpenAI | 新接口,reasoning 能力完整 | +| `POST /v1/messages` | Claude | Claude Code 等客户端 | +| `POST /v1/messages/count_tokens` | Claude | token 预估 | +| `GET /v1/models` | 共用 | 模型列表 | +| `GET /v1/models/{model}` | 共用 | 含**能力矩阵**(本项目扩展,§6) | + +认证同时接受两种 header,互为别名: + +``` +Authorization: Bearer # OpenAI 风格 +x-api-key: # Claude 风格 +``` + +`anthropic-version` 接受但不校验(我们只有一个版本)。 + +--- + +## 3. 双规范模型(核心) + +### 3.1 为什么不用单一 IR + +单一 IR 看起来更"优雅",但有个真实缺陷:**它强迫每条路径都付出一次有损转换**。 + +实际流量分布是高度不均的——OpenAI 入口 → OpenAI 兼容上游(DeepSeek、智谱、Kimi、豆包、混元、千帆 v2、星火、阿里兼容模式)会是绝大多数。这条路径上,源和目标本来就是同一种方言,却要绕经 block 模型往返,白白丢失顺序信息又白白重建。 + +改为按族划分后: + +``` +Chat ─┐ ┌─ DeepSeek / 智谱 / Kimi / 豆包 / + ├─→ 【OaiIR】 ──┬── 同族直出 ────────┤ 混元 / 千帆v2 / 星火 / 阿里兼容 +Responses ─┘ │ └─ MiniMax / DashScope 原生 + │ + ╔════╧════╗ + ║ 跨族桥 ║ ← 唯一的有损点 + ╚════╤════╝ + │ +Messages ──→ 【AnthIR】┴────────────────────── Anthropic / DeepSeek-anthropic / + 阿里 Anthropic 兼容 +``` + +**同族路径零转换成本;跨族只有一处,集中攻。** 这也让"Claude 入口 → Claude 上游"这条最脆弱的路径(thinking signature 必须原样保住)天然安全——它根本不进桥。 + +### 3.2 OaiIR — OpenAI 族规范模型 + +**以 Responses 的 item 模型为基线**,因为 `Responses item ⊃ Chat message`:Chat 把文本放 `content`、工具放 `tool_calls`,无法表达 reasoning 与 text 的交错顺序,Responses 的异构 `output[]` 可以。 + +```jsonc +{ + "model": "<逻辑模型名>", + "instructions": "...", // 顶层系统指令(Chat 的 system/developer 归一到这) + "items": [ // 异构数组,顺序即语义 + {"type":"message", "role":"user"|"assistant", "content":[Part]}, + {"type":"function_call", "call_id":"...", "name":"...", "arguments":""}, + {"type":"function_call_output", "call_id":"...", "output":"..."}, + {"type":"reasoning", "id":"...", "summary":[...], "encrypted_content":"...?"} + ], + "tools": [{"type":"function","name":...,"parameters":{...},"strict":bool}], + "tool_choice": {...}, + "sampling": {"max_output_tokens":int, "temperature":0..2, "top_p":..., "stop":[...], + "frequency_penalty":?, "presence_penalty":?, "seed":?, "n":1}, + "reasoning": {"effort":"none|minimal|low|medium|high|xhigh|max", "summary":"auto|none"}, + "text": {"format": {"type":"text"|"json_object"|"json_schema", "schema":?, "strict":?}}, + "stream": bool, + "passthrough": {"": {...}} +} +``` + +`Part` 类型:`input_text` / `output_text` / `input_image` / `input_file` / `refusal`。 + +**注意 `arguments` 是 JSON 字符串**而非对象——这是 OpenAI 族的原生形态,保留它可以让同族直出时零解析。 + +### 3.3 AnthIR — Claude 族规范模型 + +以 Messages 的 block 模型为基线: + +```jsonc +{ + "model": "<逻辑模型名>", + "system": [{"type":"text","text":"...","cache_control":?}], + "messages": [ + {"role":"user"|"assistant", "content":[Block]} + ], + "tools": [{"name":..., "description":..., "input_schema":{...}, "strict":?}], + "tool_choice": {"type":"auto"|"any"|"tool"|"none", "disable_parallel_tool_use":?}, + "sampling": {"max_tokens":int, "temperature":0..1, "top_p":?, "top_k":?, "stop_sequences":[...]}, + "thinking": {"mode":"auto"|"on"|"off", "effort":?, "budget_tokens":?, "display":"summarized"|"omitted"}, + "output_config": {"format":?, "effort":?}, + "stream": bool, + "passthrough": {"": {...}} +} +``` + +`Block` 类型:`text` / `image` / `document` / `tool_use` / `tool_result` / `thinking` / `redacted_thinking` / `server_tool_use` / 各类 `*_tool_result`。 + +**`tool_use.input` 是对象**(不是字符串)——与 OaiIR 的 `arguments` 字符串形成明确对比,跨族时必须做 parse/stringify。这是个容易漏的点:Anthropic 流式下发的 `input_json_delta` 是部分 JSON 字符串,但终态是对象。 + +### 3.4 两族各自的不变量 + +族内归一在入口层立即完成,避免畸形约束泄漏进下游逻辑。 + +**OaiIR:** +1. `system` / `developer` 消息全部提升到 `instructions`,按原顺序用 `\n` 拼接。 +2. Chat 的 `tool` role 消息转成 `function_call_output` item。 +3. Chat 的 `assistant.tool_calls[]` 拆成独立的 `function_call` item,排在该 message item 之后。 + +**AnthIR:** +1. `role` 只有 `user` / `assistant`。 +2. `content` 恒为数组(裸字符串包装成 `[{type:"text"}]`)。 +3. **连续同 role 消息自动合并**为一条。这一条直接消化百度 v1 的"必须严格交替"约束。 + +**两族共同:** +- `max_output_tokens` / `max_tokens` 缺省时由能力矩阵填默认值,并标注 `X-Ztoken-Defaulted`。理由:Anthropic 侧必填,取严格的一方可以让每个 adapter 不必各写一份兜底。 + +### 3.5 路由决策 + +``` +入口族 == 上游族 → 同族直出(零转换) +入口族 != 上游族 → 过跨族桥(§5) +``` + +`GET /v1/models/{model}` 暴露上游族,客户端可以据此选择同族入口以避开有损。 + +--- + +## 4. 入口方言层 + +三个入口各自负责"方言 → 族规范模型"的归一,不做跨族的事。 + +### 4.1 `/v1/chat/completions` → OaiIR + +按 §3.4 归一。以下字段接受但按能力矩阵处理(可能降级,§7): +`n`、`logprobs`、`top_logprobs`、`logit_bias`、`seed`、`frequency_penalty`、`presence_penalty`、`response_format`、`parallel_tool_calls`、`tools[].function.strict`。 + +响应回投影为 Chat 结构时: +- `function_call` item → `message.tool_calls[]`(`call_id` → `id`) +- `reasoning` item → `message.reasoning_content`(摘要文本) +- **`logprobs` 字段恒存在**(值可为 `null`)——OpenAI spec 列为 required,省略会打破严格客户端。 + +### 4.2 `/v1/responses` → OaiIR + +近乎恒等映射(OaiIR 本就以它为基线)。需处理的: + +| 字段 | 处理 | +| --- | --- | +| `input` 为 string | 包装成单个 user message item | +| `previous_response_id` | **v0.1 不支持**,返回 400 `unsupported_parameter`(我们无状态,见 §12 开放问题 1) | +| `conversation` | 同上 | +| `store` | 恒为 `false`,请求 `true` 时 L1 降级 | +| `background` | 不支持,L3 | +| `include` | 按能力矩阵过滤 | +| `context_management` | L1 丢弃(网关不做自动压缩) | + +### 4.3 `/v1/messages` → AnthIR + +按 §3.4 归一。`thinking` 参数接受三种历史写法并统一: + +| 客户端传入 | AnthIR | +| --- | --- | +| `{"type":"enabled","budget_tokens":N}` | `mode:"on"`, `budget_tokens:N` | +| `{"type":"adaptive","display":D}` | `mode:"auto"`, `display:D` | +| `{"type":"disabled"}` | `mode:"off"` | + +**这吃掉了 Anthropic 的代际地雷** —— 客户端不会再踩到"4.5 只认 enabled、4.7 只认 adaptive、Fable 5 两个都拒、Opus 5 的 disabled 还和 effort 冲突"(见 anthropic.md §5 的模型矩阵)。这是相对直连 Anthropic 的明确增值点。 + +`stop_reason: pause_turn` **不对外暴露**:网关内部完成服务端工具续跑循环后才返回终态。理由是 OpenAI 族无法表达它,两个族的行为必须一致。 + +### 4.4 扩展字段约定 + +三个入口统一,请求体顶层接受 `ztoken` 对象(未知子字段忽略不报错): + +```jsonc +{ + "ztoken": { + "thinking": {"mode":"on","effort":"high"}, // 跨族统一的思考控制 + "strict_capability": false, // true = 能力不足报错而非降级 + "allow_degradation": ["vision","n"], // 显式放行 L3 + "passthrough": {"dashscope": {"enable_search": true}} + } +} +``` + +用嵌套对象而非顶层字段,是为了避免和未来的官方字段撞名。OpenAI SDK 的 `extra_body` 可直接塞它,客户端零改造。 + +--- + +## 5. 跨族桥(难点集中区) + +这一节是整个设计里唯一有损的地方,所以要写得最细。 + +### 5.1 结构差异总览 + +| 维度 | OaiIR | AnthIR | 桥接难度 | +| --- | --- | --- | --- | +| 容器 | 扁平 `items[]`,工具调用是**独立 item** | `messages[].content[]`,工具调用是**消息内的 block** | 中 | +| 工具入参 | `arguments`: **JSON 字符串** | `input`: **对象** | 低(parse/stringify) | +| 工具结果 | 独立 `function_call_output` item | user 消息内的 `tool_result` block | 中 | +| 工具 ID | `call_id` | `tool_use.id` / `tool_result.tool_use_id` | 低(需映射表) | +| 系统指令 | `instructions` 字符串 | `system` block 数组 | 低 | +| 推理 | `reasoning` item + `encrypted_content` | `thinking` block + `signature` | **高**(§8) | +| 温度 | 0~2 | 0~1 | 低(clamp) | +| 工具错误 | 靠 output 文本表达 | `tool_result.is_error` 布尔 | 中(有损) | + +### 5.2 OaiIR → AnthIR + +``` +instructions → system: [{type:"text", text}] +message(role=user) → messages[].role=user, content=[blocks] +message(role=assistant) → messages[].role=assistant, content=[blocks] +function_call → 并入前一条 assistant 消息的 content: + {type:"tool_use", id:call_id, name, input:JSON.parse(arguments)} +function_call_output → 新建/并入 user 消息: + {type:"tool_result", tool_use_id:call_id, content:[{type:"text"}]} +reasoning → {type:"thinking", thinking:summary, signature:<信封>} +input_text/output_text → {type:"text"} +input_image → {type:"image", source:{...}} +input_file → {type:"document", source:{...}} +refusal part → 丢弃(L2),原文记入 degraded 说明 +``` + +**关键动作**:`function_call` item 必须**回填**进它前面那条 assistant message 的 content 数组,而不是新建消息——否则会产生连续两条 assistant 消息,违反 AnthIR 不变量 3(虽然会被自动合并,但顺序可能错)。 + +**`arguments` 解析失败**(模型吐出非法 JSON,流式截断时常见):不要抛异常。降级为 `input: {}` 并标记 L2,把原始字符串放进 `ztoken` 诊断字段。 + +### 5.3 AnthIR → OaiIR + +``` +system[] → instructions(用 \n 拼接) +content[].text → message part(output_text / input_text) +content[].tool_use → 独立 function_call item: + {call_id:id, name, arguments:JSON.stringify(input)} +content[].tool_result → 独立 function_call_output item +content[].thinking → reasoning item{summary:[thinking], encrypted_content:<信封>} +content[].redacted_thinking → reasoning item(summary 为空,data 进信封) +content[].image → input_image part +content[].document → input_file part(PDF 可直传的上游)/ L3(不可传的) +tool_result.is_error=true → output 文本前缀 "Error: "(**有损**,见 5.4) +server_tool_use / *_tool_result → L3:OpenAI 族无对应概念 +citations → annotations(尽力而为,L2) +``` + +### 5.4 已知有损清单 + +跨族时必然丢失的信息,全部记入 `X-Ztoken-Degraded`: + +| 丢失项 | 方向 | 级别 | 说明 | +| --- | --- | --- | --- | +| `tool_result.is_error` 语义 | A→O | L2 | OpenAI 无此字段,只能退化成文本前缀 | +| `thinking` 与 `text` 的交错顺序 | A→O(Chat) | L2 | Chat 的 message 结构表达不了;**走 Responses 入口则无损** | +| `cache_control` 断点 | O→A / A→非 Anthropic | L1 | 上游若非 Anthropic 则无处安放 | +| `top_k` | O→A 时反向缺失 | L1 | OpenAI 族无此参数 | +| `n > 1` | O→A | L3 | Anthropic 恒为 1 | +| `logprobs` | O→A | L2 | Anthropic 不支持,返回 null | +| server tools(web_search 等) | A→O | L3 | 概念不存在 | +| `citations` 精确位置 | A→O | L2 | annotations 表达力更弱 | +| **`signature` 真实性** | A→O→A 往返 | **L3** | 见 §8.4,往返后无法发回 Anthropic 上游 | + +### 5.5 禁止跨族的情形 + +以下组合**直接返回 400**,不做勉强转换: + +1. **Claude 入口 + 请求含 `thinking` + 非 Claude 族上游 + 后续要回传** — 见 §8.4。 +2. **`n > 1` 且上游为 Claude 族** — 除非 `allow_degradation` 含 `n`。 +3. **含 `server_tool_use` 历史的会话切到 OpenAI 族上游** — 历史无法表达,续接必然错乱。 + +--- + +## 6. 模型路由与能力矩阵 + +### 6.1 逻辑模型名 + +```jsonc +{ + "id": "qwen-max", + "upstream": { + "family": "openai", // openai | anthropic —— 决定是否过桥 + "provider": "dashscope", + "model": "qwen-max-latest", + "endpoint_ref": null // 火山方舟填 "ep-2024xxxx" + }, + "capabilities": { ... } +} +``` + +**火山方舟的 Endpoint ID 是账号级资源**(china-providers.md §4),所以路由表是 `(tenant, logical_model) → upstream`,全局表仅作 fallback。 + +模型别名支持"伪装":把 `claude-sonnet-4-6` 指向任意上游,让硬编码模型名的客户端直接可用。但**不静默兜底未知模型名**——未匹配返回 404,避免拼写错误被掩盖(DeepSeek 的 Anthropic 兼容层就是反面教材)。 + +### 6.2 能力矩阵 + +```jsonc +"capabilities": { + "family": "openai", + "streaming": true, + "streaming_incremental": true, // false = 上游返回全量累积,网关需差分(阿里) + "tools": true, + "tool_strict": false, + "parallel_tools": true, + "vision": true, + "documents": false, + "json_mode": true, + "json_schema": false, + "thinking": "always_on" | "optional" | "none", + "thinking_opaque": true, // 思考内容必须原样回传(Anthropic=true) + "prefill": false, // 助手前缀续写(DeepSeek prefix / Kimi partial) + "n_max": 1, + "temperature_range": [0.0, 1.0], + "max_output_tokens": 8192, + "cache": "explicit" | "automatic" | "none" +} +``` + +--- + +## 7. 能力降级协议 + +各家的通病是静默忽略。我们保留静默以维持生态兼容,但**强制可观测**。 + +### 7.1 三级降级 + +| 级别 | 定义 | 默认行为 | +| --- | --- | --- | +| **L1 无害** | 丢弃不改变输出语义 | 静默丢弃,仅记 header | +| **L2 降质** | 输出仍可用但质量/精度变化 | header + 结构化 warning | +| **L3 变义** | 客户端拿到的东西与请求语义不符 | **报 400**,除非显式放行 | + +| 参数/能力 | 级别 | +| --- | --- | +| `seed`、`logit_bias`、`metadata`、`user`、`store` | L1 | +| `temperature` clamp、`top_k` 丢弃、penalty 丢弃、`logprobs` 返回 null | L2 | +| **`tools[].strict` 失效** | **L3** | +| **图片/文档输入被丢弃** | **L3** | +| **`n > 1` 退化为 1** | **L3** | +| **`json_schema` 降级为 `json_object`** | **L3** | + +### 7.2 可观测通道 + +``` +X-Ztoken-Upstream: dashscope/qwen-max-latest (family=openai, bridged=false) +X-Ztoken-Degraded: seed=dropped(L1), temperature=clamped(L2,2.0->1.0), strict=ignored(L3) +X-Ztoken-Defaulted: max_output_tokens=4096 +``` + +`bridged=true/false` 让调用方知道自己是否付了跨族代价——这是选择入口的直接依据。 + +**严格模式**:`ztoken.strict_capability: true` 时,任何 L2 及以上降级直接 400,错误体列出全部冲突项。 + +L3 默认报错是本设计与所有现有兼容层的最大分歧。宁可让调用方显式声明"我接受图片被丢弃",也不要让他拿到一个看起来正常、实际模型根本没看到图的响应。放行方式: + +```jsonc +"ztoken": {"allow_degradation": ["vision", "n", "strict"]} +``` + +--- + +## 8. thinking / reasoning 桥接 + +依据 anthropic.md §5:Anthropic 的 `thinking` 文本只是**摘要**,真实推理加密在 `signature` 里,**改一个字节就 400**。所以桥接必须搬运不透明数据,不是翻译文本。 + +### 8.1 四象限 + +| 入口族 | 上游族 | 策略 | +| --- | --- | --- | +| Claude | Claude | **原样透传**,不解包不重打包 —— 唯一能保住真 signature 的路径 | +| OpenAI | OpenAI | 原样透传 `encrypted_content` / `reasoning_content` | +| OpenAI | Claude | 摘要 → `reasoning_content`;真 signature 装进信封放 `encrypted_content` | +| Claude | OpenAI | 上游 `reasoning_content` → `thinking.thinking`;`signature` 填我们的信封 | + +**两条同族路径都不进桥**,这正是双规范模型的最大收益。 + +### 8.2 信封格式 + +``` +ztk1. +``` + +`payload` = 上游原始 block/item 数组 + 元数据(provider、model、family、生成时间)。客户端视其为**完全不透明**。`ztk1.` 前缀用于版本演进;解析不了的信封当作"无思考上下文"降级,不报错。 + +### 8.3 无状态优先 + +默认下发信封由客户端原样回传,网关不存储。与 OpenAI 的 `encrypted_content`、Anthropic 的 `signature` 同一思路,行业已验证。 + +风险是客户端裁剪未知字段。缓解:信封放在**该族的原生位置**(OpenAI 族 → `reasoning.encrypted_content`;Claude 族 → `thinking.signature`),这两个位置是各自 SDK 会保留的标准字段,比自定义字段安全得多。 + +> 有状态 handle 模式作为可选项保留,不进 v0.2 契约。 + +### 8.4 A→O→A 往返的硬限制 + +**这是必须写进对外文档的一条。** + +Claude 上游的思考经 OpenAI 族客户端往返后,回来的 `signature` 是**我们的信封**,不是 Anthropic 的原始 signature。网关可以解开信封还原原始 block —— 但前提是**信封没被裁剪**。 + +若客户端丢弃了 `encrypted_content`,原始 signature 就永久丢失,此时: + +- 该会话**不能再发回 Anthropic 上游**(会 400 或丢失推理连续性)。 +- 网关的行为:剥离全部 thinking block,标记 `X-Ztoken-Degraded: thinking=lost(L3)`,并按 §7 规则报错或放行。 + +因此 §5.5 规定:Claude 入口 + thinking + 非 Claude 上游 + 需回传的组合,默认拒绝。 + +### 8.5 三条实现硬约束 + +1. **切换上游模型时必须剥离 thinking block** —— 其他模型静默忽略但**照样计费**。故障转移路径尤其注意。 +2. **信封只走 body,绝不走 header** —— Claude 4+ 的 signature 显著变长,header 有截断风险,截断的 signature 下一轮直接 400。 +3. **不得按 `type == "thinking"` 过滤** —— 会漏掉 `redacted_thinking`,直接破坏协议。 + +--- + +## 9. 流式契约 + +### 9.1 对外保证 + +无论上游行为如何: + +1. **增量语义** — 每个事件只含新增内容。上游返回全量累积时(阿里 `incremental_output=false`)网关做差分,由能力矩阵 `streaming_incremental: false` 驱动。 +2. **事件顺序合法** — Claude 族保证 `message_start` → block 三段式 → `message_delta` → `message_stop`;OpenAI 族保证首 chunk 带 role、末尾 `data: [DONE]`。 +3. **usage 位置固定** — Chat 下需 `stream_options.include_usage` 才发末尾 usage chunk(`choices` 为空数组);Messages 下 `message_delta.usage` 为**累计值**。 +4. **中途错误可表达** — §10.3。 + +### 9.2 跨族流式的状态机 + +跨族时流式比非流式难得多,因为要**在流上重建结构**: + +**O→A**:OpenAI chunk 无"块结束"信号,只能靠 `tool_calls[].index` 变化和 `finish_reason` 推断边界。网关需维护 block index 状态机,在文本↔工具切换时补发 `content_block_stop` / `content_block_start`。 + +**A→O**: +- `content_block_start(text)` → 吞掉(无对应) +- `content_block_start(tool_use)` → 发一个带 `id`/`name`、`arguments:""` 的 tool_calls chunk +- `input_json_delta.partial_json` → 追加到 `arguments` +- `thinking_delta` → `delta.reasoning_content` +- **`signature_delta` → 无处安放**,必须缓冲到 block 结束后装信封,随末尾 chunk 下发 +- `message_delta.usage` 累计值 → 取末值,不累加 +- `ping` → 丢弃或转 SSE 注释行保活 + +### 9.3 上游怪癖消化清单 + +| 上游怪癖 | 处理 | +| --- | --- | +| 阿里全量累积流 | 差分成增量 | +| 阿里需 `X-DashScope-SSE: enable` | adapter 自动附加 | +| Anthropic `fallback` block 无 delta | 解析器不假设每 block 至少一个 delta | +| Anthropic 200 后发 `error` 事件 | 转成客户端族的错误表达 | +| OpenAI `[DONE]` 非 JSON | 单独识别,不进 JSON 解析器 | +| MiniMax 200 内嵌错误 | 流首帧即检测 `base_resp`,转真实状态码 | + +### 9.4 provider 透传 + +`ztoken.passthrough.` 在**匹配到该 provider 时**原样并入上游请求体,不匹配则丢弃。用于阿里 `enable_search`、`repetition_penalty` 这类无法归一的自有参数。透传字段不参与能力校验,风险由调用方承担。 + +--- + +## 10. 错误模型 + +### 10.1 双族错误格式 + +按入口族返回对应格式,**HTTP 状态码两族一致**: + +```jsonc +// OpenAI 族 +{"error": {"message":"...", "type":"invalid_request_error", "param":"tools[0].strict", "code":"capability_unsupported"}} + +// Claude 族 +{"type":"error", "error":{"type":"invalid_request_error","message":"..."}, "request_id":"req_..."} +``` + +两族都返回 `X-Ztoken-Request-Id`。 + +### 10.2 上游错误归一 + +| 上游 | 对外 | +| --- | --- | +| MiniMax 200 + `base_resp.status_code != 0` | **翻译成真实状态**(1002→429、1004→401、1008→402、1039→400) | +| DeepSeek 402 余额不足 | 429 + `insufficient_quota` | +| Anthropic 529 overloaded | 503 | +| DeepSeek 422 | 400 | +| 智谱 `finish_reason:"sensitive"` | 非错误 → 映射 `content_filter`,原值放 `ztoken.upstream_finish_reason` | + +**MiniMax 这条是网关的核心价值之一**:上游用 200 表达限流会让所有标准重试/熔断中间件失效,还原成 429 后通用组件即可正常工作。 + +### 10.3 流式中途错误 + +已发 200 后无法改状态码: + +- **Claude 族**:发 `event: error`(与官方一致)。 +- **OpenAI 族**:发含 `error` 字段的 data chunk,随后 `data: [DONE]`。OpenAI 无官方规范,此为我们的事实约定,需写进对外文档。 + +--- + +## 11. usage 归一 + +两族 usage 互转按 cross-provider-mapping.md §5。**最易错的一条**: + +``` +OpenAI prompt_tokens = Anthropic (input_tokens + cache_read_input_tokens + cache_creation_input_tokens) +``` + +直接用 `input_tokens` 在缓存命中时会低报到 1%。 + +网关自身计费**以上游原始 usage 为准**,不依赖任何投影结果。 + +--- + +## 12. 开放问题 + +1. **`previous_response_id` / `conversation` 要不要支持?** 支持就意味着网关有状态(要存会话)。当前设计是 400 拒绝。若要支持,建议只做"信封化"——把历史塞进加密信封由客户端持有,保持无状态。 +2. **L3 默认报错**是否可接受?会让"能跑就行"的客户端在我们这里失败、在别家静默成功。我认为值得,但这是产品取向。 +3. **信封是否需对客户端可解密?** 目前完全不透明(网关持密钥)。若要客户端可自查,需改成签名而非加密。 +4. **`cache_control` 对非 Anthropic 上游**:接受并 L1 丢弃,还是直接拒绝? +5. **多模态是否代下载转码?** 图片 base64/url 支持度各家不一,代下载会引入出网请求和体积限制。 + +--- + +## 13. 实现顺序 + +1. **两个族规范模型 + 三个入口方言层**(不接上游,用 echo adapter 打通) +2. **能力矩阵 + 降级引擎**(§7,纯逻辑,可完整单测) +3. **第一个上游 adapter:DeepSeek**(OpenAI 兼容,同族直出,最简单) +4. **跨族桥的非流式路径**(§5)—— 用双向往返测试保证:`O→A→O` 与 `A→O→A` 的可逆部分必须恒等 +5. **流式规范化层**(§9,含阿里全量差分)—— 最易出 bug,值得先写对照测试 +6. **跨族桥的流式路径**(§9.2)—— 全项目最难的一块,状态机需单独设计 +7. **Anthropic adapter**(thinking 原样透传路径) +8. **国产 adapter 批量接入** + +第 4 步和第 6 步是这个项目的技术核心,其余都是工程量。 diff --git a/docs/china-providers.md b/docs/china-providers.md new file mode 100644 index 0000000..67f98db --- /dev/null +++ b/docs/china-providers.md @@ -0,0 +1,281 @@ +# 国产 AI Provider 的怪癖 + +> 采集于 2026-08-12。 +> +> **可信度标注**:本文件里 ✅ 表示来自厂商官方文档页面;⚠️ 表示来自搜索结果聚合或第三方资料,**落地前需要用真实请求验证**。国产厂商文档改版频繁(本次采集就遇到 moonshot.cn → kimi.com、volcengine.com → docs.volcengine.com 两处 301),且很多"历史怪癖"在新版本里已被修掉,不要照抄老博客。 + +国产 API 的怪癖**和欧美三家不是一个类型**。OpenAI/Anthropic/DeepSeek 的坑主要在**语义层**(thinking 怎么回传、tool_result 放哪);国产的坑主要在**协议层和运维层**:鉴权方式五花八门、HTTP 200 里藏业务错误、流式默认全量返回、内容安全是一等公民。 + +--- + +## 0. 跨厂商的共性怪癖(最重要) + +这五条是中转站架构里必须提前留出的抽象层,逐个厂商去补会很痛苦。 + +### 0.1 HTTP 200 内嵌业务错误 ⚠️→✅ + +**MiniMax 是典型**:无论成功失败都返回 **HTTP 200**,真正的状态在 body 的 `base_resp.status_code`(0 = 成功)。 + +```json +{"base_resp": {"status_code": 1002, "status_msg": "rate limit"}} +``` + +对中转站的直接后果:**只看 HTTP 状态码的限流检测会完全失效**——限流返回的是 200 而不是 429。重试逻辑、熔断器、计费统计全都要能读 body。 + +建议:网关的上游响应处理层做成"HTTP 状态码 + body 业务码"双通道判定,而不是假设 HTTP 语义可靠。 + +### 0.2 内容安全是一等公民 + +欧美三家只有 `content_filter` / `refusal` 一个笼统信号,国产普遍把内容安全做成结构化字段: + +| 厂商 | 表现形式 | +| --- | --- | +| 智谱 GLM ✅ | `finish_reason: "sensitive"`(OpenAI 枚举里没有这个值) | +| MiniMax ✅ | `input_sensitive` / `output_sensitive` 布尔,外加 `input_sensitive_type` / `output_sensitive_type`(1–7 的违规分类),还有 `mask_sensitive_info` 参数 | + +`finish_reason: "sensitive"` 会直接**打破 OpenAI 客户端的枚举校验**——严格的 SDK(尤其是 Go/Rust 这类强类型的)会反序列化失败。中转站必须把它映射成 `content_filter`,同时用扩展字段保留原值。 + +### 0.3 鉴权方式没有一个统一的 + +| 方式 | 厂商 | +| --- | --- | +| `Authorization: Bearer ` | 智谱(现行)✅、Kimi ✅、豆包/火山方舟 ✅、腾讯混元兼容端点 ✅、DeepSeek | +| Bearer + 额外 header | 百度千帆 v2(`Bearer bce-v3/ALTAK-...` + `appid` header)⚠️ | +| JWT 签名(key 形如 `{id}.{secret}`,需自行签发带 exp 的 token) | 智谱(历史方式,现在仍支持)⚠️ | +| `access_token` 放 query string(用 API Key + Secret Key 先换取) | 百度千帆 v1 ⚠️ | +| TC3-HMAC-SHA256 云厂商签名 | 腾讯混元原生接口 ✅ | +| HMAC-SHA256 签名拼进 **WebSocket URL** | 讯飞星火原生接口 ✅ | +| `AK:SK` 拼接格式 | 讯飞星火 OpenAI 兼容端点 ⚠️ | + +中转站的凭据存储不能假设"一个 key 一个字符串"——至少要支持 (key, secret, appid, region) 四元组,且签名逻辑要能按 provider 插拔。 + +### 0.4 流式默认可能是全量累积 + +见 §1.2 的阿里 `incremental_output`。这是**最容易在生产上炸**的一条:不设参数时每个 SSE chunk 返回的是**从头累积的完整文本**,直接按 OpenAI 语义拼接会得到指数级重复的内容。 + +### 0.5 非标参数塞 `extra_body` + +国产厂商的 OpenAI 兼容模式普遍保留一批自有参数(思考开关、联网搜索、重复惩罚),官方推荐用 OpenAI SDK 的 `extra_body` 透传。中转站需要一个**参数白名单 + 透传通道**,而不是严格按 OpenAI schema 校验后丢弃未知字段。 + +--- + +## 1. 阿里 · 通义千问 / 百炼(DashScope)✅ + +> 来源:https://help.aliyun.com/zh/model-studio/use-qwen-by-calling-api 、https://help.aliyun.com/zh/model-studio/qwen-api-via-dashscope 、https://www.alibabacloud.com/help/zh/model-studio/qwen-api-via-openai-chat-completions + +### 1.1 四套接口并存 + +百炼同时提供**四种**入口,这本身就是中转站的最佳参考: + +1. **OpenAI 兼容 Chat Completions** — 迁移成本最低 +2. **OpenAI 兼容 Responses** — 内置联网搜索、代码解释器、网页提取,自动管理对话历史 +3. **Anthropic 兼容 Messages** — 支持 thinking 和工具调用 +4. **DashScope 原生** — "提供最完整的功能集和参数支持" + +**功能集不等价**:原生接口是超集。做中转站时若只对接 OpenAI 兼容模式,会拿不到部分能力。 + +### 1.2 `incremental_output`:最大的坑 ⚠️ + +DashScope 原生流式下: + +| 取值 | 行为 | +| --- | --- | +| `true` | 每个 chunk 只含**新增**内容(OpenAI 语义) | +| `false`(部分场景默认) | 每个 chunk 返回**从头累积的完整文本** | + +如果按 OpenAI 的"delta 累加"逻辑处理 `incremental_output=false` 的流,输出会变成 `我我是我是通我是通义…` 这样的雪球。 + +**中转站务必显式传 `incremental_output=true`,不要依赖默认值。** + +### 1.3 原生接口是嵌套结构 + +```jsonc +// DashScope 原生 +POST /api/v1/services/aigc/text-generation/generation +{ + "model": "qwen-plus", + "input": { "messages": [...] }, // ← 嵌套 + "parameters": { "temperature": 0.7, "result_format": "message" } +} +// 响应:output.choices[].message ← 比 OpenAI 多一层 output +``` + +`result_format` 取值 `text` / `message`——**取 `text` 时响应结构完全不同**(不是 choices 数组)。中转站应固定用 `message`。 + +### 1.4 其他 + +- **原生 HTTP 流式必须加 header `X-DashScope-SSE: enable`** ⚠️,只设 `stream` 参数不生效。 +- **base_url 按地域 + workspace 分片** ✅:`https://{WorkspaceId}.{region}.maas.aliyuncs.com/compatible-mode/v1`,region 含 cn-beijing / ap-southeast-1 / cn-hongkong / eu-central-1 / ap-northeast-1,另有 `https://dashscope-us.aliyuncs.com/compatible-mode/v1`。中转站的上游配置不能是单一 URL 常量。 +- OpenAI 兼容模式下需走 `extra_body` 的非标参数 ✅:`enable_thinking`、`thinking_budget`、`top_k`、`repetition_penalty`(0.01–2.0)、`enable_search` / `search_options`、`preserve_thinking` / `clear_thinking`、`enable_code_interpreter`、`tool_stream`、`vl_high_resolution_images`。 +- **QwQ 等思考模型需要流式输出** ✅,非流式体验会因长推理阶段而劣化。 +- `seed` 范围 `[0, 2³¹-1]`,与 OpenAI 的 int64 范围不同 ⚠️。 + +--- + +## 2. 智谱 GLM(BigModel)✅ + +> 来源:https://docs.bigmodel.cn/api-reference/模型-api/对话补全 + +端点:`POST https://open.bigmodel.cn/api/paas/v4/chat/completions`(注意路径是 `/api/paas/v4/`,不是 `/v1/`) + +| 怪癖 | 详情 | +| --- | --- | +| **`do_sample`** | 布尔,默认 `true`。为 `false` 时走贪心解码,**`temperature` / `top_p` 全部失效**。OpenAI 无此参数——中转站收到 `temperature: 0` 时应考虑翻译成 `do_sample: false` 而不是直接透传 | +| **`temperature` 范围 `[0.0, 1.0]`** | 不是 OpenAI 的 0~2,超出需 clamp | +| **`top_p` 范围 `[0.01, 1.0]`,默认 0.95** | 下界不是 0,`top_p: 0` 会被拒 | +| **`finish_reason: "sensitive"`** | 内容被安全拦截。**不在 OpenAI 枚举内**,会打破严格客户端 | +| `thinking` | `{"type": "enabled"\|"disabled", "clear_thinking": bool}`,仅 GLM-4.5+ | +| `reasoning_content` | 思维链输出字段(DeepSeek 风格的事实标准) | +| 工具上限 | 最多 128 个 function,另支持 web_search / 知识库检索 / MCP 工具 | +| 鉴权 | 现行推荐 `Authorization: Bearer `;**历史 JWT 方式仍支持** ⚠️——API Key 形如 `{id}.{secret}`,需按点号拆分后签发含 `api_key`/`exp`/`timestamp` 的 JWT | + +默认模型为 `glm-5.2`。流式同样以 `data: [DONE]` 结束。 + +--- + +## 3. 月之暗面 Kimi(Moonshot)✅ + +> **已拆分为独立文件:[kimi.md](./kimi.md)**(2026-08-13 全量重采集,**并用真实 API key 实测校验**)。本节仅保留跨厂商对比时用得上的摘要,细节以那边为准——尤其 §14 列出了 10 条被实测推翻的官方文档结论。 +> +> ⚠️ **域名分层**:文档站/控制台 `platform.moonshot.cn` → `platform.kimi.com`(301),但 **API 数据面仍是 `api.moonshot.cn`**,没有迁到 `api.kimi.com`。另有国际站 `platform.kimi.ai` / `api.moonshot.ai`,**账号与 key 与国内站完全隔离**。 + +| 怪癖 | 详情 | +| --- | --- | +| **采样参数硬 400**(实测) | K3 / K2.7-code 把 `temperature`(1.0) / `top_p`(0.95) / `n`(1) / 两个 penalty 写死,**传其他值 400 而不是静默忽略**(实测 `invalid temperature: only 1 is allowed for this model`)。而 moonshot-v1 的 `temperature` 默认是 **0**、上限是 **1**(OpenAI 是 2),且 **`n>1` 要求 temperature>0**(隐藏耦合,文档未提) | +| **同参数两入口行为相反**(实测) | 同一个 `temperature=0.7`:OpenAI 入口 **400**,自家 Anthropic 入口 **200 静默忽略**。但 `tool_choice` 的限制两边一致——**是选择性的不一致**,不能"打不通就换入口" | +| **流式 `usage` 位置可控**(实测) | 不传 `stream_options.include_usage` 时藏在 **`choices[0].usage`**(OpenAI SDK 的 `chunk.usage` 恒为 `None`,会漏计费);**传 `include_usage:true` 则回到顶层 + `choices:[]`,与 OpenAI 一致**。中转站补这个参数即可,无需改写响应 | +| **Partial Mode(前缀续写)** | 在 messages 末尾加 `{"role": "assistant", "content": "...", "partial": true}`,模型从该前缀继续。**字段名是 `partial`,DeepSeek 叫 `prefix`** —— 同一能力三家三个名字(另见 Anthropic 的 prefill,且 Claude 4.6+ 已禁用) | +| **`n` 最多 5** | 且**仅 Moonshot V1 系列支持**,新模型固定为 1 | +| 思考模型分裂成两套参数 | K3 用顶层 `reasoning_effort`(`low`/`high`/`max`,默认最贵的 `max`);K2.6/K2.5 用 `thinking` 对象;K2.7-code 用 `thinking` 但只接受 `"enabled"`(实测关闭 → 400)。⚠️ **文档称 K3 恒开不可关,实测 `thinking:{"type":"disabled"}` 有效**(`prompt_tokens` 88→21,思考模式带约 67 token 隐藏 system prompt)。⚠️ `reasoning_effort` 传 `"banana"` 也返回 200——**枚举校验形同虚设,非法值静默跑默认的 max 档** | +| `reasoning_content` 明文回传 | 明文(非 Anthropic 那种加密 signature)。⚠️ **文档称 K3/K2.7-code 多轮缺失会报错,实测不报错**(200 正常继续) | +| `tool_choice` 与思考互斥 | 指定具体函数 + 思考开启 → **400 `tool_choice 'specified' is incompatible with thinking enabled`**(两个入口一致)。⚠️ **但关闭思考后再指定函数 → 200 且模型不调用工具、直接幻觉出假数据**——静默错误比 400 更危险,不要把关思考当变通方案 | +| tool_call id 可预测 | 格式是 **`{函数名}_{序号}`**(如 `get_weather_0`),不是 OpenAI 的随机串。多轮同名函数会**重复 id**,用它做去重键会误判 | +| 多模态**不收 http URL** | 只接受 base64 data URI 和 `ms://`。与 OpenAI/Anthropic 的硬性差异 | +| 动态工具加载 | 工具可声明在 `{"role":"system","tools":[...]}`(**无 `content` 字段**)里,位置决定可见轮次。仅 K3 支持,其他模型报 `tokenization failed` | +| `finish_reason` | `stop` / `length` / `tool_calls`,与 OpenAI 一致,**没有 `sensitive`**——内容安全走 400 `content_filter`,比多数国产厂商干净 | +| 错误体缺字段 + 三种格式 | OpenAI 入口只有 `error.type`/`error.message`(**无 `code`/`param`**);Anthropic 入口多 `request_id` 和顶层 `type`;**路径写错会掉到网关层**,返回 `{"code":5,"error":"url.not_found","message":"没找到对象","ua":"curl/8.7.1",...}`——连 `error` 字段的类型都从 object 变成 string | +| 三种 429 语义相反 | `engine_overloaded_error`(退避重试)/ `exceeded_current_quota_error`(欠费,重试无用)/ `rate_limit_reached_error`(降并发)。只看状态码的通用重试会在欠费时打爆上游 | +| **错误信息泄漏账号标识** | 429 的 message 原文含 `Your account org-xxx request reached organization max RPM: 3`——**org id 和 key 标识直接暴露**。响应头另有 `msh-org-id`/`msh-uid`/`msh-project-id`。中转站原样透传 = 把自己的账号广播给所有下游用户 | +| 无 `x-ratelimit-*` 头 | 只在 429 时给 `retry-after` / `x-retry-after` / `msh-cooldown-seconds`(同一信息重复三份)。**成功时无法得知剩余配额**,配额管理只能本地计数 | + +| **两个产品两套栈** | `platform`(`api.moonshot.cn`,key `sk-`)与 **Kimi For Coding**(`api.kimi.com/coding`,key `sk-kimi-`)**key 互不通用**,模型名(`kimi-k3` vs `k3`)、Anthropic 入口路径、限流、错误语义、tool_call id 格式全不同。⚠️ **coding 侧几乎不校验 `model`**:传 `gpt-4`/空字符串都返回 200 并原样回显,只有带方括号的名字被拒(且返回 **401** 而非 404)。详见 [kimi.md](./kimi.md) §17 | + +无状态 API,多轮需自行拼接历史。 + +✅ **context caching 已查证(原"需另行查证"结论作废)**:早期的独立 cache 对象 API 已被移除,**现行是全自动前缀缓存**——无需创建或引用 cache id。⚠️ 文档称"prompt > 256 token 才命中",**实测 21 token 也完整命中**。命中量同时报在 `usage.cached_tokens`(Kimi 私有)和 `usage.prompt_tokens_details.cached_tokens`(OpenAI 兼容,**中转站直接用后者即可**);未命中时字段不出现而非返回 0。K3 命中 ¥2/1M、未命中 ¥20/1M,**10 倍价差**意味着中转站任何对 prompt 前缀的改写都会带来 10 倍成本。 + +--- + +## 4. 字节 · 豆包 / 火山方舟(Ark)✅ + +> 来源:https://doubao.apifox.cn/265892759e0 、搜索结果 + +- Base URL:`https://ark.cn-beijing.volces.com/api/v3`,OpenAI SDK 直接可用。 +- **`model` 字段的历史怪癖** ⚠️:早期必须先在控制台创建"推理接入点"拿到 **Endpoint ID(形如 `ep-2024...`)** 填进 `model`,而不是模型名。现在也支持直接填模型名(如 `doubao-pro-32k-240615`)。 + + 中转站的模型别名表必须允许"一个逻辑模型名 → 一个 ep-xxx 字符串"的映射,且**不同用户的 ep id 不同**(接入点是账号级资源)。这意味着模型路由表可能需要**按租户隔离**,而不是全局共享一张表。 +- usage 中含缓存相关明细字段。 +- ⚠️ 抓取到的 OpenAPI 规范页**未列出 thinking 参数和完整限制**,深度思考模型(doubao-1.5-thinking 等)的参数需另查。 + +--- + +## 5. 百度 · 文心 / 千帆 ⚠️ + +> 来源:https://ai.baidu.com/ai-doc/WENXINWORKSHOP/0m2vwrjws(v2 通告,✅)+ 搜索结果(v1 约束,⚠️) + +百度是"新旧两代差异最大"的一家。 + +### v1(历史,仍在运行)的经典怪癖 ⚠️ + +- **每个模型一个独立 URL**,模型名在路径里,没有 `model` 参数。 +- **鉴权用 `access_token` 放 query string**:先用 API Key + Secret Key 调 `grant_type=client_credentials` 换取。 +- **`messages` 必须是奇数条**。 +- **role 必须 user / assistant 严格交替**:奇数位是 `user`(或 `function`),偶数位是 `assistant`,且**第一条不能是 `function`**。 +- `system` 是**独立顶层字段**,不能放进 messages(和 Anthropic 一样)。 + +这套约束意味着**任何带连续同角色消息的 OpenAI 请求都无法直通 v1**,中转站必须做消息合并(连续 user 合并成一条)。 + +### v2(现行,已 OpenAI 兼容)✅ + +- 端点统一为 `https://qianfan.baidubce.com/v2/chat/completions`,用 `model` 参数选模型。 +- 鉴权改为 **IAM Bearer Token**:`Authorization: Bearer bce-v3/ALTAK-...`,并需要额外的 `appid` header ⚠️。 +- 官方称"完全兼容 OpenAI SDK"。 +- v1 在过渡期内继续可用,TPM 配额在两个版本间共享。 + +**中转站建议直接对接 v2**,但要注意 v2 是否仍保留 messages 交替约束——通告未明确说明,需实测验证。 + +--- + +## 6. 腾讯混元 ✅ + +> 来源:https://cloud.tencent.com/document/product/1729/111007 + +两套接口差异极大: + +### 原生接口 + +- **腾讯云 TC3-HMAC-SHA256 签名**(云厂商通用签名 v3),需要多步 HMAC 派生密钥(SecretDate → SecretService → SecretSigning)。POST 支持最大 10MB。 +- ⚠️ 字段命名是 **PascalCase 大驼峰**(`Messages` / `Role` / `Content`),不是 OpenAI 的 snake_case。这是腾讯云全平台 API 的统一风格。 + +### OpenAI 兼容接口(推荐) + +- Base URL:`https://api.hunyuan.cloud.tencent.com/v1` +- 直接用 API Key + `Authorization: Bearer`,OpenAI SDK 无需改代码。 + +**中转站结论**:走兼容端点,不要碰 TC3 签名——那套签名实现成本高且容易在时钟偏移、URL 编码细节上出错。 + +--- + +## 7. MiniMax ✅ + +> 来源:https://platform.minimax.io/docs/api-reference/text-post + +| 怪癖 | 详情 | +| --- | --- | +| **端点路径非标** | `https://api.minimax.io/v1/text/chatcompletion_v2`,**不是** `/v1/chat/completions` | +| **HTTP 200 + `base_resp`** | 业务错误也返回 200,状态在 `base_resp.status_code`(0 = 成功) | +| 业务状态码 | `1002` 限流 / `1004` 鉴权失败 / `1008` 余额不足 / `1039` token 超限 / `2013` 参数无效 | +| 内容安全字段 | `input_sensitive`、`output_sensitive`、`input_sensitive_type` / `output_sensitive_type`(1–7 分类)、`mask_sensitive_info` | +| `temperature` 范围 | 0–1,非 OpenAI 的 0–2 | +| `top_p` 默认 | 0.95 | +| `response_format` | JSON schema **仅 MiniMax-Text-01 支持** | +| 特有响应字段 | `reasoning_content`、`audio_content`、`correct_rate`(JSON schema 模式下的置信度) | +| usage | 单独统计 `reasoning_tokens` | + +`base_resp` 这一条值得再强调:**限流不返回 429**,任何依赖 HTTP 状态码的重试/熔断中间件在 MiniMax 上都是失效的。 + +--- + +## 8. 讯飞星火 ✅ + +> 来源:https://www.xfyun.cn/doc/spark/ 系列页面 + +- **原生接口是 WebSocket,不是 HTTP** ✅。鉴权方式是用 APPID + APIKey + APISecret 做 HMAC-SHA256,把签名**拼进 WebSocket 连接 URL** 里。这在中转站架构里是完全异类——需要独立的连接管理、心跳、重连逻辑,无法复用 HTTP 连接池。 +- **OpenAI 兼容 HTTP 端点**:`https://spark-api-open.xf-yun.com/v1`,用 APIPassword 认证;最新的 Spark-X2 还支持 `AK:SK` 拼接格式 ⚠️。 + +**中转站结论**:只对接 OpenAI 兼容 HTTP 端点。WebSocket 原生接口的接入成本远高于收益,除非需要它独有的能力(如实时语音)。 + +--- + +## 9. 给中转站的设计清单 + +从上面这些怪癖倒推,网关需要的抽象层: + +1. **双通道错误判定** — HTTP 状态码 + body 业务码(MiniMax `base_resp`)。不能假设 HTTP 语义可靠。 +2. **可插拔签名器** — Bearer / JWT 自签 / query token / TC3-HMAC / WebSocket 签名 URL 至少五种。凭据模型要支持 (key, secret, appid, region) 而非单字符串。 +3. **流式规范化层** — 统一处理"全量累积 vs 增量"(阿里 `incremental_output`)、`[DONE]` 有无、SSE header 要求(`X-DashScope-SSE`)。**这一层是国产接入的核心价值**。 +4. **finish_reason 枚举映射** — `sensitive` → `content_filter`,并用扩展字段保留原值,避免打破强类型客户端。 +5. **非标参数透传白名单** — `extra_body` 风格的旁路通道,而不是按 OpenAI schema 严格校验后丢弃。 +6. **租户级模型路由表** — 火山方舟的 Endpoint ID 是账号级资源,模型别名映射不能全局共享。 +7. **多地域 base_url** — 阿里按 region + workspace 分片,上游地址不能是常量。 +8. **消息结构归一化** — 连续同角色消息合并(百度 v1 交替约束、Anthropic 单 system),作为通用前置处理。 + +--- + +## 10. 未覆盖 / 待验证 + +- **零一万物 Yi、百川 Baichuan、阶跃星辰 StepFun、商汤日日新** — 未收集。 +- **火山方舟深度思考模型的 thinking 参数** — 官方 OpenAPI 页未列出,需另查。 +- ~~**Kimi 的 context caching**~~ — ✅ 已查证(2026-08-13):早期的独立 cache 对象 API 已移除,现为全自动前缀缓存,见 [kimi.md](./kimi.md) §10。Kimi 的其余待验证项已转入 [kimi.md](./kimi.md) §14。 +- **百度 v2 是否仍保留 messages 奇数/交替约束** — 通告未明说,**必须实测**。 +- 所有标 ⚠️ 的条目 — 来自搜索结果聚合,建议用真实请求逐条验证后再固化进代码。 diff --git a/docs/compatibility-layers.md b/docs/compatibility-layers.md new file mode 100644 index 0000000..221f55f --- /dev/null +++ b/docs/compatibility-layers.md @@ -0,0 +1,129 @@ +# 官方兼容层的做法(现成参考实现) + +各家自己都做了"接受他家格式"的兼容层。它们踩过的坑和做出的取舍,正好是中转站的设计参考——尤其是**"忽略 vs 报错"的边界划在哪里**。 + +> 来源: +> - https://platform.claude.com/docs/en/api/openai-sdk +> - https://api-docs.deepseek.com/guides/anthropic_api +> - https://api-docs.deepseek.com/guides/responses_api +> +> 采集于 2026-08-12。 + +--- + +## 1. Anthropic 的 OpenAI SDK 兼容层 + +Base URL:`https://api.anthropic.com/v1/`,用 Anthropic API key,模型名填 Claude 模型名。 + +官方定位明确写着:**主要用于测试和能力对比,不建议作为长期生产方案**。 + +### 核心行为差异 + +1. **`strict` 被忽略** —— 工具调用 JSON 不保证符合 schema。要严格保证得用原生 Structured Outputs。 +2. **音频输入被忽略并从输入中剥离**(不报错)。 +3. **Prompt caching 不支持**(原生 SDK 支持)。 +4. **system/developer 消息被提升并拼接**:所有 system/developer 消息用**单个 `\n`** 连接成一条,放在对话最前面。 +5. **大部分不支持的字段静默忽略,不报错。** + +### 参数支持表 + +| 支持 | 部分支持 | 忽略 | +| --- | --- | --- | +| `model`、`max_tokens`、`max_completion_tokens`、`stream`、`stream_options`、`top_p`、`parallel_tool_calls` | `stop`(仅非空白停止序列)
`temperature`(0~1,**>1 截断为 1**)
`n`(**必须恰为 1**) | `logprobs`、`top_logprobs`、`metadata`、`response_format`、`prediction`、`presence_penalty`、`frequency_penalty`、`seed`、`service_tier`、`audio`、`logit_bias`、`store`、`user`、`modalities`、`reasoning_effort` | + +消息字段层面: + +| 位置 | 忽略的字段 | +| --- | --- | +| 所有 role | `name` | +| user + `image_url` | `detail` | +| user | `input_audio`、`file` 类型 content | +| assistant | `refusal` content、`audio`、`refusal` | +| tools | `function.strict` | + +### 响应字段 + +| 恒定行为 | 字段 | +| --- | --- | +| 长度恒为 1 | `choices[]` | +| 恒为空 | `usage.completion_tokens_details`、`usage.prompt_tokens_details`、`choices[].message.refusal`、`choices[].message.audio`、`logprobs`、`service_tier`、`system_fingerprint` | + +### Header + +支持 `x-ratelimit-*` 全套、`retry-after`、`request-id`、`authorization`。 +`openai-version` 恒为 `2020-10-01`,`openai-processing-ms` 恒为空。 + +### 错误 + +错误**格式**与 OpenAI 一致,但**错误消息内容不等价**。官方明确说明:只用于日志和调试,**不要基于错误消息文本做逻辑判断**。 + +### thinking 的传法 + +通过 `extra_body` 透传: + +```python +response = client.chat.completions.create( + model="claude-sonnet-4-6", + messages=[{"role": "user", "content": "Who are you?"}], + extra_body={"thinking": {"type": "enabled", "budget_tokens": 2000}}, +) +``` + +但 **OpenAI SDK 不会返回 Claude 的详细思考过程**——要拿到思考内容必须用原生 API。 + +--- + +## 2. DeepSeek 的 Anthropic 兼容层 + +Base URL:`https://api.deepseek.com/anthropic`(配置 `ANTHROPIC_BASE_URL`)。 + +### 模型名映射(静默兜底) + +| 传入 | 实际使用 | +| --- | --- | +| `claude-opus*` | `deepseek-v4-pro` | +| `claude-haiku*` / `claude-sonnet*` | `deepseek-v4-flash` | +| 其他任意值 | `deepseek-v4-flash` | + +**未知模型名不报错**。这是个值得商榷的设计——用户拼错模型名会得到一个成功但非预期的响应。 + +### 支持 / 忽略 + +| 支持 | 忽略或不支持 | +| --- | --- | +| `model`、`max_tokens`、`system`、`stream`、`stop_sequences`、`tools` | `anthropic-beta` / `anthropic-version` header | +| `temperature`(**0.0~2.0**,比官方 Anthropic 的 0~1 宽) | image / document content 类型 | +| `thinking`(但 **`budget_tokens` 被忽略**) | `top_k` | +| `metadata.user_id`(用于限流隔离) | 其他 metadata 字段 | + +### 思考参数命名不一致 + +DeepSeek 的 Anthropic 入口用 **`reasoning.effort`**,而官方 Anthropic API 用 `thinking` + `output_config.effort`。 +换句话说:**同一段客户端代码不能同时对接官方 Anthropic 和 DeepSeek 的 Anthropic 入口**。 + +--- + +## 3. DeepSeek 的 Responses API 兼容层 + +Base URL:`https://api.deepseek.com`,目前**只支持 `deepseek-v4-flash`**。 + +- **"未支持的参数一律静默忽略,不报错"** —— 官方明文的设计原则,理由是不破坏既有实现。 +- `store` 恒返回 `false`。 +- `previous_response_id`、`conversation` 不支持(无状态架构)。 +- 上下文缓存自动进行,无手动参数。 +- **image / file 输入被替换成占位文本**。这是"静默忽略"里最危险的一种:请求成功、响应看起来正常,但模型根本没看到图片。 + +--- + +## 4. 对本项目的设计启示 + +1. **"静默忽略"是行业默认做法**,但它把调试成本转嫁给用户。建议: + - 默认静默忽略(保持生态兼容); + - 同时通过响应 header(如 `x-gateway-dropped-params`)或可选的严格模式(`?strict=1`)暴露被丢弃的参数。 + - **对会改变语义的丢弃(图片、音频、工具 strict)必须有更强的信号**,不能和 `seed` 这种无害丢弃一视同仁。 +2. **模型名兜底要谨慎**。DeepSeek 的"未知名字 → flash"会掩盖拼写错误。建议白名单 + 显式别名表,未匹配则 404。 +3. **错误消息不要求等价,但格式必须等价**。Anthropic 兼容层的态度是对的:格式对齐,消息内容不承诺。 +4. **能力降级要分级**: + - 无害(`seed`、`logit_bias`、`metadata`)→ 静默丢 + - 影响质量(`temperature` clamp、`top_k` 丢弃)→ 记录日志 + - 改变语义(图片被丢、`strict` 失效、`n>1` 退化)→ 报错或强提示 diff --git a/docs/cross-provider-mapping.md b/docs/cross-provider-mapping.md new file mode 100644 index 0000000..b91f81a --- /dev/null +++ b/docs/cross-provider-mapping.md @@ -0,0 +1,181 @@ +# 跨 Provider 映射表 + +本文件把三家的字段与语义放在一起对照,标注**哪些能无损映射、哪些必然有损**。数据来源见 [openai.md](./openai.md)、[anthropic.md](./anthropic.md)、[deepseek.md](./deepseek.md) 各自的来源列表。 + +--- + +## 1. 端点与鉴权 + +| | OpenAI Chat | OpenAI Responses | Anthropic Messages | DeepSeek | +| --- | --- | --- | --- | --- | +| 路径 | `/v1/chat/completions` | `/v1/responses` | `/v1/messages` | `/chat/completions` | +| 鉴权 | `Authorization: Bearer` | 同左 | **`x-api-key`** | `Authorization: Bearer` | +| 版本 header | 无 | 无 | **`anthropic-version: 2023-06-01` 必需** | 无 | + +--- + +## 2. 请求参数 + +| 语义 | OpenAI Chat | OpenAI Responses | Anthropic | DeepSeek | +| --- | --- | --- | --- | --- | +| 消息列表 | `messages` | `input` | `messages` | `messages` | +| 系统提示 | `messages[role=system\|developer]`(可多条、可在任意位置) | `instructions`(顶层) | **`system`(顶层,只有一条)** | `messages[role=system]` | +| 输出上限 | `max_completion_tokens`(`max_tokens` 已废弃) | `max_output_tokens` | **`max_tokens`(必填)** | `max_tokens` | +| 温度 | `temperature` 0~2 | 同左 | **`temperature` 0~1** | `temperature` 0~2 | +| top-p | `top_p` | `top_p` | `top_p` | `top_p` | +| top-k | ✗ | ✗ | `top_k` | ✗ | +| 停止序列 | `stop`(≤4) | — | `stop_sequences` | `stop`(≤16) | +| 频率惩罚 | `frequency_penalty` | — | ✗ | **已废弃** | +| 存在惩罚 | `presence_penalty` | — | ✗ | **已废弃** | +| 多候选 | `n`(1~128) | ✗ | ✗ | ✗ | +| JSON 模式 | `response_format` | `text.format` | `output_config.format` | `response_format`(仅 `json_object`) | +| 工具 | `tools[].function.parameters` | `tools[].parameters` | **`tools[].input_schema`** | 同 OpenAI Chat | +| 工具选择 | `tool_choice`: `auto`/`required`/`none`/具体 | 同左 | `tool_choice`: `auto`/**`any`**/`none`/`tool` | 同 OpenAI Chat | +| 禁并行工具 | `parallel_tool_calls: false` | 同左 | `tool_choice.disable_parallel_tool_use: true` | `parallel_tool_calls` | +| 推理开关 | `reasoning_effort` | `reasoning.{effort,summary}` | `thinking` + `output_config.effort` | `thinking.{type,reasoning_effort}` | +| 缓存控制 | `prompt_cache_key` / `prompt_cache_options.ttl` | 同左 | `cache_control` 断点 | **无(全自动)** | +| 用户标识 | `safety_identifier`(`user` 已废弃) | 同左 | `metadata.user_id` | `user_id`(≤512,限字符集) | + +### 必然有损的转换 + +- **多条 system 消息 → Anthropic**:只能合并。Anthropic 官方兼容层的做法是把所有 system/developer 消息用**单个换行 `\n`** 连接成一条,放在最前。 +- **`n > 1` → 任何非 OpenAI 上游**:无法实现,只能拒绝或退化为 `n=1`。 +- **`frequency_penalty` / `presence_penalty` / `logit_bias` / `seed` → Anthropic 或 DeepSeek**:无对应,只能忽略。 +- **`temperature` OpenAI(0~2) → Anthropic(0~1)**:需 clamp。官方兼容层的选择是 **>1 一律截为 1**(而不是线性缩放)。 +- **`top_k` → OpenAI**:无对应,只能丢弃。 + +--- + +## 3. 消息与内容块 + +| 语义 | OpenAI Chat | Anthropic | +| --- | --- | --- | +| 纯文本 | `content: "..."` 或 `[{type:"text", text}]` | `content: "..."` 或 `[{type:"text", text}]` | +| 图片 | `{type:"image_url", image_url:{url, detail}}` | `{type:"image", source:{type:"base64"\|"url", media_type, data\|url}}` | +| 图片 detail | `detail: low/high/auto` | **无对应,丢弃** | +| PDF/文档 | `{type:"file"}` | `{type:"document", source:{...}, citations}` | +| 音频输入 | `{type:"input_audio"}` | **不支持** | +| 助手工具调用 | `assistant.tool_calls[]` | `content` 里的 `tool_use` block | +| 工具结果 | **独立 `{role:"tool", tool_call_id, content}` 消息** | **user 消息 content 里的 `tool_result` block** | +| 工具错误 | 靠 content 文本表达 | `tool_result.is_error: true` | +| 推理内容 | `reasoning` item(Responses)| `thinking` / `redacted_thinking` block | +| 引用 | `annotations` | `citations`(5 种 location 类型) | + +### 工具 ID 三套命名 + +| | 调用侧 | 结果侧 | +| --- | --- | --- | +| OpenAI Chat | `tool_calls[].id` | `tool.tool_call_id` | +| OpenAI Responses | `function_call.call_id` | `function_call_output.call_id` | +| Anthropic | `tool_use.id`(`toolu_` 前缀) | `tool_result.tool_use_id` | + +中转站需要维护 ID 映射表,不能假设上游会接受下游生成的 ID 格式。 + +--- + +## 4. 停止原因 + +| 语义 | OpenAI `finish_reason` | Anthropic `stop_reason` | DeepSeek `finish_reason` | +| --- | --- | --- | --- | +| 自然结束 | `stop` | `end_turn` | `stop` | +| 达到上限 | `length` | `max_tokens` | `length` | +| 命中停止序列 | `stop` | `stop_sequence` | `stop` | +| 调用工具 | `tool_calls` | `tool_use` | `tool_calls` | +| 内容过滤 | `content_filter` | `refusal`(+ `stop_details.category`) | `content_filter` | +| 上下文溢出 | 无(表现为 400) | `model_context_window_exceeded` | 无 | +| 服务端工具暂停 | 无 | **`pause_turn`** | 无 | +| 资源不足 | 无 | 无 | **`insufficient_system_resource`** | + +**注意**:`stop` 与 `stop_sequence` 在 OpenAI 侧合并成一个值,Anthropic → OpenAI 方向可无损降级;反向则丢失信息。`pause_turn` 在 OpenAI 格式下**无法表达**,中转站必须在网关内部自行完成续跑循环,不能透传给客户端。 + +--- + +## 5. Usage 统计 + +| 语义 | OpenAI | Anthropic | DeepSeek | +| --- | --- | --- | --- | +| 输入 token | `usage.prompt_tokens` | `usage.input_tokens` **(仅未缓存部分)** | `usage.prompt_tokens`(总数) | +| 输出 token | `usage.completion_tokens` | `usage.output_tokens` | `usage.completion_tokens` | +| 总计 | `usage.total_tokens` | 需自行相加 | `usage.total_tokens` | +| 缓存命中 | `prompt_tokens_details.cached_tokens` | `cache_read_input_tokens` | `prompt_cache_hit_tokens` | +| 缓存写入 | 无单独计数 | `cache_creation_input_tokens` | 无(`prompt_cache_miss_tokens` 是未命中数) | +| 推理 token | `completion_tokens_details.reasoning_tokens` | `output_tokens_details.thinking_tokens` | `completion_tokens_details.reasoning_tokens` | + +**关键陷阱**:Anthropic → OpenAI 时, +``` +prompt_tokens = input_tokens + cache_read_input_tokens + cache_creation_input_tokens +``` +直接用 `input_tokens` 会严重低报(缓存命中时可能低报 99%)。 + +--- + +## 6. 流式协议映射 + +| | OpenAI | Anthropic | +| --- | --- | --- | +| 结构 | 无状态 chunk 序列 | **有状态**的 block 事件机 | +| 事件名 | 无(都是匿名 `data:`) | 有(`event: message_start` 等) | +| 文本增量 | `choices[0].delta.content` | `content_block_delta` + `text_delta` | +| 工具增量 | `delta.tool_calls[].function.arguments`(按 `index` 累加) | `content_block_delta` + `input_json_delta.partial_json` | +| 推理增量 | Responses 有专门事件 | `thinking_delta` + `signature_delta` | +| usage | 最后一个 chunk(需 `include_usage`),`choices` 为空数组 | `message_delta.usage`,**累计值** | +| 结束标记 | `data: [DONE]` | `message_stop` | +| 保活 | 无标准(实践中发注释行) | `ping` 事件 | +| 中途错误 | 无标准 | `error` 事件(200 之后仍可能发) | + +### Anthropic → OpenAI 流式转换要点 + +1. `message_start` → 发一个只含 `role: "assistant"` 的首 chunk。 +2. `content_block_start(text)` → 无对应,吞掉。 +3. `text_delta` → `delta.content`。 +4. `content_block_start(tool_use)` → 发一个带 `id`/`name`、`arguments: ""` 的 `tool_calls[index]` chunk。 +5. `input_json_delta` → `tool_calls[index].function.arguments` 追加。 +6. `thinking_delta` → 无标准落点;常见做法是映射到 `delta.reasoning_content`(DeepSeek 风格,事实标准)。**`signature_delta` 无处可放,会丢失**——这意味着经过 OpenAI 格式往返后的对话**无法再发回 Anthropic 上游**。 +7. `message_delta.stop_reason` → 最后一个 chunk 的 `finish_reason`。 +8. `message_stop` → `data: [DONE]`。 +9. `ping` → 丢弃或转成 SSE 注释行保活。 + +### OpenAI → Anthropic 流式转换要点 + +必须自行维护 block index 状态机:文本与工具调用要分配不同的 `index`,并在切换时补发 `content_block_stop` / `content_block_start`。OpenAI 的 chunk 里没有"块结束"信号,只能靠 `tool_calls[].index` 变化和 `finish_reason` 推断。 + +--- + +## 7. 推理内容三方对照(最不可互通的部分) + +| | OpenAI Responses | Anthropic | DeepSeek | +| --- | --- | --- | --- | +| 载体 | `reasoning` item | `thinking` / `redacted_thinking` block | `reasoning_content` 字符串 | +| 可见文本是什么 | `summary`(需显式开启) | **摘要,永远不是原始 CoT** | 思考内容字符串 | +| 真实推理载体 | `encrypted_content` | **`signature`(完整思考的密文)** | 无,就是明文字段 | +| 密文是否必需 | ZDR / `store:false` 时必需 | **始终存在**,服务端解密它重建思考 | 不适用 | +| 可否修改 | 加密内容需原样回传 | **不可修改,改了直接 400** | 可自由处理 | +| 多轮是否必回传 | 有函数调用时必须 | **工具轮次内必须**;其他情况可省略但不可改 | 无工具调用时**不必**;有工具调用时**必须** | +| 关闭方式 | `reasoning.effort: "none"` / `"minimal"` | 部分模型**根本无法关闭** | `thinking.type: "disabled"` | +| 跨模型可移植 | — | **否**,绑定生成模型,换模型必须剥离 | 是 | +| 跨平台可移植 | — | **是**,API/Bedrock/Vertex 三方通用 | — | + +### 为什么 Anthropic 侧无法靠字段翻译打通 + +Anthropic 的 `thinking` 文本字段是**摘要**,真正的推理内容加密在 `signature` 里,服务端回传时**解密 `signature` 来重建思考,并不读你传的文本**(详见 [anthropic.md](./anthropic.md) §5)。 + +所以这条常见的转换路径是**死路**: + +``` +Anthropic thinking block → reasoning_content 字符串 → 存下来 → 转回 thinking block +``` + +转回去的东西只有摘要文本、没有 signature,Anthropic 侧要么 400,要么丢失全部推理连续性。 + +### 可行做法 + +**把 signature / encrypted_content / redacted_thinking.data 当成 opaque blob 原样搬运。** 三家的密文字段都明确声明为不可解析,中转站不需要理解它们: + +1. 网关按会话 ID 存储上游返回的**原始 content block 数组**(含密文字段)。 +2. 下游客户端只看到脱敏后的摘要文本(映射到 `reasoning_content` 或 Responses 的 reasoning item)。 +3. 下一轮请求时,用存储的原始块重建上游请求,而不是从下游回传的文本反向构造。 + +需要额外注意的两点: + +- **模型路由会破坏它**:Anthropic thinking block 绑定生成它的模型,换模型时必须剥离。其他模型**静默忽略而不报错,但照样计输入 token**——做故障转移时不清理,用户会为无用内容付费且毫无提示。 +- **signature 很长**(Claude 4+ 显著变长)。存储层的列宽、日志截断、header 大小限制都可能悄悄截断它,而截断后的 signature 会在下一轮触发 400。 diff --git a/docs/deepseek.md b/docs/deepseek.md new file mode 100644 index 0000000..d3311f3 --- /dev/null +++ b/docs/deepseek.md @@ -0,0 +1,404 @@ +# DeepSeek API + +> 来源: +> - https://api-docs.deepseek.com/api/create-chat-completion +> - https://api-docs.deepseek.com/guides/thinking_mode +> - https://api-docs.deepseek.com/guides/tool_calls +> - https://api-docs.deepseek.com/guides/json_mode +> - https://api-docs.deepseek.com/guides/kv_cache +> - https://api-docs.deepseek.com/guides/chat_prefix_completion +> - https://api-docs.deepseek.com/guides/fim_completion +> - https://api-docs.deepseek.com/guides/responses_api +> - https://api-docs.deepseek.com/guides/anthropic_api +> - https://api-docs.deepseek.com/quick_start/pricing +> - https://api-docs.deepseek.com/quick_start/error_codes +> +> 文档采集于 2026-08-12。**2026-08-13 用真实 API key 全量实测校验过一遍**,实测与文档冲突处均以实测为准并标注 ⚠️。 +> 实测环境 `system_fingerprint`:flash = `fp_a18b46594c_prod0820_fp8_kvcache_20260402`,pro = `fp_v4pro_20260812_prod0820_fp8_kvcache_20260402`。 + +DeepSeek 的定位是 **OpenAI 格式兼容**,但有一批自有扩展和"看起来支持其实忽略"的参数。它同时提供三种入口格式(OpenAI / Anthropic / Responses),本身就是一个"中转站"的现成参考——**而且是一个反面参考**:实测发现它的三个入口在思考参数、图片处理、错误格式、tool_choice 上行为都不一致。 + +Base URL: +- `https://api.deepseek.com` — OpenAI Chat Completions 格式 + Responses API 格式 +- `https://api.deepseek.com/beta` — Beta 功能(prefix completion、FIM、strict tool calls) +- `https://api.deepseek.com/anthropic` — Anthropic Messages 格式 + +实测路径别名(文档未列): +- Responses:`/responses` 与 `/v1/responses` 等价 +- Anthropic:`/anthropic/messages` 与 `/anthropic/v1/messages` 等价 +- Anthropic 入口鉴权:`x-api-key` 与 `Authorization: Bearer` **都接受** + +额外端点(文档未列):`GET /user/balance` → `{"is_available":true,"balance_infos":[{"currency":"CNY","total_balance":"...","granted_balance":"...","topped_up_balance":"..."}]}`。中转站可用它做上游健康检查与余额预警。 + +--- + +## 1. 模型与能力矩阵 + +`GET /models` 实测只返回两个模型: + +| 模型 | 上下文 | 最大输出 | +| --- | --- | --- | +| `deepseek-v4-flash` | 1,048,576 | 393,216 | +| `deepseek-v4-pro` | 1,048,576 | 393,216 | + +> 实测精确值,来自越界报错:`This model's maximum context length is 1048576 tokens`、`the valid range of max_tokens is [1, 393216]`。 + +⚠️ **模型名别名**:`deepseek-chat` 和 `deepseek-reasoner`(旧模型名)会被**静默映射到 `deepseek-v4-flash`**,响应的 `model` 字段返回 `deepseek-v4-flash`。其他未知名字(`gpt-4`、`deepseek-v3`、空串等)会明确报错:`The supported API model names are deepseek-v4-pro or deepseek-v4-flash, but you passed X.` +> 中转站注意:用户传 `deepseek-chat` 拿到的是 v4-flash 且无任何提示,账单模型名与请求模型名对不上。 + +| 能力 | v4-flash | v4-pro | +| --- | --- | --- | +| Tool Calls | ✓ | ✓ | +| JSON Output(仅 `json_object`) | ✓ | ✓ | +| FIM Completion | ✓ | ✓ | +| Chat Prefix Completion | ✓ | ✓ | +| Thinking Mode | ✓(默认开) | ✓(默认开) | +| Responses API | ✓ | ✓ ⚠️ | +| 内置 `web_search`(仅 Responses 入口) | ✓ | ✓ | +| Anthropic API | ✓ | ✓ | + +⚠️ 文档称 Responses API "不支持 v4-pro,计划 2026-08 初支持"——**实测已支持**,`deepseek-v4-pro` 在 `/v1/responses` 正常返回。 +⚠️ 文档称 FIM "仅非思考模式"且"模型用 v4-pro"——**实测 flash 和 pro 都支持 FIM**,见 §7。 + +价格(每 1M token): + +| 模型 | 缓存命中 | 缓存未命中 | 输出 | +| --- | --- | --- | --- | +| v4-flash | $0.0028 | $0.14 | $0.28 | +| v4-pro | $0.003625 | $0.435 | $0.87 | + +> 官方页面明确预告将大幅涨价,价格不要硬编码。 + +--- + +## 2. Chat Completions(`POST /chat/completions`) + +必填:`messages`、`model`。 + +| 参数 | 类型 | 默认 | 实测行为 | +| --- | --- | --- | --- | +| `thinking` | object | `{type:"enabled"}` | **DeepSeek 自有扩展**;`type: enabled/disabled/adaptive` ⚠️,`reasoning_effort: low/high/max` | +| `reasoning_effort` | string | — | ⚠️ **顶层 OpenAI 写法也生效**(文档未列),`"none"` 可关闭思考 | +| `max_tokens` | int | — | 范围 `[1, 393216]`,越界 400 | +| `temperature` | number | 1 | 范围 `[0, 2]`,越界 400(非静默忽略) | +| `top_p` | number | 1 | 范围 `(0, 1.0]`,越界 400 | +| `response_format` | object | `{type:"text"}` | 仅 `text` / `json_object`;`json_schema` 报 400 | +| `stream` | bool | false | | +| `stream_options` | object | — | `include_usage` | +| `stop` | string\|array | — | 最多 16 个,17 个报 `Stop string array too long: 17` | +| `tools` | array | — | `type` 只接受 `function`(`web_search` 等报 400) | +| `tool_choice` | — | `auto` | ⚠️ 思考模式下**只支持 `none`/`auto`**,见 §4 | +| `logprobs` | bool | false | ⚠️ 思考模式下返回结构不同,见下 | +| `top_logprobs` | int | — | 0 ~ 20,需 `logprobs=true` | +| `user_id` | string | — | ≤512 字符,文档称字符集 `[a-zA-Z0-9\-_]`,**实测不校验**(传 `用户@1` 也 200) | +| `n` | int | 1 | **只支持 1**,`n=2` 报 `Invalid n value (currently only n = 1 is supported)` | + +**已废弃**:`frequency_penalty`、`presence_penalty` — 实测传了不报错,静默忽略。 +**静默忽略**(传了不报错也不生效):`seed`、OpenAI 标准的 `user`(注意与 DeepSeek 自有的 `user_id` 不是同一个参数)。 + +**硬报错的兼容性坑**: +- `role: "developer"`(OpenAI 新标准)→ 400 `unknown variant 'developer'`。中转站必须把 `developer` 降级成 `system`。 +- `content` 数组中的 `image_url` → 400 `unknown variant 'image_url', expected ...`。**注意这与 Anthropic / Responses 入口的静默降级行为不一致**,见 §8。 +- 纯文本的 `content: [{"type":"text","text":"..."}]` 数组形式 → 正常支持。 + +**消息序列约束极宽松**(实测均返回 200):连续两条 `user`、以 `assistant` 结尾(无 `prefix`)、`system` 出现在中间、只有 `system` 一条——全部接受。唯一报错的是空数组 `messages: []` → `Empty input messages`。 + +响应字段基本对齐 OpenAI,额外有: +- `message.reasoning_content` — 思考内容(字符串) +- `usage.prompt_cache_hit_tokens` / `usage.prompt_cache_miss_tokens` +- `usage.prompt_tokens_details.cached_tokens`(与 `prompt_cache_hit_tokens` 同值,两套字段并存) +- `usage.completion_tokens_details.reasoning_tokens` — ⚠️ **仅思考模式存在**;非思考模式下整个 `completion_tokens_details` 对象都不出现 +- `finish_reason` 多一个取值:**`insufficient_system_resource`** +- ⚠️ 非流式响应的 `tool_calls[]` 里**也带 `index` 字段**(OpenAI 非流式不带) + +### ⚠️ logprobs 在思考模式下结构不同 + +非思考模式:`logprobs.content[]`(同 OpenAI)。 +思考模式:返回的是 **`logprobs.reasoning_content[]`** ——这个 key 在 OpenAI 规范里不存在(OpenAI 只有 `content` 和 `refusal`)。严格按 OpenAI schema 反序列化的客户端会拿到空的 logprobs 或直接解析失败。 + +--- + +## 3. Thinking Mode 的怪癖 + +### ⚠️ 三个入口的参数写法互不相同——且与官方文档记载相反 + +文档给出的映射表(Anthropic 用 `reasoning.effort`、Responses 用 `output_config.effort`)**实测是错的**。以"关闭思考"为探针跑的完整交叉矩阵(观察 `reasoning_content`/`thinking` block 是否出现、`prompt_tokens` 是 5 还是 84): + +| 入口 | 写法 | 实测结果 | +| --- | --- | --- | +| **OpenAI** | `{"thinking":{"type":"disabled"}}` | ✅ 生效(prompt=5) | +| **OpenAI** | `{"reasoning_effort":"none"}` | ✅ 生效(prompt=5)——文档未记载 | +| **OpenAI** | `{"reasoning":{"effort":"none"}}` | ❌ 静默忽略(prompt=84,仍思考) | +| **Anthropic** | `{"thinking":{"type":"disabled"}}` | ✅ 生效(input=5) | +| **Anthropic** | `{"reasoning":{"effort":"none"}}` | ❌ 静默忽略(input=84)——**文档说这个才对,实测无效** | +| **Anthropic** | `{"reasoning_effort":"none"}` | ❌ 静默忽略(input=84) | +| **Responses** | `{"reasoning":{"effort":"none"}}` | ✅ 生效(reasoning_tokens=0) | +| **Responses** | `{"output_config":{"effort":"none"}}` | ❌ 静默忽略——**文档说这个才对,实测无效** | +| **Responses** | `{"thinking":{"type":"disabled"}}` | ❌ 静默忽略 | + +**结论**:Chat 与 Anthropic 入口统一用 `thinking.type`;Responses 入口用 OpenAI 标准的 `reasoning.effort`。所有"不生效"的写法都是**静默忽略、不报错**——中转站若照文档实现关思考,会得到一个照常思考并照常计费的响应。 + +### ⚠️ `thinking.type` 有第三个取值 `adaptive`(文档完全未记载) + +从 400 报错里泄露的枚举:`unknown variant 'banana', expected one of 'adaptive', 'enabled', 'disabled'`。 + +实测 `adaptive` 会按问题难度调节思考量("hi" → 24 reasoning tokens;"严格证明 √2 无理" → 262),但**不会完全关闭思考**,简单问题仍产出 `reasoning_content`。 + +### ⚠️ 思考模式会注入约 79 token 的隐藏 system prompt,并计入你的账单 + +同一条 `"hi"` 请求: + +| | `prompt_tokens` | +| --- | --- | +| `thinking.type = disabled` | **5** | +| `thinking.type = enabled`(默认) | **84** | + +这 79 token 的差额是 DeepSeek 服务端注入的思考指令,按输入价计费,且**三个入口都一样**(Anthropic 入口 `input_tokens` 同为 84)。默认开启意味着中转站转发的每一条短请求都在为它买单。 + +### reasoning_effort 的实际效果存疑 + +`reasoning_effort` 的**非法值不报错**(`"banana"` 照常返回 200),只有 `thinking.type` 是强类型枚举。`none`/`minimal`/`xhigh`/`medium` 等文档外取值也一律静默接受。 + +在 flash 上对同一道题各档采样 5 次的 `reasoning_tokens`: + +| effort | 样本 | 均值 | +| --- | --- | --- | +| `low` | 116, 80, 118, 68, 118 | 100.0 | +| `high` | 48, 71, 120, 73, 90 | 80.4 | +| `max` | 101, 56, 111, 89, 79 | 87.2 | + +**方差远大于档位间差异,flash 上看不出 effort 有可观测效果。** v4-pro 上采样(low ≈ 513、high ≈ 799)倾向于正相关,但样本量不足以定论。文档所称"flash 直通、pro 上 high 与 xhigh 都映射为 high"**未能证实也未能证伪**——不要基于这个假设做路由决策。 + +> 例外:Responses 入口的 `reasoning.effort: "none"` 是唯一能观测到确定效果的取值(`reasoning_tokens` 精确为 0)。 + +### ⚠️ reasoning_content 多轮回传是硬性要求(不回传直接 400) + +文档表述为"必须回传",实测是**服务端强校验**: + +``` +{"error":{"message":"The `reasoning_content` in the thinking mode must be passed back to the API.", + "type":"invalid_request_error","code":"invalid_request_error"}} +``` + +精确的触发条件(实测逐一验证): + +| 场景 | 不回传 `reasoning_content` | +| --- | --- | +| 思考模式 + 历史含 `tool_calls` | **400 报错** | +| 思考模式 + 无 `tool_calls` 的普通多轮 | 正常,不报错 | +| 非思考模式 + 历史含 `tool_calls` | 正常,不报错 | + +传空串 `"reasoning_content": ""` **可以过校验**——这是中转站从 Anthropic/OpenAI 格式转过来、丢失了原始思考内容时的可用降级手段(模型质量可能下降,但不会 400)。 + +这和 Anthropic 的规则(thinking block 必须原样、带 signature 回传)以及 OpenAI 的规则(encrypted_content / reasoning item)都不同,中转站需要按上游分别处理。 + +### 流式响应的两处非 OpenAI 行为 + +1. **`usage` 挂在最后一个正常 chunk 上**,而不是像 OpenAI 那样单独发一个 `choices: []` 的 usage-only chunk。DeepSeek 把 usage 塞进了带 `finish_reason` 的那个 chunk 里,之后直接 `data: [DONE]`。按 OpenAI 语义等待独立 usage chunk 的实现会永远拿不到 usage。 +2. **delta 中未使用的字段是显式 `null` 而非省略**:`{"delta":{"content":null,"reasoning_content":"We"}}` / `{"delta":{"content":"4","reasoning_content":null}}`。对 `null` 与"字段缺失"处理不同的客户端需注意。 + +思考内容通过 `delta.reasoning_content` 增量下发,先出完 `reasoning_content` 再出 `content`。 + +--- + +## 4. Tool Calls 的怪癖 + +- 结构与 OpenAI 一致:`tools[].{type:"function", function:{name, description, parameters}}`,回传用 `role: "tool"` + `tool_call_id`。`tools[].type` 只接受 `function`。 +- **思考模式下 `tool_choice` 只支持 `auto` 和 `none`**(⚠️ 文档未记载): + +| `tool_choice` | thinking enabled | thinking disabled | +| --- | --- | --- | +| `"auto"` | ✅ | ✅ | +| `"none"` | ✅ | ✅ | +| `"required"` | ❌ **400** | ✅ | +| `{"type":"function","function":{"name":...}}` | ❌ **400** | ✅ | + +报错信息:`Thinking mode does not support this tool_choice`。由于**思考模式是默认开启的**,中转站转发一个带 `tool_choice: "required"` 的普通 OpenAI 请求会直接 400——必须同时下发 `thinking.type: "disabled"`,或把 `required` 降级为 `auto`。 + +- **`strict` 是 Beta 功能**,文档称必须切到 `https://api.deepseek.com/beta`。实测在主 base_url 上传 `strict: true` **不报错**(静默接受并正常返回),无法从响应上区分是否生效——属于典型的静默失效。 +- strict 模式支持的 schema 类型:object、string、number、integer、boolean、array、enum、anyOf、`$ref`、`$def`。实测传入 `pattern`、`minLength` 等不支持的关键字也不报错(静默接受,约束不保证生效)。 +- 思考模式下的工具调用从 DeepSeek-V3.2 起支持,实测思考 + 工具调用可同时返回 `reasoning_content` 与 `tool_calls`,`finish_reason: "tool_calls"`。 + +--- + +## 5. JSON Output 的怪癖 + +- 只有 `response_format: {"type": "json_object"}`。`json_schema` 明确报错:`This response_format type is unavailable now`(措辞暗示未来会支持,但目前中转站必须拦截或降级)。 +- **必须在 system 或 user prompt 中出现 "json" 字样**,否则硬报错: + `Prompt must contain the word 'json' in some form to use 'response_format' of type 'json_object'.` + 中转站从其他上游格式转发时,如果用户 prompt 里没有 "json" 字样,会拿到一个用户完全无法理解的 400。 +- 官方承认 API 偶尔会返回空 content,建议通过调整 prompt 措辞规避。中转站需要有重试策略。 +- `max_tokens` 给小了会截断成非法 JSON。 +- 与思考模式可共存,`reasoning_content` 与 JSON `content` 同时返回。 + +--- + +## 6. Context Caching 的怪癖 + +- **全自动,无 `cache_control` 之类的手动参数。** 与 Anthropic 的显式断点模型完全不同——不能双向映射。 +- ⚠️ **实测缓存粒度为 64 token**(文档称"未说明最小可缓存粒度")。两组独立实测的 `prompt_cache_hit_tokens` 均为 64 的整数倍:3328 = 52×64、1920 = 30×64。命中量向下对齐到 64 的倍数,尾部不足 64 的部分计为 miss。 +- ⚠️ **缓存即时生效**(文档称"缓存构建需要数秒")。实测第 1 次请求 miss 全量,**紧接着的第 2 次请求立即命中**,无需等待: + + | 请求 | prompt_tokens | hit | miss | + | --- | --- | --- | --- | + | 第 1 次 | 3379 | 0 | 3379 | + | 第 2 次 | 3379 | 3328 | 51 | + | 第 3 次 | 3379 | 3328 | 51 | + +- **公共前缀缓存已验证**:两个只有尾部一个词不同的请求(2006 / 2007 token)交替发送,双方都稳定命中共同前缀的 1920 token。 +- 缓存不再使用后自动清除,通常在**数小时到数天**内。 +- 统计字段:`prompt_cache_hit_tokens` / `prompt_cache_miss_tokens`,以及 `prompt_tokens_details.cached_tokens`(同值)。Anthropic 入口映射为 `cache_read_input_tokens`,`cache_creation_input_tokens` **实测恒为 0**(DeepSeek 没有"写入缓存"这个计费概念,官方 Anthropic 的缓存写入溢价在此不存在)。 + +--- + +## 7. Beta 功能(需 `base_url=https://api.deepseek.com/beta`) + +### Chat Prefix Completion + +在 `messages` 最后一条设 `{"role": "assistant", "content": "...", "prefix": true}`,模型从该前缀继续生成。 + +- ⚠️ 在主 base_url 上传 `prefix` 是**明确报错**(不是静默忽略): + `prefix is only available when using beta api (set base_url="https://api.deepseek.com/beta")` +- 最后一条 message 的 role 必须是 `assistant`。 +- 常与 `stop` 配合,例如前缀 ` ```python\n ` + `stop=["```"]` 强制只出代码。 +- **中转站注意**:这个能力无法映射到 Claude 4.6+(明确禁止 prefill),也不是 OpenAI 标准能力。 + +### FIM Completion + +端点是 `/beta/completions`(不是 chat),参数 `prompt` + `suffix`。在主 base_url 上调用明确报错:`completions api is only available when using beta api (...)`。 + +⚠️ 文档的三条限制实测**全部不成立**: + +| 文档说法 | 实测 | +| --- | --- | +| `max_tokens` 上限 4K | **上限 393216**(4096/8192/65536/393216 全部 200,393217 才报错) | +| 模型用 `deepseek-v4-pro` | **flash 和 pro 都可用** | +| 仅非思考模式支持 | 传 `thinking` 参数不报错、正常返回(该参数在此端点无意义) | + +其他实测:支持 legacy completions 的 `echo` 与 `logprobs`(int 型),但**两者互斥**——同时传报 `echo should not be used with logprobs`。响应是 `object: "text_completion"`,`choices[].text`,**没有 `completion_tokens_details`**。 + +--- + +## 8. 官方兼容层(DeepSeek 自己做的中转) + +### Anthropic 格式(`https://api.deepseek.com/anthropic`) + +模型名自动映射(实测确认): +- `claude-opus*` → `deepseek-v4-pro` +- `claude-haiku*` / `claude-sonnet*` → `deepseek-v4-flash` +- 直接传 `deepseek-v4-pro` / `deepseek-v4-flash` 也接受 +- ⚠️ 其他未知名字(如 `gpt-4`)**实测是报错,不是静默兜底**:`The supported API model names are deepseek-v4-pro or deepseek-v4-flash, but you passed gpt-4.`(此处修正了文档"静默兜底到 flash"的说法) + +支持:`model`、`max_tokens`、`system`、`temperature`(**`[0, 2]`**,比官方 Anthropic 的 0~1 宽,2.5 报 400)、`stream`、`stop_sequences`、`tools`、`thinking`(`budget_tokens` 被忽略)。 + +流式格式规范,事件序列为 `message_start` → `content_block_start` → `ping` → `content_block_delta`(×N) → `content_block_stop` → …… → `message_delta` → `message_stop`,含 `thinking_delta` / `signature_delta` / `text_delta` 三种 delta 类型。 + +⚠️ **实测发现的四个兼容性缺陷**: + +1. **错误响应用的是 OpenAI/DeepSeek 格式,不是 Anthropic 格式。** + 实际返回 `{"error":{"message":"...","type":"authentication_error","param":null,"code":"invalid_request_error"}}`, + 而 Anthropic SDK 期望 `{"type":"error","error":{"type":"authentication_error","message":"..."}}`。 + 顶层缺 `"type":"error"`、多出 `param`/`code` 字段——官方 Anthropic SDK 的错误反序列化会失败或降级成通用异常。 + +2. **`max_tokens` 不是必填。** 官方 Anthropic API 中 `max_tokens` 必填,此处省略不报错,静默用默认值。依赖该校验的客户端会行为不一致。 + +3. **thinking block 的 `signature` 是伪造的——它就是本次响应的 `id`。** + ``` + {"id": "a7f8579c-ef83-49fb-b93c-bda7d513c07b", + "content": [{"type": "thinking", "thinking": "...", + "signature": "a7f8579c-ef83-49fb-b93c-bda7d513c07b"}]} + ``` + 不是真正的加密签名,不具备任何校验意义,也不能与真实 Anthropic 上游互换。 + +4. **`tool_choice` 行为与 OpenAI 入口不一致。** + + | `tool_choice` | Anthropic 入口 | 对应的 OpenAI 入口写法 | + | --- | --- | --- | + | `{"type":"auto"}` | ✅ | `"auto"` ✅ | + | `{"type":"any"}` | ⚠️ **静默降级为 auto**(实测明确要求"不要用工具"时模型确实不调工具,`stop_reason: "end_turn"`) | `"required"` ❌ 思考模式下 400 | + | `{"type":"tool","name":"..."}` | ❌ 400 `Thinking mode does not support this tool_choice` | 同样 400 | + + 同一个语义(强制调用工具),一个入口静默失效、另一个入口硬报错。 + +忽略 / 不支持:`anthropic-beta` 与 `anthropic-version` header、`top_k`、大部分 metadata 字段(例外:`metadata.user_id` 支持,用于限流隔离)。 + +⚠️ **image content 不是"不支持",而是静默替换成占位文本**(文档表述为不支持,实测是降级)。传入 `{"type":"image","source":{...}}` 不报错,模型的思考内容里暴露了它实际收到的是 `[Unsupported Image]` 占位符,然后回答"我看不到图片"。用户拿到的是一个 HTTP 200 的错误答案。 + +### Responses 格式(`https://api.deepseek.com/v1/responses`) + +- ⚠️ **v4-flash 和 v4-pro 都已支持**(文档称仅 flash)。 +- **未支持的参数一律静默忽略,不报错**,且响应里会把它们**回显成默认值**而非你传入的值——这是判断是否生效的可靠手段: + `store: true` → 回显 `false`;`previous_response_id: "resp_123"` → 回显 `null`;`conversation` 不支持。 +- 思考参数用 `reasoning.effort`(见 §3),`effort: "none"` 可确定关闭思考。响应中 `reasoning` 字段会回显你传入的 effort。 +- 上下文缓存自动进行,无手动参数(`prompt_cache_key` 恒 `null`)。 +- ⚠️ **image 和 file 输入被替换成字面占位文本 `[Unsupported Image]`**(而不是报错)——这个行为对中转站尤其危险,用户会得到一个"成功但内容错误"的响应。与 Anthropic 入口行为一致,但与 OpenAI 入口(硬报错)不一致。 +- usage 结构是 Responses 风格:`input_tokens` / `input_tokens_details.cached_tokens` / `output_tokens` / `output_tokens_details.reasoning_tokens` / `total_tokens`。 +- 输出为 `output[]` 数组,思考是 `{"type":"reasoning","content":[{"type":"reasoning_text","text":"..."}],"summary":[]}`,正文是 `{"type":"message","phase":"final_answer",...}`(⚠️ `phase` 是非 OpenAI 标准字段)。 +- 流式事件完整:`response.created` / `response.in_progress` / `response.output_item.added` / `response.content_part.added` / `response.reasoning_text.delta` / `response.reasoning_text.done` / `response.output_text.delta` / `response.output_text.done` / `response.content_part.done` / `response.output_item.done` / `response.completed`。 + +#### ⚠️ 内置 `web_search` 工具(文档完全未记载,且只有这一个入口有) + +传 `"tools":[{"type":"web_search"}]` 实测**真的会执行联网搜索**,输出数组中出现: + +```json +{"type":"web_search_call","id":"call_00_...","status":"completed", + "action":{"type":"search","queries":["capital of France","ws_call_id=call_00_..."]}} +``` + +回显的工具定义为 `{"type":"web_search","search_context_size":null,"user_location":null}`。搜索结果注入会显著抬高 `input_tokens`(实测同一问题从 84 涨到 2403)。 + +跨入口对比: + +| 入口 | `web_search` | +| --- | --- | +| Responses | ✅ 真实执行 | +| OpenAI Chat | ❌ 400 `unknown variant 'web_search', expected 'function'` | +| Anthropic(`web_search_20250305`)| ⚠️ 静默接受但不执行,模型直接凭知识作答 | + +--- + +## 9. 错误码 + +| Code | 含义 | 处理 | +| --- | --- | --- | +| 400 | 请求体格式非法 | 按错误信息调整 | +| 401 | API key 错误 | 实测消息体:`Authentication Fails, Your api key: ****-bad is invalid`,`type: "authentication_error"` | +| 402 | **余额不足** | OpenAI/Anthropic 无此码,兼容层需决定映射到 429 还是 403 | +| 422 | 参数非法 | | +| 429 | 限流 | | +| 500 | 服务端故障 | 重试 | +| 503 | 服务器过载 | 稍后重试 | + +错误响应体统一为 OpenAI 风格 `{"error":{"message","type","param","code"}}`——**包括 Anthropic 入口**(见 §8 缺陷 1)。 + +`type` 实测取值:`invalid_request_error`(400/422)、`authentication_error`(401)。注意 401 的 `code` 字段仍是 `invalid_request_error`,与 `type` 不一致,不要用 `code` 判断错误类别。 + +**限流**:实测 30 并发短请求全部 200,未触发 429,具体阈值未知。文档未说明限流时是否有 keep-alive 空行等连接层行为。 + +反序列化错误(Rust serde 风格)会泄露内部枚举,可用于探测未文档化的取值——`adaptive` 思考模式就是这么发现的: +``` +Failed to deserialize the JSON body into the target type: thinking.type: unknown variant `banana`, +expected one of `adaptive`, `enabled`, `disabled` at line 1 column 100 +``` + +--- + +## 10. 中转站实现要点速查 + +按危险程度排序的实测结论: + +| # | 陷阱 | 后果 | +| --- | --- | --- | +| 1 | 思考默认开启且注入 ~79 token 隐藏 prompt | 每条请求静默多计 79 输入 token | +| 2 | 关思考的参数名三个入口各不相同,文档还写错了两个 | 照文档实现 = 关不掉、照常计费 | +| 3 | `tool_choice: required` + 默认思考模式 = 400 | 标准 OpenAI 请求直接失败 | +| 4 | 思考模式下 `reasoning_content` 不回传 = 400(仅工具场景) | 跨格式转发丢思考内容即中断;空串可降级过关 | +| 5 | image 在 Anthropic/Responses 入口被换成 `[Unsupported Image]` | HTTP 200 + 错误答案,最难排查 | +| 6 | Anthropic 入口错误体不是 Anthropic 格式 | 官方 SDK 错误解析失败 | +| 7 | Anthropic 入口 `tool_choice: any` 静默降级为 auto | 强制工具调用失效且无提示 | +| 8 | `deepseek-chat`/`deepseek-reasoner` 静默映射到 v4-flash | 账单模型名与请求不符 | +| 9 | 流式 usage 在最后一个正常 chunk,非独立 chunk | 按 OpenAI 语义等待 = 永远拿不到 usage | +| 10 | 思考模式 logprobs 返回 `logprobs.reasoning_content` | 非 OpenAI schema,解析失败 | +| 11 | `json_object` 要求 prompt 含 "json" 字面量 | 跨格式转发时用户看不懂的 400 | +| 12 | `role: "developer"` 硬报错 | 必须降级为 `system` | +| 13 | `strict` / `seed` / `user` / 非法 `reasoning_effort` 静默接受 | 用户以为生效了 | diff --git a/docs/errors-and-limits.md b/docs/errors-and-limits.md new file mode 100644 index 0000000..e7bf80f --- /dev/null +++ b/docs/errors-and-limits.md @@ -0,0 +1,142 @@ +# 错误、限流与超时对照 + +> 来源: +> - https://platform.claude.com/docs/en/api/errors +> - https://api-docs.deepseek.com/quick_start/error_codes +> - OpenAI OpenAPI spec(错误页面未单独抓取,见文末"待补") +> +> 采集于 2026-08-12。 + +--- + +## 1. HTTP 状态码对照 + +| 状态码 | Anthropic | DeepSeek | 说明 | +| --- | --- | --- | --- | +| 400 | `invalid_request_error` | 请求体格式非法 | Anthropic 也用它兜底其他未列出的 4xx | +| 401 | `authentication_error` | API key 错误 | Anthropic 上也可能是 key 过期/吊销,或 AWS 凭证/SigV4 问题 | +| 402 | `billing_error` | **余额不足** | OpenAI 无此码 | +| 403 | `permission_error` | — | key 无权限访问该资源 | +| 404 | `not_found_error` | — | | +| 409 | `conflict_error` | — | 资源并发修改/唯一值冲突 | +| 413 | `request_too_large` | — | 见 §2 | +| 422 | — | 参数非法 | **Anthropic/OpenAI 不用 422**,DeepSeek 用 | +| 429 | `rate_limit_error` | 限流 | | +| 500 | `api_error` | 服务端故障 | | +| 503 | — | 服务器过载 | | +| 504 | `timeout_error` | — | 建议改用流式 | +| **529** | `overloaded_error` | — | **Anthropic 专有**,全平台高负载 | + +### 中转站的映射决策点 + +- **402(余额不足)**没有 OpenAI 对应码。可选:透传 402、或映射为 429 + `insufficient_quota` 类型(OpenAI 生态的惯例是 429)。 +- **529** 对 OpenAI 客户端是未知码,多数 SDK 不会自动重试。建议映射为 **503**。 +- **422** 对 OpenAI 客户端也不常见,建议映射为 **400**。 +- Anthropic 的 **429 有两种成因**:常规限流,以及**用量突增触发的加速度限制(acceleration limit)**。后者的解决办法是"逐步爬坡、保持稳定用量",不是简单退避重试。 + +--- + +## 2. 请求体积上限(Anthropic) + +| 端点 | 上限 | +| --- | --- | +| Messages API | 32 MB | +| Token Counting API | 32 MB | +| Batch API | 256 MB | +| Files API | 500 MB | + +超限返回 413。**在直连 Claude API 时,这个错误由 Cloudflare 在到达 API 服务器之前返回**——意味着响应体格式可能不是标准的 Anthropic 错误 JSON。中转站的错误解析需要能容忍非 JSON 的 413 响应。 + +--- + +## 3. 错误响应体 + +### Anthropic + +```json +{ + "type": "error", + "error": { + "type": "not_found_error", + "message": "The requested resource could not be found." + }, + "request_id": "req_011CSHoEeqs5C35K2UUqR7Fy" +} +``` + +`error.type` 的取值集合会随时间扩充(versioning policy 明确说明),解析器必须能处理未知 type。 + +### 流式中途错误(Anthropic) + +HTTP 已经返回 200 之后,错误以 SSE 事件形式发出: + +``` +event: error +data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}} +``` + +**中转站必须处理这种情况**:此时已经无法改 HTTP 状态码,只能在下游流中也以某种方式表达错误。OpenAI SSE 格式没有标准的错误事件——常见做法是发一个带 `error` 字段的 data chunk,然后 `[DONE]`。 + +--- + +## 4. Request ID + +Anthropic 每个响应都带 `request-id` header(形如 `req_018EeWyXxfu5pfWkrYcMdjWG`),错误体里也有 `request_id` 字段。 + +在 AWS 上的 Claude Platform 会有**两个** ID:`x-amzn-requestid`(主,CloudTrail 索引)和 `request-id`(次,Anthropic 支持工单用)。 + +中转站建议:生成自己的 gateway request id,并在响应 header 中同时保留上游的 ID,便于排障。 + +--- + +## 5. 超时与长请求 + +Anthropic 侧的明确约束: + +- 官方 SDK 会**校验非流式 Messages 请求预期不超过 10 分钟**,超过则拒绝发出。 +- 超过 10 分钟的请求应改用流式或 Batch API。 +- 部分网络会在空闲一段时间后断开连接,导致请求超时且拿不到响应。官方建议直连集成时设置 **TCP socket keep-alive**。 +- SDK 默认对瞬时失败(连接错误、限流、5xx)**指数退避重试 2 次**,并遵守 `retry-after` header。 + +对中转站的含义: +- 网关自身的上游超时不能小于客户端预期,否则长任务永远失败。 +- 网关到上游、客户端到网关,**两段都需要 keep-alive 配置**。 +- 大 `max_tokens` 的非流式请求是最容易踩超时的场景,建议在网关侧自动转成流式再聚合(Anthropic SDK 的 `get_final_message()` 就是这个思路)。 + +--- + +## 6. 限流 header + +Anthropic OpenAI 兼容层确认支持全套: + +``` +x-ratelimit-limit-requests +x-ratelimit-limit-tokens +x-ratelimit-remaining-requests +x-ratelimit-remaining-tokens +x-ratelimit-reset-requests +x-ratelimit-reset-tokens +retry-after +``` + +中转站若做自己的配额管理,应该合成这套 header 返回给下游(而不是透传上游的,因为上游额度和下游用户额度不是一回事)。 + +--- + +## 7. 用户级限流隔离 + +| Provider | 字段 | 约束 | +| --- | --- | --- | +| OpenAI | `safety_identifier`(`user` 已废弃) | ≤64 字符,建议传 hash | +| OpenAI | `prompt_cache_key` | 用于提升缓存命中,与 safety_identifier 分离 | +| Anthropic | `metadata.user_id` | | +| DeepSeek | `user_id` | ≤512 字符,仅 `[a-zA-Z0-9\-_]` | + +注意 DeepSeek 的字符集限制比另外两家严格——直接透传 OpenAI 的 `safety_identifier`(可能含其他字符)会被拒。 + +--- + +## 8. 待补 + +- **OpenAI 的错误码/错误体规范未单独收集**。`platform.openai.com/docs/guides/error-codes` 未抓取(主站 403)。需要补充:`insufficient_quota`、`context_length_exceeded`、`invalid_api_key` 等 `error.code` 取值,以及 OpenAI 错误体的 `{error: {message, type, param, code}}` 结构细节。 +- DeepSeek 限流时的连接层行为(是否发 keep-alive 空行)文档未说明。 diff --git a/docs/kimi.md b/docs/kimi.md new file mode 100644 index 0000000..5e7d71d --- /dev/null +++ b/docs/kimi.md @@ -0,0 +1,966 @@ +# Kimi API(月之暗面 Moonshot) + +> 来源(官方文档站 `.md` 原文,采集于 2026-08-13): +> - API 参考:[overview](https://platform.kimi.com/docs/api/overview.md)、[chat](https://platform.kimi.com/docs/api/chat.md)、[models-overview](https://platform.kimi.com/docs/api/models-overview.md)、[list-models](https://platform.kimi.com/docs/api/list-models.md)、[errors](https://platform.kimi.com/docs/api/errors.md)、[estimate](https://platform.kimi.com/docs/api/estimate.md)、[balance](https://platform.kimi.com/docs/api/balance.md)、[batch-create](https://platform.kimi.com/docs/api/batch-create.md) +> - 指南:[models](https://platform.kimi.com/docs/models.md)、[k3-quickstart](https://platform.kimi.com/docs/guide/kimi-k3-quickstart.md)、[k2.7-code-quickstart](https://platform.kimi.com/docs/guide/kimi-k2-7-code-quickstart.md)、[thinking](https://platform.kimi.com/docs/guide/use-thinking-models.md)、[reasoning-effort](https://platform.kimi.com/docs/guide/use-reasoning-effort.md)、[partial](https://platform.kimi.com/docs/guide/use-partial-mode-feature-of-kimi-api.md)、[caching](https://platform.kimi.com/docs/guide/use-context-caching-feature-of-kimi-api.md)、[tool-choice](https://platform.kimi.com/docs/guide/use-tool-choice.md)、[dynamic-tools](https://platform.kimi.com/docs/guide/use-dynamic-tool-loading.md)、[tool-calls](https://platform.kimi.com/docs/guide/use-kimi-api-to-complete-tool-calls.md)、[web-search](https://platform.kimi.com/docs/guide/use-web-search.md)、[official-tools](https://platform.kimi.com/docs/guide/use-official-tools.md)、[response_format](https://platform.kimi.com/docs/guide/response_format.md)、[streaming](https://platform.kimi.com/docs/guide/utilize-the-streaming-output-feature-of-kimi-api.md)、[vision](https://platform.kimi.com/docs/guide/use-kimi-vision-model.md)、[file-qa](https://platform.kimi.com/docs/guide/use-kimi-api-for-file-based-qa.md)、[batch](https://platform.kimi.com/docs/guide/use-batch-api.md)、[claude-code](https://platform.kimi.com/docs/guide/claude-code-kimi.md)、[troubleshooting](https://platform.kimi.com/docs/guide/troubleshooting.md) +> - 计价与限流:[limits](https://platform.kimi.com/docs/pricing/limits.md)、[chat-k3](https://platform.kimi.com/docs/pricing/chat-k3.md) +> - 完整索引:https://platform.kimi.com/docs/llms.txt ;OpenAPI:https://platform.kimi.com/docs/openapi.json +> +> **2026-08-13 用真实 API key 实测校验过一遍**(约 60 次请求,覆盖两个入口)。实测与文档冲突处均以实测为准。 +> 实测环境:中国站 `api.moonshot.cn`,账号档位 `msh-gid: free`(Tier 0,3 RPM / 1 并发),流式 `system_fingerprint` = `fpv0_489ccb45`。 +> +> **标注约定**:✅ 文档记载且实测确认;❌ **实测推翻文档**;🆕 文档未记载、实测发现;⚠️ 未实测或存疑。 + +Kimi 对外声称 **OpenAI 协议兼容**,OpenAI SDK 换 `base_url` 即可用。但实测下来它是本项目已调研的几家里**"最容易在第一个请求就 400"** 的一家: + +1. **新模型把采样参数写死并硬 400**——`temperature` 不是 1.0 直接报错,实测确认不是静默忽略(§3)。带默认 `temperature=0.7` 的 OpenAI 客户端在 K3 上开箱即挂。 +2. **同一模型在两个入口行为相反**——OpenAI 入口对 `temperature=0.7` 报 400,Anthropic 入口 200 静默忽略(§3.2)。 +3. **参数集按模型分裂成四套**——合法请求体取决于 `model` 值(§3、§4)。 +4. **文档与实现大面积脱节**——实测推翻了 8 条文档明确写下的规则,其中包括"K3 思考恒开不可关"这样的核心设定(§14)。**照文档写代码会同时踩到"文档说能实际不能"和"文档说不能实际能"两个方向。** +5. **它其实是两个产品**——`platform`(本文 §1–§16)和 **Kimi For Coding**(`api.kimi.com/coding`,见 **§17**)是**两套独立的服务栈**:账号体系、模型名、鉴权、限流、端点、id 格式全都不同,key 互不通用。**中转站必须当作两个上游分别配置。** + +--- + +## 1. 端点、域名与鉴权 + +### 1.1 双站隔离 ✅ + +| 产品 / 站点 | 控制台 | API base URL | Key 前缀 | +| --- | --- | --- | --- | +| platform 中国站 | `platform.kimi.com` | `https://api.moonshot.cn/v1` | `sk-` | +| platform 国际站 | `platform.kimi.ai` | `https://api.moonshot.ai/v1` | `sk-` | +| **Kimi For Coding**(另一个产品,见 §17) | — | `https://api.kimi.com/coding/v1` | **`sk-kimi-`** | + +两站账号、余额、API Key 完全独立,拿错区域的 key 返回 401。中转站的上游配置必须把 key 和 base URL 绑成一对。 + +✅ **Kimi For Coding 与 platform 的 key 双向不通用**(实测)——且两边的 401 文案不同,可用来判断打错了哪个上游: + +``` +coding key → api.moonshot.cn : {"message":"Invalid Authentication","type":"invalid_authentication_error"} +platform key → api.kimi.com/coding : {"message":"The API Key appears to be invalid or may have expired. Please verify your credentials and try again.","type":"invalid_authentication_error"} +``` + +⚠️ **域名分层容易记错**:文档站/控制台已迁到 `kimi.com`(`platform.moonshot.cn` → `platform.kimi.com` 301),但 **API 数据面仍是 `api.moonshot.cn` / `api.moonshot.ai`**,没有迁到 `api.kimi.com`。 + +### 1.2 端点清单 ✅(均实测可用) + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| POST | `/v1/chat/completions` | 对话补全 | +| GET | `/v1/models` | 模型列表 | +| POST | `/v1/tokenizers/estimate-token-count` | 官方 token 计数 | +| GET | `/v1/users/me/balance` | 余额查询 | +| POST/GET/DELETE | `/v1/files`、`/v1/files/{id}`、`/v1/files/{id}/content` | 文件管理 | +| POST/GET | `/v1/batches`、`/v1/batches/{id}`、`/v1/batches/{id}/cancel` | 批处理 | + +鉴权统一 `Authorization: Bearer ` ✅。**没有智谱那种 JWT 历史包袱**,这点比多数国产厂商干净。 + +🆕 **非 chat 端点带业务包装层**:`/v1/users/me/balance` 和 `/v1/tokenizers/estimate-token-count` 返回的**不是**裸对象,而是 + +```json +{"code":0,"data":{"total_tokens":87},"scode":"0x0","status":true} +``` + +即 HTTP 200 外面还套了一层 `code`/`scode`/`status`。而 `/v1/chat/completions` 和 `/v1/models` **没有**这层包装(直接 OpenAI 结构)。**同一个 API 里两种响应信封并存**——中转站的响应解析器不能一刀切。(对比 `china-providers.md` §0 里 MiniMax `base_resp` 那种"HTTP 200 藏业务错误",Kimi 是同一思路的半吊子版本:只在辅助端点上有。) + +🆕 **未记载的调试响应头**(每个响应都带): + +``` +msh-request-id: 7b768543-96db-11f1-8fa0-624fe5b4e31b +msh-org-id: org-xxxxxxxx +msh-project-id: proj-xxxxxxxx +msh-uid: xxxxxxxxxxxxxxxxxxxx +msh-gid: free ← 账号档位,可用来自动探测 Tier +msh-trace-mode: on +``` + +`msh-request-id` 是报障时官方要的 request_id。⚠️ 但 `msh-org-id` / `msh-uid` / `msh-project-id` 是**账号标识**——中转站若原样透传响应头给下游,等于把自己的组织 ID 广播出去。**应当在响应头白名单里剥掉 `msh-*`**(`msh-request-id` 可保留或改名)。 + +### 1.3 Anthropic 兼容入口 ✅(实测可用,但与 OpenAI 入口行为不一致) + +``` +ANTHROPIC_BASE_URL="https://api.moonshot.cn/anthropic" +ANTHROPIC_AUTH_TOKEN="" # 注意是 AUTH_TOKEN 不是 API_KEY +``` + +实测结论: + +| 项 | 结果 | +| --- | --- | +| `POST /anthropic/v1/messages` + `x-api-key` | ✅ 200 | +| `POST /anthropic/v1/messages` + `Authorization: Bearer` | ✅ 200,**两种鉴权都接受** | +| `POST /anthropic/messages`(漏 `v1`) | ❌ 404,且**掉到网关层**(见下) | +| 不带 `anthropic-version` 头 | 🆕 200,**不强制** | +| `temperature: 0.7` | 🆕 **200 静默忽略**(OpenAI 入口同样请求是 400) | +| `top_p: 0.5` | 🆕 200 静默忽略 | +| `tool_choice: {"type":"tool","name":...}` | ✅ 400 `tool_choice 'specified' is incompatible with thinking enabled`(与 OpenAI 入口一致) | +| `thinking: {"type":"enabled","budget_tokens":2000}` | ✅ 200 接受 | +| `system` + `cache_control: {"type":"ephemeral"}` | 🆕 200 接受但 `cache_creation_input_tokens: 0`,**未实际建缓存**(自动缓存机制接管,见 §10) | +| `model: "kimi-k3[1m]"` | ❌ **404**(见下) | + +> **两个入口对采样参数的处理相反,但对 `tool_choice` 的限制一致**——不是"Anthropic 入口更宽松",而是**选择性地不一致**。中转站不能假设"打不通就换个入口试试"。 + +🆕 **Anthropic 入口的 `usage` 同时并存两套字段**: + +```json +"usage": { + "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 88, + "output_tokens": 151, "output_tokens_details": {"thinking_tokens": 135}, + "service_tier": "standard", "inference_geo": "not_available", + "prompt_tokens": 88, "cached_tokens": 88, "completion_tokens": 151, "total_tokens": 239 +} +``` + +Anthropic 风格(`input_tokens`/`cache_read_input_tokens`/`output_tokens`)和 OpenAI 风格(`prompt_tokens`/`cached_tokens`/`completion_tokens`)**同时存在**,且语义不同:缓存命中时 `input_tokens` 变成 **0**(Anthropic 语义:不含缓存部分),而 `prompt_tokens` 仍是 **88**(OpenAI 语义:含缓存部分)。**中转站按哪套字段计费,账单会差一个缓存量。** + +🆕 **thinking block 没有 `signature` 字段**——Anthropic 官方的 thinking block 带加密签名,Kimi 这个是纯明文 `{"type":"thinking","thinking":"..."}`。好处是不用当 opaque blob 搬运,坏处是**下游若按 Anthropic 规范校验 signature 会失败**。 + +❌ **`kimi-k3[1m]` 是错的**——官方 Claude Code 配置页把 `ANTHROPIC_MODEL` 等六个变量全设成 `kimi-k3[1m]`,但实测**两个入口都 404**: + +``` +OpenAI 入口: {"error":{"message":"Not found the model kimi-k3[1m] or Permission denied","type":"resource_not_found_error"}} +Anthropic 入口: {"error":{"type":"resource_not_found_error","message":"Not found the model kimi-k3[1m] or Permission denied"},"request_id":"...","type":"error"} +``` + +`[1m]` 是 Claude Code 客户端侧的上下文变体后缀,Kimi 服务端并不认。**正确的模型名是 `kimi-k3`。** 官方文档这段配置照抄会直接失败。 + +🆕 **三种错误格式并存**(这是中转站错误处理的隐藏坑): + +```jsonc +// 1. OpenAI 入口 API 级错误 +{"error":{"message":"...","type":"invalid_request_error"}} +// 2. Anthropic 入口 API 级错误 —— 多了 request_id 和顶层 type +{"error":{"type":"resource_not_found_error","message":"..."},"request_id":"...","type":"error"} +// 3. 网关级错误(路径不存在时)—— 完全不同的结构,中文消息,且回显 UA 和 URL +{"code":5,"error":"url.not_found","message":"没找到对象","method":"POST","scode":"0x5","status":false,"ua":"curl/8.7.1","url":"/anthropic/messages"} +``` + +第三种连 `error` 字段的类型都变了(从 object 变成 string)。**中转站解析上游错误时若假设 `error` 恒为 object,遇到网关级错误会抛类型异常。** + +✅ 已知能力缺口:该端点**不支持 WebFetch**(Claude Code 里报 `temporarily unavailable`)。 + +--- + +## 2. 模型与能力矩阵 + +### 2.1 在售模型 ✅ + +| 模型 | 上下文(实测 `/v1/models`) | 默认 max output | 思考 | 视觉 | 视频 | +| --- | --- | --- | --- | --- | --- | +| `kimi-k3` | 1,048,576 | 131,072(上限 1,048,576) | 恒开但**可关**(§4.1) | ✓ | ✓ | +| `kimi-k2.7-code` | 262,144 | 32,768 | **不可关** | ✓ | ✓ | +| `kimi-k2.7-code-highspeed` | 262,144 | 32,768 | **不可关** | ✓ | ✓ | +| `kimi-k2.6` | 262,144 | ⚠️ 未记载 | 可关 | ✓ | ✓ | +| `kimi-k2.5` | 262,144 | ⚠️ 未记载 | 可关 | ✓ | ✓ | +| `moonshot-v1-{8k,32k,128k}` | 8192/32768/131072 | ⚠️ 未记载 | 无 | ✗ | ✗ | +| `moonshot-v1-{8k,32k,128k}-vision-preview` | 同上 | ⚠️ 未记载 | 无 | ✓ | ✗ | +| 🆕 `moonshot-v1-auto` | 131072 | — | 无 | ✗ | ✗ | + +🆕 **`moonshot-v1-auto` 文档完全没提**,但 `/v1/models` 里有且可调用。它是**自动路由模型**:请求 `moonshot-v1-auto`,**响应的 `model` 字段返回 `moonshot-v1-8k`**(实测两次都是)。 + +> 这是 DeepSeek `deepseek-chat` → `deepseek-v4-flash` 静默映射的同类问题:**请求模型名 ≠ 响应模型名 ≠ 账单模型名**。中转站若按响应 `model` 字段记账,用户请求 auto 会看到一个自己没点过的模型名;若按请求名记账,又对不上实际计费。**建议两个都记。** + +`kimi-k2.7-code-highspeed` 是同模型高速档(约 180 tok/s),能力相同——可当作同一逻辑模型的 QoS 变体。 + +⚠️ 文档提到 moonshot-v1 系列"8 月 31 日下线"但未给年份,而 `/v1/models` 仍在返回。需持续关注。 + +### 2.2 已下线模型:无别名兜底 ✅(实测确认) + +``` +kimi-k2 → 404 Not found the model kimi-k2 or Permission denied +kimi-latest → 404 Not found the model kimi-latest or Permission denied +``` + +与 DeepSeek 把旧名静默映射到新模型**相反**,Kimi 是直接 404。中转站若面向存量客户需自建旧名映射表,并显式告知用户(别学 DeepSeek 静默换模型导致账单对不上)。 + +### 2.3 `/v1/models` 的真实返回:比文档丰富得多 🆕 + +文档给的示例只有 7 个字段,实测返回: + +```json +{ + "id": "kimi-k3", "object": "model", "created": 1786362236, "owned_by": "moonshot", + "permission": [{"created":0,"id":"","object":"","organization":"moonshot","group":"moonshot","is_blocking":false}], + "root": "", "parent": "", + "supports_image_in": true, "supports_video_in": true, "supports_reasoning": true, + "supports_dynamic_tools": true, + "think_efforts": {"support": true, "valid_efforts": ["low","high","max"], "default_effort": "max"}, + "reasoning_efforts": {"support": true, "valid_efforts": ["low","high","max"], "default_effort": "max"}, + "supports_thinking_type": "only", + "context_length": 1048576 +} +``` + +- `permission[]` / `root` / `parent` 是 **OpenAI 早期 `/models` 的遗留字段**(现已从 OpenAI 移除),Kimi 还留着,且内容全是空壳。 +- 🆕 `think_efforts` 和 `reasoning_efforts` **内容完全相同的两个字段**——显然是改名过程中的兼容遗留,两个都在返回。 +- 🆕 `supports_dynamic_tools`(仅 K3 为 true)、`supports_thinking_type: "only"`(仅 K3 有此字段)——**这两个字段是判断能力的最可靠来源**,比文档准。 +- ❌ 文档示例里 `kimi-k2.5` 的 `context_length` 写的是 `128000`,实测是 **262144**。 + +> 这是少见的"上游自带能力矩阵",中转站可以自动同步能力表而非硬编码。但注意 `supports_thinking_type: "only"` 的值是字符串 `"only"` 而非布尔,且**只有 K3 有这个字段**——解析时要容忍字段缺失。 + +--- + +## 3. 采样参数:新模型写死且**硬 400**(实测确认,本文件最大的坑) + +### 3.1 OpenAI 入口:逐个报错 ✅ + +实测逐参数验证,**全部是 400,不是静默忽略**: + +| 请求 | 实际响应 | +| --- | --- | +| `kimi-k3` + `temperature: 0.7` | `400 invalid temperature: only 1 is allowed for this model` | +| `kimi-k3` + `top_p: 0.5` | `400 invalid top_p: only 0.95 is allowed for this model` | +| `kimi-k3` + `n: 2` | `400 invalid n: only 1 is allowed for this model` | +| `kimi-k3` + `presence_penalty: 1` | `400 invalid presence_penalty: only 0 is allowed for this model` | +| `kimi-k2.7-code` + `temperature: 0.7` | `400 invalid temperature: only 1 is allowed for this model` | +| `moonshot-v1-8k` + `temperature: 1.5` | `400 Invalid request: temperature must not be greater than 1.000000` | +| `moonshot-v1-8k` + `temperature: 0.7` | ✅ 200 | + +| 参数 | `moonshot-v1` | `kimi-k3` / `kimi-k2.7-code` | +| --- | --- | --- | +| `temperature` | 0–1,默认 **0** | 固定 `1.0`,**传其他值 400** | +| `top_p` | 0–1,默认 1 | 固定 `0.95`,**传其他值 400** | +| `n` | 1–5,默认 1 | 固定 `1`,**传其他值 400** | +| `presence_penalty` / `frequency_penalty` | −2–2,默认 0 | 固定 `0`,**传其他值 400** | + +**三条独立的怪癖:** + +1. **`temperature` 默认值跨代反转**:moonshot-v1 默认 **0**,新模型固定 **1.0**。"同一家 API,不传 temperature 时的行为随模型跳变"。中转站若为对齐 OpenAI 而主动补默认 `temperature`,在 v1 上改变原有行为,在 K3 上直接 400。**结论:绝不给未传的参数补默认值。** +2. **`temperature` 上限是 1 而非 2**(OpenAI 是 2)。 +3. 🆕 **`n > 1` 与 `temperature` 存在隐藏耦合**——在 moonshot-v1 上传 `n: 3` 而不动 temperature: + + ``` + 400 Invalid request: n should not be greater than 1 when temperature is less than or equal to 1e-5 + ``` + + 因为 v1 的 temperature 默认是 0,**要用 `n > 1` 就必须同时把 temperature 设为 > 0**。文档从未提及这个约束。中转站转发 `n>1` 请求时得连带检查 temperature,否则报出的错会让用户完全摸不着头脑。 + +✅ 其他约束实测确认:`stop` 数组最多 5 个——传 6 个报 `400 stop array too long. Expected an array with maximum length 5, but got an array with length 6 instead`(错误信息质量不错)。 + +🆕 **未知参数静默忽略**:传 `"foo_bar": 123` → 200 正常返回。即"未知字段宽松、已知字段严格"——这与多数厂商一致,但和它对已知参数的严苛形成反差。 + +### 3.2 Anthropic 入口:同样的参数被静默忽略 🆕 + +``` +OpenAI 入口 kimi-k3 + temperature=0.7 → 400 +Anthropic 入口 kimi-k3 + temperature=0.7 → 200(正常返回,参数被忽略) +Anthropic 入口 kimi-k3 + top_p=0.5 → 200 +``` + +**同一个模型、同一个参数、两个入口结果相反。** 这是 DeepSeek "三入口行为不一致"的翻版(见 `deepseek.md`)。 + +> 中转站落地建议:为 Kimi 上游维护**按模型的参数白名单**,对新模型剥离 `temperature/top_p/n/presence_penalty/frequency_penalty` 后再转发。**这是本文件最该优先实现的一条规则**——它同时解决了两个入口的差异(剥离后两边行为一致)。 + +### 3.3 `max_tokens` vs `max_completion_tokens` ✅(实测厘清) + +两个名字都能用。同时传时 **`max_completion_tokens` 优先**: + +``` +{"max_tokens": 1500, "max_completion_tokens": 30} → completion_tokens: 30, finish_reason: "length" +``` + +文档的参数表只列 `max_completion_tokens`,正文却反复用 `max_tokens`,两者是新旧别名关系(文档未声明)。中转站两个都要能发,且注意优先级。 + +--- + +## 4. 思考模型:文档与实现脱节最严重的一节 + +| 模型 | 开关参数 | 能否关闭 | 实测 | +| --- | --- | --- | --- | +| `kimi-k3` | `reasoning_effort`(`low`/`high`/`max`,默认 `max`)**和** `thinking.type` | ❌ **文档说不能,实测能** | 见 §4.1 | +| `kimi-k2.7-code` | `thinking.type` | ✅ 确实不能 | `400 invalid thinking: only type=enabled is allowed for this model` | +| `kimi-k2.6` | `thinking.type`:`enabled`/`disabled` | ✅ 能 | 200,`reasoning_content: ""` | +| `kimi-k2.5` | `thinking.type` | ⚠️ 未测 | — | + +### 4.1 ❌ K3 的思考**可以**关闭(推翻文档) + +文档(K3 quickstart、thinking 指南、troubleshooting 三处)都说"K3 始终开启思考模式,不可关闭,只能调 `reasoning_effort`"。实测: + +```jsonc +// 请求 +{"model":"kimi-k3","messages":[{"role":"user","content":"说:好"}],"max_tokens":1500,"thinking":{"type":"disabled"}} +// 响应 200 +{"choices":[{"message":{"role":"assistant","content":"好"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":21,"completion_tokens":11,"total_tokens":32}} +``` + +- **没有 `reasoning_content` 字段**(不是空字符串,是字段不存在)。 +- **`prompt_tokens` 从 88 掉到 21**。同一条 user 消息,思考开启时 88、关闭时 21——差值约 67 token 是**思考模式注入的隐藏 system prompt**。这也解释了为什么 `estimate-token-count` 对四个汉字的消息返回 87:**官方 tokenizer 把这段隐藏 prompt 也算进去了**。 + +🆕 **`thinking.disabled` 优先于 `reasoning_effort`**:两个同时传(`thinking:{"type":"disabled"}` + `reasoning_effort:"max"`)→ 思考仍然关闭,`prompt_tokens: 21`。 + +> 对中转站的影响是正面的:**"关闭思考"这个语义在 K3 上是可表达的**,能力降级表里不必再写"无法关闭"。但因为文档明说不行,这条**属于未文档化行为,随时可能变**——建议在实现里做成可开关的特性,并在上游探测时验证。 + +### 4.2 ❌ `reasoning_effort` 的枚举校验形同虚设(推翻文档) + +`/v1/models` 明确声明 `valid_efforts: ["low","high","max"]`,但: + +``` +reasoning_effort: "medium" → 200 正常思考(OpenAI 的枚举值) +reasoning_effort: "banana" → 200 正常思考(完全非法的值) +``` + +**任意字符串都被接受并静默忽略。** 好消息是中转站不必做 `minimal`/`medium` → `low`/`high` 的枚举映射(不会 400);坏消息是**用户传错值不会有任何反馈**,只会默默跑在默认的 `max` 档(最贵)。中转站若要给用户可预期的成本,应当自己校验枚举并拒绝非法值。 + +🆕 `kimi-k2.6` 传 `reasoning_effort: "low"` → 200,但仍正常思考(`reasoning_tokens: 151`)——同样是被忽略。 + +### 4.3 `reasoning_content` 的回传要求 ✅ 部分推翻 + +- 响应里思考内容在 `choices[].message.reasoning_content`(**明文**,无签名)。 +- ✅ 流式中 `reasoning_content` **确实先于 `content` 出现**(实测 186 个 chunk 全是 reasoning_content 后才出 content)。`content` 开始即代表思考结束,是可靠的状态机切换信号。 +- ✅ `reasoning_content` 计入 `completion_tokens`,并单独报在 `completion_tokens_details.reasoning_tokens`。 +- ❌ **文档说"K3/K2.7-code 多轮缺少 `reasoning_content` 会报错"——实测不报错**: + + ``` + 多轮 assistant 消息只带 content + tool_calls、不带 reasoning_content → 200 正常继续 + ``` + + 中转站不必强制存档回放 `reasoning_content`(虽然存了对模型质量更好)。这条推翻降低了实现负担——**但也意味着 Preserved Thinking 的实际语义比文档描述的松**,⚠️ 建议在长对话+多步工具链场景再验一次。 + +### 4.4 `max_tokens` 太小导致空 `content` ✅(实测确认) + +```jsonc +{"model":"kimi-k3","max_completion_tokens":30,...} +→ 200 {"message":{"content":"","reasoning_content":"The user asked..."},"finish_reason":"length"} +``` + +token 全烧在思考上,`content` 为空字符串。官方建议 `max_tokens >= 16000`。 + +> **"200 OK + 空 content"是 Kimi 的正常返回,不能当上游故障重试**——重试只会再烧一遍思考 token。应原样透传让下游看到 `finish_reason: "length"`。 + +--- + +## 5. Partial Mode(前缀续写)✅ + +```json +{"role": "assistant", "content": "[\"红\",", "partial": true, "name": "可选"} +``` + +实测 200,返回 `" \"绿\", \"蓝\"]"`——**响应不含前缀**,需客户端自己拼。 + +| 维度 | Kimi | DeepSeek | Anthropic | +| --- | --- | --- | --- | +| 字段名 | **`partial: true`** | `prefix: true` | 无字段,末条 assistant 即前缀 | +| 端点 | 正式端点 | 需 `/beta` | 正式端点(Claude 4.6+ 已禁用) | + +**同一能力三家三个名字**——中转站 IR 里应有单一 `prefill` 标志(见 `api-design.md`),落地时翻译成对应字段。 + +❌ **`partial` + `response_format: json_object` 不冲突**(推翻 `chat.md` 里那条警告): + +``` +{"partial":true,"content":"{"} + response_format:{"type":"json_object"} → 200,正常续写出完整 JSON +``` + +这是实践中最常用的组合(前缀写 `{` 强制 JSON 开头),可以放心用。 + +⚠️ `name` 字段是 Kimi 独有语义:OpenAI 的 `name` 是"多说话人区分",Kimi 在 partial 场景当"人设锚点"。转发时不要想当然丢弃。 + +⚠️ 与思考叠加的陷阱(文档提示,未实测):K3 思考开启时若 `max_tokens` 太小,会在思考阶段截断导致 `content` 为空,**下一次续写等于从零重来**。 + +--- + +## 6. 工具调用 + +### 6.1 与 OpenAI 的差异 ✅/❌ + +`tools[] / tool_calls[] / role:"tool"` 结构、流式 `delta.tool_calls[].index` 累积规则均与 OpenAI 相同。差异: + +1. ❌ **`role: "tool"` 消息的 `name` 字段实际可选**(推翻文档)。文档说"必须提供 `tool_call_id` 和 `name`,Kimi 才能正确匹配",实测只给 `tool_call_id` 不给 `name` → **200 正常**。中转站从 Anthropic 入口转换时不必回查函数名(Anthropic 的 `tool_result` 只有 `tool_use_id`)。 +2. ✅ **`tool_call_id` 必须严格匹配**:传一个不存在的 id → + + ``` + 400 Invalid request: tool_call_id is not found + ``` + + 🆕 注意错误信息里 `tool_call_id` 和 `is not found` 之间是**两个空格**——本该填 id 值的位置是空的,是个字符串拼接 bug。**中转站不要指望从这条错误信息里解析出是哪个 id 出的问题。** +3. ✅ `finish_reason: "tool_calls"` 时 `content` 可能非空(Kimi 会解释为什么调用)。按"有 tool_calls 就忽略 content"写的下游会丢内容。 +4. 🆕 **`tool_calls[].id` 不是随机串,而是 `{函数名}_{序号}`**: + + ```json + {"index":0,"id":"get_weather_0","type":"function","function":{"name":"get_weather",...}} + ``` + + OpenAI 是 `call_abc123...` 的不透明随机串。这个格式**可预测且与函数名耦合**。风险有两层:中转站若按 id 反查函数名会"碰巧能用",形成隐式依赖,换上游即崩;且多轮对话里**同名函数会产生重复 id**(第二轮又是 `get_weather_0`),若中转站用 id 做全局去重键会误判。**建议中转站重写 tool_call id 为自己的不透明 id 并维护映射表。** +5. ✅ `finish_reason` 枚举为 `stop` / `length` / `tool_calls`,**没有国产厂商常见的 `sensitive`**——内容安全走 400 `content_filter`,不污染枚举。 + +### 6.2 `tool_choice` 与思考的互斥 ✅,但有一个危险的例外 🆕 + +| 模型 / 条件 | `required` | 具体函数 | +| --- | --- | --- | +| `kimi-k3`(思考开) | ✅ **200 正常工作** | ❌ 400 `tool_choice 'specified' is incompatible with thinking enabled` | +| `kimi-k3`(思考关) | ⚠️ 未测 | 🆕 **200,但见下** | +| `kimi-k2.7-code`(思考不可关) | ❌ 400 `tool_choice 'required' is incompatible with thinking enabled` | ❌ 400 | +| `kimi-k2.6`(思考关) | ⚠️ 未测 | ✅ 200 正常调用工具 | +| Anthropic 入口 `kimi-k3` | ⚠️ 未测 | ❌ 400(同 OpenAI 入口) | + +- ✅ K3 思考开启时 `required` **可用**(文档未明说,实测正常返回 tool_calls);但 K2.7-code 的 `required` **报错**,错误信息是"与思考不兼容"。同样是"思考开启",两个模型对 `required` 的判定不同。 +- 🆕 **最危险的一条**:K3 关闭思考 + `tool_choice` 指定函数 → **HTTP 200,但模型没有调用工具,而是直接编造了结果**: + + ```jsonc + // 请求:tool_choice 强制 get_weather,thinking disabled + // 响应 200,无 tool_calls,finish_reason: "stop" + {"content":"北京现在的天气情况如下:\n- **天气**:晴\n- **温度**:28°C\n- **湿度**:45%\n- **风向**:南风..."} + ``` + + **`tool_choice` 被静默无视,模型直接幻觉出一份完整的天气数据。** 这比 400 危险得多——下游代码等着解析 `tool_calls`,拿到的却是一段看起来很真的假数据。**中转站不应把"关闭思考"作为绕过 `tool_choice` 限制的变通方案**,那样只是把显式失败变成静默错误。 + +> 结论:思考开启时无法强制指定函数,这个缺口**无法用参数改写安全绕过**。能力降级表建议**显式报错**而非降级。 + +### 6.3 动态工具加载(K3 独有)✅ 实测确认 + +```json +{"role": "system", "tools": [{"type":"function","function":{...}}]} +``` + +- 这条 system 消息**没有 `content` 字段**——OpenAI 协议里不存在的消息形状。实测 K3 上 200 正常,模型正确调用了工具。 +- ✅ `kimi-k2.6` 上 → `400 Invalid request: tokenization failed`(一个极不直观的报错,实测确认)。 +- 消息位置决定工具从哪轮起可见;与顶层 `tools` 可共存。 +- 官方指出顶层 `tools` 不影响前缀缓存,动态工具因进了消息历史所以会影响。 + +> 中转站风险:这条消息**无法用 OpenAI/Anthropic 语义表达**。若做严格消息 schema 校验("system 必须有 content")会把合法请求拒掉。**IR 里要给消息保留 passthrough 扩展字段,别做白名单式裁剪。** + +### 6.4 内置工具:两套并存 ✅/⚠️ + +**(a)`builtin_function` 协议**(实测确认): + +```json +{"type": "builtin_function", "function": {"name": "$web_search"}} +``` + +实测响应: + +```json +{"tool_calls":[{"id":"t-web_search-6a7d5fde","type":"builtin_function", + "function":{"name":"$web_search","arguments":"{\"search_result\":{\"search_id\":\"dda31b08...\"},\"usage\":{\"total_tokens\":6674}}"}}]} +``` + +- 🆕 `tool_calls[].type` 回传的是 **`builtin_function`**,不是 OpenAI 枚举里的 `function`。**下游若按 OpenAI 枚举做校验会拒掉这个响应。** +- 🆕 id 格式又是另一套:`t-web_search-{短hash}`(与普通工具的 `get_weather_0` 不同)。 +- ✅ 反直觉之处确认:**模型内部已执行搜索**(`arguments` 里直接带 `search_id` 和 `usage.total_tokens: 6674`),但客户端仍要把 `arguments` **原样**塞回 `role: "tool"` 消息再发一次。即"假装自己执行了"。 +- 🆕 注意首轮的 `usage.total_tokens` 只有 93,而 arguments 内嵌的 `usage.total_tokens` 是 **6674**——**搜索消耗的 token 不在响应的 usage 里**,要从 arguments 里解析。中转站按响应 usage 计费会漏掉绝大部分搜索成本。 +- ✅ 计费:每次搜索按次收费,搜索结果计入下一轮 `prompt_tokens`。 + +**(b)官方工具(Formula API)** ⚠️ 未实测 —— 另一套体系,12 个工具:`convert`、`web-search`、`rethink`、`random-choice`、`mew`、`memory`、`excel`、`date`、`base64`、`fetch`、`quickjs`、`code-runner`。走标准 `function` 协议 + Formula URI(`moonshot/{tool-name}:latest`),搜索类返回 `encrypted_output`(`----MOONSHOT ENCRYPTED BEGIN----...`)需原样回传。 + +⚠️ **`web-search`(连字符,官方工具)与 `$web_search`(美元符号,builtin_function)是两个不同的东西**,文档没交代关系。已实测的是后者。 + +> 中转站建议:内置工具**不要跨厂商翻译**(OpenAI `web_search_preview`、Anthropic `web_search_20250305`、Kimi `$web_search` 的执行模型和计费都不同),按 opaque 透传,并在文档标明"内置工具与上游绑定"。 + +--- + +## 7. 结构化输出 ✅(文档为准,部分实测) + +`response_format` 支持 `text` / `json_object` / `json_schema`。 + +```json +{"type": "json_schema", "json_schema": {"name": "...", "strict": true, "schema": {...}}} +``` + +- ✅ `json_object` 实测可用,且可与 partial 叠加(§5)。 +- ⚠️ `strict: true` 走 token 级约束解码,要求 schema 符合 **MFJS(Moonshot Flavored JSON Schema)**——**自造方言**,与 OpenAI Structured Outputs 子集不是同一套。文档未给完整规则页。**未实测。** +- ⚠️ 文档罕见坦诚地承认支持度**按模型分层**:`kimi-k2.7-code` 最全(`anyOf`/`oneOf`/`$ref`/`additionalProperties`);`kimi-k3` 稳定;**`kimi-k2.6` 复杂 schema 不稳定——`$ref` 可能返回 Markdown 代码块、`oneOf` 可能被忽略**。 +- ✅ JSON Mode 只保证生成 JSON **Object**(不会返回顶层数组/标量)。 + +> 建议:`json_schema` **不做跨家 schema 转换**,透传 + 记录失败率。K2.6 可能吐 Markdown 围栏意味着"保证返回合法 JSON"的承诺需要中转站自己剥围栏——那属于改写响应体,需明确取舍。 + +--- + +## 8. 流式输出:`usage` 位置**由 `include_usage` 决定** ❌(推翻文档) + +文档说 usage "只出现在最后一个 chunk 的 `choices[0].usage`,OpenAI SDK 的 `chunk.usage` 返回 None"。实测发现**两种模式并存,取决于是否传 `stream_options.include_usage`**: + +**不传 `include_usage`**(Kimi 私有行为): + +```jsonc +// 最后一个 chunk —— usage 嵌在 choices[0] 里,与 finish_reason 同层 +data: {"id":"...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop", + "usage":{"prompt_tokens":88,"completion_tokens":211,"total_tokens":299, + "completion_tokens_details":{"reasoning_tokens":186}}}],"system_fingerprint":"fpv0_489ccb45"} +data: [DONE] +``` + +**传 `stream_options: {"include_usage": true}`**(与 OpenAI 完全一致): + +```jsonc +// 独立的最后一个 chunk —— choices 为空数组,usage 在顶层 +data: {"id":"...","object":"chat.completion.chunk","choices":[], + "usage":{"prompt_tokens":88,"completion_tokens":182,"total_tokens":270,"cached_tokens":88, + "completion_tokens_details":{"reasoning_tokens":166},"prompt_tokens_details":{"cached_tokens":88}}} +data: [DONE] +``` + +> **中转站结论(比文档的推论简单得多):总是显式传 `stream_options.include_usage = true`**,就能拿到 OpenAI 标准位置的 usage,不必改写响应体。代价从"改写响应"降为"改写请求"。 +> +> 两个附带差异:① 不传时**也有** usage(OpenAI 是不传就完全没有),所以"不传就没计费数据"的假设在 Kimi 上不成立;② **只有传了 `include_usage` 的那个 chunk 才带 `cached_tokens`**——不传时的 `choices[0].usage` 里没有缓存字段,会漏掉缓存计费信息。 + +其余流式行为 ✅ 与 OpenAI 一致:`data:` 前缀、`chat.completion.chunk`、**增量** delta(不是阿里那种全量累积)、`role` 只在首 chunk、中途有 `"delta":{}` 空 chunk、`data: [DONE]` 收尾。官方要求**以 `[DONE]` 判断结束而非 `finish_reason`**。 + +🆕 每个 chunk 都带 `system_fingerprint`(如 `fpv0_489ccb45`),**非流式响应里没有这个字段**——两种模式的响应字段集不一致。 + +### 自动断线重连 ⚠️ + +官方那篇《自动断线重连》**只是应用层 try/except 重试循环**(最多 100 次、间隔 1s),**没有任何协议级续传**——没有断点 offset,没有 resume id,重连就是整个请求重发。标题容易让人误以为有 resumable stream。中转站不要指望它做流式续传。 + +--- + +## 9. 多模态:**不支持 http(s) 图片 URL** ✅(实测确认) + +``` +{"type":"image_url","image_url":{"url":"https://statics.moonshot.cn/.../logo.png"}} +→ 400 Invalid request: unsupported image url: https://statics.moonshot.cn/.../logo.png +``` + +注意这个 URL 还是 Kimi 自家域名的图片,同样被拒。只接受两种形式: + +- base64 data URI:`data:image/png;base64,...` +- **Kimi 自有文件协议:`ms://`**(先经 `/v1/files` 上传) + +> 与 OpenAI(接受公网 URL)和 Anthropic(`source.type: "url"`)的硬性差异。中转站要么替用户下载转 base64(引入出网、体积、超时、**SSRF** 风险),要么显式报错。**建议默认报错,把"自动下载转码"做成可选开关**——代下载会让中转站变成任意 URL 的抓取代理。 + +其他 ✅(文档,未逐条实测): +- `image_url` 既接受 `{"url": "..."}` 对象也接受**直接字符串**,比 OpenAI 宽松。 +- 请求体上限 **100MB**;图片张数无硬限,受体积约束。建议图片 ≤4K、视频 ≤1080p。 +- **`content` 必须是真数组,不能是序列化后的 JSON 字符串**——文档特意警告"序列化成字符串后各模型行为不可预测"。中转站做消息归一化时若曾把 content 压成字符串(OpenAI 单文本场景的常见优化),多模态消息上必须禁用。 +- 视频输入(`video_url`)仅 K2.5/K2.6/K2.7-code/K3 支持——**OpenAI 协议里没有的模态**,跨家映射无对应物。 + +--- + +## 10. Context Caching:全自动 ✅,但 256 token 阈值 ❌ + +⚠️ **历史怪癖已消失**:Kimi 早期的显式 Cache 对象 API 已被移除,现行是**全自动前缀缓存**,无需创建或引用 cache id。(`china-providers.md` §3 原先标注的"需查证"由此作废。) + +❌ **文档说"prompt tokens > 256 才能命中前缀缓存"——实测不成立**: + +``` +prompt_tokens: 88 → cached_tokens: 88 (命中) +prompt_tokens: 21 → cached_tokens: 21 (命中,仅 21 token) +``` + +21 token 的请求也完整命中。实际阈值远低于 256,或该限制已取消。 + +🆕 **`cached_tokens` 在响应里出现两次**(同一个值): + +```json +"usage": {"prompt_tokens":88, "completion_tokens":211, "total_tokens":299, + "cached_tokens": 88, // 扁平(Kimi 私有) + "prompt_tokens_details": {"cached_tokens": 88}} // 嵌套(OpenAI 兼容) +``` + +文档只提到扁平那个。**中转站直接用 `prompt_tokens_details.cached_tokens` 即可对齐 OpenAI,不需要做字段映射**(比文档描述的情况好)。同理输出侧有 `completion_tokens_details.reasoning_tokens`。 + +⚠️ 但注意**首次请求的响应里完全没有缓存字段**(未命中时字段不出现,而非返回 0)——解析时要容忍缺失。 + +✅ 生命周期由系统管理,TTL 未公开。✅ 计价差异极大(K3):**命中 ¥2 / 1M,未命中 ¥20 / 1M**,输出 ¥100 / 1M。 + +> **中转站影响(被低估的一条)**:10 倍输入价差意味着"保持前缀稳定"是硬性成本要求。任何对 prompt 前缀的改写——注入 system 提示、重排消息、规范化空白、动态插入时间戳——都会让缓存失效,成本瞬间 10 倍。**规则:对 Kimi 上游的请求,前缀必须逐字节稳定。** 同理动态工具进消息历史会影响缓存,顶层 `tools` 不会(§6.3)。 + +🆕 **`prompt_cache_key` 实测被接受**(200,不报错),但因为缓存本来就自动命中,无法从外部观测它是否真的分裂了缓存命名空间。⚠️ 语义仍未知,文档的 Context Caching 指南完全没提这个参数。 + +🆕 **Anthropic 入口的 `cache_control: {"type":"ephemeral"}` 被接受但不生效**:返回 `cache_creation_input_tokens: 0`。自动缓存机制接管了,显式缓存断点是空操作。 + +--- + +## 11. 文件、Batch 与辅助端点 + +### 11.1 文件问答 ✅(文档,未实测) + +流程是"上传 → 抽取 → **把抽取出的文本当作 system 消息塞进 messages**",官方强调放的是**文件内容而不是 file id**。模型侧没有"引用 file id 做问答"的通道(`ms://` 只用于多模态图片/视频块)。 + +- 单用户最多 **1000 个文件**,单文件 **100MB**,空文件报错。 +- ⚠️ **`purpose` 取值文档自相矛盾**:错误码页说"仅接受 `file-extract`",batch 创建页要求 `purpose="batch"`。至少两个合法值。 +- 抽取行为:文本原样抽;图片走 OCR(**无文字则失败**);PDF 直接抽文本,纯图 PDF 走 OCR。 +- 文件类 API **限时免费**。 + +### 11.2 Batch API ✅(文档,未实测) + +- `POST /v1/batches`,字段 `input_file_id` / `endpoint`(仅 `/v1/chat/completions`)/ `completion_window` / `metadata`(≤16 对,key ≤64 字符,value ≤512 字符)。 +- ⚠️ `completion_window` 取值两处不一致:API 页说"最小 12h 最大 7d",指南页说 `24h`/`3d`/`7d`。 +- **省 40%** 推理费。**仅支持 `kimi-k2.6` / `kimi-k2.5`,不支持 `kimi-k3`**。 +- batch 请求体里**不得包含** `temperature` / `top_p` / `n` / 两个 penalty——§3 那条规则在 batch 上变成"不许出现"而非"必须等于固定值"。 +- JSONL 每行需 `custom_id`(文件内唯一)/ `method`(固定 POST)/ `url`(固定 `/v1/chat/completions`)/ `body`,文件 ≤100MB。 +- 支持多模态 batch(base64 或 `ms://`)。 + +### 11.3 官方 tokenizer ✅(实测) + +``` +POST /v1/tokenizers/estimate-token-count +{"model":"kimi-k3","messages":[{"role":"user","content":"你好世界"}]} +→ {"code":0,"data":{"total_tokens":87},"scode":"0x0","status":true} +``` + +🆕 **四个汉字算出 87 token**——因为它把 §4.1 那段思考模式的隐藏 system prompt(约 67 token)也算进去了,与实际 chat 请求的 `prompt_tokens: 88` 基本吻合。 + +> 这其实是好事:**官方 tokenizer 的口径与计费口径一致**,中转站做成本预估/限流应当用它,而不是套 tiktoken(Kimi 不是 OpenAI 词表)。但要知道它包含隐藏开销,别拿它的结果去和"用户可见文本长度"做对比,会显得离谱。代价是每次预估多一次网络往返。 + +### 11.4 余额 ✅(实测) + +``` +GET /v1/users/me/balance +→ {"code":0,"data":{"available_balance":15,"voucher_balance":15,"cash_balance":0},"scode":"0x0","status":true} +``` + +`cash_balance` **可为负数表示欠费**;`available_balance <= 0` 时无法调用推理 API。可作上游健康检查与余额预警(对应 DeepSeek 的 `/user/balance`,路径和字段名都不同)。 + +❌ **文档说"K3 需最低充值 ¥10 解锁,注册代金券不解锁 K3"——不成立**:实测账号 `cash_balance: 0`、余额全是代金券,K3 调用完全正常。 + +--- + +## 12. 错误码与限流 + +### 12.1 错误体 ✅/🆕 + +OpenAI 入口:`{"error": {"message": "...", "type": "..."}}`——**没有 OpenAI 的 `code` / `param` 字段**。中转站若要输出严格 OpenAI 错误结构需补 `code: null` / `param: null`。 + +另有 Anthropic 入口格式和网关格式,共三种,见 §1.3。 + +### 12.2 错误码表 ✅(打勾项为实测触发) + +| HTTP | `type` | 触发 | +| --- | --- | --- | +| 400 | `invalid_request_error` | ✅ 参数越界/固定值不符/schema 错/`tokenization failed`/`unsupported image url`/`tool_call_id is not found` | +| 400 | `content_filter` | 内容安全拦截 | +| 401 | `invalid_authentication_error` / `incorrect_api_key_error` | 鉴权头格式错 / key 无效(**含拿错区域的 key**) | +| 403 | `permission_denied_error` | API 未开通 / 越权 / IP 不在组织白名单 | +| 404 | `resource_not_found_error` | ✅ 模型不存在(下线模型、`kimi-k3[1m]`) | +| 429 | `engine_overloaded_error` | ✅ **上游过载**——退避重试 | +| 429 | `exceeded_current_quota_error` | **余额不足/欠费**——重试无用 | +| 429 | `rate_limit_reached_error` | ✅ **并发/RPM/TPM/TPD 超限**——降频 | +| 499 | `client_closed_request` | 客户端提前断开 | +| 500 | `server_error` / `unexpected_output` | 服务端错误 | +| 503 | `server_unavailable` | 扩缩容/维护 | +| 504 | — | **900 秒无响应**;官方建议长请求开 `stream: true` | + +**429 必须按 `type` 分流** ✅——三种 429 处置完全相反:过载要退避、欠费重试纯属浪费、限流要降并发。只看状态码的通用重试逻辑在欠费时会打爆上游。实测两种 429 都遇到了,`engine_overloaded_error` 在正常低频调用下也会偶发。 + +🆕 **429 的错误信息泄漏账号标识**: + +``` +Your account org-xxxxxxxx request reached organization max RPM: 3, please try again after 1 seconds +``` + +**org id 和 ak id(API key 的标识部分)直接出现在 message 里。** 中转站若把上游错误原样透传给下游,等于把自己的账号标识和 key 标识广播给所有用户。**这是必须在错误改写层拦截的信息泄漏点**,比 §1.2 的响应头更严重(响应头容易被整体过滤,错误 message 往往被原样转发)。 + +其他: +- ✅ **499 是非标准状态码**(nginx 系)。中转站的 HTTP 客户端和监控要能处理,别当 4xx 计入用户失败率。 +- ✅ **504 的 900 秒超时**——中转站自身的上游超时必须 ≥900s,否则会在 Kimi 还在算时先断开,浪费已消耗的 token。 +- ✅ 官方提醒:**OpenAI SDK 的自动重试会把 1 次请求放大成 2–3 次真实调用**,吃掉 RPM 配额。中转站应关掉 SDK 层重试,自己实现按 type 分流的重试。 + +### 12.3 限流响应头 🆕(文档完全未记载) + +实测 429 响应头: + +``` +retry-after: 1 +x-retry-after: 1 +msh-cooldown-seconds: 1 +``` + +**同一个信息重复了三份**(标准头 + 两个私有头),但**没有任何 `x-ratelimit-*` 头**——即**无法在请求成功时得知剩余配额**,只能等撞到 429 才知道。 + +> 中转站的配额管理因此只能靠本地计数 + 429 反馈,做不了"提前避让"。`retry-after` 是标准头,直接用它做退避即可。 + +### 12.4 限流分档 ✅ + +按累计充值金额定档(代金券不算): + +| Tier | 累计充值 | 并发 | RPM | TPM | TPD | +| --- | --- | --- | --- | --- | --- | +| 0 | ¥0 | 1 | 3 | 500K | 1.5M | +| 1 | ¥50 | 50 | 200 | 2M | 无限 | +| 2 | ¥100 | 100 | 500 | 3M | 无限 | +| 3 | ¥500 | 200 | 5K | 3M | 无限 | +| 4 | ¥5K | 400 | 5K | 4M | 无限 | +| 5 | ¥20K | 1K | 10K | 5M | 无限 | + +✅ **Tier 0 的 3 RPM 是真实且严格的**——本次实测全程必须每请求间隔 21 秒,任何并发都立刻 429。联调时若发现大量 `rate_limit_reached_error`,先看档位而不是查代码。 + +🆕 响应头 `msh-gid` 直接给出档位(实测为 `free`),**中转站可用它自动探测上游档位并据此设置本地并发上限**,无需人工配置。 + +--- + +## 13. 计价(K3,人民币)✅ + +| 项 | 单价 / 1M tokens | +| --- | --- | +| 输入(缓存命中) | ¥2 | +| 输入(缓存未命中) | ¥20 | +| 输出 | ¥100 | + +- 1M = 1,000,000。无按上下文长度分档。 +- **输出是命中输入的 50 倍**。加上思考默认 `max` 档、隐藏 system prompt、Preserved Thinking,K3 的实际输出 token 量显著高于同类模型——实测一句"说:好"能烧掉 **310 个 completion tokens**(其中 287 是 reasoning),而关闭思考后只要 **11 个**。 +- **同一请求关不关思考差 28 倍输出成本**,这是 §4.1"K3 思考实际可关"这条发现的直接价值。 +- Batch 省 40%(不支持 K3);联网搜索按次另计费,且**搜索 token 不在响应 usage 里**(§6.4)。 + +--- + +## 14. 实测推翻的文档结论(速查) + +| # | 文档说法 | 实测结果 | +| --- | --- | --- | +| 1 | K3 思考恒开、不可关闭、不使用 `thinking` 参数 | ❌ `thinking:{"type":"disabled"}` 生效,`prompt_tokens` 88→21,无 `reasoning_content` | +| 2 | 流式 usage 只在 `choices[0].usage` | ❌ 传 `include_usage:true` 时在顶层 + `choices:[]`,与 OpenAI 一致 | +| 3 | `reasoning_effort` 仅接受 `low`/`high`/`max` | ❌ `"medium"`、`"banana"` 都返回 200,非法值静默忽略 | +| 4 | K3/K2.7-code 多轮缺 `reasoning_content` 会报错 | ❌ 200 正常继续 | +| 5 | `role:"tool"` 消息必须带 `name` | ❌ 不带也 200 | +| 6 | Partial Mode 与 `json_object` 冲突 | ❌ 200 正常工作 | +| 7 | 缓存需 prompt > 256 token 才命中 | ❌ 21 token 也命中 | +| 8 | K3 需最低充值 ¥10、代金券不解锁 | ❌ 纯代金券账号正常调用 K3 | +| 9 | Claude Code 用 `kimi-k3[1m]` | ❌ 两个入口都 404,正确名是 `kimi-k3` | +| 10 | `kimi-k2.5` context_length 128000(示例) | ❌ 实际 262144 | + +**文档说不能、实际能**(1、3、4、5、6、7、8)意味着照文档写会**过度限制**;**文档说能、实际不能**(9)意味着照抄配置会**直接失败**。两个方向都有。 + +--- + +## 15. 仍待验证(下一轮实测) + +1. **`json_schema` + `strict: true` 的 MFJS 实际约束**(§7)——本轮未测,是结构化输出的核心。 +2. **K2.6 复杂 schema 吐 Markdown 围栏**的复现条件(§7)。 +3. **官方工具(Formula API)与 `$web_search` 的关系**(§6.4)。 +4. **`prompt_cache_key` 是否真的分裂缓存命名空间**(§10)——需要构造缓存命中/未命中对照。 +5. **文件上传 `purpose` 的合法枚举全集**、Batch 端到端(§11.1、§11.2)。 +6. **K3 关闭思考后 `tool_choice: "required"` 的行为**(§6.2)——指定函数已知会静默幻觉,`required` 未测。 +7. **长对话 + 多步工具链下 `reasoning_content` 是否真的可省**(§4.3)——短对话已验证可省,长链路需复验。 +8. **国际站 `api.moonshot.ai`** 的行为是否与国内站一致(本轮只测了国内站)。 +9. **`kimi-k2.5`** 的思考开关与 max output(本轮未覆盖该模型)。 + +--- + +## 16. 中转站接入 checklist + +**必须做的请求改写**: +- [ ] 按模型剥离 `temperature`/`top_p`/`n`/`presence_penalty`/`frequency_penalty`(§3)——**最高优先级**,同时消除两个入口的行为差异。 +- [ ] 流式请求一律补 `stream_options.include_usage = true`(§8)——用改写请求换取 OpenAI 标准的 usage 位置和缓存字段。 +- [ ] thinking 参数按模型分派:K3 可用 `reasoning_effort` 或 `thinking`;K2.7-code 只能 `enabled`;K2.6/K2.5 用 `thinking`(§4)。 +- [ ] 自行校验 `reasoning_effort` 枚举(上游不校验,非法值会静默跑最贵档)(§4.2)。 +- [ ] `prefill` → `partial: true`(§5)。 +- [ ] `n > 1` 时连带检查 `temperature > 0`,否则上游报的错无法理解(§3.1)。 +- [ ] 模型名 `kimi-k3[1m]` 之类的方括号后缀要拒绝或剥离(§1.3)。 + +**必须做的响应/错误改写**: +- [ ] **剥掉 `msh-org-id` / `msh-uid` / `msh-project-id` 响应头**(§1.2,账号标识泄漏)。 +- [ ] **过滤 429 错误 message 里的 `org-xxx`**(§12.2,账号+key 标识泄漏)。 +- [ ] 重写 `tool_calls[].id`(上游是 `{函数名}_{序号}`,多轮会重复)(§6.1)。 +- [ ] 错误体补 `code`/`param` 以对齐 OpenAI;解析上游错误时容忍 `error` 字段可能是 string(网关格式)(§12.1、§1.3)。 +- [ ] 非 chat 端点要剥掉 `{"code","scode","status","data"}` 业务包装(§1.2)。 +- [ ] `tool_calls[].type` 可能是 `builtin_function`,不要按 OpenAI 枚举拒绝(§6.4)。 + +**必须保持不动的**: +- [ ] prompt 前缀逐字节稳定,不注入、不重排、不规范化空白(§10,**10 倍价差**)。 +- [ ] `content` 数组不要压成字符串(§9)。 +- [ ] 内置工具与 `encrypted_output` 按 opaque 透传(§6.4)。 +- [ ] 不给未传参数补默认值(§3.1)。 +- [ ] 消息保留 passthrough 扩展字段(动态工具的 `{"role":"system","tools":[...]}` 无 content)(§6.3)。 + +**能力降级表**: +- [ ] 思考开启时**无法强制调用指定函数**——应**显式报错**,不要靠关闭思考绕过(§6.2,会静默幻觉出假数据)。 +- [ ] K2.7-code 思考不可关、`required` 也不可用(§4、§6.2)。 +- [ ] 不支持 http(s) 图片 URL(§9)。 +- [ ] 新模型不支持 `n > 1`(§3.1)。 +- [ ] 动态工具加载仅 K3(§6.3)。 + +**运维配置**: +- [ ] 上游超时 ≥900s(§12.2)。 +- [ ] 关闭 SDK 层自动重试,按 error `type` 分流(过载退避 / 欠费停止 / 限流降并发)(§12.2)。 +- [ ] 用 `retry-after` 头做退避;**没有 `x-ratelimit-*`,配额只能本地计数**(§12.3)。 +- [ ] 用响应头 `msh-gid` 自动探测账号档位并设置本地并发上限(§12.4)。 +- [ ] key 与区域 base URL 绑定成对(§1.1)。 +- [ ] "200 OK + 空 content + `finish_reason: length`" 不触发重试(§4.4)。 +- [ ] 用 `/v1/tokenizers/estimate-token-count` 做预估(口径含隐藏 system prompt,与计费一致)(§11.3)。 + +**Kimi For Coding 走单独一套配置**(§17):模型别名表、鉴权、限流、错误改写都不能与 platform 共用。 + +--- + +## 17. Kimi For Coding:同一家公司的另一套栈 ✅(2026-08-13 实测) + +> Base URL:`https://api.kimi.com/coding/v1`(根路径 `https://api.kimi.com/coding/` 返回 `{"message":"Welcome to the Kimi For Coding API!"}`) +> Key 形如 `sk-kimi-...`。面向 Claude Code / 编码 Agent 的**订阅制**产品,不是 platform 的一个套餐。 + +**底层推理栈相同**(流式 `system_fingerprint` 两边都是 `fpv0_489ccb45`),**但接入层是完全独立的两套实现**。官方 troubleshooting 只用一句"API Platform keys ≠ Kimi Code keys"带过,实际差异远不止鉴权。 + +### 17.1 差异总表 + +| 维度 | platform(`api.moonshot.cn`) | Kimi For Coding(`api.kimi.com/coding`) | +| --- | --- | --- | +| Key 前缀 | `sk-` | **`sk-kimi-`** | +| 账号体系 | 组织/项目/余额 | **个人账号**(见 §17.4) | +| 模型名 | `kimi-k3`、`kimi-k2.7-code`、`moonshot-v1-*` | **`k3`、`k3-256k`、`kimi-for-coding`、`kimi-for-coding-highspeed`** | +| 模型名校验 | 严格,未知名 404 | ❌ **几乎不校验**(见 §17.3) | +| OpenAI 入口 | `/v1/chat/completions` | `/v1/chat/completions` | +| Anthropic 入口 | `/anthropic/v1/messages` | **`/v1/messages`**(与 chat 同前缀,无 `/anthropic`) | +| thinking `signature` | ❌ 无 | ✅ **有真实签名** | +| Anthropic usage 字段 | 混入 OpenAI 字段(双套并存) | **纯 Anthropic 字段** | +| Anthropic 响应 id | `chatcmpl-...`(不规范) | **`msg_...`**(规范) | +| SSE 事件分隔 | `event: message_start`(有空格) | `event:message_start`(**无空格**) | +| `count_tokens` | ❌ 无 | ✅ `POST /v1/messages/count_tokens` | +| `estimate-token-count` | ✅ 有 | ❌ 404 | +| 余额查询 | ✅ `/v1/users/me/balance` | ❌ **404,无余额端点** | +| 账号信息端点 | ❌ 无 | 🆕 **`/v1/me`(返回手机号等 PII)** | +| `tool_calls[].id` | `get_weather_0`(可预测、会重复) | **`tool_jfSEsjJ6HvGswLR2r8XaBjnc`**(随机串,规范) | +| `$web_search` 的 id | `t-web_search-{hash}` | `tool_{随机串}` | +| 响应 id | `chatcmpl-6a7d5c23...`(十六进制) | `chatcmpl-JfEbxqHjKWC5fADqUduALJfG`(大小写混合) | +| 限流 | Tier 0 = **3 RPM / 1 并发** | ✅ **并发 10 全部 200**,无可观测的 RPM 限制 | +| 调试响应头 | `msh-request-id` / `msh-org-id` / `msh-uid` / `msh-gid` | **`x-trace-id`**、`server-timing`、`server: nginx`(**无 `msh-*`**) | +| 网关错误 | 中文 `{"code":5,"error":"url.not_found","message":"没找到对象"}` | 统一 API 格式 `{"error":{"message":"The requested resource was not found","type":"resource_not_found_error"}}` | +| `/v1/models` 字段 | `owned_by`/`permission`/`root`/`parent` | **`display_name`/`created_at`/`type`** + 分页字段 `first_id`/`last_id`/`has_more` | +| `reasoning_effort` 默认 | `max` | **`high`**(省钱档) | + +**行为一致的部分**(说明共用推理层):采样参数硬 400(`invalid temperature: only 1 is allowed for this model` 逐字相同)、`tool_choice` 指定函数与思考互斥的 400、K3 `thinking:{"type":"disabled"}` 可关(`prompt_tokens` 同样 21)、partial mode、`$web_search` 的 builtin_function 协议、流式 usage 由 `include_usage` 控制、`system_fingerprint` 值相同。 + +🆕 但**同一个校验的错误文案两边不同**,说明校验逻辑各写了一遍: + +``` +n=2 platform : invalid n: only 1 is allowed for this model +n=2 coding : Your request body contains invalid value for param n +``` + +### 17.2 模型列表(实测 `GET /coding/v1/models`) + +| id | display_name | context | 实测备注 | +| --- | --- | --- | --- | +| `kimi-for-coding` | K2.7 Coding | 262144 | **未知模型名的 fallback 目标**(§17.3.1);无隐藏 system prompt | +| `kimi-for-coding-highspeed` | K2.7 Coding Highspeed | 262144 | ⚠️ 与上一行**实测无法区分**(指纹、上限、速度全同) | +| `k3` | K3 | 1048576 | `default_effort: "high"`;带 79 token 隐藏 system prompt | +| `k3-256k` | K3-256k | 262144 | 实测是**网关层给 `k3` 加的 256K 限制**,非独立模型;`supports_video_in: false` | + +四个模型全部 `supports_thinking_type: "only"`。**没有 moonshot-v1 系列**。 + +> ⚠️ 注意 `k3-256k` 是**同模型的短上下文 SKU**——这与 platform 用 `moonshot-v1-8k/32k/128k` 切窗口是同一思路,但命名规则又不一样。中转站的别名表要为两个上游各维护一份。 + +### 17.3 ❌ 最危险的差异:`model` 字段几乎不校验 + +实测 Kimi For Coding 对未知模型名**不报错,而是照常返回 200 并把请求的名字原样回显**: + +```jsonc +{"model":"gpt-4", ...} → 200,响应 "model":"gpt-4",但内容带 reasoning_content(实际是 Kimi 在跑) +{"model":"claude-sonnet-4-5",...} → 200,响应 "model":"claude-sonnet-4-5" +{"model":"moonshot-v1-8k", ...} → 200,响应 "model":"moonshot-v1-8k"(该模型在此产品里根本不存在) +{"model":"foobar123", ...} → 200,响应 "model":"foobar123" +{"model":"", ...} → 200,响应 "model":""(空字符串也接受) +``` + +**唯一被拒的是带方括号的名字**,而且报错方式很离谱: + +``` +{"model":"k3[1m]"} → HTTP 401 +{"error":{"message":"Your model id does not exist, recognized as other:k3[1m]. Please set model id as `k3`.", + "type":"invalid_authentication_error"}} +``` + +**模型不存在却返回 401 + `invalid_authentication_error`**——状态码和错误类型都与语义不符(platform 同样情况返回的是 404 `resource_not_found_error`)。中转站若按 401 触发"key 失效"逻辑去刷新凭据或告警,会被这个错误误导。 + +> **对中转站的三重后果**: +> 1. **无法用模型名做路由校验**——上游不会告诉你名字写错了,只会默默用 fallback 模型跑完并收钱。 +> 2. **响应的 `model` 字段不可信**——它是请求值的回显,不是实际推理的模型。platform 那边至少 `moonshot-v1-auto` 会回显真实模型名(§2.1),这边连这个都没有。 +> 3. **下游可能被误导**:用户传 `gpt-4` 拿到 200 和一个 `"model":"gpt-4"` 的响应,会以为真的调用了 GPT-4。**中转站必须自己维护白名单并拒绝未知模型名**,不能依赖上游校验。 + +### 17.3.1 反推:模型名确实参与路由,未知名 fallback 到 `kimi-for-coding` ✅ + +响应只回显请求值,无法直接观测实际模型。用**两个独立的侧信道**反推,结论一致。 + +**方法一:上下文上限边界。** 构造 270,015 token 的 prompt(经 `count_tokens` 校准,介于 262144 与 1048576 之间),分别打各模型名: + +| 请求的 model | 结果 | +| --- | --- | +| `k3` | ✅ **200 正常返回**(真的吃下了 270K) | +| `k3-256k` | `k3-256k supports only 256K context.` | +| `kimi-for-coding` | `Invalid request: Your request exceeded model token limit: 262144 (requested: 270016)` | +| `gpt-4` | **同上,逐字相同** | +| `foobar123` | **同上,逐字相同** | + +**方法二:隐藏 system prompt 的 token 指纹。** §4.1 发现思考模式会注入一段隐藏 system prompt,各模型注入量不同,可作指纹。同一句 `"说:好"`: + +| 请求的 model | `prompt_tokens` | +| --- | --- | +| `k3` | **89** | +| `k3-256k` | **89** | +| `kimi-for-coding` | **10** | +| `kimi-for-coding-highspeed` | **10** | +| `gpt-4` / `foobar123` / `""` / `claude-sonnet-4-5` | **10** | + +两个方法交叉印证: + +- ✅ **模型名确实参与路由**——`k3` 走 1M 上下文且带 79 token 的隐藏 system prompt;`kimi-for-coding` 系列走 256K 且**无隐藏 prompt**。二者是两条不同的推理路径。 +- ✅ **所有无法识别的模型名(含空字符串)一律 fallback 到 `kimi-for-coding` 系列**,**不是** `k3`。 +- ✅ `k3-256k` 是**网关层对 `k3` 加的 256K 限制**,不是独立模型:它的隐藏 prompt 指纹与 `k3` 相同(89),但超限错误是一条**人工写的独立文案**(不含 `requested:` 数字),与 `kimi-for-coding` 那条模型层通用错误明显不同源。 + +> **中转站可直接用的结论**:用户若传了拼错的模型名,实际跑的是 K2.7 Coding(256K),而不是他以为的 K3——**上下文能力从 1M 悄悄降到 256K**,长上下文任务会在毫无提示的情况下超限失败。这使"自建模型名白名单"从建议变成必需。 + +⚠️ 仍无法区分的是 `kimi-for-coding` 与 `kimi-for-coding-highspeed`:两者 token 指纹相同(均为 10)、上下文上限相同(262144)、**实测输出速度也无差异**(33.2 vs 34.9 tok/s,重复一轮后 highspeed 反而更慢)。官方宣称 highspeed 约 180 tok/s,**实测两者都只有约 33 tok/s**——要么高速档在当前负载/订阅层级下未生效,要么二者本就是同一后端。 + +### 17.4 🆕 `/coding/v1/me` 返回个人身份信息(隐私暴露面) + +platform 只有余额端点(返回金额),Kimi For Coding 则有一个 `/v1/me`: + +```jsonc +{ + "user_id": "...", "global_id": "...", + "nickname": "登月者XXXX", + "avatar": "https://avatar.moonshot.cn/avatar/default/...", + "phone": {"country_code": "86", "number": "1XX****XXXX"}, // ← 部分脱敏的手机号(实测返回真实号码的首三位+后四位) + "status": "USER_STATUS_NORMAL", "region": "REGION_CN", + "user_level": 25, "user_level_name": "Allegretto", + "domain": 1, "domain_name": "DOMAIN_NEXUS", + "created_time": "...", "last_login_time": "..." +} +``` + +> **任何持有这个 key 的人都能读到账号主人的手机号(部分脱敏)、昵称、注册与最后登录时间。** 对中转站的含义: +> - **把 Kimi For Coding 的 key 交给第三方使用 = 交出账号主人的部分身份信息**,风险等级远高于 platform 的 key(那边最多泄漏余额)。多租户中转站尤其要注意。 +> - 中转站**不应把 `/v1/me` 透传给下游**。若要做上游健康检查,用它是可行的(无余额端点可用),但响应必须裁剪,只保留 `status`。 +> - 反过来,这也是**唯一能确认 coding key 是否有效**的轻量端点(没有余额接口)。 + +### 17.5 计费与配额:中转站看不见 + +- ❌ **没有余额/用量端点**(`/balance`、`/usage`、`/quota`、`/subscription` 全部 404)。订阅制产品,用量只能在网页端看。 +- 因此**中转站无法对 Kimi For Coding 做余额预警或成本核算**——只能自己按 usage 累加统计,且没有官方口径可对账。这与 platform(有 `/v1/users/me/balance`,可做健康检查与预警)是运维层面的实质差别。 +- 限流宽松(10 并发实测无 429),但**限额规则未公开**,撞到限制时的错误类型未知(本轮未触发)。⚠️ 中转站应做好"上游可能在没有任何预警的情况下拒绝服务"的准备。 + +### 17.6 Anthropic 入口质量反而更高 + +Kimi For Coding 的 `/coding/v1/messages` 是**本次调研里最接近 Anthropic 官方规范的实现**: + +- ✅ 规范的事件序列:`message_start` → `content_block_start` → `content_block_delta` ×N → `content_block_stop` → `message_delta` → `message_stop` +- ✅ `msg_` 前缀的 id、纯 Anthropic 的 usage 字段、**thinking block 带 `signature`** +- ✅ 有 `POST /v1/messages/count_tokens`(返回 `{"input_tokens":9}`) +- ✅ `x-api-key` 和 `Authorization: Bearer` 都接受,`anthropic-version` 头非必需 + +对比 platform 的 `/anthropic/v1/messages`:id 是 `chatcmpl-`、usage 混两套字段、thinking 无 signature、无 count_tokens。 + +> **中转站若要做 Anthropic 格式的上游,Kimi For Coding 明显是更合适的一侧。** + +### 17.6.1 反推:`signature` 是**装饰品**,服务端不校验 ✅ + +Anthropic 真机会校验 thinking 的 `signature`(篡改会报错),这是"推理内容不可跨家搬运"结论的根基(见 README §2 第 2 条)。实测 Kimi For Coding 的 signature(长达 12,946 字符,看起来很像真的): + +| 多轮回传时对 thinking block 的处理 | 结果 | +| --- | --- | +| 原样回传 | 200 | +| **signature 全部替换成 `XXXX...`**(等长) | **200** | +| **完全删除 `signature` 字段** | **200** | +| **保留原 signature,但把 `thinking` 文本换成伪造内容** | **200** | + +**四种情况全部正常返回,没有任何校验。** + +> 这条对中转站是**减负**:Kimi For Coding 的 thinking block **不必当 opaque blob 搬运**,可以自由丢弃 `signature`、截断甚至改写 thinking 内容——与 platform 那侧(明文无签名)的处理方式可以统一。 +> +> 但反过来它是个**安全提示**:这个 signature 只是长得像密码学签名,实际不提供任何完整性保证。**中转站不要把它当作"上游已验证推理内容"的证据**,也不要在自己的对外 API 里模仿这种"假签名"——下游若据此信任内容,就是被误导。 +> +> ⚠️ 注意方向性:这条结论**只适用于 Kimi**。转发到 Anthropic 真机时,signature 仍必须原样搬运。 + +### 17.7 结论:必须分开处理 + +**是的,必须当作两个完全独立的上游。** 具体到实现: + +| 层面 | 要点 | +| --- | --- | +| 上游配置 | 独立的 base URL + key + 模型别名表;key 前缀(`sk-` vs `sk-kimi-`)可用于自动识别配错 | +| 模型路由 | **中转站自建白名单**——coding 侧不校验模型名,**错名静默 fallback 到 `kimi-for-coding`(256K),上下文能力从 1M 悄悄降级**(§17.3.1) | +| thinking | coding 侧的 `signature` 不参与校验,可自由丢弃/改写(§17.6.1);转发到 Anthropic 真机时则必须原样搬运 | +| 响应处理 | coding 侧的 `model` 字段是回显值,**不可用于记账**;platform 侧 `moonshot-v1-auto` 也有类似问题 | +| 错误处理 | coding 侧模型不存在返回 **401**,不能触发凭据刷新逻辑;两侧错误文案不同 | +| 限流 | platform 需按 Tier 严格限速(Tier 0 = 3 RPM);coding 侧宽松但规则不透明 | +| 运维 | platform 用 `/v1/users/me/balance` 做健康检查;coding 只能用 `/v1/me`(**响应需裁剪,含 PII**) | +| Anthropic 格式 | 优先用 coding 侧;但其 thinking `signature` 必须 opaque 搬运,platform 侧则不用 | +| 隐私 | coding key 泄漏 = 泄漏账号主人手机号等信息,**不可作为共享凭据下发** | diff --git a/docs/openai.md b/docs/openai.md new file mode 100644 index 0000000..03635cd --- /dev/null +++ b/docs/openai.md @@ -0,0 +1,225 @@ +# OpenAI API + +> 来源: +> - OpenAPI 规范(权威):https://raw.githubusercontent.com/openai/openai-openapi/master/openapi.yaml +> - https://developers.openai.com/api/reference/resources/responses/methods/create +> - https://developers.openai.com/api/docs/guides/function-calling.md +> - https://developers.openai.com/api/docs/guides/structured-outputs.md +> - https://developers.openai.com/api/docs/guides/reasoning.md +> - https://developers.openai.com/api/docs/guides/migrate-to-responses.md +> +> 采集于 2026-08-12。注意:`platform.openai.com/docs/api-reference/*` 对爬虫返回 403,文档主站已迁移到 `developers.openai.com`;页面 URL 后加 `.md` 可直接取 Markdown 版本。 + +OpenAI 目前有**两套并存**的文本生成接口,中转站通常两套都要支持: + +- `POST /v1/chat/completions` — 老接口,生态最广,几乎所有第三方"OpenAI 兼容"服务都实现它。 +- `POST /v1/responses` — 新接口,OpenAI 主推,reasoning 模型的完整能力只在这里。 + +--- + +## 1. Chat Completions + +### 1.1 请求参数 + +必填:`model`、`messages`。 + +`messages[]` 的 role 取值:`system`、`developer`、`user`、`assistant`、`tool`、`function`(deprecated)。 + +来自 `CreateChatCompletionRequest`: + +| 参数 | 类型 | 默认 | 说明 | +| --- | --- | --- | --- | +| `messages` | array | — | 必填,minItems 1 | +| `model` | string | — | 必填 | +| `max_completion_tokens` | int\|null | — | 输出上限,**含 reasoning tokens** | +| `max_tokens` | int\|null | — | **已 deprecated**,且与 o 系列/reasoning 模型不兼容 | +| `modalities` | array | — | 如 `["text"]` / `["text","audio"]` | +| `verbosity` | enum | — | 输出详略控制 | +| `reasoning_effort` | enum | — | 见 §3 | +| `frequency_penalty` | number | 0 | -2 ~ 2 | +| `presence_penalty` | number | 0 | -2 ~ 2 | +| `response_format` | object | `{type:"text"}` | `text` / `json_object` / `json_schema` | +| `stream` | bool | false | SSE | +| `stream_options` | object | — | `{include_usage: true}` | +| `stop` | string\|array | — | 停止序列 | +| `logit_bias` | map | null | -100 ~ 100 | +| `logprobs` | bool | false | | +| `top_logprobs` | int | — | 0 ~ 20,需 `logprobs=true` | +| `n` | int | 1 | 1 ~ 128 | +| `seed` | int | — | **已 deprecated**,Beta,不保证确定性 | +| `store` | bool | false | 是否存储用于 distillation/evals;>8MB 图片会被丢弃 | +| `moderation` | object | — | 请求/输出审核配置 | +| `prediction` | object | — | Predicted Outputs | +| `audio` | object | — | `modalities:["audio"]` 时必填,含 `voice`、`format` | +| `web_search_options` | object | — | 内置 web 搜索 | +| `tools` | array | — | function tools 或 custom tools | +| `tool_choice` | string\|object | — | `none`/`auto`/`required`/指定函数/`allowed_tools` | +| `parallel_tool_calls` | bool | true | | +| `function_call` | — | — | **deprecated**,用 `tool_choice` | +| `functions` | array | — | **deprecated**,用 `tools`;1~128 项 | + +继承自 `ModelResponseProperties`(Chat 与 Responses **共享**): + +| 参数 | 类型 | 范围/默认 | 说明 | +| --- | --- | --- | --- | +| `temperature` | number | 0 ~ 2,默认 1 | | +| `top_p` | number | 0 ~ 1,默认 1 | 建议与 temperature 二选一 | +| `metadata` | map | — | | +| `user` | string | — | **deprecated**,被 `safety_identifier` + `prompt_cache_key` 取代 | +| `safety_identifier` | string | ≤64 字符 | 建议传用户名/邮箱的 hash | +| `prompt_cache_key` | string | — | 提升缓存命中率,取代 `user` | +| `service_tier` | enum | — | | +| `prompt_cache_retention` | enum | `in_memory` / `24h` | **deprecated**,改用 `prompt_cache_options.ttl`;`gpt-5.5` 及以后只支持 `24h` | +| `prompt_cache_options` | object | — | 含 `ttl` | +| `top_logprobs` | int | 0 ~ 20 | | + +### 1.2 响应 + +```json +{ + "id": "chatcmpl-...", + "object": "chat.completion", + "created": 1741570283, + "model": "gpt-4o-2024-08-06", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "...", "refusal": null, "annotations": []}, + "logprobs": null, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 1117, + "completion_tokens": 46, + "total_tokens": 1163, + "prompt_tokens_details": {"cached_tokens": 0, "audio_tokens": 0}, + "completion_tokens_details": { + "reasoning_tokens": 0, "audio_tokens": 0, + "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0 + } + }, + "service_tier": "default", + "system_fingerprint": "fp_..." +} +``` + +`finish_reason` 枚举:`stop` | `length` | `tool_calls` | `content_filter` | `function_call`(deprecated)。 + +`system_fingerprint` 已标记 **deprecated**。 + +`choices[].logprobs` 是 nullable 且在 schema 中被列为 **required**——即必须存在该 key,值可为 `null`。中转站生成响应时不要省略这个字段,严格的客户端会校验。 + +### 1.3 流式 + +`stream: true` 时返回 `object: "chat.completion.chunk"` 的 SSE,每个 chunk 的 `id` 相同,内容在 `choices[].delta`。 + +关键点: +- 设置 `stream_options: {"include_usage": true}` 时,**最后一个 chunk 的 `choices` 为空数组**,只带 `usage`。这是很多客户端解析崩溃的来源。 +- 流以 `data: [DONE]` 结束(非 JSON)。 +- 工具调用的 `arguments` 以字符串分片下发,需按 `tool_calls[].index` 累加。 + +--- + +## 2. Responses API + +`POST /v1/responses`。 + +### 2.1 与 Chat Completions 的字段映射 + +| Chat Completions | Responses | +| --- | --- | +| `messages` | `input`(可为 string,也可为 Item 数组) | +| system / developer 消息 | `instructions`(顶层参数) | +| `max_completion_tokens` | `max_output_tokens` | +| `response_format` | `text.format` | +| `choices[0].message.content` | `output_text`(SDK 便捷字段)/ `output[]` 里的 message item | +| `tool_calls[]`(含 `id`) | `function_call` item(含 **`call_id`**) | +| `tools[].function.{name,...}` | `tools[].{name,...}`(少一层嵌套) | + +### 2.2 专有请求参数 + +| 参数 | 说明 | +| --- | --- | +| `background` | 后台执行 | +| `conversation` | 会话 ID 或 `{id}` 对象,自动把历史 item 拼到 input 前 | +| `previous_response_id` | 由 OpenAI 托管上下文;**注意仍需重发 `instructions`** | +| `store` | Responses **默认存储**(Chat Completions 对新账户默认存储) | +| `include` | 请求额外输出,如 `web_search_call.action.sources`、`file_search_call.results` | +| `context_management` | `{type, compact_threshold}` 自动压缩上下文 | +| `reasoning` | `{effort, summary}` | + +### 2.3 输出 item 类型 + +`output[]` 是异构数组,已知类型: + +- `message` — `{id, role:"assistant", content:[text|refusal], status, phase?}`,`phase` 可为 `commentary` / `final_answer` +- `function_call` — `{arguments, call_id, name, status}` +- `file_search_call` — `{id, queries, status, results}` +- `web_search_call` — `{id, action, status}` +- `computer_call` — `{id, call_id, pending_safety_checks, action/actions, status}` +- `reasoning` — `{id, summary, content, encrypted_content, status}` +- `compaction` — `{id, encrypted_content}` + +`status` 取值:`in_progress` | `completed` | `incomplete`。 + +### 2.4 流式 + +事件是命名事件(如 `response.function_call_arguments.delta` / `.done`),与 Chat Completions 的匿名 chunk 完全不同结构。 +未取到完整事件清单(`/api/reference/responses/streaming` 返回 404),实现前需补齐。 + +--- + +## 3. Reasoning 模型的怪癖 + +这是 OpenAI 侧最容易踩的一类坑: + +1. **`max_tokens` 不可用**。Chat Completions 必须用 `max_completion_tokens`,Responses 必须用 `max_output_tokens`。老代码直接透传 `max_tokens` 会报错。 +2. **`temperature`、`top_p`、`presence_penalty` 不支持**(reasoning 模型上)。中转站若把这些参数无条件透传,请求会失败。 +3. **`max_output_tokens` 包含 reasoning tokens**。官方建议实验时至少留 **25,000** tokens。给一个小的上限很可能只烧掉推理、输出为空。 +4. **reasoning tokens 按 output token 计费**,占用上下文窗口,统计在 `output_tokens_details.reasoning_tokens`(Chat 侧为 `completion_tokens_details.reasoning_tokens`)。 +5. **`reasoning.effort` 取值随模型而变**:`none` | `minimal` | `low` | `medium` | `high` | `xhigh` | `max`。`gpt-5.5` 默认 `medium`。不要假设所有模型接受全部取值。 +6. **reasoning summary 需显式开启**:`reasoning.summary`,设 `"auto"` 取最详细的可用摘要。默认不返回。 +7. **无状态模式下必须回传 `encrypted_content`**。当 `store: false` 或组织启用 ZDR 时,reasoning item 携带 `encrypted_content`,多轮要把它传回去才能保留推理链。 +8. **函数调用多轮时必须带回 reasoning item**:把最后一次 function call 返回的 reasoning items 与 function output 一起回传;连续多次函数调用时,需保留自上一条 user 消息以来的**所有** reasoning / function_call / function_call_output item。 + +--- + +## 4. Function calling 的怪癖 + +- **`strict: true` 的硬性要求**:`additionalProperties` 必须为 `false`;**所有** `properties` 都必须出现在 `required` 里。可选字段只能用 `"type": ["string", "null"]` 表达。 +- **`strict` 在两套 API 下行为不同**:Responses API 中 `strict` 默认尝试严格模式,**失败时自动回退到非严格**;Chat Completions 无此回退。 +- **`tool_choice` 取值**:`"auto"`(默认)| `"required"` | `"none"` | `{"type":"function","name":"x"}` | `allowed_tools`(限定子集)。 +- **`parallel_tool_calls: false`** 保证一轮最多一次工具调用。 +- **ID 字段名不一致**:Chat Completions 是 `tool_calls[].id`,Responses 是 `function_call.call_id`。跨 API 转换时这是最常见的 bug 点。 +- **流式 arguments 是分片字符串**,Responses 侧要累加 `response.function_call_arguments.delta` 直到 `.done`。 + +--- + +## 5. Structured Outputs 的怪癖 + +`strict: true` 下 JSON Schema 只支持一个子集: + +**支持**:string、number、boolean、integer、object、array、enum、anyOf。 + +**不支持的关键字**:`allOf`、`not`、`dependentRequired`、`dependentSchemas`、`if`/`then`/`else`。 +微调模型还额外不支持:字符串的 `minLength`/`maxLength`/`pattern`/`format`,数字的范围约束,数组的 `minItems`/`maxItems`。 + +**结构上限**: +- 最多 5000 个对象属性 +- 最多 10 层嵌套 +- 所有属性名 + 定义名 + 枚举值的总字符数 ≤ 120,000 +- 枚举最多 1000 个值;超过 250 个时,单个属性的枚举值总长 ≤ 15,000 字符 +- **根对象必须是 object,不能是 `anyOf`** +- 所有字段必须在 `required` 中;`additionalProperties` 必须为 `false` + +**`refusal` 字段**:安全拒绝时,响应带一个**不遵守 schema** 的 `refusal` 字段。解析方必须先判断 `refusal` 再解析 JSON。 + +**与 `json_object` 的区别**:`json_object` 只保证是合法 JSON,**不保证符合 schema**。 + +--- + +## 6. 其他杂项怪癖 + +- `n > 1` 时按所有 choices 的生成 token 总量计费。多数第三方"OpenAI 兼容"实现只支持 `n=1`。 +- `seed` 已 deprecated 且从来不保证确定性。 +- `store: true` 时超过 8MB 的图片输入会被静默丢弃。 +- `prompt_cache_retention` 与 `prompt_cache_options.ttl` 是**两个独立且不交互**的字段:前者表达最大保留策略,后者表达最小缓存寿命。 diff --git a/docs/transform-spec.md b/docs/transform-spec.md new file mode 100644 index 0000000..a4cd1af --- /dev/null +++ b/docs/transform-spec.md @@ -0,0 +1,575 @@ +# zend-token 转写方案(Transform Spec) + +> 状态:设计 v0.3 · 2026-08-13 +> 范围:**Claude / OpenAI / DeepSeek / Kimi 四家**的双向转写实现规范。 +> 与 [api-design.md](./api-design.md) 的关系:那份定义**对外契约**(端点、IR、降级协议),本份定义**转写引擎本身**——上游画像、改写管线、逐字段规则、测试口径。v0.2 的双规范模型(OaiIR / AnthIR)在此**保留不变**,本文是它的落地层。 +> 依据:[anthropic.md](./anthropic.md)、[openai.md](./openai.md)、[deepseek.md](./deepseek.md)、[kimi.md](./kimi.md)(含实测)、[cross-provider-mapping.md](./cross-provider-mapping.md)、[compatibility-layers.md](./compatibility-layers.md)、[errors-and-limits.md](./errors-and-limits.md)。 + +--- + +## 0. 一页结论 + +四家做同一件事(多轮对话 + 工具调用 + 推理),但**它们的差异不在一个层次上**,混在一起处理必然写成一堆 if-else。本方案把差异**分成四层**,每层用不同机制消化: + +| 层 | 差异性质 | 举例 | 消化机制 | +| --- | --- | --- | --- | +| **L-A 方言** | 同一语义、不同字段名/形状 | `input_schema` vs `parameters`、`prefix` vs `partial` | **双 IR + 桥**(§4) | +| **L-B 能力** | 上游根本没有这个能力 | Anthropic 无 `n>1`、Kimi 不收 http 图片 URL | **能力矩阵 + 降级协议**(api-design §6/§7) | +| **L-C 怪癖** | 有能力但要求特定写法,否则 400 或静默失效 | Kimi K3 `temperature≠1` 硬 400、DeepSeek 关思考三入口三种写法 | **上游 Profile 驱动的改写管线**(§5、§6)← 本文核心新增 | +| **L-D 诚信度** | 上游返回的东西**不可信** | Kimi coding 模型名回显、假 signature、200+空 content | **可信度分级 + 网关自持真相**(§7、§8、§9) | + +**三条贯穿全文的硬规则**(违反任意一条,转写在生产上一定出事): + +1. **上游身份是三元组 `(provider, entry_family, model)`,不是 provider。** 同一家的两个入口对同一参数行为相反(Kimi `temperature`:OpenAI 入口 400 / Anthropic 入口静默忽略;DeepSeek `tool_choice: any`:Anthropic 入口静默降级 / OpenAI 入口 400)。任何"按 provider 分支"的代码都是错的。 +2. **最小合法请求体。** 只发 Profile 白名单里的字段,**绝不为未传参数补默认值**(Kimi K3 补 `temperature=0.7` 直接 400,moonshot-v1 补默认值会改变原有行为)。 +3. **上游说什么都不算数。** 模型名、usage、错误状态码、signature 全都可能是装饰品。网关必须自持一份真相(白名单、账单口径、错误分类),只在能交叉验证时才采信上游。 + +--- + +## 1. 求同:四家真正共享的内核 + +剥掉方言后,四家**语义完全一致**的部分只有下面这些。IR 的核心字段集应当**恰好等于**这张表——多一个字段就意味着某一族要造轮子,少一个就意味着有损。 + +| 内核语义 | 四家一致的部分 | 落在 IR 的字段 | +| --- | --- | --- | +| 会话是 user/assistant 交替的消息序列 | ✅ 全一致 | `messages[]` / `items[]` | +| 系统指令独立于对话 | ✅ 语义一致(位置不同) | `instructions` / `system` | +| 内容是多模态块序列(文本 / 图片 / 文档) | ✅ 结构一致(字段名不同) | `Part` / `Block` | +| 工具 = JSON Schema 描述的函数 | ✅ 全一致(嵌套层数不同) | `tools[]` | +| 模型发起调用 → 客户端执行 → 结果回灌 | ✅ 全一致(载体不同) | `tool_use` / `function_call` | +| 输出上限、温度、top_p、停止序列 | ✅ 语义一致(取值域不同) | `sampling` | +| 停止原因(自然 / 截断 / 工具 / 过滤) | ✅ 四个语义一致 | `stop_reason` | +| 流式增量下发 | ✅ 都是 SSE | 流式规范层 | +| 用量计数(输入 / 输出 / 缓存 / 推理) | ✅ 概念一致(口径不同) | `usage` | +| 前缀缓存 | ✅ 四家都有 | 无字段(见 §11) | + +**共识只到这里。** 下面每一项在四家之间都无法直接对齐:推理内容的载体与回传规则、结构化输出的 schema 方言、内置工具、前缀续写(prefill)、`n>1`、logprobs、`is_error`。 + +> 设计含义:**IR 只保内核 + 各族原生扩展**,不试图为"推理""内置工具"发明统一抽象。它们走 §7 的可信度分级和 opaque 透传。 + +--- + +## 2. 存异:一张必须先看的上游身份表 + +我们要接的**不是 4 个上游,是 8 个入口**。逐个列出,因为每一行的合法请求体都不同: + +| # | 上游身份 | Base URL | 族 | 备注 | +| --- | --- | --- | --- | --- | +| 1 | `anthropic / messages` | `api.anthropic.com/v1` | Claude | 唯一有真 signature 的上游 | +| 2 | `openai / chat` | `api.openai.com/v1` | OpenAI | 生态基准 | +| 3 | `openai / responses` | 同上 | OpenAI | reasoning 完整能力 | +| 4 | `deepseek / chat` | `api.deepseek.com` | OpenAI | `image_url` 硬 400 | +| 5 | `deepseek / anthropic` | `api.deepseek.com/anthropic` | Claude | 错误体是 OpenAI 格式;signature = 响应 id | +| 6 | `deepseek / responses` | `api.deepseek.com/v1` | OpenAI | 图片被换成 `[Unsupported Image]` | +| 7 | `kimi-platform / chat` + `/anthropic` | `api.moonshot.cn/v1`、`/anthropic/v1` | 两族 | 采样参数两入口行为**相反** | +| 8 | `kimi-coding / chat` + `/messages` | `api.kimi.com/coding/v1` | 两族 | **另一套账号体系**,key 前缀 `sk-kimi-` | + +外加 DeepSeek 的 `/beta`(prefix、FIM、strict)——**同一 provider 的第四个入口**,主 base URL 上传 `prefix` 是硬 400。 + +### 2.1 四家的"性格"速判 + +这决定了转写引擎对它们的默认信任度: + +| | Anthropic | OpenAI | DeepSeek | Kimi platform | Kimi coding | +| --- | --- | --- | --- | --- | --- | +| 未知参数 | 静默忽略 | 静默忽略 | 静默忽略 | 静默忽略 | 静默忽略 | +| **已知参数越界** | clamp/忽略 | 400 | **400** | **400(且是"只允许固定值")** | **400** | +| 未知模型名 | 404 | 404 | 400(但旧名静默别名) | 404 | **200 + 静默 fallback** | +| 不支持的模态 | — | — | **静默替换占位文本**(2 个入口) | 400 | 400 | +| 错误体格式 | 规范 | 规范 | Anthropic 入口用 OpenAI 格式 | **三种格式并存** | 统一 | +| signature | **真密文** | `encrypted_content` 真密文 | **伪造(= 响应 id)** | 无该字段 | **假的,不校验** | +| 文档可信度 | 高 | 高 | 中(3 处实测推翻) | **低(10 条实测推翻)** | 低 | + +> **最危险的两格**是 DeepSeek 的"静默替换占位文本"和 Kimi coding 的"未知模型名 200"——它们产生 **HTTP 200 + 语义错误的响应**,下游无从察觉。网关必须在**请求侧**拦掉这两类情况,而不是指望上游报错。 + +--- + +## 3. 架构总图 + +``` + ┌──────────── 入口方言层 (§4) ────────────┐ + POST /v1/chat/completions ─┐ + POST /v1/responses ────────┼──→ 【OaiIR】────┐ + POST /v1/messages ─────────┼──→ 【AnthIR】───┤ + POST /v1/messages/count_tokens │ + └────────────────────┼─────────────────┘ + │ + ┌───────────▼───────────┐ + │ 路由 + 族决策 (§6.2) │ + └───────┬───────┬───────┘ + 同族直出 │ │ 跨族 + │ ┌────▼─────┐ + │ │ 跨族桥 │ ← 唯一有损点 + │ │ (§4.3) │ + │ └────┬─────┘ + ┌───────▼───────▼───────┐ + │ 请求改写管线 (§6) │ ← Profile 驱动 + │ R1…R9 顺序固定 │ + └───────────┬───────────┘ + │ + ┌─────────────────▼─────────────────┐ + │ 上游 8 个入口(§2) │ + └─────────────────┬─────────────────┘ + │ + ┌───────────▼───────────┐ + │ 响应改写管线 (§8) │ + │ S1…S9 顺序固定 │ + └───────────┬───────────┘ + │ + 流式规范层 (§9) +``` + +**关键点**:改写管线是**数据驱动**的——同一份代码跑所有上游,行为差异全部来自 Profile(§5)。新增一家 provider 的正常代价应该是"写一份 Profile + 若干黄金样本",只有真正的新协议形态(比如 MiniMax 的 `base_resp`)才需要新增代码路径。 + +--- + +## 4. 方言层与跨族桥(L-A) + +沿用 api-design §3–§5,此处只补**四家实测暴露出的、v0.2 未覆盖的转写规则**。 + +### 4.1 消息归一的两条新约束 + +1. **`role: "developer"` 必须降级为 `system`**——DeepSeek 硬 400 `unknown variant 'developer'`。归一发生在入口层(OaiIR 本就把 system/developer 提升到 `instructions`),所以只要 §3.4 的不变量被执行,这条自动满足。**但 passthrough 路径要单独检查**。 +2. **消息级扩展字段不得裁剪。** Kimi K3 的动态工具加载是一条 `{"role":"system","tools":[...]}` 且**没有 `content`** 的消息——OpenAI 协议里不存在这种形状。IR 的消息对象必须带 `ext` 字典承载它,schema 校验不能要求 `content` 必填。 + +### 4.2 工具调用 ID:一律重写为网关 ID + +四家的 ID 形态: + +| 上游 | 形态 | 问题 | +| --- | --- | --- | +| OpenAI Chat / Responses | `call_abc…` / `call_id` | 无 | +| Anthropic | `toolu_…` | 无 | +| DeepSeek | OpenAI 风格 | 非流式响应**也带 `index`**(OpenAI 不带) | +| **Kimi platform** | **`{函数名}_{序号}`**,如 `get_weather_0` | **可预测、且多轮会重复** | +| Kimi platform builtin | `t-web_search-{hash}`,`type` 为 `builtin_function` | 打破 OpenAI 枚举 | +| Kimi coding | `tool_{随机串}` | 无 | + +**规则**:网关对外一律下发自己的不透明 ID(`ztk_tc_`),维护 `(session, gateway_id) → upstream_id` 映射,回程还原。理由不是洁癖——Kimi platform 的重复 ID 会让"按 ID 做去重/匹配"的逻辑在多轮里静默错配。 + +同时:**`tool_calls[].type` 的解析必须容忍 `builtin_function`**,不能按 OpenAI 枚举拒绝。 + +### 4.3 跨族桥的补充规则 + +在 api-design §5.2/§5.3 之上追加: + +| 场景 | 规则 | +| --- | --- | +| `arguments` 解析失败 | 降级 `input: {}` + L2,原文进诊断字段(**不抛异常**)——流式截断时常见 | +| A→O 的 `tool_result.is_error` | 文本前缀 `Error: ` + L2 标记(有损,无解) | +| O→A 的 `function_call` | **必须回填进前一条 assistant 消息**,不新建消息 | +| Kimi 动态工具 system 消息 | **禁止跨族**——AnthIR 无处安放,直接 L3 报错 | +| `prefill` 语义 | IR 单一 `prefill` 标志 → DeepSeek `prefix:true`(且**必须切 `/beta`**)/ Kimi `partial:true` / Anthropic 4.5 及更早的末条 assistant;**Claude 4.6+ 与 Mythos Preview 一律 L3 拒绝** | +| Kimi `partial` 的 `name` | Kimi 语义是"人设锚点",不是 OpenAI 的说话人区分——**转发时不要丢弃** | + +--- + +## 5. 上游 Profile:把怪癖变成数据(L-C 的核心) + +一份 Profile 描述一个**上游身份**(§2 表中的一行)。管线只读 Profile,不认 provider 名字。 + +### 5.1 Schema + +```jsonc +{ + "id": "kimi-platform/openai", + "family": "openai", // openai | anthropic —— 决定是否过桥 + "base_url": "https://api.moonshot.cn/v1", + "auth": {"scheme": "bearer", "key_prefix": "sk-", "reject_prefix": ["sk-kimi-"]}, + + "params": { + "policy": "whitelist", // 白名单:未列出的字段一律不发 + "allow": ["model","messages","tools","tool_choice","stream","stop", + "max_tokens","max_completion_tokens","response_format"], + "clamp": {"temperature": [0, 1]}, + "force": {"stream_options.include_usage": true}, // 仅当 stream=true + "never_default": ["temperature","top_p","n","presence_penalty","frequency_penalty"], + "couplings": [ + {"when": "n > 1", "require": "temperature > 0", "else": "reject:400"} + ] + }, + + "model_overrides": { // 参数合法性按模型分裂 + "kimi-k3": {"drop": ["temperature","top_p","n","presence_penalty","frequency_penalty"]}, + "kimi-k2.7-code": {"drop": ["temperature","top_p","n","presence_penalty","frequency_penalty"], + "thinking": {"toggle": "forbidden"}}, + "moonshot-v1-*": {"clamp": {"temperature": [0,1], "n": [1,5]}} + }, + + "thinking": { + "carrier": "reasoning_content", // reasoning_content | thinking_block | reasoning_item + "trust": "plaintext", // plaintext | fake_sig | opaque (§7.1) + "toggle": {"param": "thinking.type", "off": "disabled", "on": "enabled"}, + "effort": {"param": "reasoning_effort", "values": ["low","high","max"], + "upstream_validates": false}, // false = 网关自己校验枚举 + "replay": "optional", // required_on_tool | optional | forbidden + "hidden_prompt_tokens": 67 // 思考开启时上游注入的隐藏 system prompt + }, + + "models": { + "policy": "gateway_whitelist", // 永不依赖上游校验 + "reject_patterns": ["\\[.*\\]$"], // kimi-k3[1m] 之类 + "echo_trustworthy": false // 响应 model 字段能否用于记账 + }, + + "response": { + "envelope": "none | code_data | base_resp", + "strip_headers": ["msh-org-id","msh-uid","msh-project-id"], + "scrub_error_message": ["org-[a-z0-9]+]+>"], + "usage_dialect": "openai | anthropic | dual", + "stream_usage_position": ["top_level","choices0","last_chunk"] // 全部探测 + }, + + "errors": { + "shapes": ["openai","anthropic","gateway_cn"], // 解析时全部尝试 + "retry_class": {"engine_overloaded_error":"backoff", + "rate_limit_reached_error":"throttle", + "exceeded_current_quota_error":"stop"} + }, + + "limits": {"timeout_s": 900, "rpm": 3, "concurrency": 1, + "tier_probe_header": "msh-gid", "sdk_retry": "off"} +} +``` + +### 5.2 四家 Profile 关键值对照 + +这张表是转写引擎的**实际配置内容**,逐格都有实测或文档依据: + +| Profile 字段 | anthropic | openai/chat | deepseek/chat | deepseek/anthropic | kimi-platform/openai | kimi-platform/anthropic | kimi-coding/* | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `temperature` 域 | 0–1 | 0–2 | 0–2(越界 400) | **0–2**(比官方宽) | K3 系列**必须省略** | 静默忽略 | 同 platform | +| 越界处理 | clamp | 400 | 400 | 400 | **400** | 忽略 | 400 | +| `n>1` | 不支持 | 1–128 | 400 | — | 400 | — | 400(文案不同) | +| 关思考写法 | `thinking.type` 按代际三选一 | `reasoning_effort` | `thinking.type` **或** 顶层 `reasoning_effort:none` | `thinking.type`(**文档说的 `reasoning.effort` 无效**) | `thinking.type:disabled`(**文档说不可关,实测可关**) | 同左 | 同左 | +| effort 校验 | 枚举严格 | 按模型 | **不校验**(非法值静默跑默认) | 不校验 | **不校验** | 不校验 | 默认档 `high` | +| `tool_choice` 强制 | `any` / `tool` | `required` / 指定 | **思考开启时 400** | `any` **静默降级 auto**;指定 400 | 思考开启:`required` ✅ / 指定 400 | 指定 400 | 同 platform | +| 图片输入 | base64 / url | url / base64 | **400** | **静默换占位文本** | base64 / `ms://`(**http URL 400**) | 同左 | 同左 | +| `strict` | 支持 | 支持 | **beta,静默失效** | — | MFJS 自造方言 | — | — | +| 流式 usage 位置 | `message_delta`(**累计值**) | 独立 chunk(需 `include_usage`) | **挂在最后一个正常 chunk** | 规范 | **由 `include_usage` 决定两种位置** | — | 同 platform | +| 缓存 | 显式断点 ×4 | `prompt_cache_key` | 全自动,**64 token 粒度** | 同左 | 全自动(`cache_control` 空操作) | 同左 | 同左 | +| 思考隐藏 prompt | — | — | **~79 token** | ~79 | **~67 token**(K3;coding 侧 79) | 同左 | `kimi-for-coding` **0** | +| 超时 | 10 min(SDK 校验) | — | — | — | **900 s** | 900 s | 900 s | + +--- + +## 6. 请求改写管线(R1–R9) + +**顺序固定**。每一步的输入输出都是 IR,便于单测与快照对比。 + +### R1 入口归一 +方言 → 族 IR,执行 api-design §3.4 的不变量。此处唯一的新增是 §4.1 的两条。 + +### R2 模型解析 +1. 查网关白名单(`(tenant, logical_model) → 上游身份 + 上游模型名`)。**未命中 404**,绝不兜底。 +2. 按 `models.reject_patterns` 拒绝 `kimi-k3[1m]` 这类客户端侧后缀(Kimi 两个入口都 404,coding 侧更离谱:**返回 401 `invalid_authentication_error`**)。 +3. 记录 `requested_model`(下游传的)与 `routed_model`(我们实际发的)——两者都要进账单(§8.5)。 + +> 为什么必须自建白名单:Kimi coding 对任意模型名返回 200,未知名**静默 fallback 到 `kimi-for-coding`(256K)**,用户以为在用 K3(1M),长上下文任务会在毫无提示的情况下超限失败。DeepSeek 把 `deepseek-chat` 静默映射到 `deepseek-v4-flash`。这两类静默替换只有网关能拦。 + +### R3 族决策 +`入口族 == Profile.family` → 直出;否则过桥。桥的产物仍是 IR。 + +### R4 能力校验与降级 +按 api-design §7 的 L1/L2/L3 三级判定,产出 `degradation[]`。**L3 默认 400**,除非 `ztoken.allow_degradation` 放行。 + +四家新增的 L3 项: + +| L3 项 | 触发上游 | 理由 | +| --- | --- | --- | +| 图片/文档输入 | deepseek 全入口、kimi(http URL) | DeepSeek 会静默替换成 `[Unsupported Image]`,产生 200 + 错误答案 | +| 思考开启 + 强制指定函数 | kimi 全入口、deepseek | **不得靠关思考绕过**:Kimi K3 关思考 + 指定函数 → 200 但**模型幻觉出假数据**,比 400 危险得多 | +| `prefill` → Claude 4.6+ | anthropic | 上游明确禁止 | +| 动态工具 system 消息跨族 | 非 kimi 上游 | 无法表达 | + +### R5 参数消毒(本管线最重要的一步) +按 `params.policy = whitelist` 执行,顺序: + +``` +1. 丢弃不在 allow ∪ model_overrides.allow 中的字段 +2. 应用 model_overrides.drop ← Kimi K3 剥离全部采样参数 +3. clamp 到合法域 ← O→A 的 temperature >1 截为 1(不做线性缩放) +4. 校验 couplings ← n>1 必须 temperature>0(否则 Kimi 报的错无人能懂) +5. 应用 force ← 流式一律补 stream_options.include_usage=true +6. never_default 检查:确认没有任何一步给未传的参数补了默认值 +``` + +> 第 2 步是本项目 ROI 最高的一条规则:**对 Kimi 新模型剥离 `temperature/top_p/n/presence_penalty/frequency_penalty`**,一次同时解决"OpenAI 客户端默认 `temperature=0.7` 开箱 400"和"两个入口行为相反"两个问题——剥离后两侧行为一致。 +> +> 第 6 步是**断言**,不是转换。它防止后续维护者出于"对齐 OpenAI 默认值"的好意加回默认值——那会在 Kimi 上 400、在 moonshot-v1 上悄悄改变输出。 + +### R6 thinking 编排 +见 §7。 + +### R7 结构化输出与工具 schema +- `json_schema` **不做跨家 schema 翻译**。Profile 声明支持则透传,否则按能力矩阵降级(`json_object`,L3)。 +- DeepSeek 的 `json_object` **要求 prompt 里出现 "json" 字面量**,否则硬 400。网关的选择:**检测到缺失时报一个自解释的 400**(`"DeepSeek 要求 prompt 含 'json' 字样"`),不要自作主张往用户 prompt 里注入 —— 注入会破坏前缀缓存(§11),且改变了用户的输入。 +- Kimi 的 `strict` 走自造的 MFJS 方言,OpenAI 的 strict 有硬性 schema 子集要求(`additionalProperties:false` + 全字段 `required`)。**跨家时 `strict` 一律标 L3**。 + +### R8 缓存前缀保护 +在请求出网前做一次断言:本次请求的 `(tools, system, messages[0..k])` 序列化结果,与同会话上一次请求的对应前缀**逐字节相同**。不同则记 metric `cache_prefix_broken`。理由见 §11。 + +### R9 上游怪癖注入 +Profile 里的最后一批动作:header(`anthropic-version`、`X-DashScope-SSE`)、endpoint 选择(DeepSeek `prefix`/`FIM`/`strict` → `/beta`)、鉴权(key 前缀与 base URL **必须成对校验**,`sk-` 与 `sk-kimi-` 互换会得到两种不同文案的 401)。 + +--- + +## 7. 推理内容转写(L-D 的第一战场) + +四家都有"推理",但**它们不是同一种东西**。转写规则由**可信度等级**驱动,而不是 provider。 + +### 7.1 三级可信度 + +| 级别 | 含义 | 上游 | 网关可以做什么 | +| --- | --- | --- | --- | +| **T0 明文** | 就是一段文本,无校验 | DeepSeek `reasoning_content`、Kimi platform `thinking` block(**无 signature 字段**) | 自由读、改、截断、丢弃 | +| **T1 假签名** | 有 `signature` 字段但**服务端不校验** | DeepSeek Anthropic 入口(signature = 响应 id)、**Kimi coding**(12,946 字符的签名,实测替换成 `XXXX`、删除、伪造 thinking 文本**四种情况全部 200**) | 同 T0;**但要保留字段形状**,否则下游按 Anthropic 规范校验会失败 | +| **T2 真密文** | 密文即推理本体,改一字节即 400 | **Anthropic 官方**(`signature`)、**OpenAI**(`encrypted_content`) | **只能 opaque 原样搬运** | + +> 这个分级是本方案相对 v0.2 的关键修正。v0.2 假定"所有 Anthropic 族上游的 signature 都要当 opaque blob 搬运"——实测表明**只有 Anthropic 官方真机如此**。对 Kimi coding 和 DeepSeek,把它当 opaque 搬运会白白付出存储与信封成本。 +> +> **反向的安全提示**:Kimi coding 的 signature 只是"长得像密码学签名"。网关**不得**把它当作"上游已验证推理内容"的证据,也**不得**在自己的对外 API 里模仿这种假签名——下游若据此信任内容,就是被我们误导。 + +### 7.2 回传要求矩阵 + +| 上游 | 工具轮次内 | 普通多轮 | 不满足时 | +| --- | --- | --- | --- | +| Anthropic | **必须原样**(不可重排/编辑/部分丢弃,含 `redacted_thinking`) | 建议全传 | 400 `blocks ... cannot be modified` | +| OpenAI | **必须带回 reasoning item**(`store:false`/ZDR 时含 `encrypted_content`) | 建议 | 推理链断裂 | +| DeepSeek | **必须**(缺失直接 400) | 可省 | 400;**传空串 `""` 可过校验** ← 跨族丢失内容时的降级手段 | +| Kimi | 可省(文档说必须,**实测 200 正常**) | 可省 | — | + +### 7.3 四象限策略(保留 v0.2) + +| 入口族 | 上游族 | 策略 | +| --- | --- | --- | +| Claude | Claude | **原样透传**,不解包不重打包 | +| OpenAI | OpenAI | 原样透传 `encrypted_content` / `reasoning_content` | +| OpenAI | Claude | 摘要 → `reasoning_content`;T2 时真 signature 装信封放 `encrypted_content`,T0/T1 时**不装信封** | +| Claude | OpenAI | `reasoning_content` → `thinking.thinking`;T2 需要信封,T0/T1 直接留空 | + +信封格式 `ztk1.`,只走 body **绝不走 header**(Claude 4+ 的 signature 显著变长,header 截断后下一轮直接 400)。 + +### 7.4 三条实现硬约束 + +1. **切换上游模型时必须剥离 thinking block**——Anthropic thinking 与生成它的模型绑定,其他模型**静默忽略但照样计费**。故障转移路径尤其容易漏。 +2. **不得按 `type == "thinking"` 过滤**——会漏掉 `redacted_thinking`,直接破坏协议。 +3. **思考的隐藏成本要显式记账**——DeepSeek 思考模式注入 ~79 token、Kimi K3 ~67 token 的隐藏 system prompt,按输入价计费。Kimi K3 一句"说:好"开思考烧 310 completion tokens、关思考 11 个,**成本差 28 倍**。网关应把"思考开关"作为一等路由参数暴露给租户,并在账单里分列。 + +--- + +## 8. 响应改写管线(S1–S9) + +### S1 信封剥离 +- Kimi 的 `/v1/users/me/balance`、`/v1/tokenizers/estimate-token-count` 返回 `{"code":0,"data":{…},"scode":"0x0","status":true}`;而 `/v1/chat/completions` 和 `/v1/models` **没有**这层包装。**同一 API 两种信封并存**,解析器不能一刀切。 +- 预留 `base_resp` 通道(MiniMax 式 200 内嵌错误),Profile 里用 `response.envelope` 选择。 + +### S2 双通道错误判定 +HTTP 状态码 **+** body 业务码,两者任一为错即为错。见 §10。 + +### S3 隐私剥离(**上线前必须完成**) +| 泄漏点 | 动作 | +| --- | --- | +| `msh-org-id` / `msh-uid` / `msh-project-id` 响应头 | **白名单式剥离**(只放行网关自己合成的头 + `msh-request-id` 改名) | +| Kimi 429 错误 message 里的 `org-xxx` | 正则擦除后重写文案(**这是账号 + key 标识泄漏**,且错误 message 通常被原样转发,比响应头更危险) | +| Kimi 网关错误回显的 `ua` / `url` | 丢弃 | +| Kimi coding `/v1/me`(手机号、昵称、登录时间) | **绝不透传**;用作健康检查时只保留 `status` | + +> 附带的运维结论:**Kimi coding 的 key 不可作为共享凭据下发给第三方**——持有 key 即可读到账号主人的部分身份信息。 + +### S4 ID 重写 +工具调用 ID → 网关不透明 ID(§4.2)。 + +### S5 usage 归一 +统一到 canonical usage: + +```jsonc +{ + "input_total": int, // 完整输入,含缓存读与缓存写 + "input_cache_read": int, + "input_cache_write": int, + "output_total": int, // 含 reasoning tokens + "output_reasoning": int +} +``` + +四家的坑: + +| 陷阱 | 规则 | +| --- | --- | +| Anthropic `input_tokens` **不含缓存** | `input_total = input_tokens + cache_read + cache_creation`(直接用 `input_tokens` 缓存命中时可低报 99%) | +| **Kimi Anthropic 入口双套字段并存**且语义不同(`input_tokens:0` vs `prompt_tokens:88`) | Profile 标 `usage_dialect: dual`,**固定取 Anthropic 语义那套再补齐**,两套混用会差一个缓存量 | +| Kimi `$web_search` 的 token **不在响应 usage 里** | 需从 `tool_calls[].function.arguments` 内嵌的 `usage.total_tokens` 解析(首轮 usage 93,实际 6674) | +| DeepSeek 非思考模式**整个 `completion_tokens_details` 不出现** | 解析容忍缺失,不要当 0 之外的异常 | +| Kimi 首次请求**无缓存字段**(不是 0,是字段不存在) | 同上 | + +**网关计费以上游原始 usage 为准,不依赖任何投影结果。** + +### S6 模型三元记账 +记录 `requested_model` / `routed_model` / `echoed_model` 三个值。当 `echo_trustworthy: false`(Kimi coding、`moonshot-v1-auto`)时,**账单与对外响应都用 `routed_model`**,`echoed_model` 只进日志。 + +### S7 finish_reason / stop_reason 映射 +按 cross-provider-mapping §4。补充: +- `pause_turn` **不对外暴露**,网关内部完成服务端工具续跑循环后再返回终态。 +- DeepSeek 的 `insufficient_system_resource` → OpenAI 侧无对应,映射 `stop` + L2 warning,原值进 `ztoken.upstream_finish_reason`。 +- 预留 `sensitive`(智谱)→ `content_filter` 的映射通道。 + +### S8 族投影 +IR → 入口方言。两条易漏: +- Chat 响应的 `choices[].logprobs` **必须存在**(值可为 `null`)——OpenAI spec 列为 required。 +- Kimi OpenAI 入口错误体没有 `code`/`param`,对外补 `null` 以对齐严格客户端。 + +### S9 可观测头注入 +``` +X-Ztoken-Upstream: kimi-platform/openai:kimi-k3 (family=openai, bridged=false) +X-Ztoken-Degraded: temperature=dropped(L2,model-fixed), strict=ignored(L3,allowed) +X-Ztoken-Defaulted: max_tokens=4096 +X-Ztoken-Request-Id: … +``` + +--- + +## 9. 流式转写 + +### 9.1 对外保证 +1. 增量语义(上游全量累积时网关差分)。 +2. 事件顺序合法(Claude 族三段式;OpenAI 族首 chunk 带 role、末尾 `[DONE]`)。 +3. usage 位置固定。 +4. 中途错误可表达。 + +### 9.2 usage 位置探测(四家四种) + +| 上游 | 位置 | +| --- | --- | +| OpenAI | 独立 chunk,`choices: []`(需 `include_usage`) | +| DeepSeek | **挂在带 `finish_reason` 的最后一个正常 chunk 上** ← 按 OpenAI 语义等独立 chunk 会永远拿不到 | +| Kimi | 不传 `include_usage` → `choices[0].usage`;传了 → 顶层 + `choices:[]`(**且只有传了才带 `cached_tokens`**) | +| Anthropic | `message_delta.usage`,**累计值**(取末值,不累加) | + +**统一策略**:R5 强制补 `include_usage=true`;解析时**三个位置全部探测**,取最后一次非空。 + +### 9.3 跨族流式状态机 +- **O→A**:OpenAI chunk 无"块结束"信号,靠 `tool_calls[].index` 变化与 `finish_reason` 推断边界,在文本↔工具切换时补发 `content_block_stop` / `content_block_start`。 +- **A→O**:`content_block_start(text)` 吞掉;`input_json_delta.partial_json` 追加到 `arguments`;`thinking_delta` → `delta.reasoning_content`;**`signature_delta` 缓冲到 block 结束**,T2 时装信封随末尾 chunk 下发,T0/T1 时直接丢弃。 + +### 9.4 解析器必须容忍的形态 +- Anthropic `fallback` block:只有 `content_block_start` + `content_block_stop`,**中间没有任何 delta**。 +- Anthropic `display:"omitted"`:只有一个 `signature_delta`,没有 `thinking_delta`。 +- 未知事件类型:**优雅忽略**(Anthropic versioning policy 明确要求)。 +- DeepSeek delta 中未用字段是显式 `null` 而非省略。 +- Kimi 每个 chunk 带 `system_fingerprint`,非流式响应则没有。 +- Kimi coding 的 SSE 事件名**没有空格**(`event:message_start`)——分隔符解析不能假设有空格。 +- `data: [DONE]` 非 JSON,单独识别。 + +### 9.5 中途错误 +已发 200 后无法改状态码:Claude 族发 `event: error`;OpenAI 族发含 `error` 字段的 data chunk 再 `[DONE]`(无官方规范,属我们的事实约定,需写进对外文档)。 + +--- + +## 10. 错误与重试归一 + +### 10.1 上游错误体的三种形状(都要能解析) +```jsonc +{"error":{"message":"…","type":"…"}} // OpenAI 风格 +{"type":"error","error":{"type":"…","message":"…"},"request_id":"…"} // Anthropic 风格 +{"code":5,"error":"url.not_found","message":"没找到对象","ua":"…"} // Kimi 网关风格 +``` +第三种里 **`error` 从 object 变成了 string**——假设 `error` 恒为 object 的解析器会抛类型异常。413 也可能返回**非 JSON**(Anthropic 侧由 Cloudflare 在到达 API 前返回)。 + +### 10.2 状态码归一 +| 上游 | 对外 | +| --- | --- | +| DeepSeek 402 余额不足 | 429 + `insufficient_quota` | +| DeepSeek 422 | 400 | +| Anthropic 529 overloaded | 503 | +| **Kimi coding 模型不存在返回的 401** | **404 `model_not_found`** ← 绝不能触发凭据刷新逻辑 | +| MiniMax 200 + `base_resp≠0` | 真实状态码 | + +### 10.3 429 必须按 type 分流(三种处置完全相反) +| type | 处置 | +| --- | --- | +| `engine_overloaded_error` | 退避重试 | +| `rate_limit_reached_error` | 降并发 | +| `exceeded_current_quota_error` | **停止**(欠费,重试纯属打爆上游) | + +### 10.4 不可重试清单(会加倍烧钱或放大故障) +- **Kimi "200 OK + 空 content + `finish_reason:length`"**——这是思考烧光 token 的正常返回,重试只会再烧一遍。 +- DeepSeek `json_object` 偶发空 content——可重试,但要限次并记 metric。 +- 任何 `exceeded_current_quota_error` / 402。 +- **关闭上游 SDK 的自动重试**——它会把 1 次请求放大成 2–3 次真实调用,直接吃掉 RPM 配额(Kimi Tier 0 只有 3 RPM)。 + +### 10.5 限流治理 +- Kimi **没有 `x-ratelimit-*` 头**,配额只能本地计数 + 429 反馈,做不了提前避让;`retry-after` 是标准头,直接用。 +- 用 `msh-gid` 响应头**自动探测账号档位**并设置本地并发上限(Tier 0 = 3 RPM / 1 并发,实测严格)。 +- 上游超时 **≥900 s**(Kimi 服务端 900 s 才返回 504,网关先断会白烧已消耗的 token)。 +- 对外**合成自己的 `x-ratelimit-*`**,不要透传上游的——上游额度和下游租户额度不是一回事。 + +--- + +## 11. 缓存稳定性:一条被低估的转写约束 + +Kimi K3 的输入价差是 **10 倍**(命中 ¥2 / 未命中 ¥20 每 1M)。DeepSeek 缓存粒度 64 token、即时生效。Anthropic 是显式断点且**改动某层会使该层及之后所有层失效**(顺序 `tools → system → messages`)。 + +**对转写引擎的直接约束**: + +| 禁止的操作 | 原因 | +| --- | --- | +| 往 prompt 里注入内容(时间戳、system 提示、"json" 字样) | 前缀变化 → 全量 miss | +| 规范化空白 / 重排消息 / 合并相邻文本块 | 同上 | +| 把 `content` 数组压成字符串 | 前缀变化,且 Kimi 多模态下明确禁止 | +| 动态改写 tools 定义顺序 | tools 是缓存第一层,失效面最大 | + +**允许的**:合并连续同 role 消息(AnthIR 不变量 3)——但**必须在会话首轮就确定策略并保持一致**,不能有的轮次合有的轮次不合。 + +> 落地建议:R8 的前缀断言接一个 metric,任何一次 `cache_prefix_broken` 都应当能定位到是管线哪一步改写导致的。这是转写引擎唯一直接与钱挂钩的可观测指标。 + +--- + +## 12. 测试口径 + +转写引擎的正确性无法靠"跑通一次"证明,需要三类测试: + +1. **黄金样本(golden files)**:每个上游身份 × 每种消息形态(纯文本 / 多模态 / 工具轮 / 思考轮 / 流式),存请求-响应对。管线改动后做快照 diff。**四家的实测原始响应体已在 docs 里,直接作为首批样本。** +2. **往返恒等**:`O→A→O` 与 `A→O→A` 的**可逆部分必须恒等**。不可逆部分(`is_error`、thinking 交错顺序、server tools)需在测试里**显式列举断言其丢失**——防止有人"顺手修好"后反而破坏了降级标记。 +3. **上游契约探针**(定期跑,不进 CI 主流程):把 §5.2 表里的每一格变成一个真实请求断言。**四家的文档共有 13+ 条被实测推翻**(Kimi 10 条、DeepSeek 3 条),且两个方向的错都有——文档说不能实际能、文档说能实际不能。Profile 是照实测写的,探针就是它的回归网。 + +--- + +## 13. 实现顺序 + +| # | 阶段 | 产出 | 备注 | +| --- | --- | --- | --- | +| 1 | 双 IR + 三入口方言层 | echo adapter 打通 | 纯逻辑,可完整单测 | +| 2 | **Profile 装载 + R5 参数消毒** | 白名单/clamp/coupling 引擎 | **优先级高于跨族桥**——它单独就能让 Kimi/DeepSeek 从"开箱 400"变成可用 | +| 3 | 能力矩阵 + 降级引擎(L1/L2/L3) | `X-Ztoken-Degraded` | 纯逻辑 | +| 4 | 第一个上游:`deepseek/chat` | 同族直出 | 最简单,验证管线骨架 | +| 5 | 跨族桥(非流式) | O↔A | 用往返恒等测试守住 | +| 6 | 流式规范层 | usage 三处探测 + 事件容错 | 最易出 bug,先写对照测试 | +| 7 | 跨族桥(流式) | 状态机 | **全项目最难**,单独设计 | +| 8 | `anthropic` adapter | thinking T2 原样透传 | 唯一真 signature 路径 | +| 9 | `kimi-platform` + `kimi-coding` | 两份独立 Profile | 含 §8 S3 隐私剥离,**上线前必须完成** | + +第 5、7 步是技术核心;第 2、9 步是"能不能对外提供服务"的门槛。 + +**建议的 Java 模块划分**(对应当前空项目 `com.bytenya.ztoken`): + +``` +ztoken.ir — OaiIR / AnthIR 数据模型(不可变对象 + Jackson) +ztoken.entry — chat / responses / messages 三个方言层 +ztoken.bridge — 跨族桥(非流式 + 流式状态机) +ztoken.profile — Profile 装载与校验(YAML/JSON 声明) +ztoken.pipeline — R1–R9 / S1–S9,每步一个可单测的 Stage +ztoken.upstream — HTTP 客户端、鉴权、超时、重试分类 +ztoken.stream — SSE 编解码与规范化 +ztoken.observe — 降级记录、usage 归一、计费事件 +``` + +--- + +## 14. 开放问题 + +1. **`previous_response_id` / `conversation`**:当前 400 拒绝(网关无状态)。若要支持,建议信封化由客户端持有。 +2. **L3 默认报错**是产品取向——会让"能跑就行"的客户端在我们这里失败、在别家静默成功。我认为值得(DeepSeek 的 `[Unsupported Image]` 就是反面教材),但需要产品确认。 +3. **多模态代下载转码**:Kimi 不收 http 图片 URL。代下载会让网关变成任意 URL 的抓取代理(SSRF 风险),建议默认报错 + 可选开关。 +4. **Kimi coding 的用量核算**:该上游**没有任何余额/用量端点**,只能自行累加 usage,无官方口径可对账。多租户计费需接受这个误差。 +5. **thinking 可信度 T1 的对外表达**:我们知道 Kimi coding 的 signature 是假的,是否应该在响应里向下游标注?标注会泄漏上游身份,不标注则下游可能误信。 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..f8e8efb --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Aug 12 21:50:11 CST 2026 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${0##*/} + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..df6116a --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "zend-token" \ No newline at end of file diff --git a/src/main/java/com/bytenya/ztoken/Main.java b/src/main/java/com/bytenya/ztoken/Main.java new file mode 100644 index 0000000..0721635 --- /dev/null +++ b/src/main/java/com/bytenya/ztoken/Main.java @@ -0,0 +1,17 @@ +package com.bytenya.ztoken; + +//TIP To Run code, press or +// click the icon in the gutter. +public class Main { + static void main() { + //TIP Press with your caret at the highlighted text + // to see how IntelliJ IDEA suggests fixing it. + IO.println(String.format("Hello and welcome!")); + + for (int i = 1; i <= 5; i++) { + //TIP Press to start debugging your code. We have set one breakpoint + // for you, but you can always add more by pressing . + IO.println("i = " + i); + } + } +}