Prechádzať zdrojové kódy

Merge pull request #218 from Narcooo/feat/llm-providers

统一 LLM 配置解析与 Studio CLI 边界
hanjun fang 6 mesiacov pred
rodič
commit
74d260811a
100 zmenil súbory, kde vykonal 5653 pridanie a 911 odobranie
  1. 104 28
      README.md
  2. 25 0
      packages/cli/src/__tests__/llm-overrides.test.ts
  3. 15 2
      packages/cli/src/__tests__/publish-package.test.ts
  4. 28 2
      packages/cli/src/commands/config.ts
  5. 17 13
      packages/cli/src/commands/doctor.ts
  6. 8 0
      packages/cli/src/program.ts
  7. 69 3
      packages/cli/src/utils.ts
  8. 1 1
      packages/core/package.json
  9. 11 11
      packages/core/src/__tests__/architect.test.ts
  10. 5 5
      packages/core/src/__tests__/chapter-analyzer.test.ts
  11. 58 3
      packages/core/src/__tests__/config-loader.test.ts
  12. 3 3
      packages/core/src/__tests__/continuity.test.ts
  13. 395 0
      packages/core/src/__tests__/effective-llm-config.test.ts
  14. 1 1
      packages/core/src/__tests__/length-normalizer.test.ts
  15. 93 0
      packages/core/src/__tests__/list-models.test.ts
  16. 31 0
      packages/core/src/__tests__/llm-env.test.ts
  17. 1 1
      packages/core/src/__tests__/pipeline-agent.test.ts
  18. 1 1
      packages/core/src/__tests__/pipeline-runner-memory-sync.test.ts
  19. 7 12
      packages/core/src/__tests__/pipeline-runner.test.ts
  20. 64 0
      packages/core/src/__tests__/probe.test.ts
  21. 85 68
      packages/core/src/__tests__/provider.test.ts
  22. 34 0
      packages/core/src/__tests__/providers-group.test.ts
  23. 83 0
      packages/core/src/__tests__/providers-lookup.test.ts
  24. 155 0
      packages/core/src/__tests__/providers-schema.test.ts
  25. 5 5
      packages/core/src/__tests__/reviser.test.ts
  26. 1 1
      packages/core/src/__tests__/scheduler.test.ts
  27. 71 0
      packages/core/src/__tests__/secrets-migration.test.ts
  28. 4 4
      packages/core/src/__tests__/service-presets-regression.test.ts
  29. 0 3
      packages/core/src/__tests__/state-validator-agent.test.ts
  30. 2 2
      packages/core/src/__tests__/temperature-constraints.test.ts
  31. 61 0
      packages/core/src/__tests__/verify-service.test.ts
  32. 8 8
      packages/core/src/__tests__/writer.test.ts
  33. 4 0
      packages/core/src/index.ts
  34. 102 47
      packages/core/src/llm/provider.ts
  35. 42 0
      packages/core/src/llm/providers/endpoints/ai360.ts
  36. 82 0
      packages/core/src/llm/providers/endpoints/anthropic.ts
  37. 30 0
      packages/core/src/llm/providers/endpoints/astronCodingPlan.ts
  38. 28 0
      packages/core/src/llm/providers/endpoints/baichuan.ts
  39. 65 0
      packages/core/src/llm/providers/endpoints/bailian.ts
  40. 30 0
      packages/core/src/llm/providers/endpoints/bailianCodingPlan.ts
  41. 22 0
      packages/core/src/llm/providers/endpoints/custom.ts
  42. 34 0
      packages/core/src/llm/providers/endpoints/deepseek.ts
  43. 41 0
      packages/core/src/llm/providers/endpoints/giteeai.ts
  44. 43 0
      packages/core/src/llm/providers/endpoints/githubCopilot.ts
  45. 28 0
      packages/core/src/llm/providers/endpoints/glmCodingPlan.ts
  46. 51 0
      packages/core/src/llm/providers/endpoints/google.ts
  47. 42 0
      packages/core/src/llm/providers/endpoints/hunyuan.ts
  48. 72 0
      packages/core/src/llm/providers/endpoints/infiniai.ts
  49. 28 0
      packages/core/src/llm/providers/endpoints/internlm.ts
  50. 24 0
      packages/core/src/llm/providers/endpoints/kimiCodingPlan.ts
  51. 25 0
      packages/core/src/llm/providers/endpoints/longcat.ts
  52. 39 0
      packages/core/src/llm/providers/endpoints/minimax.ts
  53. 28 0
      packages/core/src/llm/providers/endpoints/minimaxCodingPlan.ts
  54. 40 0
      packages/core/src/llm/providers/endpoints/mistral.ts
  55. 30 0
      packages/core/src/llm/providers/endpoints/modelscope.ts
  56. 39 0
      packages/core/src/llm/providers/endpoints/moonshot.ts
  57. 21 0
      packages/core/src/llm/providers/endpoints/newapi.ts
  58. 73 0
      packages/core/src/llm/providers/endpoints/ollama.ts
  59. 77 0
      packages/core/src/llm/providers/endpoints/openai.ts
  60. 30 0
      packages/core/src/llm/providers/endpoints/opencodeCodingPlan.ts
  61. 87 0
      packages/core/src/llm/providers/endpoints/openrouter.ts
  62. 86 0
      packages/core/src/llm/providers/endpoints/ppio.ts
  63. 32 0
      packages/core/src/llm/providers/endpoints/qiniu.ts
  64. 45 0
      packages/core/src/llm/providers/endpoints/sensenova.ts
  65. 126 0
      packages/core/src/llm/providers/endpoints/siliconcloud.ts
  66. 33 0
      packages/core/src/llm/providers/endpoints/spark.ts
  67. 35 0
      packages/core/src/llm/providers/endpoints/stepfun.ts
  68. 25 0
      packages/core/src/llm/providers/endpoints/tencentcloud.ts
  69. 52 0
      packages/core/src/llm/providers/endpoints/volcengine.ts
  70. 42 0
      packages/core/src/llm/providers/endpoints/volcengineCodingPlan.ts
  71. 106 0
      packages/core/src/llm/providers/endpoints/wenxin.ts
  72. 34 0
      packages/core/src/llm/providers/endpoints/xai.ts
  73. 26 0
      packages/core/src/llm/providers/endpoints/xiaomimimo.ts
  74. 34 0
      packages/core/src/llm/providers/endpoints/zeroone.ts
  75. 61 0
      packages/core/src/llm/providers/endpoints/zhipu.ts
  76. 75 0
      packages/core/src/llm/providers/index.ts
  77. 70 0
      packages/core/src/llm/providers/lookup.ts
  78. 33 0
      packages/core/src/llm/providers/probe.ts
  79. 86 0
      packages/core/src/llm/providers/types.ts
  80. 101 0
      packages/core/src/llm/providers/verify.ts
  81. 29 2
      packages/core/src/llm/secrets.ts
  82. 86 44
      packages/core/src/llm/service-presets.ts
  83. 1 1
      packages/core/src/llm/service-resolver.ts
  84. 4 9
      packages/core/src/models/project.ts
  85. 0 1
      packages/core/src/pipeline/runner.ts
  86. 23 291
      packages/core/src/utils/config-loader.ts
  87. 523 0
      packages/core/src/utils/effective-llm-config.ts
  88. 40 0
      packages/core/src/utils/llm-endpoint-auth.ts
  89. 74 0
      packages/core/src/utils/llm-env.ts
  90. 400 20
      packages/studio/src/api/server.test.ts
  91. 232 68
      packages/studio/src/api/server.ts
  92. 12 13
      packages/studio/src/components/ServiceConfigSourceCard.tsx
  93. 25 0
      packages/studio/src/constants/service-groups.ts
  94. 22 18
      packages/studio/src/pages/ChatPage.tsx
  95. 20 39
      packages/studio/src/pages/ServiceDetailPage.tsx
  96. 218 45
      packages/studio/src/pages/ServiceListPage.tsx
  97. 42 0
      packages/studio/src/pages/chat-page-state.test.ts
  98. 19 0
      packages/studio/src/pages/chat-page-state.ts
  99. 52 51
      packages/studio/src/pages/service-detail-state.test.ts
  100. 26 85
      packages/studio/src/pages/service-detail-state.ts

+ 104 - 28
README.md

@@ -72,7 +72,40 @@ inkos interact --json --message "继续当前书,但把节奏再收紧一点"
 
 ### 配置
 
-**方式一:全局配置(推荐,只需一次)**
+InkOS 2.0 将 LLM 配置分成两条清晰路径:**Studio 用可视化服务配置**,**CLI / daemon / 部署环境支持 env 覆盖**。两者不会互相污染。
+
+#### 方式一:Studio 服务配置(推荐)
+
+适合本地写作、Web 工作台和可视化管理。
+
+```bash
+inkos init my-novel
+cd my-novel
+inkos
+```
+
+打开 Studio 后进入「模型配置」:
+
+1. 选择服务商,例如 Google Gemini、Moonshot、MiniMax、智谱、百炼或自定义端点。
+2. 粘贴 API Key,点击「测试连接」。
+3. 选择可用模型,保存配置。
+4. 回到书籍页面开始写作。
+
+Studio 运行时只使用:
+
+```text
+provider bank 默认值
+→ inkos.json 里的 services / 当前 service / defaultModel
+→ .inkos/secrets.json 里的 service API Key
+```
+
+即使检测到 `~/.inkos/.env` 或项目 `.env`,Studio 也只会展示提示,不会用 env 覆盖 service、model、baseUrl 或 API Key。API Key 存在项目内的 `.inkos/secrets.json`,不会写进 `inkos.json`。
+
+#### 方式二:CLI / daemon / 部署环境的 env 配置
+
+适合终端批处理、服务器部署、CI、Docker、守护进程和一次性切模型。
+
+全局 env:
 
 ```bash
 inkos config set-global \
@@ -80,38 +113,49 @@ inkos config set-global \
   --base-url <API 地址> \
   --api-key <你的 API Key> \
   --model <模型名>
-
-# provider: openai / anthropic / custom(兼容 OpenAI 格式的中转站选 custom)
-# base-url: 你的 API 提供商地址
-# api-key: 你的 API Key
-# model: 你的模型名称
 ```
 
-配置保存在 `~/.inkos/.env`,所有项目共享。之后新建项目不用再配。
-
-**方式二:项目级 `.env`**
+也可以手动写 `~/.inkos/.env` 或项目 `.env`:
 
 ```bash
-inkos init my-novel     # 初始化项目
-# 编辑 my-novel/.env
+INKOS_LLM_PROVIDER=custom
+INKOS_LLM_BASE_URL=https://api.moonshot.cn/v1
+INKOS_LLM_API_KEY=sk-...
+INKOS_LLM_MODEL=kimi-k2.5
+
+# 可选
+INKOS_LLM_SERVICE=moonshot                         # 推荐写;不写时会尽量从 baseUrl 自动识别
+INKOS_LLM_TEMPERATURE=0.7
+INKOS_LLM_THINKING_BUDGET=0
+INKOS_DEFAULT_LANGUAGE=zh
+INKOS_LLM_EXTRA_top_p=0.9
 ```
 
-```bash
-# 必填
-INKOS_LLM_PROVIDER=                               # openai / anthropic / custom(兼容 OpenAI 接口的都选 custom)
-INKOS_LLM_BASE_URL=                               # API 地址(支持中转站、智谱、Gemini 等)
-INKOS_LLM_API_KEY=                                 # API Key
-INKOS_LLM_MODEL=                                   # 模型名
+CLI 合成顺序:
 
-# 可选
-# INKOS_LLM_TEMPERATURE=0.7                       # 温度
-# INKOS_LLM_MAX_TOKENS=8192                        # 最大输出 token
-# INKOS_LLM_THINKING_BUDGET=0                      # Anthropic 扩展思考预算
+```text
+Studio/project service 配置
+→ .inkos/secrets.json service key
+→ global ~/.inkos/.env
+→ project .env
+→ 当前进程环境变量
+→ CLI 参数
 ```
 
-项目 `.env` 会覆盖全局配置。不需要覆盖时可以不写。
+也就是说,CLI 默认可以复用 Studio 配好的服务和密钥;如果 env 里声明了 `INKOS_LLM_SERVICE`、`INKOS_LLM_MODEL`、`INKOS_LLM_BASE_URL` 或 `INKOS_LLM_API_KEY`,则作为覆盖层生效。旧 env 只写 `baseUrl + model + apiKey` 也能继续用,InkOS 会尽量从 baseUrl 反推 service。
 
-**方式三:多模型路由(可选)**
+一次性指定服务或模型:
+
+```bash
+inkos write next --service google --model gemini-2.5-flash
+inkos write next --service moonshot --model kimi-k2.5 --no-stream
+inkos agent "继续写下一章" --api-key-env MOONSHOT_API_KEY
+inkos doctor --service minimaxCodingPlan --model MiniMax-M2.7
+```
+
+`--service` 会从 provider bank 自动推导 baseUrl、协议和兼容策略;`--model` 必须属于最终 service,否则会直接报错,避免把 Kimi 模型发到 Gemini 这类错配问题。
+
+#### 方式三:多模型路由(可选)
 
 给不同 Agent 分配不同模型,按需平衡质量与成本:
 
@@ -124,6 +168,31 @@ inkos config show-models        # 查看当前路由
 
 未单独配置的 Agent 自动使用全局模型。
 
+#### 配置排查
+
+```bash
+inkos doctor
+```
+
+`doctor` 会显示当前 effective config mode、service/model/API Key 来源,并尝试 API 连通性。常见模式:
+
+| 模式 | 含义 |
+|------|------|
+| `studio-project` | Studio 运行时:只使用 Studio/project 配置和 secrets |
+| `cli-project` | CLI 运行时:以 Studio 配置为基础,再叠加 env 和 CLI 参数 |
+| `legacy-env` | 旧 env 模式:兼容老项目的纯 `.env` 配置 |
+
+如果服务测试失败,优先检查服务商、模型和协议是否匹配。Google Gemini 的 AI Studio API Key 可用于 Gemini OpenAI-compatible endpoint;InkOS 会自动禁用 Google 不支持的 OpenAI `store` 参数。MiniMax / MiniMax CodingPlan 会优先使用可工作的非流式 transport,避免流式返回 usage 但无正文的问题。
+
+### v2.0 LLM 配置更新
+
+- **Studio / CLI 配置隔离**:Studio 固定使用服务页配置和 `.inkos/secrets.json`;CLI、daemon、部署环境支持 env 覆盖和一次性命令参数。
+- **Provider bank 能力表**:内置 Google Gemini、Moonshot、MiniMax、智谱、百炼、DeepSeek、硅基流动、PPIO、OpenRouter、Ollama、CodingPlan 等服务的 baseUrl、协议、模型和兼容策略。
+- **模型归属校验**:`--service google --model kimi-k2.5` 这类错配会直接报错,避免把请求发到错误服务商。
+- **Google Gemini 兼容修复**:AI Studio API Key 可直接用于 Gemini OpenAI-compatible endpoint,InkOS 会自动禁用 Google 不支持的 OpenAI `store` 参数。
+- **MiniMax transport 探测**:MiniMax / MiniMax CodingPlan 自动使用可工作的 transport,规避流式 usage 正常但正文为空的问题。
+- **旧 env 兼容**:老的 `INKOS_LLM_BASE_URL + INKOS_LLM_MODEL + INKOS_LLM_API_KEY` 仍可用于 CLI;没有 `INKOS_LLM_SERVICE` 时会尝试从 baseUrl 反推服务商。
+
 ### v1.2 更新
 
 **统一交互内核 + TUI 仪表盘 + Studio 助手**
@@ -214,7 +283,7 @@ inkos compose chapter 吞天魔帝
 
 ### 本地模型兼容
 
-支持任何 OpenAI 兼容接口(`--provider custom`)。Stream 自动降级——中转站不支持 SSE 时自动回退 sync。Fallback 解析器处理小模型不规范输出,流中断时自动恢复部分内容。
+支持任何 OpenAI 兼容接口(Studio 里新增自定义服务,或 CLI 使用 `--provider custom` / `INKOS_LLM_PROVIDER=custom`)。服务测试会尝试不同协议和流式开关组合,并保存或提示可用 transport。Fallback 解析器处理小模型不规范输出,流中断时自动恢复部分内容。
 
 ### 可靠性保障
 
@@ -222,7 +291,7 @@ inkos compose chapter 吞天魔帝
 
 伏笔系统使用 Zod schema 校验——`lastAdvancedChapter` 必须是整数,`status` 只能是 open/progressing/deferred/resolved。LLM 输出的 JSON delta 在写入前经过 `applyRuntimeStateDelta` 做 immutable 更新 + `validateRuntimeState` 结构校验。坏数据直接拒绝,不会滚雪球。
 
-用户设置的 `INKOS_LLM_MAX_TOKENS` 作为全局上限生效,`llm.extra` 中的保留键(max_tokens、temperature 等)被自动过滤,防止意外覆盖。
+模型输出上限由 provider bank 的模型卡管理;`llm.extra` / `INKOS_LLM_EXTRA_*` 中的保留键(max_tokens、temperature、model、messages、stream 等)会被自动过滤,防止意外覆盖核心请求参数。
 
 ---
 
@@ -377,13 +446,13 @@ inkos agent "先扫描市场趋势,然后根据结果创建一本新书"
 | `inkos export [id]` | 导出书籍(`--format txt/md/epub`、`--output <path>`、`--approved-only`) |
 | `inkos radar scan` | 扫描平台趋势 |
 | `inkos fanfic init` | 从原作素材创建同人书(`--from`、`--mode canon/au/ooc/cp`) |
-| `inkos config set-global` | 设置全局 LLM 配置(~/.inkos/.env) |
+| `inkos config set-global` | 设置 CLI / daemon / 部署环境的全局 LLM env(`~/.inkos/.env`) |
 | `inkos config show-global` | 查看全局配置 |
 | `inkos config set/show` | 查看/更新项目配置 |
 | `inkos config set-model <agent> <model>` | 为指定 agent 设置模型覆盖(`--base-url`、`--provider`、`--api-key-env` 支持多 Provider 路由) |
 | `inkos config remove-model <agent>` | 移除 agent 模型覆盖(回退到默认) |
 | `inkos config show-models` | 查看当前模型路由 |
-| `inkos doctor` | 诊断配置问题(含 API 连通性测试 + 提供商兼容性提示) |
+| `inkos doctor` | 诊断配置问题(显示 effective config mode、来源、API 连通性和提供商兼容性提示) |
 | `inkos detect [id] [n]` | AIGC 检测(`--all` 全部章节,`--stats` 统计) |
 | `inkos style analyze <file>` | 分析参考文本提取文风指纹 |
 | `inkos style import <file> [id]` | 导入文风指纹到指定书 |
@@ -391,11 +460,18 @@ inkos agent "先扫描市场趋势,然后根据结果创建一本新书"
 | `inkos import chapters [id] --from <path>` | 导入已有章节续写(`--split`、`--resume-from`) |
 | `inkos analytics [id]` / `inkos stats [id]` | 书籍数据分析(审计通过率、高频问题、章节排名、token 用量) |
 | `inkos update` | 更新到最新版本 |
-| `inkos studio` | 启动 Web 工作台(`-p` 指定端口,默认 4567) |
+| `inkos studio` / `inkos` | 启动 Web 工作台(`-p` 指定端口,默认 4567;Studio 使用服务页配置,不使用 env 覆盖) |
 | `inkos up / down` | 启动/停止守护进程(`-q` 静默模式,自动写入 `inkos.log`) |
 
 `[id]` 参数在项目只有一本书时可省略,自动检测。所有命令支持 `--json` 输出结构化数据。`draft` / `write next` / `plan chapter` / `compose chapter` 支持 `--context` 传入创作指导,`--words` 覆盖每章目标字数。`book create` 支持 `--brief <file>` 传入创作简报(你的脑洞/设定文档),Architect 会基于此生成设定而非凭空创作。`plan chapter` / `compose chapter` 不要求在线 LLM,可在配置 API Key 之前先检查输入治理结果。
 
+CLI 运行时还支持一次性 LLM 覆盖参数:`--service`、`--model`、`--api-key-env`、`--base-url`、`--api-format <chat|responses>`、`--stream`、`--no-stream`。例如:
+
+```bash
+inkos write next --service google --model gemini-2.5-flash
+inkos up --service moonshot --model kimi-k2.5 --api-key-env MOONSHOT_API_KEY
+```
+
 ## 路线图
 
 - [x] ~~`packages/studio` Web UI 工作台(Vite + React + Hono)~~ — 已发布,`inkos studio` 启动

+ 25 - 0
packages/cli/src/__tests__/llm-overrides.test.ts

@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+import { parseLLMOverridesFromArgv } from "../utils.js";
+
+describe("parseLLMOverridesFromArgv", () => {
+  it("parses service/model/api key env and transport overrides from CLI argv", () => {
+    expect(parseLLMOverridesFromArgv([
+      "write",
+      "next",
+      "--service",
+      "google",
+      "--model=gemini-2.5-flash",
+      "--api-key-env",
+      "GOOGLE_API_KEY",
+      "--api-format",
+      "chat",
+      "--no-stream",
+    ])).toEqual({
+      service: "google",
+      model: "gemini-2.5-flash",
+      apiKeyEnv: "GOOGLE_API_KEY",
+      apiFormat: "chat",
+      stream: false,
+    });
+  });
+});

+ 15 - 2
packages/cli/src/__tests__/publish-package.test.ts

@@ -16,6 +16,19 @@ const sourceStudioPackageJsonPromise = readFile(resolve(studioDir, "package.json
   JSON.parse(raw),
 );
 
+function tarForceLocalArgs(): string[] {
+  if (process.platform !== "win32") return [];
+  try {
+    const version = execFileSync("tar", ["--version"], {
+      encoding: "utf-8",
+      stdio: ["ignore", "pipe", "ignore"],
+    });
+    return version.includes("GNU tar") ? ["--force-local"] : [];
+  } catch {
+    return [];
+  }
+}
+
 async function packPackage(packageDir: string, packDir: string) {
   const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
   execFileSync(npmCmd, ["pack", "--pack-destination", packDir], {
@@ -35,7 +48,7 @@ async function packPackage(packageDir: string, packDir: string) {
 
 async function extractPackedPackageJson(packageDir: string, packDir: string) {
   const tarballPath = await packPackage(packageDir, packDir);
-  const tarArgs = process.platform === "win32" ? ["--force-local", "-xOf"] : ["-xOf"];
+  const tarArgs = [...tarForceLocalArgs(), "-xOf"];
   return execFileSync("tar", [...tarArgs, tarballPath, "package/package.json"], {
     cwd: workspaceRoot,
     encoding: "utf-8",
@@ -242,7 +255,7 @@ describe.sequential("publish packaging", () => {
 
     try {
       const tarballPath = await packPackage(studioDir, packDir);
-      const tarArgs = process.platform === "win32" ? ["--force-local", "-tf"] : ["-tf"];
+      const tarArgs = [...tarForceLocalArgs(), "-tf"];
       const archiveListing = execFileSync("tar", [...tarArgs, tarballPath], {
         cwd: workspaceRoot,
         encoding: "utf-8",

+ 28 - 2
packages/cli/src/commands/config.ts

@@ -2,6 +2,7 @@ import { Command } from "commander";
 import { readFile, writeFile, mkdir } from "node:fs/promises";
 import { join } from "node:path";
 import { findProjectRoot, log, logError, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH } from "../utils.js";
+import { listModelsForService } from "@actalk/inkos-core";
 
 export const configCommand = new Command("config")
   .description("Manage project configuration");
@@ -23,7 +24,7 @@ configCommand
 
       const KNOWN_KEYS = new Set([
         "llm.provider", "llm.baseUrl", "llm.model", "llm.temperature",
-        "llm.maxTokens", "llm.thinkingBudget", "llm.apiFormat", "llm.stream",
+        "llm.thinkingBudget", "llm.apiFormat", "llm.stream",
         "inputGovernanceMode",
         "daemon.schedule.radarCron", "daemon.schedule.writeCron",
         "daemon.maxConcurrentBooks", "daemon.chaptersPerCycle",
@@ -108,7 +109,6 @@ configCommand
         `INKOS_LLM_MODEL=${opts.model}`,
       ];
       if (opts.temperature) lines.push(`INKOS_LLM_TEMPERATURE=${opts.temperature}`);
-      if (opts.maxTokens) lines.push(`INKOS_LLM_MAX_TOKENS=${opts.maxTokens}`);
       if (opts.thinkingBudget) lines.push(`INKOS_LLM_THINKING_BUDGET=${opts.thinkingBudget}`);
       if (opts.apiFormat) lines.push(`INKOS_LLM_API_FORMAT=${opts.apiFormat}`);
       if (opts.lang) lines.push(`INKOS_DEFAULT_LANGUAGE=${opts.lang}`);
@@ -295,3 +295,29 @@ configCommand
       process.exit(1);
     }
   });
+
+// B17: list-models 命令 —— 列出指定 service 的可用模型(含元数据)
+configCommand
+  .command("list-models <service>")
+  .description("List available models for a service (with maxOutput / contextWindow / abilities)")
+  .option("--api-key <key>", "API Key (also reads from INKOS_LLM_API_KEY env)")
+  .option("--base-url <url>", "Live /models probe baseUrl (for custom/newapi)")
+  .option("--json", "Output as JSON")
+  .action(async (service: string, opts: { apiKey?: string; baseUrl?: string; json?: boolean }) => {
+    const apiKey = opts.apiKey ?? process.env.INKOS_LLM_API_KEY;
+    const models = await listModelsForService(service, apiKey, opts.baseUrl);
+    if (models.length === 0) {
+      logError(`${service} 没有可用模型(可能需要 --api-key 和 --base-url)`);
+      process.exit(1);
+    }
+    if (opts.json) {
+      log(JSON.stringify(models, null, 2));
+      return;
+    }
+    log(`${service}:${models.length} 个模型\n`);
+    for (const m of models) {
+      const maxOut = m.maxOutput ? `out=${m.maxOutput}` : "out=?";
+      const ctx = m.contextWindow > 0 ? `ctx=${m.contextWindow}` : "ctx=?";
+      log(`  ${m.id.padEnd(42)} ${maxOut.padEnd(14)} ${ctx}`);
+    }
+  });

+ 17 - 13
packages/cli/src/commands/doctor.ts

@@ -154,23 +154,27 @@ export const doctorCommand = new Command("doctor")
       });
     }
 
-    // 5. Check LLM API key (global + project .env)
+    // 5. Check effective LLM config (Studio project base + env/CLI overlay, or legacy env)
     {
-      const { loadConfig } = await import("../utils.js");
-      const { config: loadDotenv } = await import("dotenv");
-      loadDotenv({ path: GLOBAL_ENV_PATH });
-      loadDotenv({ path: join(root, ".env"), override: true });
+      const { loadConfigWithDiagnostics } = await import("../utils.js");
       const { isApiKeyOptionalForEndpoint } = await import("@actalk/inkos-core");
-      let provider = process.env.INKOS_LLM_PROVIDER;
-      let baseUrl = process.env.INKOS_LLM_BASE_URL;
+      let configResult: Awaited<ReturnType<typeof loadConfigWithDiagnostics>> | undefined;
       try {
-        const config = await loadConfig({ requireApiKey: false });
-        provider = config.llm.provider;
-        baseUrl = config.llm.baseUrl;
+        configResult = await loadConfigWithDiagnostics({ requireApiKey: false });
+        checks.push({
+          name: "LLM Config Mode",
+          ok: true,
+          detail: `${configResult.diagnostics.configMode} (service=${configResult.diagnostics.serviceSource}, model=${configResult.diagnostics.modelSource}, key=${configResult.diagnostics.apiKeySource})`,
+        });
+        for (const warning of configResult.diagnostics.warnings) {
+          checks.push({ name: "  Config Hint", ok: true, detail: warning });
+        }
       } catch {
-        // Fall back to raw env inspection only.
+        // The API connectivity check below will report the concrete config failure.
       }
-      const apiKey = process.env.INKOS_LLM_API_KEY;
+      const provider = configResult?.llm.provider;
+      const baseUrl = configResult?.llm.baseUrl;
+      const apiKey = configResult?.llm.apiKey;
       const apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl });
       const hasKey = apiKeyOptional || (!!apiKey && apiKey.length > 10 && apiKey !== "your-api-key-here");
       checks.push({
@@ -180,7 +184,7 @@ export const doctorCommand = new Command("doctor")
           ? "Optional for local/self-hosted endpoint"
           : hasKey
             ? "Configured"
-            : "Missing — run 'inkos config set-global' or add to project .env",
+            : "Missing — save a Studio service key or set env for CLI/daemon/deploy",
       });
     }
 

+ 8 - 0
packages/cli/src/program.ts

@@ -47,6 +47,14 @@ export function createProgram(hooks: ProgramHooks = {}): Command {
     .name("inkos")
     .description("InkOS — Multi-agent novel production system")
     .version(version)
+    .enablePositionalOptions()
+    .option("--service <service>", "Override LLM service for this CLI run")
+    .option("--model <model>", "Override LLM model for this CLI run")
+    .option("--api-key-env <envVar>", "Read LLM API key from this environment variable for this CLI run")
+    .option("--base-url <url>", "Override LLM base URL for this CLI run")
+    .option("--api-format <chat|responses>", "Override LLM API format for this CLI run")
+    .option("--stream", "Force streaming LLM responses for this CLI run")
+    .option("--no-stream", "Force non-streaming LLM responses for this CLI run")
     .action(async () => {
       await launchStudioEntry(process.cwd(), "4567", { launchStudio: hooks.launchStudio });
     });

+ 69 - 3
packages/cli/src/utils.ts

@@ -1,6 +1,6 @@
 import { readFile, stat } from "node:fs/promises";
 import { join, resolve } from "node:path";
-import { createLLMClient, StateManager, createLogger, createStderrSink, createJsonLineSink, loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, type ProjectConfig, type PipelineConfig, type LogSink } from "@actalk/inkos-core";
+import { createLLMClient, StateManager, createLogger, createStderrSink, createJsonLineSink, resolveEffectiveLLMConfig, loadLLMEnvLayers, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, type EffectiveLLMConfigResult, type LLMConfigCliOverrides, type ProjectConfig, type PipelineConfig, type LogSink } from "@actalk/inkos-core";
 import { formatSqliteMemorySupportWarning } from "./runtime-requirements.js";
 
 export { GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH };
@@ -31,14 +31,80 @@ export function findProjectRoot(): string {
   return process.cwd();
 }
 
-export async function loadConfig(options?: { readonly requireApiKey?: boolean; readonly projectRoot?: string }): Promise<ProjectConfig> {
-  return loadProjectConfig(options?.projectRoot ?? findProjectRoot(), options);
+export async function loadConfig(options?: {
+  readonly requireApiKey?: boolean;
+  readonly projectRoot?: string;
+  readonly cli?: LLMConfigCliOverrides;
+}): Promise<ProjectConfig> {
+  return (await loadConfigWithDiagnostics(options)).config;
+}
+
+export async function loadConfigWithDiagnostics(options?: {
+  readonly requireApiKey?: boolean;
+  readonly projectRoot?: string;
+  readonly cli?: LLMConfigCliOverrides;
+}): Promise<EffectiveLLMConfigResult> {
+  const root = options?.projectRoot ?? findProjectRoot();
+  const cli = {
+    ...parseLLMOverridesFromArgv(process.argv.slice(2)),
+    ...options?.cli,
+  };
+  const envLayers = await loadLLMEnvLayers(root);
+  return resolveEffectiveLLMConfig({
+    consumer: "cli",
+    projectRoot: root,
+    envLayers,
+    cli,
+    requireApiKey: options?.requireApiKey,
+  });
 }
 
 export function createClient(config: ProjectConfig) {
   return createLLMClient(config.llm);
 }
 
+export function parseLLMOverridesFromArgv(argv: readonly string[]): LLMConfigCliOverrides {
+  const overrides: {
+    service?: string;
+    model?: string;
+    apiKeyEnv?: string;
+    baseUrl?: string;
+    apiFormat?: "chat" | "responses";
+    stream?: boolean;
+  } = {};
+
+  for (let i = 0; i < argv.length; i++) {
+    const arg = argv[i];
+    if (!arg) continue;
+
+    const [flag, inlineValue] = arg.split("=", 2) as [string, string | undefined];
+    const nextValue = () => inlineValue ?? argv[++i];
+
+    if (flag === "--service") {
+      const value = nextValue();
+      if (value) overrides.service = value;
+    } else if (flag === "--model") {
+      const value = nextValue();
+      if (value) overrides.model = value;
+    } else if (flag === "--api-key-env") {
+      const value = nextValue();
+      if (value) overrides.apiKeyEnv = value;
+    } else if (flag === "--base-url") {
+      const value = nextValue();
+      if (value) overrides.baseUrl = value;
+    } else if (flag === "--api-format") {
+      const value = nextValue();
+      if (value === "chat" || value === "responses") overrides.apiFormat = value;
+    } else if (flag === "--stream") {
+      overrides.stream = true;
+    } else if (flag === "--no-stream") {
+      overrides.stream = false;
+    }
+  }
+
+  return overrides;
+}
+
 export function buildPipelineConfig(
   config: ProjectConfig,
   root: string,

+ 1 - 1
packages/core/package.json

@@ -50,7 +50,7 @@
     "dotenv": "^16.4.0",
     "epub-gen-memory": "^1.0.10",
     "js-yaml": "^4.1.1",
-    "zod": "^3.24.0"
+    "zod": "^3.25.76"
   },
   "devDependencies": {
     "@types/js-yaml": "^4.0.9",

+ 11 - 11
packages/core/src/__tests__/architect.test.ts

@@ -23,7 +23,7 @@ describe("ArchitectAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -89,7 +89,7 @@ describe("ArchitectAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -156,7 +156,7 @@ describe("ArchitectAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -221,7 +221,7 @@ describe("ArchitectAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -287,7 +287,7 @@ describe("ArchitectAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -352,7 +352,7 @@ describe("ArchitectAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -412,7 +412,7 @@ describe("ArchitectAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -476,7 +476,7 @@ describe("ArchitectAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -527,7 +527,7 @@ describe("ArchitectAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -590,7 +590,7 @@ describe("ArchitectAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -653,7 +653,7 @@ describe("ArchitectAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },

+ 5 - 5
packages/core/src/__tests__/chapter-analyzer.test.ts

@@ -28,7 +28,7 @@ describe("ChapterAnalyzerAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -113,7 +113,7 @@ describe("ChapterAnalyzerAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -236,7 +236,7 @@ describe("ChapterAnalyzerAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -325,7 +325,7 @@ describe("ChapterAnalyzerAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -454,7 +454,7 @@ describe("ChapterAnalyzerAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },

+ 58 - 3
packages/core/src/__tests__/config-loader.test.ts

@@ -5,14 +5,17 @@ import { afterEach, describe, expect, it } from "vitest";
 import { loadProjectConfig } from "../utils/config-loader.js";
 
 const ENV_KEYS = [
+  "INKOS_LLM_SERVICE",
   "INKOS_LLM_PROVIDER",
   "INKOS_LLM_BASE_URL",
   "INKOS_LLM_MODEL",
   "INKOS_LLM_API_KEY",
   "INKOS_LLM_TEMPERATURE",
-  "INKOS_LLM_MAX_TOKENS",
   "INKOS_LLM_THINKING_BUDGET",
   "INKOS_LLM_API_FORMAT",
+  "INKOS_LLM_STREAM",
+  "INKOS_LLM_EXTRA_top_p",
+  "INKOS_DEFAULT_LANGUAGE",
 ] as const;
 
 describe("loadProjectConfig local provider auth", () => {
@@ -104,7 +107,7 @@ describe("loadProjectConfig local provider auth", () => {
       "utf-8",
     );
 
-    const config = await loadProjectConfig(root);
+    const config = await loadProjectConfig(root, { consumer: "studio" });
 
     expect(config.llm.service).toBe("moonshot");
     expect(config.llm.provider).toBe("openai");
@@ -112,7 +115,6 @@ describe("loadProjectConfig local provider auth", () => {
     expect(config.llm.model).toBe("kimi-k2.5");
     expect(config.llm.apiKey).toBe("sk-moon");
     expect(config.llm.temperature).toBe(1);
-    expect(config.llm.maxTokens).toBe(4096);
   });
 
   it("derives provider/baseUrl from the MiniMax preset single source of truth", async () => {
@@ -230,6 +232,59 @@ describe("loadProjectConfig local provider auth", () => {
     expect(config.llm.apiKey).toBe("sk-corp");
   });
 
+  it("does not mix stale top-level env-era model/baseUrl with selected Studio service", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-config-loader-studio-stale-top-level-"));
+    for (const key of ENV_KEYS) {
+      previousEnv.set(key, process.env[key]);
+      process.env[key] = "";
+    }
+
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "studio-stale-project",
+      version: "0.1.0",
+      language: "zh",
+      llm: {
+        configSource: "studio",
+        service: "google",
+        model: "kimi-k2.5",
+        baseUrl: "https://api.moonshot.cn/v1",
+        apiKey: "sk-moon-inline",
+        services: [
+          { service: "google", temperature: 0.7, apiFormat: "chat", stream: true },
+          { service: "moonshot", temperature: 1, apiFormat: "chat", stream: true },
+        ],
+        defaultModel: "gemini-2.5-flash",
+      },
+      notify: [],
+    }, null, 2), "utf-8");
+    await writeFile(join(root, ".env"), [
+      "INKOS_LLM_PROVIDER=custom",
+      "INKOS_LLM_BASE_URL=https://api.moonshot.cn/v1",
+      "INKOS_LLM_MODEL=kimi-k2.5",
+      "INKOS_LLM_API_KEY=sk-env-moon",
+    ].join("\n"), "utf-8");
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(
+      join(root, ".inkos", "secrets.json"),
+      JSON.stringify({
+        services: {
+          google: { apiKey: "sk-google" },
+          moonshot: { apiKey: "sk-moon" },
+        },
+      }, null, 2),
+      "utf-8",
+    );
+
+    const config = await loadProjectConfig(root, { consumer: "studio", requireApiKey: false });
+
+    expect(config.llm.configSource).toBe("studio");
+    expect(config.llm.service).toBe("google");
+    expect(config.llm.provider).toBe("openai");
+    expect(config.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
+    expect(config.llm.model).toBe("gemini-2.5-flash");
+    expect(config.llm.apiKey).toBe("sk-google");
+  });
+
   it("falls back to env when Studio config is still the empty bootstrap state", async () => {
     root = await mkdtemp(join(tmpdir(), "inkos-config-loader-studio-bootstrap-"));
     for (const key of ENV_KEYS) {

+ 3 - 3
packages/core/src/__tests__/continuity.test.ts

@@ -56,7 +56,7 @@ describe("ContinuityAuditor", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -128,7 +128,7 @@ describe("ContinuityAuditor", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -223,7 +223,7 @@ describe("ContinuityAuditor", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },

+ 395 - 0
packages/core/src/__tests__/effective-llm-config.test.ts

@@ -0,0 +1,395 @@
+import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { resolveEffectiveLLMConfig } from "../utils/effective-llm-config.js";
+
+describe("resolveEffectiveLLMConfig", () => {
+  let root = "";
+
+  afterEach(async () => {
+    if (root) await rm(root, { recursive: true, force: true });
+    root = "";
+  });
+
+  async function writeProject(llm: Record<string, unknown>) {
+    root = await mkdtemp(join(tmpdir(), "inkos-effective-llm-"));
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      name: "effective-project",
+      version: "0.1.0",
+      language: "zh",
+      llm,
+      notify: [],
+    }, null, 2), "utf-8");
+  }
+
+  async function writeSecrets(services: Record<string, { apiKey: string }>) {
+    await mkdir(join(root, ".inkos"), { recursive: true });
+    await writeFile(join(root, ".inkos", "secrets.json"), JSON.stringify({ services }, null, 2), "utf-8");
+  }
+
+  it("Studio consumer 使用 Studio/project 配置,并忽略旧顶层 model/baseUrl", async () => {
+    await writeProject({
+      configSource: "studio",
+      service: "google",
+      provider: "custom",
+      baseUrl: "https://api.moonshot.cn/v1",
+      model: "kimi-k2.5",
+      services: [{ service: "google", apiFormat: "chat", stream: true }],
+      defaultModel: "gemini-2.5-flash",
+    });
+    await writeSecrets({ google: { apiKey: "sk-google" } });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "studio",
+      projectRoot: root,
+      envLayers: { global: {}, project: {}, process: {} },
+      requireApiKey: true,
+    });
+
+    expect(result.llm.configSource).toBe("studio");
+    expect(result.diagnostics.configMode).toBe("studio-project");
+    expect(result.llm.service).toBe("google");
+    expect(result.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
+    expect(result.llm.model).toBe("gemini-2.5-flash");
+    expect(result.llm.apiKey).toBe("sk-google");
+    expect(result.diagnostics.apiKeySource).toBe("studio-secret");
+    expect(result.diagnostics.warnings.join("\n")).toContain("旧顶层");
+  });
+
+  it("CLI consumer 允许 INKOS_LLM_SERVICE 切换服务,并从 provider bank 推导 baseUrl", async () => {
+    await writeProject({
+      configSource: "studio",
+      service: "google",
+      services: [{ service: "google" }, { service: "moonshot", temperature: 1 }],
+      defaultModel: "gemini-2.5-flash",
+    });
+    await writeSecrets({
+      google: { apiKey: "sk-google" },
+      moonshot: { apiKey: "sk-moon" },
+    });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: {
+        global: {},
+        project: {
+          INKOS_LLM_SERVICE: "moonshot",
+          INKOS_LLM_MODEL: "kimi-k2.5",
+        },
+        process: {},
+      },
+    });
+
+    expect(result.llm.service).toBe("moonshot");
+    expect(result.llm.baseUrl).toBe("https://api.moonshot.cn/v1");
+    expect(result.llm.model).toBe("kimi-k2.5");
+    expect(result.llm.apiKey).toBe("sk-moon");
+    expect(result.diagnostics.serviceSource).toBe("env");
+    expect(result.diagnostics.modelSource).toBe("env");
+  });
+
+  it("CLI consumer 兼容旧 env:没有 INKOS_LLM_SERVICE 时从 baseUrl 反推 service", async () => {
+    await writeProject({
+      configSource: "studio",
+      service: "google",
+      services: [{ service: "google" }],
+      defaultModel: "gemini-2.5-flash",
+    });
+    await writeSecrets({ google: { apiKey: "sk-google" } });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: {
+        global: {
+          INKOS_LLM_PROVIDER: "custom",
+          INKOS_LLM_BASE_URL: "https://api.moonshot.cn/v1",
+          INKOS_LLM_MODEL: "kimi-k2.5",
+          INKOS_LLM_API_KEY: "sk-env-moon",
+        },
+        project: {},
+        process: {},
+      },
+    });
+
+    expect(result.llm.service).toBe("moonshot");
+    expect(result.llm.baseUrl).toBe("https://api.moonshot.cn/v1");
+    expect(result.llm.model).toBe("kimi-k2.5");
+    expect(result.llm.apiKey).toBe("sk-env-moon");
+    expect(result.diagnostics.serviceSource).toBe("env");
+  });
+
+  it("Studio consumer 不让任何 env 里的旧模型污染 Studio service", async () => {
+    await writeProject({
+      configSource: "studio",
+      service: "google",
+      services: [{ service: "google" }],
+      defaultModel: "gemini-2.5-flash",
+    });
+    await writeSecrets({ google: { apiKey: "sk-google" } });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "studio",
+      projectRoot: root,
+      envLayers: {
+        global: {
+          INKOS_LLM_SERVICE: "moonshot",
+          INKOS_LLM_MODEL: "kimi-k2.5",
+          INKOS_LLM_API_KEY: "sk-global-moon",
+        },
+        project: {
+          INKOS_LLM_SERVICE: "deepseek",
+          INKOS_LLM_MODEL: "deepseek-chat",
+          INKOS_LLM_API_KEY: "sk-project-deepseek",
+        },
+        process: {
+          INKOS_LLM_SERVICE: "zhipu",
+          INKOS_LLM_MODEL: "glm-4-flash",
+          INKOS_LLM_API_KEY: "sk-process-zhipu",
+        },
+      },
+      requireApiKey: true,
+    });
+
+    expect(result.llm.service).toBe("google");
+    expect(result.llm.model).toBe("gemini-2.5-flash");
+    expect(result.llm.apiKey).toBe("sk-google");
+    expect(result.diagnostics.warnings.join("\n")).toContain("Studio 运行时不会使用 env");
+  });
+
+  it("旧 configSource=env 保持 legacy-env 行为", async () => {
+    await writeProject({
+      configSource: "env",
+      provider: "openai",
+      baseUrl: "https://stale.example.com/v1",
+      model: "stale-model",
+      services: [{ service: "google" }],
+      defaultModel: "gemini-2.5-flash",
+    });
+    await writeSecrets({ google: { apiKey: "sk-google" } });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: {
+        global: {
+          INKOS_LLM_PROVIDER: "custom",
+          INKOS_LLM_BASE_URL: "https://api.example.com/v1",
+          INKOS_LLM_MODEL: "legacy-model",
+          INKOS_LLM_API_KEY: "sk-env",
+        },
+        project: {},
+        process: {},
+      },
+    });
+
+    expect(result.diagnostics.configMode).toBe("legacy-env");
+    expect(result.llm.service).toBe("custom");
+    expect(result.llm.provider).toBe("custom");
+    expect(result.llm.baseUrl).toBe("https://api.example.com/v1");
+    expect(result.llm.model).toBe("legacy-model");
+    expect(result.llm.apiKey).toBe("sk-env");
+  });
+
+  it("legacy-env 模式下 CLI --service 覆盖会切换到目标 service 的 endpoint 默认值", async () => {
+    await writeProject({
+      configSource: "env",
+      provider: "custom",
+      baseUrl: "https://api.moonshot.cn/v1",
+      model: "kimi-k2.5",
+    });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: {
+        global: {
+          INKOS_LLM_PROVIDER: "custom",
+          INKOS_LLM_BASE_URL: "https://api.moonshot.cn/v1",
+          INKOS_LLM_MODEL: "kimi-k2.5",
+          INKOS_LLM_API_KEY: "sk-moon",
+        },
+        project: {},
+        process: {
+          GOOGLE_API_KEY: "sk-google",
+        },
+      },
+      cli: {
+        service: "google",
+        model: "gemini-2.5-flash",
+        apiKeyEnv: "GOOGLE_API_KEY",
+      },
+    });
+
+    expect(result.diagnostics.configMode).toBe("legacy-env");
+    expect(result.llm.service).toBe("google");
+    expect(result.llm.provider).toBe("openai");
+    expect(result.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
+    expect(result.llm.apiFormat).toBe("chat");
+    expect(result.llm.model).toBe("gemini-2.5-flash");
+    expect(result.llm.apiKey).toBe("sk-google");
+  });
+
+  it("legacy-env 模式下 CLI transport 覆盖优先级高于 env", async () => {
+    await writeProject({
+      configSource: "env",
+      provider: "openai",
+      baseUrl: "https://api.example.com/v1",
+      model: "legacy-model",
+    });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: {
+        global: {
+          INKOS_LLM_PROVIDER: "openai",
+          INKOS_LLM_BASE_URL: "https://api.example.com/v1",
+          INKOS_LLM_MODEL: "legacy-model",
+          INKOS_LLM_API_KEY: "sk-env",
+          INKOS_LLM_API_FORMAT: "chat",
+          INKOS_LLM_STREAM: "true",
+        },
+        project: {},
+        process: {},
+      },
+      cli: {
+        apiFormat: "responses",
+        stream: false,
+      },
+    });
+
+    expect(result.diagnostics.configMode).toBe("legacy-env");
+    expect(result.llm.apiFormat).toBe("responses");
+    expect(result.llm.stream).toBe(false);
+  });
+
+  it("保留旧 INKOS_LLM_EXTRA_* 和 INKOS_DEFAULT_LANGUAGE 行为", async () => {
+    await writeProject({
+      configSource: "env",
+      provider: "openai",
+      baseUrl: "https://api.example.com/v1",
+      model: "legacy-model",
+    });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: {
+        global: {},
+        project: {
+          INKOS_LLM_API_KEY: "sk-env",
+          INKOS_LLM_EXTRA_top_p: "0.9",
+          INKOS_DEFAULT_LANGUAGE: "en",
+        },
+        process: {},
+      },
+    });
+
+    expect(result.config.language).toBe("en");
+    expect(result.llm.extra).toMatchObject({ top_p: 0.9 });
+  });
+
+  it("CLI override 优先级高于 env", async () => {
+    await writeProject({
+      configSource: "studio",
+      service: "google",
+      services: [{ service: "google" }, { service: "zhipu" }],
+      defaultModel: "gemini-2.5-flash",
+    });
+    await writeSecrets({ google: { apiKey: "sk-google" }, zhipu: { apiKey: "sk-zhipu" } });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: {
+        global: {},
+        project: { INKOS_LLM_SERVICE: "google", INKOS_LLM_MODEL: "gemini-2.5-pro" },
+        process: {},
+      },
+      cli: { service: "zhipu", model: "glm-4-flash" },
+    });
+
+    expect(result.llm.service).toBe("zhipu");
+    expect(result.llm.model).toBe("glm-4-flash");
+    expect(result.llm.baseUrl).toBe("https://open.bigmodel.cn/api/paas/v4");
+    expect(result.diagnostics.serviceSource).toBe("cli");
+    expect(result.diagnostics.modelSource).toBe("cli");
+  });
+
+  it("CLI 指定 service 时不会继承旧 env 的 baseUrl/model/apiKey", async () => {
+    await writeProject({
+      configSource: "studio",
+      service: "google",
+      services: [{ service: "google" }, { service: "moonshot" }],
+      defaultModel: "gemini-2.5-flash",
+    });
+    await writeSecrets({ google: { apiKey: "sk-google" }, moonshot: { apiKey: "sk-moon" } });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: {
+        global: {
+          INKOS_LLM_PROVIDER: "custom",
+          INKOS_LLM_BASE_URL: "https://api.moonshot.cn/v1",
+          INKOS_LLM_MODEL: "kimi-k2.5",
+          INKOS_LLM_API_KEY: "sk-env-moon",
+        },
+        project: {},
+        process: {},
+      },
+      cli: { service: "google" },
+    });
+
+    expect(result.llm.service).toBe("google");
+    expect(result.llm.provider).toBe("openai");
+    expect(result.llm.baseUrl).toBe("https://generativelanguage.googleapis.com/v1beta/openai");
+    expect(result.llm.model).toBe("gemini-2.5-flash");
+    expect(result.llm.apiKey).toBe("sk-google");
+    expect(result.diagnostics.serviceSource).toBe("cli");
+    expect(result.diagnostics.modelSource).toBe("project");
+    expect(result.diagnostics.apiKeySource).toBe("studio-secret");
+  });
+
+  it("拒绝不属于最终 service 的模型", async () => {
+    await writeProject({
+      configSource: "studio",
+      service: "google",
+      services: [{ service: "google" }],
+      defaultModel: "gemini-2.5-flash",
+    });
+    await writeSecrets({ google: { apiKey: "sk-google" } });
+
+    await expect(resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: {
+        global: {},
+        project: { INKOS_LLM_MODEL: "kimi-k2.5" },
+        process: {},
+      },
+    })).rejects.toThrow(/模型.*kimi-k2\.5.*不属于.*google/);
+  });
+
+  it("从 provider bank 应用 service transport 默认值", async () => {
+    await writeProject({
+      configSource: "studio",
+      service: "minimaxCodingPlan",
+      services: [{ service: "minimaxCodingPlan" }],
+      defaultModel: "MiniMax-M2.7",
+    });
+    await writeSecrets({ minimaxCodingPlan: { apiKey: "sk-minimax" } });
+
+    const result = await resolveEffectiveLLMConfig({
+      consumer: "cli",
+      projectRoot: root,
+      envLayers: { global: {}, project: {}, process: {} },
+    });
+
+    expect(result.llm.service).toBe("minimaxCodingPlan");
+    expect(result.llm.stream).toBe(false);
+  });
+});

+ 1 - 1
packages/core/src/__tests__/length-normalizer.test.ts

@@ -18,7 +18,7 @@ const AGENT_CONTEXT = {
     defaults: {
       temperature: 0.7,
       maxTokens: 4096,
-      thinkingBudget: 0, maxTokensCap: null,
+      thinkingBudget: 0,
       extra: {},
     },
   } as const,

+ 93 - 0
packages/core/src/__tests__/list-models.test.ts

@@ -0,0 +1,93 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { listModelsForService, resolveServiceModelsBaseUrl } from "../llm/service-presets.js";
+
+describe("listModelsForService (B8)", () => {
+  const originalEnv = process.env.INKOS_LLM_MODEL;
+  const originalFetch = global.fetch;
+
+  beforeEach(() => {
+    delete process.env.INKOS_LLM_MODEL;
+  });
+
+  afterEach(() => {
+    if (originalEnv) process.env.INKOS_LLM_MODEL = originalEnv;
+    else delete process.env.INKOS_LLM_MODEL;
+    global.fetch = originalFetch;
+  });
+
+  it("anthropic service 无 apikey 时返回 provider 内置 enabled 子集", async () => {
+    const models = await listModelsForService("anthropic");
+    expect(models.length).toBeGreaterThan(0);
+    expect(models.some((m) => m.id === "claude-sonnet-4-6")).toBe(true);
+    const sonnet = models.find((m) => m.id === "claude-sonnet-4-6");
+    expect(sonnet?.maxOutput).toBe(64_000);
+    expect(sonnet?.contextWindow).toBe(1_000_000);
+  });
+
+  it("google service 的文本模型列表不包含 image preview 模型", async () => {
+    const models = await listModelsForService("google");
+    expect(models.some((m) => m.id === "gemini-2.5-flash")).toBe(true);
+    expect(models.some((m) => m.id.includes("image"))).toBe(false);
+  });
+
+  it("custom service 走 live probe + bank 补元数据", async () => {
+    global.fetch = vi.fn().mockResolvedValue({
+      ok: true,
+      json: async () => ({ data: [{ id: "gpt-4o" }, { id: "my-proxy-model" }] }),
+    } as any) as typeof fetch;
+    const models = await listModelsForService("custom", "sk-test", "https://myproxy.example/v1");
+    // gpt-4o 命中 openai provider,拿到元数据
+    const gpt = models.find((m) => m.id === "gpt-4o");
+    expect(gpt).toBeDefined();
+    expect(gpt?.maxOutput).toBe(4096);
+    // 自定义 id 没元数据也保留
+    expect(models.some((m) => m.id === "my-proxy-model")).toBe(true);
+  });
+
+  it("R4:env 补丁已删除 — INKOS_LLM_MODEL 不再污染跨 service 菜单", async () => {
+    process.env.INKOS_LLM_MODEL = "my-secret-model";
+    const models = await listModelsForService("anthropic");
+    // my-secret-model 不在 anthropic bank → 不应该出现
+    expect(models.some((m) => m.id === "my-secret-model")).toBe(false);
+  });
+
+  it("live 挂了降级到 provider 内置 models(没有 fetch 错误 crash)", async () => {
+    global.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) as typeof fetch;
+    const models = await listModelsForService("anthropic", "sk-test");
+    expect(models.length).toBeGreaterThan(0);
+    expect(models.some((m) => m.id === "claude-sonnet-4-6")).toBe(true);
+  });
+
+  it("bailian 不用 OpenAI 兼容 /models 污染 Anthropic 通道模型列表", async () => {
+    const fetchMock = vi.fn().mockImplementation(async (input: string | URL | Request) => {
+      const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
+      if (url === "https://dashscope.aliyuncs.com/compatible-mode/v1/models") {
+        return {
+          ok: true,
+          json: async () => ({ data: [{ id: "kimi-k2.6" }, { id: "deepseek-v3.2" }] }),
+        };
+      }
+      return {
+        ok: false,
+        json: async () => ({ data: [] }),
+      };
+    });
+    global.fetch = fetchMock as unknown as typeof fetch;
+
+    const models = await listModelsForService("bailian", "sk-test");
+
+    expect(resolveServiceModelsBaseUrl("bailian")).toBe("https://dashscope.aliyuncs.com/apps/anthropic");
+    expect(fetchMock).not.toHaveBeenCalledWith(
+      "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
+      expect.any(Object),
+    );
+    expect(models.some((m) => m.id === "qwen-turbo")).toBe(true);
+    expect(models.some((m) => m.id === "kimi-k2.6")).toBe(false);
+    expect(models.some((m) => m.id === "deepseek-v3.2")).toBe(false);
+  });
+
+  it("未知 service 返回空数组", async () => {
+    const models = await listModelsForService("nonexistent-xyz");
+    expect(models).toEqual([]);
+  });
+});

+ 31 - 0
packages/core/src/__tests__/llm-env.test.ts

@@ -0,0 +1,31 @@
+import { mkdtemp, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterEach, describe, expect, it } from "vitest";
+import { loadLLMEnvLayers } from "../utils/llm-env.js";
+
+describe("loadLLMEnvLayers", () => {
+  let root = "";
+  const previousGoogleKey = process.env.GOOGLE_API_KEY;
+
+  afterEach(async () => {
+    if (previousGoogleKey === undefined) delete process.env.GOOGLE_API_KEY;
+    else process.env.GOOGLE_API_KEY = previousGoogleKey;
+
+    if (root) {
+      await rm(root, { recursive: true, force: true });
+      root = "";
+    }
+  });
+
+  it("exposes project .env variables through process.env for apiKeyEnv consumers", async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-llm-env-"));
+    delete process.env.GOOGLE_API_KEY;
+    await writeFile(join(root, ".env"), "GOOGLE_API_KEY=sk-from-project-env\n", "utf-8");
+
+    const layers = await loadLLMEnvLayers(root);
+
+    expect(layers.project.GOOGLE_API_KEY).toBe("sk-from-project-env");
+    expect(process.env.GOOGLE_API_KEY).toBe("sk-from-project-env");
+  });
+});

+ 1 - 1
packages/core/src/__tests__/pipeline-agent.test.ts

@@ -25,7 +25,7 @@ describe("agent pipeline tools", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },

+ 1 - 1
packages/core/src/__tests__/pipeline-runner-memory-sync.test.ts

@@ -187,7 +187,7 @@ describe("PipelineRunner structured-state memory sync", () => {
           temperature: 0.7,
           maxTokens: 4096,
           thinkingBudget: 0,
-          maxTokensCap: null,
+
         },
       } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
       model: "test-model",

+ 7 - 12
packages/core/src/__tests__/pipeline-runner.test.ts

@@ -187,7 +187,7 @@ async function createRunnerFixture(
       defaults: {
         temperature: 0.7,
         maxTokens: 4096,
-        thinkingBudget: 0, maxTokensCap: null,
+        thinkingBudget: 0,
       },
     } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
     model: "test-model",
@@ -240,7 +240,7 @@ describe("PipelineRunner", () => {
           defaults: {
             temperature: 0.7,
             maxTokens: 4096,
-            thinkingBudget: 0, maxTokensCap: null,
+            thinkingBudget: 0,
           },
         } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
         model: "base-model",
@@ -253,7 +253,6 @@ describe("PipelineRunner", () => {
           apiKey: "base-key",
           model: "base-model",
           temperature: 0.7,
-          maxTokens: 4096,
           thinkingBudget: 0,
           apiFormat: "chat",
           stream: false,
@@ -317,8 +316,7 @@ describe("PipelineRunner", () => {
         stream: false,
         defaults: {
           temperature: 0.7,
-          maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
         },
       } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
       model: "test-model",
@@ -372,8 +370,7 @@ describe("PipelineRunner", () => {
         stream: false,
         defaults: {
           temperature: 0.7,
-          maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
         },
       } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
       model: "test-model",
@@ -420,8 +417,7 @@ describe("PipelineRunner", () => {
         stream: false,
         defaults: {
           temperature: 0.7,
-          maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
         },
       } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
       model: "test-model",
@@ -520,7 +516,7 @@ describe("PipelineRunner", () => {
     } finally {
       await rm(root, { recursive: true, force: true });
     }
-  });
+  }, 15_000);
 
   it("cleans staged files when initBook fails before foundation is complete", async () => {
     const root = await mkdtemp(join(tmpdir(), "inkos-init-rollback-"));
@@ -531,8 +527,7 @@ describe("PipelineRunner", () => {
         stream: false,
         defaults: {
           temperature: 0.7,
-          maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
         },
       } as ConstructorParameters<typeof PipelineRunner>[0]["client"],
       model: "test-model",

+ 64 - 0
packages/core/src/__tests__/probe.test.ts

@@ -0,0 +1,64 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { probeModelsFromUpstream } from "../llm/providers/probe.js";
+
+describe("probeModelsFromUpstream", () => {
+  const origFetch = globalThis.fetch;
+
+  beforeEach(() => {
+    globalThis.fetch = vi.fn();
+  });
+
+  afterEach(() => {
+    globalThis.fetch = origFetch;
+  });
+
+  it("正常响应返回 ProbedModel 数组", async () => {
+    (globalThis.fetch as any).mockResolvedValue({
+      ok: true,
+      json: async () => ({ data: [{ id: "gpt-4" }, { id: "gpt-3.5" }] }),
+    });
+    const result = await probeModelsFromUpstream("https://api.example.com/v1", "sk-test");
+    expect(result).toEqual([
+      { id: "gpt-4", name: "gpt-4", contextWindow: 0 },
+      { id: "gpt-3.5", name: "gpt-3.5", contextWindow: 0 },
+    ]);
+  });
+
+  it("非 2xx 返回空数组", async () => {
+    (globalThis.fetch as any).mockResolvedValue({ ok: false });
+    const result = await probeModelsFromUpstream("https://api.example.com/v1", "sk-test");
+    expect(result).toEqual([]);
+  });
+
+  it("fetch 抛错返回空数组", async () => {
+    (globalThis.fetch as any).mockRejectedValue(new Error("network down"));
+    const result = await probeModelsFromUpstream("https://api.example.com/v1", "sk-test");
+    expect(result).toEqual([]);
+  });
+
+  it("响应 json.data 不是数组返回空数组", async () => {
+    (globalThis.fetch as any).mockResolvedValue({
+      ok: true,
+      json: async () => ({ data: "not-an-array" }),
+    });
+    const result = await probeModelsFromUpstream("https://api.example.com/v1", "sk-test");
+    expect(result).toEqual([]);
+  });
+
+  it("baseUrl 或 apiKey 空直接返回空数组,不发请求", async () => {
+    const r1 = await probeModelsFromUpstream("", "sk-test");
+    const r2 = await probeModelsFromUpstream("https://x.com", "");
+    expect(r1).toEqual([]);
+    expect(r2).toEqual([]);
+    expect(globalThis.fetch).not.toHaveBeenCalled();
+  });
+
+  it("过滤掉 id 非字符串的 entry", async () => {
+    (globalThis.fetch as any).mockResolvedValue({
+      ok: true,
+      json: async () => ({ data: [{ id: "valid" }, { id: null }, { id: 123 }, {}] }),
+    });
+    const result = await probeModelsFromUpstream("https://api.example.com/v1", "sk-test");
+    expect(result).toEqual([{ id: "valid", name: "valid", contextWindow: 0 }]);
+  });
+});

+ 85 - 68
packages/core/src/__tests__/provider.test.ts

@@ -120,7 +120,7 @@ function makeClient(temperature = 0.7, extra: Partial<LLMClient> = {}): LLMClien
       temperature,
       maxTokens: 512,
       thinkingBudget: 0,
-      maxTokensCap: null,
+
       extra: {},
     },
     ...extra,
@@ -180,7 +180,7 @@ describe("chatCompletion via pi-ai", () => {
     );
 
     expect(error.message).toContain("API 返回 400");
-    expect(error.message).toContain("检查提供方文档");
+    expect(error.message).toContain("temperature");
   });
 
   it("wraps 401 errors with an unauthorized message", async () => {
@@ -443,11 +443,11 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
     warn.mockRestore();
   });
 
-  it("also clamps any model name containing 'thinking'", async () => {
+  it("clamps kimi-k2-thinking (bank-marked temperature:1)", async () => {
     const client = makeClient(0.5);
     vi.spyOn(console, "warn").mockImplementation(() => {});
 
-    await chatCompletion(client, "kimi-thinking-preview", [
+    await chatCompletion(client, "kimi-k2-thinking", [
       { role: "user", content: "hi" },
     ]);
 
@@ -485,22 +485,11 @@ describe("chatCompletion fixed-temperature clamp (thinking models)", () => {
   });
 });
 
-// ── 回归测试:per-call maxTokens 不能被 config.maxTokens 误封顶 ─────────────
-//
-// 背景 / bug 成因:LLMConfigSchema.maxTokens 有 zod default 8192,曾经
-// createLLMClient 里 `maxTokensCap: config.maxTokens ?? null` 的实现会让 cap
-// 永远等于 config.maxTokens。architect 的 per-call 16384 会被 Math.min(16384,
-// 8192) 裁到 8192,基础设定输出被截断——这是 CLAUDE.md 禁止的 maxTokens 回归。
-//
-// 修复后 maxTokens 和 maxTokensCap 是两个独立字段:
-//   - maxTokens: agent 没传 per-call 时的 fallback
-//   - maxTokensCap: per-call 的硬上限,默认 null(不封顶)
-//
-// 如果后续有人把两个字段的语义合回去、或者把 cap 默认改成非 null,这组回归
-// 测试会立刻挂掉。
-
-describe("createLLMClient maxTokensCap regression", () => {
-  it("setting config.maxTokens alone leaves defaults.maxTokensCap null (no cap)", async () => {
+// ── 回归测试:per-call maxTokens 不被裁剪(v2.0.0 精简版)─────────────
+// 背景:v2.0.0 删除了 config.maxTokens / maxTokensCap 字段,provider 层不再做 cap,
+//      agent per-call 传的 maxTokens 原样透传到下游。
+describe("createLLMClient per-call maxTokens not capped (v2.0.0)", () => {
+  it("per-call maxTokens 16384 reaches the API as-is", async () => {
     const { createLLMClient } = await import("../llm/provider.js");
     const { LLMConfigSchema } = await import("../models/project.js");
 
@@ -508,85 +497,113 @@ describe("createLLMClient maxTokensCap regression", () => {
       provider: "openai",
       baseUrl: "http://localhost:0",
       model: "test-model",
-      maxTokens: 8192, // 用户配了 fallback,但没有显式要求封顶
     }));
 
-    expect(client.defaults.maxTokens).toBe(8192);
-    expect(client.defaults.maxTokensCap).toBeNull();
+    mockStreamSimple.mockReset();
+    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
+
+    await chatCompletion(client, "test-model", [
+      { role: "user", content: "architect" },
+    ], { maxTokens: 16384 });
+
+    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
+    expect(opts.maxTokens).toBe(16384);
   });
+});
 
-  it("setting config.maxTokensCap flips cap on", async () => {
+describe("createLLMClient with providers lookup", () => {
+  it("anthropic + claude-sonnet-4-6 拿到 modelCard 的 maxOutput (64000),不是 8192 fallback", async () => {
     const { createLLMClient } = await import("../llm/provider.js");
     const { LLMConfigSchema } = await import("../models/project.js");
-
     const client = createLLMClient(LLMConfigSchema.parse({
-      provider: "openai",
-      baseUrl: "http://localhost:0",
-      model: "test-model",
-      maxTokens: 8192,
-      maxTokensCap: 4096, // 显式要求封顶
+      provider: "anthropic",
+      service: "anthropic",
+      model: "claude-sonnet-4-6",
+      apiKey: "test",
+      baseUrl: "https://api.anthropic.com",
     }));
-
-    expect(client.defaults.maxTokens).toBe(8192);
-    expect(client.defaults.maxTokensCap).toBe(4096);
+    expect(client.defaults.maxTokens).toBe(64_000);
+    expect(client._piModel?.maxTokens).toBe(64_000);
+    expect(client._piModel?.contextWindow).toBe(1_000_000);
   });
 
-  it("defaults (no config keys) leave cap null", async () => {
+  it("custom service + gpt-4o 靠 Layer 2 全局扫命中 openai provider", async () => {
     const { createLLMClient } = await import("../llm/provider.js");
     const { LLMConfigSchema } = await import("../models/project.js");
-
     const client = createLLMClient(LLMConfigSchema.parse({
       provider: "openai",
-      baseUrl: "http://localhost:0",
-      model: "test-model",
+      service: "custom",
+      model: "gpt-4o",
+      apiKey: "test",
+      baseUrl: "https://middleman.example/v1",
     }));
-
-    expect(client.defaults.maxTokensCap).toBeNull();
+    // lobe 数据里 gpt-4o maxOutput=4096
+    expect(client.defaults.maxTokens).toBe(4096);
   });
 
-  it("per-call maxTokens 16384 reaches the API when config.maxTokens is 8192", async () => {
+  it("未知 model 走保守默认 8192", async () => {
     const { createLLMClient } = await import("../llm/provider.js");
     const { LLMConfigSchema } = await import("../models/project.js");
-
     const client = createLLMClient(LLMConfigSchema.parse({
       provider: "openai",
-      baseUrl: "http://localhost:0",
-      model: "test-model",
-      maxTokens: 8192,
+      service: "custom",
+      model: "my-private-xyz-model-does-not-exist",
+      apiKey: "test",
+      baseUrl: "https://middleman.example/v1",
     }));
+    expect(client.defaults.maxTokens).toBe(8192);
+  });
 
-    mockStreamSimple.mockReset();
-    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
-
-    await chatCompletion(client, "test-model", [
-      { role: "user", content: "architect" },
-    ], { maxTokens: 16384 });
+  it("config.maxTokens 命中 modelCard 后被覆盖(用户填 4000 还是用 modelCard 的 64000)", async () => {
+    const { createLLMClient } = await import("../llm/provider.js");
+    const { LLMConfigSchema } = await import("../models/project.js");
+    const client = createLLMClient(LLMConfigSchema.parse({
+      provider: "anthropic",
+      service: "anthropic",
+      model: "claude-sonnet-4-6",
+      apiKey: "test",
+      baseUrl: "https://api.anthropic.com",
+      maxTokens: 4000,
+    }));
+    expect(client.defaults.maxTokens).toBe(64_000);
+  });
 
-    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
-    // 16384 必须原样传到下游,不能被 config.maxTokens=8192 裁成 8192
-    expect(opts.maxTokens).toBe(16384);
+  it("B7: kimiCodingPlan 的 kimi-k2.5 走 API 时 piModel.id 是 deploymentName (k2p5)", async () => {
+    const { createLLMClient } = await import("../llm/provider.js");
+    const { LLMConfigSchema } = await import("../models/project.js");
+    const client = createLLMClient(LLMConfigSchema.parse({
+      provider: "anthropic",
+      service: "kimiCodingPlan",
+      model: "kimi-k2.5",
+      apiKey: "test",
+      baseUrl: "https://api.moonshot.cn/anthropic",
+    }));
+    expect(client._piModel?.id).toBe("k2p5");
   });
 
-  it("per-call maxTokens is capped when config.maxTokensCap is set explicitly", async () => {
+  it("B7: 没有 deploymentName 的 model piModel.id 保持原 config.model", async () => {
     const { createLLMClient } = await import("../llm/provider.js");
     const { LLMConfigSchema } = await import("../models/project.js");
+    const client = createLLMClient(LLMConfigSchema.parse({
+      provider: "anthropic",
+      service: "kimiCodingPlan",
+      model: "kimi-k2-thinking",
+      apiKey: "test",
+      baseUrl: "https://api.moonshot.cn/anthropic",
+    }));
+    expect(client._piModel?.id).toBe("kimi-k2-thinking");
+  });
 
+  it("Google Gemini OpenAI-compatible endpoint disables OpenAI store parameter", async () => {
+    const { createLLMClient } = await import("../llm/provider.js");
+    const { LLMConfigSchema } = await import("../models/project.js");
     const client = createLLMClient(LLMConfigSchema.parse({
       provider: "openai",
-      baseUrl: "http://localhost:0",
-      model: "test-model",
-      maxTokens: 8192,
-      maxTokensCap: 4096, // 用户确实要硬上限
+      service: "google",
+      model: "gemini-2.5-flash",
+      apiKey: "test",
+      baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
     }));
-
-    mockStreamSimple.mockReset();
-    mockStreamSimple.mockReturnValue(makeTextStream("ok"));
-
-    await chatCompletion(client, "test-model", [
-      { role: "user", content: "test" },
-    ], { maxTokens: 16384 });
-
-    const opts = mockStreamSimple.mock.calls[0]?.[2] as Record<string, unknown>;
-    expect(opts.maxTokens).toBe(4096);
+    expect(client._piModel?.compat).toMatchObject({ supportsStore: false });
   });
 });

+ 34 - 0
packages/core/src/__tests__/providers-group.test.ts

@@ -0,0 +1,34 @@
+import { describe, expect, it } from "vitest";
+import { getAllEndpoints } from "../llm/providers/index.js";
+
+describe("InkosEndpoint.group", () => {
+  it("每个非 custom endpoint 都必须声明 group 字段", () => {
+    const missing = getAllEndpoints().filter((ep) => ep.id !== "custom" && !ep.group);
+    expect(missing, `missing group: ${missing.map((e) => e.id).join(", ")}`).toHaveLength(0);
+  });
+
+  it("每个 group 的 endpoint 数量匹配分组清单", () => {
+    const all = getAllEndpoints();
+    const byGroup = (g: string) => all.filter((ep) => ep.group === g).map((e) => e.id).sort();
+
+    expect(byGroup("overseas")).toEqual(["anthropic", "google", "mistral", "openai", "xai"].sort());
+    expect(byGroup("china")).toEqual([
+      "ai360", "baichuan", "bailian", "deepseek", "hunyuan", "internlm", "longcat",
+      "minimax", "moonshot", "sensenova", "spark", "stepfun", "tencentcloud",
+      "volcengine", "wenxin", "xiaomimimo", "zeroone", "zhipu",
+    ].sort());
+    expect(byGroup("aggregator")).toEqual([
+      "giteeai", "infiniai", "modelscope", "newapi", "openrouter", "ppio", "qiniu", "siliconcloud",
+    ].sort());
+    expect(byGroup("local")).toEqual(["githubCopilot", "ollama"].sort());
+    expect(byGroup("codingPlan")).toEqual([
+      "astronCodingPlan", "bailianCodingPlan", "glmCodingPlan", "kimiCodingPlan",
+      "minimaxCodingPlan", "opencodeCodingPlan", "volcengineCodingPlan",
+    ].sort());
+  });
+
+  it("custom endpoint 不参与分组计数", () => {
+    const custom = getAllEndpoints().find((ep) => ep.id === "custom");
+    expect(custom).toBeDefined();
+  });
+});

+ 83 - 0
packages/core/src/__tests__/providers-lookup.test.ts

@@ -0,0 +1,83 @@
+import { describe, it, expect } from "vitest";
+import { lookupModel, listEnabledModels } from "../llm/providers/lookup.js";
+
+describe("lookupModel", () => {
+  describe("Layer 1(已知 provider 精确查)", () => {
+    it("anthropic 下 claude-sonnet-4-6 命中 provider.models", () => {
+      const hit = lookupModel("anthropic", "claude-sonnet-4-6");
+      expect(hit).toBeDefined();
+      expect(hit?.maxOutput).toBe(64_000);
+      expect(hit?.contextWindowTokens).toBe(1_000_000);
+    });
+
+    it("openai 下 gpt-4o 命中", () => {
+      const hit = lookupModel("openai", "gpt-4o");
+      expect(hit).toBeDefined();
+      expect(hit?.maxOutput).toBe(4096);
+      expect(hit?.contextWindowTokens).toBe(128_000);
+    });
+
+    it("大小写不敏感", () => {
+      const hit = lookupModel("anthropic", "CLAUDE-SONNET-4-6");
+      expect(hit?.maxOutput).toBe(64_000);
+    });
+  });
+
+  describe("Layer 2(全局扫按优先级)", () => {
+    it("custom 下 gpt-4o 命中 openai provider", () => {
+      const hit = lookupModel("custom", "gpt-4o");
+      expect(hit?.maxOutput).toBe(4096);
+    });
+
+    it("custom 下 claude-sonnet-4-6 命中 anthropic provider", () => {
+      const hit = lookupModel("custom", "claude-sonnet-4-6");
+      expect(hit?.maxOutput).toBe(64_000);
+    });
+
+    it("未知 id 返回 undefined", () => {
+      const hit = lookupModel("custom", "my-private-llm-does-not-exist");
+      expect(hit).toBeUndefined();
+    });
+  });
+
+  describe("Layer 2 优先级排序(B 组之后会覆盖更多场景)", () => {
+    it("当同 id 在多个 provider 都存在时按 PROVIDER_PRIORITY 排序", () => {
+      const hit = lookupModel("custom", "deepseek-chat");
+      expect(hit?.maxOutput).toBeGreaterThan(0);
+    });
+  });
+});
+
+describe("Layer 2 优先级(B5:PPIO vs OpenRouter 同 id)", () => {
+  it("deepseek/deepseek-r1-0528 在 PPIO 和 OpenRouter 都有,按 PROVIDER_PRIORITY 取 ppio(第二梯队)而不是 openrouter(第三梯队)", () => {
+    const hit = lookupModel("custom", "deepseek/deepseek-r1-0528");
+    expect(hit).toBeDefined();
+    // PPIO 的 maxOutput 是 65536(reasoner),OpenRouter 的是 4096
+    expect(hit?.maxOutput).toBe(65536);
+  });
+
+  it("OpenRouter 专属带后缀 id(:free)命中 openrouter provider", () => {
+    const hit = lookupModel("custom", "google/gemma-2-9b-it:free");
+    expect(hit).toBeDefined();
+    expect(hit?.maxOutput).toBe(4096);
+  });
+
+  it("PPIO 专属带斜线 id 命中 ppio provider", () => {
+    const hit = lookupModel("ppio", "deepseek/deepseek-v3.2");
+    expect(hit?.maxOutput).toBe(8192);
+    expect(hit?.contextWindowTokens).toBe(131072);
+  });
+});
+
+describe("listEnabledModels", () => {
+  it("返回 provider 里 enabled !== false 的 models", () => {
+    const models = listEnabledModels("anthropic");
+    expect(models.length).toBeGreaterThan(0);
+    expect(models.every((m) => m.enabled !== false)).toBe(true);
+  });
+
+  it("未知 service 返回空数组", () => {
+    const models = listEnabledModels("nope");
+    expect(models).toEqual([]);
+  });
+});

+ 155 - 0
packages/core/src/__tests__/providers-schema.test.ts

@@ -0,0 +1,155 @@
+import { describe, it, expect } from "vitest";
+import { getAllEndpoints, getEndpoint } from "../llm/providers/index.js";
+
+describe("providers structural integrity", () => {
+  it("每个 provider 必填字段都存在", () => {
+    const gatewayProviders = new Set(["custom", "newapi"]);
+    for (const p of getAllEndpoints()) {
+      expect(p.id).toBeTruthy();
+      expect(p.label).toBeTruthy();
+      expect(p.api).toMatch(/^(openai-completions|openai-responses|anthropic-messages)$/);
+      // gateway/anchor provider 允许 baseUrl 为空(由用户填)
+      if (gatewayProviders.has(p.id)) {
+        expect(typeof p.baseUrl).toBe("string");
+      } else {
+        expect(p.baseUrl, `provider=${p.id}`).toBeTruthy();
+      }
+    }
+  });
+
+  it("每个 model card 必填字段都存在且 contextWindowTokens >= maxOutput", () => {
+    for (const p of getAllEndpoints()) {
+      for (const m of p.models) {
+        expect(m.id, `provider=${p.id}`).toBeTruthy();
+        expect(m.maxOutput, `provider=${p.id} model=${m.id}`).toBeGreaterThan(0);
+        expect(m.contextWindowTokens, `provider=${p.id} model=${m.id}`).toBeGreaterThanOrEqual(m.maxOutput);
+      }
+    }
+  });
+
+  it("每个 provider 的 id 唯一", () => {
+    const ids = getAllEndpoints().map((p) => p.id);
+    expect(new Set(ids).size).toBe(ids.length);
+  });
+
+  it("每个 provider 里 models 的 id 唯一", () => {
+    for (const p of getAllEndpoints()) {
+      const ids = p.models.map((m) => m.id);
+      expect(new Set(ids).size, `provider=${p.id} 有重复 model id`).toBe(ids.length);
+    }
+  });
+
+  it("DeepSeek 官方 API model card 跟进 V4", () => {
+    const deepseek = getEndpoint("deepseek");
+    expect(deepseek?.checkModel).toBe("deepseek-v4-flash");
+
+    expect(deepseek?.models).toEqual([
+      { id: "deepseek-v4-flash", maxOutput: 393216, contextWindowTokens: 1_000_000, enabled: true, releasedAt: "2026-04-24" },
+      { id: "deepseek-v4-pro", maxOutput: 393216, contextWindowTokens: 1_000_000, enabled: true, releasedAt: "2026-04-24" },
+      { id: "deepseek-chat", maxOutput: 393216, contextWindowTokens: 1_000_000, releasedAt: "2026-04-24" },
+      { id: "deepseek-reasoner", maxOutput: 393216, contextWindowTokens: 1_000_000, releasedAt: "2026-04-24" },
+    ]);
+  });
+
+  it("Zhipu check model uses a stable chat alias", () => {
+    const zhipu = getEndpoint("zhipu");
+    expect(zhipu?.checkModel).toBe("glm-4-flash");
+    expect(zhipu?.models.some((model) => model.id === "glm-4-flash" && model.enabled !== false)).toBe(true);
+  });
+
+  it("A 组至少有 5 个核心 provider", () => {
+    const ids = getAllEndpoints().map((p) => p.id);
+    expect(ids).toContain("anthropic");
+    expect(ids).toContain("openai");
+    expect(ids).toContain("google");
+    expect(ids).toContain("deepseek");
+    expect(ids).toContain("minimax");
+    // 阿里通义千问:仅保留 bailian(Anthropic 协议,工具调用更稳),
+    // 已删除重复的 qwen endpoint(OpenAI 协议同家)
+    expect(ids).toContain("bailian");
+    expect(ids).not.toContain("qwen");
+  });
+
+  it("B1:中国原厂批次 1 全部收录(10 个)", () => {
+    const ids = getAllEndpoints().map((p) => p.id);
+    for (const id of [
+      "moonshot", "zhipu", "siliconcloud", "ppio", "bailian",
+      "volcengine", "hunyuan", "baichuan", "stepfun", "wenxin",
+    ]) {
+      expect(ids).toContain(id);
+    }
+  });
+
+  it("B1:bailian 保留 anthropic-messages api(例外,不按 lobe 迁移)", () => {
+    expect(getEndpoint("bailian")?.api).toBe("anthropic-messages");
+    expect(getEndpoint("bailian")?.baseUrl).toContain("/anthropic");
+  });
+
+  it("B1:minimax 保留 anthropic-messages api(例外)", () => {
+    expect(getEndpoint("minimax")?.api).toBe("anthropic-messages");
+    expect(getEndpoint("minimax")?.baseUrl).toContain("/anthropic");
+  });
+
+  it("B2:中国原厂批次 2 全部收录(6 个)", () => {
+    const ids = getAllEndpoints().map((p) => p.id);
+    for (const id of ["spark", "sensenova", "tencentcloud", "xiaomimimo", "longcat", "internlm"]) {
+      expect(ids).toContain(id);
+    }
+  });
+
+  it("B3:中国原厂批次 3 全部收录(6 个,R5 已删 higress)", () => {
+    const ids = getAllEndpoints().map((p) => p.id);
+    for (const id of ["modelscope", "giteeai", "qiniu", "infiniai", "zeroone", "ai360"]) {
+      expect(ids).toContain(id);
+    }
+    expect(ids).not.toContain("higress");
+  });
+
+  it("B4:海外/本地/自定义/聚合/GH 全部收录(7 个)", () => {
+    const ids = getAllEndpoints().map((p) => p.id);
+    for (const id of ["ollama", "openrouter", "custom", "mistral", "xai", "newapi", "githubCopilot"]) {
+      expect(ids).toContain(id);
+    }
+  });
+
+  it("B4:custom / newapi baseUrl 为空(gateway 占位)", () => {
+    expect(getEndpoint("custom")?.baseUrl).toBe("");
+    expect(getEndpoint("newapi")?.baseUrl).toBe("");
+  });
+
+  it("B4:总 provider 数 = 34(不含 CodingPlan,R5 删 qwen / higress 后)", () => {
+    const nonCoding = getAllEndpoints().filter((p) => !p.id.endsWith("CodingPlan"));
+    expect(nonCoding.length).toBe(34);
+  });
+
+  it("B6:CodingPlan 7 个 provider 全部收录", () => {
+    const ids = getAllEndpoints().map((p) => p.id);
+    for (const id of [
+      "kimiCodingPlan", "minimaxCodingPlan", "bailianCodingPlan",
+      "glmCodingPlan", "volcengineCodingPlan", "opencodeCodingPlan",
+      "astronCodingPlan",
+    ]) {
+      expect(ids).toContain(id);
+    }
+  });
+
+  it("B6:总 provider 数 = 41 (34 base + 7 CodingPlan)", () => {
+    expect(getAllEndpoints().length).toBe(41);
+  });
+
+  it("B6:CodingPlan provider 都走 anthropic-messages", () => {
+    for (const id of [
+      "kimiCodingPlan", "minimaxCodingPlan", "bailianCodingPlan",
+      "glmCodingPlan", "volcengineCodingPlan", "opencodeCodingPlan",
+      "astronCodingPlan",
+    ]) {
+      expect(getEndpoint(id)?.api).toBe("anthropic-messages");
+    }
+  });
+
+  it("R3:endpoint 不再出现 piProvider 字段(已移到 provider-to-pi-ai adapter)", () => {
+    for (const ep of getAllEndpoints()) {
+      expect((ep as any).piProvider, `endpoint=${ep.id}`).toBeUndefined();
+    }
+  });
+});

+ 5 - 5
packages/core/src/__tests__/reviser.test.ts

@@ -54,7 +54,7 @@ describe("ReviserAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -107,7 +107,7 @@ describe("ReviserAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -165,7 +165,7 @@ describe("ReviserAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -237,7 +237,7 @@ describe("ReviserAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -369,7 +369,7 @@ describe("ReviserAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },

+ 1 - 1
packages/core/src/__tests__/scheduler.test.ts

@@ -11,7 +11,7 @@ function createConfig(): SchedulerConfig {
       defaults: {
         temperature: 0.7,
         maxTokens: 1024,
-        thinkingBudget: 0, maxTokensCap: null,
+        thinkingBudget: 0,
       },
     } as SchedulerConfig["client"],
     model: "test-model",

+ 71 - 0
packages/core/src/__tests__/secrets-migration.test.ts

@@ -0,0 +1,71 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { loadSecrets } from "../llm/secrets.js";
+
+describe("loadSecrets legacy service id migration", () => {
+  let root: string;
+
+  beforeEach(async () => {
+    root = await mkdtemp(join(tmpdir(), "inkos-secrets-mig-"));
+    await mkdir(join(root, ".inkos"), { recursive: true });
+  });
+
+  afterEach(async () => {
+    await rm(root, { recursive: true, force: true });
+  });
+
+  async function seedSecrets(data: unknown): Promise<void> {
+    await writeFile(join(root, ".inkos", "secrets.json"), JSON.stringify(data, null, 2), "utf-8");
+  }
+
+  async function readSecretsRaw(): Promise<any> {
+    return JSON.parse(await readFile(join(root, ".inkos", "secrets.json"), "utf-8"));
+  }
+
+  it("siliconflow -> siliconcloud 重命名(目标 id 不存在)", async () => {
+    await seedSecrets({ services: { siliconflow: { apiKey: "sk-legacy" } } });
+    const result = await loadSecrets(root);
+    expect(result.services.siliconcloud).toEqual({ apiKey: "sk-legacy" });
+    expect(result.services.siliconflow).toBeUndefined();
+
+    const onDisk = await readSecretsRaw();
+    expect(onDisk.services.siliconcloud).toEqual({ apiKey: "sk-legacy" });
+    expect(onDisk.services.siliconflow).toBeUndefined();
+  });
+
+  it("目标 id 已存在时不迁移(幂等)", async () => {
+    await seedSecrets({
+      services: {
+        siliconflow: { apiKey: "sk-legacy" },
+        siliconcloud: { apiKey: "sk-new" },
+      },
+    });
+    const result = await loadSecrets(root);
+    expect(result.services.siliconcloud).toEqual({ apiKey: "sk-new" });
+    expect(result.services.siliconflow).toEqual({ apiKey: "sk-legacy" });
+  });
+
+  it("无迁移时不重写磁盘", async () => {
+    await seedSecrets({ services: { openai: { apiKey: "sk-openai" } } });
+    const before = await readFile(join(root, ".inkos", "secrets.json"), "utf-8");
+    await loadSecrets(root);
+    const after = await readFile(join(root, ".inkos", "secrets.json"), "utf-8");
+    expect(after).toBe(before);
+  });
+
+  it("secrets 文件不存在时返回空 services,不报错", async () => {
+    await rm(join(root, ".inkos", "secrets.json"), { force: true });
+    const result = await loadSecrets(root);
+    expect(result).toEqual({ services: {} });
+  });
+
+  it("重复调用 loadSecrets 是幂等的", async () => {
+    await seedSecrets({ services: { siliconflow: { apiKey: "sk-legacy" } } });
+    await loadSecrets(root);
+    const r2 = await loadSecrets(root);
+    expect(r2.services.siliconcloud).toEqual({ apiKey: "sk-legacy" });
+    expect(r2.services.siliconflow).toBeUndefined();
+  });
+});

+ 4 - 4
packages/core/src/__tests__/service-presets-regression.test.ts

@@ -26,13 +26,13 @@ describe("service-presets regression", () => {
   });
 
   describe("listModelsForService", () => {
-    it("returns knownModels immediately for minimax without calling /models API", async () => {
+    it("returns provider bank models for minimax (B8 升级:provider.models 替代 preset.knownModels)", async () => {
       const models = await listModelsForService("minimax");
-      expect(models.length).toBe(7);
-      expect(models[0].id).toBe("MiniMax-M2.7");
+      expect(models.length).toBeGreaterThanOrEqual(7);
+      expect(models.some((m) => m.id === "MiniMax-M2.7")).toBe(true);
     });
 
-    it("returns empty for custom service", async () => {
+    it("returns empty for custom service without apikey + baseUrl", async () => {
       const models = await listModelsForService("custom");
       expect(models).toEqual([]);
     });

+ 0 - 3
packages/core/src/__tests__/state-validator-agent.test.ts

@@ -22,7 +22,6 @@ describe("StateValidatorAgent", () => {
           temperature: 0.7,
           maxTokens: 4096,
           thinkingBudget: 0,
-          maxTokensCap: null,
           extra: {},
         },
       },
@@ -65,7 +64,6 @@ describe("StateValidatorAgent", () => {
           temperature: 0.7,
           maxTokens: 8192,
           thinkingBudget: 0,
-          maxTokensCap: null,
           extra: {},
         },
       },
@@ -95,7 +93,6 @@ describe("StateValidatorAgent", () => {
           temperature: 0.7,
           maxTokens: 4096,
           thinkingBudget: 0,
-          maxTokensCap: null,
           extra: {},
         },
       },

+ 2 - 2
packages/core/src/__tests__/temperature-constraints.test.ts

@@ -36,9 +36,9 @@ describe("temperature constraints per service", () => {
     expect(preset?.temperatureRange).toEqual([0, 2]);
   });
 
-  it("minimax has range [0, 2]", () => {
+  it("minimax has range [0, 1] (R2-6: MiniMax Anthropic compat 官方上限 1)", () => {
     const preset = resolveServicePreset("minimax");
-    expect(preset?.temperatureRange).toEqual([0, 2]);
+    expect(preset?.temperatureRange).toEqual([0, 1]);
   });
 
   it("clampTemperature respects service range", () => {

+ 61 - 0
packages/core/src/__tests__/verify-service.test.ts

@@ -0,0 +1,61 @@
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { verifyService } from "../llm/providers/verify.js";
+
+describe("verifyService (B9)", () => {
+  const originalFetch = global.fetch;
+
+  afterEach(() => {
+    global.fetch = originalFetch;
+    vi.restoreAllMocks();
+  });
+
+  it("probe 成功 → probe.ok=true + chat 字段非 null(chat 步骤被执行)", async () => {
+    global.fetch = vi.fn()
+      // probe /models 成功
+      .mockResolvedValueOnce({
+        ok: true,
+        json: async () => ({ data: [{ id: "gpt-4o" }, { id: "gpt-4o-mini" }] }),
+      } as any)
+      // 后续 chat 请求失败(测试环境没真实 openai SDK 支持),但这 OK —— 只要 chat 被尝试了就行
+      .mockRejectedValue(new Error("test: no real chat backend"));
+
+    const result = await verifyService("openai", "sk-test");
+    expect(result.probe.ok).toBe(true);
+    expect(result.probe.models).toBe(2);
+    // chat 字段不为 null 说明 checkModel 存在 + chat 步骤被执行(ok=true/false 都可以)
+    expect(result.chat).not.toBeNull();
+    expect(typeof result.chat?.ok).toBe("boolean");
+    expect(result.chat?.latencyMs).toBeGreaterThanOrEqual(0);
+  });
+
+  it("probe 401 → probe.ok=false, error 带 401", async () => {
+    global.fetch = vi.fn().mockResolvedValue({
+      ok: false,
+      status: 401,
+      statusText: "Unauthorized",
+    } as any) as typeof fetch;
+
+    const result = await verifyService("openai", "wrong-key");
+    expect(result.probe.ok).toBe(false);
+    expect(result.probe.error).toContain("401");
+  });
+
+  it("probe 网络挂 → probe.ok=false, error 是 fetch 错误", async () => {
+    global.fetch = vi.fn().mockRejectedValue(new Error("ECONNREFUSED")) as typeof fetch;
+    const result = await verifyService("openai", "sk-test");
+    expect(result.probe.ok).toBe(false);
+    expect(result.probe.error).toContain("ECONNREFUSED");
+  });
+
+  it("provider 没 checkModel(custom baseUrl 空)→ chat 字段返回 null,不发 chat 请求", async () => {
+    // custom 没 checkModel,verifyService 跳过 chat step
+    const result = await verifyService("custom", "sk-x");
+    expect(result.chat).toBeNull();
+  });
+
+  it("未知 service → probe 报 '无 baseUrl'", async () => {
+    const result = await verifyService("nonexistent-xyz", "sk-test");
+    expect(result.probe.ok).toBe(false);
+    expect(result.probe.error).toContain("baseUrl");
+  });
+});

+ 8 - 8
packages/core/src/__tests__/writer.test.ts

@@ -101,7 +101,7 @@ describe("WriterAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -289,7 +289,7 @@ describe("WriterAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -438,7 +438,7 @@ describe("WriterAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -587,7 +587,7 @@ describe("WriterAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -712,7 +712,7 @@ describe("WriterAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -833,7 +833,7 @@ describe("WriterAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -957,7 +957,7 @@ describe("WriterAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },
@@ -1100,7 +1100,7 @@ describe("WriterAgent", () => {
         defaults: {
           temperature: 0.7,
           maxTokens: 4096,
-          thinkingBudget: 0, maxTokensCap: null,
+          thinkingBudget: 0,
           extra: {},
         },
       },

+ 4 - 0
packages/core/src/index.ts

@@ -188,6 +188,8 @@ export {
 export { resolveServiceModel, type ResolvedModel } from "./llm/service-resolver.js";
 export { loadSecrets, saveSecrets, getServiceApiKey, type SecretsFile } from "./llm/secrets.js";
 export { migrateConfig, type MigrationResult } from "./llm/config-migration.js";
+export { getAllEndpoints, getEndpoint, type InkosEndpoint, type InkosModel, type EndpointGroup } from "./llm/providers/index.js";
+export { probeModelsFromUpstream, type ProbedModel } from "./llm/providers/probe.js";
 
 // Agents
 export { BaseAgent, type AgentContext } from "./agents/base.js";
@@ -227,6 +229,8 @@ export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
 export { countChapterLength, resolveLengthCountingMode, formatLengthCount, buildLengthSpec, isOutsideSoftRange, isOutsideHardRange, chooseNormalizeMode, type LengthLanguage } from "./utils/length-metrics.js";
 export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.js";
 export { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, isApiKeyOptionalForEndpoint } from "./utils/config-loader.js";
+export { resolveEffectiveLLMConfig, type EffectiveLLMConfigResult, type EffectiveLLMDiagnostics, type LLMConfigCliOverrides, type LLMConfigMode, type LLMConsumer, type LLMValueSource } from "./utils/effective-llm-config.js";
+export { loadLLMEnvLayers, mergeEnvMaps, studioIgnoredEnv, cliOverlayEnv, legacyEnv, type LLMEnvLayers, type LLMEnvMap } from "./utils/llm-env.js";
 export { computeAnalytics, type AnalyticsData, type TokenStats } from "./utils/analytics.js";
 export {
   collectStaleHookDebt,

+ 102 - 47
packages/core/src/llm/provider.ts

@@ -15,6 +15,9 @@ import type {
   ToolCall as PiToolCall,
 } from "@mariozechner/pi-ai";
 import { resolveServicePreset } from "./service-presets.js";
+import { getEndpoint } from "./providers/index.js";
+import { lookupModel } from "./providers/lookup.js";
+
 
 // === Streaming Monitor Types ===
 
@@ -95,19 +98,9 @@ export interface LLMClient {
     readonly temperature: number;
     /**
      * Per-call fallback: 当 agent 调 chat() 不传 options.maxTokens 时用这个值。
-     * 不是硬上限——per-call 显式传的值会覆盖它,不会被它限制。
+     * 来自 providers bank 的 modelCard.maxOutput(v2.0.0 起)。
      */
     readonly maxTokens: number;
-    /**
-     * Per-call 硬上限。null 表示不封顶;非 null 表示给 chat() 的 per-call
-     * maxTokens 加一个 Math.min(perCall, cap) 约束。
-     *
-     * 语义必须跟 defaults.maxTokens 严格分开:maxTokens 是 fallback,
-     * maxTokensCap 是 cap。旧实现把两者用同一个数推导,导致 agent per-call 16384
-     * 被 config.maxTokens=8192 误裁(见 tests/__tests__/provider.test.ts 的
-     * "per-call maxTokens is not capped by config.maxTokens" 回归测试)。
-     */
-    readonly maxTokensCap: number | null;
     readonly thinkingBudget: number;
     readonly extra: Record<string, unknown>;
   };
@@ -141,14 +134,11 @@ export interface ChatWithToolsResult {
 // === Factory ===
 
 export function createLLMClient(config: LLMConfig): LLMClient {
+  // C1 (v2.0.0):config.maxTokens / maxTokensCap 已删除;defaults.maxTokens 完全从 modelCard 推导。
+  const _earlyCard = lookupModel(config.service ?? "custom", config.model);
   const defaults = {
     temperature: config.temperature ?? 0.7,
-    // fallback: agent 没传 per-call 时用这个
-    maxTokens: config.maxTokens ?? 8192,
-    // cap: 只在用户显式配 maxTokensCap 时生效;默认 null = 不封顶 per-call。
-    // **禁止**改成 `config.maxTokens ?? null` —— 那样会让 architect 的 per-call
-    // 16384 被用户 config.maxTokens=8192 自动裁剪,基础设定输出会被截断。
-    maxTokensCap: config.maxTokensCap ?? null,
+    maxTokens: _earlyCard?.maxOutput ?? 8192,
     thinkingBudget: config.thinkingBudget ?? 0,
     extra: config.extra ?? {},
   };
@@ -159,24 +149,41 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   // --- Build pi-ai Model object ---
   const serviceName = config.service ?? "custom";
   const preset = resolveServicePreset(serviceName);
-  const piApi = resolvePiApi(serviceName, config.apiFormat, preset?.api) as PiApi;
-  const baseUrl = config.baseUrl || preset?.baseUrl || "";
+  const inkosProvider = getEndpoint(serviceName);
+  const modelCard = lookupModel(serviceName, config.model);
+
+  const piApi = resolvePiApi(serviceName, config.apiFormat, (inkosProvider?.api ?? preset?.api) as PiApi) as PiApi;
+  const baseUrl = config.baseUrl || inkosProvider?.baseUrl || preset?.baseUrl || "";
   const extraHeaders = config.headers ?? parseEnvHeaders();
+  const compat = resolveProviderCompat(inkosProvider, baseUrl);
 
   const provider = config.provider === "anthropic" ? "anthropic" : "openai";
+  // pi-ai provider 字段:大多数情况 pi-ai 会按 baseUrl 自动嗅探(openrouter.ai / api.z.ai /
+  // api.x.ai / deepseek.com / anthropic.com 等)。这里只列 pi-ai 嗅探不到、需要显式指定的少数情况。
+  let piProvider: string;
+  if (inkosProvider?.id === "zhipu") piProvider = "zai";
+  else if (inkosProvider?.id === "openrouter") piProvider = "openrouter";
+  else if (inkosProvider?.id === "githubCopilot") piProvider = "githubCopilot";
+  else if (inkosProvider?.api === "anthropic-messages") piProvider = "anthropic";
+  else piProvider = provider;
 
   const piModel: PiModel<PiApi> = {
-    id: config.model,
+    id: modelCard?.deploymentName ?? config.model,
     name: config.model,
     api: piApi,
-    provider,
+    provider: piProvider,
     baseUrl,
+    // 注意:piModel.reasoning 是"激活 reasoning 模式"标志(会让 pi-ai 把 system 改成 developer role 等),
+    // 不是"模型能力"标签。只有用户显式配了 thinkingBudget > 0 才启用 reasoning mode。
+    // 千万不要从 lobe abilities.reasoning 自动推导,否则 Moonshot 这类不支持 developer role 的服务
+    // 会把 content 吃掉,只返回 reasoning_content(见 R4 bug 1 诊断)。
     reasoning: (config.thinkingBudget ?? 0) > 0,
     input: ["text"] as ("text" | "image")[],
     cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
-    contextWindow: 128_000,
-    maxTokens: config.maxTokens ?? 8192,
+    contextWindow: modelCard?.contextWindowTokens ?? 128_000,
+    maxTokens: modelCard?.maxOutput ?? 8192,
     ...(extraHeaders ? { headers: extraHeaders } : {}),
+    ...(compat ? { compat } : {}),
   };
 
   return {
@@ -202,6 +209,17 @@ function resolvePiApi(
   return (presetApi ?? "openai-completions") as PiApi;
 }
 
+function resolveProviderCompat(
+  provider: ReturnType<typeof getEndpoint>,
+  baseUrl: string,
+): Record<string, unknown> | undefined {
+  const compat = {
+    ...(provider?.compat ?? {}),
+    ...(baseUrl.includes("generativelanguage.googleapis.com") ? { supportsStore: false } : {}),
+  };
+  return Object.keys(compat).length > 0 ? compat : undefined;
+}
+
 function parseEnvHeaders(): Record<string, string> | undefined {
   const raw = process.env.INKOS_LLM_HEADERS;
   if (!raw) return undefined;
@@ -247,30 +265,34 @@ function stripReservedKeys(extra: Record<string, unknown>): Record<string, unkno
 
 // === Fixed-Temperature Model Clamp ===
 //
-// 部分 thinking 模型(如 Moonshot kimi-k2.5、kimi-thinking-preview)强制要求
-// temperature === 1,其他值会被 API 直接 400 拒绝。为让这类模型能和 inkos
-// 已有的 per-call 温度调参(0.1 validator → 0.8 architect brainstorm)共存,
-// 在 provider 层统一夹制:命中名单就把传入的 temperature 强制改成 1,并对
-// 每个模型名打一次 warning 提示用户。
-
-function requiresFixedTemperature(model: string): boolean {
-  const lower = model.toLowerCase();
-  // kimi-k2.5 及其子变体(k2.5-preview 等),以及任何名字里带 "thinking" 的模型
-  return lower.startsWith("kimi-k2.5") || lower.includes("thinking");
-}
+// 部分 thinking 模型(如 Moonshot kimi-k2.5/k2.6、kimi-k2-thinking)的 API
+// 硬要求 temperature === 1,其他值会被直接 400 拒绝(Moonshot 返回
+// `invalid temperature: only 1 is allowed for this model`)。
+//
+// inkos 让 writer/validator/architect 各自带 per-call 温度(0.1~1.5),
+// 所以 provider 层统一夹制:如果 bank 里模型卡标了 temperature 字段,
+// 就把 per-call 温度 clamp 到那个值,并对每个模型名打一次 warning。
+//
+// 这个字段只表达"服务端硬约束",普通模型不要标,避免误伤 per-call 调参。
 
 const warnedFixedTemperatureModels = new Set<string>();
 
-function clampTemperatureForModel(model: string, requested: number): number {
-  if (!requiresFixedTemperature(model)) return requested;
-  if (requested === 1) return 1;
+function clampTemperatureForModel(
+  service: string | undefined,
+  model: string,
+  requested: number,
+): number {
+  const card = service ? lookupModel(service, model) : undefined;
+  if (card?.temperature === undefined) return requested;
+  const locked = card.temperature;
+  if (requested === locked) return locked;
   if (!warnedFixedTemperatureModels.has(model)) {
     warnedFixedTemperatureModels.add(model);
     console.warn(
-      `[inkos] 模型 "${model}" 是 thinking 模型,强制 temperature=1(原请求值 ${requested})`,
+      `[inkos] 模型 "${model}" API 要求 temperature=${locked},已 clamp(原值 ${requested})`,
     );
   }
-  return 1;
+  return locked;
 }
 
 // 仅测试用:清空 warning 去重集合。
@@ -287,12 +309,23 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
     : "";
 
   if (msg.includes("400")) {
+    // 抽上游 error body 的 message / reason / code(和下方 5xx 一致),让真实错因浮到用户面前
+    let detail = "";
+    if (error && typeof error === "object") {
+      const err = error as { error?: unknown; body?: unknown; message?: string };
+      const bodyLike = err.error ?? err.body;
+      if (bodyLike && typeof bodyLike === "object") {
+        const b = bodyLike as { reason?: string; message?: string; code?: number | string; type?: string };
+        if (b.message) detail = b.type ? `${b.type}: ${b.message}` : b.message;
+        else if (b.reason) detail = b.reason;
+      }
+    }
     return new Error(
-      `API 返回 400 (请求参数错误)。可能原因:\n` +
-      `  1. 模型名称不正确(检查 INKOS_LLM_MODEL)\n` +
-      `  2. 提供方不支持某些参数(如 max_tokens、stream)\n` +
-      `  3. 消息格式不兼容(部分提供方不支持 system role)\n` +
-      `  建议:检查提供方文档,确认该接口要求流式开启、流式关闭,还是根本不支持 stream${ctxLine}`,
+      `API 返回 400(请求参数错误)。${detail ? `上游详情:${detail}。\n` : ""}` +
+      `常见原因:\n` +
+      `  1. temperature / max_tokens 超出模型约束(如 Moonshot kimi-k2.X 强制 temperature=1)\n` +
+      `  2. 模型名称不正确或未上架\n` +
+      `  3. 消息格式不兼容(部分服务不支持 system role 或 developer role)${ctxLine}`,
     );
   }
   if (msg.includes("403")) {
@@ -323,6 +356,27 @@ function wrapLLMError(error: unknown, context?: { readonly baseUrl?: string; rea
       `  建议:检查 INKOS_LLM_BASE_URL 是否包含完整路径(如 /v1)`,
     );
   }
+  // R4 Bug 2: 5xx "status code (no body)" — 尝试从 OpenAI SDK APIError 里抽 body 给用户看具体原因
+  // (如 PPIO 的 {"code":500,"reason":"MODEL_NOT_AVAILABLE","message":"model not available"})
+  if (msg.includes("status code") && msg.includes("no body")) {
+    let detail = "";
+    if (error && typeof error === "object") {
+      const err = error as { error?: unknown; body?: unknown; message?: string };
+      const bodyLike = err.error ?? err.body;
+      if (bodyLike && typeof bodyLike === "object") {
+        const b = bodyLike as { reason?: string; message?: string; code?: number | string };
+        if (b.reason) detail = `${b.reason}${b.message ? `: ${b.message}` : ""}`;
+        else if (b.message) detail = b.message;
+      }
+    }
+    return new Error(
+      `API 返回 5xx(上游服务异常)。${detail ? `上游详情:${detail}。` : ""}\n` +
+      `可能原因:\n` +
+      `  1. 模型在 /models 列表但 inference 未上架(如 PPIO 返回 MODEL_NOT_AVAILABLE)\n` +
+      `  2. 服务端临时故障,稍后重试\n` +
+      `  3. 当前 apikey 无权限调用该模型${ctxLine}`,
+    );
+  }
   return error instanceof Error ? error : new Error(msg);
 }
 
@@ -750,14 +804,14 @@ export async function chatCompletion(
     readonly onTextDelta?: (text: string) => void;
   },
 ): Promise<LLMResponse> {
-  const perCallMax = options?.maxTokens ?? client.defaults.maxTokens;
-  const cap = client.defaults.maxTokensCap;
+  // C1 (v2.0.0):删除 maxTokensCap 机制。per-call 显式传的 maxTokens 永远不被裁剪。
   const resolved = {
     temperature: clampTemperatureForModel(
+      client.service,
       model,
       options?.temperature ?? client.defaults.temperature,
     ),
-    maxTokens: cap !== null ? Math.min(perCallMax, cap) : perCallMax,
+    maxTokens: options?.maxTokens ?? client.defaults.maxTokens,
     extra: client.defaults.extra,
   };
   const onStreamProgress = options?.onStreamProgress;
@@ -796,6 +850,7 @@ export async function chatWithTools(
   try {
     const resolved = {
       temperature: clampTemperatureForModel(
+        client.service,
         model,
         options?.temperature ?? client.defaults.temperature,
       ),

+ 42 - 0
packages/core/src/llm/providers/endpoints/ai360.ts

@@ -0,0 +1,42 @@
+/**
+ * 360 智脑 (360 AI)
+ *
+ * - 官网:https://ai.360.com/
+ * - 控制台 / API key:https://ai.360.com/platform/keys
+ * - API 文档:https://ai.360.com/platform/docs
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const AI360: InkosEndpoint = {
+  id: "ai360",
+  label: "360 智脑",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://api.360.cn/v1",
+  checkModel: "360gpt2-pro",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.5,
+  writingTemperature: 1,
+  models: [
+    { id: "360zhinao3-o1.5", maxOutput: 4096, contextWindowTokens: 128000, enabled: true },
+    { id: "360zhinao2-o1.5", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "360zhinao2-o1", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "360zhinao-pro-32k-thinking-vision", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "360zhinao-turbo", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "360zhinao-turbo-qwen-plus", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "360gpt2-o1", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "360gpt2-pro", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "360gpt-pro", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "360gpt-pro-trans", maxOutput: 4096, contextWindowTokens: 4096 },
+    { id: "360gpt-turbo", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "deepseek-v3.2", maxOutput: 4096, contextWindowTokens: 65536 },
+    { id: "paratera/deepseek-v3.2", maxOutput: 4096, contextWindowTokens: 4096 },
+    { id: "sophnet/deepseek-v3.2", maxOutput: 4096, contextWindowTokens: 4096 },
+    { id: "deepseek-v3.2-speciale", maxOutput: 4096, contextWindowTokens: 65536 },
+    { id: "360/deepseek-r1", maxOutput: 4096, contextWindowTokens: 65536 },
+    { id: "volcengine/doubao-seed-2-0-lite", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "volcengine/doubao-seed-2-0-mini", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "volcengine/doubao-seed-2-0-pro", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "volcengine/doubao-seed-2-0-code", maxOutput: 4096, contextWindowTokens: 32000 },
+  ],
+};

+ 82 - 0
packages/core/src/llm/providers/endpoints/anthropic.ts

@@ -0,0 +1,82 @@
+/**
+ * Anthropic (Claude)
+ *
+ * - 官网:https://www.anthropic.com/
+ * - 控制台:https://console.anthropic.com/
+ * - API key:https://console.anthropic.com/settings/keys
+ * - API 文档:https://docs.anthropic.com/en/api/
+ * - 模型列表:https://docs.anthropic.com/en/docs/about-claude/models/overview
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const ANTHROPIC: InkosEndpoint = {
+  id: "anthropic",
+  label: "Anthropic",
+  group: "overseas",
+  api: "anthropic-messages",
+  baseUrl: "https://api.anthropic.com",
+  checkModel: "claude-haiku-4-5-20251001",
+  temperatureRange: [0, 1],
+  defaultTemperature: 1.0,
+  writingTemperature: 1.0,
+  temperatureHint: "不要同时改 temperature 和 top_p",
+  models: [
+    {
+      id: "claude-opus-4-6",
+      maxOutput: 128_000,
+      contextWindowTokens: 1_000_000,
+      enabled: true,
+      releasedAt: "2026-02-05",
+    },
+    {
+      id: "claude-sonnet-4-6",
+      maxOutput: 64_000,
+      contextWindowTokens: 1_000_000,
+      enabled: true,
+      releasedAt: "2026-02-17",
+    },
+    {
+      id: "claude-opus-4-5-20251101",
+      maxOutput: 64_000,
+      contextWindowTokens: 200_000,
+      releasedAt: "2025-11-24",
+    },
+    {
+      id: "claude-sonnet-4-5-20250929",
+      maxOutput: 64_000,
+      contextWindowTokens: 200_000,
+      releasedAt: "2025-09-29",
+    },
+    {
+      id: "claude-haiku-4-5-20251001",
+      maxOutput: 64_000,
+      contextWindowTokens: 200_000,
+      enabled: true,
+      releasedAt: "2025-10-16",
+    },
+    {
+      id: "claude-opus-4-1-20250805",
+      maxOutput: 32_000,
+      contextWindowTokens: 200_000,
+      releasedAt: "2025-08-05",
+    },
+    {
+      id: "claude-opus-4-20250514",
+      maxOutput: 32_000,
+      contextWindowTokens: 200_000,
+      releasedAt: "2025-05-23",
+    },
+    {
+      id: "claude-sonnet-4-20250514",
+      maxOutput: 64_000,
+      contextWindowTokens: 200_000,
+      releasedAt: "2025-05-23",
+    },
+    {
+      id: "claude-3-haiku-20240307",
+      maxOutput: 4096,
+      contextWindowTokens: 200_000,
+      releasedAt: "2024-03-07",
+    },
+  ],
+};

+ 30 - 0
packages/core/src/llm/providers/endpoints/astronCodingPlan.ts

@@ -0,0 +1,30 @@
+/**
+ * 讯飞星辰 Astron Coding Plan
+ *
+ * - 官网:https://training.xfyun.cn/maas
+ * - 控制台 / 订阅入口:https://training.xfyun.cn/maas/coding
+ * - API 文档:https://www.xfyun.cn/doc/spark/CodingPlan.html
+ * - OpenAI 协议 baseUrl:https://maas-coding-api.cn-huabei-1.xf-yun.com/v2
+ * - Anthropic 协议 baseUrl:https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic
+ *
+ * 讯飞星辰 MaaS 针对编程场景的订阅套餐。订阅包里的底层模型由用户在控制台切换,
+ * 对外统一 id 是 astron-code-latest(无需根据底层模型改配置)。
+ * inkos 走 Anthropic 协议接入(和 Claude Code 一致),agent 场景工具调用更稳。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const ASTRON_CODING_PLAN: InkosEndpoint = {
+  id: "astronCodingPlan",
+  label: "讯飞星辰 Astron Coding Plan",
+  group: "codingPlan",
+  api: "anthropic-messages",
+  baseUrl: "https://maas-coding-api.cn-huabei-1.xf-yun.com/anthropic",
+  modelsBaseUrl: "https://maas-coding-api.cn-huabei-1.xf-yun.com/v2",
+  checkModel: "astron-code-latest",
+  temperatureRange: [0, 1],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "astron-code-latest", maxOutput: 32768, contextWindowTokens: 131072, enabled: true },
+  ],
+};

+ 28 - 0
packages/core/src/llm/providers/endpoints/baichuan.ts

@@ -0,0 +1,28 @@
+/**
+ * 百川智能 (Baichuan AI)
+ *
+ * - 官网:https://www.baichuan-ai.com/
+ * - 控制台 / API key:https://platform.baichuan-ai.com/console/apikey
+ * - API 文档:https://platform.baichuan-ai.com/docs/api
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const BAICHUAN: InkosEndpoint = {
+  id: "baichuan",
+  label: "百川智能",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://api.baichuan-ai.com/v1",
+  checkModel: "Baichuan4",
+  temperatureRange: [0, 1],
+  defaultTemperature: 0.3,
+  writingTemperature: 1,
+  models: [
+    { id: "Baichuan4", maxOutput: 4096, contextWindowTokens: 32768, enabled: true },
+    { id: "Baichuan4-Turbo", maxOutput: 4096, contextWindowTokens: 32768, enabled: true },
+    { id: "Baichuan4-Air", maxOutput: 4096, contextWindowTokens: 32768, enabled: true },
+    { id: "Baichuan3-Turbo", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "Baichuan3-Turbo-128k", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "Baichuan2-Turbo", maxOutput: 8192, contextWindowTokens: 32768 },
+  ],
+};

+ 65 - 0
packages/core/src/llm/providers/endpoints/bailian.ts

@@ -0,0 +1,65 @@
+/**
+ * 阿里云百炼(DashScope)— Anthropic 通道
+ *
+ * - 控制台:https://bailian.console.aliyun.com/
+ * - API key:https://bailian.console.aliyun.com/?tab=model#/api-key
+ * - 模型列表:https://help.aliyun.com/zh/model-studio/getting-started/models
+ * - Anthropic 兼容接入:https://help.aliyun.com/zh/model-studio/developer-reference/use-anthropic-sdk
+ *
+ * inkos 用 /apps/anthropic 接入(agent 场景工具调用更稳)。
+ *
+ * **重要**:百炼有两条通道,模型清单**不完全对等**:
+ *   - OpenAI 兼容(/compatible-mode/v1):全量 236+ 模型(qwen3.6、kimi-k2.6、deepseek-v3.2 等)
+ *   - Anthropic 兼容(/apps/anthropic):精选 20+ 模型,主要是 qwen 主力 + 少数代理(kimi-k2.5、
+ *     kimi-k2-thinking、MiniMax-M2.5/2.1、glm-5/5.1/4.7/4.6)
+ *
+ * 下面的清单是 2026-04-23 对 /apps/anthropic 通道逐一 live 验证过的子集,
+ * 不能从 OpenAI 通道的 /models 清单直接抄——kimi-k2.6、deepseek-v3.2、
+ * qwen3-235b / qwen3-32b 等 OpenAI 通道支持的 id,Anthropic 通道会 400。
+ *
+ * 同理:不设 modelsBaseUrl 让 live /models probe 走 OpenAI 通道,那会拉到
+ * 大量 Anthropic 通道不支持的 id,用户选了就 400。这里宁可只用 bank 兜底。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const BAILIAN: InkosEndpoint = {
+  id: "bailian",
+  label: "百炼 (通义千问)",
+  group: "china",
+  api: "anthropic-messages",
+  baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
+  checkModel: "qwen-turbo",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    // --- Qwen3.6 系列(2026-04 发布,Anthropic 通道已放开) ---
+    { id: "qwen3.6-max-preview", maxOutput: 65536, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-04-21" },
+    { id: "qwen3.6-plus", maxOutput: 65536, contextWindowTokens: 1000000, enabled: true, releasedAt: "2026-04-09" },
+    { id: "qwen3.6-flash", maxOutput: 65536, contextWindowTokens: 1000000, enabled: true, releasedAt: "2026-04-17" },
+    { id: "qwen3.6-27b", maxOutput: 65536, contextWindowTokens: 262144, releasedAt: "2026-04-23" },
+    // --- Qwen3.5 系列 ---
+    { id: "qwen3.5-plus", maxOutput: 65536, contextWindowTokens: 1000000, enabled: true, releasedAt: "2026-02-15" },
+    { id: "qwen3.5-flash", maxOutput: 65536, contextWindowTokens: 1000000, enabled: true, releasedAt: "2026-02-24" },
+    { id: "qwen3.5-397b-a17b", maxOutput: 65536, contextWindowTokens: 262144, releasedAt: "2026-02-16" },
+    { id: "qwen3.5-122b-a10b", maxOutput: 65536, contextWindowTokens: 262144, releasedAt: "2026-02-24" },
+    { id: "qwen3.5-35b-a3b", maxOutput: 65536, contextWindowTokens: 262144, releasedAt: "2026-02-24" },
+    { id: "qwen3.5-27b", maxOutput: 65536, contextWindowTokens: 262144, releasedAt: "2026-02-24" },
+    // --- Qwen 通用 ---
+    { id: "qwen3-max", maxOutput: 65536, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-01-23" },
+    { id: "qwen3-max-preview", maxOutput: 65536, contextWindowTokens: 262144, releasedAt: "2025-10-30" },
+    { id: "qwen-max", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "qwen-plus", maxOutput: 32768, contextWindowTokens: 1000000 },
+    { id: "qwen-flash", maxOutput: 32768, contextWindowTokens: 1000000, releasedAt: "2025-07-28" },
+    { id: "qwen-turbo", maxOutput: 16384, contextWindowTokens: 1000000, releasedAt: "2025-07-15" },
+    // --- 第三方代理(Anthropic 通道放开的那部分) ---
+    { id: "kimi-k2.5", maxOutput: 32768, contextWindowTokens: 262144, temperature: 1 },
+    { id: "kimi-k2-thinking", maxOutput: 16384, contextWindowTokens: 262144, releasedAt: "2025-11-10", temperature: 1 },
+    { id: "MiniMax-M2.5", maxOutput: 32768, contextWindowTokens: 196608 },
+    { id: "MiniMax-M2.1", maxOutput: 32768, contextWindowTokens: 204800 },
+    { id: "glm-5.1", maxOutput: 16384, contextWindowTokens: 202752, enabled: true, releasedAt: "2026-04-23" },
+    { id: "glm-5", maxOutput: 16384, contextWindowTokens: 202752 },
+    { id: "glm-4.7", maxOutput: 16384, contextWindowTokens: 202752 },
+    { id: "glm-4.6", maxOutput: 16384, contextWindowTokens: 202752 },
+  ],
+};

+ 30 - 0
packages/core/src/llm/providers/endpoints/bailianCodingPlan.ts

@@ -0,0 +1,30 @@
+/**
+ * 百炼 CodingPlan (通义灵码订阅)
+ *
+ * - 官网:https://tongyi.aliyun.com/lingma
+ * - 订阅套餐,非即用即付。具体模型取决于套餐权益。
+ * - 模型列表:https://help.aliyun.com/zh/model-studio/coding-plan
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const BAILIAN_CODING_PLAN: InkosEndpoint = {
+  id: "bailianCodingPlan",
+  label: "百炼 Coding Plan",
+  group: "codingPlan",
+  api: "anthropic-messages",
+  baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
+  checkModel: "qwen-max",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "qwen3.5-plus", maxOutput: 65536, contextWindowTokens: 1000000, enabled: true, releasedAt: "2026-02-15" },
+    { id: "qwen3-coder-plus", maxOutput: 65536, contextWindowTokens: 1000000, releasedAt: "2025-09-23" },
+    { id: "qwen3-max-2026-01-23", maxOutput: 65536, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-01-23" },
+    { id: "qwen3-coder-next", maxOutput: 65536, contextWindowTokens: 262144, releasedAt: "2026-02-15" },
+    { id: "glm-5", maxOutput: 131072, contextWindowTokens: 200000, enabled: true, releasedAt: "2026-02-12" },
+    { id: "glm-4.7", maxOutput: 131072, contextWindowTokens: 200000, enabled: true, releasedAt: "2025-12-01" },
+    { id: "kimi-k2.5", maxOutput: 32768, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-01-27", temperature: 1 },
+    { id: "MiniMax-M2.5", maxOutput: 131072, contextWindowTokens: 204800, enabled: true, releasedAt: "2026-02-12" },
+  ],
+};

+ 22 - 0
packages/core/src/llm/providers/endpoints/custom.ts

@@ -0,0 +1,22 @@
+/**
+ * 自定义 OpenAI 兼容端点
+ *
+ * - 任意兼容 OpenAI /v1/chat/completions 协议的服务都可通过此 endpoint 接入。
+ * - 模型列表走 live /models probe,bank 不预置任何条目。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+/**
+ * custom 是"用户自填 baseUrl 的中转站"的锚点 provider。
+ * models 为空——lookup 对 custom service 走 Layer 2 全局扫。
+ * checkModel 为 undefined——verifyService 对 custom 只做 probe 不做 chat hello。
+ * listModelsForService 对 custom 用用户填的 baseUrl 走 live /models probe,
+ * 失败再 fallback 到全局 bank 反查补元数据。
+ */
+export const CUSTOM: InkosEndpoint = {
+  id: "custom",
+  label: "自定义端点",
+  api: "openai-completions",
+  baseUrl: "",
+  models: [],
+};

+ 34 - 0
packages/core/src/llm/providers/endpoints/deepseek.ts

@@ -0,0 +1,34 @@
+/**
+ * DeepSeek
+ *
+ * - 官网:https://www.deepseek.com/
+ * - 控制台:https://platform.deepseek.com/
+ * - API key:https://platform.deepseek.com/api_keys
+ * - API 文档:https://api-docs.deepseek.com/
+ * - 模型列表:https://api-docs.deepseek.com/quick_start/pricing
+ * - V4 开源模型:https://huggingface.co/collections/deepseek-ai/deepseek-v4
+ *
+ * 官方 API 主模型:deepseek-v4-flash / deepseek-v4-pro。
+ * deepseek-chat / deepseek-reasoner 是兼容别名,官方标注将在 2026-07-24 废弃,
+ * 分别对应 deepseek-v4-flash 的非思考模式 / 思考模式。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const DEEPSEEK: InkosEndpoint = {
+  id: "deepseek",
+  label: "DeepSeek",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://api.deepseek.com",
+  checkModel: "deepseek-v4-flash",
+  temperatureRange: [0, 2],
+  defaultTemperature: 1,
+  writingTemperature: 1.5,
+  temperatureHint: "创意写作推荐 1.5",
+  models: [
+    { id: "deepseek-v4-flash", maxOutput: 393216, contextWindowTokens: 1_000_000, enabled: true, releasedAt: "2026-04-24" },
+    { id: "deepseek-v4-pro", maxOutput: 393216, contextWindowTokens: 1_000_000, enabled: true, releasedAt: "2026-04-24" },
+    { id: "deepseek-chat", maxOutput: 393216, contextWindowTokens: 1_000_000, releasedAt: "2026-04-24" },
+    { id: "deepseek-reasoner", maxOutput: 393216, contextWindowTokens: 1_000_000, releasedAt: "2026-04-24" },
+  ],
+};

+ 41 - 0
packages/core/src/llm/providers/endpoints/giteeai.ts

@@ -0,0 +1,41 @@
+/**
+ * Gitee AI
+ *
+ * - 官网:https://ai.gitee.com/
+ * - 控制台 / API key:https://ai.gitee.com/dashboard/settings/tokens
+ * - API 文档:https://ai.gitee.com/docs/openapi/serverless
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const GITEEAI: InkosEndpoint = {
+  id: "giteeai",
+  label: "Gitee AI",
+  group: "aggregator",
+  api: "openai-completions",
+  baseUrl: "https://ai.gitee.com/v1",
+  checkModel: "Qwen2.5-72B-Instruct",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "DeepSeek-R1-Distill-Qwen-1.5B", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "DeepSeek-R1-Distill-Qwen-7B", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "DeepSeek-R1-Distill-Qwen-14B", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "DeepSeek-R1-Distill-Qwen-32B", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "QwQ-32B-Preview", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "Qwen2.5-72B-Instruct", maxOutput: 4096, contextWindowTokens: 16000, enabled: true },
+    { id: "Qwen2.5-32B-Instruct", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "Qwen2.5-14B-Instruct", maxOutput: 4096, contextWindowTokens: 24000, enabled: true },
+    { id: "Qwen2.5-7B-Instruct", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "Qwen2-72B-Instruct", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "Qwen2-7B-Instruct", maxOutput: 4096, contextWindowTokens: 24000 },
+    { id: "Qwen2.5-Coder-32B-Instruct", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "Qwen2.5-Coder-14B-Instruct", maxOutput: 4096, contextWindowTokens: 24000, enabled: true },
+    { id: "Qwen2-VL-72B", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "InternVL2.5-26B", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "InternVL2-8B", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "glm-4-9b-chat", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+    { id: "deepseek-coder-33B-instruct", maxOutput: 4096, contextWindowTokens: 8000, enabled: true },
+    { id: "codegeex4-all-9b", maxOutput: 4096, contextWindowTokens: 32000, enabled: true },
+  ],
+};

+ 43 - 0
packages/core/src/llm/providers/endpoints/githubCopilot.ts

@@ -0,0 +1,43 @@
+/**
+ * GitHub Copilot
+ *
+ * - 官网:https://github.com/features/copilot
+ * - 通过 GitHub OAuth 获取 token 后走 Copilot API,inkos 用它作为 LLM 入口需要用户已有 Copilot 订阅。
+ * - 接入细节:https://docs.github.com/en/copilot
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const GITHUB_COPILOT: InkosEndpoint = {
+  id: "githubCopilot",
+  label: "GitHub Copilot",
+  group: "local",
+  api: "openai-responses",
+  baseUrl: "https://api.githubcopilot.com",
+  checkModel: "gpt-4o",
+  temperatureRange: [0, 2],
+  defaultTemperature: 1,
+  writingTemperature: 1,
+  models: [
+    { id: "gpt-5.4", maxOutput: 4096, contextWindowTokens: 400000, enabled: true, releasedAt: "2026-03-05" },
+    { id: "gpt-5.4-mini", maxOutput: 4096, contextWindowTokens: 400000, enabled: true, releasedAt: "2026-03-18" },
+    { id: "gpt-5.3-codex", maxOutput: 4096, contextWindowTokens: 400000, releasedAt: "2026-02-05" },
+    { id: "gpt-5.2", maxOutput: 4096, contextWindowTokens: 192000, releasedAt: "2025-12-11" },
+    { id: "gpt-5.2-codex", maxOutput: 4096, contextWindowTokens: 400000, releasedAt: "2025-12-18" },
+    { id: "gpt-5.1", maxOutput: 4096, contextWindowTokens: 192000, releasedAt: "2025-11-13" },
+    { id: "gpt-5-mini", maxOutput: 4096, contextWindowTokens: 192000, releasedAt: "2025-08-07" },
+    { id: "gpt-4.1", maxOutput: 4096, contextWindowTokens: 128000, releasedAt: "2025-04-14" },
+    { id: "claude-opus-4.6", maxOutput: 4096, contextWindowTokens: 139000, enabled: true, releasedAt: "2026-02-05" },
+    { id: "claude-opus-4.6-fast", maxOutput: 4096, contextWindowTokens: 139000, releasedAt: "2026-02-05" },
+    { id: "claude-sonnet-4.6", maxOutput: 4096, contextWindowTokens: 139000, enabled: true, releasedAt: "2026-02-17" },
+    { id: "claude-opus-4.5", maxOutput: 4096, contextWindowTokens: 139000, releasedAt: "2025-11-24" },
+    { id: "claude-sonnet-4.5", maxOutput: 4096, contextWindowTokens: 139000, releasedAt: "2025-09-29" },
+    { id: "claude-haiku-4.5", maxOutput: 4096, contextWindowTokens: 139000, enabled: true, releasedAt: "2025-10-16" },
+    { id: "claude-sonnet-4", maxOutput: 4096, contextWindowTokens: 139000, releasedAt: "2025-05-23" },
+    { id: "gemini-3.1-pro-preview", maxOutput: 4096, contextWindowTokens: 173000, enabled: true, releasedAt: "2026-02-19" },
+    { id: "gemini-3-flash-preview", maxOutput: 4096, contextWindowTokens: 173000, releasedAt: "2025-12-17" },
+    { id: "gemini-2.5-pro", maxOutput: 4096, contextWindowTokens: 173000, releasedAt: "2025-06-17" },
+    { id: "grok-code-fast-1", maxOutput: 4096, contextWindowTokens: 173000, enabled: true, releasedAt: "2025-08-27" },
+    { id: "oswe-vscode-prime", maxOutput: 4096, contextWindowTokens: 264000, enabled: true },
+    { id: "oswe-vscode-secondary", maxOutput: 4096, contextWindowTokens: 264000 },
+  ],
+};

+ 28 - 0
packages/core/src/llm/providers/endpoints/glmCodingPlan.ts

@@ -0,0 +1,28 @@
+/**
+ * 智谱 GLM CodingPlan
+ *
+ * - 官网:https://bigmodel.cn/coding-plan
+ * - 订阅套餐,针对编程场景优化过的 GLM 变体。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const GLM_CODING_PLAN: InkosEndpoint = {
+  id: "glmCodingPlan",
+  label: "GLM Coding Plan",
+  group: "codingPlan",
+  api: "anthropic-messages",
+  baseUrl: "https://api.z.ai/api/anthropic",
+  checkModel: "glm-5.1",
+  temperatureRange: [0, 1],
+  defaultTemperature: 0.95,
+  writingTemperature: 0.95,
+  models: [
+    { id: "GLM-5.1", maxOutput: 131072, contextWindowTokens: 204800, enabled: true, releasedAt: "2026-03-27" },
+    { id: "GLM-5", maxOutput: 131072, contextWindowTokens: 200000, enabled: true, releasedAt: "2026-02-12" },
+    { id: "GLM-5-Turbo", maxOutput: 131072, contextWindowTokens: 200000, enabled: true, releasedAt: "2026-02-12" },
+    { id: "GLM-4.7", maxOutput: 131072, contextWindowTokens: 200000, enabled: true, releasedAt: "2025-12-01" },
+    { id: "GLM-4.6", maxOutput: 65536, contextWindowTokens: 202752, releasedAt: "2025-12-01" },
+    { id: "GLM-4.5", maxOutput: 65536, contextWindowTokens: 202752, releasedAt: "2025-12-01" },
+    { id: "GLM-4.5-Air", maxOutput: 65536, contextWindowTokens: 202752, releasedAt: "2025-12-01" },
+  ],
+};

+ 51 - 0
packages/core/src/llm/providers/endpoints/google.ts

@@ -0,0 +1,51 @@
+/**
+ * Google Gemini
+ *
+ * - 官网:https://ai.google.dev/
+ * - 控制台 / API key:https://aistudio.google.com/app/apikey
+ * - API 文档:https://ai.google.dev/gemini-api/docs
+ * - 模型列表:https://ai.google.dev/gemini-api/docs/models
+ * - OpenAI 兼容接入:https://ai.google.dev/gemini-api/docs/openai
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const GOOGLE: InkosEndpoint = {
+  id: "google",
+  label: "Google Gemini",
+  group: "overseas",
+  api: "openai-completions",
+  baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
+  checkModel: "gemini-2.5-flash",
+  compat: { supportsStore: false },
+  temperatureRange: [0, 2],
+  defaultTemperature: 1,
+  writingTemperature: 1,
+  models: [
+    { id: "gemini-pro-latest", maxOutput: 65536, contextWindowTokens: 1114112 },
+    { id: "gemini-flash-latest", maxOutput: 65536, contextWindowTokens: 1114112 },
+    { id: "gemini-flash-lite-latest", maxOutput: 65536, contextWindowTokens: 1114112 },
+    { id: "gemini-3.1-flash-image-preview", maxOutput: 32768, contextWindowTokens: 163840, enabled: false, status: "nonText", capabilities: { text: false, imageOutput: true }, releasedAt: "2026-02-26" },
+    { id: "gemini-3.1-pro-preview", maxOutput: 65536, contextWindowTokens: 1114112, enabled: true, releasedAt: "2026-02-19" },
+    { id: "gemini-3.1-flash-lite-preview", maxOutput: 65536, contextWindowTokens: 1114112, enabled: true, releasedAt: "2026-03-04" },
+    { id: "gemini-3-flash-preview", maxOutput: 65536, contextWindowTokens: 1114112, enabled: true, releasedAt: "2025-12-17" },
+    { id: "gemini-3-pro-image-preview", maxOutput: 32768, contextWindowTokens: 163840, enabled: false, status: "nonText", capabilities: { text: false, imageOutput: true }, releasedAt: "2025-11-20" },
+    { id: "gemini-2.5-pro", maxOutput: 65536, contextWindowTokens: 1114112, releasedAt: "2025-06-17" },
+    { id: "gemini-2.5-flash", maxOutput: 65536, contextWindowTokens: 1114112, releasedAt: "2025-06-17" },
+    { id: "gemini-2.5-flash-image", maxOutput: 32768, contextWindowTokens: 98304, enabled: false, status: "nonText", capabilities: { text: false, imageOutput: true }, releasedAt: "2025-08-26" },
+    { id: "gemini-2.5-flash-lite", maxOutput: 65536, contextWindowTokens: 1114112, releasedAt: "2025-07-22" },
+    { id: "gemini-2.5-flash-lite-preview-09-2025", maxOutput: 65536, contextWindowTokens: 1114112, releasedAt: "2025-09-25" },
+    { id: "gemini-2.0-flash", maxOutput: 8192, contextWindowTokens: 1056768, releasedAt: "2025-02-05" },
+    { id: "gemini-2.0-flash-001", maxOutput: 8192, contextWindowTokens: 1056768, releasedAt: "2025-02-05" },
+    { id: "gemini-2.0-flash-lite", maxOutput: 8192, contextWindowTokens: 1056768, releasedAt: "2025-02-05" },
+    { id: "gemini-2.0-flash-lite-001", maxOutput: 8192, contextWindowTokens: 1056768, releasedAt: "2025-02-05" },
+    { id: "gemini-1.5-flash-002", maxOutput: 8192, contextWindowTokens: 1008192, releasedAt: "2024-09-25" },
+    { id: "gemini-1.5-pro-002", maxOutput: 8192, contextWindowTokens: 2008192, releasedAt: "2024-09-24" },
+    { id: "gemini-1.5-flash-8b-latest", maxOutput: 8192, contextWindowTokens: 1008192, releasedAt: "2024-10-03" },
+    { id: "gemma-3-1b-it", maxOutput: 8192, contextWindowTokens: 40960 },
+    { id: "gemma-3-4b-it", maxOutput: 8192, contextWindowTokens: 40960 },
+    { id: "gemma-3-12b-it", maxOutput: 8192, contextWindowTokens: 40960 },
+    { id: "gemma-3-27b-it", maxOutput: 8192, contextWindowTokens: 139264 },
+    { id: "gemma-3n-e2b-it", maxOutput: 2048, contextWindowTokens: 10240 },
+    { id: "gemma-3n-e4b-it", maxOutput: 2048, contextWindowTokens: 10240 },
+  ],
+};

+ 42 - 0
packages/core/src/llm/providers/endpoints/hunyuan.ts

@@ -0,0 +1,42 @@
+/**
+ * 腾讯混元 (Hunyuan)
+ *
+ * - 官网:https://cloud.tencent.com/product/hunyuan
+ * - 控制台 / API key:https://console.cloud.tencent.com/hunyuan/api-key
+ * - API 文档:https://cloud.tencent.com/document/product/1729
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const HUNYUAN: InkosEndpoint = {
+  id: "hunyuan",
+  label: "腾讯混元",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://api.hunyuan.cloud.tencent.com/v1",
+  checkModel: "hunyuan-lite",
+  temperatureRange: [0, 2],
+  defaultTemperature: 1,
+  writingTemperature: 1,
+  models: [
+    { id: "hunyuan-2.0-thinking-20251109", maxOutput: 64000, contextWindowTokens: 128000, enabled: true, releasedAt: "2025-11-09" },
+    { id: "hunyuan-2.0-instruct-20251111", maxOutput: 16000, contextWindowTokens: 128000, enabled: true, releasedAt: "2025-11-11" },
+    { id: "hunyuan-a13b", maxOutput: 32000, contextWindowTokens: 256000, enabled: true, releasedAt: "2025-06-25" },
+    { id: "hunyuan-t1-latest", maxOutput: 64000, contextWindowTokens: 96000, releasedAt: "2025-08-22" },
+    { id: "hunyuan-t1-20250711", maxOutput: 64000, contextWindowTokens: 92000, releasedAt: "2025-07-11" },
+    { id: "hunyuan-t1-20250529", maxOutput: 64000, contextWindowTokens: 92000, releasedAt: "2025-05-29" },
+    { id: "hunyuan-t1-20250403", maxOutput: 64000, contextWindowTokens: 92000, releasedAt: "2025-04-03" },
+    { id: "hunyuan-t1-20250321", maxOutput: 64000, contextWindowTokens: 92000, releasedAt: "2025-03-21" },
+    { id: "hunyuan-lite", maxOutput: 6000, contextWindowTokens: 256000, enabled: true, releasedAt: "2024-10-30" },
+    { id: "hunyuan-standard", maxOutput: 2000, contextWindowTokens: 32000, releasedAt: "2025-02-10" },
+    { id: "hunyuan-standard-256K", maxOutput: 6000, contextWindowTokens: 256000, releasedAt: "2025-02-10" },
+    { id: "hunyuan-large", maxOutput: 4000, contextWindowTokens: 32000, releasedAt: "2025-02-10" },
+    { id: "hunyuan-large-longcontext", maxOutput: 6000, contextWindowTokens: 134000, releasedAt: "2024-12-18" },
+    { id: "hunyuan-turbo-latest", maxOutput: 4000, contextWindowTokens: 32000, releasedAt: "2025-01-10" },
+    { id: "hunyuan-turbo-20241223", maxOutput: 4000, contextWindowTokens: 32000, releasedAt: "2025-01-10" },
+    { id: "hunyuan-turbos-longtext-128k-20250325", maxOutput: 6000, contextWindowTokens: 134000, releasedAt: "2025-03-25" },
+    { id: "hunyuan-turbos-latest", maxOutput: 16000, contextWindowTokens: 44000, releasedAt: "2025-07-16" },
+    { id: "hunyuan-vision-1.5-instruct", maxOutput: 16000, contextWindowTokens: 40000, releasedAt: "2025-12-17" },
+    { id: "hunyuan-t1-vision-20250916", maxOutput: 20000, contextWindowTokens: 48000, releasedAt: "2025-09-16" },
+    { id: "hunyuan-turbos-vision-video", maxOutput: 8000, contextWindowTokens: 32000, releasedAt: "2025-07-28" },
+  ],
+};

+ 72 - 0
packages/core/src/llm/providers/endpoints/infiniai.ts

@@ -0,0 +1,72 @@
+/**
+ * 无问芯穹 (Infini-AI / GenStudio)
+ *
+ * - 官网:https://cloud.infini-ai.com/
+ * - 控制台 / API key:https://cloud.infini-ai.com/iam/secret
+ * - API 文档:https://docs.infini-ai.com/
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const INFINIAI: InkosEndpoint = {
+  id: "infiniai",
+  label: "无问芯穹 InfiniAI",
+  group: "aggregator",
+  api: "openai-completions",
+  baseUrl: "https://cloud.infini-ai.com/maas/v1",
+  checkModel: "qwen2.5-72b-instruct",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "minimax-m2.7", maxOutput: 4096, contextWindowTokens: 204800, enabled: true, releasedAt: "2026-03-17" },
+    { id: "deepseek-v3.2", maxOutput: 8192, contextWindowTokens: 131072, releasedAt: "2025-12-02" },
+    { id: "deepseek-v3.2-thinking", maxOutput: 65536, contextWindowTokens: 131072, enabled: true, releasedAt: "2025-12-02" },
+    { id: "glm-4.6v", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "glm-5", maxOutput: 4096, contextWindowTokens: 198000, enabled: true, releasedAt: "2026-02-13" },
+    { id: "minimax-m2.5", maxOutput: 4096, contextWindowTokens: 204800, releasedAt: "2026-02-13" },
+    { id: "kimi-k2-thinking", maxOutput: 32768, contextWindowTokens: 262144, releasedAt: "2025-11-07", temperature: 1 },
+    { id: "deepseek-ocr", maxOutput: 8192, contextWindowTokens: 8192, releasedAt: "2025-10-20" },
+    { id: "minimax-m2.1", maxOutput: 200000, contextWindowTokens: 200000 },
+    { id: "minimax-m2", maxOutput: 200000, contextWindowTokens: 200000 },
+    { id: "glm-4.7", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "glm-4.6", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "deepseek-v3.2-exp", maxOutput: 65536, contextWindowTokens: 131072 },
+    { id: "qwen3-vl-235b-a22b-instruct", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-vl-235b-a22b-thinking", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "deepseek-v3.1-terminus", maxOutput: 65536, contextWindowTokens: 131072 },
+    { id: "qwen3-next-80b-a3b-thinking", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-next-80b-a3b-instruct", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "deepseek-v3.1", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "baichuan-m2-32b", maxOutput: 8192, contextWindowTokens: 64000 },
+    { id: "glm-4.5v", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "glm-4.5", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "glm-4.5-air", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "qwen3-coder-480b-a35b-instruct", maxOutput: 32768, contextWindowTokens: 262144 },
+    { id: "qwen3-235b-a22b-instruct-2507", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "kimi-k2-instruct", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "ernie-4.5-300b-a47b", maxOutput: 8192, contextWindowTokens: 32000 },
+    { id: "ernie-4.5-21b-a3b", maxOutput: 8192, contextWindowTokens: 120000 },
+    { id: "qwen3-8b", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "qwen3-14b", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "qwen3-32b", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "qwen3-30b-a3b", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "qwen3-235b-a22b", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "qwen2.5-vl-72b-instruct", maxOutput: 4096, contextWindowTokens: 125000 },
+    { id: "qwen2.5-vl-32b-instruct", maxOutput: 4096, contextWindowTokens: 125000 },
+    { id: "qwen2.5-vl-7b-instruct", maxOutput: 4096, contextWindowTokens: 125000 },
+    { id: "qwq-32b", maxOutput: 8192, contextWindowTokens: 32000 },
+    { id: "deepseek-v3", maxOutput: 16384, contextWindowTokens: 131072 },
+    { id: "deepseek-r1", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "deepseek-r1-distill-qwen-32b", maxOutput: 8192, contextWindowTokens: 32000 },
+    { id: "megrez-3b-instruct", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "qwen2.5-32b-instruct", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "qwen2.5-72b-instruct", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "qwen2.5-14b-instruct", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "qwen2.5-7b-instruct", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "qwen2.5-coder-32b-instruct", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "gpt-oss-120b", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "gpt-oss-20b", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "pro-deepseek-r1", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "pro-deepseek-v3", maxOutput: 16384, contextWindowTokens: 131072 },
+  ],
+};

+ 28 - 0
packages/core/src/llm/providers/endpoints/internlm.ts

@@ -0,0 +1,28 @@
+/**
+ * 书生浦语 (InternLM)
+ *
+ * - 官网:https://internlm.intern-ai.org.cn/
+ * - 控制台 / API key:https://internlm.intern-ai.org.cn/api/document
+ * - 开源仓库:https://github.com/InternLM/InternLM
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const INTERNLM: InkosEndpoint = {
+  id: "internlm",
+  label: "书生浦语 (InternLM)",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://chat.intern-ai.org.cn/api/v1",
+  checkModel: "internlm2.5-latest",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.8,
+  writingTemperature: 1,
+  models: [
+    { id: "intern-latest", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-02-04" },
+    { id: "intern-s1-pro", maxOutput: 4096, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-02-04" },
+    { id: "intern-s1", maxOutput: 4096, contextWindowTokens: 32768, enabled: true, releasedAt: "2025-07-26" },
+    { id: "intern-s1-mini", maxOutput: 4096, contextWindowTokens: 32768, enabled: true, releasedAt: "2025-08-20" },
+    { id: "internvl3.5-latest", maxOutput: 4096, contextWindowTokens: 32768, releasedAt: "2025-08-28" },
+    { id: "internvl3.5-241b-a28b", maxOutput: 4096, contextWindowTokens: 32768, enabled: true, releasedAt: "2025-08-28" },
+  ],
+};

+ 24 - 0
packages/core/src/llm/providers/endpoints/kimiCodingPlan.ts

@@ -0,0 +1,24 @@
+/**
+ * Kimi CodingPlan
+ *
+ * - 官网:https://platform.moonshot.cn/
+ * - Moonshot 针对编程场景的订阅套餐。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const KIMI_CODING_PLAN: InkosEndpoint = {
+  id: "kimiCodingPlan",
+  label: "Kimi Coding Plan",
+  group: "codingPlan",
+  api: "anthropic-messages",
+  baseUrl: "https://api.moonshot.cn/anthropic",
+  checkModel: "kimi-k2.5",
+  temperatureRange: [0, 1],
+  defaultTemperature: 1,
+  writingTemperature: 1,
+  temperatureHint: "kimi-k2.5 推荐 temperature=1.0",
+  models: [
+    { id: "kimi-k2.5", maxOutput: 32768, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-01-27", deploymentName: "k2p5", temperature: 1 },
+    { id: "kimi-k2-thinking", maxOutput: 65536, contextWindowTokens: 262144, releasedAt: "2025-11-06", temperature: 1 },
+  ],
+};

+ 25 - 0
packages/core/src/llm/providers/endpoints/longcat.ts

@@ -0,0 +1,25 @@
+/**
+ * LongCat (美团)
+ *
+ * - 官网:https://longcat.chat/
+ * - API 文档:https://longcat.chat/platform/docs
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const LONGCAT: InkosEndpoint = {
+  id: "longcat",
+  label: "美团 LongCat",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://api.longcat.chat/openai/v1",
+  checkModel: "LongCat-Flash-Chat",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "LongCat-Flash-Lite", maxOutput: 4096, contextWindowTokens: 327680, enabled: true, releasedAt: "2026-02-05" },
+    { id: "LongCat-Flash-Thinking-2601", maxOutput: 4096, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-01-14" },
+    { id: "LongCat-Flash-Thinking", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-09-22" },
+    { id: "LongCat-Flash-Chat", maxOutput: 4096, contextWindowTokens: 262144, enabled: true, releasedAt: "2025-12-12" },
+  ],
+};

+ 39 - 0
packages/core/src/llm/providers/endpoints/minimax.ts

@@ -0,0 +1,39 @@
+/**
+ * MiniMax
+ *
+ * - 官网:https://www.minimax.io/
+ * - 控制台 / API key:https://platform.minimaxi.com/user-center/basic-information/interface-key
+ * - API 文档:https://platform.minimaxi.com/document/platform%20introduction
+ * - 模型列表:https://platform.minimaxi.com/document/text
+ *
+ * inkos 用 Anthropic 兼容接入 (api.minimaxi.com/anthropic):agent 场景下
+ * OpenAI 兼容 (/v1) 的工具调用不稳定;这条链路上工具调用可靠。
+ * MiniMax 没有公开的 /models 端点,模型清单只能按官方文档手维护。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const MINIMAX: InkosEndpoint = {
+  id: "minimax",
+  label: "MiniMax",
+  group: "china",
+  api: "anthropic-messages",
+  baseUrl: "https://api.minimaxi.com/anthropic",
+  checkModel: "MiniMax-M2.7",
+  transportDefaults: { stream: false },
+  temperatureRange: [0, 1],
+  defaultTemperature: 0.9,
+  writingTemperature: 0.9,
+  models: [
+    { id: "MiniMax-M2.7", maxOutput: 131072, contextWindowTokens: 204800, enabled: true, releasedAt: "2026-03-18" },
+    { id: "MiniMax-M2.7-highspeed", maxOutput: 131072, contextWindowTokens: 204800, releasedAt: "2026-03-18" },
+    { id: "MiniMax-M2.5", maxOutput: 131072, contextWindowTokens: 204800, releasedAt: "2026-02-12" },
+    { id: "MiniMax-M2.5-highspeed", maxOutput: 131072, contextWindowTokens: 204800, releasedAt: "2026-02-12" },
+    { id: "M2-her", maxOutput: 2048, contextWindowTokens: 65536, releasedAt: "2026-01-23" },
+    { id: "MiniMax-M2.1", maxOutput: 131072, contextWindowTokens: 204800, releasedAt: "2025-12-23" },
+    { id: "MiniMax-M2.1-highspeed", maxOutput: 131072, contextWindowTokens: 204800, releasedAt: "2025-12-23" },
+    { id: "MiniMax-M2", maxOutput: 131072, contextWindowTokens: 204800, releasedAt: "2025-10-27" },
+    { id: "MiniMax-M2-Stable", maxOutput: 131072, contextWindowTokens: 204800, releasedAt: "2025-10-27" },
+    { id: "MiniMax-M1", maxOutput: 40000, contextWindowTokens: 1000192, releasedAt: "2025-06-16" },
+    { id: "MiniMax-Text-01", maxOutput: 40000, contextWindowTokens: 1000192, releasedAt: "2025-01-15" },
+  ],
+};

+ 28 - 0
packages/core/src/llm/providers/endpoints/minimaxCodingPlan.ts

@@ -0,0 +1,28 @@
+/**
+ * MiniMax CodingPlan
+ *
+ * - 官网:https://platform.minimaxi.com/
+ * - MiniMax 针对编程场景的订阅套餐。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const MINIMAX_CODING_PLAN: InkosEndpoint = {
+  id: "minimaxCodingPlan",
+  label: "MiniMax Coding Plan",
+  group: "codingPlan",
+  api: "anthropic-messages",
+  baseUrl: "https://api.minimaxi.com/anthropic",
+  checkModel: "MiniMax-M2.7",
+  transportDefaults: { stream: false },
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.9,
+  writingTemperature: 0.9,
+  models: [
+    { id: "MiniMax-M2.7", maxOutput: 131072, contextWindowTokens: 204800, enabled: true, releasedAt: "2026-03-18" },
+    { id: "MiniMax-M2.7-highspeed", maxOutput: 131072, contextWindowTokens: 204800, releasedAt: "2026-03-18" },
+    { id: "MiniMax-M2.5", maxOutput: 131072, contextWindowTokens: 204800, enabled: true, releasedAt: "2026-02-12" },
+    { id: "MiniMax-M2.5-highspeed", maxOutput: 131072, contextWindowTokens: 204800, releasedAt: "2026-02-12" },
+    { id: "MiniMax-M2.1", maxOutput: 131072, contextWindowTokens: 204800, releasedAt: "2025-12-23" },
+    { id: "MiniMax-M2", maxOutput: 131072, contextWindowTokens: 204800, releasedAt: "2025-12-23" },
+  ],
+};

+ 40 - 0
packages/core/src/llm/providers/endpoints/mistral.ts

@@ -0,0 +1,40 @@
+/**
+ * Mistral AI
+ *
+ * - 官网:https://mistral.ai/
+ * - 控制台 / API key:https://console.mistral.ai/api-keys/
+ * - API 文档:https://docs.mistral.ai/api/
+ * - 模型列表:https://docs.mistral.ai/getting-started/models/models_overview/
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const MISTRAL: InkosEndpoint = {
+  id: "mistral",
+  label: "Mistral AI",
+  group: "overseas",
+  api: "openai-completions",
+  baseUrl: "https://api.mistral.ai/v1",
+  checkModel: "mistral-small-latest",
+  temperatureRange: [0, 1],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "devstral-2512", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-12-09" },
+    { id: "labs-devstral-small-2512", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-12-09" },
+    { id: "mistral-medium-2508", maxOutput: 4096, contextWindowTokens: 131072, enabled: true },
+    { id: "magistral-medium-2509", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "magistral-small-2509", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "open-mistral-nemo", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "mistral-small-2603", maxOutput: 4096, contextWindowTokens: 256000, releasedAt: "2026-03-16" },
+    { id: "mistral-small-2506", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "mistral-large-2512", maxOutput: 4096, contextWindowTokens: 256000, enabled: true, releasedAt: "2025-12-02" },
+    { id: "mistral-large-2411", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "codestral-latest", maxOutput: 4096, contextWindowTokens: 256000, releasedAt: "2025-07-30" },
+    { id: "pixtral-large-latest", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "pixtral-12b-2409", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "ministral-3b-latest", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "ministral-8b-latest", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "open-codestral-mamba", maxOutput: 4096, contextWindowTokens: 256000 },
+    { id: "labs-leanstral-2603", maxOutput: 4096, contextWindowTokens: 256000, releasedAt: "2026-03-16" },
+  ],
+};

+ 30 - 0
packages/core/src/llm/providers/endpoints/modelscope.ts

@@ -0,0 +1,30 @@
+/**
+ * ModelScope (魔搭)
+ *
+ * - 官网:https://www.modelscope.cn/
+ * - 控制台 / API key:https://www.modelscope.cn/my/myaccesstoken
+ * - API 文档:https://www.modelscope.cn/docs/model-service/API-Inference/intro
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const MODELSCOPE: InkosEndpoint = {
+  id: "modelscope",
+  label: "魔搭社区 ModelScope",
+  group: "aggregator",
+  api: "openai-completions",
+  baseUrl: "https://api-inference.modelscope.cn/v1",
+  checkModel: "Qwen/Qwen2.5-72B-Instruct",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "Qwen/Qwen3-Next-80B-A3B-Thinking", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "Qwen/Qwen3-Next-80B-A3B-Instruct", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "deepseek-ai/DeepSeek-V3.2", maxOutput: 4096, contextWindowTokens: 131072, enabled: true },
+    { id: "deepseek-ai/DeepSeek-V3.2-Exp", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "deepseek-ai/DeepSeek-V3.1", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "deepseek-ai/DeepSeek-R1-0528", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "Qwen/Qwen3-235B-A22B", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "Qwen/Qwen3-32B", maxOutput: 4096, contextWindowTokens: 131072 },
+  ],
+};

+ 39 - 0
packages/core/src/llm/providers/endpoints/moonshot.ts

@@ -0,0 +1,39 @@
+/**
+ * Moonshot AI (Kimi)
+ *
+ * - 官网:https://www.moonshot.cn/
+ * - 控制台:https://platform.moonshot.cn/console/
+ * - API key:https://platform.moonshot.cn/console/api-keys
+ * - API 文档:https://platform.moonshot.cn/docs/api-reference
+ * - 模型列表:https://platform.moonshot.cn/docs/pricing/chat
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const MOONSHOT: InkosEndpoint = {
+  id: "moonshot",
+  label: "Moonshot (Kimi)",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://api.moonshot.cn/v1",
+  checkModel: "moonshot-v1-8k",
+  temperatureRange: [0, 1],
+  defaultTemperature: 0.3,
+  writingTemperature: 1,
+  temperatureHint: "kimi-k2.5/k2.6 推荐 temperature=1.0",
+  models: [
+    { id: "kimi-k2.6", maxOutput: 32768, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-04-21", temperature: 1 },
+    { id: "kimi-k2.5", maxOutput: 32768, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-01-27", temperature: 1 },
+    { id: "kimi-k2-thinking", maxOutput: 65536, contextWindowTokens: 262144, releasedAt: "2025-11-06", temperature: 1 },
+    { id: "kimi-k2-thinking-turbo", maxOutput: 65536, contextWindowTokens: 262144, releasedAt: "2025-11-06", temperature: 1 },
+    { id: "kimi-k2-0905-preview", maxOutput: 65536, contextWindowTokens: 262144, releasedAt: "2025-09-05" },
+    { id: "kimi-k2-0711-preview", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-07-11" },
+    { id: "kimi-k2-turbo-preview", maxOutput: 4096, contextWindowTokens: 262144, enabled: true, releasedAt: "2025-09-05" },
+    { id: "moonshot-v1-auto", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "moonshot-v1-8k", maxOutput: 4096, contextWindowTokens: 8192 },
+    { id: "moonshot-v1-32k", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "moonshot-v1-128k", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "moonshot-v1-8k-vision-preview", maxOutput: 4096, contextWindowTokens: 8192, releasedAt: "2025-01-14" },
+    { id: "moonshot-v1-32k-vision-preview", maxOutput: 4096, contextWindowTokens: 32768, releasedAt: "2025-01-14" },
+    { id: "moonshot-v1-128k-vision-preview", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-01-14" },
+  ],
+};

+ 21 - 0
packages/core/src/llm/providers/endpoints/newapi.ts

@@ -0,0 +1,21 @@
+/**
+ * NewAPI (自建网关)
+ *
+ * - New API (one-api fork):https://github.com/Calcium-Ion/new-api
+ * - 自建 OpenAI 兼容网关,模型清单由部署方自定义,inkos 只提供协议对接。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+/**
+ * newapi 是 OneAPI / new-api 中转网关的锚点 provider。
+ * 跟 custom 类似:baseUrl 由用户在 Studio 填(指向自己部署的 new-api 网关),
+ * models 默认为空,实际可走 live /models probe(跟 custom 共用逻辑)。
+ */
+export const NEWAPI: InkosEndpoint = {
+  id: "newapi",
+  label: "New API (中转网关)",
+  group: "aggregator",
+  api: "openai-completions",
+  baseUrl: "",
+  models: [],
+};

+ 73 - 0
packages/core/src/llm/providers/endpoints/ollama.ts

@@ -0,0 +1,73 @@
+/**
+ * Ollama (本地)
+ *
+ * - 官网:https://ollama.com/
+ * - 模型广场:https://ollama.com/library
+ * - API 文档:https://github.com/ollama/ollama/blob/main/docs/api.md
+ *
+ * - 默认 baseUrl 指向本地 11434,inkos 通过 OpenAI 兼容模式 (/v1) 接入。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const OLLAMA: InkosEndpoint = {
+  id: "ollama",
+  label: "Ollama (本地)",
+  group: "local",
+  api: "openai-completions",
+  baseUrl: "http://localhost:11434/v1",
+  checkModel: "llama3.2:3b",
+  models: [
+    { id: "deepseek-v3.1:671b", maxOutput: 4096, contextWindowTokens: 163840 },
+    { id: "gpt-oss:20b", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-08-05" },
+    { id: "gpt-oss:120b", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-08-05" },
+    { id: "qwen3-coder:480b", maxOutput: 4096, contextWindowTokens: 262144 },
+    { id: "deepseek-r1", maxOutput: 4096, contextWindowTokens: 65536 },
+    { id: "deepseek-v3", maxOutput: 4096, contextWindowTokens: 65536 },
+    { id: "llama3.1", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "llama3.1:70b", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "llama3.1:405b", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "codellama", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "codellama:13b", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "codellama:34b", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "codellama:70b", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "qwq", maxOutput: 4096, contextWindowTokens: 128000, releasedAt: "2024-11-28" },
+    { id: "qwen3", maxOutput: 4096, contextWindowTokens: 65536 },
+    { id: "qwen2.5:0.5b", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "qwen2.5:1.5b", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "qwen2.5", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "qwen2.5:72b", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "codeqwen", maxOutput: 4096, contextWindowTokens: 65536 },
+    { id: "qwen2:0.5b", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "qwen2:1.5b", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "qwen2", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "qwen2:72b", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "gemma2:2b", maxOutput: 4096, contextWindowTokens: 8192 },
+    { id: "gemma2", maxOutput: 4096, contextWindowTokens: 8192 },
+    { id: "gemma2:27b", maxOutput: 4096, contextWindowTokens: 8192 },
+    { id: "codegemma:2b", maxOutput: 4096, contextWindowTokens: 8192 },
+    { id: "codegemma", maxOutput: 4096, contextWindowTokens: 8192 },
+    { id: "phi3", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "phi3:14b", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "wizardlm2", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "wizardlm2:8x22b", maxOutput: 4096, contextWindowTokens: 65536 },
+    { id: "mathstral", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "mistral", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "mixtral", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "mixtral:8x22b", maxOutput: 4096, contextWindowTokens: 65536 },
+    { id: "mistral-large", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "mistral-nemo", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "codestral", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "aya", maxOutput: 4096, contextWindowTokens: 8192 },
+    { id: "aya:35b", maxOutput: 4096, contextWindowTokens: 8192 },
+    { id: "command-r", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "command-r-plus", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "deepseek-v2", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "deepseek-v2:236b", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "deepseek-coder-v2", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "deepseek-coder-v2:236b", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "llava", maxOutput: 4096, contextWindowTokens: 4096 },
+    { id: "llava:13b", maxOutput: 4096, contextWindowTokens: 4096 },
+    { id: "llava:34b", maxOutput: 4096, contextWindowTokens: 4096 },
+    { id: "minicpm-v", maxOutput: 4096, contextWindowTokens: 128000 },
+  ],
+};

+ 77 - 0
packages/core/src/llm/providers/endpoints/openai.ts

@@ -0,0 +1,77 @@
+/**
+ * OpenAI
+ *
+ * - 官网:https://openai.com/
+ * - 控制台:https://platform.openai.com/
+ * - API key:https://platform.openai.com/api-keys
+ * - API 文档:https://platform.openai.com/docs/api-reference
+ * - 模型列表:https://platform.openai.com/docs/models
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const OPENAI: InkosEndpoint = {
+  id: "openai",
+  label: "OpenAI",
+  group: "overseas",
+  api: "openai-responses",
+  baseUrl: "https://api.openai.com/v1",
+  checkModel: "gpt-4o-mini",
+  temperatureRange: [0, 2],
+  defaultTemperature: 1,
+  writingTemperature: 1,
+  models: [
+    { id: "gpt-5.4", maxOutput: 128000, contextWindowTokens: 1050000, enabled: true, releasedAt: "2026-03-05" },
+    { id: "gpt-5.4-pro", maxOutput: 128000, contextWindowTokens: 1050000, releasedAt: "2026-03-05" },
+    { id: "gpt-5.4-mini", maxOutput: 128000, contextWindowTokens: 400000, enabled: true, releasedAt: "2026-03-18" },
+    { id: "gpt-5.4-nano", maxOutput: 128000, contextWindowTokens: 400000, enabled: true, releasedAt: "2026-03-18" },
+    { id: "gpt-5.3-chat-latest", maxOutput: 16384, contextWindowTokens: 128000, releasedAt: "2026-03-04" },
+    { id: "gpt-5.3-codex", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2026-02-05" },
+    { id: "gpt-5.2", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2025-12-11" },
+    { id: "gpt-5.2-codex", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2025-12-18" },
+    { id: "gpt-5.2-pro", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2025-12-11" },
+    { id: "gpt-5.2-chat-latest", maxOutput: 16384, contextWindowTokens: 128000, releasedAt: "2025-12-11" },
+    { id: "gpt-5.1", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2025-11-13" },
+    { id: "gpt-5.1-chat-latest", maxOutput: 16384, contextWindowTokens: 128000, releasedAt: "2025-11-13" },
+    { id: "gpt-5.1-codex-max", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2025-12-04" },
+    { id: "gpt-5.1-codex", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2025-11-13" },
+    { id: "gpt-5.1-codex-mini", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2025-11-13" },
+    { id: "gpt-5-pro", maxOutput: 272000, contextWindowTokens: 400000, releasedAt: "2025-10-06" },
+    { id: "gpt-5-codex", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2024-09-15" },
+    { id: "gpt-5", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2025-08-07" },
+    { id: "gpt-5-mini", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2025-08-07" },
+    { id: "gpt-5-nano", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2025-08-07" },
+    { id: "gpt-5-chat-latest", maxOutput: 128000, contextWindowTokens: 400000, releasedAt: "2025-08-07" },
+    { id: "o4-mini", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2025-04-17" },
+    { id: "o4-mini-deep-research", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2025-06-26" },
+    { id: "o3-pro", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2025-06-10" },
+    { id: "o3", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2025-04-16" },
+    { id: "o3-deep-research", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2025-06-26" },
+    { id: "o3-mini", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2025-01-31" },
+    { id: "o1-pro", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2025-03-19" },
+    { id: "o1", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2024-12-17" },
+    { id: "gpt-4.1", maxOutput: 32768, contextWindowTokens: 1047576, releasedAt: "2025-04-14" },
+    { id: "gpt-4.1-mini", maxOutput: 32768, contextWindowTokens: 1047576, releasedAt: "2025-04-14" },
+    { id: "gpt-4.1-nano", maxOutput: 32768, contextWindowTokens: 1047576, releasedAt: "2025-04-14" },
+    { id: "gpt-4o-mini", maxOutput: 16384, contextWindowTokens: 128000, releasedAt: "2024-07-18" },
+    { id: "gpt-4o-mini-search-preview", maxOutput: 16384, contextWindowTokens: 128000, releasedAt: "2025-03-11" },
+    { id: "gpt-4o", maxOutput: 4096, contextWindowTokens: 128000, releasedAt: "2024-05-13" },
+    { id: "gpt-4o-search-preview", maxOutput: 16384, contextWindowTokens: 128000, releasedAt: "2025-03-11" },
+    { id: "gpt-4o-2024-11-20", maxOutput: 4096, contextWindowTokens: 128000, releasedAt: "2024-11-20" },
+    { id: "gpt-4o-2024-05-13", maxOutput: 4096, contextWindowTokens: 128000, releasedAt: "2024-05-13" },
+    { id: "gpt-audio", maxOutput: 16384, contextWindowTokens: 128000, releasedAt: "2025-08-28" },
+    { id: "gpt-4o-audio-preview", maxOutput: 16384, contextWindowTokens: 128000, releasedAt: "2024-12-17" },
+    { id: "gpt-4o-mini-audio-preview", maxOutput: 16384, contextWindowTokens: 128000, releasedAt: "2024-12-17" },
+    { id: "gpt-4-turbo", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "gpt-4-turbo-2024-04-09", maxOutput: 4096, contextWindowTokens: 128000, releasedAt: "2024-04-09" },
+    { id: "gpt-4-turbo-preview", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "gpt-4-0125-preview", maxOutput: 4096, contextWindowTokens: 128000, releasedAt: "2024-01-25" },
+    { id: "gpt-4-1106-preview", maxOutput: 4096, contextWindowTokens: 128000, releasedAt: "2023-11-06" },
+    { id: "gpt-4", maxOutput: 4096, contextWindowTokens: 8192 },
+    { id: "gpt-4-0613", maxOutput: 4096, contextWindowTokens: 8192, releasedAt: "2023-06-13" },
+    { id: "gpt-3.5-turbo", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "gpt-3.5-turbo-0125", maxOutput: 4096, contextWindowTokens: 16384, releasedAt: "2024-01-25" },
+    { id: "gpt-3.5-turbo-1106", maxOutput: 4096, contextWindowTokens: 16384, releasedAt: "2023-11-06" },
+    { id: "gpt-3.5-turbo-instruct", maxOutput: 4096, contextWindowTokens: 4096 },
+    { id: "computer-use-preview", maxOutput: 1024, contextWindowTokens: 8192, releasedAt: "2025-03-11" },
+  ],
+};

+ 30 - 0
packages/core/src/llm/providers/endpoints/opencodeCodingPlan.ts

@@ -0,0 +1,30 @@
+/**
+ * OpenCode CodingPlan
+ *
+ * - OpenCode 聚合的编码类套餐,具体后端随订阅变化。
+ * - 官网:https://opencode.ai/
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const OPENCODE_CODING_PLAN: InkosEndpoint = {
+  id: "opencodeCodingPlan",
+  label: "OpenCode Coding Plan",
+  group: "codingPlan",
+  api: "anthropic-messages",
+  baseUrl: "https://opencode.ai/api/anthropic",
+  checkModel: "glm-5.1",
+  temperatureRange: [0, 1],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "glm-5.1", maxOutput: 32000, contextWindowTokens: 204800, enabled: true, releasedAt: "2026-04-07" },
+    { id: "glm-5", maxOutput: 32000, contextWindowTokens: 204800, enabled: false, releasedAt: "2026-02-11" },
+    { id: "kimi-k2.5", maxOutput: 32000, contextWindowTokens: 262144, enabled: false, releasedAt: "2026-01-27", temperature: 1 },
+    { id: "mimo-v2-omni", maxOutput: 32000, contextWindowTokens: 262144, enabled: false, releasedAt: "2026-03-18" },
+    { id: "qwen3.6-plus", maxOutput: 32000, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-04-02" },
+    { id: "minimax-m2.5", maxOutput: 32000, contextWindowTokens: 204800, enabled: false, releasedAt: "2026-02-12" },
+    { id: "minimax-m2.7", maxOutput: 32000, contextWindowTokens: 204800, enabled: true, releasedAt: "2026-03-18" },
+    { id: "mimo-v2-pro", maxOutput: 32000, contextWindowTokens: 1048576, enabled: false, releasedAt: "2026-03-18" },
+    { id: "qwen3.5-plus", maxOutput: 32000, contextWindowTokens: 262144, enabled: false, releasedAt: "2026-02-16" },
+  ],
+};

+ 87 - 0
packages/core/src/llm/providers/endpoints/openrouter.ts

@@ -0,0 +1,87 @@
+/**
+ * OpenRouter
+ *
+ * - 官网:https://openrouter.ai/
+ * - 控制台 / API key:https://openrouter.ai/keys
+ * - 模型广场:https://openrouter.ai/models
+ * - API 文档:https://openrouter.ai/docs/api-reference/overview
+ * - 模型列表 JSON:https://openrouter.ai/api/v1/models
+ *
+ * 聚合所有主流家 (Anthropic / OpenAI / Google / xAI / Meta 等) 的统一入口。
+ * 350+ 模型,bank 只列最常用的;完整清单用户侧 live /models probe。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const OPENROUTER: InkosEndpoint = {
+  id: "openrouter",
+  label: "OpenRouter",
+  group: "aggregator",
+  api: "openai-responses",
+  baseUrl: "https://openrouter.ai/api/v1",
+  checkModel: "google/gemma-2-9b-it:free",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "openrouter/auto", maxOutput: 4096, contextWindowTokens: 2000000, enabled: true },
+    { id: "deepseek/deepseek-chat-v3.1", maxOutput: 4096, contextWindowTokens: 163840, releasedAt: "2025-08-21" },
+    { id: "google/gemini-3.1-flash-image-preview", maxOutput: 65536, contextWindowTokens: 131072, releasedAt: "2026-02-26" },
+    { id: "google/gemini-3-pro-image-preview", maxOutput: 32768, contextWindowTokens: 163840, releasedAt: "2025-11-20" },
+    { id: "google/gemini-2.5-flash-image", maxOutput: 8192, contextWindowTokens: 40960, releasedAt: "2025-10-07" },
+    { id: "qwen/qwen3-30b-a3b:free", maxOutput: 4096, contextWindowTokens: 40960 },
+    { id: "qwen/qwen3-30b-a3b", maxOutput: 40960, contextWindowTokens: 40960 },
+    { id: "qwen/qwen3-8b:free", maxOutput: 40960, contextWindowTokens: 40960 },
+    { id: "qwen/qwen3-14b:free", maxOutput: 4096, contextWindowTokens: 40960 },
+    { id: "qwen/qwen3-14b", maxOutput: 40960, contextWindowTokens: 40960 },
+    { id: "qwen/qwen3-32b:free", maxOutput: 4096, contextWindowTokens: 40960 },
+    { id: "qwen/qwen3-32b", maxOutput: 4096, contextWindowTokens: 40960 },
+    { id: "qwen/qwen3-235b-a22b:free", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "qwen/qwen3-235b-a22b", maxOutput: 40960, contextWindowTokens: 40960 },
+    { id: "tngtech/deepseek-r1t-chimera:free", maxOutput: 4096, contextWindowTokens: 163840 },
+    { id: "thudm/glm-z1-rumination-32b", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "thudm/glm-z1-32b", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "thudm/glm-4-32b:free", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "thudm/glm-4-32b", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "google/gemini-2.5-pro", maxOutput: 65536, contextWindowTokens: 1048576 },
+    { id: "google/gemini-2.5-pro-preview", maxOutput: 65536, contextWindowTokens: 1048576 },
+    { id: "google/gemini-2.5-flash", maxOutput: 65535, contextWindowTokens: 1048576 },
+    { id: "google/gemini-2.5-flash-preview", maxOutput: 65535, contextWindowTokens: 1048576 },
+    { id: "google/gemini-2.5-flash-preview:thinking", maxOutput: 65535, contextWindowTokens: 1048576 },
+    { id: "openai/o3", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2025-04-17" },
+    { id: "openai/o4-mini-high", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2025-04-17" },
+    { id: "openai/o4-mini", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2025-04-17" },
+    { id: "openai/gpt-4.1", maxOutput: 32768, contextWindowTokens: 1047576, releasedAt: "2025-04-14" },
+    { id: "openai/gpt-4.1-mini", maxOutput: 32768, contextWindowTokens: 1047576, releasedAt: "2025-04-14" },
+    { id: "openai/gpt-4.1-nano", maxOutput: 32768, contextWindowTokens: 1047576, releasedAt: "2025-04-14" },
+    { id: "openai/o3-mini-high", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2025-01-31" },
+    { id: "openai/o3-mini", maxOutput: 100000, contextWindowTokens: 200000, releasedAt: "2025-01-31" },
+    { id: "openai/o1-mini", maxOutput: 65536, contextWindowTokens: 128000, releasedAt: "2024-09-12" },
+    { id: "openai/o1-preview", maxOutput: 32768, contextWindowTokens: 128000, releasedAt: "2024-09-12" },
+    { id: "openai/gpt-4o-mini", maxOutput: 16385, contextWindowTokens: 128000 },
+    { id: "openai/gpt-4o", maxOutput: 4096, contextWindowTokens: 128000 },
+    { id: "deepseek/deepseek-r1-0528", maxOutput: 4096, contextWindowTokens: 163840, releasedAt: "2025-05-28" },
+    { id: "deepseek/deepseek-r1-0528:free", maxOutput: 4096, contextWindowTokens: 163840, releasedAt: "2025-05-28" },
+    { id: "deepseek/deepseek-r1", maxOutput: 4096, contextWindowTokens: 163840, releasedAt: "2025-01-20" },
+    { id: "deepseek/deepseek-r1:free", maxOutput: 4096, contextWindowTokens: 163840, releasedAt: "2025-01-20" },
+    { id: "deepseek/deepseek-chat-v3-0324", maxOutput: 4096, contextWindowTokens: 163840 },
+    { id: "deepseek/deepseek-chat-v3-0324:free", maxOutput: 4096, contextWindowTokens: 163840 },
+    { id: "anthropic/claude-opus-4.5", maxOutput: 64000, contextWindowTokens: 200000, releasedAt: "2025-11-24" },
+    { id: "anthropic/claude-sonnet-4.5", maxOutput: 64000, contextWindowTokens: 200000, releasedAt: "2025-09-30" },
+    { id: "anthropic/claude-3-haiku", maxOutput: 4096, contextWindowTokens: 200000, releasedAt: "2024-03-07" },
+    { id: "anthropic/claude-3.5-haiku", maxOutput: 8192, contextWindowTokens: 200000, releasedAt: "2024-11-05" },
+    { id: "anthropic/claude-3.5-sonnet", maxOutput: 8192, contextWindowTokens: 200000, releasedAt: "2024-06-20" },
+    { id: "anthropic/claude-3.7-sonnet", maxOutput: 8192, contextWindowTokens: 200000, releasedAt: "2025-02-24" },
+    { id: "anthropic/claude-sonnet-4", maxOutput: 64000, contextWindowTokens: 200000, releasedAt: "2025-05-23" },
+    { id: "anthropic/claude-opus-4", maxOutput: 32000, contextWindowTokens: 200000, releasedAt: "2025-05-23" },
+    { id: "anthropic/claude-3-opus", maxOutput: 4096, contextWindowTokens: 200000, releasedAt: "2024-02-29" },
+    { id: "google/gemini-flash-1.5", maxOutput: 8192, contextWindowTokens: 1008192 },
+    { id: "google/gemini-2.0-flash-001", maxOutput: 8192, contextWindowTokens: 1056768, releasedAt: "2025-02-05" },
+    { id: "google/gemini-pro-1.5", maxOutput: 8192, contextWindowTokens: 2008192 },
+    { id: "meta-llama/llama-3.2-11b-vision-instruct", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "meta-llama/llama-3.3-70b-instruct", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "meta-llama/llama-3.3-70b-instruct:free", maxOutput: 4096, contextWindowTokens: 65536 },
+    { id: "qwen/qwen-2-7b-instruct:free", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "meta-llama/llama-3.1-8b-instruct:free", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "google/gemma-2-9b-it:free", maxOutput: 4096, contextWindowTokens: 8192 },
+  ],
+};

+ 86 - 0
packages/core/src/llm/providers/endpoints/ppio.ts

@@ -0,0 +1,86 @@
+/**
+ * PPIO (派欧云)
+ *
+ * - 官网:https://ppinfra.com/
+ * - 控制台 / API key:https://ppinfra.com/user/api-keys
+ * - 模型广场:https://ppinfra.com/model
+ * - API 文档:https://ppinfra.com/docs/model-api
+ *
+ * 聚合型推理平台,代理 DeepSeek / Qwen / Moonshot / MiniMax / GLM / 百度 ERNIE 等,
+ * 模型更新频繁(每周都有新 id),bank 只维护主流文本 chat 模型,
+ * 元数据以 live /models probe 为准。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const PPIO: InkosEndpoint = {
+  id: "ppio",
+  label: "PPIO",
+  group: "aggregator",
+  api: "openai-completions",
+  baseUrl: "https://api.ppinfra.com/v3/openai",
+  checkModel: "deepseek/deepseek-v3.2",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    // --- DeepSeek 系列 ---
+    { id: "deepseek/deepseek-v3.2", maxOutput: 8192, contextWindowTokens: 131072, enabled: true },
+    { id: "deepseek/deepseek-v3.2-exp", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "deepseek/deepseek-v3.1-terminus", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "deepseek/deepseek-v3.1", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "deepseek/deepseek-v3-0324", maxOutput: 8192, contextWindowTokens: 65536 },
+    { id: "deepseek/deepseek-v3-turbo", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "deepseek/deepseek-r1-0528", maxOutput: 65536, contextWindowTokens: 131072 },
+    { id: "deepseek/deepseek-r1-turbo", maxOutput: 65536, contextWindowTokens: 131072 },
+    { id: "deepseek/deepseek-prover-v2-671b", maxOutput: 32768, contextWindowTokens: 131072 },
+    // --- Moonshot Kimi 系列 ---
+    { id: "moonshotai/kimi-k2.6", maxOutput: 32768, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-04-21", temperature: 1 },
+    { id: "moonshotai/kimi-k2.5", maxOutput: 32768, contextWindowTokens: 262144, releasedAt: "2026-01-27", temperature: 1 },
+    { id: "moonshotai/kimi-k2-thinking", maxOutput: 65536, contextWindowTokens: 262144, temperature: 1 },
+    { id: "moonshotai/kimi-k2-0905", maxOutput: 8192, contextWindowTokens: 262144 },
+    { id: "moonshotai/kimi-k2-instruct", maxOutput: 8192, contextWindowTokens: 131072 },
+    // --- MiniMax ---
+    { id: "minimax/minimax-m2.7", maxOutput: 32768, contextWindowTokens: 204800, enabled: true },
+    { id: "minimax/minimax-m2.7-highspeed", maxOutput: 32768, contextWindowTokens: 204800 },
+    { id: "minimax/minimax-m2.5", maxOutput: 32768, contextWindowTokens: 196608 },
+    { id: "minimax/minimax-m2.5-highspeed", maxOutput: 32768, contextWindowTokens: 196608 },
+    { id: "minimax/minimax-m2.1", maxOutput: 32768, contextWindowTokens: 204800 },
+    { id: "minimax/minimax-m2", maxOutput: 32768, contextWindowTokens: 204800 },
+    // --- 智谱 GLM ---
+    { id: "zai-org/glm-5.1", maxOutput: 16384, contextWindowTokens: 202752, enabled: true, releasedAt: "2026-04-23" },
+    { id: "zai-org/glm-5", maxOutput: 16384, contextWindowTokens: 202752 },
+    { id: "zai-org/glm-5-turbo", maxOutput: 16384, contextWindowTokens: 131072 },
+    { id: "zai-org/glm-4.7", maxOutput: 16384, contextWindowTokens: 202752 },
+    { id: "zai-org/glm-4.7-flash", maxOutput: 16384, contextWindowTokens: 131072 },
+    { id: "zai-org/glm-4.6", maxOutput: 16384, contextWindowTokens: 202752 },
+    { id: "zai-org/glm-4.5", maxOutput: 16384, contextWindowTokens: 131072 },
+    { id: "zai-org/glm-4.5-air", maxOutput: 16384, contextWindowTokens: 131072 },
+    // --- Qwen 系列 ---
+    { id: "qwen/qwen3.6-27b", maxOutput: 65536, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-04-23" },
+    { id: "qwen/qwen3.5-plus", maxOutput: 65536, contextWindowTokens: 1000000, enabled: true },
+    { id: "qwen/qwen3.5-397b-a17b", maxOutput: 65536, contextWindowTokens: 262144 },
+    { id: "qwen/qwen3.5-122b-a10b", maxOutput: 65536, contextWindowTokens: 262144 },
+    { id: "qwen/qwen3.5-35b-a3b", maxOutput: 65536, contextWindowTokens: 262144 },
+    { id: "qwen/qwen3.5-27b", maxOutput: 65536, contextWindowTokens: 262144 },
+    { id: "qwen/qwen3-coder-next", maxOutput: 65536, contextWindowTokens: 262144 },
+    { id: "qwen/qwen3-coder-480b-a35b-instruct", maxOutput: 65536, contextWindowTokens: 262144 },
+    { id: "qwen/qwen3-next-80b-a3b-instruct", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen/qwen3-next-80b-a3b-thinking", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen/qwen3-235b-a22b-instruct-2507", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen/qwen3-235b-a22b-thinking-2507", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen/qwen3-235b-a22b-fp8", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "qwen/qwen3-32b-fp8", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "qwen/qwen3-30b-a3b-fp8", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "qwen/qwen-2.5-72b-instruct", maxOutput: 8192, contextWindowTokens: 32768 },
+    // --- 百度 ERNIE 4.5 ---
+    { id: "baidu/ernie-4.5-300b-a47b-paddle", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "baidu/ernie-4.5-21B-a3b", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "baidu/ernie-4.5-21b-a3b-thinking", maxOutput: 32768, contextWindowTokens: 131072 },
+    // --- 小米 MiMo ---
+    { id: "xiaomimimo/mimo-v2-pro", maxOutput: 16384, contextWindowTokens: 131072 },
+    { id: "xiaomimimo/mimo-v2-flash", maxOutput: 16384, contextWindowTokens: 131072 },
+    // --- PPIO 自研 ---
+    { id: "ppio-4b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "kat-coder", maxOutput: 32768, contextWindowTokens: 131072 },
+  ],
+};

+ 32 - 0
packages/core/src/llm/providers/endpoints/qiniu.ts

@@ -0,0 +1,32 @@
+/**
+ * 七牛云 AI 推理 (Qiniu)
+ *
+ * - 官网:https://www.qiniu.com/
+ * - 控制台 / API key:https://portal.qiniu.com/ai-inference/api-key
+ * - API 文档:https://developer.qiniu.com/aitokenapi
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const QINIU: InkosEndpoint = {
+  id: "qiniu",
+  label: "七牛云 AI",
+  group: "aggregator",
+  api: "openai-completions",
+  baseUrl: "https://api.qnaigc.com/v1",
+  checkModel: "deepseek-v3",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "deepseek-v3", maxOutput: 4096, contextWindowTokens: 131072, enabled: true },
+    { id: "deepseek-r1", maxOutput: 4096, contextWindowTokens: 65536, enabled: true },
+    { id: "minimax/minimax-m2.1", maxOutput: 131072, contextWindowTokens: 204800, enabled: true, releasedAt: "2025-12-24" },
+    { id: "minimax/minimax-m2", maxOutput: 131072, contextWindowTokens: 204800, enabled: true, releasedAt: "2025-10-27" },
+    { id: "deepseek/deepseek-math-v2", maxOutput: 131072, contextWindowTokens: 163840, enabled: true, releasedAt: "2025-11-27" },
+    { id: "meituan/longcat-flash-chat", maxOutput: 65536, contextWindowTokens: 131072, enabled: true, releasedAt: "2025-09-01" },
+    { id: "z-ai/glm-4.7", maxOutput: 128000, contextWindowTokens: 200000, enabled: true, releasedAt: "2025-12-23" },
+    { id: "z-ai/glm-4.6", maxOutput: 128000, contextWindowTokens: 200000, enabled: true, releasedAt: "2025-09-30" },
+    { id: "x-ai/grok-4-fast", maxOutput: 4096, contextWindowTokens: 2000000, enabled: true, releasedAt: "2025-09-09" },
+    { id: "x-ai/grok-code-fast-1", maxOutput: 4096, contextWindowTokens: 256000, releasedAt: "2025-08-27" },
+  ],
+};

+ 45 - 0
packages/core/src/llm/providers/endpoints/sensenova.ts

@@ -0,0 +1,45 @@
+/**
+ * 商汤日日新 (SenseNova)
+ *
+ * - 官网:https://platform.sensenova.cn/
+ * - 控制台 / API key:https://console.sensecore.cn/aistudio/management/access-key
+ * - API 文档:https://platform.sensenova.cn/doc
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const SENSENOVA: InkosEndpoint = {
+  id: "sensenova",
+  label: "商汤日日新",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://api.sensenova.cn/compatible-mode/v1",
+  checkModel: "SenseChat-Turbo",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "SenseNova-V6-5-Pro", maxOutput: 4096, contextWindowTokens: 131072, enabled: true, releasedAt: "2025-07-23" },
+    { id: "SenseNova-V6-5-Turbo", maxOutput: 4096, contextWindowTokens: 131072, enabled: true, releasedAt: "2025-07-23" },
+    { id: "Qwen3-235B", maxOutput: 4096, contextWindowTokens: 32768, releasedAt: "2025-05-27" },
+    { id: "Qwen3-32B", maxOutput: 4096, contextWindowTokens: 32768, releasedAt: "2025-05-27" },
+    { id: "SenseNova-V6-Reasoner", maxOutput: 4096, contextWindowTokens: 32768, releasedAt: "2025-04-14" },
+    { id: "SenseNova-V6-Turbo", maxOutput: 4096, contextWindowTokens: 32768, releasedAt: "2025-04-14" },
+    { id: "SenseNova-V6-Pro", maxOutput: 4096, contextWindowTokens: 32768, releasedAt: "2025-04-14" },
+    { id: "SenseChat-5-beta", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "SenseChat-5-1202", maxOutput: 4096, contextWindowTokens: 32768, releasedAt: "2024-12-30" },
+    { id: "SenseChat-Turbo-1202", maxOutput: 4096, contextWindowTokens: 32768, releasedAt: "2024-12-30" },
+    { id: "SenseChat-5", maxOutput: 131072, contextWindowTokens: 131072 },
+    { id: "SenseChat-Vision", maxOutput: 16384, contextWindowTokens: 16384, releasedAt: "2024-09-12" },
+    { id: "SenseChat-Turbo", maxOutput: 32768, contextWindowTokens: 32768 },
+    { id: "SenseChat-128K", maxOutput: 131072, contextWindowTokens: 131072 },
+    { id: "SenseChat-32K", maxOutput: 32768, contextWindowTokens: 32768 },
+    { id: "SenseChat", maxOutput: 4096, contextWindowTokens: 4096 },
+    { id: "SenseChat-5-Cantonese", maxOutput: 32768, contextWindowTokens: 32768 },
+    { id: "SenseChat-Character", maxOutput: 1024, contextWindowTokens: 8192 },
+    { id: "SenseChat-Character-Pro", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "DeepSeek-V3", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "DeepSeek-R1", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "DeepSeek-R1-Distill-Qwen-14B", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "DeepSeek-R1-Distill-Qwen-32B", maxOutput: 4096, contextWindowTokens: 8192 },
+  ],
+};

+ 126 - 0
packages/core/src/llm/providers/endpoints/siliconcloud.ts

@@ -0,0 +1,126 @@
+/**
+ * 硅基流动 (SiliconCloud / SiliconFlow)
+ *
+ * - 官网:https://www.siliconflow.com/
+ * - 控制台 / API key:https://cloud.siliconflow.cn/account/ak
+ * - 模型广场:https://cloud.siliconflow.cn/models
+ * - API 文档:https://docs.siliconflow.cn/
+ *
+ * 聚合平台,代理 Qwen / DeepSeek / GLM / Kimi / 百川 / 昆仑万维等多家开源 + 托管模型,
+ * 新模型上架频繁,bank 只维护文本 chat 主流,元数据以 live /models probe 为准。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const SILICONCLOUD: InkosEndpoint = {
+  id: "siliconcloud",
+  label: "硅基流动",
+  group: "aggregator",
+  api: "openai-completions",
+  baseUrl: "https://api.siliconflow.cn/v1",
+  checkModel: "Qwen/Qwen2.5-7B-Instruct",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    // --- 2026-04 新上 ---
+    { id: "Pro/moonshotai/Kimi-K2.6", maxOutput: 4096, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-04-21", temperature: 1 },
+    { id: "Pro/zai-org/GLM-5", maxOutput: 4096, contextWindowTokens: 198000 },
+    { id: "Pro/zai-org/GLM-4.7", maxOutput: 4096, contextWindowTokens: 200000 },
+    { id: "Qwen/Qwen3.6-35B-A3B", maxOutput: 4096, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-04-17" },
+    { id: "Qwen/Qwen3.6-27B", maxOutput: 4096, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-04-23" },
+    // ---
+    { id: "Qwen/Qwen3.5-397B-A17B", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-02-16" },
+    { id: "Qwen/Qwen3.5-122B-A10B", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-02-26" },
+    { id: "Qwen/Qwen3.5-35B-A3B", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-02-25" },
+    { id: "Qwen/Qwen3.5-27B", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-02-25" },
+    { id: "Qwen/Qwen3.5-9B", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-03-03" },
+    { id: "Qwen/Qwen3.5-4B", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-03-03" },
+    { id: "Pro/MiniMaxAI/MiniMax-M2.5", maxOutput: 4096, contextWindowTokens: 192000, releasedAt: "2026-02-13" },
+    { id: "Pro/zai-org/glm-5", maxOutput: 4096, contextWindowTokens: 198000, releasedAt: "2026-02-12" },
+    { id: "Pro/zai-org/GLM-5.1", maxOutput: 4096, contextWindowTokens: 198000, releasedAt: "2026-04-08" },
+    { id: "zai-org/GLM-4.6V", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-12-08" },
+    { id: "deepseek-ai/DeepSeek-V3.2", maxOutput: 4096, contextWindowTokens: 163840, enabled: true, releasedAt: "2025-12-01" },
+    { id: "Pro/deepseek-ai/DeepSeek-V3.2", maxOutput: 4096, contextWindowTokens: 163840, releasedAt: "2025-12-01" },
+    { id: "Pro/moonshotai/Kimi-K2.5", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-01-27", temperature: 1 },
+    { id: "qwen/qwen3.5-397b-a17b", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-03-03" },
+    { id: "qwen/qwen3.5-122b-a10b", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-03-03" },
+    { id: "qwen/qwen3.5-35b-a3b", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-03-03" },
+    { id: "qwen/qwen3.5-27b", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-03-03" },
+    { id: "qwen/qwen3.5-9b", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-03-03" },
+    { id: "qwen/qwen3.5-4b", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2026-03-03" },
+    { id: "Pro/zai-org/glm-4.7", maxOutput: 4096, contextWindowTokens: 200000, releasedAt: "2025-12-22" },
+    { id: "moonshotai/Kimi-K2-Thinking", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-11-07", temperature: 1 },
+    { id: "Pro/moonshotai/Kimi-K2-Thinking", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-11-07", temperature: 1 },
+    { id: "MiniMaxAI/MiniMax-M2", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-10-28" },
+    { id: "Qwen/Qwen3-VL-32B-Instruct", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-10-21" },
+    { id: "Qwen/Qwen3-VL-32B-Thinking", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-10-21" },
+    { id: "deepseek-ai/DeepSeek-OCR", maxOutput: 4096, contextWindowTokens: 8192, releasedAt: "2025-10-20" },
+    { id: "Qwen/Qwen3-Omni-30B-A3B-Instruct", maxOutput: 4096, contextWindowTokens: 65536, releasedAt: "2025-09-22" },
+    { id: "Qwen/Qwen3-Omni-30B-A3B-Thinking", maxOutput: 4096, contextWindowTokens: 65536, releasedAt: "2025-09-22" },
+    { id: "Qwen/Qwen3-Omni-30B-A3B-Captioner", maxOutput: 4096, contextWindowTokens: 65536, releasedAt: "2025-09-22" },
+    { id: "tencent/Hunyuan-MT-7B", maxOutput: 4096, contextWindowTokens: 32768, releasedAt: "2025-09-01" },
+    { id: "Kwaipilot/KAT-Dev", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-09-27" },
+    { id: "zai-org/GLM-4.6", maxOutput: 4096, contextWindowTokens: 198000, releasedAt: "2025-09-30" },
+    { id: "Qwen/Qwen3-VL-30B-A3B-Instruct", maxOutput: 4096, contextWindowTokens: 256000 },
+    { id: "Qwen/Qwen3-VL-30B-A3B-Thinking", maxOutput: 4096, contextWindowTokens: 256000 },
+    { id: "Qwen/Qwen3-VL-235B-A22B-Instruct", maxOutput: 4096, contextWindowTokens: 256000 },
+    { id: "Qwen/Qwen3-VL-235B-A22B-Thinking", maxOutput: 4096, contextWindowTokens: 256000 },
+    { id: "deepseek-ai/DeepSeek-V3.1-Terminus", maxOutput: 4096, contextWindowTokens: 163840 },
+    { id: "Pro/deepseek-ai/DeepSeek-V3.1-Terminus", maxOutput: 4096, contextWindowTokens: 163840 },
+    { id: "Qwen/Qwen3-VL-8B-Instruct", maxOutput: 4096, contextWindowTokens: 256000, releasedAt: "2025-10-15" },
+    { id: "Qwen/Qwen3-VL-8B-Thinking", maxOutput: 4096, contextWindowTokens: 256000, releasedAt: "2025-10-15" },
+    { id: "inclusionAI/Ring-flash-2.0", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-09-19" },
+    { id: "inclusionAI/Ling-flash-2.0", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-09-17" },
+    { id: "inclusionAI/Ling-mini-2.0", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-09-09" },
+    { id: "ByteDance-Seed/Seed-OSS-36B-Instruct", maxOutput: 4096, contextWindowTokens: 256000, releasedAt: "2025-08-20" },
+    { id: "stepfun-ai/step3", maxOutput: 4096, contextWindowTokens: 65536, releasedAt: "2025-07-31" },
+    { id: "Qwen/Qwen3-Coder-480B-A35B-Instruct", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-07-23" },
+    { id: "Qwen/Qwen3-Coder-30B-A3B-Instruct", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-07-31" },
+    { id: "zai-org/GLM-4.5V", maxOutput: 4096, contextWindowTokens: 65536, releasedAt: "2025-08-11" },
+    { id: "zai-org/GLM-4.5-Air", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-07-28" },
+    { id: "ascend-tribe/pangu-pro-moe", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-06-17" },
+    { id: "baidu/ERNIE-4.5-300B-A47B", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-06-30" },
+    { id: "moonshotai/Kimi-K2-Instruct-0905", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-09-05" },
+    { id: "Pro/moonshotai/Kimi-K2-Instruct-0905", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-09-05" },
+    { id: "moonshotai/Kimi-Dev-72B", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-06-17" },
+    { id: "tencent/Hunyuan-A13B-Instruct", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-06-27" },
+    { id: "MiniMaxAI/MiniMax-M1-80k", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-06-16" },
+    { id: "Tongyi-Zhiwen/QwenLong-L1-32B", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-05-26" },
+    { id: "Qwen/Qwen3-235B-A22B-Thinking-2507", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-07-25" },
+    { id: "Qwen/Qwen3-235B-A22B-Instruct-2507", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-07-21" },
+    { id: "Qwen/Qwen3-30B-A3B-Thinking-2507", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-07-30" },
+    { id: "Qwen/Qwen3-30B-A3B-Instruct-2507", maxOutput: 4096, contextWindowTokens: 262144, releasedAt: "2025-07-29" },
+    { id: "Qwen/Qwen3-32B", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-04-28" },
+    { id: "Qwen/Qwen3-14B", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-04-28" },
+    { id: "Qwen/Qwen3-8B", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-04-28" },
+    { id: "THUDM/GLM-4.1V-9B-Thinking", maxOutput: 4096, contextWindowTokens: 65536, releasedAt: "2025-07-02" },
+    { id: "Pro/THUDM/GLM-4.1V-9B-Thinking", maxOutput: 4096, contextWindowTokens: 65536, releasedAt: "2025-07-02" },
+    { id: "THUDM/GLM-Z1-32B-0414", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-04-14" },
+    { id: "THUDM/GLM-Z1-9B-0414", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-04-14" },
+    { id: "THUDM/GLM-4-32B-0414", maxOutput: 4096, contextWindowTokens: 32768, releasedAt: "2025-04-14" },
+    { id: "THUDM/GLM-4-9B-0414", maxOutput: 4096, contextWindowTokens: 32768, releasedAt: "2025-04-14" },
+    { id: "deepseek-ai/DeepSeek-R1-0528-Qwen3-8B", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "deepseek-ai/DeepSeek-R1", maxOutput: 4096, contextWindowTokens: 98304 },
+    { id: "deepseek-ai/DeepSeek-V3", maxOutput: 4096, contextWindowTokens: 65536 },
+    { id: "Pro/deepseek-ai/DeepSeek-R1", maxOutput: 4096, contextWindowTokens: 98304 },
+    { id: "Pro/deepseek-ai/DeepSeek-V3", maxOutput: 4096, contextWindowTokens: 65536 },
+    { id: "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "deepseek-ai/DeepSeek-R1-Distill-Qwen-14B", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "Pro/deepseek-ai/DeepSeek-R1-Distill-Qwen-7B", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "deepseek-ai/DeepSeek-V2.5", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "Qwen/QVQ-72B-Preview", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "Qwen/QwQ-32B", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "Qwen/Qwen2.5-7B-Instruct", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "Pro/Qwen/Qwen2.5-7B-Instruct", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "Qwen/Qwen2.5-14B-Instruct", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "Qwen/Qwen2.5-32B-Instruct", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "Qwen/Qwen2.5-72B-Instruct", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "Qwen/Qwen2.5-72B-Instruct-128K", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "Qwen/Qwen2.5-Coder-32B-Instruct", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "Qwen/Qwen2-VL-72B-Instruct", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "Qwen/Qwen2.5-VL-32B-Instruct", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "Qwen/Qwen2.5-VL-72B-Instruct", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "internlm/internlm2_5-7b-chat", maxOutput: 4096, contextWindowTokens: 32768 },
+  ],
+};

+ 33 - 0
packages/core/src/llm/providers/endpoints/spark.ts

@@ -0,0 +1,33 @@
+/**
+ * 讯飞星火 (iFLYTEK Spark)
+ *
+ * - 官网:https://xinghuo.xfyun.cn/
+ * - 控制台 / API key:https://console.xfyun.cn/services/cbm
+ * - HTTP API 文档:https://www.xfyun.cn/doc/spark/HTTP%E8%B0%83%E7%94%A8%E6%96%87%E6%A1%A3.html
+ * - OpenAI 兼容接入:https://spark-api-open.xf-yun.com/v1
+ *
+ * 模型 id 命名历史遗留——用的是内部 domain 字段(generalv3 = Spark Pro,
+ * generalv3.5 = Spark Max,4.0Ultra = 当前旗舰)。bank 按官方文档清单维护。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const SPARK: InkosEndpoint = {
+  id: "spark",
+  label: "讯飞星火",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://spark-api-open.xf-yun.com/v1",
+  checkModel: "lite",
+  temperatureRange: [0, 1],
+  defaultTemperature: 0.5,
+  writingTemperature: 0.95,
+  models: [
+    // id 即官方文档 model 字段。对应产品名写在行尾注释
+    { id: "4.0Ultra", maxOutput: 32768, contextWindowTokens: 32768, enabled: true }, // Spark 4.0 Ultra
+    { id: "pro-128k", maxOutput: 32768, contextWindowTokens: 131072, enabled: true }, // Spark Pro-128K
+    { id: "max-32k", maxOutput: 8192, contextWindowTokens: 32768 }, // Spark Max-32K
+    { id: "generalv3.5", maxOutput: 8192, contextWindowTokens: 8192 }, // Spark Max (2026-03-10 后台升级到 Ultra)
+    { id: "generalv3", maxOutput: 8192, contextWindowTokens: 8192 }, // Spark Pro
+    { id: "lite", maxOutput: 4096, contextWindowTokens: 8192, enabled: true }, // Spark Lite
+  ],
+};

+ 35 - 0
packages/core/src/llm/providers/endpoints/stepfun.ts

@@ -0,0 +1,35 @@
+/**
+ * 阶跃星辰 (StepFun)
+ *
+ * - 官网:https://www.stepfun.com/
+ * - 控制台 / API key:https://platform.stepfun.com/interface-key
+ * - API 文档:https://platform.stepfun.com/docs/overview/concept
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const STEPFUN: InkosEndpoint = {
+  id: "stepfun",
+  label: "阶跃星辰",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://api.stepfun.com/v1",
+  checkModel: "step-1-8k",
+  temperatureRange: [0, 1],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "step-3.5-flash", maxOutput: 4096, contextWindowTokens: 256000, enabled: true },
+    { id: "step-3", maxOutput: 4096, contextWindowTokens: 64000, enabled: true },
+    { id: "step-r1-v-mini", maxOutput: 4096, contextWindowTokens: 100000 },
+    { id: "step-1-8k", maxOutput: 4096, contextWindowTokens: 8000 },
+    { id: "step-1-32k", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "step-1-256k", maxOutput: 4096, contextWindowTokens: 256000 },
+    { id: "step-2-mini", maxOutput: 4096, contextWindowTokens: 8000, releasedAt: "2025-01-14" },
+    { id: "step-2-16k", maxOutput: 4096, contextWindowTokens: 16000 },
+    { id: "step-2-16k-exp", maxOutput: 4096, contextWindowTokens: 16000, releasedAt: "2025-01-15" },
+    { id: "step-1v-8k", maxOutput: 4096, contextWindowTokens: 8000 },
+    { id: "step-1v-32k", maxOutput: 4096, contextWindowTokens: 32000 },
+    { id: "step-1o-vision-32k", maxOutput: 4096, contextWindowTokens: 32000, releasedAt: "2025-01-22" },
+    { id: "step-1o-turbo-vision", maxOutput: 4096, contextWindowTokens: 32000, releasedAt: "2025-02-14" },
+  ],
+};

+ 25 - 0
packages/core/src/llm/providers/endpoints/tencentcloud.ts

@@ -0,0 +1,25 @@
+/**
+ * 腾讯云 TI-ONE / LKE
+ *
+ * - 官网:https://cloud.tencent.com/product/lkeap
+ * - 控制台 / API key:https://console.cloud.tencent.com/lkeap/api
+ * - API 文档:https://cloud.tencent.com/document/product/1772
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const TENCENTCLOUD: InkosEndpoint = {
+  id: "tencentcloud",
+  label: "腾讯云 (lkeap)",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://api.lkeap.cloud.tencent.com/v1",
+  checkModel: "deepseek-v3",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "deepseek-r1", maxOutput: 16000, contextWindowTokens: 65536, enabled: true },
+    { id: "deepseek-v3-0324", maxOutput: 16000, contextWindowTokens: 65536, enabled: true },
+    { id: "deepseek-v3", maxOutput: 16000, contextWindowTokens: 65536 },
+  ],
+};

+ 52 - 0
packages/core/src/llm/providers/endpoints/volcengine.ts

@@ -0,0 +1,52 @@
+/**
+ * 火山方舟 (Volcengine Ark / Doubao)
+ *
+ * - 官网:https://www.volcengine.com/product/ark
+ * - 控制台 / API key:https://console.volcengine.com/ark/region:ark+cn-beijing/apiKey
+ * - API 文档:https://www.volcengine.com/docs/82379
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const VOLCENGINE: InkosEndpoint = {
+  id: "volcengine",
+  label: "火山引擎 (豆包)",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://ark.cn-beijing.volces.com/api/v3",
+  checkModel: "doubao-lite-32k",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "doubao-seed-2.0-pro", maxOutput: 32000, contextWindowTokens: 256000, enabled: true, releasedAt: "2026-02-15" },
+    { id: "doubao-seed-2.0-lite", maxOutput: 32000, contextWindowTokens: 256000, enabled: true, releasedAt: "2026-02-15" },
+    { id: "doubao-seed-2.0-mini", maxOutput: 32000, contextWindowTokens: 256000, releasedAt: "2026-02-15" },
+    { id: "doubao-seed-2.0-code", maxOutput: 32000, contextWindowTokens: 256000, releasedAt: "2026-02-15" },
+    { id: "doubao-seed-1.8", maxOutput: 64000, contextWindowTokens: 256000, releasedAt: "2025-12-18" },
+    { id: "doubao-seed-code", maxOutput: 32000, contextWindowTokens: 256000 },
+    { id: "glm-4-7", maxOutput: 16000, contextWindowTokens: 200000 },
+    { id: "deepseek-v3.2", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "deepseek-v3.1", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "kimi-k2-thinking", maxOutput: 32768, contextWindowTokens: 262144, temperature: 1 },
+    { id: "kimi-k2", maxOutput: 32768, contextWindowTokens: 262144 },
+    { id: "doubao-seed-1.6-vision", maxOutput: 32000, contextWindowTokens: 256000 },
+    { id: "doubao-seed-1.6-thinking", maxOutput: 32000, contextWindowTokens: 256000 },
+    { id: "doubao-seed-1.6", maxOutput: 32000, contextWindowTokens: 256000 },
+    { id: "doubao-seed-1.6-lite", maxOutput: 32000, contextWindowTokens: 256000 },
+    { id: "doubao-seed-1.6-flash", maxOutput: 32000, contextWindowTokens: 256000 },
+    { id: "doubao-1.5-ui-tars", maxOutput: 16000, contextWindowTokens: 131072 },
+    { id: "doubao-1.5-thinking-vision-pro", maxOutput: 16000, contextWindowTokens: 131072 },
+    { id: "doubao-1.5-thinking-pro", maxOutput: 16000, contextWindowTokens: 131072 },
+    { id: "doubao-1.5-thinking-pro-m", maxOutput: 16000, contextWindowTokens: 131072 },
+    { id: "deepseek-r1", maxOutput: 16384, contextWindowTokens: 131072 },
+    { id: "deepseek-v3", maxOutput: 16384, contextWindowTokens: 128000 },
+    { id: "doubao-1.5-pro-32k", maxOutput: 16384, contextWindowTokens: 128000 },
+    { id: "doubao-1.5-pro-256k", maxOutput: 12288, contextWindowTokens: 256000 },
+    { id: "doubao-1.5-lite-32k", maxOutput: 12288, contextWindowTokens: 32768 },
+    { id: "doubao-1.5-vision-pro-32k", maxOutput: 12288, contextWindowTokens: 32768, releasedAt: "2025-01-15" },
+    { id: "doubao-1.5-vision-pro", maxOutput: 16384, contextWindowTokens: 128000, releasedAt: "2025-03-28" },
+    { id: "doubao-1.5-vision-lite", maxOutput: 16384, contextWindowTokens: 128000, releasedAt: "2025-03-15" },
+    { id: "doubao-lite-32k", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "doubao-pro-32k", maxOutput: 4096, contextWindowTokens: 32768 },
+  ],
+};

+ 42 - 0
packages/core/src/llm/providers/endpoints/volcengineCodingPlan.ts

@@ -0,0 +1,42 @@
+/**
+ * 火山方舟 Coding Plan (豆包编程订阅)
+ *
+ * - 官网:https://www.volcengine.com/product/ark
+ * - 订阅入口:https://www.volcengine.com/docs/82379/1925114
+ * - 快速开始:https://www.volcengine.com/docs/82379/1928261
+ * - 模型价格:https://www.volcengine.com/docs/82379/1544106
+ * - Anthropic 协议 baseUrl:https://ark.cn-beijing.volces.com/api/coding
+ *
+ * 火山方舟 Coding Plan 是火山引擎针对编程场景的订阅服务,订阅包内解锁多家
+ * 主力编程模型(豆包 / MiniMax / GLM / DeepSeek / Kimi),走 Anthropic
+ * 兼容协议,Claude Code / Cursor 等客户端可直连。
+ *
+ * 注:baseUrl 是 /api/coding(不是 /api/v3/anthropic)—— 后者是火山通用
+ * Anthropic 兼容接入,和 CodingPlan 订阅计量不通。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const VOLCENGINE_CODING_PLAN: InkosEndpoint = {
+  id: "volcengineCodingPlan",
+  label: "火山 Coding Plan",
+  group: "codingPlan",
+  api: "anthropic-messages",
+  baseUrl: "https://ark.cn-beijing.volces.com/api/coding",
+  checkModel: "doubao-seed-2.0-code",
+  temperatureRange: [0, 1],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    // --- Doubao-Seed-2.0 主力(2026-02-14 发布) ---
+    { id: "doubao-seed-2.0-code", maxOutput: 128000, contextWindowTokens: 256000, enabled: true, releasedAt: "2026-02-15" },
+    { id: "doubao-seed-2.0-pro", maxOutput: 128000, contextWindowTokens: 256000, enabled: true, releasedAt: "2026-02-15" },
+    { id: "doubao-seed-2.0-lite", maxOutput: 128000, contextWindowTokens: 256000, enabled: true, releasedAt: "2026-02-15" },
+    // --- 老版 ---
+    { id: "doubao-seed-code", maxOutput: 32000, contextWindowTokens: 256000, releasedAt: "2025-11-01" },
+    // --- 订阅包里的第三方模型 ---
+    { id: "minimax-m2.5", maxOutput: 131072, contextWindowTokens: 204800, enabled: true },
+    { id: "glm-4.7", maxOutput: 131072, contextWindowTokens: 200000, enabled: true },
+    { id: "deepseek-v3.2", maxOutput: 65536, contextWindowTokens: 262144, enabled: true },
+    { id: "kimi-k2.5", maxOutput: 32768, contextWindowTokens: 262144, enabled: true, temperature: 1 },
+  ],
+};

+ 106 - 0
packages/core/src/llm/providers/endpoints/wenxin.ts

@@ -0,0 +1,106 @@
+/**
+ * 百度文心 (ERNIE)
+ *
+ * - 官网:https://cloud.baidu.com/product/wenxinworkshop
+ * - 控制台 / API key:https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application/v2
+ * - API 文档:https://cloud.baidu.com/doc/WENXINWORKSHOP/index.html
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const WENXIN: InkosEndpoint = {
+  id: "wenxin",
+  label: "文心一言 (千帆)",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://qianfan.baidubce.com/v2",
+  checkModel: "ernie-3.5-8k",
+  temperatureRange: [0, 1],
+  defaultTemperature: 0.95,
+  writingTemperature: 0.95,
+  models: [
+    { id: "ernie-5.0-thinking-latest", maxOutput: 65536, contextWindowTokens: 131072, enabled: true, releasedAt: "2025-11-12" },
+    { id: "ernie-5.0-thinking-preview", maxOutput: 65536, contextWindowTokens: 131072, releasedAt: "2025-11-12" },
+    { id: "ernie-4.5-turbo-128k", maxOutput: 12288, contextWindowTokens: 131072, enabled: true },
+    { id: "ernie-4.5-turbo-128k-preview", maxOutput: 12288, contextWindowTokens: 131072 },
+    { id: "ernie-4.5-turbo-32k", maxOutput: 12288, contextWindowTokens: 32768 },
+    { id: "ernie-4.5-turbo-latest", maxOutput: 12288, contextWindowTokens: 131072 },
+    { id: "ernie-speed-pro-128k", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "ernie-lite-pro-128k", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "ernie-char-8k", maxOutput: 2048, contextWindowTokens: 8192 },
+    { id: "ernie-char-fiction-8k", maxOutput: 2048, contextWindowTokens: 8192 },
+    { id: "ernie-char-fiction-8k-preview", maxOutput: 2048, contextWindowTokens: 8192 },
+    { id: "ernie-novel-8k", maxOutput: 2048, contextWindowTokens: 8192 },
+    { id: "ernie-4.5-0.3b", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "ernie-4.5-21b-a3b", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "ernie-4.5-21b-a3b-thinking", maxOutput: 8192, contextWindowTokens: 131072 },
+    { id: "ernie-4.5-vl-28b-a3b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "qianfan-8b", maxOutput: 16384, contextWindowTokens: 32768 },
+    { id: "qianfan-70b", maxOutput: 16384, contextWindowTokens: 32768 },
+    { id: "qianfan-agent-intent-32k", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "qianfan-agent-lite-8k", maxOutput: 2048, contextWindowTokens: 8192 },
+    { id: "ernie-4.5-turbo-vl-preview", maxOutput: 16384, contextWindowTokens: 131072 },
+    { id: "ernie-4.5-turbo-vl", maxOutput: 16384, contextWindowTokens: 131072 },
+    { id: "ernie-4.5-turbo-vl-32k", maxOutput: 12288, contextWindowTokens: 32768 },
+    { id: "ernie-4.5-turbo-vl-32k-preview", maxOutput: 16384, contextWindowTokens: 32768 },
+    { id: "ernie-4.5-turbo-vl-latest", maxOutput: 16384, contextWindowTokens: 131072 },
+    { id: "ernie-4.5-8k-preview", maxOutput: 2048, contextWindowTokens: 8192 },
+    { id: "ernie-x1.1", maxOutput: 65536, contextWindowTokens: 65536, enabled: true },
+    { id: "ernie-x1.1-preview", maxOutput: 65536, contextWindowTokens: 65536 },
+    { id: "ernie-x1-turbo-32k", maxOutput: 28160, contextWindowTokens: 32768 },
+    { id: "ernie-x1-turbo-32k-preview", maxOutput: 28160, contextWindowTokens: 32768 },
+    { id: "qianfan-composition", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "qianfan-check-vl", maxOutput: 131072, contextWindowTokens: 131072 },
+    { id: "qianfan-multipicocr", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qianfan-vl-70b", maxOutput: 28672, contextWindowTokens: 32768 },
+    { id: "qianfan-vl-8b", maxOutput: 28672, contextWindowTokens: 32768 },
+    { id: "qianfan-llama-vl-8b", maxOutput: 16384, contextWindowTokens: 32768 },
+    { id: "qianfan-qi-vl", maxOutput: 131072, contextWindowTokens: 131072 },
+    { id: "qianfan-engcard-vl", maxOutput: 4000, contextWindowTokens: 4096 },
+    { id: "qianfan-singlepicocr", maxOutput: 4096, contextWindowTokens: 4096 },
+    { id: "internvl3-38b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "internvl3-14b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "internvl3-1b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "internvl2.5-38b-mpo", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "qwen3-vl-32b-instruct", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-vl-32b-thinking", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-vl-8b-instruct", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-vl-8b-thinking", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-vl-30b-a3b-instruct", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-vl-30b-a3b-thinking", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-vl-235b-a22b-instruct", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-vl-235b-a22b-thinking", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen2.5-vl-32b-instruct", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "qwen2.5-vl-7b-instruct", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "glm-4.5v", maxOutput: 16384, contextWindowTokens: 65536 },
+    { id: "deepseek-vl2", maxOutput: 2048, contextWindowTokens: 4096 },
+    { id: "deepseek-vl2-small", maxOutput: 2048, contextWindowTokens: 4096 },
+    { id: "deepseek-v3.2-think", maxOutput: 32768, contextWindowTokens: 144000 },
+    { id: "deepseek-v3.1-think-250821", maxOutput: 32768, contextWindowTokens: 144000 },
+    { id: "deepseek-r1-250528", maxOutput: 32768, contextWindowTokens: 144000 },
+    { id: "deepseek-r1", maxOutput: 32768, contextWindowTokens: 144000 },
+    { id: "deepseek-r1-distill-qianfan-70b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "deepseek-r1-distill-qianfan-8b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "deepseek-r1-distill-qianfan-llama-70b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "deepseek-r1-distill-llama-70b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "deepseek-r1-distill-qwen-32b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "deepseek-r1-distill-qwen-14b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "deepseek-r1-distill-qwen-7b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "deepseek-r1-distill-qwen-1.5b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "qwen3-next-80b-a3b-thinking", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-235b-a22b-thinking-2507", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-30b-a3b-thinking-2507", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwq-32b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "kimi-k2-instruct", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-coder-480b-a35b-instruct", maxOutput: 65536, contextWindowTokens: 131072 },
+    { id: "qwen3-235b-a22b-instruct-2507", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-30b-a3b-instruct-2507", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "qwen3-30b-a3b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "qwen3-32b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "qwen3-14b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "qwen3-8b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "qwen3-4b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "qwen3-1.7b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "qwen3-0.6b", maxOutput: 8192, contextWindowTokens: 32768 },
+    { id: "qwen2.5-7b-instruct", maxOutput: 8192, contextWindowTokens: 32768 },
+  ],
+};

+ 34 - 0
packages/core/src/llm/providers/endpoints/xai.ts

@@ -0,0 +1,34 @@
+/**
+ * xAI (Grok)
+ *
+ * - 官网:https://x.ai/
+ * - 控制台 / API key:https://console.x.ai/
+ * - API 文档:https://docs.x.ai/docs/overview
+ * - 模型列表:https://docs.x.ai/docs/models
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const XAI: InkosEndpoint = {
+  id: "xai",
+  label: "xAI (Grok)",
+  group: "overseas",
+  api: "openai-completions",
+  baseUrl: "https://api.x.ai/v1",
+  checkModel: "grok-2-1212",
+  temperatureRange: [0, 2],
+  defaultTemperature: 1,
+  writingTemperature: 1,
+  models: [
+    { id: "grok-4.20-beta-0309-reasoning", maxOutput: 4096, contextWindowTokens: 2000000, enabled: true, releasedAt: "2026-03-09" },
+    { id: "grok-4.20-beta-0309-non-reasoning", maxOutput: 4096, contextWindowTokens: 2000000, enabled: true, releasedAt: "2026-03-09" },
+    { id: "grok-4.20-multi-agent-beta-0309", maxOutput: 4096, contextWindowTokens: 2000000, enabled: true, releasedAt: "2026-03-09" },
+    { id: "grok-4-1-fast-non-reasoning", maxOutput: 4096, contextWindowTokens: 2000000, enabled: true, releasedAt: "2025-11-20" },
+    { id: "grok-4-1-fast-reasoning", maxOutput: 4096, contextWindowTokens: 2000000, enabled: true, releasedAt: "2025-11-20" },
+    { id: "grok-4-fast-non-reasoning", maxOutput: 4096, contextWindowTokens: 2000000, releasedAt: "2025-09-09" },
+    { id: "grok-4-fast-reasoning", maxOutput: 4096, contextWindowTokens: 2000000, releasedAt: "2025-09-09" },
+    { id: "grok-code-fast-1", maxOutput: 4096, contextWindowTokens: 256000, releasedAt: "2025-08-27" },
+    { id: "grok-4", maxOutput: 4096, contextWindowTokens: 256000, releasedAt: "2025-07-09" },
+    { id: "grok-3", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-04-03" },
+    { id: "grok-3-mini", maxOutput: 4096, contextWindowTokens: 131072, releasedAt: "2025-04-03" },
+  ],
+};

+ 26 - 0
packages/core/src/llm/providers/endpoints/xiaomimimo.ts

@@ -0,0 +1,26 @@
+/**
+ * 小米 MiMo
+ *
+ * - 官网:https://api-ai.xiaomi.com/
+ * - API 端点:https://api-ai.xiaomi.com/v1 (OpenAI 兼容)
+ * - 模型卡 (HuggingFace):https://huggingface.co/XiaomiMiMo
+ *
+ * MiMo 是小米自研模型系列,除小米官方 /v1 外,也在 PPIO / 百炼等第三方平台开放。
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const XIAOMI_MIMO: InkosEndpoint = {
+  id: "xiaomimimo",
+  label: "小米 MiMo",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://api-ai.xiaomi.com/v1",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.7,
+  writingTemperature: 1,
+  models: [
+    { id: "mimo-v2-pro", maxOutput: 131072, contextWindowTokens: 1000000, enabled: true, releasedAt: "2026-03-18" },
+    { id: "mimo-v2-omni", maxOutput: 131072, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-03-18" },
+    { id: "mimo-v2-flash", maxOutput: 65536, contextWindowTokens: 262144, enabled: true, releasedAt: "2026-03-03" },
+  ],
+};

+ 34 - 0
packages/core/src/llm/providers/endpoints/zeroone.ts

@@ -0,0 +1,34 @@
+/**
+ * 零一万物 (01.AI / Yi)
+ *
+ * - 官网:https://www.lingyiwanwu.com/
+ * - 控制台 / API key:https://platform.lingyiwanwu.com/apikeys
+ * - API 文档:https://platform.lingyiwanwu.com/docs
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const ZEROONE: InkosEndpoint = {
+  id: "zeroone",
+  label: "零一万物 (01.AI)",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://api.lingyiwanwu.com/v1",
+  checkModel: "yi-lightning",
+  temperatureRange: [0, 2],
+  defaultTemperature: 0.3,
+  writingTemperature: 1,
+  models: [
+    { id: "yi-lightning", maxOutput: 4096, contextWindowTokens: 16384, enabled: true },
+    { id: "yi-vision-v2", maxOutput: 4096, contextWindowTokens: 16384, enabled: true },
+    { id: "yi-spark", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "yi-medium", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "yi-medium-200k", maxOutput: 4096, contextWindowTokens: 200000 },
+    { id: "yi-large-turbo", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "yi-large-rag", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "yi-large-fc", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "yi-large", maxOutput: 4096, contextWindowTokens: 32768 },
+    { id: "yi-vision", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "yi-large-preview", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "yi-lightning-lite", maxOutput: 4096, contextWindowTokens: 16384 },
+  ],
+};

+ 61 - 0
packages/core/src/llm/providers/endpoints/zhipu.ts

@@ -0,0 +1,61 @@
+/**
+ * 智谱 GLM (Zhipu AI)
+ *
+ * - 官网:https://www.bigmodel.cn/
+ * - 控制台:https://open.bigmodel.cn/
+ * - API key:https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys
+ * - API 文档:https://docs.bigmodel.cn/cn/api/normal-model/glm-4
+ * - 模型列表:https://open.bigmodel.cn/dev/howuse/model
+ */
+import type { InkosEndpoint } from "../types.js";
+
+export const ZHIPU: InkosEndpoint = {
+  id: "zhipu",
+  label: "智谱 GLM",
+  group: "china",
+  api: "openai-completions",
+  baseUrl: "https://open.bigmodel.cn/api/paas/v4",
+  checkModel: "glm-4-flash",
+  temperatureRange: [0, 1],
+  defaultTemperature: 0.95,
+  writingTemperature: 0.95,
+  models: [
+    { id: "glm-5.1", maxOutput: 131072, contextWindowTokens: 200000, enabled: true, releasedAt: "2026-03-27" },
+    { id: "glm-5-turbo", maxOutput: 131072, contextWindowTokens: 200000, releasedAt: "2026-03-15" },
+    { id: "glm-5", maxOutput: 131072, contextWindowTokens: 200000, enabled: true, releasedAt: "2026-02-12" },
+    { id: "glm-4.7", maxOutput: 131072, contextWindowTokens: 200000, releasedAt: "2025-12-22" },
+    { id: "glm-4.7-flash", maxOutput: 131072, contextWindowTokens: 200000, enabled: true, releasedAt: "2026-01-19" },
+    { id: "glm-4.7-flashx", maxOutput: 131072, contextWindowTokens: 200000, releasedAt: "2026-01-19" },
+    { id: "glm-5v-turbo", maxOutput: 131072, contextWindowTokens: 200000, enabled: true, releasedAt: "2026-04-02" },
+    { id: "glm-4.6v", maxOutput: 32768, contextWindowTokens: 131072, releasedAt: "2025-12-08" },
+    { id: "glm-4.6v-flashx", maxOutput: 32768, contextWindowTokens: 131072, releasedAt: "2025-12-08" },
+    { id: "glm-4.6v-flash", maxOutput: 32768, contextWindowTokens: 131072, releasedAt: "2025-12-08" },
+    { id: "glm-4.6", maxOutput: 131072, contextWindowTokens: 200000, releasedAt: "2025-09-08" },
+    { id: "glm-4.5v", maxOutput: 16384, contextWindowTokens: 65536 },
+    { id: "glm-4.5", maxOutput: 98304, contextWindowTokens: 131072 },
+    { id: "glm-4.5-x", maxOutput: 98304, contextWindowTokens: 131072 },
+    { id: "glm-4.5-air", maxOutput: 98304, contextWindowTokens: 131072 },
+    { id: "glm-4.5-airx", maxOutput: 98304, contextWindowTokens: 131072 },
+    { id: "glm-4.1v-thinking-flashx", maxOutput: 32768, contextWindowTokens: 65536 },
+    { id: "glm-4.1v-thinking-flash", maxOutput: 32768, contextWindowTokens: 65536 },
+    { id: "glm-zero-preview", maxOutput: 4096, contextWindowTokens: 16384 },
+    { id: "glm-z1-air", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "glm-z1-airx", maxOutput: 32768, contextWindowTokens: 32768 },
+    { id: "glm-z1-flashx", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "glm-z1-flash", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "glm-4-flash", maxOutput: 32768, contextWindowTokens: 131072, enabled: true },
+    { id: "glm-4-flash-250414", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "glm-4-flashx", maxOutput: 4095, contextWindowTokens: 131072 },
+    { id: "glm-4-long", maxOutput: 4095, contextWindowTokens: 1024000 },
+    { id: "glm-4-air-250414", maxOutput: 16384, contextWindowTokens: 131072 },
+    { id: "glm-4-airx", maxOutput: 4095, contextWindowTokens: 8192 },
+    { id: "glm-4-plus", maxOutput: 4095, contextWindowTokens: 131072 },
+    { id: "glm-4-0520", maxOutput: 4096, contextWindowTokens: 131072 },
+    { id: "glm-4v-flash", maxOutput: 1024, contextWindowTokens: 4096, releasedAt: "2024-12-09" },
+    { id: "glm-4v-plus-0111", maxOutput: 8192, contextWindowTokens: 16000 },
+    { id: "glm-4v", maxOutput: 1024, contextWindowTokens: 4096 },
+    { id: "codegeex-4", maxOutput: 32768, contextWindowTokens: 131072 },
+    { id: "charglm-4", maxOutput: 4000, contextWindowTokens: 8192 },
+    { id: "emohaa", maxOutput: 4000, contextWindowTokens: 8192 },
+  ],
+};

+ 75 - 0
packages/core/src/llm/providers/index.ts

@@ -0,0 +1,75 @@
+import type { InkosEndpoint } from "./types.js";
+import { ANTHROPIC } from "./endpoints/anthropic.js";
+import { OPENAI } from "./endpoints/openai.js";
+import { GOOGLE } from "./endpoints/google.js";
+import { DEEPSEEK } from "./endpoints/deepseek.js";
+import { MINIMAX } from "./endpoints/minimax.js";
+// B1
+import { MOONSHOT } from "./endpoints/moonshot.js";
+import { ZHIPU } from "./endpoints/zhipu.js";
+import { SILICONCLOUD } from "./endpoints/siliconcloud.js";
+import { PPIO } from "./endpoints/ppio.js";
+import { BAILIAN } from "./endpoints/bailian.js";
+import { VOLCENGINE } from "./endpoints/volcengine.js";
+import { HUNYUAN } from "./endpoints/hunyuan.js";
+import { BAICHUAN } from "./endpoints/baichuan.js";
+import { STEPFUN } from "./endpoints/stepfun.js";
+import { WENXIN } from "./endpoints/wenxin.js";
+// B2
+import { SPARK } from "./endpoints/spark.js";
+import { SENSENOVA } from "./endpoints/sensenova.js";
+import { TENCENTCLOUD } from "./endpoints/tencentcloud.js";
+import { XIAOMI_MIMO } from "./endpoints/xiaomimimo.js";
+import { LONGCAT } from "./endpoints/longcat.js";
+import { INTERNLM } from "./endpoints/internlm.js";
+// B3
+import { MODELSCOPE } from "./endpoints/modelscope.js";
+import { GITEEAI } from "./endpoints/giteeai.js";
+import { QINIU } from "./endpoints/qiniu.js";
+import { INFINIAI } from "./endpoints/infiniai.js";
+import { ZEROONE } from "./endpoints/zeroone.js";
+import { AI360 } from "./endpoints/ai360.js";
+// B4
+import { OLLAMA } from "./endpoints/ollama.js";
+import { OPENROUTER } from "./endpoints/openrouter.js";
+import { CUSTOM } from "./endpoints/custom.js";
+import { MISTRAL } from "./endpoints/mistral.js";
+import { XAI } from "./endpoints/xai.js";
+import { NEWAPI } from "./endpoints/newapi.js";
+import { GITHUB_COPILOT } from "./endpoints/githubCopilot.js";
+// B6 CodingPlan
+import { KIMI_CODING_PLAN } from "./endpoints/kimiCodingPlan.js";
+import { MINIMAX_CODING_PLAN } from "./endpoints/minimaxCodingPlan.js";
+import { BAILIAN_CODING_PLAN } from "./endpoints/bailianCodingPlan.js";
+import { GLM_CODING_PLAN } from "./endpoints/glmCodingPlan.js";
+import { VOLCENGINE_CODING_PLAN } from "./endpoints/volcengineCodingPlan.js";
+import { OPENCODE_CODING_PLAN } from "./endpoints/opencodeCodingPlan.js";
+import { ASTRON_CODING_PLAN } from "./endpoints/astronCodingPlan.js";
+
+export type { InkosEndpoint, InkosModel, ApiProtocol, EndpointGroup } from "./types.js";
+
+/**
+ * 所有已注册 provider 的扁平列表。顺序定义了 lookup Layer 2 的遍历顺序,
+ * 但 Layer 2 还会按 PROVIDER_PRIORITY 显式排序,所以此处顺序不影响结果。
+ */
+const ALL_PROVIDERS: readonly InkosEndpoint[] = [
+  ANTHROPIC, OPENAI, GOOGLE, DEEPSEEK, MINIMAX,
+  MOONSHOT, ZHIPU, SILICONCLOUD, PPIO, BAILIAN, VOLCENGINE, HUNYUAN, BAICHUAN, STEPFUN, WENXIN,
+  SPARK, SENSENOVA, TENCENTCLOUD, XIAOMI_MIMO, LONGCAT, INTERNLM,
+  MODELSCOPE, GITEEAI, QINIU, INFINIAI, ZEROONE, AI360,
+  OLLAMA, OPENROUTER, CUSTOM, MISTRAL, XAI, NEWAPI, GITHUB_COPILOT,
+  // B6 CodingPlan(7 个)
+  KIMI_CODING_PLAN, MINIMAX_CODING_PLAN, BAILIAN_CODING_PLAN, GLM_CODING_PLAN, VOLCENGINE_CODING_PLAN, OPENCODE_CODING_PLAN, ASTRON_CODING_PLAN,
+];
+
+const PROVIDERS_BY_ID: Map<string, InkosEndpoint> = new Map(
+  ALL_PROVIDERS.map((p) => [p.id, p]),
+);
+
+export function getAllEndpoints(): readonly InkosEndpoint[] {
+  return ALL_PROVIDERS;
+}
+
+export function getEndpoint(id: string): InkosEndpoint | undefined {
+  return PROVIDERS_BY_ID.get(id);
+}

+ 70 - 0
packages/core/src/llm/providers/lookup.ts

@@ -0,0 +1,70 @@
+import type { InkosModel } from "./types.js";
+import { getAllEndpoints, getEndpoint } from "./index.js";
+
+/**
+ * provider id 优先级。Layer 2 全局扫时同 id 多条匹配按这个顺序取第一条,
+ * 保证结果确定性。白名单外的 provider 视同 999(排最后)。
+ */
+const PROVIDER_PRIORITY: readonly string[] = [
+  "anthropic", "openai", "google", "deepseek", "bailian", "moonshot",
+  "zhipu", "minimax", "xai",
+  "siliconcloud", "ppio",
+  "openrouter", "aihubmix", "novita",
+];
+
+/**
+ * 两层 lookup:
+ * - Layer 1: 已知 provider 精确查(整串比较,不拆斜线)
+ * - Layer 2: 全局扫所有 provider 的 models,按 provider id 优先级取第一条
+ * - 都 miss: 返回 undefined,调用方走保守默认
+ *
+ * 不做斜线前缀拆分。lobe 的 processModelList 证实了"靠调用入口带 provider 消歧"
+ * 是对的做法,斜线拆分对 PPIO / SiliconCloud 原生命名会误匹配。
+ */
+export function lookupModel(
+  serviceId: string,
+  modelId: string,
+): InkosModel | undefined {
+  const lowerId = modelId.toLowerCase();
+
+  const provider = getEndpoint(serviceId);
+  if (provider) {
+    const hit = provider.models.find((m) => m.id.toLowerCase() === lowerId);
+    if (hit) return hit;
+  }
+
+  const matches: Array<{ model: InkosModel; providerId: string }> = [];
+  for (const p of getAllEndpoints()) {
+    const hit = p.models.find((m) => m.id.toLowerCase() === lowerId);
+    if (hit) matches.push({ model: hit, providerId: p.id });
+  }
+  if (matches.length === 0) return undefined;
+
+  matches.sort((a, b) => {
+    const ai = PROVIDER_PRIORITY.indexOf(a.providerId);
+    const bi = PROVIDER_PRIORITY.indexOf(b.providerId);
+    return (ai === -1 ? 999 : ai) - (bi === -1 ? 999 : bi);
+  });
+  return matches[0].model;
+}
+
+/** 某 service 下可用(enabled !== false)的模型列表 */
+export function listEnabledModels(serviceId: string): InkosModel[] {
+  const provider = getEndpoint(serviceId);
+  if (!provider) return [];
+  return provider.models.filter((m) => m.enabled !== false);
+}
+
+export function isActiveTextModel(model: InkosModel): boolean {
+  if (model.enabled === false) return false;
+  if (model.status === "disabled" || model.status === "deprecated" || model.status === "nonText") return false;
+  if (model.capabilities?.text === false) return false;
+  if (model.capabilities?.imageOutput === true && model.capabilities?.text !== true) return false;
+  return true;
+}
+
+export function listActiveTextModels(serviceId: string): InkosModel[] {
+  const provider = getEndpoint(serviceId);
+  if (!provider) return [];
+  return provider.models.filter(isActiveTextModel);
+}

+ 33 - 0
packages/core/src/llm/providers/probe.ts

@@ -0,0 +1,33 @@
+/**
+ * 通用 OpenAI 兼容 /models 探针。
+ * 任何失败(网络错、超时、非 JSON、非 2xx)一律返回空数组,不抛异常。
+ */
+
+export interface ProbedModel {
+  readonly id: string;
+  readonly name: string;
+  readonly contextWindow: number;
+}
+
+export async function probeModelsFromUpstream(
+  baseUrl: string,
+  apiKey: string,
+  timeoutMs = 10_000,
+): Promise<ReadonlyArray<ProbedModel>> {
+  if (!baseUrl || !apiKey) return [];
+  try {
+    const modelsUrl = baseUrl.replace(/\/$/, "") + "/models";
+    const res = await fetch(modelsUrl, {
+      headers: { Authorization: `Bearer ${apiKey}` },
+      signal: AbortSignal.timeout(timeoutMs),
+    });
+    if (!res.ok) return [];
+    const json = (await res.json()) as { data?: Array<{ id: unknown }> };
+    if (!Array.isArray(json.data)) return [];
+    return json.data
+      .filter((m): m is { id: string } => typeof m.id === "string" && m.id.length > 0)
+      .map((m) => ({ id: m.id, name: m.id, contextWindow: 0 }));
+  } catch {
+    return [];
+  }
+}

+ 86 - 0
packages/core/src/llm/providers/types.ts

@@ -0,0 +1,86 @@
+/**
+ * inkos 自维护的 provider 定义。每个 provider 一个 .ts 文件,
+ * 里面一个 InkosEndpoint 对象(provider 元数据 + models 数组)。
+ *
+ * 数据冷启动自 lobe-chat/packages/model-bank,之后由 inkos 自管。
+ * 新模型发布 / 参数调整时手动加 card,不做持续 sync。
+ */
+
+export type ApiProtocol =
+  | "openai-completions"
+  | "openai-responses"
+  | "anthropic-messages";
+
+export type EndpointGroup =
+  | "overseas"
+  | "china"
+  | "aggregator"
+  | "local"
+  | "codingPlan";
+
+export interface InkosModel {
+  /** API 请求体里实际用的 model id(可能带斜线如 'deepseek/deepseek-v3')。UI 也直接用 id 显示 */
+  readonly id: string;
+  /** 模型输出上限 tokens */
+  readonly maxOutput: number;
+  /** 上下文窗口总 tokens */
+  readonly contextWindowTokens: number;
+  /** 默认 true。false 表示 lobe 标记此模型不可用 */
+  readonly enabled?: boolean;
+  /** CodingPlan 专用:API 调用时用这个代替 id 作为 model 字段 */
+  readonly deploymentName?: string;
+  /** 发布日期 ISO 字符串,可选(用于 UI 的"新模型"徽章) */
+  readonly releasedAt?: string;
+  /**
+   * API 硬要求的 temperature 值(例:Moonshot kimi-k2.5/k2.6 强制 1,违反直接 400)。
+   * 有值时 provider 层会 clamp 所有 per-call 温度到该值——这是服务端约束,
+   * 不是"推荐值"。普通模型不要设这个字段。
+   */
+  readonly temperature?: number;
+  /** 生命周期状态;enabled=false 仍保留为兼容旧数据的硬下线标记。 */
+  readonly status?: "active" | "deprecated" | "disabled" | "nonText";
+  readonly replacement?: string;
+  readonly capabilities?: {
+    readonly text?: boolean;
+    readonly imageInput?: boolean;
+    readonly imageOutput?: boolean;
+    readonly tools?: boolean;
+    readonly reasoning?: boolean;
+  };
+}
+
+export interface ProviderCompat {
+  /** OpenAI Responses store 参数是否被兼容层接受;Google Gemini OpenAI-compatible 不接受。 */
+  readonly supportsStore?: boolean;
+  readonly supportsSystemRole?: boolean;
+  readonly supportsDeveloperRole?: boolean;
+}
+
+export interface ProviderTransportDefaults {
+  readonly apiFormat?: "chat" | "responses";
+  readonly stream?: boolean;
+}
+
+export interface InkosEndpoint {
+  readonly id: string;
+  readonly label: string;
+  /** UI 分组。custom 不参与分组,其他 endpoint 必填。 */
+  readonly group?: EndpointGroup;
+
+  readonly api: ApiProtocol;
+  readonly baseUrl: string;
+  /** /models 接口的 baseUrl 跟主 baseUrl path 不同时(如百炼 dashscope) */
+  readonly modelsBaseUrl?: string;
+
+  /** apikey 两步验证时发 chat hello 用的模型 id */
+  readonly checkModel?: string;
+
+  readonly temperatureRange?: readonly [number, number];
+  readonly defaultTemperature?: number;
+  readonly writingTemperature?: number;
+  readonly temperatureHint?: string;
+  readonly compat?: ProviderCompat;
+  readonly transportDefaults?: ProviderTransportDefaults;
+
+  readonly models: readonly InkosModel[];
+}

+ 101 - 0
packages/core/src/llm/providers/verify.ts

@@ -0,0 +1,101 @@
+import { getEndpoint } from "./index.js";
+import { resolveServiceModelsBaseUrl } from "../service-presets.js";
+
+export interface VerifyResult {
+  readonly recommendedTransport?: {
+    readonly apiFormat?: "chat" | "responses";
+    readonly stream?: boolean;
+  };
+  readonly probe: {
+    readonly ok: boolean;
+    readonly models: number;
+    readonly error?: string;
+  };
+  readonly chat: {
+    readonly ok: boolean;
+    readonly latencyMs?: number;
+    readonly error?: string;
+  } | null;
+}
+
+/**
+ * Step 1: 用 apikey 调 /models endpoint 探测。
+ * 返回 models 条数 + ok 标志;失败原因落到 error 字段。
+ */
+async function probe(
+  service: string,
+  apiKey: string,
+  baseUrl?: string,
+): Promise<VerifyResult["probe"]> {
+  const provider = getEndpoint(service);
+  const probeBaseUrl = baseUrl || provider?.modelsBaseUrl || provider?.baseUrl || resolveServiceModelsBaseUrl(service);
+  if (!probeBaseUrl) {
+    return { ok: false, models: 0, error: "无 baseUrl 可探测(custom / newapi / higress 需要用户填)" };
+  }
+  try {
+    const url = probeBaseUrl.replace(/\/$/, "") + "/models";
+    const res = await fetch(url, {
+      headers: { Authorization: `Bearer ${apiKey}` },
+      signal: AbortSignal.timeout(10_000),
+    });
+    if (!res.ok) {
+      return { ok: false, models: 0, error: `HTTP ${res.status} ${res.statusText}` };
+    }
+    const json = (await res.json()) as { data?: Array<{ id: string }> };
+    const count = json.data?.length ?? 0;
+    return { ok: count > 0, models: count };
+  } catch (error) {
+    return {
+      ok: false,
+      models: 0,
+      error: error instanceof Error ? error.message : String(error),
+    };
+  }
+}
+
+/**
+ * verifyService: 两步验证。
+ * Step 1 probe /models。
+ * Step 2 chat hello 到 checkModel(命中 provider.checkModel 才做;custom / newapi / higress 没有 checkModel,chat 字段返回 null)。
+ */
+export async function verifyService(
+  service: string,
+  apiKey: string,
+  opts?: { checkModel?: string; baseUrl?: string },
+): Promise<VerifyResult> {
+  const probeResult = await probe(service, apiKey, opts?.baseUrl);
+
+  const provider = getEndpoint(service);
+  const recommendedTransport = provider?.transportDefaults;
+  const checkModel = opts?.checkModel ?? provider?.checkModel;
+  if (!checkModel) {
+    return { recommendedTransport, probe: probeResult, chat: null };
+  }
+
+  const start = Date.now();
+  try {
+    const { createLLMClient, chatCompletion } = await import("../provider.js");
+    const { LLMConfigSchema } = await import("../../models/project.js");
+    const client = createLLMClient(LLMConfigSchema.parse({
+      provider: provider?.api === "anthropic-messages" ? "anthropic" : "openai",
+      service,
+      model: checkModel,
+      apiKey,
+      baseUrl: opts?.baseUrl ?? provider?.baseUrl ?? "",
+      configSource: "studio",
+      stream: false,
+    }));
+    await chatCompletion(client, checkModel, [{ role: "user", content: "hi" }], { maxTokens: 10 });
+    return { recommendedTransport, probe: probeResult, chat: { ok: true, latencyMs: Date.now() - start } };
+  } catch (error) {
+    return {
+      recommendedTransport,
+      probe: probeResult,
+      chat: {
+        ok: false,
+        latencyMs: Date.now() - start,
+        error: error instanceof Error ? error.message : String(error),
+      },
+    };
+  }
+}

+ 29 - 2
packages/core/src/llm/secrets.ts

@@ -8,18 +8,45 @@ export interface SecretsFile {
 const SECRETS_DIR = ".inkos";
 const SECRETS_FILE = "secrets.json";
 
-export async function loadSecrets(projectRoot: string): Promise<SecretsFile> {
+const LEGACY_SERVICE_ID_REMAP: Record<string, string> = {
+  siliconflow: "siliconcloud",
+};
+
+function migrateLegacyServiceIds(secrets: SecretsFile): { data: SecretsFile; changed: boolean } {
+  let changed = false;
+  for (const [oldId, newId] of Object.entries(LEGACY_SERVICE_ID_REMAP)) {
+    if (secrets.services[oldId] && !secrets.services[newId]) {
+      secrets.services[newId] = secrets.services[oldId];
+      delete secrets.services[oldId];
+      changed = true;
+    }
+  }
+  return { data: secrets, changed };
+}
+
+async function readSecretsRaw(projectRoot: string): Promise<SecretsFile> {
   try {
     const raw = await readFile(
       join(projectRoot, SECRETS_DIR, SECRETS_FILE),
       "utf-8",
     );
-    return JSON.parse(raw) as SecretsFile;
+    const parsed = JSON.parse(raw) as SecretsFile;
+    if (!parsed || typeof parsed !== "object" || !parsed.services) {
+      return { services: {} };
+    }
+    return parsed;
   } catch {
     return { services: {} };
   }
 }
 
+export async function loadSecrets(projectRoot: string): Promise<SecretsFile> {
+  const raw = await readSecretsRaw(projectRoot);
+  const { data, changed } = migrateLegacyServiceIds(raw);
+  if (changed) await saveSecrets(projectRoot, data);
+  return data;
+}
+
 export async function saveSecrets(
   projectRoot: string,
   secrets: SecretsFile,

+ 86 - 44
packages/core/src/llm/service-presets.ts

@@ -1,9 +1,12 @@
+import { getEndpoint } from "./providers/index.js";
+import { probeModelsFromUpstream } from "./providers/probe.js";
+
 export interface ServicePreset {
   readonly providerFamily: "openai" | "anthropic";
   readonly api: string;
   readonly baseUrl: string;
   readonly label: string;
-  readonly temperatureRange?: [number, number];
+  readonly temperatureRange?: readonly [number, number];
   readonly defaultTemperature?: number;
   readonly writingTemperature?: number;
   readonly temperatureHint?: string;
@@ -32,7 +35,6 @@ export const SERVICE_PRESETS: Record<string, ServicePreset> = {
     providerFamily: "anthropic",
     api: "anthropic-messages",
     baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
-    modelsBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1",
     label: "百炼 (通义千问)",
     temperatureRange: [0, 2],
     defaultTemperature: 0.7,
@@ -48,7 +50,34 @@ export const SERVICE_PRESETS: Record<string, ServicePreset> = {
 };
 
 export function resolveServicePreset(service: string): ServicePreset | undefined {
-  return SERVICE_PRESETS[service];
+  const provider = getEndpoint(service);
+  const legacy = SERVICE_PRESETS[service];
+  if (!provider && !legacy) return undefined;
+
+  return {
+    providerFamily: legacy?.providerFamily ?? (provider?.api.startsWith("anthropic") ? "anthropic" : "openai"),
+    api: (provider?.api ?? legacy?.api ?? "openai-completions") as ServicePreset["api"],
+    baseUrl: provider?.baseUrl ?? legacy?.baseUrl ?? "",
+    label: provider?.label ?? legacy?.label ?? service,
+    ...(provider?.temperatureRange ?? legacy?.temperatureRange
+      ? { temperatureRange: provider?.temperatureRange ?? legacy?.temperatureRange }
+      : {}),
+    ...(provider?.defaultTemperature !== undefined || legacy?.defaultTemperature !== undefined
+      ? { defaultTemperature: provider?.defaultTemperature ?? legacy?.defaultTemperature }
+      : {}),
+    ...(provider?.writingTemperature !== undefined || legacy?.writingTemperature !== undefined
+      ? { writingTemperature: provider?.writingTemperature ?? legacy?.writingTemperature }
+      : {}),
+    ...(provider?.temperatureHint ?? legacy?.temperatureHint
+      ? { temperatureHint: provider?.temperatureHint ?? legacy?.temperatureHint }
+      : {}),
+    ...(legacy?.knownModels ? { knownModels: legacy.knownModels } : {}),
+    // piProvider 字段已从 InkosEndpoint 移除(走 provider-to-pi-ai adapter),这里只保留 legacy fallback
+    ...(legacy?.piProvider ? { piProvider: legacy.piProvider } : {}),
+    ...((provider ? provider.modelsBaseUrl : legacy?.modelsBaseUrl)
+      ? { modelsBaseUrl: provider ? provider.modelsBaseUrl : legacy?.modelsBaseUrl }
+      : {}),
+  };
 }
 
 export function resolveServiceProviderFamily(service: string): "openai" | "anthropic" | undefined {
@@ -101,57 +130,70 @@ export const SERVICE_TO_PI_PROVIDER: Record<string, string> = Object.fromEntries
 export interface ModelInfo {
   readonly id: string;
   readonly name: string;
-  readonly reasoning: boolean;
   readonly contextWindow: number;
+  /** 模型输出上限(来自 providers bank 或 live /models 补充) */
+  readonly maxOutput?: number;
 }
 
-export async function listModelsForService(service: string, apiKey?: string): Promise<ReadonlyArray<ModelInfo>> {
-  const preset = SERVICE_PRESETS[service];
-  if (!preset || service === "custom") return [];
+function toModelInfo(inkosModel: { id: string; maxOutput: number; contextWindowTokens: number }): ModelInfo {
+  return {
+    id: inkosModel.id,
+    name: inkosModel.id,
+    contextWindow: inkosModel.contextWindowTokens,
+    maxOutput: inkosModel.maxOutput,
+  };
+}
 
-  if (preset.knownModels && preset.knownModels.length > 0) {
-    return preset.knownModels.map((id) => ({ id, name: id, reasoning: false, contextWindow: 0 }));
+/**
+ * listModelsForService(R4 精修):
+ * - 先试 live /models probe(如果 baseUrl + apiKey 具备)
+ * - probe 失败或无 apiKey:fallback 到 provider.models(inkos bank)
+ * - 不再做 INKOS_LLM_MODEL env 补丁(会污染跨 service 菜单;bank 已足够全)
+ *
+ * custom / newapi / higress 等 baseUrl 空的 gateway provider:
+ *   必须传 liveBaseUrl 才能做 probe;否则只依赖 bank。
+ */
+export async function listModelsForService(
+  service: string,
+  apiKey?: string,
+  liveBaseUrl?: string,
+): Promise<ReadonlyArray<ModelInfo>> {
+  const provider = getEndpoint(service);
+  const preset = SERVICE_PRESETS[service];
+  if (!provider && !preset) return [];
+
+  const byId = new Map<string, ModelInfo>();
+
+  // 1) 先试 live /models probe
+  const probeBaseUrl = liveBaseUrl || provider?.modelsBaseUrl || provider?.baseUrl || resolveServiceModelsBaseUrl(service);
+  if (apiKey && probeBaseUrl) {
+    const probed = await probeModelsFromUpstream(probeBaseUrl, apiKey, 10_000);
+    if (probed.length > 0) {
+      const { lookupModel } = await import("./providers/lookup.js");
+      for (const m of probed) {
+        const card = lookupModel(service, m.id);
+        byId.set(m.id, card ? toModelInfo(card) : { id: m.id, name: m.name, contextWindow: m.contextWindow });
+      }
+    }
   }
 
-  const modelsBaseUrl = resolveServiceModelsBaseUrl(service);
-  if (apiKey && modelsBaseUrl) {
-    try {
-      const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
-      const res = await fetch(modelsUrl, {
-        headers: { Authorization: `Bearer ${apiKey}` },
-        signal: AbortSignal.timeout(10_000),
-      });
-      if (res.ok) {
-        const json = await res.json() as { data?: Array<{ id: string; owned_by?: string }> };
-        if (json.data && json.data.length > 0) {
-          return json.data.map((m) => ({
-            id: m.id,
-            name: m.id,
-            reasoning: false,
-            contextWindow: 0,
-          }));
-        }
-      }
-    } catch {
-      // /models unavailable, fall through
+  // 2) provider bank fallback / 补充
+  if (provider) {
+    for (const m of provider.models) {
+      if (m.enabled === false) continue;
+      if (byId.has(m.id)) continue;
+      byId.set(m.id, toModelInfo(m));
     }
   }
 
-  const piProvider = SERVICE_TO_PI_PROVIDER[service];
-  if (!piProvider) return [];
-
-  try {
-    const { getModels } = await import("@mariozechner/pi-ai");
-    const models = getModels(piProvider as any);
-    return models.map((m: any) => ({
-      id: m.id,
-      name: m.name,
-      reasoning: m.reasoning ?? false,
-      contextWindow: m.contextWindow ?? 0,
-    }));
-  } catch {
-    return [];
+  // 3) 旧 knownModels fallback
+  if (byId.size === 0 && preset?.knownModels) {
+    for (const id of preset.knownModels) {
+      byId.set(id, { id, name: id, contextWindow: 0 });
+    }
   }
+
+  return Array.from(byId.values());
 }
 
 export async function listServicesWithModelCount(): Promise<ReadonlyArray<{ service: string; label: string; modelCount: number }>> {

+ 1 - 1
packages/core/src/llm/service-resolver.ts

@@ -7,7 +7,7 @@ export interface ResolvedModel {
   model: Model<Api>;
   apiKey: string;
   writingTemperature?: number;
-  temperatureRange?: [number, number];
+  temperatureRange?: readonly [number, number];
   temperatureHint?: string;
 }
 

+ 4 - 9
packages/core/src/models/project.ts

@@ -1,15 +1,18 @@
 import { z } from "zod";
 
+// C1 (v2.0.0 breaking): `maxTokens` 字段已被 providers bank 接管;zod 用 strip mode 静默丢弃老配置里的 `maxTokens`。
 const LLMServiceEntrySchema = z.object({
   service: z.string().min(1),
   name: z.string().min(1).optional(),
   baseUrl: z.string().url().optional(),
   temperature: z.number().min(0).max(2).optional(),
-  maxTokens: z.number().int().min(1).optional(),
   apiFormat: z.enum(["chat", "responses"]).optional(),
   stream: z.boolean().optional(),
 });
 
+// C1 (v2.0.0 breaking): 删除 maxTokens / maxTokensCap 字段。
+// 每个模型的真实 maxOutput 来自 providers/<name>.ts 的 InkosModel.maxOutput;
+// 老配置里写的 maxTokens / maxTokensCap 会被 zod strip 静默丢弃(不报错)。
 export const LLMConfigSchema = z.object({
   provider: z.enum(["anthropic", "openai", "custom"]),
   service: z.string().default("custom"),
@@ -18,14 +21,6 @@ export const LLMConfigSchema = z.object({
   apiKey: z.string().default(""),
   model: z.string().min(1),
   temperature: z.number().min(0).max(2).default(0.7),
-  maxTokens: z.number().int().min(1).default(8192),
-  // per-call 硬上限。默认 undefined → createLLMClient 里变 null → 不封顶 per-call。
-  // 只有用户确实要限制单次调用最大输出时才显式设。注意:这个字段跟 maxTokens
-  // 语义不同:maxTokens 是"agent 没传 per-call 时的 fallback",maxTokensCap
-  // 是"给 agent per-call 加硬上限"。分开成两个字段是为了避免旧实现下
-  // config.maxTokens 同时被当 fallback 和 cap 的歧义(旧行为会把 architect
-  // per-call 16384 裁到 config.maxTokens=8192,导致基础设定输出被截断)。
-  maxTokensCap: z.number().int().min(1).optional(),
   thinkingBudget: z.number().int().min(0).default(0),
   extra: z.record(z.unknown()).optional(),
   headers: z.record(z.string()).optional(),

+ 0 - 1
packages/core/src/pipeline/runner.ts

@@ -400,7 +400,6 @@ export class PipelineRunner {
         apiKey,
         model: override.model,
         temperature: base?.temperature ?? 0.7,
-        maxTokens: base?.maxTokens ?? 8192,
         thinkingBudget: base?.thinkingBudget ?? 0,
         apiFormat,
         stream,

+ 23 - 291
packages/core/src/utils/config-loader.ts

@@ -1,297 +1,29 @@
-import { readFile, access } from "node:fs/promises";
-import { homedir } from "node:os";
-import { join } from "node:path";
-import { ProjectConfigSchema, type ProjectConfig } from "../models/project.js";
-import { getServiceApiKey } from "../llm/secrets.js";
-import { resolveServicePreset, resolveServiceProviderFamily } from "../llm/service-presets.js";
+import type { ProjectConfig } from "../models/project.js";
+import {
+  resolveEffectiveLLMConfig,
+  type LLMConfigCliOverrides,
+  type LLMConsumer,
+} from "./effective-llm-config.js";
+import { loadLLMEnvLayers, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH } from "./llm-env.js";
+import { isApiKeyOptionalForEndpoint } from "./llm-endpoint-auth.js";
 
-export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
-export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
+export { GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH, isApiKeyOptionalForEndpoint };
 
-interface ServiceConfigEntry {
-  readonly service: string;
-  readonly name?: string;
-  readonly baseUrl?: string;
-  readonly temperature?: number;
-  readonly maxTokens?: number;
-  readonly apiFormat?: "chat" | "responses";
-  readonly stream?: boolean;
-}
-
-type LLMConfigSource = "env" | "studio";
-
-export function isApiKeyOptionalForEndpoint(params: {
-  readonly provider?: string | undefined;
-  readonly baseUrl?: string | undefined;
-}): boolean {
-  if (params.provider === "anthropic") {
-    return false;
-  }
-  if (!params.baseUrl) {
-    return false;
-  }
-
-  try {
-    const url = new URL(params.baseUrl);
-    const hostname = url.hostname.toLowerCase();
-
-    return (
-      hostname === "localhost"
-      || hostname === "127.0.0.1"
-      || hostname === "::1"
-      || hostname === "0.0.0.0"
-      || hostname === "host.docker.internal"
-      || hostname.endsWith(".local")
-      || isPrivateIpv4(hostname)
-    );
-  } catch {
-    return false;
-  }
-}
-
-/**
- * Load project config from inkos.json with .env overrides.
- * Shared by CLI and Studio — single source of truth for config loading.
- */
 export async function loadProjectConfig(
   root: string,
-  options?: { readonly requireApiKey?: boolean },
+  options?: {
+    readonly requireApiKey?: boolean;
+    readonly cli?: LLMConfigCliOverrides;
+    readonly consumer?: LLMConsumer;
+  },
 ): Promise<ProjectConfig> {
-  // Load global ~/.inkos/.env first, then project .env overrides
-  const { config: loadEnv } = await import("dotenv");
-  loadEnv({ path: GLOBAL_ENV_PATH });
-  loadEnv({ path: join(root, ".env"), override: true });
-
-  const configPath = join(root, "inkos.json");
-
-  try {
-    await access(configPath);
-  } catch {
-    throw new Error(
-      `inkos.json not found in ${root}.\nMake sure you are inside an InkOS project directory (cd into the project created by 'inkos init').`,
-    );
-  }
-
-  const raw = await readFile(configPath, "utf-8");
-
-  let config: Record<string, unknown>;
-  try {
-    config = JSON.parse(raw);
-  } catch {
-    throw new Error(`inkos.json in ${root} is not valid JSON. Check the file for syntax errors.`);
-  }
-
-  // llm.configSource controls whether INKOS_LLM_* env vars override project config
-  const env = process.env;
-  const llm = (config.llm ?? {}) as Record<string, unknown>;
-  const configSource = resolveConfigSource(llm.configSource);
-  llm.configSource = configSource;
-
-  const normalizedServices = normalizeServiceEntries(llm.services);
-  if (normalizedServices.length > 0) {
-    llm.services = normalizedServices;
-
-    const selectedEntry = selectServiceEntry(normalizedServices, llm.service);
-    const selectedServiceId = selectedEntry ? serviceEntryKey(selectedEntry) : undefined;
-
-    if (selectedEntry) {
-      llm.service = selectedEntry.service;
-
-      if (!(typeof llm.model === "string" && llm.model.length > 0) && typeof llm.defaultModel === "string" && llm.defaultModel.length > 0) {
-        llm.model = llm.defaultModel;
-      }
-
-      if (!(typeof llm.baseUrl === "string" && llm.baseUrl.length > 0)) {
-        llm.baseUrl = selectedEntry.baseUrl ?? resolveServicePreset(selectedEntry.service)?.baseUrl ?? "";
-      }
-
-      if (!(typeof llm.provider === "string" && llm.provider.length > 0)) {
-        llm.provider = deriveProviderFromService(selectedEntry.service);
-      }
-
-      if (llm.temperature === undefined && selectedEntry.temperature !== undefined) {
-        llm.temperature = selectedEntry.temperature;
-      }
-
-      if (llm.maxTokens === undefined && selectedEntry.maxTokens !== undefined) {
-        llm.maxTokens = selectedEntry.maxTokens;
-      }
-
-      if (selectedEntry.apiFormat !== undefined) {
-        llm.apiFormat = selectedEntry.apiFormat;
-      }
-
-      if (selectedEntry.stream !== undefined) {
-        llm.stream = selectedEntry.stream;
-      }
-
-      if (selectedServiceId && (configSource !== "env" || !env.INKOS_LLM_API_KEY)) {
-        const secretApiKey = await getServiceApiKey(root, selectedServiceId);
-        if (secretApiKey) {
-          llm.apiKey = secretApiKey;
-        }
-      }
-    }
-  }
-
-  const shouldBootstrapStudioFallbackToEnv = configSource === "studio"
-    && normalizedServices.length === 0
-    && !(typeof llm.apiKey === "string" && llm.apiKey.length > 0)
-    && !(typeof llm.model === "string" && llm.model.length > 0)
-    && !(typeof llm.baseUrl === "string" && llm.baseUrl.length > 0);
-
-  if (configSource === "env" || shouldBootstrapStudioFallbackToEnv) {
-    if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
-    if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
-    if (env.INKOS_LLM_MODEL) llm.model = env.INKOS_LLM_MODEL;
-    if (env.INKOS_LLM_TEMPERATURE) llm.temperature = parseFloat(env.INKOS_LLM_TEMPERATURE);
-    if (env.INKOS_LLM_MAX_TOKENS) llm.maxTokens = parseInt(env.INKOS_LLM_MAX_TOKENS, 10);
-    if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
-  }
-  // Extra params from env: INKOS_LLM_EXTRA_<key>=<value>
-  const extraFromEnv: Record<string, unknown> = {};
-  if (configSource === "env" || shouldBootstrapStudioFallbackToEnv) {
-    for (const [key, value] of Object.entries(env)) {
-      if (key.startsWith("INKOS_LLM_EXTRA_") && value) {
-        const paramName = key.slice("INKOS_LLM_EXTRA_".length);
-        // Auto-coerce: numbers, booleans, JSON objects
-        if (/^\d+(\.\d+)?$/.test(value)) extraFromEnv[paramName] = parseFloat(value);
-        else if (value === "true") extraFromEnv[paramName] = true;
-        else if (value === "false") extraFromEnv[paramName] = false;
-        else if (value.startsWith("{") || value.startsWith("[")) {
-          try { extraFromEnv[paramName] = JSON.parse(value); } catch { extraFromEnv[paramName] = value; }
-        }
-        else extraFromEnv[paramName] = value;
-      }
-    }
-  }
-  if (Object.keys(extraFromEnv).length > 0) {
-    llm.extra = { ...(llm.extra as Record<string, unknown> ?? {}), ...extraFromEnv };
-  }
-  if ((configSource === "env" || shouldBootstrapStudioFallbackToEnv) && env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
-  config.llm = llm;
-
-  // Global language override
-  if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;
-
-  // API key ONLY from env — never stored in inkos.json
-  const apiKey = (configSource === "env" || shouldBootstrapStudioFallbackToEnv)
-    ? env.INKOS_LLM_API_KEY || (typeof llm.apiKey === "string" ? llm.apiKey : "")
-    : (typeof llm.apiKey === "string" ? llm.apiKey : "");
-  const provider = typeof llm.provider === "string" ? llm.provider : undefined;
-  const baseUrl = typeof llm.baseUrl === "string" ? llm.baseUrl : undefined;
-  const apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl });
-
-  if (!apiKey && options?.requireApiKey !== false && !apiKeyOptional) {
-    throw new Error(
-      "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
-    );
-  }
-  if (options?.requireApiKey === false) {
-    llm.provider = typeof llm.provider === "string" && llm.provider.length > 0
-      ? llm.provider
-      : "openai";
-    llm.baseUrl = typeof llm.baseUrl === "string" && llm.baseUrl.length > 0
-      ? llm.baseUrl
-      : "https://example.invalid/v1";
-    llm.model = typeof llm.model === "string" && llm.model.length > 0
-      ? llm.model
-      : "noop-model";
-  }
-  llm.apiKey = apiKey ?? "";
-
-  return ProjectConfigSchema.parse(config);
-}
-
-function resolveConfigSource(value: unknown): LLMConfigSource {
-  return value === "studio" ? "studio" : "env";
-}
-
-function normalizeServiceEntries(raw: unknown): ServiceConfigEntry[] {
-  if (Array.isArray(raw)) {
-    return raw
-      .filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
-      .map((entry) => ({
-        service: typeof entry.service === "string" && entry.service.length > 0 ? entry.service : "custom",
-        ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
-        ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
-        ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
-        ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
-        ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
-        ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
-      }));
-  }
-
-  if (raw && typeof raw === "object") {
-    return Object.entries(raw as Record<string, unknown>)
-      .filter(([, value]) => value && typeof value === "object")
-      .map(([serviceId, value]) => normalizeServiceEntryFromPatch(serviceId, value as Record<string, unknown>));
-  }
-
-  return [];
-}
-
-function normalizeServiceEntryFromPatch(serviceId: string, value: Record<string, unknown>): ServiceConfigEntry {
-  if (serviceId.startsWith("custom:")) {
-    return {
-      service: "custom",
-      name: decodeURIComponent(serviceId.slice("custom:".length)),
-      ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
-      ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
-      ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
-      ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
-      ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
-    };
-  }
-
-  if (serviceId === "custom") {
-    return {
-      service: "custom",
-      ...(typeof value.name === "string" && value.name.length > 0 ? { name: value.name } : {}),
-      ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
-      ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
-      ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
-      ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
-      ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
-    };
-  }
-
-  return {
-    service: serviceId,
-    ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
-    ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
-    ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
-    ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
-  };
-}
-
-function selectServiceEntry(
-  services: readonly ServiceConfigEntry[],
-  configuredService: unknown,
-): ServiceConfigEntry | undefined {
-  if (typeof configuredService === "string" && configuredService.length > 0) {
-    return services.find((entry) => entry.service === configuredService || serviceEntryKey(entry) === configuredService) ?? services[0];
-  }
-  return services[0];
-}
-
-function serviceEntryKey(entry: ServiceConfigEntry): string {
-  return entry.service === "custom" ? `custom:${entry.name ?? "Custom"}` : entry.service;
-}
-
-function deriveProviderFromService(service: string): "anthropic" | "openai" | "custom" {
-  if (service === "custom") return "custom";
-  return resolveServiceProviderFamily(service) ?? "openai";
-}
-
-function isPrivateIpv4(hostname: string): boolean {
-  const parts = hostname.split(".").map((segment) => Number.parseInt(segment, 10));
-  if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
-    return false;
-  }
-
-  if (parts[0] === 10) return true;
-  if (parts[0] === 192 && parts[1] === 168) return true;
-  if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
-  return false;
+  const envLayers = await loadLLMEnvLayers(root);
+  const result = await resolveEffectiveLLMConfig({
+    consumer: options?.consumer ?? "cli",
+    projectRoot: root,
+    envLayers,
+    cli: options?.cli,
+    requireApiKey: options?.requireApiKey,
+  });
+  return result.config;
 }

+ 523 - 0
packages/core/src/utils/effective-llm-config.ts

@@ -0,0 +1,523 @@
+import { access, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { ProjectConfigSchema, type LLMConfig, type ProjectConfig } from "../models/project.js";
+import { loadSecrets } from "../llm/secrets.js";
+import { getEndpoint } from "../llm/providers/index.js";
+import { guessServiceFromBaseUrl, resolveServicePreset, resolveServiceProviderFamily } from "../llm/service-presets.js";
+import { isApiKeyOptionalForEndpoint } from "./llm-endpoint-auth.js";
+import { cliOverlayEnv, legacyEnv, studioIgnoredEnv, type LLMEnvLayers, type LLMEnvMap } from "./llm-env.js";
+
+export type LLMConsumer = "studio" | "cli" | "daemon" | "deploy";
+export type LLMConfigMode = "studio-project" | "cli-project" | "legacy-env";
+export type LLMValueSource = "project" | "studio-secret" | "env" | "cli" | "default";
+
+export interface LLMConfigCliOverrides {
+  readonly service?: string;
+  readonly model?: string;
+  readonly apiKeyEnv?: string;
+  readonly baseUrl?: string;
+  readonly apiFormat?: "chat" | "responses";
+  readonly stream?: boolean;
+}
+
+export interface ResolveEffectiveLLMConfigInput {
+  readonly consumer: LLMConsumer;
+  readonly projectRoot: string;
+  readonly envLayers: LLMEnvLayers;
+  readonly cli?: LLMConfigCliOverrides;
+  readonly requireApiKey?: boolean;
+}
+
+export interface EffectiveLLMDiagnostics {
+  readonly configMode: LLMConfigMode;
+  readonly serviceSource: LLMValueSource;
+  readonly modelSource: LLMValueSource;
+  readonly apiKeySource: LLMValueSource;
+  readonly warnings: readonly string[];
+}
+
+export interface EffectiveLLMConfigResult {
+  readonly config: ProjectConfig;
+  readonly llm: LLMConfig;
+  readonly diagnostics: EffectiveLLMDiagnostics;
+}
+
+interface ServiceConfigEntry {
+  readonly service: string;
+  readonly name?: string;
+  readonly baseUrl?: string;
+  readonly temperature?: number;
+  readonly maxTokens?: number;
+  readonly apiFormat?: "chat" | "responses";
+  readonly stream?: boolean;
+}
+
+interface MutableDiagnostics {
+  configMode: LLMConfigMode;
+  serviceSource: LLMValueSource;
+  modelSource: LLMValueSource;
+  apiKeySource: LLMValueSource;
+  warnings: string[];
+}
+
+export async function resolveEffectiveLLMConfig(
+  input: ResolveEffectiveLLMConfigInput,
+): Promise<EffectiveLLMConfigResult> {
+  const config = await readProjectConfig(input.projectRoot);
+  const llm = { ...((config.llm ?? {}) as Record<string, unknown>) };
+  const services = normalizeServiceEntries(llm.services);
+  const configMode = resolveConfigMode(input.consumer, llm.configSource, services);
+  const diagnostics: MutableDiagnostics = {
+    configMode,
+    serviceSource: "project",
+    modelSource: "project",
+    apiKeySource: "project",
+    warnings: [],
+  };
+
+  if (services.length > 0) {
+    llm.services = services;
+  }
+
+  if (configMode === "studio-project") {
+    warnIfStudioIgnoresEnv(input.envLayers, diagnostics);
+    warnIfStaleTopLevel(llm, services, diagnostics);
+    await applyProjectServiceConfig(config, llm, services, input.projectRoot, diagnostics, {
+      requireApiKey: input.requireApiKey,
+      ignoreTopLevelModel: services.length > 0,
+    });
+  } else if (configMode === "cli-project") {
+    await applyCliProjectConfig(config, llm, services, input, diagnostics);
+  } else {
+    await applyLegacyEnvConfig(config, llm, input, diagnostics);
+  }
+
+  if (input.requireApiKey === false) {
+    fillNoopLLMDefaults(llm);
+  }
+
+  const provider = typeof llm.provider === "string" ? llm.provider : undefined;
+  const baseUrl = typeof llm.baseUrl === "string" ? llm.baseUrl : undefined;
+  const apiKey = typeof llm.apiKey === "string" ? llm.apiKey : "";
+  if (!apiKey && input.requireApiKey !== false && !isApiKeyOptionalForEndpoint({ provider, baseUrl })) {
+    throw new Error(
+      configMode === "studio-project"
+        ? "Studio LLM API key not set. Open Studio services and save an API key for the selected service."
+        : "INKOS_LLM_API_KEY not set. Run 'inkos config set-global' or add it to project .env file.",
+    );
+  }
+
+  llm.apiKey = apiKey;
+  config.llm = llm;
+
+  const parsed = ProjectConfigSchema.parse(config);
+  return {
+    config: parsed,
+    llm: parsed.llm,
+    diagnostics,
+  };
+}
+
+async function readProjectConfig(root: string): Promise<Record<string, unknown>> {
+  const configPath = join(root, "inkos.json");
+  try {
+    await access(configPath);
+  } catch {
+    throw new Error(
+      `inkos.json not found in ${root}.\nMake sure you are inside an InkOS project directory (cd into the project created by 'inkos init').`,
+    );
+  }
+
+  const raw = await readFile(configPath, "utf-8");
+  try {
+    return JSON.parse(raw) as Record<string, unknown>;
+  } catch {
+    throw new Error(`inkos.json in ${root} is not valid JSON. Check the file for syntax errors.`);
+  }
+}
+
+function resolveConfigMode(
+  consumer: LLMConsumer,
+  source: unknown,
+  services: readonly ServiceConfigEntry[],
+): LLMConfigMode {
+  if (consumer === "studio") return "studio-project";
+  if (source === "env") return "legacy-env";
+  if (source === "studio" || services.length > 0) return "cli-project";
+  return "legacy-env";
+}
+
+async function applyProjectServiceConfig(
+  config: Record<string, unknown>,
+  llm: Record<string, unknown>,
+  services: readonly ServiceConfigEntry[],
+  projectRoot: string,
+  diagnostics: MutableDiagnostics,
+  options: {
+    readonly requireApiKey?: boolean;
+    readonly ignoreTopLevelModel: boolean;
+    readonly requestedService?: string;
+    readonly requestedModel?: string;
+    readonly requestedModelSource?: LLMValueSource;
+    readonly envApiKey?: string;
+    readonly envBaseUrl?: string;
+    readonly envProvider?: string;
+    readonly cli?: LLMConfigCliOverrides;
+    readonly env?: LLMEnvMap;
+  },
+): Promise<void> {
+  llm.configSource = "studio";
+  const selectedEntry = selectServiceEntry(services, options.requestedService ?? llm.service)
+    ?? synthesizeServiceEntry(options.requestedService ?? llm.service);
+
+  if (selectedEntry) {
+    applyServiceEntry(llm, selectedEntry);
+    diagnostics.serviceSource = options.requestedService ? diagnostics.serviceSource : "project";
+  }
+
+  const modelSource = options.requestedModel ? options.requestedModelSource ?? "env" : "project";
+  const model = options.requestedModel
+    ?? resolveServiceModel(
+      selectedEntry,
+      options.ignoreTopLevelModel ? undefined : stringValue(llm.model),
+      stringValue(llm.defaultModel),
+    );
+  if (model) {
+    assertModelBelongsToService(selectedEntry, model);
+    llm.model = model;
+    diagnostics.modelSource = modelSource;
+  }
+
+  if (options.envProvider) llm.provider = options.envProvider;
+  if (options.envBaseUrl) llm.baseUrl = options.envBaseUrl;
+  if (options.env) {
+    applyCommonEnv(config, llm, options.env);
+  }
+  if (options.cli?.baseUrl) llm.baseUrl = options.cli.baseUrl;
+  if (options.cli?.apiFormat) llm.apiFormat = options.cli.apiFormat;
+  if (options.cli?.stream !== undefined) llm.stream = options.cli.stream;
+
+  const serviceKey = selectedEntry ? serviceEntryKey(selectedEntry) : stringValue(llm.service);
+  const secretApiKey = serviceKey ? await getStudioServiceApiKey(projectRoot, serviceKey) : "";
+  const cliApiKey = options.cli?.apiKeyEnv ? options.env?.[options.cli.apiKeyEnv] ?? "" : "";
+  const apiKey = cliApiKey || options.envApiKey || secretApiKey || "";
+  llm.apiKey = apiKey;
+  diagnostics.apiKeySource = cliApiKey
+    ? "cli"
+    : options.envApiKey
+      ? "env"
+      : secretApiKey
+        ? "studio-secret"
+        : "project";
+
+}
+
+async function applyCliProjectConfig(
+  config: Record<string, unknown>,
+  llm: Record<string, unknown>,
+  services: readonly ServiceConfigEntry[],
+  input: ResolveEffectiveLLMConfigInput,
+  diagnostics: MutableDiagnostics,
+): Promise<void> {
+  const env = cliOverlayEnv(input.envLayers);
+  const envBaseUrl = stringValue(env.INKOS_LLM_BASE_URL);
+  const envService = stringValue(env.INKOS_LLM_SERVICE) ?? (envBaseUrl ? guessServiceFromBaseUrl(envBaseUrl) : undefined);
+  const envModel = stringValue(env.INKOS_LLM_MODEL);
+  const requestedService = input.cli?.service ?? envService;
+  if (input.cli?.service) diagnostics.serviceSource = "cli";
+  else if (envService) diagnostics.serviceSource = "env";
+
+  const requestedModel = input.cli?.model ?? (input.cli?.service ? undefined : envModel);
+  const requestedModelSource: LLMValueSource = input.cli?.model ? "cli" : !input.cli?.service && envModel ? "env" : "project";
+  const allowEnvEndpointOverlay = !input.cli?.service;
+
+  await applyProjectServiceConfig(config, llm, services, input.projectRoot, diagnostics, {
+    requireApiKey: input.requireApiKey,
+    ignoreTopLevelModel: true,
+    requestedService,
+    requestedModel,
+    requestedModelSource,
+    envApiKey: allowEnvEndpointOverlay ? stringValue(env.INKOS_LLM_API_KEY) : undefined,
+    envBaseUrl: allowEnvEndpointOverlay ? envBaseUrl : undefined,
+    envProvider: allowEnvEndpointOverlay ? stringValue(env.INKOS_LLM_PROVIDER) : undefined,
+    cli: input.cli,
+    env,
+  });
+}
+
+async function applyLegacyEnvConfig(
+  config: Record<string, unknown>,
+  llm: Record<string, unknown>,
+  input: ResolveEffectiveLLMConfigInput,
+  diagnostics: MutableDiagnostics,
+): Promise<void> {
+  const env = legacyEnv(input.envLayers);
+  llm.configSource = "env";
+
+  if (env.INKOS_LLM_SERVICE) {
+    llm.service = env.INKOS_LLM_SERVICE;
+    diagnostics.serviceSource = "env";
+  } else if (typeof llm.service !== "string" || llm.service.length === 0) {
+    llm.service = "custom";
+  }
+
+  if (env.INKOS_LLM_PROVIDER) llm.provider = env.INKOS_LLM_PROVIDER;
+  else if (typeof llm.provider !== "string" || llm.provider.length === 0) llm.provider = "custom";
+  if (env.INKOS_LLM_BASE_URL) llm.baseUrl = env.INKOS_LLM_BASE_URL;
+  if (env.INKOS_LLM_MODEL) {
+    llm.model = env.INKOS_LLM_MODEL;
+    diagnostics.modelSource = "env";
+  }
+  if (env.INKOS_LLM_API_KEY) {
+    llm.apiKey = env.INKOS_LLM_API_KEY;
+    diagnostics.apiKeySource = "env";
+  } else if (typeof llm.apiKey !== "string") {
+    llm.apiKey = "";
+  }
+
+  if (input.cli?.service) {
+    const entry = synthesizeServiceEntry(input.cli.service);
+    if (entry) {
+      applyServiceEntry(llm, entry);
+      if (!input.cli.model) {
+        llm.model = resolveServiceModel(entry, undefined, stringValue(llm.defaultModel));
+      }
+    } else {
+      llm.service = input.cli.service;
+    }
+    diagnostics.serviceSource = "cli";
+  }
+  if (input.cli?.model) {
+    assertModelBelongsToService(synthesizeServiceEntry(stringValue(llm.service)), input.cli.model);
+    llm.model = input.cli.model;
+    diagnostics.modelSource = "cli";
+  }
+  if (input.cli?.baseUrl) llm.baseUrl = input.cli.baseUrl;
+  if (input.cli?.apiFormat) llm.apiFormat = input.cli.apiFormat;
+  if (input.cli?.stream !== undefined) llm.stream = input.cli.stream;
+  if (input.cli?.apiKeyEnv) {
+    llm.apiKey = env[input.cli.apiKeyEnv] ?? "";
+    diagnostics.apiKeySource = "cli";
+  }
+
+  applyCommonEnv(config, llm, env);
+  if (input.cli?.apiFormat) llm.apiFormat = input.cli.apiFormat;
+  if (input.cli?.stream !== undefined) llm.stream = input.cli.stream;
+}
+
+function applyServiceEntry(llm: Record<string, unknown>, entry: ServiceConfigEntry): void {
+  const endpoint = getEndpoint(entry.service);
+  const transportDefaults = endpoint?.transportDefaults;
+  llm.service = entry.service;
+  llm.provider = deriveProviderFromService(entry.service);
+  llm.baseUrl = entry.baseUrl ?? resolveServicePreset(entry.service)?.baseUrl ?? "";
+
+  if (entry.temperature !== undefined) llm.temperature = entry.temperature;
+  if (entry.apiFormat !== undefined) llm.apiFormat = entry.apiFormat;
+  else if (transportDefaults?.apiFormat !== undefined) llm.apiFormat = transportDefaults.apiFormat;
+  else llm.apiFormat = resolveServicePreset(entry.service)?.api.startsWith("openai-responses") ? "responses" : "chat";
+  if (entry.stream !== undefined) llm.stream = entry.stream;
+  else if (transportDefaults?.stream !== undefined) llm.stream = transportDefaults.stream;
+}
+
+function applyCommonEnv(
+  config: Record<string, unknown>,
+  llm: Record<string, unknown>,
+  env: LLMEnvMap,
+): void {
+  if (env.INKOS_LLM_TEMPERATURE) llm.temperature = Number.parseFloat(env.INKOS_LLM_TEMPERATURE);
+  if (env.INKOS_LLM_THINKING_BUDGET) llm.thinkingBudget = Number.parseInt(env.INKOS_LLM_THINKING_BUDGET, 10);
+  if (env.INKOS_LLM_API_FORMAT) llm.apiFormat = env.INKOS_LLM_API_FORMAT;
+  if (env.INKOS_LLM_STREAM) llm.stream = parseBoolean(env.INKOS_LLM_STREAM);
+  if (env.INKOS_DEFAULT_LANGUAGE) config.language = env.INKOS_DEFAULT_LANGUAGE;
+
+  const extraFromEnv: Record<string, unknown> = {};
+  for (const [key, value] of Object.entries(env)) {
+    if (key.startsWith("INKOS_LLM_EXTRA_") && value) {
+      extraFromEnv[key.slice("INKOS_LLM_EXTRA_".length)] = parseEnvValue(value);
+    }
+  }
+  if (Object.keys(extraFromEnv).length > 0) {
+    llm.extra = { ...(objectValue(llm.extra)), ...extraFromEnv };
+  }
+}
+
+async function getStudioServiceApiKey(projectRoot: string, serviceKey: string): Promise<string> {
+  const secrets = await loadSecrets(projectRoot);
+  return secrets.services[serviceKey]?.apiKey ?? "";
+}
+
+function normalizeServiceEntries(raw: unknown): ServiceConfigEntry[] {
+  if (Array.isArray(raw)) {
+    return raw
+      .filter((entry): entry is Record<string, unknown> => Boolean(entry) && typeof entry === "object")
+      .map((entry) => ({
+        service: typeof entry.service === "string" && entry.service.length > 0 ? entry.service : "custom",
+        ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
+        ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
+        ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
+        ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
+        ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
+        ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
+      }));
+  }
+
+  if (raw && typeof raw === "object") {
+    return Object.entries(raw as Record<string, unknown>)
+      .filter(([, value]) => value && typeof value === "object")
+      .map(([serviceId, value]) => normalizeServiceEntryFromPatch(serviceId, value as Record<string, unknown>));
+  }
+
+  return [];
+}
+
+function normalizeServiceEntryFromPatch(serviceId: string, value: Record<string, unknown>): ServiceConfigEntry {
+  if (serviceId.startsWith("custom:")) {
+    return {
+      service: "custom",
+      name: decodeURIComponent(serviceId.slice("custom:".length)),
+      ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
+      ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
+      ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+      ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
+      ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
+    };
+  }
+
+  if (serviceId === "custom") {
+    return {
+      service: "custom",
+      ...(typeof value.name === "string" && value.name.length > 0 ? { name: value.name } : {}),
+      ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
+      ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
+      ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+      ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
+      ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
+    };
+  }
+
+  return {
+    service: serviceId,
+    ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
+    ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
+    ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
+    ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
+  };
+}
+
+function selectServiceEntry(
+  services: readonly ServiceConfigEntry[],
+  configuredService: unknown,
+): ServiceConfigEntry | undefined {
+  if (typeof configuredService === "string" && configuredService.length > 0) {
+    return services.find((entry) => entry.service === configuredService || serviceEntryKey(entry) === configuredService)
+      ?? synthesizeServiceEntry(configuredService);
+  }
+  return services[0];
+}
+
+function synthesizeServiceEntry(service: unknown): ServiceConfigEntry | undefined {
+  if (typeof service !== "string" || service.length === 0) return undefined;
+  if (service.startsWith("custom:")) {
+    return { service: "custom", name: service.slice("custom:".length) || "Custom" };
+  }
+  if (service === "custom" || getEndpoint(service) || resolveServicePreset(service)) {
+    return { service };
+  }
+  return undefined;
+}
+
+function resolveServiceModel(
+  entry: ServiceConfigEntry | undefined,
+  currentModel: string | undefined,
+  defaultModel: string | undefined,
+): string {
+  if (!entry) return defaultModel || currentModel || "noop-model";
+  if (entry.service === "custom") return defaultModel || currentModel || "noop-model";
+
+  const endpoint = getEndpoint(entry.service);
+  const candidate = [defaultModel, currentModel]
+    .find((model): model is string => Boolean(model && modelBelongsToService(entry.service, model)));
+  if (candidate) return candidate;
+
+  return endpoint?.checkModel
+    ?? endpoint?.models.find((model) => model.enabled !== false)?.id
+    ?? defaultModel
+    ?? currentModel
+    ?? "noop-model";
+}
+
+function assertModelBelongsToService(entry: ServiceConfigEntry | undefined, model: string): void {
+  if (!entry || entry.service === "custom") return;
+  const endpoint = getEndpoint(entry.service);
+  if (!endpoint) return;
+  if (!modelBelongsToService(entry.service, model)) {
+    throw new Error(`模型 ${model} 不属于 ${entry.service} 服务,请切换服务或选择该服务下的模型。`);
+  }
+}
+
+function modelBelongsToService(service: string, model: string): boolean {
+  const endpoint = getEndpoint(service);
+  if (!endpoint) return true;
+  return endpoint.models.some((knownModel) => knownModel.id.toLowerCase() === model.toLowerCase());
+}
+
+function serviceEntryKey(entry: ServiceConfigEntry): string {
+  return entry.service === "custom" ? `custom:${entry.name ?? "Custom"}` : entry.service;
+}
+
+function deriveProviderFromService(service: string): "anthropic" | "openai" | "custom" {
+  if (service === "custom") return "custom";
+  return resolveServiceProviderFamily(service) ?? "openai";
+}
+
+function warnIfStudioIgnoresEnv(layers: LLMEnvLayers, diagnostics: MutableDiagnostics): void {
+  const ignored = studioIgnoredEnv(layers);
+  if (Object.keys(ignored).some((key) => key.startsWith("INKOS_LLM_"))) {
+    diagnostics.warnings.push("Studio 运行时不会使用 env 中的 INKOS_LLM_* 配置;请在服务配置页保存 Studio 配置。");
+  }
+}
+
+function warnIfStaleTopLevel(
+  llm: Record<string, unknown>,
+  services: readonly ServiceConfigEntry[],
+  diagnostics: MutableDiagnostics,
+): void {
+  if (services.length === 0) return;
+  if (["provider", "baseUrl", "model", "apiKey"].some((key) => typeof llm[key] === "string" && (llm[key] as string).length > 0)) {
+    diagnostics.warnings.push("检测到旧顶层 LLM 配置;Studio 模式以选中的 service/defaultModel/secrets 为准。");
+  }
+}
+
+function fillNoopLLMDefaults(llm: Record<string, unknown>): void {
+  if (typeof llm.provider !== "string" || llm.provider.length === 0) llm.provider = "openai";
+  if (typeof llm.baseUrl !== "string" || llm.baseUrl.length === 0) llm.baseUrl = "https://example.invalid/v1";
+  if (typeof llm.model !== "string" || llm.model.length === 0) llm.model = "noop-model";
+  if (typeof llm.apiKey !== "string") llm.apiKey = "";
+}
+
+function parseEnvValue(value: string): unknown {
+  if (/^\d+(\.\d+)?$/.test(value)) return Number.parseFloat(value);
+  if (value === "true") return true;
+  if (value === "false") return false;
+  if (value.startsWith("{") || value.startsWith("[")) {
+    try {
+      return JSON.parse(value) as unknown;
+    } catch {
+      return value;
+    }
+  }
+  return value;
+}
+
+function parseBoolean(value: string): boolean {
+  return value === "true" || value === "1" || value === "yes";
+}
+
+function stringValue(value: unknown): string | undefined {
+  return typeof value === "string" && value.length > 0 ? value : undefined;
+}
+
+function objectValue(value: unknown): Record<string, unknown> {
+  return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
+}

+ 40 - 0
packages/core/src/utils/llm-endpoint-auth.ts

@@ -0,0 +1,40 @@
+export function isApiKeyOptionalForEndpoint(params: {
+  readonly provider?: string | undefined;
+  readonly baseUrl?: string | undefined;
+}): boolean {
+  if (params.provider === "anthropic") {
+    return false;
+  }
+  if (!params.baseUrl) {
+    return false;
+  }
+
+  try {
+    const url = new URL(params.baseUrl);
+    const hostname = url.hostname.toLowerCase();
+
+    return (
+      hostname === "localhost"
+      || hostname === "127.0.0.1"
+      || hostname === "::1"
+      || hostname === "0.0.0.0"
+      || hostname === "host.docker.internal"
+      || hostname.endsWith(".local")
+      || isPrivateIpv4(hostname)
+    );
+  } catch {
+    return false;
+  }
+}
+
+function isPrivateIpv4(hostname: string): boolean {
+  const parts = hostname.split(".").map((segment) => Number.parseInt(segment, 10));
+  if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
+    return false;
+  }
+
+  if (parts[0] === 10) return true;
+  if (parts[0] === 192 && parts[1] === 168) return true;
+  if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
+  return false;
+}

+ 74 - 0
packages/core/src/utils/llm-env.ts

@@ -0,0 +1,74 @@
+import { readFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import { parse } from "dotenv";
+
+export const GLOBAL_CONFIG_DIR = join(homedir(), ".inkos");
+export const GLOBAL_ENV_PATH = join(GLOBAL_CONFIG_DIR, ".env");
+
+export type LLMEnvMap = Record<string, string | undefined>;
+
+export interface LLMEnvLayers {
+  readonly global: LLMEnvMap;
+  readonly project: LLMEnvMap;
+  readonly process: LLMEnvMap;
+}
+
+export async function loadLLMEnvLayers(
+  root: string,
+  processEnv: NodeJS.ProcessEnv = process.env,
+): Promise<LLMEnvLayers> {
+  const global = await parseEnvFile(GLOBAL_ENV_PATH);
+  const project = await parseEnvFile(join(root, ".env"));
+  // Compatibility: modelOverrides.apiKeyEnv and detector config still read process.env directly.
+  hydrateProcessEnvFromEnvFiles(processEnv, global, project);
+
+  return {
+    global,
+    project,
+    process: { ...processEnv },
+  };
+}
+
+export function mergeEnvMaps(...layers: readonly LLMEnvMap[]): LLMEnvMap {
+  const merged: LLMEnvMap = {};
+  for (const layer of layers) {
+    for (const [key, value] of Object.entries(layer)) {
+      if (value !== undefined) merged[key] = value;
+    }
+  }
+  return merged;
+}
+
+export function studioIgnoredEnv(layers: LLMEnvLayers): LLMEnvMap {
+  return mergeEnvMaps(layers.global, layers.project, layers.process);
+}
+
+export function cliOverlayEnv(layers: LLMEnvLayers): LLMEnvMap {
+  return mergeEnvMaps(layers.global, layers.project, layers.process);
+}
+
+export function legacyEnv(layers: LLMEnvLayers): LLMEnvMap {
+  return mergeEnvMaps(layers.global, layers.project, layers.process);
+}
+
+async function parseEnvFile(path: string): Promise<LLMEnvMap> {
+  try {
+    return parse(await readFile(path, "utf-8"));
+  } catch {
+    return {};
+  }
+}
+
+function hydrateProcessEnvFromEnvFiles(
+  processEnv: NodeJS.ProcessEnv,
+  global: LLMEnvMap,
+  project: LLMEnvMap,
+): void {
+  const fileEnv = mergeEnvMaps(global, project);
+  for (const [key, value] of Object.entries(fileEnv)) {
+    if (value !== undefined && processEnv[key] === undefined) {
+      processEnv[key] = value;
+    }
+  }
+}

+ 400 - 20
packages/studio/src/api/server.test.ts

@@ -45,6 +45,7 @@ const SERVICE_PRESETS_MOCK: Record<string, ServicePresetMock> = {
   anthropic: { providerFamily: "anthropic", baseUrl: "https://api.anthropic.com", modelsBaseUrl: "https://api.anthropic.com", knownModels: [] as string[] },
   minimax: { providerFamily: "anthropic", baseUrl: "https://api.minimaxi.com/anthropic", modelsBaseUrl: "https://api.minimaxi.com/anthropic", knownModels: [] as string[] },
   bailian: { providerFamily: "anthropic", baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic", modelsBaseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", knownModels: [] as string[] },
+  google: { providerFamily: "openai", baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", modelsBaseUrl: "https://generativelanguage.googleapis.com/v1beta/openai", knownModels: [] as string[] },
   custom: { providerFamily: "openai", baseUrl: "", knownModels: [] as string[] },
 };
 const resolveServicePresetMock = vi.fn((service: string) => SERVICE_PRESETS_MOCK[service]);
@@ -53,13 +54,13 @@ const resolveServiceModelsBaseUrlMock = vi.fn((service: string) => {
   const preset = SERVICE_PRESETS_MOCK[service];
   return preset?.modelsBaseUrl ?? preset?.baseUrl;
 });
-const listModelsForServiceMock = vi.fn(async (service: string, apiKey?: string) => {
+const listModelsForServiceMock = vi.fn(async (service: string, apiKey?: string, liveBaseUrl?: string) => {
   const preset = resolveServicePresetMock(service);
-  if (!preset || service === "custom") return [];
+  if (!preset) return [];
   if (preset.knownModels.length > 0) {
     return preset.knownModels.map((id) => ({ id, name: id, reasoning: false, contextWindow: 0 }));
   }
-  const modelsBaseUrl = resolveServiceModelsBaseUrlMock(service);
+  const modelsBaseUrl = liveBaseUrl ?? resolveServiceModelsBaseUrlMock(service);
   if (!apiKey || !modelsBaseUrl) return [];
   const res = await fetch(`${modelsBaseUrl.replace(/\/$/, "")}/models`, {
     headers: { Authorization: `Bearer ${apiKey}` },
@@ -74,6 +75,38 @@ const listModelsForServiceMock = vi.fn(async (service: string, apiKey?: string)
     contextWindow: 0,
   }));
 });
+const endpointIdsByGroup = {
+  overseas: ["anthropic", "google", "mistral", "openai", "xai"],
+  china: [
+    "ai360", "baichuan", "bailian", "deepseek", "hunyuan", "internlm", "longcat",
+    "minimax", "moonshot", "sensenova", "spark", "stepfun", "tencentcloud",
+    "volcengine", "wenxin", "xiaomimimo", "zeroone", "zhipu",
+  ],
+  aggregator: ["giteeai", "infiniai", "modelscope", "newapi", "openrouter", "ppio", "qiniu", "siliconcloud"],
+  local: ["githubCopilot", "ollama"],
+  codingPlan: [
+    "astronCodingPlan", "bailianCodingPlan", "glmCodingPlan", "kimiCodingPlan",
+    "minimaxCodingPlan", "opencodeCodingPlan", "volcengineCodingPlan",
+  ],
+} as const;
+const endpointMocks = [
+  ...Object.entries(endpointIdsByGroup).flatMap(([group, ids]) => ids.map((id) => ({
+    id,
+    label: id,
+    group,
+    ...(id === "google" ? { checkModel: "gemini-2.5-flash" } : {}),
+    ...(id === "minimax" ? { checkModel: "MiniMax-M2.7" } : {}),
+    models: [
+      { id: `${id}-model`, maxOutput: 4096, contextWindowTokens: 32768, enabled: true },
+      { id: `${id}-disabled`, maxOutput: 4096, contextWindowTokens: 32768, enabled: false },
+    ],
+  }))),
+  { id: "custom", label: "自定义端点", models: [] },
+];
+const getAllEndpointsMock = vi.fn(() => endpointMocks);
+const probeModelsFromUpstreamMock = vi.fn(async () => [
+  { id: "custom-model", name: "custom-model", contextWindow: 0 },
+]);
 
 const logger = {
   child: () => logger,
@@ -189,6 +222,8 @@ vi.mock("@actalk/inkos-core", () => {
     saveSecrets: saveSecretsMock,
     getServiceApiKey: getServiceApiKeyMock,
     listModelsForService: listModelsForServiceMock,
+    getAllEndpoints: getAllEndpointsMock,
+    probeModelsFromUpstream: probeModelsFromUpstreamMock,
     GLOBAL_ENV_PATH: join(tmpdir(), "inkos-global.env"),
   };
 });
@@ -355,6 +390,8 @@ describe("createStudioServer daemon lifecycle", () => {
     resolveServiceProviderFamilyMock.mockClear();
     resolveServiceModelsBaseUrlMock.mockClear();
     listModelsForServiceMock.mockClear();
+    getAllEndpointsMock.mockClear();
+    probeModelsFromUpstreamMock.mockClear();
     // Default BookSession for agent tests
     const defaultBookSession = {
       sessionId: "agent-session-1",
@@ -479,7 +516,6 @@ describe("createStudioServer daemon lifecycle", () => {
       body: JSON.stringify({
         language: "en",
         temperature: 0.2,
-        maxTokens: 2048,
         stream: true,
       }),
     });
@@ -490,7 +526,6 @@ describe("createStudioServer daemon lifecycle", () => {
     await expect(project.json()).resolves.toMatchObject({
       language: "en",
       temperature: 0.2,
-      maxTokens: 2048,
       stream: true,
     });
   });
@@ -646,12 +681,151 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("returns all bank services with group fields and custom services", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1" },
+        ],
+      },
+    }, null, 2), "utf-8");
+    loadSecretsMock.mockResolvedValue({
+      services: {
+        moonshot: { apiKey: "sk-moonshot" },
+        "custom:内网GPT": { apiKey: "sk-corp" },
+      },
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const res = await app.request("http://localhost/api/v1/services");
+    expect(res.status).toBe(200);
+    const body = await res.json() as { services: Array<{ service: string; group?: string; connected: boolean }> };
+    const bank = body.services.filter((s) => !s.service.startsWith("custom"));
+    expect(bank.length).toBe(40);
+    expect(bank.every((s) => typeof s.group === "string")).toBe(true);
+    expect(bank.filter((s) => s.group === "overseas")).toHaveLength(5);
+    expect(bank.filter((s) => s.group === "china")).toHaveLength(18);
+    expect(bank.filter((s) => s.group === "aggregator")).toHaveLength(8);
+    expect(bank.filter((s) => s.group === "local")).toHaveLength(2);
+    expect(bank.filter((s) => s.group === "codingPlan")).toHaveLength(7);
+    expect(body.services.find((s) => s.service === "moonshot")?.connected).toBe(true);
+    expect(body.services.find((s) => s.service === "custom:内网GPT")).toMatchObject({
+      connected: true,
+    });
+  });
+
+  it("returns connected bank model groups from the local bank", async () => {
+    loadSecretsMock.mockResolvedValue({
+      services: {
+        moonshot: { apiKey: "sk-moonshot" },
+      },
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/models");
+    expect(response.status).toBe(200);
+    const body = await response.json() as { groups: Array<{ service: string; models: Array<{ id: string }> }> };
+    expect(body.groups.map((g) => g.service)).toEqual(["moonshot"]);
+    expect(body.groups[0]?.models).toEqual([
+      { id: "moonshot-model", name: "moonshot-model", maxOutput: 4096, contextWindow: 32768 },
+    ]);
+  });
+
+  it("filters non-text models out of connected bank model groups", async () => {
+    loadSecretsMock.mockResolvedValue({
+      services: {
+        google: { apiKey: "sk-google" },
+      },
+    });
+    getAllEndpointsMock.mockReturnValueOnce([
+      {
+        id: "google",
+        label: "Google Gemini",
+        group: "overseas",
+        models: [
+          { id: "gemini-2.5-flash", maxOutput: 65536, contextWindowTokens: 1114112, enabled: true },
+          { id: "gemini-3.1-flash-image-preview", maxOutput: 32768, contextWindowTokens: 163840, enabled: true },
+          { id: "text-embedding-004", maxOutput: 2048, contextWindowTokens: 2048, enabled: true },
+        ],
+      },
+    ] as never);
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/models");
+    expect(response.status).toBe(200);
+    const body = await response.json() as { groups: Array<{ service: string; models: Array<{ id: string }> }> };
+    expect(body.groups[0]?.models.map((m) => m.id)).toEqual(["gemini-2.5-flash"]);
+  });
+
+  it("returns custom model groups through the slow probe path", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1" },
+        ],
+      },
+    }, null, 2), "utf-8");
+    loadSecretsMock.mockResolvedValue({
+      services: {
+        "custom:内网GPT": { apiKey: "sk-corp" },
+      },
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/models/custom");
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toEqual({
+      groups: [
+        {
+          service: "custom:内网GPT",
+          label: "内网GPT",
+          models: [{ id: "custom-model", name: "custom-model", contextWindow: 0 }],
+        },
+      ],
+    });
+    expect(probeModelsFromUpstreamMock).toHaveBeenCalledWith(
+      "https://llm.internal.corp/v1",
+      "sk-corp",
+      10_000,
+    );
+  });
+
+  it("filters non-text models out of live service model lists", async () => {
+    loadSecretsMock.mockResolvedValue({ services: { google: { apiKey: "sk-google" } } });
+    listModelsForServiceMock.mockResolvedValueOnce([
+      { id: "gemini-2.5-flash", name: "gemini-2.5-flash", reasoning: false, contextWindow: 1114112 },
+      { id: "gemini-3.1-flash-image-preview", name: "gemini-3.1-flash-image-preview", reasoning: false, contextWindow: 163840 },
+      { id: "text-embedding-004", name: "text-embedding-004", reasoning: false, contextWindow: 2048 },
+    ]);
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/google/models?refresh=1");
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toEqual({
+      models: [
+        { id: "gemini-2.5-flash", name: "gemini-2.5-flash", contextWindow: 1114112 },
+      ],
+    });
+  });
+
   it("merges service config patches instead of overwriting existing services", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,
       llm: {
         services: [
-          { service: "moonshot", temperature: 1, maxTokens: 4096, apiFormat: "chat", stream: true },
+          { service: "moonshot", temperature: 1, apiFormat: "chat", stream: true },
           { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
         ],
         defaultModel: "kimi-k2.5",
@@ -668,7 +842,6 @@ describe("createStudioServer daemon lifecycle", () => {
         services: {
           moonshot: {
             temperature: 0.5,
-            maxTokens: 2048,
             apiFormat: "responses",
             stream: false,
           },
@@ -680,7 +853,7 @@ describe("createStudioServer daemon lifecycle", () => {
 
     const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
     expect(raw.llm.services).toEqual([
-      { service: "moonshot", temperature: 0.5, maxTokens: 2048, apiFormat: "responses", stream: false },
+      { service: "moonshot", temperature: 0.5, apiFormat: "responses", stream: false },
       { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1", temperature: 0.9, apiFormat: "responses", stream: false },
     ]);
   });
@@ -712,9 +885,11 @@ describe("createStudioServer daemon lifecycle", () => {
     const response = await app.request("http://localhost/api/v1/services/config");
     expect(response.status).toBe(200);
     await expect(response.json()).resolves.toMatchObject({
-      configSource: "env",
+      configSource: "studio",
+      storedConfigSource: "env",
       envConfig: {
         effectiveSource: "project",
+        runtimeUsesEnv: false,
         project: {
           detected: true,
           baseUrl: "https://project.example.com/v1",
@@ -736,7 +911,7 @@ describe("createStudioServer daemon lifecycle", () => {
       ...projectConfig,
       llm: {
         services: [
-          { service: "moonshot", temperature: 1, maxTokens: 4096 },
+          { service: "moonshot", temperature: 1 },
         ],
         defaultModel: "kimi-k2.5",
         configSource: "env",
@@ -757,11 +932,27 @@ describe("createStudioServer daemon lifecycle", () => {
     const raw = JSON.parse(await readFile(join(root, "inkos.json"), "utf-8"));
     expect(raw.llm.configSource).toBe("studio");
     expect(raw.llm.services).toEqual([
-      { service: "moonshot", temperature: 1, maxTokens: 4096 },
+      { service: "moonshot", temperature: 1 },
     ]);
     expect(raw.llm.defaultModel).toBe("kimi-k2.5");
   });
 
+  it("rejects switching Studio runtime to env config source", async () => {
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const save = await app.request("http://localhost/api/v1/services/config", {
+      method: "PUT",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({ configSource: "env" }),
+    });
+
+    expect(save.status).toBe(400);
+    await expect(save.json()).resolves.toMatchObject({
+      error: expect.stringContaining("Studio 运行时不支持"),
+    });
+  });
+
   it("tests and lists models for custom services using baseUrl and stored config", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,
@@ -777,7 +968,6 @@ describe("createStudioServer daemon lifecycle", () => {
         "custom:内网GPT": { apiKey: "sk-corp" },
       },
     });
-    getServiceApiKeyMock.mockResolvedValue("sk-corp");
 
     const fetchMock = vi.fn()
       .mockResolvedValueOnce({
@@ -991,7 +1181,148 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
-  it("uses the preset models baseUrl when listing Bailian models", async () => {
+  it("uses the bank endpoint check model before the global default during service probe", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "google", apiFormat: "chat", stream: false },
+        ],
+        defaultModel: "MiniMax-M2.7",
+      },
+    }, null, 2), "utf-8");
+
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: false,
+      status: 404,
+      text: async () => "Not Found",
+    });
+    vi.stubGlobal("fetch", fetchMock as typeof fetch);
+    createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
+    chatCompletionMock.mockImplementation(async (_client: any, model: string) => {
+      if (model === "gemini-2.5-flash") {
+        return {
+          content: "pong",
+          usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
+        };
+      }
+      throw new Error(`unexpected model: ${model}`);
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/google/test", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        apiKey: "google-key",
+        apiFormat: "chat",
+        stream: false,
+      }),
+    });
+
+    expect(response.status).toBe(200);
+    await expect(response.json()).resolves.toMatchObject({
+      ok: true,
+      selectedModel: "gemini-2.5-flash",
+    });
+    expect(chatCompletionMock).toHaveBeenCalledWith(
+      expect.anything(),
+      "gemini-2.5-flash",
+      expect.any(Array),
+      expect.any(Object),
+    );
+    expect(chatCompletionMock.mock.calls.map((call) => call[1])).not.toContain("MiniMax-M2.7");
+  });
+
+  it("does not fall back to the global default model when a bank endpoint probe fails", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "google", apiFormat: "chat", stream: false },
+        ],
+        defaultModel: "MiniMax-M2.7",
+      },
+    }, null, 2), "utf-8");
+
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: false,
+      status: 404,
+      text: async () => "Not Found",
+    });
+    vi.stubGlobal("fetch", fetchMock as typeof fetch);
+    createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
+    chatCompletionMock.mockImplementation(async (_client: any, model: string) => {
+      throw new Error(`probe failed for ${model}`);
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/google/test", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        apiKey: "google-key",
+        apiFormat: "chat",
+        stream: false,
+      }),
+    });
+
+    expect(response.status).toBe(400);
+    await expect(response.json()).resolves.toMatchObject({
+      ok: false,
+      error: expect.stringContaining("gemini-2.5-flash"),
+    });
+    expect(new Set(chatCompletionMock.mock.calls.map((call) => call[1]))).toEqual(new Set(["gemini-2.5-flash"]));
+  });
+
+  it("returns a Google-specific diagnostic when Gemini probe returns 400", async () => {
+    await writeFile(join(root, "inkos.json"), JSON.stringify({
+      ...projectConfig,
+      llm: {
+        services: [
+          { service: "google", apiFormat: "chat", stream: false },
+        ],
+      },
+    }, null, 2), "utf-8");
+
+    const fetchMock = vi.fn().mockResolvedValue({
+      ok: false,
+      status: 404,
+      text: async () => "Not Found",
+    });
+    vi.stubGlobal("fetch", fetchMock as typeof fetch);
+    createLLMClientMock.mockImplementation(((cfg: unknown) => cfg) as any);
+    chatCompletionMock.mockRejectedValue(
+      new Error("API 返回 400(请求参数错误)。常见原因:\n  1. temperature / max_tokens 超出模型约束(如 Moonshot kimi-k2.X 强制 temperature=1)\n  (baseUrl: https://generativelanguage.googleapis.com/v1beta/openai, model: gemini-2.5-flash)"),
+    );
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/services/google/test", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        apiKey: "google-key",
+        apiFormat: "chat",
+        stream: false,
+      }),
+    });
+
+    expect(response.status).toBe(400);
+    const json = await response.json() as { error?: string };
+    expect(json.error).toContain("Google Gemini 测试连接失败");
+    expect(json.error).toContain("测试模型:gemini-2.5-flash");
+    expect(json.error).toContain("API Key 是否来自 Google AI Studio");
+    expect(json.error).toContain("Gemini API");
+    expect(json.error).not.toContain("Moonshot");
+  });
+
+  it("does not return OpenAI-compatible Bailian models from the Anthropic channel connection test", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,
       llm: {
@@ -1001,14 +1332,25 @@ describe("createStudioServer daemon lifecycle", () => {
         defaultModel: "qwen-max",
       },
     }, null, 2), "utf-8");
-    getServiceApiKeyMock.mockResolvedValue("sk-bailian");
+    loadSecretsMock.mockResolvedValue({ services: { bailian: { apiKey: "sk-bailian" } } });
+    const bailianEndpoint = endpointMocks.find((ep) => ep.id === "bailian");
+    expect(bailianEndpoint).toBeDefined();
+    Object.assign(bailianEndpoint!, {
+      checkModel: "qwen-max",
+      api: "anthropic-messages",
+      baseUrl: "https://dashscope.aliyuncs.com/apps/anthropic",
+      models: [
+        { id: "qwen-max", maxOutput: 8192, contextWindowTokens: 131072, enabled: true },
+        { id: "kimi-k2.5", maxOutput: 32768, contextWindowTokens: 262144, enabled: true },
+      ],
+    });
 
     const fetchMock = vi.fn(async (input: string | URL | Request) => {
       const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
       if (url === "https://dashscope.aliyuncs.com/compatible-mode/v1/models") {
         return {
           ok: true,
-          json: async () => ({ data: [{ id: "qwen-max" }] }),
+          json: async () => ({ data: [{ id: "kimi-k2.6" }, { id: "deepseek-v3.2" }] }),
           text: async (): Promise<string> => "",
         };
       }
@@ -1033,13 +1375,22 @@ describe("createStudioServer daemon lifecycle", () => {
     const { createStudioServer } = await import("./server.js");
     const app = createStudioServer(cloneProjectConfig() as never, root);
 
-    const response = await app.request("http://localhost/api/v1/services/bailian/models");
+    const response = await app.request("http://localhost/api/v1/services/bailian/test", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        apiKey: "sk-bailian",
+        apiFormat: "chat",
+        stream: false,
+      }),
+    });
 
     expect(response.status).toBe(200);
-    await expect(response.json()).resolves.toMatchObject({
-      models: [{ id: "qwen-max", name: "qwen-max" }],
-    });
-    expect(fetchMock).toHaveBeenCalledWith(
+    const body = await response.json() as { models: Array<{ id: string }> };
+    expect(body.models.map((m) => m.id)).toEqual(["qwen-max", "kimi-k2.5"]);
+    expect(body.models.some((m) => m.id === "kimi-k2.6")).toBe(false);
+    expect(body.models.some((m) => m.id === "deepseek-v3.2")).toBe(false);
+    expect(fetchMock).not.toHaveBeenCalledWith(
       "https://dashscope.aliyuncs.com/compatible-mode/v1/models",
       expect.any(Object),
     );
@@ -1470,6 +1821,35 @@ describe("createStudioServer daemon lifecycle", () => {
     });
   });
 
+  it("rejects explicit non-text models before running the agent", async () => {
+    resolveServiceModelMock.mockResolvedValue({
+      model: { id: "gemini-3.1-flash-image-preview", provider: "google", api: "openai-completions" },
+      apiKey: "sk-google",
+    });
+
+    const { createStudioServer } = await import("./server.js");
+    const app = createStudioServer(cloneProjectConfig() as never, root);
+
+    const response = await app.request("http://localhost/api/v1/agent", {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify({
+        instruction: "nihao",
+        service: "google",
+        model: "gemini-3.1-flash-image-preview",
+        sessionId: "agent-session-1",
+      }),
+    });
+
+    expect(response.status).toBe(400);
+    await expect(response.json()).resolves.toMatchObject({
+      error: expect.stringContaining("不适合文本聊天"),
+      response: expect.stringContaining("gemini-3.1-flash-image-preview"),
+    });
+    expect(resolveServiceModelMock).not.toHaveBeenCalled();
+    expect(runAgentSessionMock).not.toHaveBeenCalled();
+  });
+
   it("returns 500 with an error payload when the agent session fails", async () => {
     runAgentSessionMock.mockRejectedValueOnce(new Error("boom"));
 

+ 232 - 68
packages/studio/src/api/server.ts

@@ -30,8 +30,9 @@ import {
   resolveServiceModel,
   loadSecrets,
   saveSecrets,
-  getServiceApiKey,
   listModelsForService,
+  getAllEndpoints,
+  probeModelsFromUpstream,
   chatCompletion,
   buildExportArtifact,
   GLOBAL_ENV_PATH,
@@ -89,6 +90,31 @@ function summarizeResult(result: unknown): string {
   return String(result).slice(0, 200);
 }
 
+const NON_TEXT_MODEL_ID_PARTS = [
+  "image",
+  "embedding",
+  "embed",
+  "rerank",
+  "tts",
+  "speech",
+  "audio",
+  "moderation",
+] as const;
+
+function isTextChatModelId(modelId: string): boolean {
+  const normalized = modelId.trim().toLowerCase();
+  if (!normalized) return false;
+  return !NON_TEXT_MODEL_ID_PARTS.some((part) => normalized.includes(part));
+}
+
+function filterTextChatModels<T extends { readonly id: string }>(models: ReadonlyArray<T>): T[] {
+  return models.filter((model) => isTextChatModelId(model.id));
+}
+
+function nonTextModelMessage(modelId: string): string {
+  return `模型 ${modelId} 不适合文本聊天/写作。请在模型选择器中改用文本模型,例如 gemini-2.5-flash、gemini-2.5-pro 或对应服务的 chat 模型。`;
+}
+
 function extractToolError(result: unknown): string {
   if (typeof result === "string") return result.slice(0, 500);
   if (result && typeof result === "object") {
@@ -130,7 +156,6 @@ interface ServiceConfigEntry {
   name?: string;
   baseUrl?: string;
   temperature?: number;
-  maxTokens?: number;
   apiFormat?: "chat" | "responses";
   stream?: boolean;
 }
@@ -149,6 +174,7 @@ interface EnvConfigStatus {
   project: EnvConfigSummary;
   global: EnvConfigSummary;
   effectiveSource: "project" | "global" | null;
+  runtimeUsesEnv: false;
 }
 
 interface ServiceProbeResult {
@@ -183,7 +209,6 @@ function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>
       name: decodeURIComponent(serviceId.slice("custom:".length)),
       ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
-      ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
       ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
       ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
     };
@@ -195,7 +220,6 @@ function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>
       ...(typeof value.name === "string" && value.name.length > 0 ? { name: value.name } : {}),
       ...(typeof value.baseUrl === "string" && value.baseUrl.length > 0 ? { baseUrl: value.baseUrl } : {}),
       ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
-      ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
       ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
       ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
     };
@@ -204,7 +228,6 @@ function normalizeServiceEntry(serviceId: string, value: Record<string, unknown>
   return {
     service: serviceId,
     ...(typeof value.temperature === "number" ? { temperature: value.temperature } : {}),
-    ...(typeof value.maxTokens === "number" ? { maxTokens: value.maxTokens } : {}),
     ...(value.apiFormat === "chat" || value.apiFormat === "responses" ? { apiFormat: value.apiFormat } : {}),
     ...(typeof value.stream === "boolean" ? { stream: value.stream } : {}),
   };
@@ -223,7 +246,6 @@ function normalizeServiceConfig(raw: unknown): ServiceConfigEntry[] {
         ...(typeof entry.name === "string" && entry.name.length > 0 ? { name: entry.name } : {}),
         ...(typeof entry.baseUrl === "string" && entry.baseUrl.length > 0 ? { baseUrl: entry.baseUrl } : {}),
         ...(typeof entry.temperature === "number" ? { temperature: entry.temperature } : {}),
-        ...(typeof entry.maxTokens === "number" ? { maxTokens: entry.maxTokens } : {}),
         ...(entry.apiFormat === "chat" || entry.apiFormat === "responses" ? { apiFormat: entry.apiFormat } : {}),
         ...(typeof entry.stream === "boolean" ? { stream: entry.stream } : {}),
       }));
@@ -301,6 +323,7 @@ async function readEnvConfigStatus(root: string): Promise<EnvConfigStatus> {
     project,
     global,
     effectiveSource: project.detected ? "project" : global.detected ? "global" : null,
+    runtimeUsesEnv: false,
   };
 }
 
@@ -363,6 +386,7 @@ function buildModelCandidates(args: {
   configModel?: string;
   envModel?: string | null;
   discoveredModels: Array<{ id: string; name: string }>;
+  includeGenericFallbacks?: boolean;
 }): string[] {
   const seen = new Set<string>();
   const candidates: string[] = [];
@@ -378,6 +402,7 @@ function buildModelCandidates(args: {
   push(args.configModel);
   push(args.envModel ?? undefined);
   for (const model of args.discoveredModels) push(model.id);
+  if (args.includeGenericFallbacks === false) return candidates;
   push("gpt-5.4");
   push("gpt-4o");
   push("claude-sonnet-4-6");
@@ -386,14 +411,72 @@ function buildModelCandidates(args: {
   return candidates;
 }
 
+function formatServiceProbeError(args: {
+  readonly service: string;
+  readonly label?: string;
+  readonly baseUrl: string;
+  readonly model?: string;
+  readonly apiFormat?: "chat" | "responses";
+  readonly stream?: boolean;
+  readonly error: string;
+}): string {
+  const rawDetail = args.error
+    .replace(/\n\s*\(baseUrl:[\s\S]*?\)$/m, "")
+    .trim();
+  const upstreamDetail = rawDetail.includes("上游详情:")
+    ? rawDetail
+    : "";
+  const context = [
+    `服务商:${args.label ?? args.service}`,
+    `测试模型:${args.model ?? "未确定"}`,
+    `协议:${args.apiFormat === "responses" ? "Responses" : "Chat / Completions"}${typeof args.stream === "boolean" ? `,${args.stream ? "流式" : "非流式"}` : ""}`,
+    `Base URL:${args.baseUrl}`,
+  ].join("\n");
+
+  if (args.service === "google") {
+    return [
+      "Google Gemini 测试连接失败。",
+      context,
+      "",
+      "请优先检查:",
+      "1. API Key 是否来自 Google AI Studio 的 Gemini API key,而不是 OAuth、Vertex AI 或其它 Google 服务凭据。",
+      "2. 该 key 所属项目是否已启用 Gemini API,并且没有被限制到其它 API、来源或服务。",
+      "3. 当前地区/账号是否允许访问 Gemini API。",
+      "4. 如果 key 曾经泄露,请在 AI Studio 重新生成后再保存。",
+      upstreamDetail ? `\n上游返回:${upstreamDetail}` : "",
+    ].filter(Boolean).join("\n");
+  }
+
+  if (args.service === "moonshot" || args.service === "kimiCodingPlan") {
+    return [
+      `${args.label ?? args.service} 测试连接失败。`,
+      context,
+      "",
+      "请优先检查模型是否可用,以及 kimi-k2.x 这类模型是否需要 temperature=1。",
+      rawDetail ? `\n上游返回:${rawDetail}` : "",
+    ].filter(Boolean).join("\n");
+  }
+
+  return [
+    `${args.label ?? args.service} 测试连接失败。`,
+    context,
+    "",
+    "请检查 API Key、模型可用性、账号额度,以及协议类型是否匹配该服务商。",
+    rawDetail ? `\n上游返回:${rawDetail}` : "",
+  ].filter(Boolean).join("\n");
+}
+
 async function fetchModelsFromServiceBaseUrl(
   serviceId: string,
   baseUrl: string,
   apiKey: string,
 ): Promise<{ models: Array<{ id: string; name: string }>; error?: string; authFailed?: boolean }> {
+  const endpoint = isCustomServiceId(serviceId)
+    ? undefined
+    : getAllEndpoints().find((ep) => ep.id === serviceId);
   const modelsBaseUrl = isCustomServiceId(serviceId)
     ? baseUrl
-    : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl;
+    : endpoint?.modelsBaseUrl ?? (endpoint ? baseUrl : resolveServiceModelsBaseUrl(serviceId) ?? baseUrl);
   const modelsUrl = modelsBaseUrl.replace(/\/$/, "") + "/models";
   try {
     const res = await fetch(modelsUrl, {
@@ -448,14 +531,29 @@ async function probeServiceCapabilities(args: {
     };
   }
   const discoveredModels = modelsResponse.models;
-  // For services with knownModels, use their first model as top candidate — not the global default
+  // For bank services, probe with the service's own check model first — not the global default.
+  const endpoint = getAllEndpoints().find((ep) => ep.id === baseService);
   const preset = resolveServicePreset(baseService);
-  const serviceFirstModel = preset?.knownModels?.[0];
+  const serviceFirstModel =
+    endpoint?.checkModel
+    ?? preset?.knownModels?.[0]
+    ?? endpoint?.models.find((model) => model.enabled !== false)?.id;
+  const useEndpointCheckModel = !isCustomServiceId(args.service) && Boolean(endpoint?.checkModel);
+  const configService = typeof llm.service === "string" ? llm.service : undefined;
+  const configModel = !useEndpointCheckModel && configService === args.service
+    ? typeof llm.defaultModel === "string"
+      ? llm.defaultModel
+      : typeof llm.model === "string"
+        ? llm.model
+        : undefined
+    : undefined;
+  const useCustomFallbacks = isCustomServiceId(args.service);
   const modelCandidates = buildModelCandidates({
     preferredModel: args.preferredModel ?? serviceFirstModel,
-    configModel: typeof llm.defaultModel === "string" ? llm.defaultModel : typeof llm.model === "string" ? llm.model : undefined,
-    envModel,
-    discoveredModels,
+    configModel,
+    envModel: useCustomFallbacks ? envModel : undefined,
+    discoveredModels: useEndpointCheckModel ? [] : discoveredModels,
+    includeGenericFallbacks: useCustomFallbacks,
   });
 
   if (modelCandidates.length === 0) {
@@ -488,7 +586,12 @@ async function probeServiceCapabilities(args: {
         await chatCompletion(client, model, [{ role: "user", content: "ping" }], { maxTokens: 2048 });
         const models = discoveredModels.length > 0
           ? discoveredModels
-          : preset?.knownModels?.map((id) => ({ id, name: id })) ?? [{ id: model, name: model }];
+          : endpoint?.models
+            .filter((m) => m.enabled !== false)
+            .filter((m) => isTextChatModelId(m.id))
+            .map((m) => ({ id: m.id, name: m.id }))
+            ?? preset?.knownModels?.map((id) => ({ id, name: id }))
+            ?? [{ id: model, name: model }];
         return {
           ok: true,
           models,
@@ -499,7 +602,15 @@ async function probeServiceCapabilities(args: {
           modelsSource: discoveredModels.length > 0 ? "api" : "fallback",
         };
       } catch (error) {
-        lastError = error instanceof Error ? error.message : String(error);
+        lastError = formatServiceProbeError({
+          service: baseService,
+          label: endpoint?.label ?? preset?.label,
+          baseUrl: args.baseUrl,
+          model,
+          apiFormat: plan.apiFormat,
+          stream: plan.stream,
+          error: error instanceof Error ? error.message : String(error),
+        });
       }
     }
   }
@@ -567,7 +678,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   async function loadCurrentProjectConfig(
     options?: { readonly requireApiKey?: boolean },
   ): Promise<ProjectConfig> {
-    const freshConfig = await loadProjectConfig(root, options);
+    const freshConfig = await loadProjectConfig(root, { ...options, consumer: "studio" });
     cachedConfig = freshConfig;
     return freshConfig;
   }
@@ -919,21 +1030,15 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
 
   app.get("/api/v1/services", async (c) => {
     const secrets = await loadSecrets(root);
+    const endpoints = getAllEndpoints().filter((ep) => ep.id !== "custom");
 
-    const SERVICE_KEYS = [
-      "openai", "anthropic", "deepseek", "moonshot", "minimax",
-      "bailian", "zhipu", "siliconflow", "ppio", "openrouter", "ollama",
-    ];
-
-    // Fast: only check connection status from secrets, no external API calls
-    const services = SERVICE_KEYS.map((key) => {
-      const preset = resolveServicePreset(key);
-      return {
-        service: key,
-        label: preset?.label ?? key,
-        connected: Boolean(secrets.services[key]?.apiKey),
-      };
-    });
+    // Fast: only check connection status from secrets, no external API calls.
+    const services = endpoints.map((ep) => ({
+      service: ep.id,
+      label: ep.label,
+      group: ep.group,
+      connected: Boolean(secrets.services[ep.id]?.apiKey),
+    }));
 
     // Add custom services from inkos.json
     try {
@@ -944,6 +1049,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
           services.push({
             service: secretKey,
             label: svc.name ?? "Custom",
+            group: undefined,
             connected: Boolean(secrets.services[secretKey]?.apiKey),
           });
         }
@@ -961,7 +1067,8 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     return c.json({
       services,
       defaultModel: llm.defaultModel ?? null,
-      configSource: normalizeConfigSource(llm.configSource),
+      configSource: "studio" satisfies LLMConfigSource,
+      storedConfigSource: normalizeConfigSource(llm.configSource),
       envConfig,
     });
   });
@@ -979,6 +1086,11 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     if (body.defaultModel !== undefined) {
       llm.defaultModel = body.defaultModel;
     }
+    if (body.configSource === "env") {
+      return c.json({
+        error: "Studio 运行时不支持切换到 env;env 只在 CLI/daemon/部署运行时作为覆盖层使用。",
+      }, 400);
+    }
     if (body.configSource !== undefined) {
       llm.configSource = normalizeConfigSource(body.configSource);
     }
@@ -1016,8 +1128,20 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       preferredStream: stream,
     });
 
+    // B12: 升级响应 shape 为 { probe, chat, ... },同时保留老字段供 UI 过渡期兼容
+    const probeStatus = {
+      ok: probe.ok,
+      models: probe.models?.length ?? 0,
+      ...(probe.ok ? {} : { error: probe.error ?? "连接失败" }),
+    };
+
     if (!probe.ok) {
-      return c.json({ ok: false, error: probe.error ?? "连接失败" }, 400);
+      return c.json({
+        ok: false,
+        error: probe.error ?? "连接失败",
+        probe: probeStatus,
+        chat: null,
+      }, 400);
     }
 
     return c.json({
@@ -1031,6 +1155,9 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         baseUrl: probe.baseUrl,
         modelsSource: probe.modelsSource,
       },
+      // B12 新字段:两步验证状态
+      probe: probeStatus,
+      chat: null,  // probeServiceCapabilities 本身只做 probe,chat hello 在 Studio 的 follow-up 调用里单独触发
     });
   });
 
@@ -1055,15 +1182,66 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     });
   });
 
+  app.get("/api/v1/services/models", async (c) => {
+    const secrets = await loadSecrets(root);
+    const endpoints = getAllEndpoints()
+      .filter((ep) => ep.id !== "custom" && Boolean(secrets.services[ep.id]?.apiKey));
+
+    const groups = endpoints.map((ep) => ({
+      service: ep.id,
+      label: ep.label,
+      models: ep.models
+        .filter((m) => m.enabled !== false)
+        .filter((m) => isTextChatModelId(m.id))
+        .map((m) => ({
+          id: m.id,
+          name: m.id,
+          ...(typeof m.maxOutput === "number" ? { maxOutput: m.maxOutput } : {}),
+          ...(m.contextWindowTokens > 0 ? { contextWindow: m.contextWindowTokens } : {}),
+        })),
+    }));
+
+    return c.json({ groups });
+  });
+
+  app.get("/api/v1/services/models/custom", async (c) => {
+    const secrets = await loadSecrets(root);
+    let config: Record<string, unknown> = {};
+    try {
+      config = await loadRawConfig(root);
+    } catch {
+      // no config file
+    }
+
+    const customs = normalizeServiceConfig((config.llm as Record<string, unknown> | undefined)?.services)
+      .filter((s) => s.service === "custom")
+      .map((s) => ({
+        id: `custom:${s.name ?? "Custom"}`,
+        baseUrl: s.baseUrl ?? "",
+        label: s.name ?? "Custom",
+      }))
+      .filter((s) => s.baseUrl && Boolean(secrets.services[s.id]?.apiKey));
+
+    const groups = await Promise.all(customs.map(async (s) => ({
+      service: s.id,
+      label: s.label,
+      models: filterTextChatModels(
+        await probeModelsFromUpstream(s.baseUrl, secrets.services[s.id].apiKey, 10_000),
+      ),
+    })));
+
+    return c.json({ groups });
+  });
+
   app.get("/api/v1/services/:service/models", async (c) => {
     const service = c.req.param("service");
     const refresh = c.req.query("refresh") === "1";
-    const apiKey = c.req.query("apiKey") || await getServiceApiKey(root, service);
+    const secrets = await loadSecrets(root);
+    const apiKey = c.req.query("apiKey") || secrets.services[service]?.apiKey || "";
 
     // No key = no models
     if (!apiKey) return c.json({ models: [] });
 
-    const preset = resolveServicePreset(isCustomServiceId(service) ? "custom" : service);
     const resolvedBaseUrl = await resolveConfiguredServiceBaseUrl(root, service);
 
     // Cache by service + resolved baseUrl + apiKey fingerprint; valid for 10 min unless ?refresh=1
@@ -1075,33 +1253,18 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       }
     }
 
-    // Fast path: services with knownModels return immediately
-    if (preset?.knownModels && preset.knownModels.length > 0) {
-      const models = preset.knownModels.map((id) => ({ id, name: id }));
-      modelListCache.set(cacheKey, { models, at: Date.now() });
-      return c.json({ models });
-    }
-
-    // Simple /models API call + fallback to pi-ai built-in list (no slow probe)
-    if (!resolvedBaseUrl) return c.json({ models: [] });
-
-    const modelsBase = preset?.modelsBaseUrl ?? resolvedBaseUrl;
-    let models: Array<{ id: string; name: string }> = [];
-    try {
-      const modelsUrl = modelsBase.replace(/\/$/, "") + "/models";
-      const res = await fetch(modelsUrl, {
-        headers: { Authorization: `Bearer ${apiKey}` },
-        signal: AbortSignal.timeout(10_000),
-      });
-      if (res.ok) {
-        const json = await res.json() as { data?: Array<{ id: string }> };
-        models = (json.data ?? []).map((m) => ({ id: m.id, name: m.id }));
-      }
-    } catch { /* timeout or network error */ }
-    if (models.length === 0) {
-      const builtIn = await listModelsForService(service, apiKey);
-      models = builtIn.map((m) => ({ id: m.id, name: m.name }));
-    }
+    // B13: 走 listModelsForService 走 live probe + bank 交叉,返回带元数据的 models
+    const enriched = await listModelsForService(
+      isCustomServiceId(service) ? "custom" : service,
+      apiKey,
+      isCustomServiceId(service) ? resolvedBaseUrl ?? undefined : undefined,
+    );
+    const models = filterTextChatModels(enriched).map((m) => ({
+      id: m.id,
+      name: m.name,
+      ...(m.maxOutput !== undefined ? { maxOutput: m.maxOutput } : {}),
+      ...(m.contextWindow > 0 ? { contextWindow: m.contextWindow } : {}),
+    }));
     modelListCache.set(cacheKey, { models, at: Date.now() });
     return c.json({ models });
   });
@@ -1123,7 +1286,6 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       baseUrl: currentConfig.llm.baseUrl,
       stream: currentConfig.llm.stream,
       temperature: currentConfig.llm.temperature,
-      maxTokens: currentConfig.llm.maxTokens,
     });
   });
 
@@ -1139,9 +1301,6 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
       if (updates.temperature !== undefined) {
         existing.llm.temperature = updates.temperature;
       }
-      if (updates.maxTokens !== undefined) {
-        existing.llm.maxTokens = updates.maxTokens;
-      }
       if (updates.stream !== undefined) {
         existing.llm.stream = updates.stream;
       }
@@ -1325,6 +1484,10 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     if (!sessionId?.trim()) {
       throw new ApiError(400, "SESSION_ID_REQUIRED", "sessionId is required");
     }
+    if (reqModel && !isTextChatModelId(reqModel)) {
+      const message = nonTextModelMessage(reqModel);
+      return c.json({ error: message, response: message }, 400);
+    }
 
     broadcast("agent:start", { instruction, activeBookId, sessionId });
 
@@ -1380,7 +1543,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
         const defaultModel = rawConfig.defaultModel as string | undefined;
         const servicesArr = normalizeServiceConfig(rawConfig.services);
         const firstService = servicesArr[0];
-        if (firstService?.service && defaultModel) {
+        if (firstService?.service && defaultModel && isTextChatModelId(defaultModel)) {
           try {
             const resolved = await resolveServiceModel(
               serviceConfigKey(firstService),
@@ -1402,11 +1565,12 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
           if (svcData?.apiKey) {
             try {
               const models = await listModelsForService(svcName, svcData.apiKey);
-              if (models.length > 0) {
+              const textModels = filterTextChatModels(models);
+              if (textModels.length > 0) {
                 const configuredEntry = await resolveConfiguredServiceEntry(root, svcName);
                 const resolved = await resolveServiceModel(
                   svcName,
-                  models[0].id,
+                  textModels[0].id,
                   root,
                   await resolveConfiguredServiceBaseUrl(root, svcName),
                   configuredEntry?.apiFormat,
@@ -2376,7 +2540,7 @@ export async function startStudioServer(
   port = 4567,
   options?: { readonly staticDir?: string },
 ): Promise<void> {
-  const config = await loadProjectConfig(root, { requireApiKey: false });
+  const config = await loadProjectConfig(root, { consumer: "studio", requireApiKey: false });
 
   const app = createStudioServer(config, root);
 

+ 12 - 13
packages/studio/src/components/ServiceConfigSourceCard.tsx

@@ -16,10 +16,12 @@ interface ServiceConfigPayload {
   services: Array<Record<string, unknown>>;
   defaultModel: string | null;
   configSource: ConfigSource;
+  storedConfigSource?: ConfigSource;
   envConfig: {
     project: EnvConfigSummary;
     global: EnvConfigSummary;
     effectiveSource: EnvScope;
+    runtimeUsesEnv: boolean;
   };
 }
 
@@ -76,6 +78,7 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
   }
 
   const { configSource, envConfig } = data;
+  const storedConfigSource = data.storedConfigSource ?? configSource;
   const activeEnvSummary = envConfig.effectiveSource === "project" ? envConfig.project : envConfig.global;
   const envLabel = envConfig.effectiveSource === "project" ? "项目 .env" : envConfig.effectiveSource === "global" ? "全局 ~/.inkos/.env" : null;
   const envDetected = envConfig.project.detected || envConfig.global.detected;
@@ -86,8 +89,8 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
         <div>
           <div className="text-sm font-medium">LLM 配置来源</div>
           <div className="text-xs text-muted-foreground/70 mt-1">
-            当前策略:
-            <span className="text-foreground"> {configSource === "env" ? "优先使用 .env" : "优先使用 Studio 配置"}</span>
+            Studio 运行时:
+            <span className="text-foreground"> 使用服务页配置和 Studio 密钥</span>
           </div>
         </div>
         <div className="flex items-center gap-2">
@@ -99,17 +102,15 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
           >
             {saving === "studio" ? "切换中…" : "使用 Studio 配置"}
           </button>
-          <button
-            type="button"
-            onClick={() => void switchSource("env")}
-            disabled={saving !== null || configSource === "env"}
-            className="rounded-lg border border-border/50 px-3 py-1.5 text-xs hover:bg-secondary/50 disabled:opacity-50"
-          >
-            {saving === "env" ? "切换中…" : "使用 .env 优先"}
-          </button>
         </div>
       </div>
 
+      {storedConfigSource === "env" ? (
+        <div className="rounded-lg border border-amber-500/25 bg-amber-500/[0.04] p-3 text-xs text-muted-foreground/80">
+          检测到旧配置标记为 `.env` 优先。Studio 运行时不会使用它;CLI、daemon 和部署环境仍可按 env 覆盖层使用。
+        </div>
+      ) : null}
+
       {envDetected ? (
         <div className="rounded-lg border border-amber-500/25 bg-amber-500/[0.04] p-3 text-xs text-muted-foreground/80 space-y-1.5">
           <div className="text-foreground">
@@ -121,9 +122,7 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
           {activeEnvSummary.provider ? <div>Provider: <span className="font-mono text-foreground">{activeEnvSummary.provider}</span></div> : null}
           <div>API Key: <span className="text-foreground">{activeEnvSummary.hasApiKey ? "已设置" : "未设置"}</span></div>
           <div className="text-muted-foreground/70 pt-1">
-            {configSource === "env"
-              ? "当前请求会优先使用这套 .env 配置。切到 Studio 配置后,将改用服务页里的 service/baseUrl/model。"
-              : "当前虽然检测到 .env,但已切到 Studio 配置;Studio 和 Agent 请求会忽略这套 LLM 覆盖。"}
+            当前虽然检测到 .env,但 Studio 和 Agent 请求会忽略这套 LLM 覆盖;CLI、daemon 和部署环境可以使用它。
           </div>
         </div>
       ) : (

+ 25 - 0
packages/studio/src/constants/service-groups.ts

@@ -0,0 +1,25 @@
+import type { EndpointGroup } from "../store/service/types";
+
+export const GROUP_ORDER: ReadonlyArray<EndpointGroup> = [
+  "overseas",
+  "china",
+  "aggregator",
+  "local",
+  "codingPlan",
+] as const;
+
+export const GROUP_LABELS: Record<EndpointGroup, string> = {
+  overseas: "海外原厂",
+  china: "国产原厂",
+  aggregator: "聚合 / 二手 API",
+  local: "本地 / 订阅",
+  codingPlan: "CodingPlan",
+};
+
+export const GROUP_SHORT_LABELS: Record<EndpointGroup, string> = {
+  overseas: "海外",
+  china: "国产",
+  aggregator: "聚合",
+  local: "本地",
+  codingPlan: "CodingPlan",
+};

+ 22 - 18
packages/studio/src/pages/ChatPage.tsx

@@ -33,6 +33,7 @@ import {
 import {
   filterModelGroups,
   getBookCreateSessionId,
+  pickModelSelection,
   setBookCreateSessionId,
 } from "./chat-page-state";
 
@@ -89,41 +90,44 @@ export function ChatPage({ activeBookId, nav, theme, t, sse: _sse }: ChatPagePro
   // -- Model picker: read raw state, derive with useMemo (stable refs) --
   const services = useServiceStore((s) => s.services);
   const servicesLoading = useServiceStore((s) => s.servicesLoading);
+  const bankModelsLoading = useServiceStore((s) => s.bankModelsLoading);
+  const customModelsLoading = useServiceStore((s) => s.customModelsLoading);
   const modelsByService = useServiceStore((s) => s.modelsByService);
   const fetchServices = useServiceStore((s) => s.fetchServices);
-  const fetchModels = useServiceStore((s) => s.fetchModels);
+  const fetchBankModels = useServiceStore((s) => s.fetchBankModels);
+  const fetchCustomModels = useServiceStore((s) => s.fetchCustomModels);
 
   useEffect(() => { void fetchServices(); }, [fetchServices]);
   useEffect(() => {
-    for (const svc of services) {
-      if (svc.connected) void fetchModels(svc.service);
-    }
-  }, [services, fetchModels]);
+    void fetchBankModels();
+    void fetchCustomModels();
+  }, [fetchBankModels, fetchCustomModels]);
 
   const modelPickerStatus = useMemo(() => {
     if (servicesLoading || services.length === 0) return "loading" as const;
     const connected = services.filter((s) => s.connected);
     if (connected.length === 0) return "no-models" as const;
-    if (connected.some((s) => modelsByService[s.service]?.loading)) return "loading" as const;
-    return connected.some((s) => (modelsByService[s.service]?.models.length ?? 0) > 0)
-      ? "ready" as const : "no-models" as const;
-  }, [services, servicesLoading, modelsByService]);
+    if (bankModelsLoading) return "loading" as const;
+    if (connected.some((s) => (modelsByService[s.service]?.length ?? 0) > 0)) return "ready" as const;
+    const hasConnectedBank = connected.some((s) => !s.service.startsWith("custom"));
+    const hasConnectedCustom = connected.some((s) => s.service.startsWith("custom"));
+    if (!hasConnectedBank && hasConnectedCustom && customModelsLoading) return "loading" as const;
+    return "no-models" as const;
+  }, [services, servicesLoading, bankModelsLoading, customModelsLoading, modelsByService]);
 
   const groupedModels = useMemo(() => {
     return services
-      .filter((s) => s.connected && (modelsByService[s.service]?.models.length ?? 0) > 0)
-      .map((s) => ({ service: s.service, label: s.label, models: modelsByService[s.service]!.models }));
+      .filter((s) => s.connected && (modelsByService[s.service]?.length ?? 0) > 0)
+      .map((s) => ({ service: s.service, label: s.label, models: modelsByService[s.service]! }));
   }, [services, modelsByService]);
 
-  // Auto-select first model when models load and none selected
+  // Auto-select first model when models load, and replace stale selections after lists refresh.
   useEffect(() => {
-    if (!selectedModel && groupedModels.length > 0) {
-      const first = groupedModels[0];
-      if (first.models.length > 0) {
-        setSelectedModel(first.models[0].id, first.service);
-      }
+    const nextSelection = pickModelSelection(groupedModels, selectedModel, selectedService);
+    if (nextSelection) {
+      setSelectedModel(nextSelection.model, nextSelection.service);
     }
-  }, [groupedModels, selectedModel, setSelectedModel]);
+  }, [groupedModels, selectedModel, selectedService, setSelectedModel]);
 
   // Auto-resize textarea
   useEffect(() => {

+ 20 - 39
packages/studio/src/pages/ServiceDetailPage.tsx

@@ -2,11 +2,11 @@ import { useState, useEffect } from "react";
 import { fetchJson } from "../hooks/use-api";
 import { useServiceStore } from "../store/service";
 import { Eye, EyeOff, Loader2, ArrowLeft } from "lucide-react";
-import { ServiceConfigSourceCard } from "../components/ServiceConfigSourceCard";
 import {
+  matchServiceConfigEntryForDetail,
   probeServiceForDetail,
   rehydrateServiceConnectionStatus,
-  saveServiceConfigWithValidation,
+  saveServiceConfig,
   type ServiceDetailConnectionStatus as ConnectionStatus,
   type ServiceDetailDetectedConfig as DetectedConfig,
   type ServiceDetailModelInfo as ModelInfo,
@@ -33,7 +33,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   const loading = useServiceStore((s) => s.servicesLoading);
   const fetchServices = useServiceStore((s) => s.fetchServices);
   const refreshServices = useServiceStore((s) => s.refreshServices);
-  const setStoreModels = useServiceStore((s) => s.setModels);
+  const setStoreModels = useServiceStore((s) => s.setLiveModels);
   const clearStoreModels = useServiceStore((s) => s.clearModels);
 
   useEffect(() => { void fetchServices(); }, [fetchServices]);
@@ -48,7 +48,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   const [customName, setCustomName] = useState("");
   const [baseUrl, setBaseUrl] = useState("");
   const [temperature, setTemperature] = useState("0.7");
-  const [maxTokens, setMaxTokens] = useState("4096");
   const [apiFormat, setApiFormat] = useState<"chat" | "responses">("chat");
   const [stream, setStream] = useState(true);
   const [detectedModel, setDetectedModel] = useState<string>("");
@@ -62,20 +61,13 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
     void fetchJson<{ services: Array<Record<string, unknown>> }>("/services/config")
       .then((data) => {
         if (cancelled) return;
-        const matched = (data.services ?? []).find((entry) => {
-          if (typeof entry.service !== "string") return false;
-          if (serviceId.startsWith("custom:")) {
-            return entry.service === "custom" && `custom:${String(entry.name ?? "")}` === serviceId;
-          }
-          return entry.service === serviceId;
-        });
+        const matched = matchServiceConfigEntryForDetail(data.services ?? [], serviceId);
         if (!matched) return;
         if (isCustom) {
           setCustomName(String(matched.name ?? persistedCustomName));
           setBaseUrl(String(matched.baseUrl ?? ""));
         }
         if (typeof matched.temperature === "number") setTemperature(String(matched.temperature));
-        if (typeof matched.maxTokens === "number") setMaxTokens(String(matched.maxTokens));
         if (matched.apiFormat === "chat" || matched.apiFormat === "responses") setApiFormat(matched.apiFormat);
         if (typeof matched.stream === "boolean") setStream(matched.stream);
       })
@@ -86,12 +78,10 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   const resolvedCustomName = persistedCustomName || customName.trim() || "Custom";
   const effectiveServiceId = isCustom ? `custom:${resolvedCustomName}` : serviceId;
   const label = isCustom ? (customName || persistedCustomName || "自定义服务") : (svc?.label ?? serviceId);
+  const storeModels = useServiceStore((s) => s.modelsByService[effectiveServiceId]);
 
   useEffect(() => {
     let cancelled = false;
-    if (svc?.connected) {
-      setStatus({ state: "testing" });
-    }
     void rehydrateServiceConnectionStatus({
       effectiveServiceId,
       shouldVerify: Boolean(svc?.connected),
@@ -108,8 +98,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         setStatus(result.status);
         if (result.status.state === "connected") {
           setStoreModels(effectiveServiceId, result.status.models);
-        } else {
-          clearStoreModels(effectiveServiceId);
         }
       })
       .catch(() => {
@@ -120,7 +108,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   }, [
     apiFormat,
     baseUrl,
-    clearStoreModels,
     effectiveServiceId,
     isCustom,
     setStoreModels,
@@ -131,8 +118,8 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
   if (loading) return <DetailSkeleton />;
 
   // -- Derived state --
-  const isConnected = status.state === "connected";
-  const models = status.state === "connected" ? status.models : [];
+  const isConnected = Boolean(svc?.connected);
+  const models = status.state === "connected" ? status.models : (storeModels ?? []);
   const isBusy = status.state === "testing" || status.state === "saving";
 
   // -- Handlers --
@@ -182,7 +169,7 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
     }
     setStatus({ state: "saving" });
     try {
-      const result = await saveServiceConfigWithValidation({
+      const result = await saveServiceConfig({
         effectiveServiceId,
         serviceId,
         isCustom,
@@ -192,7 +179,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         apiFormat,
         stream,
         temperature,
-        maxTokens,
         detectedModel,
       });
       if (result.status.state === "connected") {
@@ -204,9 +190,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         setStoreModels(effectiveServiceId, result.status.models);
         setStatus(result.status);
       } else {
-        clearStoreModels(effectiveServiceId);
-        setDetectedModel("");
-        setDetectedConfig(null);
         setStatus(result.status);
       }
       await refreshServices();
@@ -237,8 +220,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         )}
       </div>
 
-      <ServiceConfigSourceCard onChange={() => { void refreshServices(); }} />
-
       <div className="space-y-5">
         {/* Custom fields */}
         {isCustom && (
@@ -321,18 +302,22 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
         </div>
 
         {/* Models */}
-        {models.length > 0 && (
+        {isConnected && (
           <div className="space-y-2">
             <p className="text-xs text-muted-foreground/70 font-medium uppercase tracking-wider">
               可用模型({models.length})
             </p>
-            <div className="flex gap-1.5 flex-wrap">
-              {models.map((m) => (
-                <span key={m.id} className="text-[11px] px-2.5 py-1 rounded-md bg-emerald-500/[0.06] text-emerald-600 dark:text-emerald-400 border border-emerald-500/15">
-                  {m.name ?? m.id}
-                </span>
-              ))}
-            </div>
+            {models.length > 0 ? (
+              <div className="flex gap-1.5 flex-wrap">
+                {models.map((m) => (
+                  <span key={m.id} className="text-[11px] px-2.5 py-1 rounded-md bg-emerald-500/[0.06] text-emerald-600 dark:text-emerald-400 border border-emerald-500/15">
+                    {m.name ?? m.id}
+                  </span>
+                ))}
+              </div>
+            ) : (
+              <p className="text-xs text-muted-foreground/60">点击“测试连接”查看可用模型</p>
+            )}
           </div>
         )}
 
@@ -350,10 +335,6 @@ export function ServiceDetailPage({ serviceId, nav }: { serviceId: string; nav:
                   min="0" max="2" step="0.05" className="w-16 rounded-md border border-border/60 bg-background px-2 py-1 text-xs text-right font-mono" />
               </div>
             </Field>
-            <Field label="maxTokens">
-              <input type="number" value={maxTokens} onChange={(e) => setMaxTokens(e.target.value)}
-                min="256" max="200000" step="256" className="w-full rounded-md border border-border/60 bg-background px-3 py-1.5 text-xs font-mono" />
-            </Field>
           </div>
         </details>
       </div>

+ 218 - 45
packages/studio/src/pages/ServiceListPage.tsx

@@ -1,7 +1,8 @@
-import { useEffect } from "react";
+import { useEffect, useMemo, useState } from "react";
+import { Check, Plus, Search, X } from "lucide-react";
+import { GROUP_LABELS, GROUP_ORDER, GROUP_SHORT_LABELS } from "../constants/service-groups";
 import { useServiceStore } from "../store/service";
-import type { ServiceInfo } from "../store/service";
-import { ServiceConfigSourceCard } from "../components/ServiceConfigSourceCard";
+import type { EndpointGroup, ServiceInfo } from "../store/service";
 
 interface Nav {
   toDashboard: () => void;
@@ -10,7 +11,7 @@ interface Nav {
 
 function SkeletonCard() {
   return (
-    <div className="rounded-xl border border-border/30 p-5 animate-pulse">
+    <div className="rounded-lg border border-border/30 p-5 animate-pulse">
       <div className="flex items-center justify-between mb-3">
         <div className="h-4 w-24 bg-muted rounded" />
         <div className="w-2 h-2 rounded-full bg-muted" />
@@ -20,16 +21,104 @@ function SkeletonCard() {
   );
 }
 
+function ServiceCard({ svc, onClick }: { svc: ServiceInfo; onClick: () => void }) {
+  return (
+    <button
+      onClick={onClick}
+      className={[
+        "flex min-h-[92px] flex-col gap-2 rounded-lg border p-5 text-left transition-all hover:shadow-sm",
+        svc.connected
+          ? "border-emerald-500/30 bg-emerald-500/[0.03]"
+          : "border-dashed border-border/40",
+      ].join(" ")}
+    >
+      <div className="flex items-center justify-between gap-3">
+        <span className="truncate text-sm font-medium">{svc.label}</span>
+        <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${svc.connected ? "bg-emerald-500" : "bg-muted-foreground/30"}`} />
+      </div>
+      <span className="text-xs text-muted-foreground/60">
+        {svc.connected ? "已连接" : "未配置"}
+      </span>
+    </button>
+  );
+}
+
 export function ServiceListPage({ nav }: { nav: Nav }) {
   const services = useServiceStore((s) => s.services);
   const loading = useServiceStore((s) => s.servicesLoading);
   const fetchServices = useServiceStore((s) => s.fetchServices);
-  const refreshServices = useServiceStore((s) => s.refreshServices);
 
   useEffect(() => { void fetchServices(); }, [fetchServices]);
 
+  const [query, setQuery] = useState("");
+  const [selectedGroups, setSelectedGroups] = useState<Set<EndpointGroup>>(new Set());
+  const [onlyConnected, setOnlyConnected] = useState(false);
+
+  const bankServices = useMemo(
+    () => services.filter((s) => !s.service.startsWith("custom")),
+    [services],
+  );
+  const customServices = useMemo(
+    () => services.filter((s) => s.service.startsWith("custom")),
+    [services],
+  );
+
+  const groupCounts = useMemo(() => {
+    const counts = {} as Record<EndpointGroup, number>;
+    for (const group of GROUP_ORDER) {
+      counts[group] = bankServices.filter((s) => s.group === group).length;
+    }
+    return counts;
+  }, [bankServices]);
+
+  const connectedCount = useMemo(
+    () => services.filter((s) => s.connected).length,
+    [services],
+  );
+
+  const filtered = useMemo(() => {
+    const q = query.trim().toLowerCase();
+    return bankServices.filter((svc) => {
+      if (onlyConnected && !svc.connected) return false;
+      if (selectedGroups.size > 0 && (!svc.group || !selectedGroups.has(svc.group))) return false;
+      if (q && !svc.label.toLowerCase().includes(q) && !svc.service.toLowerCase().includes(q)) return false;
+      return true;
+    });
+  }, [bankServices, onlyConnected, query, selectedGroups]);
+
+  const filteredCustom = useMemo(() => {
+    const q = query.trim().toLowerCase();
+    if (selectedGroups.size > 0) return [];
+    return customServices.filter((svc) => {
+      if (onlyConnected && !svc.connected) return false;
+      if (q && !svc.label.toLowerCase().includes(q) && !svc.service.toLowerCase().includes(q)) return false;
+      return true;
+    });
+  }, [customServices, onlyConnected, query, selectedGroups]);
+
+  const byGroup = useMemo(() => {
+    const map = {} as Record<EndpointGroup, ServiceInfo[]>;
+    for (const group of GROUP_ORDER) map[group] = [];
+    for (const svc of filtered) {
+      if (svc.group) map[svc.group].push(svc);
+    }
+    return map;
+  }, [filtered]);
+
+  const toggleGroup = (group: EndpointGroup) => {
+    setSelectedGroups((prev) => {
+      const next = new Set(prev);
+      if (next.has(group)) next.delete(group);
+      else next.add(group);
+      return next;
+    });
+  };
+
+  const canCreateCustom = selectedGroups.size === 0 && query.trim() === "" && !onlyConnected;
+  const showCustomSection = !loading && selectedGroups.size === 0 && (filteredCustom.length > 0 || canCreateCustom);
+
   return (
-    <div className="max-w-2xl mx-auto space-y-8">
+    <div className="mx-auto max-w-2xl space-y-6">
       <div className="flex items-center gap-2 text-sm text-muted-foreground">
         <button
           onClick={nav.toDashboard}
@@ -43,51 +132,135 @@ export function ServiceListPage({ nav }: { nav: Nav }) {
 
       <h1 className="font-serif text-2xl">服务商管理</h1>
 
-      <ServiceConfigSourceCard onChange={() => { void refreshServices(); }} />
-
-      <div className="grid grid-cols-2 gap-3">
-        {loading
-          ? Array.from({ length: 6 }, (_, i) => <SkeletonCard key={i} />)
-          : services.map((svc) => (
-              <ServiceCard
-                key={svc.service}
-                svc={svc}
-                onClick={() => nav.toServiceDetail(svc.service)}
-              />
-            ))
-        }
-        {!loading && (
+      <div className="relative">
+        <Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground/40" />
+        <input
+          type="text"
+          value={query}
+          onChange={(event) => setQuery(event.target.value)}
+          placeholder="搜索服务商"
+          className="w-full rounded-lg border border-border/60 bg-background py-2 pl-9 pr-9 text-sm outline-none focus:border-primary/50"
+        />
+        {query && (
           <button
-            onClick={() => nav.toServiceDetail("custom")}
-            className="flex flex-col items-center justify-center gap-1.5 rounded-xl border border-dashed border-border/40 p-5 text-muted-foreground/60 hover:border-primary/30 hover:text-muted-foreground transition-all"
+            onClick={() => setQuery("")}
+            className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground"
+            aria-label="清空搜索"
           >
-            <span className="text-lg leading-none">+</span>
-            <span className="text-xs">自定义服务</span>
+            <X size={14} />
           </button>
         )}
       </div>
-    </div>
-  );
-}
 
-function ServiceCard({ svc, onClick }: { svc: ServiceInfo; onClick: () => void }) {
-  return (
-    <button
-      onClick={onClick}
-      className={[
-        "flex flex-col gap-2 rounded-xl border p-5 text-left transition-all hover:shadow-sm",
-        svc.connected
-          ? "border-emerald-500/30 bg-emerald-500/[0.03]"
-          : "border-dashed border-border/40",
-      ].join(" ")}
-    >
-      <div className="flex items-center justify-between">
-        <span className="font-medium text-sm">{svc.label}</span>
-        <span className={`w-1.5 h-1.5 rounded-full shrink-0 ${svc.connected ? "bg-emerald-500" : "bg-muted-foreground/30"}`} />
+      <div className="flex flex-wrap gap-2">
+        <button
+          onClick={() => setSelectedGroups(new Set())}
+          className={[
+            "inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs transition-colors",
+            selectedGroups.size === 0
+              ? "border-foreground bg-foreground text-background"
+              : "border-border/60 text-muted-foreground hover:bg-secondary/50",
+          ].join(" ")}
+        >
+          全部 {bankServices.length}
+        </button>
+        {GROUP_ORDER.map((group) => {
+          const selected = selectedGroups.has(group);
+          return (
+            <button
+              key={group}
+              onClick={() => toggleGroup(group)}
+              className={[
+                "inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs transition-colors",
+                selected
+                  ? "border-foreground bg-foreground text-background"
+                  : "border-border/60 text-muted-foreground hover:bg-secondary/50",
+              ].join(" ")}
+            >
+              {selected && <Check size={12} />}
+              {GROUP_SHORT_LABELS[group]} {groupCounts[group]}
+            </button>
+          );
+        })}
+        {selectedGroups.size > 0 && (
+          <button
+            onClick={() => setSelectedGroups(new Set())}
+            className="inline-flex items-center rounded-full px-3 py-1 text-xs text-muted-foreground transition-colors hover:text-foreground"
+          >
+            清除筛选
+          </button>
+        )}
       </div>
-      <span className="text-xs text-muted-foreground/60">
-        {svc.connected ? "已连接" : "未配置"}
-      </span>
-    </button>
+
+      <label className="inline-flex cursor-pointer select-none items-center gap-2 text-xs text-muted-foreground">
+        <input
+          type="checkbox"
+          checked={onlyConnected}
+          onChange={(event) => setOnlyConnected(event.target.checked)}
+        />
+        <span>只看已连接 ({connectedCount})</span>
+      </label>
+
+      <div className="h-px bg-border/30" />
+
+      {loading && (
+        <div className="grid grid-cols-2 gap-3">
+          {Array.from({ length: 6 }, (_, i) => <SkeletonCard key={i} />)}
+        </div>
+      )}
+
+      {!loading && GROUP_ORDER.map((group) => {
+        const list = byGroup[group];
+        if (!list || list.length === 0) return null;
+        return (
+          <section key={group} className="space-y-3">
+            <h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
+              {GROUP_LABELS[group]}
+            </h2>
+            <div className="grid grid-cols-2 gap-3">
+              {list.map((svc) => (
+                <ServiceCard
+                  key={svc.service}
+                  svc={svc}
+                  onClick={() => nav.toServiceDetail(svc.service)}
+                />
+              ))}
+            </div>
+          </section>
+        );
+      })}
+
+      {showCustomSection && (
+        <section className="space-y-3">
+          <h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
+            自定义服务
+          </h2>
+          <div className="grid grid-cols-2 gap-3">
+            {filteredCustom.map((svc) => (
+              <ServiceCard
+                key={svc.service}
+                svc={svc}
+                onClick={() => nav.toServiceDetail(svc.service)}
+              />
+            ))}
+            {canCreateCustom && (
+              <button
+                onClick={() => nav.toServiceDetail("custom")}
+                className="flex min-h-[92px] flex-col items-center justify-center gap-1.5 rounded-lg border border-dashed border-border/40 p-5 text-muted-foreground/60 transition-all hover:border-primary/30 hover:text-muted-foreground"
+              >
+                <Plus size={18} />
+                <span className="text-xs">自定义服务</span>
+              </button>
+            )}
+          </div>
+        </section>
+      )}
+
+      {!loading && filtered.length === 0 && filteredCustom.length === 0 && !canCreateCustom && (
+        <div className="rounded-lg border border-dashed border-border/40 p-8 text-center text-sm text-muted-foreground">
+          没有匹配的服务商
+        </div>
+      )}
+    </div>
   );
 }

+ 42 - 0
packages/studio/src/pages/chat-page-state.test.ts

@@ -3,6 +3,7 @@ import {
   clearBookCreateSessionId,
   filterModelGroups,
   getBookCreateSessionId,
+  pickModelSelection,
   setBookCreateSessionId,
 } from "./chat-page-state";
 
@@ -104,3 +105,44 @@ describe("filterModelGroups", () => {
     ]);
   });
 });
+
+describe("pickModelSelection", () => {
+  const grouped = [
+    {
+      service: "google",
+      label: "Google Gemini",
+      models: [
+        { id: "gemini-2.5-flash", name: "gemini-2.5-flash" },
+      ],
+    },
+    {
+      service: "moonshot",
+      label: "Moonshot",
+      models: [
+        { id: "kimi-k2.5", name: "kimi-k2.5" },
+      ],
+    },
+  ] as const;
+
+  it("keeps the current selection when it is still available", () => {
+    expect(pickModelSelection(grouped, "kimi-k2.5", "moonshot")).toBeNull();
+  });
+
+  it("selects the first available model when current selection is missing", () => {
+    expect(pickModelSelection(grouped, "gemini-3.1-flash-image-preview", "google")).toEqual({
+      model: "gemini-2.5-flash",
+      service: "google",
+    });
+  });
+
+  it("selects the first available model when there is no current selection", () => {
+    expect(pickModelSelection(grouped, null, null)).toEqual({
+      model: "gemini-2.5-flash",
+      service: "google",
+    });
+  });
+
+  it("returns null when no models are available", () => {
+    expect(pickModelSelection([], "gemini-3.1-flash-image-preview", "google")).toBeNull();
+  });
+});

+ 19 - 0
packages/studio/src/pages/chat-page-state.ts

@@ -40,3 +40,22 @@ export function filterModelGroups(
     }))
     .filter((group) => group.models.length > 0);
 }
+
+export function pickModelSelection(
+  groupedModels: ReadonlyArray<ChatPageModelGroup>,
+  selectedModel: string | null,
+  selectedService: string | null,
+): { model: string; service: string } | null {
+  const selectedStillAvailable = selectedModel && selectedService
+    ? groupedModels.some((group) =>
+        group.service === selectedService
+        && group.models.some((model) => model.id === selectedModel),
+      )
+    : false;
+  if (selectedStillAvailable) return null;
+
+  const firstGroup = groupedModels.find((group) => group.models.length > 0);
+  const firstModel = firstGroup?.models[0];
+  if (!firstGroup || !firstModel) return null;
+  return { model: firstModel.id, service: firstGroup.service };
+}

+ 52 - 51
packages/studio/src/pages/service-detail-state.test.ts

@@ -1,23 +1,16 @@
 import { describe, expect, it, vi } from "vitest";
 import {
+  matchServiceConfigEntryForDetail,
   rehydrateServiceConnectionStatus,
-  saveServiceConfigWithValidation,
+  saveServiceConfig,
 } from "./service-detail-state";
 
 describe("rehydrateServiceConnectionStatus", () => {
-  it("verifies the saved key via /test on page load instead of /models", async () => {
+  it("loads saved key without probing models on page load", async () => {
     const fetchJsonImpl = vi.fn(async (path: string) => {
       if (path === "/services/openai/secret") {
         return { apiKey: "sk-live" };
       }
-      if (path === "/services/openai/test") {
-        return {
-          ok: true,
-          models: [{ id: "gpt-5.4", name: "gpt-5.4" }],
-          selectedModel: "gpt-5.4",
-          detected: { apiFormat: "responses", stream: false, modelsSource: "api" },
-        };
-      }
       throw new Error(`unexpected path: ${path}`);
     });
 
@@ -31,44 +24,47 @@ describe("rehydrateServiceConnectionStatus", () => {
       fetchJsonImpl: fetchJsonImpl as never,
     });
 
-    expect(fetchJsonImpl).toHaveBeenCalledTimes(2);
-    expect(fetchJsonImpl).toHaveBeenNthCalledWith(1, "/services/openai/secret");
-    expect(fetchJsonImpl).toHaveBeenNthCalledWith(
-      2,
-      "/services/openai/test",
-      expect.objectContaining({ method: "POST" }),
-    );
+    expect(fetchJsonImpl).toHaveBeenCalledTimes(1);
+    expect(fetchJsonImpl).toHaveBeenCalledWith("/services/openai/secret");
     expect(result).toMatchObject({
       apiKey: "sk-live",
-      detectedModel: "gpt-5.4",
-      detectedConfig: { apiFormat: "responses", stream: false, modelsSource: "api" },
-      status: {
-        state: "connected",
-        models: [{ id: "gpt-5.4", name: "gpt-5.4" }],
-      },
+      detectedModel: "",
+      detectedConfig: null,
+      status: { state: "idle" },
     });
   });
 });
 
-describe("saveServiceConfigWithValidation", () => {
-  it("validates the key before persisting secrets/config", async () => {
+describe("matchServiceConfigEntryForDetail", () => {
+  const entries = [
+    { service: "moonshot", temperature: 0.5 },
+    { service: "custom", name: "内网GPT", baseUrl: "https://llm.internal.corp/v1" },
+    { service: "custom", name: "本地Ollama", baseUrl: "http://localhost:11434/v1" },
+  ];
+
+  it("matches concrete custom services without treating bare custom as an existing config", () => {
+    expect(matchServiceConfigEntryForDetail(entries, "custom")).toBeUndefined();
+    expect(matchServiceConfigEntryForDetail(entries, "custom:内网GPT")).toEqual(entries[1]);
+  });
+
+  it("matches non-custom services by service id", () => {
+    expect(matchServiceConfigEntryForDetail(entries, "moonshot")).toEqual(entries[0]);
+  });
+});
+
+describe("saveServiceConfig", () => {
+  it("persists secrets/config without probing the upstream service", async () => {
     const calls: string[] = [];
-    const fetchJsonImpl = vi.fn(async (path: string) => {
+    const bodies: unknown[] = [];
+    const fetchJsonImpl = vi.fn(async (path: string, init?: { body?: string }) => {
       calls.push(path);
-      if (path === "/services/openai/test") {
-        return {
-          ok: true,
-          models: [{ id: "gpt-5.4", name: "gpt-5.4" }],
-          selectedModel: "gpt-5.4",
-          detected: { apiFormat: "responses", stream: false },
-        };
-      }
+      if (init?.body) bodies.push(JSON.parse(init.body));
       if (path === "/services/openai/secret") return { ok: true };
       if (path === "/services/config") return { ok: true };
       throw new Error(`unexpected path: ${path}`);
     });
 
-    const result = await saveServiceConfigWithValidation({
+    const result = await saveServiceConfig({
       effectiveServiceId: "openai",
       serviceId: "openai",
       isCustom: false,
@@ -78,37 +74,40 @@ describe("saveServiceConfigWithValidation", () => {
       apiFormat: "chat",
       stream: true,
       temperature: "0.7",
-      maxTokens: "4096",
       detectedModel: "",
       fetchJsonImpl: fetchJsonImpl as never,
     });
 
     expect(calls).toEqual([
-      "/services/openai/test",
       "/services/openai/secret",
       "/services/config",
     ]);
-    expect(result).toMatchObject({
-      detectedModel: "gpt-5.4",
-      detectedConfig: { apiFormat: "responses", stream: false },
-      status: {
-        state: "connected",
-        models: [{ id: "gpt-5.4", name: "gpt-5.4" }],
+    expect(bodies).toEqual([
+      { apiKey: "sk-live" },
+      {
+        service: "openai",
+        services: [
+          { service: "openai", temperature: 0.7, apiFormat: "chat", stream: true },
+        ],
       },
+    ]);
+    expect(result).toEqual({
+      detectedModel: "",
+      detectedConfig: null,
+      status: { state: "saved" },
     });
   });
 
-  it("does not persist secrets/config when validation fails", async () => {
+  it("still persists secrets/config when the key has not been manually tested", async () => {
     const calls: string[] = [];
     const fetchJsonImpl = vi.fn(async (path: string) => {
       calls.push(path);
-      if (path === "/services/openai/test") {
-        throw new Error("401 Unauthorized");
-      }
+      if (path === "/services/openai/secret") return { ok: true };
+      if (path === "/services/config") return { ok: true };
       throw new Error(`unexpected path: ${path}`);
     });
 
-    await expect(saveServiceConfigWithValidation({
+    await expect(saveServiceConfig({
       effectiveServiceId: "openai",
       serviceId: "openai",
       isCustom: false,
@@ -118,11 +117,13 @@ describe("saveServiceConfigWithValidation", () => {
       apiFormat: "chat",
       stream: true,
       temperature: "0.7",
-      maxTokens: "4096",
       detectedModel: "",
       fetchJsonImpl: fetchJsonImpl as never,
-    })).rejects.toThrow("401 Unauthorized");
+    })).resolves.toMatchObject({ status: { state: "saved" } });
 
-    expect(calls).toEqual(["/services/openai/test"]);
+    expect(calls).toEqual([
+      "/services/openai/secret",
+      "/services/config",
+    ]);
   });
 });

+ 26 - 85
packages/studio/src/pages/service-detail-state.ts

@@ -30,10 +30,6 @@ interface ServiceProbeResponse {
   readonly error?: string;
 }
 
-function toErrorMessage(error: unknown, fallback: string): string {
-  return error instanceof Error ? error.message : fallback;
-}
-
 export async function probeServiceForDetail(
   serviceId: string,
   body: {
@@ -75,60 +71,29 @@ export async function rehydrateServiceConnectionStatus(args: {
   );
   const apiKey = String(secret.apiKey ?? "");
 
-  if (!args.shouldVerify || apiKey.trim().length === 0) {
-    return {
-      apiKey,
-      status: { state: "idle" },
-      detectedModel: "",
-      detectedConfig: null,
-    };
-  }
-
-  if (args.isCustom && args.baseUrl.trim().length === 0) {
-    return {
-      apiKey,
-      status: { state: "idle" },
-      detectedModel: "",
-      detectedConfig: null,
-    };
-  }
+  return {
+    apiKey,
+    status: { state: "idle" },
+    detectedModel: "",
+    detectedConfig: null,
+  };
+}
 
-  try {
-    const result = await probeServiceForDetail(
-      args.effectiveServiceId,
-      {
-        apiKey: apiKey.trim(),
-        apiFormat: args.apiFormat,
-        stream: args.stream,
-        ...(args.isCustom ? { baseUrl: args.baseUrl.trim() } : {}),
-      },
-      { fetchJsonImpl },
-    );
-    if (!result.ok) {
-      return {
-        apiKey,
-        status: { state: "error", message: result.error ?? "连接失败" },
-        detectedModel: "",
-        detectedConfig: null,
-      };
+export function matchServiceConfigEntryForDetail(
+  entries: ReadonlyArray<Record<string, unknown>>,
+  serviceId: string,
+): Record<string, unknown> | undefined {
+  return entries.find((entry) => {
+    if (typeof entry.service !== "string") return false;
+    if (serviceId.startsWith("custom:")) {
+      return entry.service === "custom" && `custom:${String(entry.name ?? "")}` === serviceId;
     }
-    return {
-      apiKey,
-      status: { state: "connected", models: result.models ?? [] },
-      detectedModel: result.selectedModel ?? "",
-      detectedConfig: result.detected ?? null,
-    };
-  } catch (error) {
-    return {
-      apiKey,
-      status: { state: "error", message: toErrorMessage(error, "连接失败") },
-      detectedModel: "",
-      detectedConfig: null,
-    };
-  }
+    if (serviceId === "custom") return false;
+    return entry.service === serviceId;
+  });
 }
 
-export async function saveServiceConfigWithValidation(args: {
+export async function saveServiceConfig(args: {
   readonly effectiveServiceId: string;
   readonly serviceId: string;
   readonly isCustom: boolean;
@@ -138,7 +103,6 @@ export async function saveServiceConfigWithValidation(args: {
   readonly apiFormat: "chat" | "responses";
   readonly stream: boolean;
   readonly temperature: string;
-  readonly maxTokens: string;
   readonly detectedModel: string;
   readonly fetchJsonImpl?: JsonFetcher;
 }): Promise<{
@@ -150,20 +114,6 @@ export async function saveServiceConfigWithValidation(args: {
   const trimmedKey = args.apiKey.trim();
   const trimmedBaseUrl = args.baseUrl.trim();
 
-  let probeResult: ServiceProbeResponse | null = null;
-  if (trimmedKey) {
-    probeResult = await probeServiceForDetail(
-      args.effectiveServiceId,
-      {
-        apiKey: trimmedKey,
-        apiFormat: args.apiFormat,
-        stream: args.stream,
-        ...(args.isCustom ? { baseUrl: trimmedBaseUrl } : {}),
-      },
-      { fetchJsonImpl },
-    );
-  }
-
   await fetchJsonImpl(`/services/${encodeURIComponent(args.effectiveServiceId)}/secret`, {
     method: "PUT",
     headers: { "Content-Type": "application/json" },
@@ -175,34 +125,25 @@ export async function saveServiceConfigWithValidation(args: {
     headers: { "Content-Type": "application/json" },
     body: JSON.stringify({
       service: args.effectiveServiceId,
-      ...(probeResult?.selectedModel ? { defaultModel: probeResult.selectedModel } : args.detectedModel ? { defaultModel: args.detectedModel } : {}),
+      ...(args.detectedModel ? { defaultModel: args.detectedModel } : {}),
       services: [
         {
           service: args.isCustom ? "custom" : args.serviceId,
           temperature: parseFloat(args.temperature),
-          maxTokens: parseInt(args.maxTokens, 10),
-          apiFormat: probeResult?.detected?.apiFormat ?? args.apiFormat,
-          stream: typeof probeResult?.detected?.stream === "boolean" ? probeResult.detected.stream : args.stream,
+          apiFormat: args.apiFormat,
+          stream: args.stream,
           ...(args.isCustom ? {
             name: args.resolvedCustomName,
-            baseUrl: probeResult?.detected?.baseUrl ?? trimmedBaseUrl,
+            baseUrl: trimmedBaseUrl,
           } : {}),
         },
       ],
     }),
   });
 
-  if (!probeResult) {
-    return {
-      status: { state: "saved" },
-      detectedModel: "",
-      detectedConfig: null,
-    };
-  }
-
   return {
-    status: { state: "connected", models: probeResult.models ?? [] },
-    detectedModel: probeResult.selectedModel ?? "",
-    detectedConfig: probeResult.detected ?? null,
+    status: { state: "saved" },
+    detectedModel: "",
+    detectedConfig: null,
   };
 }

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov