Fix DeepSeek SDK model metadata resolution

This commit is contained in:
Dominic Cooney
2026-05-18 08:23:22 -07:00
committed by Max Paulus 🥪
parent 6507a1786b
commit 4191d64162
21 changed files with 1431 additions and 23 deletions
+1
View File
@@ -8,5 +8,6 @@ Read in this order:
2. **[architecture.md](./architecture.md)** — *What shape*. The two abstractions (`ProviderConfigStore`, `ProviderCatalog`), invariants, dependency graph, structural impossibilities, scenario walkthroughs.
3. **[implementation-plan.md](./implementation-plan.md)** — *What to do*. Twelve phases, twelve checkpoints, exit criteria, offramps.
4. **[agent-guidance.md](./agent-guidance.md)** — *How to steer agents*. Succinct prompting and per-model observations from teammate experiments.
5. **[context-window-display-fix-plan.md](./context-window-display-fix-plan.md)** — *Targeted fix*. Plan for repairing the chat status bar's incorrect context window for migrated providers and the (provider, modelId) tearing on switch/commit. Four host-then-webview steps.
The contracts these docs describe live at [`src/sdk/model-catalog/contracts.ts`](../../src/sdk/model-catalog/contracts.ts).
@@ -0,0 +1,685 @@
# Context Window Display Fix — Plan
> **Companion docs:** `design.md`, `architecture.md`, `implementation-plan.md`,
> `agent-guidance.md` in this directory. This document is the work plan for
> one bug, written so a fresh implementer (or reviewing agent) can carry it
> out without the conversation history that produced it.
## Problem statement
The chat status bar shows an incorrect context window for DeepSeek (128K)
when the DeepSeek picker correctly shows 1M for 1M-context models like
`deepseek-v4-pro`. Two related symptoms compound the bug:
1. **Stale 128K fallback.** For the `deepseek` provider,
`webview-ui/src/components/settings/utils/providerUtils.ts`'s
`normalizeApiConfiguration` returns `openAiModelInfoSafeDefaults` (defined
in `src/shared/api.ts:1453`), whose `contextWindow` is hardcoded to
`128_000`. The chat header reads this and displays 128K regardless of
the actually-selected DeepSeek model.
2. **Cross-surface tearing after provider switch.** Switching the provider
updates `apiConfiguration.actModeApiProvider` but leaves
`apiConfiguration.actModeApiModelId` set to the *previous* provider's
model id. The chat textarea renders `selectedProvider:selectedModelId`,
producing nonsense like `deepseek:claude-sonnet-4-5-20250929`.
3. **Cross-surface tearing after picker commit.** When the DeepSeek picker
calls `commitSelection`, it writes to the in-memory
`ProviderConfigStore` but does **not** update
`apiConfiguration.actModeApiModelId`. The chat surfaces (which read from
`apiConfiguration`) and the picker (which reads the selection envelope)
drift out of sync.
## Why the previous attempts failed
A prior fix attempt that threaded a "migrated provider configs" map through
React props had the right idea (read `ModelInfo` from the SDK-backed
selection envelope, not the hardcoded default) but was reverted because:
- It propagated the underlying `apiConfiguration` incoherence into the chat
surfaces (the `deepseek:claude-sonnet-4-5-20250929` bug became more
visible, not less).
- It produced cross-surface staleness after picker commits because each
`useProviderConfig` consumer had its own `useState` cache and there was
no way to invalidate the chat header's copy from a settings-panel commit.
- An earlier attempt to share state across consumers tried introducing
`useProviderModels` into the chat path, which triggered a model-list
refresh (`resolveProviderModels` with `forceRefresh: true`). This caused
a second regression where the DeepSeek picker only displayed
`deepseek-chat` and `deepseek-reasoner` instead of the SDK catalog's
4-model list. Likely cause: a stray `useProviderModels` call from chat
triggered a model-list resolve before the provider config had loaded,
which hit a fallback path returning the live API's 2-model list, and
that response overwrote the SDK-generated 4-model state via the apply
rule.
## Architectural background
Two operating principles from `architecture.md` are load-bearing here:
- **Principle 2:** *"The picker is the only careful thing. All freshness,
race, and fingerprint machinery lives in the picker path."*
- **Principle 4:** *"`ModelInfo` is part of the selection envelope. When
the user picks a model, `{modelId, modelInfo}` are committed together.
The stored snapshot wins over later SDK catalog changes."*
The consequence for chat/status display: it must read `ModelInfo`
derivable from a coherent `(providerId, modelId)` pair, and it must
**not** call `useProviderModels` / `resolveProviderModels` — those belong
to the picker path and have side effects (model-list cache mutation,
apply rule).
## Why this fix does not need a shared React store
A previous version of this plan proposed converting `useProviderConfig`
into a `useSyncExternalStore`-backed shared cache to fix the
picker-doesn't-update-chat tearing. That was an unnecessary detour.
`apiConfiguration` is *already* a globally-broadcast state object
reachable from `useExtensionState`. Every webview surface re-renders when
it changes. If we make `(provider, modelId)` in `apiConfiguration` the
single source of truth for "which model is selected," and resolve
`ModelInfo` from those two fields via a pure host RPC, then:
- The picker re-renders when `apiConfiguration` updates.
- The chat re-renders when `apiConfiguration` updates.
- Any path that wants to change the selection (picker commit, provider
switch) has exactly one obligation: write both `actModeApiModelId` and
the selection envelope coherently.
No external store, no subscription, no `useSyncExternalStore`. The host
becomes the source of truth; the webview is a pure function of it.
## Goals
1. The chat status bar shows the correct context window for the active
`(provider, modelId)` pair on:
- extension startup,
- provider switch with no picker interaction,
- picker commit,
- cross-surface (settings panel commit reflected in chat header).
2. No "tearing" — `(provider, modelId)` is always coherent. The chat
textarea never shows `deepseek:claude-sonnet-4-5-20250929`.
3. The model-list cache is never mutated by status-display code. The
`useProviderModels` hook is never called from chat surfaces.
4. The fix is independently verifiable per step in the debug harness.
## Non-goals (deliberately out of scope)
- Migrating providers beyond DeepSeek. The fix must generalize, but only
one provider (`deepseek`) is in `MIGRATED_PROVIDERS` for now.
- Cross-window or cross-process state synchronization (e.g. JetBrains
plugin ↔ VSCode extension). The current bug is intra-window.
- A subscription/streaming RPC for provider config changes. The
`apiConfiguration` broadcast already provides the trigger we need.
- Eager-commit semantics that would *write* a default selection envelope
to the store on provider switch. The host can answer "what's the
default ModelInfo for `deepseek`?" on demand without writing anything
to the store.
## Key primitives already in place
These already exist; the plan reuses them. Do **not** re-derive or
duplicate:
- `@clinebot/llms` exports `MODEL_COLLECTIONS_BY_PROVIDER_ID:
Record<string, ModelCollection>`. This is a static, in-memory constant
built once at module load.
`MODEL_COLLECTIONS_BY_PROVIDER_ID["deepseek"]?.models["deepseek-v4-pro"]`
is a synchronous, side-effect-free lookup. No network. No cache
mutation. No apply rule.
- `src/sdk/model-catalog/shape-adapter.ts` exports `adaptSdkModelInfo`
which converts an SDK `ModelInfo` into the extension's `ModelInfo`
shape.
- `ProviderConfigStore` (from `src/sdk/model-catalog/store.ts`) exposes
`readSelection(providerId, mode)` which returns the committed
`{providerId, modelId, modelInfo}` triple or `undefined`. Pure read.
- The `readProviderConfig` RPC is already side-effect-free.
- The webview's `useExtensionState` hook already broadcasts
`apiConfiguration` changes to all consumers via React context.
Mutations to `apiConfiguration` on the host propagate to every mounted
component automatically.
## Plan
Four steps. Each step is independently verifiable; do not start the next
step until the previous one's exit criteria are met. The work is
host-heavy; the webview side is small at the end.
---
### Step 1 — Host: add a pure `resolveModelInfo` RPC
**Goal:** Provide the webview a side-effect-free way to ask "for this
`(providerId, modelId)`, what is the `ModelInfo`?" with a well-defined
fallback when `modelId` is omitted or unknown.
**Files to add/modify:**
- `proto/cline/models.proto`: add a new RPC.
- `src/core/controller/models/resolveModelInfo.ts`: new handler.
- `src/core/controller/models/__tests__/resolveModelInfo.test.ts`: new
test file.
**Proto shape:**
```proto
service ModelsService {
// ... existing RPCs
// Resolve ModelInfo for (providerId, modelId). Pure read; no
// model-list refresh, no cache mutation. modelId may be omitted to
// request the provider's SDK default model.
rpc resolveModelInfo(ResolveModelInfoRequest) returns (ResolveModelInfoResponse);
}
message ResolveModelInfoRequest {
string provider_id = 1;
optional string model_id = 2;
}
message ResolveModelInfoResponse {
string provider_id = 1;
// The resolved model id. Equals request.model_id when it exists in
// the catalog or committed envelope; otherwise the SDK provider's
// default model id; otherwise empty.
string model_id = 2;
// Empty when source == "unknown".
optional OpenRouterModelInfo model_info = 3;
// Resolution source. Tells callers which branch produced the answer
// so they can degrade gracefully (e.g. show "unknown" rather than
// 128K) when the catalog can't answer.
string source = 4; // "committed-selection" | "sdk-known-models" | "sdk-default" | "unknown"
}
```
After editing the proto, run `npm run protos`.
**Handler logic (`resolveModelInfo.ts`):**
The handler must be **synchronous in effect** — no `await`, no calls into
any function that performs I/O or mutates a cache. It can be declared
`async` to satisfy the gRPC handler signature.
Resolution order:
1. **`committed-selection`**: If `request.modelId` is non-empty, look up
the committed selection in both modes (`act` first, then `plan`) via
`controller.getProviderConfigStore().readSelection(providerId, mode)`.
If any selection has the matching `modelId`, return that envelope's
`modelInfo` via `toProtobufModelInfo`.
2. **`sdk-known-models`**: If `request.modelId` is non-empty, look up
`MODEL_COLLECTIONS_BY_PROVIDER_ID[providerId]?.models[request.modelId]`.
If found, return `adaptSdkModelInfo(...)` converted to proto.
3. **`sdk-default`**: Read
`MODEL_COLLECTIONS_BY_PROVIDER_ID[providerId]?.provider?.defaultModelId`.
If non-empty and present in `.models`, return that
`(modelId, modelInfo)`.
4. **`unknown`**: Return `{providerId, modelId: request.modelId ?? "",
modelInfo: undefined, source: "unknown"}`.
**Tests (must include all of these):**
- Returns `source: "committed-selection"` and the envelope's modelInfo
when the store has a matching selection.
- Returns `source: "sdk-known-models"` and the SDK's modelInfo for a
model in `MODEL_COLLECTIONS_BY_PROVIDER_ID` that has no committed
selection.
- Returns `source: "sdk-default"` with the provider's default model when
`request.modelId` is omitted.
- Returns `source: "sdk-default"` when `request.modelId` references a
model not in the catalog (provider switch tear case: deepseek + a
claude-only model id should fall through to deepseek's SDK default).
- Returns `source: "unknown"` for a provider id that the SDK doesn't
know.
- **Crucially:** spy on the model-list cache and
`resolveProviderModels`-adjacent paths; assert nothing was called. The
handler must be observably side-effect-free.
- Spy on `store.commitSelection` and any `store.write*` methods; assert
nothing was called. The handler must not mutate the store.
**Exit criteria:**
- `npm run compile` clean.
- New unit tests pass.
- `git grep -n "resolveModelInfo" src/core/controller/models/` shows
handler and tests only — no callers yet.
**Out of scope for this step:**
- Wiring this RPC into any webview code. That happens in step 4.
- Touching `commitModelSelection` or provider-switch behavior.
---
### Step 2 — Host: make `commitModelSelection` also write the legacy `apiConfiguration` modelId field
**Goal:** Eliminate the cross-surface tear after picker commit by ensuring
`apiConfiguration.{plan,act}ModeApiModelId` is updated atomically with the
selection envelope. After this step, picker commits cause
`apiConfiguration` to change, which is the React re-render trigger the
chat surfaces rely on.
**Background:** Today,
`src/core/controller/models/commitModelSelection.ts` only calls
`store.commitSelection(...)`. The legacy field
`apiConfiguration.actModeApiModelId` (defined in
`src/shared/storage/state-keys.ts:192`) is set by separate code paths
(`updateApiConfiguration.ts`, settings panel actions, etc.). The
DeepSeek picker calls `commitSelection` via `useProviderConfig`; it does
NOT call `updateApiConfiguration`, so the legacy field stays stale.
The runtime (`buildApiHandler`) reads `actModeApiModelId` for inference.
If a user commits `deepseek-v4-pro` via the picker, but the legacy field
still says `deepseek-chat`, inference will go against `deepseek-chat`.
This is also a runtime correctness bug, not just a display bug — fixing
it here is independently valuable.
**Files to modify:**
- `src/core/controller/models/commitModelSelection.ts`: extend handler.
- `src/core/controller/models/__tests__/`: extend existing tests, add
new assertions.
**Logic to add:**
After `store.commitSelection(...)`, also update the legacy
`apiConfiguration` fields via the existing `StateManager` / controller
mechanism. Specifically:
- For `mode === "act"`: write `actModeApiModelId = modelId` (and
`actModeApiProvider = providerId` defensively).
- For `mode === "plan"`: write `planModeApiModelId = modelId` (and
`planModeApiProvider = providerId`).
Use the same controller plumbing that `updateApiConfiguration` uses —
find the existing helper for writing modeled config fields, do not
reinvent. Search `src/core/controller/state/updateSettings.ts` and
`src/core/storage/StateManager.ts` for the right write path. The change
must trigger the state broadcast back to the webview the same way other
configuration changes do.
**Tests to update / add:**
- `providerCatalogHandlers.test.ts` (existing): the existing assertion
that `commitSelection` was called must still pass; add an assertion
that the controller's state-write helper was also called with the
legacy fields.
- New test: after `commitModelSelection({providerId: "deepseek", mode:
"act", modelId: "deepseek-v4-pro", modelInfo: ...})`, the next
`getStateToPostToWebview()` (or equivalent broadcast snapshot)
includes `apiConfiguration.actModeApiModelId === "deepseek-v4-pro"`
and `actModeApiProvider === "deepseek"`.
- Round-trip test: commit a selection, then call `buildApiHandler(...)`
(or inspect what would be passed to it) and assert it sees
`deepseek-v4-pro` as the model id, not whatever was in the legacy
field before the commit.
**Exit criteria:**
- `npm run compile` clean.
- All existing `providerCatalog*` tests still pass.
- New round-trip test demonstrates legacy field is updated.
- In the debug harness: open chat with DeepSeek, open picker, pick
`deepseek-v4-pro`, evaluate `apiConfiguration.actModeApiModelId` via
`web.evaluate`; it should equal `"deepseek-v4-pro"`.
**Out of scope:**
- Provider switch normalization (step 3).
- Webview rendering changes (step 4).
---
### Step 3 — Host: normalize `(provider, modelId)` on provider switch
**Goal:** Ensure that switching `apiConfiguration.actModeApiProvider`
from `anthropic` to `deepseek` cannot leave `actModeApiModelId` pointing
at `claude-sonnet-4-5-20250929`. After this step, `(provider, modelId)`
in `apiConfiguration` is always coherent: `modelId` is always either a
committed DeepSeek selection or the SDK default model for DeepSeek.
**Background:** A commit `3cf06f0a1 Normalize DeepSeek provider switch
model` did exactly this for the legacy path. It was reverted by
`d22881d61 Delete legacy DeepSeek provider; SDK catalog is the source
of truth` with the rationale that *"with both flows reading the SDK,
the dropdown and runtime config no longer disagree, so the workaround
is unnecessary."* That assumption turned out to be wrong: the chat
surfaces still read `apiConfiguration.actModeApiModelId`, so an
incoherent `(provider, modelId)` is still visible.
This step re-introduces the normalization, but rewires it to consult
the SDK catalog and the `ProviderConfigStore` instead of the legacy
hardcoded `deepSeekModels` map (which no longer exists).
**Files to modify:**
- Probably reintroduce
`src/core/controller/models/providerSwitchNormalization.ts` (look at
the deleted version in `git show 3cf06f0a1` for prior art, but
rewrite the resolution logic per below — do not just `git revert`).
- The callers that the original commit modified:
`updateApiConfiguration.ts`, `updateApiConfigurationPartial.ts`,
`updateApiConfigurationProto.ts`, and `state/updateSettings.ts`.
**Resolution logic for `normalizeProviderSwitchModel(providerId, currentModelId)`:**
1. If `currentModelId` belongs to the new provider's
`MODEL_COLLECTIONS_BY_PROVIDER_ID[providerId].models`, keep it.
2. Otherwise, if `store.readSelection(providerId, "act")` exists, use
that model id. (User had previously committed a DeepSeek model;
restore it.)
3. Otherwise, use
`MODEL_COLLECTIONS_BY_PROVIDER_ID[providerId].provider.defaultModelId`.
4. Otherwise, leave as empty string (caller decides; chat will render
"unknown" via the `source: "unknown"` path from step 1).
Apply this for both `plan` and `act` modes when the corresponding
provider field changes.
**Tests:**
- Switching `anthropic → deepseek` with `currentModelId =
"claude-sonnet-4-5-20250929"` produces `actModeApiModelId =
"deepseek-v4-flash"` (or whatever the SDK default is — assert it
equals
`MODEL_COLLECTIONS_BY_PROVIDER_ID["deepseek"].provider.defaultModelId`).
- Switching `anthropic → deepseek` when the user previously committed
`deepseek-v4-pro` produces `actModeApiModelId = "deepseek-v4-pro"`.
- Switching `anthropic → deepseek` when current model is already
`deepseek-v4-flash` is a no-op.
- Switching to a non-migrated provider falls back to the existing
legacy behavior (read the original `providerSwitchNormalization`
logic from `git show 3cf06f0a1` carefully before deciding what to
keep).
**Exit criteria:**
- All tests pass.
- Debug harness: open chat with Anthropic, switch provider to DeepSeek
(without opening the picker), evaluate
`apiConfiguration.actModeApiModelId` — should equal the DeepSeek SDK
default, not the Anthropic model id.
**Out of scope:**
- Webview rendering changes (step 4).
- Provider switch normalization for other migrated providers — none
exist yet.
---
### Step 4 — Webview: read `ModelInfo` via the pure RPC
**Goal:** Replace the chat surfaces' use of `normalizeApiConfiguration`
(which returns hardcoded `openAiModelInfoSafeDefaults` for DeepSeek) with
a hook that calls `resolveModelInfo` from step 1. With steps 2 and 3 in
place, `apiConfiguration.(plan|act)ModeApi(Provider|ModelId)` is now
always coherent, so this RPC always has good inputs.
**Files to add:**
- `webview-ui/src/hooks/useNormalizedApiConfiguration.ts`: new hook.
- `webview-ui/src/hooks/useNormalizedApiConfiguration.test.ts`: new
tests.
**Files to modify:**
- `webview-ui/src/components/chat/task-header/TaskHeader.tsx`: replace
`normalizeApiConfiguration` with the new hook.
- `webview-ui/src/components/chat/ChatView.tsx`: same.
- `webview-ui/src/components/chat/ChatTextArea.tsx`: same.
**Hook contract:**
```ts
export function useNormalizedApiConfiguration(mode: Mode): NormalizedApiConfig
```
- Reads `apiConfiguration` from `useExtensionState`.
- Computes `activeProvider` and `currentModelId` from `apiConfiguration`
mode-specific fields.
- If `activeProvider` is in `MIGRATED_PROVIDERS` (initially
`Set(["deepseek"])`), calls
`ModelsServiceClient.resolveModelInfo({providerId, modelId})` with
the current pair. Updates local state with the response. Returns
`{selectedProvider, selectedModelId: response.modelId,
selectedModelInfo: fromProtobufModelInfo(response.modelInfo)}` if
`source !== "unknown"`; otherwise returns a placeholder with
`contextWindow: undefined`.
- If `activeProvider` is not migrated, delegates to
`normalizeApiConfiguration(apiConfiguration, mode)` as today.
- Re-runs whenever `(activeProvider, currentModelId)` changes — this is
the *only* trigger needed because `apiConfiguration` is the
React-context broadcast that every commit (step 2) and provider
switch (step 3) updates.
**Tests (must include):**
- Returns the SDK's modelInfo when the active provider is `deepseek`
and modelId is `deepseek-v4-pro`. (Mock
`ModelsServiceClient.resolveModelInfo` to return a response with
`modelInfo.contextWindow === 1_000_000`; assert
`selectedModelInfo.contextWindow === 1_000_000`.)
- Returns `contextWindow: undefined` during the very first render
before the RPC resolves (no 128K flash).
- Returns the SDK default's modelInfo when `modelId` is empty.
- **Regression guard:** mock
`ModelsServiceClient.resolveProviderModels` as well; assert it is
**never called** when the hook is mounted with `apiProvider:
"deepseek"`. This is the test that proves the prior regression
cannot reoccur.
- Falls back to legacy `normalizeApiConfiguration` for `anthropic`
without calling `resolveModelInfo` (mock asserts no calls).
**Important constraint: hook structure.**
The hook must call `useState` and `useEffect` in the same order every
render. Do not conditionally call hooks based on `isMigrated`. The
pattern is:
```ts
const { apiConfiguration } = useExtensionState()
const provider = ... // derived from apiConfiguration
const modelId = ... // derived from apiConfiguration
const isMigrated = MIGRATED_PROVIDERS.has(provider)
const [resolvedInfo, setResolvedInfo] = useState<ResolveModelInfoResponse | undefined>(undefined)
useEffect(() => {
if (!isMigrated) return
let cancelled = false
void ModelsServiceClient.resolveModelInfo({ providerId: provider, modelId })
.then((response) => { if (!cancelled) setResolvedInfo(response) })
return () => { cancelled = true }
}, [isMigrated, provider, modelId])
return useMemo(() => {
if (!isMigrated) return normalizeApiConfiguration(apiConfiguration, mode)
// ... use resolvedInfo, fall back to placeholder if undefined
}, [apiConfiguration, mode, isMigrated, resolvedInfo])
```
The `cancelled` flag is important: when `provider` or `modelId`
changes during a pending RPC, the stale response must be discarded.
**Exit criteria:**
- All new and existing webview tests pass (`npx vitest run` in
`webview-ui/`).
- Host-side typecheck clean.
- Webview typecheck clean.
- Debug harness manual verification (see "Manual verification" below).
**Out of scope:**
- Adding other providers to `MIGRATED_PROVIDERS`. That happens as each
provider gets migrated.
- Subscriptions or pub/sub. Not needed.
---
## Manual verification (debug harness)
After all four steps land, run this sequence to confirm the fix:
```bash
# Start harness
npx tsx src/dev/debug-harness/server.ts --skip-build --auto-launch
curl localhost:19229/api -d '{"method":"launch","params":{"skipBuild":false}}'
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
```
**Scenario A: Fresh install, switch to DeepSeek, never open picker.**
1. Anthropic is the default provider; verify chat header shows a 200K
context window (anthropic default model).
2. Open settings, switch provider to DeepSeek, enter API key, close
settings without touching the model picker.
3. Open chat. Chat header should show:
- Model display: `deepseek:<sdk-default-model-id>` (NOT
`deepseek:claude-sonnet-4-5-20250929`).
- Context window: the SDK default's actual context window (1M for
current DeepSeek models, but assert against the SDK-reported
number, not the literal `1_000_000`).
4. Run inference. It should succeed and target the SDK default model.
**Scenario B: Pick a model from the picker.**
1. From scenario A, open settings, open the DeepSeek model picker.
2. Verify all 4 SDK-catalog models are listed (regression guard: if
only 2 models appear, step 4's hook is calling `useProviderModels`
or otherwise triggering a model-list refresh).
3. Pick `deepseek-v4-pro`.
4. Close settings.
5. Chat header should immediately show `deepseek:deepseek-v4-pro` and a
1M context window.
6. Open settings again, verify the picker still shows `deepseek-v4-pro`
as selected.
**Scenario C: Picker commit while chat is open.**
1. From scenario B, with chat open showing `deepseek-v4-pro`, open
settings.
2. Change the picker to `deepseek-v4-flash`.
3. Without reloading anything, look at the chat header.
4. The model display and context window should update to reflect
`deepseek-v4-flash`. This is the cross-surface update test.
**Regression guard: capture model-list RPC calls.**
```bash
# Before doing any of the above scenarios:
curl localhost:19229/api -d '{"method":"ext.evaluate","params":{"expression":"globalThis.__resolveProviderModelsCallCount = 0; const orig = ModelsServiceClient.resolveProviderModels; ModelsServiceClient.resolveProviderModels = (...args) => { globalThis.__resolveProviderModelsCallCount += 1; return orig.apply(this, args); }"}}'
# After running the scenarios:
curl localhost:19229/api -d '{"method":"ext.evaluate","params":{"expression":"globalThis.__resolveProviderModelsCallCount"}}'
```
Expected: the call count after scenarios A, B, C equals the number of
times the user explicitly opened the DeepSeek picker. It must **not**
be incremented by chat-only operations.
## Validation commands (run between each step)
```bash
# Type check
npx tsc --noEmit -p tsconfig.json
npx tsc --noEmit -p webview-ui/tsconfig.json
# Lint
npx biome check src/ webview-ui/src/
# Host tests
npx vitest run
# Webview tests
cd webview-ui && npx vitest run --reporter=dot
```
## Risk register
- **Risk:** `MODEL_COLLECTIONS_BY_PROVIDER_ID` is part of
`@clinebot/llms`'s public surface but is a static initialized
constant; if a future SDK upgrade changes initialization to be async,
the host code in step 1 breaks. **Mitigation:** the handler can fall
back to `getProvider(providerId)` (async) if the static lookup
misses, but the synchronous path is the common case. Test for both.
- **Risk:** Step 2 writes both the selection envelope and the legacy
field. If the writes are not atomic (e.g. broadcast happens between
them), the webview could observe a brief inconsistent state.
**Mitigation:** use a single controller method that performs both
writes and emits one broadcast. Look at how other controller methods
batch writes (e.g. `updateApiConfiguration`).
- **Risk:** Step 3's normalization might fire on every state update,
not just on provider switches. **Mitigation:** the original
`3cf06f0a1` commit carefully gated normalization on detecting a
provider field change. Reuse that gating pattern.
- **Risk:** Step 4's hook may issue an RPC on every render if its
`useEffect` dependencies are unstable. **Mitigation:** the deps are
primitive strings (`provider`, `modelId`, `isMigrated`), so they
will only change when the underlying value changes. Add a test that
mounts the hook and re-renders with unchanged inputs; assert
`resolveModelInfo` is called only once.
## Open questions for the implementer
1. Should the `source: "sdk-default"` fallback in step 1 happen only
for migrated providers, or for all providers? **Recommended:** all
providers; let the webview decide whether to surface the default or
show "unknown" by checking the `source` field.
2. In step 2, should the legacy-field write happen *before* or *after*
the store commit? If commit fails (validation error), the legacy
field should not be written. **Recommended:** commit first, then
write legacy fields, then emit the single broadcast.
3. In step 3, is there a way to test the normalization without going
through the full state-management plumbing? **Recommended:** pull
the resolution logic into a pure function
`normalizeProviderSwitchModel(...)` (no controller dependency) and
unit-test that directly, then test the caller integration once.
## Definition of done
- [ ] All four steps' exit criteria met.
- [ ] All three manual verification scenarios pass in the debug
harness.
- [ ] The regression guard (call count of `resolveProviderModels`)
confirms chat-only operations do not refresh the model list.
- [ ] `webview-ui` test suite green (`npx vitest run`).
- [ ] Host test suite green (`npx vitest run`).
- [ ] Both typechecks clean.
- [ ] `git diff --stat` reviewed: changes confined to the files listed
in each step's "Files to modify" section. No surprise edits to
unrelated provider panels.
## Implementer guidance
- Land each step as a separate commit with the title format
`model-catalog (context-window fix step N): <one-line summary>`.
This makes bisection trivial if a later step regresses.
- Do not skip the unit tests in step 1 even though the RPC is not yet
called by anyone. The tests are the spec for step 4's mock.
- If you find yourself reaching for `useProviderModels`,
`useSyncExternalStore`, a subscription RPC, or any other
state-sharing mechanism in step 4, stop: the design assumes
`apiConfiguration` is the single trigger. If that assumption breaks
down, write up why in this document and re-plan.
+14
View File
@@ -59,6 +59,8 @@ service ModelsService {
rpc listProviders(Empty) returns (ProviderListingsResponse);
// Resolves model metadata for a provider through the unified SDK-backed catalog
rpc resolveProviderModels(ResolveProviderModelsRequest) returns (ProviderModelsResponse);
// Resolves model metadata for a provider/model without refreshing model lists
rpc resolveModelInfo(ResolveModelInfoRequest) returns (ResolveModelInfoResponse);
// Reads redacted effective provider configuration
rpc readProviderConfig(StringRequest) returns (ProviderConfigResponse);
// Writes provider configuration fields and returns redacted effective configuration
@@ -169,6 +171,18 @@ message ProviderModelsResponse {
optional CatalogErrorInfo error = 9;
}
message ResolveModelInfoRequest {
string provider_id = 1;
optional string model_id = 2;
}
message ResolveModelInfoResponse {
string provider_id = 1;
string model_id = 2;
optional OpenRouterModelInfo model_info = 3;
string source = 4;
}
message ProviderConfigResponse {
string provider_id = 1;
optional string base_url = 2;
@@ -5,6 +5,10 @@ import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import { ApiFormat, OpenRouterModelInfo } from "@/shared/proto/cline/models"
import type { ProviderCatalogController } from "../providerCatalogShared"
type TestStateManager = {
setGlobalStateBatch: ReturnType<typeof vi.fn>
}
function makeStore(config: EffectiveProviderConfig): ProviderConfigStore {
return {
read: vi.fn(() => config),
@@ -23,10 +27,15 @@ function makeCatalog(): ProviderCatalog {
}
}
function makeController(store: ProviderConfigStore, catalog: ProviderCatalog): ProviderCatalogController {
function makeController(
store: ProviderConfigStore,
catalog: ProviderCatalog,
stateManager?: TestStateManager,
): ProviderCatalogController {
return {
getProviderConfigStore: () => store,
getProviderCatalog: () => catalog,
...(stateManager ? { stateManager } : {}),
}
}
@@ -178,7 +187,8 @@ describe("provider model catalog handlers", () => {
const { commitModelSelection } = await import("../commitModelSelection")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const controller = makeController(store, makeCatalog())
const stateManager: TestStateManager = { setGlobalStateBatch: vi.fn() }
const controller = makeController(store, makeCatalog(), stateManager)
await commitModelSelection(controller, {
providerId: "deepseek",
@@ -202,6 +212,10 @@ describe("provider model catalog handlers", () => {
apiFormat: ApiFormat.OPENAI_CHAT,
}),
})
expect(stateManager.setGlobalStateBatch).toHaveBeenCalledWith({
actModeApiProvider: "deepseek",
actModeApiModelId: "deepseek-v4-flash",
})
})
it("commitModelSelection rejects invalid mode", async () => {
@@ -0,0 +1,93 @@
import { MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@clinebot/llms"
import { describe, expect, it, vi } from "vitest"
import type { EffectiveProviderConfig, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import { normalizeProviderSwitchModel } from "../providerSwitchNormalization"
function makeStore(config: EffectiveProviderConfig): ProviderConfigStore {
return {
read: vi.fn(() => config),
readSelection: vi.fn(() => undefined),
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
write: vi.fn(() => config),
commitSelection: vi.fn(),
}
}
describe("normalizeProviderSwitchModel", () => {
it("uses the SDK default when switching to DeepSeek with a stale Anthropic model id", () => {
const providerId = parseProviderId("deepseek")
const defaultModelId = MODEL_COLLECTIONS_BY_PROVIDER_ID.deepseek.provider.defaultModelId
const store = makeStore({ providerId })
const normalized = normalizeProviderSwitchModel(
store,
{ actModeApiProvider: "anthropic", actModeApiModelId: "claude-sonnet-4-5-20250929" },
{ actModeApiProvider: "deepseek", actModeApiModelId: undefined },
)
expect(normalized.actModeApiProvider).toBe("deepseek")
expect(normalized.actModeApiModelId).toBe(defaultModelId)
})
it("restores a previously committed DeepSeek selection before falling back to SDK default", () => {
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
vi.mocked(store.readSelection).mockReturnValue({
providerId,
modelId: "deepseek-v4-pro",
modelInfo: { name: "DeepSeek V4 Pro", contextWindow: 1_000_000, supportsPromptCache: true },
})
const normalized = normalizeProviderSwitchModel(
store,
{ actModeApiProvider: "anthropic", actModeApiModelId: "claude-sonnet-4-5-20250929" },
{ actModeApiProvider: "deepseek", actModeApiModelId: undefined },
)
expect(normalized.actModeApiModelId).toBe("deepseek-v4-pro")
})
it("keeps an already valid DeepSeek model id", () => {
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const normalized = normalizeProviderSwitchModel(
store,
{ actModeApiProvider: "anthropic", actModeApiModelId: "deepseek-v4-flash" },
{ actModeApiProvider: "deepseek", actModeApiModelId: undefined },
)
expect(normalized.actModeApiModelId).toBe("deepseek-v4-flash")
expect(store.readSelection).not.toHaveBeenCalled()
})
it("does not change model id for non-migrated provider switches", () => {
const providerId = parseProviderId("anthropic")
const store = makeStore({ providerId })
const normalized = normalizeProviderSwitchModel(
store,
{ actModeApiProvider: "deepseek", actModeApiModelId: "deepseek-v4-flash" },
{ actModeApiProvider: "anthropic" },
)
expect(normalized).toEqual({ actModeApiProvider: "anthropic" })
expect(store.readSelection).not.toHaveBeenCalled()
})
it("normalizes plan mode independently", () => {
const providerId = parseProviderId("deepseek")
const defaultModelId = MODEL_COLLECTIONS_BY_PROVIDER_ID.deepseek.provider.defaultModelId
const store = makeStore({ providerId })
const normalized = normalizeProviderSwitchModel(
store,
{ planModeApiProvider: "anthropic", planModeApiModelId: "claude-sonnet-4-5-20250929" },
{ planModeApiProvider: "deepseek", planModeApiModelId: undefined },
)
expect(normalized.planModeApiProvider).toBe("deepseek")
expect(normalized.planModeApiModelId).toBe(defaultModelId)
})
})
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest"
import type { EffectiveProviderConfig, ProviderCatalog, ProviderConfigStore } from "@/sdk/model-catalog/contracts"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import type { ProviderCatalogController } from "../providerCatalogShared"
function makeStore(config: EffectiveProviderConfig): ProviderConfigStore {
return {
read: vi.fn(() => config),
readSelection: vi.fn(() => undefined),
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
write: vi.fn(() => config),
commitSelection: vi.fn(),
}
}
function makeCatalog(): ProviderCatalog {
return {
listProviders: vi.fn(async () => []),
resolveModels: vi.fn(),
subscribe: vi.fn(() => ({ dispose: vi.fn() })),
}
}
function makeController(store: ProviderConfigStore, catalog: ProviderCatalog): ProviderCatalogController {
return {
getProviderConfigStore: () => store,
getProviderCatalog: () => catalog,
}
}
describe("resolveModelInfo", () => {
it("returns committed-selection source when a matching selection exists", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
vi.mocked(store.readSelection).mockImplementation((_, mode) =>
mode === "act"
? {
providerId,
modelId: "deepseek-v4-pro",
modelInfo: { name: "Committed Pro", supportsPromptCache: true, contextWindow: 999_999 },
}
: undefined,
)
const catalog = makeCatalog()
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "deepseek",
modelId: "deepseek-v4-pro",
})
expect(response.source).toBe("committed-selection")
expect(response.modelId).toBe("deepseek-v4-pro")
expect(response.modelInfo?.contextWindow).toBe(999_999)
expect(catalog.resolveModels).not.toHaveBeenCalled()
expect(store.commitSelection).not.toHaveBeenCalled()
expect(store.write).not.toHaveBeenCalled()
})
it("returns sdk-known-models source for a known SDK model without a committed selection", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const providerId = parseProviderId("deepseek")
const store = makeStore({ providerId })
const catalog = makeCatalog()
const response = await resolveModelInfo(makeController(store, catalog), {
providerId: "deepseek",
modelId: "deepseek-v4-pro",
})
expect(response.source).toBe("sdk-known-models")
expect(response.modelId).toBe("deepseek-v4-pro")
expect(response.modelInfo?.contextWindow).toBe(1_000_000)
expect(catalog.resolveModels).not.toHaveBeenCalled()
})
it("returns sdk-default source when model id is omitted", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const providerId = parseProviderId("deepseek")
const response = await resolveModelInfo(makeController(makeStore({ providerId }), makeCatalog()), {
providerId: "deepseek",
})
expect(response.source).toBe("sdk-default")
expect(response.modelId).toBe("deepseek-v4-flash")
expect(response.modelInfo?.contextWindow).toBe(1_000_000)
})
it("returns sdk-default source when model id belongs to another provider", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const providerId = parseProviderId("deepseek")
const response = await resolveModelInfo(makeController(makeStore({ providerId }), makeCatalog()), {
providerId: "deepseek",
modelId: "claude-sonnet-4-5-20250929",
})
expect(response.source).toBe("sdk-default")
expect(response.modelId).toBe("deepseek-v4-flash")
expect(response.modelInfo?.contextWindow).toBe(1_000_000)
})
it("returns unknown source for an unknown provider", async () => {
const { resolveModelInfo } = await import("../resolveModelInfo")
const providerId = parseProviderId("not-real-provider")
const response = await resolveModelInfo(makeController(makeStore({ providerId }), makeCatalog()), {
providerId: "not-real-provider",
modelId: "whatever",
})
expect(response).toMatchObject({
providerId: "not-real-provider",
modelId: "whatever",
source: "unknown",
})
expect(response.modelInfo).toBeUndefined()
})
})
@@ -1,18 +1,34 @@
import type { ApiProvider } from "@shared/api"
import { Empty } from "@/shared/proto/cline/common"
import { CommitModelSelectionRequest } from "@/shared/proto/cline/models"
import { getProviderModelIdKey } from "@/shared/storage/provider-keys"
import {
hasProviderCatalogStateController,
type ProviderCatalogController,
parseModeRequest,
parseProviderIdRequest,
toModelSelection,
} from "./providerCatalogShared"
function providerForStorage(providerId: string): ApiProvider {
return (providerId === "nousresearch" ? "nousResearch" : providerId) as ApiProvider
}
export async function commitModelSelection(
controller: ProviderCatalogController,
request: CommitModelSelectionRequest,
): Promise<Empty> {
const providerId = parseProviderIdRequest(request.providerId)
const mode = parseModeRequest(request.mode)
controller.getProviderConfigStore().commitSelection(providerId, mode, toModelSelection(request, providerId))
const selection = toModelSelection(request, providerId)
controller.getProviderConfigStore().commitSelection(providerId, mode, selection)
if (hasProviderCatalogStateController(controller)) {
controller.stateManager.setGlobalStateBatch({
[`${mode}ModeApiProvider`]: providerId,
[getProviderModelIdKey(providerForStorage(providerId.toString()), mode)]: selection.modelId,
})
}
return Empty.create()
}
@@ -1,4 +1,4 @@
import type { ModelInfo } from "@shared/api"
import type { ApiConfiguration, ModelInfo } from "@shared/api"
import type {
EffectiveProviderConfig,
Mode,
@@ -22,12 +22,27 @@ import {
WriteProviderConfigPatch,
} from "@/shared/proto/cline/models"
import { fromProtobufModelInfo, toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
import type { GlobalStateAndSettings } from "@/shared/storage/state-keys"
export interface ProviderCatalogController {
getProviderConfigStore(): ProviderConfigStore
getProviderCatalog(): ProviderCatalog
}
export interface ProviderCatalogStateController extends ProviderCatalogController {
stateManager: {
setGlobalStateBatch(updates: Partial<GlobalStateAndSettings>): void
getApiConfiguration?(): ApiConfiguration
}
}
export function hasProviderCatalogStateController(
controller: ProviderCatalogController,
): controller is ProviderCatalogStateController {
const candidate = controller as { stateManager?: { setGlobalStateBatch?: unknown } }
return typeof candidate.stateManager?.setGlobalStateBatch === "function"
}
export function parseProviderIdRequest(rawProviderId: string | undefined, fieldName = "provider_id"): ProviderId {
const providerId = rawProviderId?.trim()
if (!providerId) {
@@ -0,0 +1,85 @@
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@clinebot/llms"
import type { Mode, ProviderConfigStore, ProviderId } from "@/sdk/model-catalog/contracts"
import { parseProviderId } from "@/sdk/model-catalog/provider-id"
import type { ApiConfiguration, ApiProvider } from "@/shared/api"
import { getProviderModelIdKey } from "@/shared/storage/provider-keys"
type ProviderSwitchConfig = Partial<
Pick<ApiConfiguration, "planModeApiProvider" | "actModeApiProvider" | "planModeApiModelId" | "actModeApiModelId">
>
const modeFields = {
plan: {
provider: "planModeApiProvider",
modelId: "planModeApiModelId",
},
act: {
provider: "actModeApiProvider",
modelId: "actModeApiModelId",
},
} as const
function toProviderId(provider: ApiProvider | string | undefined): ProviderId | undefined {
const trimmed = provider?.trim()
return trimmed ? parseProviderId(trimmed) : undefined
}
function resolveProviderSwitchModelId(
store: ProviderConfigStore,
providerId: ProviderId,
mode: Mode,
currentModelId: string | undefined,
): string {
const generatedModels = getGeneratedModelsForProvider(providerId)
const collectionModels = MODEL_COLLECTIONS_BY_PROVIDER_ID[providerId]?.models ?? {}
if (currentModelId && (generatedModels[currentModelId] || collectionModels[currentModelId])) {
return currentModelId
}
const selection = store.readSelection(providerId, mode)
if (selection?.modelId) {
return selection.modelId
}
return MODEL_COLLECTIONS_BY_PROVIDER_ID[providerId]?.provider.defaultModelId?.trim() || Object.keys(generatedModels)[0] || ""
}
/**
* Keep generic legacy model-id slots coherent when switching to an SDK-catalog
* provider. DeepSeek currently stores its model id in `*ModeApiModelId`; without
* this normalization, switching Anthropic/Cline/etc. → DeepSeek can leave the
* generic slot pointing at a previous provider's model.
*/
export function normalizeProviderSwitchModel<T extends ProviderSwitchConfig>(
store: ProviderConfigStore,
previous: ProviderSwitchConfig,
next: T,
): T {
const normalized: ProviderSwitchConfig = { ...next }
for (const [mode, fields] of Object.entries(modeFields) as [Mode, (typeof modeFields)[Mode]][]) {
const previousProvider = previous[fields.provider]
const nextProvider = (normalized[fields.provider] ?? previousProvider) as ApiProvider | undefined
if (!nextProvider || nextProvider === previousProvider) {
continue
}
const providerId = toProviderId(nextProvider)
if (!providerId || providerId !== "deepseek") {
continue
}
const genericModelKey = getProviderModelIdKey(nextProvider, mode)
if (genericModelKey !== fields.modelId) {
continue
}
const currentModelId = (normalized[fields.modelId] ?? previous[fields.modelId]) as string | undefined
const resolvedModelId = resolveProviderSwitchModelId(store, providerId, mode, currentModelId)
if (resolvedModelId) {
normalized[fields.modelId] = resolvedModelId
}
}
return normalized as T
}
@@ -0,0 +1,69 @@
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@clinebot/llms"
import { adaptSdkModelInfo } from "@/sdk/model-catalog/shape-adapter"
import { ResolveModelInfoRequest, ResolveModelInfoResponse } from "@/shared/proto/cline/models"
import { toProtobufModelInfo } from "@/shared/proto-conversions/models/typeConversion"
import { type ProviderCatalogController, parseProviderIdRequest } from "./providerCatalogShared"
export async function resolveModelInfo(
controller: ProviderCatalogController,
request: ResolveModelInfoRequest,
): Promise<ResolveModelInfoResponse> {
const providerId = parseProviderIdRequest(request.providerId)
const requestedModelId = request.modelId?.trim() || ""
if (requestedModelId) {
const store = controller.getProviderConfigStore()
const actSelection = store.readSelection(providerId, "act")
if (actSelection?.modelId === requestedModelId) {
return ResolveModelInfoResponse.create({
providerId,
modelId: actSelection.modelId,
modelInfo: toProtobufModelInfo(actSelection.modelInfo),
source: "committed-selection",
})
}
const planSelection = store.readSelection(providerId, "plan")
if (planSelection?.modelId === requestedModelId) {
return ResolveModelInfoResponse.create({
providerId,
modelId: planSelection.modelId,
modelInfo: toProtobufModelInfo(planSelection.modelInfo),
source: "committed-selection",
})
}
}
const collection = MODEL_COLLECTIONS_BY_PROVIDER_ID[providerId]
const generatedModels = getGeneratedModelsForProvider(providerId)
const requestedModelInfo = requestedModelId
? (generatedModels[requestedModelId] ?? collection?.models?.[requestedModelId])
: undefined
if (requestedModelId && requestedModelInfo) {
return ResolveModelInfoResponse.create({
providerId,
modelId: requestedModelId,
modelInfo: toProtobufModelInfo(adaptSdkModelInfo(requestedModelInfo)),
source: "sdk-known-models",
})
}
const defaultModelId = collection?.provider?.defaultModelId?.trim() || Object.keys(generatedModels)[0] || ""
const defaultModelInfo = defaultModelId
? (generatedModels[defaultModelId] ?? collection?.models?.[defaultModelId])
: undefined
if (defaultModelId && defaultModelInfo) {
return ResolveModelInfoResponse.create({
providerId,
modelId: defaultModelId,
modelInfo: toProtobufModelInfo(adaptSdkModelInfo(defaultModelInfo)),
source: "sdk-default",
})
}
return ResolveModelInfoResponse.create({
providerId,
modelId: requestedModelId,
source: "unknown",
})
}
@@ -6,6 +6,7 @@ import { UpdateApiConfigurationRequestNew } from "@/shared/proto/index.cline"
import { Logger } from "@/shared/services/Logger"
import { Secrets } from "@/shared/storage/state-keys"
import type { Controller } from "../index"
import { normalizeProviderSwitchModel } from "./providerSwitchNormalization"
/**
* Parses field mask paths into separate sets for options and secrets
@@ -136,7 +137,13 @@ export async function updateApiConfiguration(controller: Controller, request: Up
controller.stateManager.setSecretsBatch(secrets)
}
if (Object.keys(options).length > 0) {
controller.stateManager.setGlobalStateBatch(options)
controller.stateManager.setGlobalStateBatch(
normalizeProviderSwitchModel(
controller.getProviderConfigStore(),
controller.stateManager.getApiConfiguration(),
options,
),
)
}
// Update the task's API handler if there's an active task
@@ -4,6 +4,7 @@ import { UpdateApiConfigurationPartialRequest } from "@shared/proto/cline/models
import { convertProtoToApiConfiguration } from "@shared/proto-conversions/models/api-configuration-conversion"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
import { normalizeProviderSwitchModel } from "./providerSwitchNormalization"
/**
* Updates API configuration with partial values using FieldMask
@@ -39,12 +40,13 @@ export async function updateApiConfigurationPartial(
for (const field of request.updateMask) {
;(updatedConfig as Record<string, any>)[field] = (newConfigValues as Record<string, any>)[field]
}
const normalizedConfig = normalizeProviderSwitchModel(controller.getProviderConfigStore(), currentConfig, updatedConfig)
// Update storage and task API handler
controller.stateManager.setApiConfiguration(updatedConfig)
controller.stateManager.setApiConfiguration(normalizedConfig)
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
controller.task.api = buildApiHandler({ ...updatedConfig, ulid: controller.task.ulid }, currentMode)
controller.task.api = buildApiHandler({ ...normalizedConfig, ulid: controller.task.ulid }, currentMode)
}
// Notify webview
@@ -11,6 +11,7 @@ import { OpenaiReasoningEffort } from "@shared/storage/types"
import { buildApiHandler } from "@/core/api"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
import { normalizeProviderSwitchModel } from "./providerSwitchNormalization"
/**
* Updates API configuration
@@ -124,16 +125,19 @@ export async function updateApiConfigurationProto(
actModeReasoningEffort: protoApiConfiguration.actModeReasoningEffort as OpenaiReasoningEffort | undefined,
}
const normalizedApiConfiguration = normalizeProviderSwitchModel(
controller.getProviderConfigStore(),
controller.stateManager.getApiConfiguration(),
convertedApiConfigurationFromProto,
)
// Update the API configuration in storage
controller.stateManager.setApiConfiguration(convertedApiConfigurationFromProto)
controller.stateManager.setApiConfiguration(normalizedApiConfiguration)
// Update the task's API handler if there's an active task
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
controller.task.api = buildApiHandler(
{ ...convertedApiConfigurationFromProto, ulid: controller.task.ulid },
currentMode,
)
controller.task.api = buildApiHandler({ ...normalizedApiConfiguration, ulid: controller.task.ulid }, currentMode)
}
// Post updated state to webview
+9 -2
View File
@@ -13,6 +13,7 @@ import { telemetryService } from "../../../services/telemetry"
import { BrowserSettings as SharedBrowserSettings } from "../../../shared/BrowserSettings"
import { Controller } from ".."
import { accountLogoutClicked } from "../account/accountLogoutClicked"
import { normalizeProviderSwitchModel } from "../models/providerSwitchNormalization"
/**
* Updates multiple extension settings in a single request
@@ -43,12 +44,18 @@ export async function updateSettings(controller: Controller, request: UpdateSett
actModeReasoningEffort: protoApiConfiguration.actModeReasoningEffort as OpenaiReasoningEffort | undefined,
}
controller.stateManager.setApiConfiguration(convertedApiConfigurationFromProto)
const normalizedApiConfiguration = normalizeProviderSwitchModel(
controller.getProviderConfigStore(),
controller.stateManager.getApiConfiguration(),
convertedApiConfigurationFromProto,
)
controller.stateManager.setApiConfiguration(normalizedApiConfiguration)
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
const apiConfigForHandler = {
...convertedApiConfigurationFromProto,
...normalizedApiConfiguration,
ulid: controller.task.ulid,
}
controller.task.api = buildApiHandler(apiConfigForHandler, currentMode)
+91
View File
@@ -0,0 +1,91 @@
import { getGeneratedModelsForProvider, MODEL_COLLECTIONS_BY_PROVIDER_ID } from "@clinebot/llms"
export interface OAuthCredentials {
accessToken?: string
refreshToken?: string
accountId?: string
}
export interface StartSessionResult {
sessionId: string
}
export interface SessionHistoryRecord {
id: string
}
export type CoreSessionEvent = { type: string; payload?: unknown }
interface ProviderSettingsState {
providers: Record<string, Record<string, unknown>>
lastUsedProvider?: string
}
export class ProviderSettingsManager {
private state: ProviderSettingsState = { providers: {} }
constructor(_options?: { filePath?: string; dataDir?: string }) {}
read(): ProviderSettingsState {
return { providers: { ...this.state.providers }, lastUsedProvider: this.state.lastUsedProvider }
}
getProviderSettings(providerId: string): Record<string, unknown> | undefined {
return this.state.providers[providerId]
}
getLastUsedProviderSettings(): Record<string, unknown> | undefined {
return this.state.lastUsedProvider ? this.state.providers[this.state.lastUsedProvider] : undefined
}
saveProviderSettings(settings: Record<string, unknown>, options?: { setLastUsed?: boolean }): ProviderSettingsState {
const provider = settings.provider
if (typeof provider !== "string") {
throw new Error("provider is required")
}
this.state.providers[provider] = { ...settings }
if (options?.setLastUsed !== false) {
this.state.lastUsedProvider = provider
}
return this.read()
}
}
export interface ModelCatalogConfig {
loadLatestOnInit?: boolean
loadPrivateOnAuth?: boolean
failOnError?: boolean
cacheTtlMs?: number
}
export async function resolveProviderConfig(
providerId: string,
_config?: ModelCatalogConfig,
providerConfig?: { modelId?: string },
) {
const knownModels = getGeneratedModelsForProvider(providerId)
const requestedModelId = providerConfig?.modelId?.trim()
const defaultModelId = MODEL_COLLECTIONS_BY_PROVIDER_ID[providerId]?.provider.defaultModelId
const modelId = requestedModelId && knownModels[requestedModelId] ? requestedModelId : defaultModelId
return { modelId, knownModels }
}
export function createOAuthClientCallbacks() {
return {}
}
export async function getValidClineCredentials(): Promise<OAuthCredentials | undefined> {
return undefined
}
export async function loginClineOAuth(): Promise<OAuthCredentials> {
return {}
}
export async function loginOcaOAuth(): Promise<OAuthCredentials> {
return {}
}
export async function loginOpenAICodex(): Promise<OAuthCredentials> {
return {}
}
+5
View File
@@ -7,12 +7,17 @@ export default defineConfig({
"src/sdk/**/*.test.ts",
"src/core/storage/remote-config/**/*.test.ts",
"src/core/controller/models/__tests__/providerCatalogHandlers.test.ts",
"src/core/controller/models/__tests__/providerSwitchNormalization.test.ts",
"src/core/controller/models/__tests__/resolveModelInfo.test.ts",
"src/core/controller/models/__tests__/providerCatalogSmoke.test.ts",
],
environment: "node",
},
resolve: {
alias: {
"@clinebot/core": path.resolve(__dirname, "src/test/clinebot-core-vitest-stub.ts"),
"@clinebot/llms": path.resolve(__dirname, "node_modules/@clinebot/llms/dist/index.js"),
"@clinebot/shared": path.resolve(__dirname, "node_modules/@clinebot/shared/dist/index.js"),
vscode: path.resolve(__dirname, "src/test/vscode-vitest-stub.ts"),
"@": path.resolve(__dirname, "src"),
"@api": path.resolve(__dirname, "src/core/api"),
@@ -14,10 +14,11 @@ import ContextMenu from "@/components/chat/ContextMenu"
import { CHAT_CONSTANTS } from "@/components/chat/chat-view/constants"
import SlashCommandMenu from "@/components/chat/SlashCommandMenu"
import Thumbnails from "@/components/common/Thumbnails"
import { getModeSpecificFields, normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
import { getModeSpecificFields } from "@/components/settings/utils/providerUtils"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { usePlatform } from "@/context/PlatformContext"
import { useNormalizedApiConfiguration } from "@/hooks/useNormalizedApiConfiguration"
import { cn } from "@/lib/utils"
import { FileServiceClient, StateServiceClient } from "@/services/grpc-client"
import {
@@ -258,6 +259,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
const [fileSearchResults, setFileSearchResults] = useState<SearchResult[]>([])
const [searchLoading, setSearchLoading] = useState(false)
const [, metaKeyChar] = useMetaKeyDetection(platform)
const { selectedProvider, selectedModelId } = useNormalizedApiConfiguration(mode)
// Fetch git commits when Git is selected or when typing a hash
useEffect(() => {
@@ -1088,7 +1090,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
// Get model display name
const modelDisplayName = useMemo(() => {
const { selectedProvider, selectedModelId } = normalizeApiConfiguration(apiConfiguration, mode)
const {
vsCodeLmModelSelector,
togetherModelId,
@@ -1127,7 +1128,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
default:
return `${selectedProvider}:${selectedModelId}`
}
}, [apiConfiguration, mode])
}, [apiConfiguration, mode, selectedProvider, selectedModelId])
// Function to show error message for unsupported files for drag and drop
const showUnsupportedFileErrorMessage = () => {
+2 -4
View File
@@ -6,9 +6,9 @@ import { getApiMetrics, getLastApiReqTotalTokens } from "@shared/getApiMetrics"
import { BooleanRequest, StringRequest } from "@shared/proto/cline/common"
import { useCallback, useEffect, useMemo } from "react"
import { useMount } from "react-use"
import { normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useShowNavbar } from "@/context/PlatformContext"
import { useNormalizedApiConfiguration } from "@/hooks/useNormalizedApiConfiguration"
import { FileServiceClient, UiServiceClient } from "@/services/grpc-client"
import { Navbar } from "../menu/Navbar"
import AutoApproveBar from "./auto-approve-menu/AutoApproveBar"
@@ -177,9 +177,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
// Use message handlers hook
const messageHandlers = useMessageHandlers(messages, chatState)
const { selectedModelInfo } = useMemo(() => {
return normalizeApiConfiguration(apiConfiguration, mode)
}, [apiConfiguration, mode])
const { selectedModelInfo } = useNormalizedApiConfiguration(mode)
const selectFilesAndImages = useCallback(async () => {
try {
@@ -2,8 +2,9 @@ import { ClineMessage } from "@shared/ExtensionMessage"
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
import React, { useCallback, useLayoutEffect, useMemo, useState } from "react"
import Thumbnails from "@/components/common/Thumbnails"
import { getModeSpecificFields, normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
import { getModeSpecificFields } from "@/components/settings/utils/providerUtils"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useNormalizedApiConfiguration } from "@/hooks/useNormalizedApiConfiguration"
import { cn } from "@/lib/utils"
import { getEnvironmentColor } from "@/utils/environmentColors"
import CopyTaskButton from "./buttons/CopyTaskButton"
@@ -84,7 +85,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
}, [isHighlightedTextExpanded])
// Simplified computed values
const { selectedModelInfo } = normalizeApiConfiguration(apiConfiguration, mode)
const { selectedModelInfo } = useNormalizedApiConfiguration(mode)
const modeFields = getModeSpecificFields(apiConfiguration, mode)
const isCostAvailable =
@@ -0,0 +1,113 @@
import { ApiFormat, ResolveModelInfoResponse } from "@shared/proto/cline/models"
import { act, renderHook, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { ModelsServiceClient } from "@/services/grpc-client"
import { useNormalizedApiConfiguration } from "./useNormalizedApiConfiguration"
vi.mock("@/context/ExtensionStateContext", () => ({
useExtensionState: vi.fn(),
}))
vi.mock("@/services/grpc-client", () => ({
ModelsServiceClient: {
resolveModelInfo: vi.fn(),
resolveProviderModels: vi.fn(),
},
}))
const mockUseExtensionState = vi.mocked(useExtensionState)
const mockResolveModelInfo = vi.mocked(ModelsServiceClient.resolveModelInfo)
const mockResolveProviderModels = vi.mocked(ModelsServiceClient.resolveProviderModels)
function setApiConfiguration(apiConfiguration: Record<string, unknown>) {
mockUseExtensionState.mockReturnValue({ apiConfiguration } as ReturnType<typeof useExtensionState>)
}
function deepSeekResponse(modelId: string, contextWindow = 1_000_000) {
return ResolveModelInfoResponse.create({
providerId: "deepseek",
modelId,
source: "sdk-known-models",
modelInfo: {
name: "DeepSeek V4 Pro",
contextWindow,
maxTokens: 384_000,
supportsPromptCache: true,
supportsReasoning: true,
apiFormat: ApiFormat.OPENAI_CHAT,
},
})
}
describe("useNormalizedApiConfiguration", () => {
beforeEach(() => {
vi.clearAllMocks()
setApiConfiguration({})
})
it("resolves DeepSeek model info through the pure RPC", async () => {
setApiConfiguration({ actModeApiProvider: "deepseek", actModeApiModelId: "deepseek-v4-pro" })
mockResolveModelInfo.mockResolvedValue(deepSeekResponse("deepseek-v4-pro"))
const { result } = renderHook(() => useNormalizedApiConfiguration("act"))
expect(result.current.selectedModelInfo.contextWindow).toBeUndefined()
await waitFor(() => expect(result.current.selectedModelInfo.contextWindow).toBe(1_000_000))
expect(result.current.selectedModelId).toBe("deepseek-v4-pro")
expect(mockResolveModelInfo).toHaveBeenCalledWith({ providerId: "deepseek", modelId: "deepseek-v4-pro" })
expect(mockResolveProviderModels).not.toHaveBeenCalled()
})
it("does not flash 128K before the DeepSeek RPC resolves", () => {
setApiConfiguration({ actModeApiProvider: "deepseek", actModeApiModelId: "deepseek-v4-pro" })
mockResolveModelInfo.mockReturnValue(new Promise(() => undefined))
const { result } = renderHook(() => useNormalizedApiConfiguration("act"))
expect(result.current.selectedModelInfo.contextWindow).toBeUndefined()
expect(mockResolveProviderModels).not.toHaveBeenCalled()
})
it("uses the SDK default model info when model id is empty", async () => {
setApiConfiguration({ actModeApiProvider: "deepseek", actModeApiModelId: "" })
mockResolveModelInfo.mockResolvedValue(deepSeekResponse("deepseek-v4-flash"))
const { result } = renderHook(() => useNormalizedApiConfiguration("act"))
await waitFor(() => expect(result.current.selectedModelId).toBe("deepseek-v4-flash"))
expect(result.current.selectedModelInfo.contextWindow).toBe(1_000_000)
expect(mockResolveModelInfo).toHaveBeenCalledWith({ providerId: "deepseek", modelId: undefined })
expect(mockResolveProviderModels).not.toHaveBeenCalled()
})
it("falls back to legacy normalization for non-migrated providers", () => {
setApiConfiguration({ actModeApiProvider: "anthropic", actModeApiModelId: "claude-sonnet-4-5-20250929" })
const { result } = renderHook(() => useNormalizedApiConfiguration("act"))
expect(result.current.selectedProvider).toBe("anthropic")
expect(result.current.selectedModelId).toBe("claude-sonnet-4-5-20250929")
expect(mockResolveModelInfo).not.toHaveBeenCalled()
expect(mockResolveProviderModels).not.toHaveBeenCalled()
})
it("ignores stale DeepSeek responses after provider/model changes", async () => {
let resolveFirst: (value: ResolveModelInfoResponse) => void = () => undefined
mockResolveModelInfo
.mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve)))
.mockResolvedValueOnce(deepSeekResponse("deepseek-v4-flash", 1_000_000))
setApiConfiguration({ actModeApiProvider: "deepseek", actModeApiModelId: "deepseek-v4-pro" })
const { result, rerender } = renderHook(() => useNormalizedApiConfiguration("act"))
setApiConfiguration({ actModeApiProvider: "deepseek", actModeApiModelId: "deepseek-v4-flash" })
rerender()
await act(async () => {
resolveFirst(deepSeekResponse("deepseek-v4-pro", 500_000))
})
await waitFor(() => expect(result.current.selectedModelId).toBe("deepseek-v4-flash"))
expect(result.current.selectedModelInfo.contextWindow).toBe(1_000_000)
})
})
@@ -0,0 +1,70 @@
import { ResolveModelInfoRequest } from "@shared/proto/cline/models"
import { fromProtobufModelInfo } from "@shared/proto-conversions/models/typeConversion"
import { useEffect, useMemo, useState } from "react"
import { type NormalizedApiConfig, normalizeApiConfiguration } from "@/components/settings/utils/providerUtils"
import { type ProviderId, useExtensionState } from "@/context/ExtensionStateContext"
import { ModelsServiceClient } from "@/services/grpc-client"
import type { ModelInfo } from "../../../src/shared/api"
import type { Mode } from "../../../src/shared/storage/types"
const MIGRATED_PROVIDERS = new Set<ProviderId>(["deepseek"])
const unknownModelInfo: ModelInfo = {
supportsPromptCache: false,
}
function getActiveProviderAndModelId(apiConfiguration: ReturnType<typeof useExtensionState>["apiConfiguration"], mode: Mode) {
return {
provider: (mode === "plan" ? apiConfiguration?.planModeApiProvider : apiConfiguration?.actModeApiProvider) || "anthropic",
modelId: mode === "plan" ? apiConfiguration?.planModeApiModelId : apiConfiguration?.actModeApiModelId,
}
}
export function useNormalizedApiConfiguration(mode: Mode): NormalizedApiConfig {
const { apiConfiguration } = useExtensionState()
const { provider, modelId } = getActiveProviderAndModelId(apiConfiguration, mode)
const isMigrated = MIGRATED_PROVIDERS.has(provider)
const [resolvedInfo, setResolvedInfo] = useState<
Awaited<ReturnType<typeof ModelsServiceClient.resolveModelInfo>> | undefined
>(undefined)
useEffect(() => {
setResolvedInfo(undefined)
if (!isMigrated) {
return
}
let cancelled = false
void ModelsServiceClient.resolveModelInfo(
ResolveModelInfoRequest.create({ providerId: provider, modelId: modelId || undefined }),
).then((response) => {
if (!cancelled) {
setResolvedInfo(response)
}
})
return () => {
cancelled = true
}
}, [isMigrated, provider, modelId])
return useMemo(() => {
if (!isMigrated) {
return normalizeApiConfiguration(apiConfiguration, mode)
}
if (!resolvedInfo || resolvedInfo.source === "unknown" || !resolvedInfo.modelInfo) {
return {
selectedProvider: provider,
selectedModelId: resolvedInfo?.modelId || modelId || "",
selectedModelInfo: unknownModelInfo,
}
}
return {
selectedProvider: provider,
selectedModelId: resolvedInfo.modelId,
selectedModelInfo: fromProtobufModelInfo(resolvedInfo.modelInfo),
}
}, [apiConfiguration, mode, isMigrated, provider, modelId, resolvedInfo])
}