Explorar o código

统一 LLM 配置解析与 Studio CLI 边界

fanghanjun hai 6 meses
pai
achega
fc86ba96c6

+ 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
 ```bash
 inkos config set-global \
 inkos config set-global \
@@ -80,38 +113,49 @@ inkos config set-global \
   --base-url <API 地址> \
   --base-url <API 地址> \
   --api-key <你的 API Key> \
   --api-key <你的 API Key> \
   --model <模型名>
   --model <模型名>
-
-# provider: openai / anthropic / custom(兼容 OpenAI 格式的中转站选 custom)
-# base-url: 你的 API 提供商地址
-# api-key: 你的 API Key
-# model: 你的模型名称
 ```
 ```
 
 
-配置保存在 `~/.inkos/.env`,所有项目共享。之后新建项目不用再配。
-
-**方式二:项目级 `.env`**
+也可以手动写 `~/.inkos/.env` 或项目 `.env`:
 
 
 ```bash
 ```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 分配不同模型,按需平衡质量与成本:
 给不同 Agent 分配不同模型,按需平衡质量与成本:
 
 
@@ -124,6 +168,31 @@ inkos config show-models        # 查看当前路由
 
 
 未单独配置的 Agent 自动使用全局模型。
 未单独配置的 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 更新
 ### v1.2 更新
 
 
 **统一交互内核 + TUI 仪表盘 + Studio 助手**
 **统一交互内核 + 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` 结构校验。坏数据直接拒绝,不会滚雪球。
 伏笔系统使用 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 export [id]` | 导出书籍(`--format txt/md/epub`、`--output <path>`、`--approved-only`) |
 | `inkos radar scan` | 扫描平台趋势 |
 | `inkos radar scan` | 扫描平台趋势 |
 | `inkos fanfic init` | 从原作素材创建同人书(`--from`、`--mode canon/au/ooc/cp`) |
 | `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 show-global` | 查看全局配置 |
 | `inkos config set/show` | 查看/更新项目配置 |
 | `inkos config set/show` | 查看/更新项目配置 |
 | `inkos config set-model <agent> <model>` | 为指定 agent 设置模型覆盖(`--base-url`、`--provider`、`--api-key-env` 支持多 Provider 路由) |
 | `inkos config set-model <agent> <model>` | 为指定 agent 设置模型覆盖(`--base-url`、`--provider`、`--api-key-env` 支持多 Provider 路由) |
 | `inkos config remove-model <agent>` | 移除 agent 模型覆盖(回退到默认) |
 | `inkos config remove-model <agent>` | 移除 agent 模型覆盖(回退到默认) |
 | `inkos config show-models` | 查看当前模型路由 |
 | `inkos config show-models` | 查看当前模型路由 |
-| `inkos doctor` | 诊断配置问题(含 API 连通性测试 + 提供商兼容性提示) |
+| `inkos doctor` | 诊断配置问题(显示 effective config mode、来源、API 连通性和提供商兼容性提示) |
 | `inkos detect [id] [n]` | AIGC 检测(`--all` 全部章节,`--stats` 统计) |
 | `inkos detect [id] [n]` | AIGC 检测(`--all` 全部章节,`--stats` 统计) |
 | `inkos style analyze <file>` | 分析参考文本提取文风指纹 |
 | `inkos style analyze <file>` | 分析参考文本提取文风指纹 |
 | `inkos style import <file> [id]` | 导入文风指纹到指定书 |
 | `inkos style import <file> [id]` | 导入文风指纹到指定书 |
@@ -391,11 +460,18 @@ inkos agent "先扫描市场趋势,然后根据结果创建一本新书"
 | `inkos import chapters [id] --from <path>` | 导入已有章节续写(`--split`、`--resume-from`) |
 | `inkos import chapters [id] --from <path>` | 导入已有章节续写(`--split`、`--resume-from`) |
 | `inkos analytics [id]` / `inkos stats [id]` | 书籍数据分析(审计通过率、高频问题、章节排名、token 用量) |
 | `inkos analytics [id]` / `inkos stats [id]` | 书籍数据分析(审计通过率、高频问题、章节排名、token 用量) |
 | `inkos update` | 更新到最新版本 |
 | `inkos update` | 更新到最新版本 |
-| `inkos studio` | 启动 Web 工作台(`-p` 指定端口,默认 4567) |
+| `inkos studio` / `inkos` | 启动 Web 工作台(`-p` 指定端口,默认 4567;Studio 使用服务页配置,不使用 env 覆盖) |
 | `inkos up / down` | 启动/停止守护进程(`-q` 静默模式,自动写入 `inkos.log`) |
 | `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 之前先检查输入治理结果。
 `[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` 启动
 - [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,
+    });
+  });
+});

