makecindy / makecindy/cindy

bug: 自定义 Provider 会话的首个 turn 被静默路由到 XD 网关,返回 403 user not allowed to access model

Open
#4,154 2 comments 0 reactions 0 assignees View on GitHub
bug
Dominant language
TypeScript
Stars
2.7k
Forks
401
Avg merge
21h 48m
Merged PRs (30d)
776

Description

## 问题描述 / What happened

**实际行为**

会话显式选定了一个自定义 Provider(runtime = `claude-code`,上游是第三方 Anthropic 兼容中转),新建会话后发出的**第一个 turn** 会概率性地以 `api_error` 结束,报错内容是 Cindy 自家 XD 网关返回的 403:

```
Failed to authenticate. API Error: 403 user not allowed to access model.
This user can only access models=['deepseek/deepseek-v4-pro', 'tencent/hy3', 'cindy/*', ...].
Tried to access claude-opus-5
```

在**同一个会话内**再发一条消息即恢复正常,无需重启会话或改任何设置。

关键点是错误里的白名单 —— 那是 XD 网关的模型列表。也就是说这笔请求本该发往该自定义 Provider 的 `baseUrl`,实际却被发去了 Cindy 网关,用网关 key 请求 `claude-opus-5`,因此被拒。**请求被静默路由到了非预期上游。**

**期望行为**

会话已显式选定 Provider 时,路由要么正确指向该 Provider 的上游,要么 fail-closed 报出可辨识的错误(如已有的 `provider_route_updating` 503)。不应该回落到默认网关。

## 环境 / Environment

- Cindy 版本或 commit / version or commit: 0.1.73(darwin-arm64 hotfix);代码引用对应 `main` @ `dacc91156`
- 平台与版本 / platform & OS version: macOS 15(Darwin 25.6.0),arm64
- 安装方式 / install method: 官方安装包 + 自动 hotfix 更新

## 复现步骤 / Steps to reproduce

1. 新建一个自定义 Provider,`runtimes["claude-code"].baseUrl` 指向第三方 Anthropic 兼容中转,模型列表含 `claude-opus-5`,并在设置里填好该 Provider 的 API key。
2. 新建会话,选中这个 Provider + `claude-opus-5`。
3. 会话打开后立即发送第一条消息。
4. 约 3 秒后该 turn 以 `api_error` 结束,报上述 403。
5. 紧接着再发一条消息 → 正常。

竞态性质,非 100% 复现。观测到当天 5 个使用该 Provider 的会话中 4 个在首个 turn 命中。

## 日志与截图 / Logs & screenshots

(已脱敏:真实中转域名与 sessionId 已替换)

```
10:23:28 startSession
model : claude-opus-5[1m]
providerId : claude-code ← 自定义 Provider 的 id
credentialMode : provider-oauth
endpoint : http://127.0.0.1:50991
10:23:31 SDK ◀ turn ended with error
terminalReason : api_error
durationMs : 1068
output : Failed to authenticate. API Error: 403 user not allowed to
access model. This user can only access models=[...XD 网关模型...].
Tried to access claude-opus-5
```

对照:同期所有 `providerId: anthropic` 的会话 0 失败;失败全部集中在 `providerId` 为自定义 Provider 的会话的**首个 turn**,且都在 `startSession` 后 3 秒左右。

## 根因分析 / Root cause

`apps/desktop/src/main/maker-host/session-provider-store.ts:33-36`:

```ts
/** 读取某会话的供应商;未设置或已清除返回 null(调用方据此走默认路由)。 */
export function getSessionProvider(sessionId: string): string | null {
return bySession.get(sessionId) ?? null;
}
```

`bySession` 是纯内存 Map,只由 `SET_MODEL` 与 `hydrateSessionProvider` 填充。**「尚未登记」与「显式无 Provider」被压成同一个 `null`**,而该模块的文档注释(第 7-10 行)明确定义 `null` 的语义是「走默认路由」。

路由热路径 `apps/desktop/src/main/maker-host/provider-route.ts:816-823`:

```ts
export function resolveSessionRouteDecision(
sessionId: string, agent: AgentKind, gatewayKey: string | null, wireModel?: string,
): RoutingDecision | null | Promise {
const providerId = getSessionProvider(sessionId);
const pendingDecision = resolvePendingSessionRouteDecision(sessionId, wireModel);
if (!providerId) return pendingDecision; // → null → 回落默认路由 = XD 网关
```

于是:spawn 出来的 claude CLI 子进程的第一个请求若抢在会话 Provider 登记之前到达 loopback proxy,`getSessionProvider` 返回 `null`,代理据此走默认网关路由 → 用网关 key 请求 `claude-opus-5` → 403。重试时 Map 已填好,故恢复。

**这是本文件里唯一 fail-open 的分支**,同文件与相邻路径的其他「路由不确定」情形都是 fail-closed 的:

- `provider-route.ts:207-209` 注释即写明该约束:*「已迁移但无法安全执行的历史路由必须由 proxy 原地拒绝,不能返回 null:null 在两个 proxy host 里表示『未命中』,会继续走默认网关/订阅上游。」*
- `provider-route.ts:825-826`:凭证 mutation 窗口内返回 `updatingProviderRouteDecision()`(503 `provider_route_updating`)而非 `null`。
- `apps/desktop/src/main/maker-host/remote-claude-route.ts:65-84`:远端路径显式区分 `REMOTE_PROVIDER_UPDATING` 与 `REMOTE_PROVIDER_UNSUPPORTED`,从不回落网关。

此外 `session-provider-store.ts:39-41` 已经提供了正是用于区分这两种状态的 `hasSessionProvider()`(注释:「内存里是否已有该会话的来源条目(含显式 null)。未 hydrate 时为 false。」),但路由热路径没有使用它。

## 建议修复 / Suggested fix

**主修(堵在源头)**:`opts.providerId` 在 `startSession` 时已是已知值(`packages/maker-core/src/agents/claude-code/index.ts:1443` 的日志打的就是它)。在 spawn CLI 子进程**之前**同步登记该会话的 Provider,竞态窗口即不存在,且对所有调用方一次性生效。

**兜底(对齐既有语义)**:`resolveSessionRouteDecision` 改用 `hasSessionProvider(sessionId)` 区分两种状态 —— 会话条目尚未 hydrate 时返回 `updatingProviderRouteDecision(...)`(503,语义与 mutation 窗口一致,客户端重试即可),而不是返回 `null` 落入默认网关。

## 影响 / Impact

功能层面不算严重(重试即恢复),但性质上是**请求被静默发往非预期上游**:

- 用户选定的自定义 Provider 被绕过,请求改用 Cindy 网关凭证发出;
- 本例中因网关 key 无权访问 `claude-opus-5` 而「恰好」报错暴露。若网关侧存在同名可访问模型,这次误路由将不报错,而是安静地跑在错误的上游与错误的计费归属上;
- 会话显式选定 Provider 时静默回落到默认路由,与 `provider-route.ts:207-209` 自述的安全约束相悖。

Contributor guide

Open the contributing guide

Research direction

Start with apps/desktop/src/main/maker-host/session-provider-store.ts and provider-route.ts, then trace the startSession entry point in packages/maker-core/src/agents/claude-code/index.ts before the CLI is spawned. Verify that the first turn for an explicitly selected custom Provider cannot fall back to the default gateway, and that an unresolved route produces the existing provider_route_updating 503 behavior.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.