feat(kha-267): finalize multi-provider tabbed UI and state refactor#40
feat(kha-267): finalize multi-provider tabbed UI and state refactor#40KHAEntertainment wants to merge 6 commits intomainfrom
Conversation
…nthropic defaults On proxy stop, remove all ThroneKeeper env vars from .claude/settings.json and let Claude Code use its built-in defaults. This eliminates the fetchAnthropicDefaults() API call during revert, which failed for OAuth (Pro/Max) users and returned outdated 4.5-era model names. Also updates hardcoded fallbacks to current 4.6 -latest aliases. Closes: KHA-272 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- New provider-router.js: ProviderContext (per-tier state) + ProviderRouter (model→tier→provider mapping) classes with smart key validation - key-resolver.js: add resolveApiKeyForProvider() and buildUpstreamHeadersForContext() (additive exports, no breaking changes) - index.js: integrate router from MIXED_PROVIDERS_CONFIG env var; route each request to correct upstream provider; use effectiveProvider/BaseUrl/Key instead of globals inside /v1/messages handler - lib/config.js: add mixedProviders default (null = single-provider mode) - 29 unit tests covering ProviderContext, ProviderRouter, createRouterFromEnv, smart key validation, and mixed endpoint kinds (Anthropic + OpenAI in same session) 100% backward compatible: when MIXED_PROVIDERS_CONFIG is absent, all behavior is identical to single-provider mode.
…xed providers Phase 2: Extension & Config Schema - config.ts (v1.1.0): add TierProviderBindingSchema, MixedProviderConfigSchema, enableMixedProviders feature flag, mixed-provider invariant checks - messages.ts (v1.1.0): add SaveMixedProvidersMessage type, mixedProviders in ConfigLoaded payload, expandenableMixedProviders in feature flag enum - ProxyManager.ts: serialize MIXED_PROVIDERS_CONFIG env var with smart key resolution (resolves keys from SecretStorage per unique provider) - extension.ts: pass mixedProviders config when starting proxy - package.json: add claudeThrone.mixedProviders VS Code configuration property TypeScript compiles clean (zero errors). All 29 router tests pass.
…andler Phase 3: Webview UI - PanelViewProvider.ts: add saveMixedProviders message handler, handleSaveMixedProviders method, mixedProviders in config payload, enableMixedProviders in KNOWN_FLAGS, HTML toggle UI - main.js: add mixedProviders state, enableMixedProviders feature flag state, checkbox event listener, config hydration, visibility tied to 3-model mode Toggle appears as sub-option under '3 Different Models', hidden when disabled. Status indicator shows when mixed mode is active.
…tion invariant Phase 4: Presets, CLI Stubs & Documentation - docs/mixed-providers.md: Document mixed-provider architecture, schema, security - CONSTITUTION.md: Add Invariant 6 (Strict Isolation for Mixed Providers) - cli/commands/mixed.js: Create Phase 4 stub for CLI functionality - mixed-presets.json: Create Phase 4 stub for CLI presets
|
🤖 Hi @KHAEntertainment, I've received your request, and I'm working on it now! You can track my progress in the logs for more details. |
WalkthroughThis pull request introduces Mixed Provider Routing (KHA-267), enabling per-tier provider routing within a single session. A request's model name is resolved to a tier (reasoning/completion/value), each routed to a potentially different AI provider with distinct API keys and endpoints. Configuration, UI, routing logic, and validation are added across extension, proxy, and CLI layers. Changes
Sequence Diagram(s)sequenceDiagram
actor Client as Client<br/>(VS Code)
participant Webview as Webview UI
participant Extension as Extension<br/>Service
participant SecretStorage as VS Code<br/>SecretStorage
participant Proxy as Proxy<br/>Server
participant Router as ProviderRouter
participant Providers as AI Providers<br/>(Multiple)
Client->>Webview: Select reasoning/completion/value providers & models
Webview->>Extension: saveMixedProviders {enabled, reasoning, completion, value}
Extension->>SecretStorage: Retrieve API keys for each provider
Extension->>Extension: Serialize MIXED_PROVIDERS_CONFIG
Extension->>Proxy: Start proxy with mixed config
Client->>Proxy: POST /v1/messages {model: "reasoning-model"}
Proxy->>Router: resolve("reasoning-model")
Router->>Router: Lookup tier from model name
Router-->>Proxy: Return ProviderContext (provider ID, key, baseUrl)
Proxy->>Proxy: Construct effective headers<br/>(auth per provider type)
Proxy->>Providers: Forward request to routed provider<br/>with provider-specific headers
Providers-->>Proxy: Stream/return response
Proxy-->>Client: Relay response
Estimated Code Review Effort🎯 4 (Complex) | ⏱️ ~55 minutes Possibly Related PRs
Suggested Labels
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
🤖 I'm sorry @KHAEntertainment, but I was unable to process your request. Please see the logs for more details. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c33b1b147e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let endpointKind = 'openai'; | ||
| if (providers[pid]) { | ||
| if (pid === 'anthropic' || pid === 'bedrock') endpointKind = 'anthropic'; | ||
| } else if (state.customProviders) { |
There was a problem hiding this comment.
Preserve Anthropic endpoint kinds for mixed built-ins
buildTierPayload defaults every built-in provider tier to endpointKind: 'openai' and only special-cases anthropic/bedrock, so tiers using deepseek, glm, minimax, or kimi are serialized as OpenAI-compatible. In mixed mode this makes the proxy send /v1/chat/completions + Bearer auth instead of Anthropic-native /v1/messages + x-api-key, which breaks routed requests for those providers.
Useful? React with 👍 / 👎.
| let baseUrl = ''; | ||
| if (state.customProviders) { | ||
| const custom = state.customProviders.find(p => p.id === pid); | ||
| if (custom?.baseUrl) baseUrl = custom.baseUrl; |
There was a problem hiding this comment.
Include base URLs for mixed built-in provider tiers
For non-custom providers, the mixed-tier payload leaves baseUrl empty, so MIXED_PROVIDERS_CONFIG lacks per-tier upstream URLs. The runtime then falls back to the global base URL (routedContext?.baseUrl || normalizedBaseUrl), causing cross-provider mixes to be routed to the wrong backend host instead of each tier’s selected provider.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Actionable comments posted: 17
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
index.js (1)
954-960:⚠️ Potential issue | 🟠 MajorGuard uses global
endpointKindinstead ofeffectiveEndpointKind— potential routing bug.This guard at line 956 checks the global
endpointKind, but after the mixed-provider changes,effectiveEndpointKindmay differ from the global value.Scenario: If the global provider is Anthropic-native (e.g.,
glm) but a request routes to an OpenAI-compatible provider (e.g.,openrouter),isAnthropicNativeis correctlyfalse(fromeffectiveEndpointKind), so control reaches the OpenAI path. However, the guard still sees the globalendpointKindasANTHROPIC_NATIVEand throws an incorrect "endpoint kind mismatch" error.🐛 Proposed fix
// Comment 2: Guard - never run OpenAI↔Anthropic mapping when endpoint-kind is Anthropic // At this point, isAnthropicNative is false, so we proceed with OpenAI-compatible flow - if (endpointKind === ENDPOINT_KIND.ANTHROPIC_NATIVE) { + if (effectiveEndpointKind === ENDPOINT_KIND.ANTHROPIC_NATIVE) { console.error('[Guard Violation] Anthropic-native endpoint reached OpenAI conversion path - this should not happen') reply.code(500) return { error: { message: 'Internal error: endpoint kind mismatch', type: 'internal_error' } } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@index.js` around lines 954 - 960, The guard is checking the global endpointKind constant instead of the per-request effectiveEndpointKind, causing false positives; update the conditional in the guard (the block that currently tests "if (endpointKind === ENDPOINT_KIND.ANTHROPIC_NATIVE) { ... }") to use effectiveEndpointKind (or the same computed variable used to set isAnthropicNative) so the check reads against effectiveEndpointKind === ENDPOINT_KIND.ANTHROPIC_NATIVE and returns the error only when the effective (per-request) endpoint kind actually indicates Anthropic-native.extensions/thronekeeper/out/extension.js (1)
196-223:⚠️ Potential issue | 🟠 MajorRemove
-latestsuffix from hardcoded Claude fallback model IDs.The fallback aliases use
claude-opus-4-6-latest,claude-sonnet-4-6-latest, andclaude-haiku-4-5-latest, but Anthropic's current API aliases omit the-latestsuffix:claude-opus-4-6,claude-sonnet-4-6, andclaude-haiku-4-5. When the/v1/modelsendpoint is unavailable, these invalid IDs become the cached defaults and will fail when Claude Code tries to use them downstream. (docs.anthropic.com)Fix
- const opus = selectBestModel(opusModels, 'claude-opus-4-6-latest'); + const opus = selectBestModel(opusModels, 'claude-opus-4-6'); - const sonnet = selectBestModel(sonnetModels, 'claude-sonnet-4-6-latest'); + const sonnet = selectBestModel(sonnetModels, 'claude-sonnet-4-6'); - const haiku = selectBestModel(haikuModels, 'claude-haiku-4-5-latest'); + const haiku = selectBestModel(haikuModels, 'claude-haiku-4-5'); - return { opus: 'claude-opus-4-6-latest', sonnet: 'claude-sonnet-4-6-latest', haiku: 'claude-haiku-4-5-latest' }; + return { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5' };🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@extensions/thronekeeper/out/extension.js` around lines 196 - 223, The hardcoded Claude fallback IDs in the catch block of fetchAnthropicDefaults are using the deprecated "-latest" suffix and must be changed to the API aliases without it; update the return in the catch of fetchAnthropicDefaults to return opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5' (replace the current 'claude-opus-4-6-latest', 'claude-sonnet-4-6-latest', 'claude-haiku-4-5-latest') so cached defaults are valid for downstream Claude usage.
🟡 Minor comments (4)
docs/mixed-providers.md-61-73 (1)
61-73:⚠️ Potential issue | 🟡 MinorThis section no longer matches the implementation.
The code now resolves mixed-provider keys during proxy startup and already renders tabbed provider controls in the webview. Describing SecretStorage lookup as “per request” and the UI as “single toggle only” gives readers the wrong mental model right where they’ll copy their setup from.
As per coding guidelines, "Update documentation in
docs/andREADME.mdfor user-facing changes".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/mixed-providers.md` around lines 61 - 73, Update the docs to match current implementation: change the description of key resolution to state that mixed-provider keys are resolved at proxy startup (not per-request) and mention where startup-resolved keys come from (MIXED_PROVIDERS_CONFIG / mixed-presets.json / SecretStorage integration during startup), update the Webview UI section to reflect that tabbed provider controls are rendered (not a single mix-provider toggle), and remove or reword any references to per-request SecretStorage lookups and Phase 3b UI TODOs; reference ProviderRouter, effectiveProvider, MIXED_PROVIDERS_CONFIG, saveMixedProviders, mixed-presets.json and the Webview/tabbed provider controls while keeping the security/isolation guarantees intact.extensions/thronekeeper/src/views/PanelViewProvider.ts-2003-2046 (1)
2003-2046:⚠️ Potential issue | 🟡 MinorAdd input validation before accessing nested properties in
handleSaveMixedProviders.The method accesses nested properties like
msg.reasoning.providerId,msg.completion.baseUrl, etc. without first validating that these objects exist. If the webview sends a malformed message (or if there's a state synchronization issue), this could throw an uncaught exception.Think of it like unpacking a shipping box — you want to check the box actually contains inner boxes before reaching into them.
🛡️ Proposed fix
private async handleSaveMixedProviders(msg: any) { + // Validate required tier objects exist + if (!msg.reasoning || !msg.completion || !msg.value) { + this.log.appendLine(`[handleSaveMixedProviders] ERROR: Missing tier data in message`) + return + } + const cfg = vscode.workspace.getConfiguration('claudeThrone') const applyScope = cfg.get<string>('applyScope', 'workspace') const target = this.getConfigurationTarget(applyScope)🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@extensions/thronekeeper/src/views/PanelViewProvider.ts` around lines 2003 - 2046, handleSaveMixedProviders currently assumes msg and its nested objects (msg.reasoning, msg.completion, msg.value) exist and will throw if malformed; add input validation at the start of handleSaveMixedProviders to guard against missing or non-object msg fields (e.g., check msg && typeof msg === 'object' and each of msg.reasoning, msg.completion, msg.value are objects), log a clear error and return early if validation fails, and when building mixedConfig use safe access (optional chaining or defaults) to populate providerId/baseUrl/model/displayModel/endpointKind so cfg.update('mixedProviders', mixedConfig, target) and postConfig() are only called with a well-formed config.provider-router.js-281-288 (1)
281-288:⚠️ Potential issue | 🟡 MinorUse the injected env for debug gating.
Line 281 reads
process.env.DEBUGeven though this factory accepts anenvobject. That makescreateRouterFromEnv(fakeEnv)awkward to test and harder to reuse outside the main process, because the caller's DEBUG flag is ignored.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@provider-router.js` around lines 281 - 288, The debug gating currently reads process.env.DEBUG inside the createRouterFromEnv factory; change that to use the injected env parameter (e.g. env.DEBUG or env.debug) so callers of createRouterFromEnv(fakeEnv) control debug output. Update the conditional that prints router.toDebugObject() to check the env argument (and ensure createRouterFromEnv accepts/threads the env object through to this scope), leaving the existing log messages (router.toDebugObject(), validation.uniqueProviders, router.tierMap) unchanged.extensions/thronekeeper/out/extension.js-341-355 (1)
341-355:⚠️ Potential issue | 🟡 MinorUpdate the revert copy everywhere this command is surfaced.
This command now deliberately hands control back to Claude Code's built-in defaults, but
extensions/thronekeeper/src/views/PanelViewProvider.ts:1522-1530still logs and shows "Anthropic defaults". That makes the same success path read like two different products depending on where the user clicked.Also applies to: 439-445
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@extensions/thronekeeper/out/extension.js` around lines 341 - 355, The UI/logging still says "Anthropic defaults" even though updateClaudeSettings(settingsDir, ...) now restores Claude Code's built-in defaults; update the success message(s) in PanelViewProvider (the UI code that logs/shows the result around the success path) to say "Claude Code defaults" or "restored to Claude Code defaults" instead of "Anthropic defaults", and likewise find and replace the other occurrence referenced (the second block around 439-445) so all user-visible messages and logs match the new behavior of the updateClaudeSettings call.
🧹 Nitpick comments (3)
extensions/thronekeeper/src/services/Models.ts (1)
259-265: Use structured error metadata for manual-entry routing.Current string-only errors work, but they’re brittle for callers (like matching by prose). A stable error code makes handling deterministic.
♻️ Suggested hardening
- if (provider === 'minimax') { - throw new Error('Minimax models must be entered manually.') - } - if (provider === 'kimi') { - throw new Error('Kimi models must be entered manually.') - } + if (provider === 'minimax' || provider === 'kimi') { + const err = new Error(`${provider} models must be entered manually.`) + ;(err as any).code = 'MANUAL_MODEL_ENTRY_REQUIRED' + ;(err as any).provider = provider + throw err + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@extensions/thronekeeper/src/services/Models.ts` around lines 259 - 265, Replace brittle string-only throws in Models.ts with a structured error type: create and export a ManualEntryError class (e.g., class ManualEntryError extends Error) that includes a stable error code like code = 'MANUAL_ENTRY_REQUIRED' and a provider property, then change the two branches that currently do throw new Error('Minimax models must be entered manually.') / throw new Error('Kimi models must be entered manually.') to throw new ManualEntryError('Manual entry required for provider', { provider: 'minimax' }) / throw new ManualEntryError(..., { provider: 'kimi' }); ensure callers can use instanceof ManualEntryError or check error.code/provider to route to the Manual Entry UI.tests/provider-router.test.js (1)
269-273: Consider adding tests for partially malformed tier configs.The
ProviderRouterconstructor validates that all three tiers must be present, but per Context snippet 1 (lines 134-147), the constructor specifically checksconfig.reasoning,config.completion, andconfig.value. You've tested{},null, and single-tier configs — it would be valuable to add tests for configs where a tier exists but has malformed/empty nested data (e.g.,{ reasoning: {}, completion: {...}, value: {...} }).This helps verify the router gracefully handles edge cases from potentially corrupted
MIXED_PROVIDERS_CONFIGserialization.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/provider-router.test.js` around lines 269 - 273, Add tests that exercise partially malformed tier configs for the ProviderRouter constructor: create cases where config has all three top-level keys but one tier is an empty object or missing required nested fields (e.g., reasoning: {}, completion: {…}, value: {…}) and assert that new ProviderRouter(...) throws. Target the ProviderRouter constructor and add assertions similar to the existing tests to cover combinations like { reasoning: {}, completion: valid, value: valid }, { reasoning: valid, completion: {}, value: valid } and { reasoning: valid, completion: valid, value: {} } to ensure malformed nested tier data is rejected.extensions/thronekeeper/out/extension.js (1)
1034-1034: Don't push the same disposables twice.These command disposables were already added to
context.subscriptionsat Line 507. Adding them again here means shutdown walks the same handles twice; probably harmless today, but it's an easy cleanup footgun.As per coding guidelines "Prevent duplicate event listeners; check cleanup in Start/Stop hydration sequence."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@extensions/thronekeeper/out/extension.js` at line 1034, The listed disposables (openPanel, storeOpenRouterKey, storeOpenAIKey, storeTogetherKey, storeDeepseekKey, storeGlmKey, storeKimiKey, storeMinimaxKey, storeCustomKey, storeAnyKey, storeAnthropicKey, refreshAnthropicDefaults, startProxy, stopProxy, status, applyToClaudeCode, revertApply, log, checkConfigHealthCommand) are being pushed into context.subscriptions a second time; remove this duplicate push and ensure these disposables are only added once (reuse the initial push that occurs earlier around the first registration instead of re-pushing them here), or centralize the array of disposables into a single variable and push that variable only once during activation to prevent duplicate disposal on shutdown.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@extensions/thronekeeper/bundled/proxy/index.cjs`:
- Around line 34022-34043: The constructor that builds tierMap currently
overwrites duplicate model IDs; update the population logic (the for loop that
iterates Object.entries(this.contexts) and calls this.tierMap.set) to detect
duplicates: if ctx.model is already present in this.tierMap (use
this.tierMap.has(ctx.model)) then reject/raise an error or log and abort
initialization (or otherwise surface a config validation error) citing both tier
names and the conflicting model ID; alternatively, if duplicates are
intentionally allowed, change the lookup strategy used by resolve(modelName) to
include the tier signal (e.g., use a composite key or maintain a map of
modelName -> array of {tier,context}) and update resolve() to disambiguate based
on payload-provided tier. Ensure references to tierMap, resolve(), and the
constructor/context population loop are updated consistently.
- Around line 34710-34743: When a router exists and payload.model is provided
but router.resolve(payload.model) returns falsy, fail closed instead of silently
falling back: return a 400 (or appropriate) error and stop processing rather
than using defaults. Ensure the code path uses routedContext as the single
source of truth when it exists (set routedContext from router.resolve and derive
effectiveProvider/effectiveBaseUrl/effectiveKey/effectiveEndpointKind and
headers exclusively from routedContext.getHeaders()), and only use
normalizedBaseUrl/provider/key/buildUpstreamHeaders when there is no router or
no payload.model; update the logic around router.resolve, routedContext,
effective* variables, headers, and the missing-key check to be deterministic and
avoid mixed fallback behavior (functions/vars: router.resolve, routedContext,
effectiveProvider/effectiveBaseUrl/effectiveKey/effectiveEndpointKind, headers,
ensureEndpointKindReady, buildUpstreamHeaders).
In `@extensions/thronekeeper/out/services/ProxyManager.js`:
- Around line 480-488: When mixed mode is enabled in ProxyManager, the startup
must fail fast if any provider key is missing: in the loop that iterates
uniqueProviders (the block using this.secrets.getProviderKey and providerKeys),
if a key lookup returns falsy for any pid, throw a descriptive Error (e.g.
`throw new Error('[ProxyManager] Mixed mode enabled but missing provider key
for: ' + pid)`) instead of only logging a warning; ensure the thrown error
bubbles out of the initialization/startup routine so startup is blocked. Apply
the same change to the other similar block (around the 490-503 area) that checks
provider keys so both code paths enforce the fail-fast behavior.
In `@extensions/thronekeeper/out/views/PanelViewProvider.js`:
- Around line 563-565: The current implementation of handleListModels uses a
single this.currentSequenceToken for all providers, causing cross-provider
race-condition drops; change to track tokens and cached models per provider/tab:
introduce a map like this.sequenceTokens (keyed by requestedProvider or
this.runtimeProvider fallback) and a models cache keyed by provider, increment
the provider-specific token when issuing a request in handleListModels, store
the issued token in a local variable, and before discarding a late response
compare the response token against this.sequenceTokens[provider]; update cache
this.modelsByProvider[provider] on successful responses. Use the symbols
handleListModels, requestedProvider, this.runtimeProvider,
this.currentSequenceToken (replace), and create this.sequenceTokens and
this.modelsByProvider to locate and modify the code.
- Around line 1814-1849: handleStartProxy currently ignores the saved
mixedProviders; update handleStartProxy to read the saved mixedProviders from
the vscode configuration
(vscode.workspace.getConfiguration('claudeThrone').get('mixedProviders')) or use
this.getConfigurationTarget like handleSaveMixedProviders, and pass that config
into this.proxy.start(...) (or into whatever startOptions object the proxy
expects) so the proxy is started in mixed-provider mode when the panel's
Save/Start flow is used; keep references to handleSaveMixedProviders,
mixedProviders, and this.proxy.start when making the change and add a log line
indicating the mixed config being passed.
In `@extensions/thronekeeper/src/extension.ts`:
- Around line 597-611: Validate mixedProviders before calling proxy.start:
import and use MixedProviderConfigSchema.safeParse (from
extensions/claude-throne/src/schemas) to validate the cfg.get result stored in
mixedProviders; if safeParse returns success, pass the parsed value to
proxy.start, otherwise log the validation errors via log.appendLine and throw or
abort startup so the service fails fast instead of silently falling back to
single-provider mode; update the code around the mixedProviders check and the
await proxy!.start(...) call to use the validated value.
In `@extensions/thronekeeper/src/services/ProxyManager.ts`:
- Around line 494-503: The loop in ProxyManager that builds providerKeys uses
this.secrets.getProviderKey(pid) which misses the special Anthropic secret path
used elsewhere, so mixed tiers end up with null keys and only a warning is
logged; update the loop in ProxyManager to resolve Anthropic with the dedicated
secret method used by the codebase (e.g., call the same Anthropic retrieval like
this.secrets.getAnthropicKey(pid) or equivalent when pid === 'anthropic') and,
for any provider where no key is returned, fail fast by throwing an error (or
return a rejected promise) instead of just logging a warning so the proxy does
not start with missing keys.
In `@extensions/thronekeeper/webview/main.js`:
- Around line 3345-3372: The add button click handler in initMixedTabListeners
currently flips threeModelToggle.checked and sets state.twoModelMode directly
without invoking onTwoModelToggle() or persisting the change, so the UI change
isn't saved; update the handler to (1) set threeModelToggle.checked = true only
if needed and then call onTwoModelToggle() to run the normal toggle logic
(instead of directly setting state.twoModelMode and calling updateTwoModelUI()),
(2) after incrementing mixedTabState.tabCount and switching the tab
(switchToTab(tabId)), call saveState() and saveMixedState() so the new
multi-tab/three-model state is persisted. Ensure you still update the
addBtn/tabBtn display logic and only call these functions when the
checkbox/threeToggle transition actually occurs.
- Around line 3551-3552: The mixed-tab event bindings are being initialized at
module load which can run before the DOM exists; move the
initMixedTabListeners() invocation into the DOM-ready initialization path
(inside the existing init() function or the DOMContentLoaded handler that init()
uses) so it runs after markup is available; locate the initMixedTabListeners()
call and remove the top-level module invocation, then call
initMixedTabListeners() from within init() (or the same DOMContentLoaded
callback) to ensure add/close/tab buttons are bound reliably.
- Around line 3391-3432: switchToTab currently changes state.provider and model
selections directly but doesn't run the same sync/update steps as normal
provider changes; call the same routines after you set state.provider and models
so UI and engine stay in sync: invoke updateProviderUI(),
updateSelectedModelsDisplay(), persist by calling saveState(), and send the same
"updateProvider" message (or call the existing updateProvider handler) so the
extension host receives the change; ensure these calls occur right after you set
state.provider and update
state.reasoningModel/state.codingModel/state.valueModel (and after models are
loaded if loadModels is asynchronous).
- Around line 564-580: The mixedProvidersCheckbox change currently never
persists a disabled state because saveMixedState() only emits an enabled config;
update saveMixedState() so it always writes the current enabled value (including
enabled: false) into the outgoing config payload (e.g., emit
config.mixedProviders = { enabled: state.featureFlags.enableMixedProviders }
even when false), and ensure handleConfigLoaded() reads
config.mixedProviders.enabled and applies it to
state.featureFlags.enableMixedProviders and the UI
(mixedProvidersCheckbox.checked and updateMixedProvidersUI()) so unchecking
truly persists and is restored on hydrate/reload; reference the
mixedProvidersCheckbox change handler, saveMixedState(), and
handleConfigLoaded() when making these edits.
- Around line 3515-3538: buildTierPayload currently determines endpointKind from
providers or custom.provider.endpointKind but ignores state.endpointOverrides
and thus mislabels anthopic-compatible providers like "deepseek" and "glm";
update buildTierPayload to first check state.endpointOverrides for an override
entry for the pid and use that endpointKind if present, then fall back to: if
providers[pid] exists use the provider-based logic (treat "anthropic",
"bedrock", and declared anthopic-compatible ids like "deepseek" and "glm" as
'anthropic'), else look at state.customProviders for baseUrl only (do not use
custom.endpointKind) and finally default to 'openai'; ensure the function
returns endpointKind set from endpointOverrides when available and only uses
automatic detection otherwise.
In `@key-resolver.js`:
- Around line 397-405: The branch that sets headers based on ctx.endpointKind
only checks ENDPOINT_KIND.ANTHROPIC_NATIVE so contexts with canonical
'anthropic' get the wrong Authorization header; update the logic in the
headers-setting block (where ctx.endpointKind is inspected and headers are
assigned: headers['x-api-key'], headers['anthropic-version'],
headers['anthropic-beta'], else Authorization) to normalize or accept canonical
values — e.g., compare ctx.endpointKind after lowercasing to both
ENDPOINT_KIND.ANTHROPIC_NATIVE and the canonical string 'anthropic' (or map
canonical names to constants) so requests for Anthropic use x-api-key and
related headers instead of Bearer.
In `@mixed-presets.json`:
- Around line 6-20: The preset entries under "config" (the "reasoning",
"completion", and "value" tier blocks) are missing the required mixed-provider
field `baseUrl`, so they fail mixed-provider schema validation; update each tier
object (reasoning, completion, value) to include the provider's `baseUrl`
alongside `providerId` and `model` (and ensure the same fix is applied to the
other similar preset block noted in the comment), using the actual endpoint for
each provider so the mixedProviders loader passes validation.
In `@provider-router.js`:
- Around line 263-269: Parse MIXED_PROVIDERS_CONFIG as before but validate the
resulting object against the shared mixed-provider schema in
extensions/claude-throne/src/schemas/ before constructing ProviderRouter: run
the parsed config through the schema validator (use the existing
schema/validator helper), convert any deprecated "coding" key to "completion"
(or merge its contents into "completion"), and ensure required fields like
provider.baseUrl and provider.model are present and strings; if validation
fails, log the validation errors and return null instead of calling new
ProviderRouter(config, endpointOverrides), only constructing ProviderRouter
after the schema validator succeeds.
- Around line 167-173: When building the model → { tier, context } lookup (the
this.tierMap population that iterates this.contexts and calls Map.set with
ctx.model), detect if a model key already exists and the existing entry points
at a different upstream context; in that case reject the configuration (throw or
log and exit) instead of overwriting so ambiguous model collisions are not
allowed; implement this check using this.tierMap.has(model) and compare the
existing entry’s context/tier to ctx before deciding to set, and apply the same
guard to the other identical population block around the 190-194 region.
---
Outside diff comments:
In `@extensions/thronekeeper/out/extension.js`:
- Around line 196-223: The hardcoded Claude fallback IDs in the catch block of
fetchAnthropicDefaults are using the deprecated "-latest" suffix and must be
changed to the API aliases without it; update the return in the catch of
fetchAnthropicDefaults to return opus: 'claude-opus-4-6', sonnet:
'claude-sonnet-4-6', haiku: 'claude-haiku-4-5' (replace the current
'claude-opus-4-6-latest', 'claude-sonnet-4-6-latest', 'claude-haiku-4-5-latest')
so cached defaults are valid for downstream Claude usage.
In `@index.js`:
- Around line 954-960: The guard is checking the global endpointKind constant
instead of the per-request effectiveEndpointKind, causing false positives;
update the conditional in the guard (the block that currently tests "if
(endpointKind === ENDPOINT_KIND.ANTHROPIC_NATIVE) { ... }") to use
effectiveEndpointKind (or the same computed variable used to set
isAnthropicNative) so the check reads against effectiveEndpointKind ===
ENDPOINT_KIND.ANTHROPIC_NATIVE and returns the error only when the effective
(per-request) endpoint kind actually indicates Anthropic-native.
---
Minor comments:
In `@docs/mixed-providers.md`:
- Around line 61-73: Update the docs to match current implementation: change the
description of key resolution to state that mixed-provider keys are resolved at
proxy startup (not per-request) and mention where startup-resolved keys come
from (MIXED_PROVIDERS_CONFIG / mixed-presets.json / SecretStorage integration
during startup), update the Webview UI section to reflect that tabbed provider
controls are rendered (not a single mix-provider toggle), and remove or reword
any references to per-request SecretStorage lookups and Phase 3b UI TODOs;
reference ProviderRouter, effectiveProvider, MIXED_PROVIDERS_CONFIG,
saveMixedProviders, mixed-presets.json and the Webview/tabbed provider controls
while keeping the security/isolation guarantees intact.
In `@extensions/thronekeeper/out/extension.js`:
- Around line 341-355: The UI/logging still says "Anthropic defaults" even
though updateClaudeSettings(settingsDir, ...) now restores Claude Code's
built-in defaults; update the success message(s) in PanelViewProvider (the UI
code that logs/shows the result around the success path) to say "Claude Code
defaults" or "restored to Claude Code defaults" instead of "Anthropic defaults",
and likewise find and replace the other occurrence referenced (the second block
around 439-445) so all user-visible messages and logs match the new behavior of
the updateClaudeSettings call.
In `@extensions/thronekeeper/src/views/PanelViewProvider.ts`:
- Around line 2003-2046: handleSaveMixedProviders currently assumes msg and its
nested objects (msg.reasoning, msg.completion, msg.value) exist and will throw
if malformed; add input validation at the start of handleSaveMixedProviders to
guard against missing or non-object msg fields (e.g., check msg && typeof msg
=== 'object' and each of msg.reasoning, msg.completion, msg.value are objects),
log a clear error and return early if validation fails, and when building
mixedConfig use safe access (optional chaining or defaults) to populate
providerId/baseUrl/model/displayModel/endpointKind so
cfg.update('mixedProviders', mixedConfig, target) and postConfig() are only
called with a well-formed config.
In `@provider-router.js`:
- Around line 281-288: The debug gating currently reads process.env.DEBUG inside
the createRouterFromEnv factory; change that to use the injected env parameter
(e.g. env.DEBUG or env.debug) so callers of createRouterFromEnv(fakeEnv) control
debug output. Update the conditional that prints router.toDebugObject() to check
the env argument (and ensure createRouterFromEnv accepts/threads the env object
through to this scope), leaving the existing log messages
(router.toDebugObject(), validation.uniqueProviders, router.tierMap) unchanged.
---
Nitpick comments:
In `@extensions/thronekeeper/out/extension.js`:
- Line 1034: The listed disposables (openPanel, storeOpenRouterKey,
storeOpenAIKey, storeTogetherKey, storeDeepseekKey, storeGlmKey, storeKimiKey,
storeMinimaxKey, storeCustomKey, storeAnyKey, storeAnthropicKey,
refreshAnthropicDefaults, startProxy, stopProxy, status, applyToClaudeCode,
revertApply, log, checkConfigHealthCommand) are being pushed into
context.subscriptions a second time; remove this duplicate push and ensure these
disposables are only added once (reuse the initial push that occurs earlier
around the first registration instead of re-pushing them here), or centralize
the array of disposables into a single variable and push that variable only once
during activation to prevent duplicate disposal on shutdown.
In `@extensions/thronekeeper/src/services/Models.ts`:
- Around line 259-265: Replace brittle string-only throws in Models.ts with a
structured error type: create and export a ManualEntryError class (e.g., class
ManualEntryError extends Error) that includes a stable error code like code =
'MANUAL_ENTRY_REQUIRED' and a provider property, then change the two branches
that currently do throw new Error('Minimax models must be entered manually.') /
throw new Error('Kimi models must be entered manually.') to throw new
ManualEntryError('Manual entry required for provider', { provider: 'minimax' })
/ throw new ManualEntryError(..., { provider: 'kimi' }); ensure callers can use
instanceof ManualEntryError or check error.code/provider to route to the Manual
Entry UI.
In `@tests/provider-router.test.js`:
- Around line 269-273: Add tests that exercise partially malformed tier configs
for the ProviderRouter constructor: create cases where config has all three
top-level keys but one tier is an empty object or missing required nested fields
(e.g., reasoning: {}, completion: {…}, value: {…}) and assert that new
ProviderRouter(...) throws. Target the ProviderRouter constructor and add
assertions similar to the existing tests to cover combinations like { reasoning:
{}, completion: valid, value: valid }, { reasoning: valid, completion: {},
value: valid } and { reasoning: valid, completion: valid, value: {} } to ensure
malformed nested tier data is rejected.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 573648f7-4bc4-43ad-9018-7b4ac03b940e
⛔ Files ignored due to path filters (8)
extensions/thronekeeper/out/extension.js.mapis excluded by!**/*.mapextensions/thronekeeper/out/schemas/config.js.mapis excluded by!**/*.mapextensions/thronekeeper/out/schemas/messages.js.mapis excluded by!**/*.mapextensions/thronekeeper/out/services/Models.js.mapis excluded by!**/*.mapextensions/thronekeeper/out/services/ProxyManager.js.mapis excluded by!**/*.mapextensions/thronekeeper/out/views/PanelViewProvider.js.mapis excluded by!**/*.mapextensions/thronekeeper/package-lock.jsonis excluded by!**/package-lock.jsonpackage-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (24)
CONSTITUTION.mdcli/commands/mixed.jsdocs/mixed-providers.mdextensions/thronekeeper/bundled/proxy/index.cjsextensions/thronekeeper/out/extension.jsextensions/thronekeeper/out/schemas/config.jsextensions/thronekeeper/out/schemas/messages.jsextensions/thronekeeper/out/services/Models.jsextensions/thronekeeper/out/services/ProxyManager.jsextensions/thronekeeper/out/views/PanelViewProvider.jsextensions/thronekeeper/package.jsonextensions/thronekeeper/src/extension.tsextensions/thronekeeper/src/schemas/config.tsextensions/thronekeeper/src/schemas/messages.tsextensions/thronekeeper/src/services/Models.tsextensions/thronekeeper/src/services/ProxyManager.tsextensions/thronekeeper/src/views/PanelViewProvider.tsextensions/thronekeeper/webview/main.jsindex.jskey-resolver.jslib/config.jsmixed-presets.jsonprovider-router.jstests/provider-router.test.js
| this.tierMap = /* @__PURE__ */ new Map(); | ||
| for (const [tier, ctx] of Object.entries(this.contexts)) { | ||
| if (ctx.model) { | ||
| this.tierMap.set(ctx.model, { tier, context: ctx }); | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Given a model name from an incoming request, resolve which ProviderContext to use. | ||
| * | ||
| * @param {string} modelName - The model name from payload.model | ||
| * @returns {{ tier: string, context: ProviderContext } | null} Resolved context, or null if not found | ||
| */ | ||
| resolve(modelName) { | ||
| if (!modelName) return null; | ||
| const exact = this.tierMap.get(modelName); | ||
| if (exact) return exact; | ||
| const lower = modelName.toLowerCase(); | ||
| for (const [name, entry] of this.tierMap) { | ||
| if (name.toLowerCase() === lower) return entry; | ||
| } | ||
| return null; |
There was a problem hiding this comment.
Reject duplicate model IDs across tiers.
tierMap is a single mailbox per model name. If two tiers reuse the same model ID—especially across custom or OpenAI-compatible providers exposing the same upstream name—the later set() wins and resolve() sends both requests through one context.
Possible guard
this.tierMap = /* `@__PURE__` */ new Map();
+ const normalizedModels = /* `@__PURE__` */ new Map();
for (const [tier, ctx] of Object.entries(this.contexts)) {
if (ctx.model) {
+ const normalized = ctx.model.toLowerCase();
+ const existingTier = normalizedModels.get(normalized);
+ if (existingTier) {
+ throw new Error(
+ `[ProviderRouter] Model "${ctx.model}" is assigned to both "${existingTier}" and "${tier}"`
+ );
+ }
+ normalizedModels.set(normalized, tier);
this.tierMap.set(ctx.model, { tier, context: ctx });
}
}If duplicate model IDs are meant to be supported, the lookup key needs an explicit tier signal instead of bare payload.model.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| this.tierMap = /* @__PURE__ */ new Map(); | |
| for (const [tier, ctx] of Object.entries(this.contexts)) { | |
| if (ctx.model) { | |
| this.tierMap.set(ctx.model, { tier, context: ctx }); | |
| } | |
| } | |
| } | |
| /** | |
| * Given a model name from an incoming request, resolve which ProviderContext to use. | |
| * | |
| * @param {string} modelName - The model name from payload.model | |
| * @returns {{ tier: string, context: ProviderContext } | null} Resolved context, or null if not found | |
| */ | |
| resolve(modelName) { | |
| if (!modelName) return null; | |
| const exact = this.tierMap.get(modelName); | |
| if (exact) return exact; | |
| const lower = modelName.toLowerCase(); | |
| for (const [name, entry] of this.tierMap) { | |
| if (name.toLowerCase() === lower) return entry; | |
| } | |
| return null; | |
| this.tierMap = /* `@__PURE__` */ new Map(); | |
| const normalizedModels = /* `@__PURE__` */ new Map(); | |
| for (const [tier, ctx] of Object.entries(this.contexts)) { | |
| if (ctx.model) { | |
| const normalized = ctx.model.toLowerCase(); | |
| const existingTier = normalizedModels.get(normalized); | |
| if (existingTier) { | |
| throw new Error( | |
| `[ProviderRouter] Model "${ctx.model}" is assigned to both "${existingTier}" and "${tier}"` | |
| ); | |
| } | |
| normalizedModels.set(normalized, tier); | |
| this.tierMap.set(ctx.model, { tier, context: ctx }); | |
| } | |
| } | |
| } | |
| /** | |
| * Given a model name from an incoming request, resolve which ProviderContext to use. | |
| * | |
| * `@param` {string} modelName - The model name from payload.model | |
| * `@returns` {{ tier: string, context: ProviderContext } | null} Resolved context, or null if not found | |
| */ | |
| resolve(modelName) { | |
| if (!modelName) return null; | |
| const exact = this.tierMap.get(modelName); | |
| if (exact) return exact; | |
| const lower = modelName.toLowerCase(); | |
| for (const [name, entry] of this.tierMap) { | |
| if (name.toLowerCase() === lower) return entry; | |
| } | |
| return null; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@extensions/thronekeeper/bundled/proxy/index.cjs` around lines 34022 - 34043,
The constructor that builds tierMap currently overwrites duplicate model IDs;
update the population logic (the for loop that iterates
Object.entries(this.contexts) and calls this.tierMap.set) to detect duplicates:
if ctx.model is already present in this.tierMap (use
this.tierMap.has(ctx.model)) then reject/raise an error or log and abort
initialization (or otherwise surface a config validation error) citing both tier
names and the conflicting model ID; alternatively, if duplicates are
intentionally allowed, change the lookup strategy used by resolve(modelName) to
include the tier signal (e.g., use a composite key or maintain a map of
modelName -> array of {tier,context}) and update resolve() to disambiguate based
on payload-provided tier. Ensure references to tierMap, resolve(), and the
constructor/context population loop are updated consistently.
| if (router && payload.model) { | ||
| const resolved = router.resolve(payload.model); | ||
| if (resolved) { | ||
| routedContext = resolved.context; | ||
| console.log(`[Mixed Router] Model "${payload.model}" \u2192 tier "${resolved.tier}" \u2192 provider "${routedContext.providerId}" (${routedContext.endpointKind})`); | ||
| } else { | ||
| console.log(`[Mixed Router] Model "${payload.model}" not in tier map, falling back to default provider`); | ||
| } | ||
| } | ||
| const isAnthropicNative = endpointKind === ENDPOINT_KIND.ANTHROPIC_NATIVE; | ||
| if (!key) { | ||
| const effectiveProvider = routedContext?.providerId || provider; | ||
| const effectiveBaseUrl = routedContext?.baseUrl || normalizedBaseUrl; | ||
| const effectiveKey = routedContext?.key || key; | ||
| const effectiveEndpointKind = routedContext?.endpointKind || endpointKind; | ||
| if (!routedContext) { | ||
| const negotiationError = await ensureEndpointKindReady(); | ||
| if (negotiationError) { | ||
| reply.code(negotiationError.statusCode); | ||
| return negotiationError.body; | ||
| } | ||
| } | ||
| const isAnthropicNative = effectiveEndpointKind === ENDPOINT_KIND.ANTHROPIC_NATIVE; | ||
| if (!effectiveKey) { | ||
| reply.code(400); | ||
| const hint = isAnthropicNative ? `Store the provider API key in the extension (Thronekeeper: Store ${provider === "deepseek" ? "Deepseek" : provider === "glm" ? "GLM" : provider} API Key) or set the correct env var (${provider === "deepseek" ? "DEEPSEEK_API_KEY" : provider === "glm" ? "ZAI_API_KEY or GLM_API_KEY" : "API_KEY"}), and confirm the provider switch in settings.` : `Use Authorization: Bearer <token> header or configure ${provider === "openrouter" ? "OpenRouter" : provider} API key in the extension settings.`; | ||
| const hint = isAnthropicNative ? `Store the provider API key in the extension (Thronekeeper: Store ${effectiveProvider === "deepseek" ? "Deepseek" : effectiveProvider === "glm" ? "GLM" : effectiveProvider} API Key) or set the correct env var (${effectiveProvider === "deepseek" ? "DEEPSEEK_API_KEY" : effectiveProvider === "glm" ? "ZAI_API_KEY or GLM_API_KEY" : "API_KEY"}), and confirm the provider switch in settings.` : `Use Authorization: Bearer <token> header or configure ${effectiveProvider === "openrouter" ? "OpenRouter" : effectiveProvider} API key in the extension settings.`; | ||
| return { | ||
| error: { | ||
| message: `No API key found for provider "${provider}". Checked ${KEY_ENV_HINT}.`, | ||
| message: `No API key found for provider "${effectiveProvider}". Checked ${KEY_ENV_HINT}.`, | ||
| type: "missing_api_key", | ||
| hint | ||
| } | ||
| }; | ||
| } | ||
| const requestUrl = isAnthropicNative ? `${normalizedBaseUrl}/v1/messages` : `${normalizedBaseUrl}/v1/chat/completions`; | ||
| const headers = buildUpstreamHeaders({ provider, endpointKind, key }); | ||
| const requestUrl = isAnthropicNative ? `${effectiveBaseUrl}/v1/messages` : `${effectiveBaseUrl}/v1/chat/completions`; | ||
| const headers = routedContext ? routedContext.getHeaders() : buildUpstreamHeaders({ provider, endpointKind, key }); |
There was a problem hiding this comment.
Fail closed and keep routed config to one source of truth.
This branch currently mixes two routing sources: unresolved models silently fall back to the default provider, while effective* values can fall back to defaults even though headers still come from routedContext. That can turn a config miss into a request sent to the wrong upstream, like a train switch snapping back to the main line when the destination plate is missing.
One way to make the path deterministic
if (router && payload.model) {
const resolved = router.resolve(payload.model);
if (resolved) {
routedContext = resolved.context;
console.log(`[Mixed Router] Model "${payload.model}" → tier "${resolved.tier}" → provider "${routedContext.providerId}" (${routedContext.endpointKind})`);
} else {
- console.log(`[Mixed Router] Model "${payload.model}" not in tier map, falling back to default provider`);
+ reply.code(400);
+ return {
+ error: {
+ type: "invalid_model",
+ message: `Model "${payload.model}" is not configured in MIXED_PROVIDERS_CONFIG`
+ }
+ };
}
}
- const effectiveProvider = routedContext?.providerId || provider;
- const effectiveBaseUrl = routedContext?.baseUrl || normalizedBaseUrl;
- const effectiveKey = routedContext?.key || key;
- const effectiveEndpointKind = routedContext?.endpointKind || endpointKind;
+ const effectiveProvider = routedContext ? routedContext.providerId : provider;
+ const effectiveBaseUrl = routedContext ? routedContext.baseUrl : normalizedBaseUrl;
+ const effectiveKey = routedContext ? routedContext.key : key;
+ const effectiveEndpointKind = routedContext ? routedContext.endpointKind : endpointKind;
@@
- const headers = routedContext ? routedContext.getHeaders() : buildUpstreamHeaders({ provider, endpointKind, key });
+ const headers = buildUpstreamHeaders({
+ provider: effectiveProvider,
+ endpointKind: effectiveEndpointKind,
+ key: effectiveKey
+ });That makes the missing-key check trustworthy again and avoids silent reroutes in mixed mode.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@extensions/thronekeeper/bundled/proxy/index.cjs` around lines 34710 - 34743,
When a router exists and payload.model is provided but
router.resolve(payload.model) returns falsy, fail closed instead of silently
falling back: return a 400 (or appropriate) error and stop processing rather
than using defaults. Ensure the code path uses routedContext as the single
source of truth when it exists (set routedContext from router.resolve and derive
effectiveProvider/effectiveBaseUrl/effectiveKey/effectiveEndpointKind and
headers exclusively from routedContext.getHeaders()), and only use
normalizedBaseUrl/provider/key/buildUpstreamHeaders when there is no router or
no payload.model; update the logic around router.resolve, routedContext,
effective* variables, headers, and the missing-key check to be deterministic and
avoid mixed fallback behavior (functions/vars: router.resolve, routedContext,
effectiveProvider/effectiveBaseUrl/effectiveKey/effectiveEndpointKind, headers,
ensureEndpointKindReady, buildUpstreamHeaders).
| // KHA-267: Serialize mixed-provider config if enabled | ||
| if (opts.mixedProviders?.enabled) { | ||
| try { | ||
| const mp = opts.mixedProviders; | ||
| const tiers = ['reasoning', 'completion', 'value']; | ||
| // Collect unique provider IDs to resolve keys | ||
| const uniqueProviders = new Set(); | ||
| for (const tier of tiers) { | ||
| uniqueProviders.add(mp[tier].providerId); | ||
| } | ||
| // Resolve keys for each unique provider (smart key validation) | ||
| const providerKeys = new Map(); | ||
| for (const pid of uniqueProviders) { | ||
| const key = await this.secrets.getProviderKey(pid); | ||
| if (key) { | ||
| providerKeys.set(pid, key); | ||
| this.log.appendLine(`[ProxyManager] Mixed provider key found for: ${pid}`); | ||
| } | ||
| else { | ||
| this.log.appendLine(`[ProxyManager] WARNING: No key found for mixed provider: ${pid}`); | ||
| } | ||
| } | ||
| // Build MIXED_PROVIDERS_CONFIG object | ||
| const mixedConfig = {}; | ||
| for (const tier of tiers) { | ||
| const binding = mp[tier]; | ||
| mixedConfig[tier] = { | ||
| providerId: binding.providerId, | ||
| baseUrl: binding.baseUrl, | ||
| key: providerKeys.get(binding.providerId) || null, | ||
| model: binding.model, | ||
| endpointKind: binding.endpointKind || undefined, | ||
| }; | ||
| } | ||
| base.MIXED_PROVIDERS_CONFIG = JSON.stringify(mixedConfig); | ||
| this.log.appendLine(`[ProxyManager] Mixed provider config serialized: ${uniqueProviders.size} unique providers`); | ||
| this.log.appendLine(`[ProxyManager] Mixed tiers: reasoning=${mp.reasoning.providerId}/${mp.reasoning.model}, completion=${mp.completion.providerId}/${mp.completion.model}, value=${mp.value.providerId}/${mp.value.model}`); | ||
| } | ||
| catch (err) { | ||
| this.log.appendLine(`[ProxyManager] Failed to serialize mixed provider config: ${err}`); | ||
| } | ||
| } |
There was a problem hiding this comment.
Clear MIXED_PROVIDERS_CONFIG when mixed mode is disabled.
Because base starts from process.env, a pre-existing MIXED_PROVIDERS_CONFIG can accidentally keep mixed routing active even when the feature is off.
🧹 Suggested fix
if (opts.mixedProviders?.enabled) {
try {
// ...
}
catch (err) {
this.log.appendLine(`[ProxyManager] Failed to serialize mixed provider config: ${err}`);
}
+ } else {
+ delete base.MIXED_PROVIDERS_CONFIG;
}| for (const pid of uniqueProviders) { | ||
| const key = await this.secrets.getProviderKey(pid); | ||
| if (key) { | ||
| providerKeys.set(pid, key); | ||
| this.log.appendLine(`[ProxyManager] Mixed provider key found for: ${pid}`); | ||
| } | ||
| else { | ||
| this.log.appendLine(`[ProxyManager] WARNING: No key found for mixed provider: ${pid}`); | ||
| } |
There was a problem hiding this comment.
Fail fast when mixed mode is enabled but provider keys are missing.
Right now this is like launching a 3-engine plane after confirming only 2 engines have fuel: startup succeeds, but failures happen mid-flight (401s). Block startup if any referenced provider key is missing.
🛠️ Suggested fix
- const providerKeys = new Map();
+ const providerKeys = new Map();
+ const missingProviders = [];
for (const pid of uniqueProviders) {
const key = await this.secrets.getProviderKey(pid);
if (key) {
providerKeys.set(pid, key);
this.log.appendLine(`[ProxyManager] Mixed provider key found for: ${pid}`);
}
else {
this.log.appendLine(`[ProxyManager] WARNING: No key found for mixed provider: ${pid}`);
+ missingProviders.push(pid);
}
}
+ if (missingProviders.length > 0) {
+ throw new Error(`Mixed provider mode missing API keys for: ${missingProviders.join(', ')}`);
+ }Also applies to: 490-503
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@extensions/thronekeeper/out/services/ProxyManager.js` around lines 480 - 488,
When mixed mode is enabled in ProxyManager, the startup must fail fast if any
provider key is missing: in the loop that iterates uniqueProviders (the block
using this.secrets.getProviderKey and providerKeys), if a key lookup returns
falsy for any pid, throw a descriptive Error (e.g. `throw new
Error('[ProxyManager] Mixed mode enabled but missing provider key for: ' +
pid)`) instead of only logging a warning; ensure the thrown error bubbles out of
the initialization/startup routine so startup is blocked. Apply the same change
to the other similar block (around the 490-503 area) that checks provider keys
so both code paths enforce the fail-fast behavior.
| async handleListModels(freeOnly, requestToken, requestedProvider) { | ||
| // Use requestedProvider (for Phase 3b) or fallback to runtimeProvider | ||
| const provider = requestedProvider || this.runtimeProvider || 'openrouter'; |
There was a problem hiding this comment.
Track request tokens per provider/tab, not with one global counter.
Adding requestedProvider turns model loading into a multi-lane road, but this.currentSequenceToken is still a single traffic light for every lane. If Provider 2 requests after Provider 1, Provider 1’s slower response gets discarded as “late” even though it belongs to a different tab. Keep the latest token per provider (or per tab) before the late-response check.
Based on learnings, "In provider-aware model loading logic, validate request/response token matching and cache models by provider to prevent race conditions from concurrent provider switches".
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@extensions/thronekeeper/out/views/PanelViewProvider.js` around lines 563 -
565, The current implementation of handleListModels uses a single
this.currentSequenceToken for all providers, causing cross-provider
race-condition drops; change to track tokens and cached models per provider/tab:
introduce a map like this.sequenceTokens (keyed by requestedProvider or
this.runtimeProvider fallback) and a models cache keyed by provider, increment
the provider-specific token when issuing a request in handleListModels, store
the issued token in a local variable, and before discarding a late response
compare the response token against this.sequenceTokens[provider]; update cache
this.modelsByProvider[provider] on successful responses. Use the symbols
handleListModels, requestedProvider, this.runtimeProvider,
this.currentSequenceToken (replace), and create this.sequenceTokens and
this.modelsByProvider to locate and modify the code.
| // Initialize on DOM ready | ||
| initMixedTabListeners(); |
There was a problem hiding this comment.
Bind mixed-tab listeners from the DOM-ready init path.
Every other control is wired inside init(), but this runs at module load. If the script is evaluated before the tab markup exists, initMixedTabListeners() is a silent no-op and the add/close/tab buttons never bind.
Minimal fix
function init() {
console.log('[init] Initializing Claude Throne webview...');
setupEventListeners();
+ initMixedTabListeners();
restoreState();
@@
-// Initialize on DOM ready
-initMixedTabListeners();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@extensions/thronekeeper/webview/main.js` around lines 3551 - 3552, The
mixed-tab event bindings are being initialized at module load which can run
before the DOM exists; move the initMixedTabListeners() invocation into the
DOM-ready initialization path (inside the existing init() function or the
DOMContentLoaded handler that init() uses) so it runs after markup is available;
locate the initMixedTabListeners() call and remove the top-level module
invocation, then call initMixedTabListeners() from within init() (or the same
DOMContentLoaded callback) to ensure add/close/tab buttons are bound reliably.
| if (ctx.endpointKind === ENDPOINT_KIND.ANTHROPIC_NATIVE) { | ||
| headers['x-api-key'] = ctx.key | ||
| headers['anthropic-version'] = env.ANTHROPIC_VERSION || '2023-06-01' | ||
| if (env.ANTHROPIC_BETA) { | ||
| headers['anthropic-beta'] = env.ANTHROPIC_BETA | ||
| } | ||
| } else { | ||
| headers['Authorization'] = `Bearer ${ctx.key}` | ||
| } |
There was a problem hiding this comment.
Normalize endpoint-kind values before choosing auth headers.
This branch only recognizes anthropic-native. If context carries canonical 'anthropic', it falls through and sends Authorization: Bearer instead of x-api-key, which can break provider auth.
🔧 Suggested fix
- if (ctx.endpointKind === ENDPOINT_KIND.ANTHROPIC_NATIVE) {
+ const kind = String(ctx.endpointKind || '').toLowerCase()
+ const isAnthropic =
+ kind === 'anthropic' ||
+ kind === 'anthropic-native' ||
+ kind === ENDPOINT_KIND.ANTHROPIC_NATIVE
+
+ if (isAnthropic) {
headers['x-api-key'] = ctx.key
headers['anthropic-version'] = env.ANTHROPIC_VERSION || '2023-06-01'
if (env.ANTHROPIC_BETA) {
headers['anthropic-beta'] = env.ANTHROPIC_BETA
}
} else {
headers['Authorization'] = `Bearer ${ctx.key}`
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@key-resolver.js` around lines 397 - 405, The branch that sets headers based
on ctx.endpointKind only checks ENDPOINT_KIND.ANTHROPIC_NATIVE so contexts with
canonical 'anthropic' get the wrong Authorization header; update the logic in
the headers-setting block (where ctx.endpointKind is inspected and headers are
assigned: headers['x-api-key'], headers['anthropic-version'],
headers['anthropic-beta'], else Authorization) to normalize or accept canonical
values — e.g., compare ctx.endpointKind after lowercasing to both
ENDPOINT_KIND.ANTHROPIC_NATIVE and the canonical string 'anthropic' (or map
canonical names to constants) so requests for Anthropic use x-api-key and
related headers instead of Bearer.
| "config": { | ||
| "enabled": true, | ||
| "reasoning": { | ||
| "providerId": "deepseek", | ||
| "model": "deepseek-reasoner" | ||
| }, | ||
| "completion": { | ||
| "providerId": "anthropic", | ||
| "model": "claude-3-5-sonnet-20241022" | ||
| }, | ||
| "value": { | ||
| "providerId": "deepseek", | ||
| "model": "deepseek-chat" | ||
| } | ||
| } |
There was a problem hiding this comment.
These presets don’t match the mixed-provider schema.
Every tier binding now requires baseUrl, but both presets only provide providerId and model. Right now this is like shipping a map with the street names removed: anything that tries to load these into mixedProviders will fail validation before routing even starts.
Also applies to: 24-38
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@mixed-presets.json` around lines 6 - 20, The preset entries under "config"
(the "reasoning", "completion", and "value" tier blocks) are missing the
required mixed-provider field `baseUrl`, so they fail mixed-provider schema
validation; update each tier object (reasoning, completion, value) to include
the provider's `baseUrl` alongside `providerId` and `model` (and ensure the same
fix is applied to the other similar preset block noted in the comment), using
the actual endpoint for each provider so the mixedProviders loader passes
validation.
| // Build model name → { tier, context } lookup | ||
| // The proxy sets these model names in .claude/settings.json, so they are authoritative | ||
| this.tierMap = new Map() | ||
| for (const [tier, ctx] of Object.entries(this.contexts)) { | ||
| if (ctx.model) { | ||
| this.tierMap.set(ctx.model, { tier, context: ctx }) | ||
| } |
There was a problem hiding this comment.
Reject ambiguous model collisions when building the routing map.
If two tiers reuse the same model string but point at different upstream contexts, the later Map.set() quietly paints over the first one. The proxy only receives payload.model, so every request for that ID will route to whichever tier was inserted last.
Possible guard
this.tierMap = new Map()
+ this.normalizedTierMap = new Map()
for (const [tier, ctx] of Object.entries(this.contexts)) {
if (ctx.model) {
- this.tierMap.set(ctx.model, { tier, context: ctx })
+ const normalizedModel = ctx.model.toLowerCase()
+ const existing = this.normalizedTierMap.get(normalizedModel)
+ if (
+ existing &&
+ (
+ existing.context.providerId !== ctx.providerId ||
+ existing.context.baseUrl !== ctx.baseUrl ||
+ existing.context.endpointKind !== ctx.endpointKind ||
+ existing.context.key !== ctx.key
+ )
+ ) {
+ throw new Error(
+ `[ProviderRouter] Mixed routing requires unique model IDs across providers: "${ctx.model}"`
+ )
+ }
+
+ const entry = { tier, context: ctx }
+ this.tierMap.set(ctx.model, entry)
+ this.normalizedTierMap.set(normalizedModel, entry)
}
}- const lower = modelName.toLowerCase()
- for (const [name, entry] of this.tierMap) {
- if (name.toLowerCase() === lower) return entry
- }
-
- return null
+ return this.normalizedTierMap.get(modelName.toLowerCase()) || nullAlso applies to: 190-194
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@provider-router.js` around lines 167 - 173, When building the model → { tier,
context } lookup (the this.tierMap population that iterates this.contexts and
calls Map.set with ctx.model), detect if a model key already exists and the
existing entry points at a different upstream context; in that case reject the
configuration (throw or log and exit) instead of overwriting so ambiguous model
collisions are not allowed; implement this check using this.tierMap.has(model)
and compare the existing entry’s context/tier to ctx before deciding to set, and
apply the same guard to the other identical population block around the 190-194
region.
| export function createRouterFromEnv(env = process.env, endpointOverrides = {}) { | ||
| const raw = env.MIXED_PROVIDERS_CONFIG | ||
| if (!raw) return null | ||
|
|
||
| try { | ||
| const config = JSON.parse(raw) | ||
| const router = new ProviderRouter(config, endpointOverrides) |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major
Validate MIXED_PROVIDERS_CONFIG with the shared schema before constructing the router.
JSON.parse() plus the tier-presence check is still too thin here. A stale coding key, missing baseUrl, or non-string model will fail late and just fall back to single-provider mode. Please run this boundary through the existing mixed-provider schema so the env contract stays in lockstep with the rest of the feature.
As per coding guidelines "Validate message/config contracts against schemas in extensions/claude-throne/src/schemas/." and "Use 'completion' key for storage operations (deprecated: 'coding' was deprecated in favor of 'completion')."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@provider-router.js` around lines 263 - 269, Parse MIXED_PROVIDERS_CONFIG as
before but validate the resulting object against the shared mixed-provider
schema in extensions/claude-throne/src/schemas/ before constructing
ProviderRouter: run the parsed config through the schema validator (use the
existing schema/validator helper), convert any deprecated "coding" key to
"completion" (or merge its contents into "completion"), and ensure required
fields like provider.baseUrl and provider.model are present and strings; if
validation fails, log the validation errors and return null instead of calling
new ProviderRouter(config, endpointOverrides), only constructing ProviderRouter
after the schema validator succeeds.
PR Description: Multi-Provider UI Tabbed Refactor (KHA-267)
Overview
This PR completes the transition from the legacy "Mix Providers" dropdown system to a modern, integrated tabbed interface. The refactor simplifies the configuration flow, reduces UI clutter, and ensures robust state persistence for multi-provider setups.
Key Changes
1. Integrated Tabbed UI
[+]button, replacing the complex manual toggle logic.[+]automatically enables "Mix Providers" and "Three Model Mode" if they aren't already active, streamlining the onboarding to advanced routing.2. State Management & Implicit Tiering
state.mixedTierAssignmentsto track provider-to-tier bindings independently of tab order, ensuring configuraiton remains stable across reloads.3. Layout & UX Fixes
content-gridlayout would break (pushing the model list below providers) due to incorrect div nesting.Invariants Touched
handleSaveMixedProvidersto accept the new tier-based payload.handleConfigLoadedto recreate secondary tabs and restore provider assignments on startup.Manual Testing Results
mixedProvidersconfig in VS Code settings.[+]enables mixed mode and spawns a new tab.Version Bump
1.5.71for final layout verification.Summary by CodeRabbit
New Features
Updates
Documentation