+ 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");
       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 {
       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 {
       } 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 apiKeyOptional = isApiKeyOptionalForEndpoint({ provider, baseUrl });
       const hasKey = apiKeyOptional || (!!apiKey && apiKey.length > 10 && apiKey !== "your-api-key-here");
       const hasKey = apiKeyOptional || (!!apiKey && apiKey.length > 10 && apiKey !== "your-api-key-here");
       checks.push({
       checks.push({
@@ -180,7 +184,7 @@ export const doctorCommand = new Command("doctor")
           ? "Optional for local/self-hosted endpoint"
           ? "Optional for local/self-hosted endpoint"
           : hasKey
           : hasKey
             ? "Configured"
             ? "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",
       });
       });
     }
     }
 
 

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

@@ -47,6 +47,13 @@ export function createProgram(hooks: ProgramHooks = {}): Command {
     .name("inkos")
     .name("inkos")
     .description("InkOS — Multi-agent novel production system")
     .description("InkOS — Multi-agent novel production system")
     .version(version)
     .version(version)
+    .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 () => {
     .action(async () => {
       await launchStudioEntry(process.cwd(), "4567", { launchStudio: hooks.launchStudio });
       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 { readFile, stat } from "node:fs/promises";
 import { join, resolve } from "node:path";
 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";
 import { formatSqliteMemorySupportWarning } from "./runtime-requirements.js";
 
 
 export { GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH };
 export { GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH };
@@ -31,14 +31,80 @@ export function findProjectRoot(): string {
   return process.cwd();
   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) {
 export function createClient(config: ProjectConfig) {
   return createLLMClient(config.llm);
   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(
 export function buildPipelineConfig(
   config: ProjectConfig,
   config: ProjectConfig,
   root: string,
   root: string,

+ 6 - 2
packages/core/src/__tests__/config-loader.test.ts

@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";
 import { loadProjectConfig } from "../utils/config-loader.js";
 import { loadProjectConfig } from "../utils/config-loader.js";
 
 
 const ENV_KEYS = [
 const ENV_KEYS = [
+  "INKOS_LLM_SERVICE",
   "INKOS_LLM_PROVIDER",
   "INKOS_LLM_PROVIDER",
   "INKOS_LLM_BASE_URL",
   "INKOS_LLM_BASE_URL",
   "INKOS_LLM_MODEL",
   "INKOS_LLM_MODEL",
@@ -12,6 +13,9 @@ const ENV_KEYS = [
   "INKOS_LLM_TEMPERATURE",
   "INKOS_LLM_TEMPERATURE",
   "INKOS_LLM_THINKING_BUDGET",
   "INKOS_LLM_THINKING_BUDGET",
   "INKOS_LLM_API_FORMAT",
   "INKOS_LLM_API_FORMAT",
+  "INKOS_LLM_STREAM",
+  "INKOS_LLM_EXTRA_top_p",
+  "INKOS_DEFAULT_LANGUAGE",
 ] as const;
 ] as const;
 
 
 describe("loadProjectConfig local provider auth", () => {
 describe("loadProjectConfig local provider auth", () => {
@@ -103,7 +107,7 @@ describe("loadProjectConfig local provider auth", () => {
       "utf-8",
       "utf-8",
     );
     );
 
 
-    const config = await loadProjectConfig(root);
+    const config = await loadProjectConfig(root, { consumer: "studio" });
 
 
     expect(config.llm.service).toBe("moonshot");
     expect(config.llm.service).toBe("moonshot");
     expect(config.llm.provider).toBe("openai");
     expect(config.llm.provider).toBe("openai");
@@ -271,7 +275,7 @@ describe("loadProjectConfig local provider auth", () => {
       "utf-8",
       "utf-8",
     );
     );
 
 
-    const config = await loadProjectConfig(root, { requireApiKey: false });
+    const config = await loadProjectConfig(root, { consumer: "studio", requireApiKey: false });
 
 
     expect(config.llm.configSource).toBe("studio");
     expect(config.llm.configSource).toBe("studio");
     expect(config.llm.service).toBe("google");
     expect(config.llm.service).toBe("google");

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

@@ -0,0 +1,322 @@
+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("保留旧 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);
+  });
+});

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

@@ -229,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 { 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 { 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 { 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 { computeAnalytics, type AnalyticsData, type TokenStats } from "./utils/analytics.js";
 export {
 export {
   collectStaleHookDebt,
   collectStaleHookDebt,

+ 12 - 4
packages/core/src/llm/provider.ts

@@ -155,10 +155,7 @@ export function createLLMClient(config: LLMConfig): LLMClient {
   const piApi = resolvePiApi(serviceName, config.apiFormat, (inkosProvider?.api ?? preset?.api) as PiApi) as PiApi;
   const piApi = resolvePiApi(serviceName, config.apiFormat, (inkosProvider?.api ?? preset?.api) as PiApi) as PiApi;
   const baseUrl = config.baseUrl || inkosProvider?.baseUrl || preset?.baseUrl || "";
   const baseUrl = config.baseUrl || inkosProvider?.baseUrl || preset?.baseUrl || "";
   const extraHeaders = config.headers ?? parseEnvHeaders();
   const extraHeaders = config.headers ?? parseEnvHeaders();
-  const compat = piApi === "openai-completions"
-    && (inkosProvider?.id === "google" || baseUrl.includes("generativelanguage.googleapis.com"))
-    ? { supportsStore: false }
-    : undefined;
+  const compat = resolveProviderCompat(inkosProvider, baseUrl);
 
 
   const provider = config.provider === "anthropic" ? "anthropic" : "openai";
   const provider = config.provider === "anthropic" ? "anthropic" : "openai";
   // pi-ai provider 字段:大多数情况 pi-ai 会按 baseUrl 自动嗅探(openrouter.ai / api.z.ai /
   // pi-ai provider 字段:大多数情况 pi-ai 会按 baseUrl 自动嗅探(openrouter.ai / api.z.ai /
@@ -212,6 +209,17 @@ function resolvePiApi(
   return (presetApi ?? "openai-completions") as PiApi;
   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 {
 function parseEnvHeaders(): Record<string, string> | undefined {
   const raw = process.env.INKOS_LLM_HEADERS;
   const raw = process.env.INKOS_LLM_HEADERS;
   if (!raw) return undefined;
   if (!raw) return undefined;

+ 4 - 3
packages/core/src/llm/providers/endpoints/google.ts

@@ -16,6 +16,7 @@ export const GOOGLE: InkosEndpoint = {
   api: "openai-completions",
   api: "openai-completions",
   baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
   baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai",
   checkModel: "gemini-2.5-flash",
   checkModel: "gemini-2.5-flash",
+  compat: { supportsStore: false },
   temperatureRange: [0, 2],
   temperatureRange: [0, 2],
   defaultTemperature: 1,
   defaultTemperature: 1,
   writingTemperature: 1,
   writingTemperature: 1,
@@ -23,14 +24,14 @@ export const GOOGLE: InkosEndpoint = {
     { id: "gemini-pro-latest", maxOutput: 65536, contextWindowTokens: 1114112 },
     { id: "gemini-pro-latest", maxOutput: 65536, contextWindowTokens: 1114112 },
     { id: "gemini-flash-latest", maxOutput: 65536, contextWindowTokens: 1114112 },
     { id: "gemini-flash-latest", maxOutput: 65536, contextWindowTokens: 1114112 },
     { id: "gemini-flash-lite-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, releasedAt: "2026-02-26" },
+    { 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-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.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-flash-preview", maxOutput: 65536, contextWindowTokens: 1114112, enabled: true, releasedAt: "2025-12-17" },
-    { id: "gemini-3-pro-image-preview", maxOutput: 32768, contextWindowTokens: 163840, enabled: false, releasedAt: "2025-11-20" },
+    { 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-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", maxOutput: 65536, contextWindowTokens: 1114112, releasedAt: "2025-06-17" },
-    { id: "gemini-2.5-flash-image", maxOutput: 32768, contextWindowTokens: 98304, enabled: false, releasedAt: "2025-08-26" },
+    { 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", 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.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", maxOutput: 8192, contextWindowTokens: 1056768, releasedAt: "2025-02-05" },

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

@@ -19,6 +19,7 @@ export const MINIMAX: InkosEndpoint = {
   api: "anthropic-messages",
   api: "anthropic-messages",
   baseUrl: "https://api.minimaxi.com/anthropic",
   baseUrl: "https://api.minimaxi.com/anthropic",
   checkModel: "MiniMax-M2.7",
   checkModel: "MiniMax-M2.7",
+  transportDefaults: { stream: false },
   temperatureRange: [0, 1],
   temperatureRange: [0, 1],
   defaultTemperature: 0.9,
   defaultTemperature: 0.9,
   writingTemperature: 0.9,
   writingTemperature: 0.9,

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

@@ -13,6 +13,7 @@ export const MINIMAX_CODING_PLAN: InkosEndpoint = {
   api: "anthropic-messages",
   api: "anthropic-messages",
   baseUrl: "https://api.minimaxi.com/anthropic",
   baseUrl: "https://api.minimaxi.com/anthropic",
   checkModel: "MiniMax-M2.7",
   checkModel: "MiniMax-M2.7",
+  transportDefaults: { stream: false },
   temperatureRange: [0, 2],
   temperatureRange: [0, 2],
   defaultTemperature: 0.9,
   defaultTemperature: 0.9,
   writingTemperature: 0.9,
   writingTemperature: 0.9,

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

@@ -54,3 +54,17 @@ export function listEnabledModels(serviceId: string): InkosModel[] {
   if (!provider) return [];
   if (!provider) return [];
   return provider.models.filter((m) => m.enabled !== false);
   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);
+}

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

@@ -37,6 +37,28 @@ export interface InkosModel {
    * 不是"推荐值"。普通模型不要设这个字段。
    * 不是"推荐值"。普通模型不要设这个字段。
    */
    */
   readonly temperature?: number;
   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 {
 export interface InkosEndpoint {
@@ -57,6 +79,8 @@ export interface InkosEndpoint {
   readonly defaultTemperature?: number;
   readonly defaultTemperature?: number;
   readonly writingTemperature?: number;
   readonly writingTemperature?: number;
   readonly temperatureHint?: string;
   readonly temperatureHint?: string;
+  readonly compat?: ProviderCompat;
+  readonly transportDefaults?: ProviderTransportDefaults;
 
 
   readonly models: readonly InkosModel[];
   readonly models: readonly InkosModel[];
 }
 }

+ 8 - 2
packages/core/src/llm/providers/verify.ts

@@ -2,6 +2,10 @@ import { getEndpoint } from "./index.js";
 import { resolveServiceModelsBaseUrl } from "../service-presets.js";
 import { resolveServiceModelsBaseUrl } from "../service-presets.js";
 
 
 export interface VerifyResult {
 export interface VerifyResult {
+  readonly recommendedTransport?: {
+    readonly apiFormat?: "chat" | "responses";
+    readonly stream?: boolean;
+  };
   readonly probe: {
   readonly probe: {
     readonly ok: boolean;
     readonly ok: boolean;
     readonly models: number;
     readonly models: number;
@@ -62,9 +66,10 @@ export async function verifyService(
   const probeResult = await probe(service, apiKey, opts?.baseUrl);
   const probeResult = await probe(service, apiKey, opts?.baseUrl);
 
 
   const provider = getEndpoint(service);
   const provider = getEndpoint(service);
+  const recommendedTransport = provider?.transportDefaults;
   const checkModel = opts?.checkModel ?? provider?.checkModel;
   const checkModel = opts?.checkModel ?? provider?.checkModel;
   if (!checkModel) {
   if (!checkModel) {
-    return { probe: probeResult, chat: null };
+    return { recommendedTransport, probe: probeResult, chat: null };
   }
   }
 
 
   const start = Date.now();
   const start = Date.now();
@@ -81,9 +86,10 @@ export async function verifyService(
       stream: false,
       stream: false,
     }));
     }));
     await chatCompletion(client, checkModel, [{ role: "user", content: "hi" }], { maxTokens: 10 });
     await chatCompletion(client, checkModel, [{ role: "user", content: "hi" }], { maxTokens: 10 });
-    return { probe: probeResult, chat: { ok: true, latencyMs: Date.now() - start } };
+    return { recommendedTransport, probe: probeResult, chat: { ok: true, latencyMs: Date.now() - start } };
   } catch (error) {
   } catch (error) {
     return {
     return {
+      recommendedTransport,
       probe: probeResult,
       probe: probeResult,
       chat: {
       chat: {
         ok: false,
         ok: false,

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

@@ -1,332 +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 { getEndpoint } from "../llm/providers/index.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(
 export async function loadProjectConfig(
   root: string,
   root: string,
-  options?: { readonly requireApiKey?: boolean },
+  options?: {
+    readonly requireApiKey?: boolean;
+    readonly cli?: LLMConfigCliOverrides;
+    readonly consumer?: LLMConsumer;
+  },
 ): Promise<ProjectConfig> {
 ): 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 (configSource === "studio") {
-        llm.model = resolveStudioServiceModel(
-          selectedEntry,
-          typeof llm.model === "string" ? llm.model : undefined,
-          typeof llm.defaultModel === "string" ? llm.defaultModel : undefined,
-        );
-      } else if (!(typeof llm.model === "string" && llm.model.length > 0) && typeof llm.defaultModel === "string" && llm.defaultModel.length > 0) {
-        llm.model = llm.defaultModel;
-      }
-
-      if (configSource === "studio") {
-        llm.baseUrl = selectedEntry.baseUrl ?? resolveServicePreset(selectedEntry.service)?.baseUrl ?? "";
-      } else if (!(typeof llm.baseUrl === "string" && llm.baseUrl.length > 0)) {
-        llm.baseUrl = selectedEntry.baseUrl ?? resolveServicePreset(selectedEntry.service)?.baseUrl ?? "";
-      }
-
-      if (configSource === "studio") {
-        llm.provider = deriveProviderFromService(selectedEntry.service);
-      } else 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);
-    // C3 (v2.0.0): 删除 INKOS_LLM_MAX_TOKENS 读取 — maxTokens 已由 providers bank 接管。
-    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 resolveStudioServiceModel(
-  entry: ServiceConfigEntry,
-  currentModel: string | undefined,
-  defaultModel: string | undefined,
-): string {
-  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
-      && endpoint?.models.some((knownModel) => knownModel.enabled !== false && knownModel.id.toLowerCase() === model.toLowerCase()),
-    ));
-  if (candidate) return candidate;
-
-  return endpoint?.checkModel
-    ?? endpoint?.models.find((model) => model.enabled !== false)?.id
-    ?? defaultModel
-    ?? currentModel
-    ?? "noop-model";
-}
-
-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;
 }
 }

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

@@ -0,0 +1,512 @@
+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) {
+    llm.service = input.cli.service;
+    diagnostics.serviceSource = "cli";
+  }
+  if (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);
+}
+
+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;
+}

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

@@ -0,0 +1,56 @@
+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> {
+  return {
+    global: await parseEnvFile(GLOBAL_ENV_PATH),
+    project: await parseEnvFile(join(root, ".env")),
+    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 {};
+  }
+}

+ 21 - 4
packages/studio/src/api/server.test.ts

@@ -801,7 +801,7 @@ describe("createStudioServer daemon lifecycle", () => {
   });
   });
 
 
   it("filters non-text models out of live service model lists", async () => {
   it("filters non-text models out of live service model lists", async () => {
-    getServiceApiKeyMock.mockResolvedValue("sk-google");
+    loadSecretsMock.mockResolvedValue({ services: { google: { apiKey: "sk-google" } } });
     listModelsForServiceMock.mockResolvedValueOnce([
     listModelsForServiceMock.mockResolvedValueOnce([
       { id: "gemini-2.5-flash", name: "gemini-2.5-flash", reasoning: false, contextWindow: 1114112 },
       { 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: "gemini-3.1-flash-image-preview", name: "gemini-3.1-flash-image-preview", reasoning: false, contextWindow: 163840 },
@@ -885,9 +885,11 @@ describe("createStudioServer daemon lifecycle", () => {
     const response = await app.request("http://localhost/api/v1/services/config");
     const response = await app.request("http://localhost/api/v1/services/config");
     expect(response.status).toBe(200);
     expect(response.status).toBe(200);
     await expect(response.json()).resolves.toMatchObject({
     await expect(response.json()).resolves.toMatchObject({
-      configSource: "env",
+      configSource: "studio",
+      storedConfigSource: "env",
       envConfig: {
       envConfig: {
         effectiveSource: "project",
         effectiveSource: "project",
+        runtimeUsesEnv: false,
         project: {
         project: {
           detected: true,
           detected: true,
           baseUrl: "https://project.example.com/v1",
           baseUrl: "https://project.example.com/v1",
@@ -935,6 +937,22 @@ describe("createStudioServer daemon lifecycle", () => {
     expect(raw.llm.defaultModel).toBe("kimi-k2.5");
     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 () => {
   it("tests and lists models for custom services using baseUrl and stored config", async () => {
     await writeFile(join(root, "inkos.json"), JSON.stringify({
     await writeFile(join(root, "inkos.json"), JSON.stringify({
       ...projectConfig,
       ...projectConfig,
@@ -950,7 +968,6 @@ describe("createStudioServer daemon lifecycle", () => {
         "custom:内网GPT": { apiKey: "sk-corp" },
         "custom:内网GPT": { apiKey: "sk-corp" },
       },
       },
     });
     });
-    getServiceApiKeyMock.mockResolvedValue("sk-corp");
 
 
     const fetchMock = vi.fn()
     const fetchMock = vi.fn()
       .mockResolvedValueOnce({
       .mockResolvedValueOnce({
@@ -1315,7 +1332,7 @@ describe("createStudioServer daemon lifecycle", () => {
         defaultModel: "qwen-max",
         defaultModel: "qwen-max",
       },
       },
     }, null, 2), "utf-8");
     }, null, 2), "utf-8");
-    getServiceApiKeyMock.mockResolvedValue("sk-bailian");
+    loadSecretsMock.mockResolvedValue({ services: { bailian: { apiKey: "sk-bailian" } } });
 
 
     const fetchMock = vi.fn(async (input: string | URL | Request) => {
     const fetchMock = vi.fn(async (input: string | URL | Request) => {
       const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
       const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;

+ 13 - 5
packages/studio/src/api/server.ts

@@ -30,7 +30,6 @@ import {
   resolveServiceModel,
   resolveServiceModel,
   loadSecrets,
   loadSecrets,
   saveSecrets,
   saveSecrets,
-  getServiceApiKey,
   listModelsForService,
   listModelsForService,
   getAllEndpoints,
   getAllEndpoints,
   probeModelsFromUpstream,
   probeModelsFromUpstream,
@@ -175,6 +174,7 @@ interface EnvConfigStatus {
   project: EnvConfigSummary;
   project: EnvConfigSummary;
   global: EnvConfigSummary;
   global: EnvConfigSummary;
   effectiveSource: "project" | "global" | null;
   effectiveSource: "project" | "global" | null;
+  runtimeUsesEnv: false;
 }
 }
 
 
 interface ServiceProbeResult {
 interface ServiceProbeResult {
@@ -323,6 +323,7 @@ async function readEnvConfigStatus(root: string): Promise<EnvConfigStatus> {
     project,
     project,
     global,
     global,
     effectiveSource: project.detected ? "project" : global.detected ? "global" : null,
     effectiveSource: project.detected ? "project" : global.detected ? "global" : null,
+    runtimeUsesEnv: false,
   };
   };
 }
 }
 
 
@@ -669,7 +670,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   async function loadCurrentProjectConfig(
   async function loadCurrentProjectConfig(
     options?: { readonly requireApiKey?: boolean },
     options?: { readonly requireApiKey?: boolean },
   ): Promise<ProjectConfig> {
   ): Promise<ProjectConfig> {
-    const freshConfig = await loadProjectConfig(root, options);
+    const freshConfig = await loadProjectConfig(root, { ...options, consumer: "studio" });
     cachedConfig = freshConfig;
     cachedConfig = freshConfig;
     return freshConfig;
     return freshConfig;
   }
   }
@@ -1058,7 +1059,8 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     return c.json({
     return c.json({
       services,
       services,
       defaultModel: llm.defaultModel ?? null,
       defaultModel: llm.defaultModel ?? null,
-      configSource: normalizeConfigSource(llm.configSource),
+      configSource: "studio" satisfies LLMConfigSource,
+      storedConfigSource: normalizeConfigSource(llm.configSource),
       envConfig,
       envConfig,
     });
     });
   });
   });
@@ -1076,6 +1078,11 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
     if (body.defaultModel !== undefined) {
     if (body.defaultModel !== undefined) {
       llm.defaultModel = body.defaultModel;
       llm.defaultModel = body.defaultModel;
     }
     }
+    if (body.configSource === "env") {
+      return c.json({
+        error: "Studio 运行时不支持切换到 env;env 只在 CLI/daemon/部署运行时作为覆盖层使用。",
+      }, 400);
+    }
     if (body.configSource !== undefined) {
     if (body.configSource !== undefined) {
       llm.configSource = normalizeConfigSource(body.configSource);
       llm.configSource = normalizeConfigSource(body.configSource);
     }
     }
@@ -1221,7 +1228,8 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string) {
   app.get("/api/v1/services/:service/models", async (c) => {
   app.get("/api/v1/services/:service/models", async (c) => {
     const service = c.req.param("service");
     const service = c.req.param("service");
     const refresh = c.req.query("refresh") === "1";
     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
     // No key = no models
     if (!apiKey) return c.json({ models: [] });
     if (!apiKey) return c.json({ models: [] });
@@ -2524,7 +2532,7 @@ export async function startStudioServer(
   port = 4567,
   port = 4567,
   options?: { readonly staticDir?: string },
   options?: { readonly staticDir?: string },
 ): Promise<void> {
 ): Promise<void> {
-  const config = await loadProjectConfig(root, { requireApiKey: false });
+  const config = await loadProjectConfig(root, { consumer: "studio", requireApiKey: false });
 
 
   const app = createStudioServer(config, root);
   const app = createStudioServer(config, root);
 
 

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

@@ -16,10 +16,12 @@ interface ServiceConfigPayload {
   services: Array<Record<string, unknown>>;
   services: Array<Record<string, unknown>>;
   defaultModel: string | null;
   defaultModel: string | null;
   configSource: ConfigSource;
   configSource: ConfigSource;
+  storedConfigSource?: ConfigSource;
   envConfig: {
   envConfig: {
     project: EnvConfigSummary;
     project: EnvConfigSummary;
     global: EnvConfigSummary;
     global: EnvConfigSummary;
     effectiveSource: EnvScope;
     effectiveSource: EnvScope;
+    runtimeUsesEnv: boolean;
   };
   };
 }
 }
 
 
@@ -76,6 +78,7 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
   }
   }
 
 
   const { configSource, envConfig } = data;
   const { configSource, envConfig } = data;
+  const storedConfigSource = data.storedConfigSource ?? configSource;
   const activeEnvSummary = envConfig.effectiveSource === "project" ? envConfig.project : envConfig.global;
   const activeEnvSummary = envConfig.effectiveSource === "project" ? envConfig.project : envConfig.global;
   const envLabel = envConfig.effectiveSource === "project" ? "项目 .env" : envConfig.effectiveSource === "global" ? "全局 ~/.inkos/.env" : null;
   const envLabel = envConfig.effectiveSource === "project" ? "项目 .env" : envConfig.effectiveSource === "global" ? "全局 ~/.inkos/.env" : null;
   const envDetected = envConfig.project.detected || envConfig.global.detected;
   const envDetected = envConfig.project.detected || envConfig.global.detected;
@@ -86,8 +89,8 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
         <div>
         <div>
           <div className="text-sm font-medium">LLM 配置来源</div>
           <div className="text-sm font-medium">LLM 配置来源</div>
           <div className="text-xs text-muted-foreground/70 mt-1">
           <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>
         </div>
         <div className="flex items-center gap-2">
         <div className="flex items-center gap-2">
@@ -99,17 +102,15 @@ export function ServiceConfigSourceCard({ onChange }: { onChange?: () => void })
           >
           >
             {saving === "studio" ? "切换中…" : "使用 Studio 配置"}
             {saving === "studio" ? "切换中…" : "使用 Studio 配置"}
           </button>
           </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>
       </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 ? (
       {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="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">
           <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}
           {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>API Key: <span className="text-foreground">{activeEnvSummary.hasApiKey ? "已设置" : "未设置"}</span></div>
           <div className="text-muted-foreground/70 pt-1">
           <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>
         </div>
         </div>
       ) : (
       ) : (