Browse Source

Installer and dynamic planner overhaul with models-only updates (#123)

* feat: add reusable OpenCode free-model selection flow

* docs: keep antigravity guide focused on antigravity setup

* feat: add Chutes provider with adaptive model selection

* fix: remove request-tier bias from Chutes model scoring

* feat: add provider-aware 6-agent fallback chains with 15s failover

* feat: add subscription prompts and provider-aware chains for anthropic/copilot/zai

* refactor: rebalance mixed provider defaults toward Kimi and GPT-5.3

* feat: add dynamic provider-aware model planning from live catalog

* docs: add 5 provider-combination config scenarios

* feat: blend Artificial Analysis and OpenRouter signals into dynamic model planning

* feat: prompt API keys during install for external ranking signals

* refactor: rebalance dynamic planner for provider diversity and non-flash depth

* refactor: boost explorer speed signals and map chutes aliases

* refactor: prefer Gemini 3 Pro over 2.5 in dynamic ranking

* feat: add version-aware recency scoring across model families

* refactor: enforce provider-balanced primaries and richer fallback bundles

* Address code review comments

* Fix formatting issues

* refactor: remove model-name and provider bonus heuristics

* fix: check if chutes alias exists before adding

* revert: restore heuristic bonuses in dynamic model planner

* Merge pull request 3: Add external ranking signals from Artificial Analysis and OpenRouter

* fix: harden fallback chains and external signal handling

Prevent key leakage in installer prompts, isolate external ranking aliasing, and make free-model tails deterministic before Big Pickle fallback. Also relax fallback chain schema to preserve custom agent keys and align tests.

* feat: add scoring v2 foundation with precedence resolver

Introduce modular V2 scoring components, shadow-mode plumbing, and deterministic precedence resolution with provenance while keeping V1 as the applied default. Adds schema flag support and regression tests for determinism and fallback compilation.

* feat: add OMOS manual preference backend operations

Introduce a validated manual plan schema and new omos_preferences tool with show/plan/apply/reset-agent operations, including atomic writes with backups. Wire the tool into plugin registration and add tests for config loading and manual plan compilation.

* feat: ship /omos command UX and rollout hardening

Install the /omos command entry, add diff-first confirm flow for omos_preferences, and handle global/project target precedence warnings. Include tests for command install, diff behavior, and precedence plus rollout gate documentation updates.

* test: add dynamic scoring matrix scenarios

Add a deterministic provider-combination matrix test that compares v1, v2-shadow, and v2 outputs across curated mixes plus three random mixes. Refresh the provider matrix doc with the new scenario results and scoring-mode behavior.

* feat: expose manual scoring preview and harden apply flow

Add score-plan output for manual model plans, including per-agent ranked scores for v1/v2/shadow comparisons. Also align plan/apply diff hashes to target config, ensure target directories exist before writes, and update provider-coverage provenance after swap logic.

* docs: add /omos score-plan guidance in English

Update README and rollout docs with an English how-to for viewing model scores during manual planning, and refresh the installed /omos command template to include score-plan and diffHash-safe apply flow.

* docs: add copy-paste /omos scoring workflow example

Include an English command sequence for show -> score-plan -> plan -> apply using operation-based omos_preferences calls, with target selection and diffHash-safe apply guidance.

* feat: move chutes to auth flow and full catalog refresh

Remove CHUTES_API_KEY env dependency from installer/config wiring and align Chutes with OpenCode auth provider flow. Switch Chutes discovery to all provider models from opencode refresh output while keeping OpenCode free-model filtering unchanged.

* fix: parse multi-segment provider model headers

Accept provider/model headers with additional path segments so chutes catalog entries like chutes/vendor/model are discovered during opencode verbose parsing.

* fix: support multi-segment model ids in /omos scoring

Allow manual plan model ids like provider/vendor/model and keep them in score-plan ranking. Add regressions for loader validation and /omos scoring with multi-segment Chutes model identifiers.

* fix: strip provider prefix for nested model signal matching

Use model ids after the first slash for external signal lookup so chutes/vendor/model names map correctly in v1 and v2 scoring. Add regressions for multi-segment chutes ids in dynamic and scoring engine tests.

* feat: normalize model keys and rebalance v2 capability scoring

Apply canonical model alias normalization across external signal ingestion and v1/v2 lookups (strip provider prefix, remove TEE/FP* tokens, lowercase, and normalize slash/space/hyphen variants). Also switch V2 capability scoring to one-sided bonuses, add K2.5 version preference, and update regression matrices/tests for the new ranking behavior.

* feat: add balanced subscription mode for install and /omos

Add an installer question for subscription/pay-per-API usage and persist balanceProviderUsage in config. When enabled, dynamic planning rebalances provider assignments toward even distribution using a max score-loss tolerance. Also expose the setting in omos_preferences show/plan/apply flows and update docs/tests.

* tune: reduce chutes qwen3 over-ranking

Add targeted Chutes role priors that down-rank Qwen3 and prefer Kimi K2.5/Minimax M2.1 by role in both v1 and v2 scoring. Update matrix and regression tests to reflect the calibrated ranking behavior.

* tune: remove Gemini bonus from v1 scoring

* feat: add guided click-through /omos command flow

* feat: remove /omos command, add manual model selection to CLI install

- Remove omos_preferences tool and /omos command flow
- Add --dry-run flag for testing install without OpenCode
- Add manual setup mode to choose models per agent
- Exclude already-selected models from fallback choices
- Limit model list display to 5 items with option to type any model ID
- Support primary + 3 fallbacks configuration per agent
- Add ManualAgentConfig type for storing manual selections

* fix: auto-detect opencode installation in common paths

- Add OPENCODE_PATHS with common installation locations
- Add resolveOpenCodePath() helper with caching
- Update all opencode commands to use resolved path
- Show found path in success message
- Add helpful instructions when opencode not found in PATH

* chore: remove accidentally added submodule

* fix: expand opencode path detection for macOS and Linux

Add comprehensive path detection including:
- macOS: Applications, Homebrew, Library paths
- Linux: /usr/bin, snap, flatpak, nix paths
- Package managers: Homebrew, Cargo, npm, yarn, pnpm
- More system-wide and user-local locations

* chore: remove accidentally added submodule

* fix: enforce selected provider models in dynamic assignments

Respect user-selected primary/secondary models during dynamic planning, lock forced assignments from rebalance overrides, and update tests to match manual-plan precedence behavior. Also include robust OpenCode path resolution updates for cross-platform installs.

* feat: add models-only update flow and refine model selection UX

- add  command and  mode for updating assignments without reinstalling plugins/skills
- keep full provider model lists available for primary/support choices
- improve large-list selection with  expansion option
- refine dynamic planner provider candidate narrowing and pinned model handling
- update matrix/planner tests and remove residual internal placeholder wording

* chore: polish installer menu copy and provider prompt consistency

Clarify quick/manual setup descriptions, standardize provider prompts, and improve models-only messaging.

* fix: address PR review for OpenCode path resolution and alias regex

Avoid spawning processes in resolveOpenCodePath by selecting only existing filesystem paths and simplify slash normalization regex for clarity.

* fix: remove stale commands exports and placeholder test

---------

Co-authored-by: Ruben Beuker <rubenbeuker@MacBook-Pro-van-Ruben.local>
Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com>
Co-authored-by: Your Name <you@example.com>
Daltonganger 5 months ago
parent
commit
05b7ffc5bf

+ 1 - 1
bun.lock

@@ -16,7 +16,7 @@
       "devDependencies": {
         "@biomejs/biome": "2.3.11",
         "bun-types": "latest",
-        "typescript": "^5.7.3",
+        "typescript": "^5.9.3",
       },
     },
   },

+ 6 - 2
docs/installation.md

@@ -34,7 +34,7 @@ The installer supports multiple providers:
 - **Kimi For Coding**: High-performance coding models
 - **OpenAI**: GPT-4 and GPT-3.5 models
 - **Antigravity (Google)**: Claude 4.5 and Gemini 3 models via Google's infrastructure
-- **Chutes**: Free daily-capped models (`chutes/*`) with dynamic role-aware selection
+- **Chutes**: Live-refreshed `chutes/*` models via OpenCode auth flow
 
 When OpenCode free mode is enabled, the installer runs:
 
@@ -105,7 +105,11 @@ Help the user understand the tradeoffs:
 - Kimi For Coding provides powerful coding models.
 - OpenAI enables `openai/` models.
 - Antigravity (Google) provides Claude and Gemini models via Google infrastructure.
-- Chutes provides free daily-capped models and requires `CHUTES_API_KEY`.
+- Chutes uses OpenCode provider authentication (`opencode auth login` -> select `chutes`).
+- Optional external ranking signals:
+  - `ARTIFICIAL_ANALYSIS_API_KEY` (quality/coding/latency/price)
+  - `OPENROUTER_API_KEY` (model pricing metadata)
+  If set, installer dynamic planning uses these signals to improve model ranking.
 
 ### Step 3: Run the Installer
 

+ 52 - 0
docs/provider-combination-matrix.md

@@ -122,6 +122,58 @@ Active providers: OpenAI + Anthropic + Copilot + ZAI Plan + Chutes + OpenCode Fr
   - `librarian`: `opencode/gpt-5-nano -> openai/gpt-5.1-codex-mini -> anthropic/claude-sonnet-4-5 -> github-copilot/grok-code-fast-1 -> zai-coding-plan/glm-4.7 -> chutes/minimax-m2.1 -> opencode/big-pickle`
   - `fixer`: `opencode/gpt-5-nano -> openai/gpt-5.1-codex-mini -> anthropic/claude-sonnet-4-5 -> github-copilot/grok-code-fast-1 -> zai-coding-plan/glm-4.7 -> chutes/minimax-m2.1 -> opencode/big-pickle`
 
+## Dynamic scoring rerun (new compositions + 3 random)
+
+This rerun validates `buildDynamicModelPlan` using `scoringEngineVersion` in three modes:
+
+- `v1`
+- `v2-shadow` (applies V1 results, compares V2)
+- `v2`
+
+The exact assertions are captured in `src/cli/dynamic-model-selection-matrix.test.ts`.
+
+### C1 (curated)
+
+Active providers: OpenAI + Anthropic + Chutes + OpenCode Free
+
+- V1 agents: `oracle=openai/gpt-5.3-codex`, `orchestrator=openai/gpt-5.3-codex`, `fixer=openai/gpt-5.1-codex-mini`, `designer=chutes/kimi-k2.5`, `librarian=anthropic/claude-opus-4-6`, `explorer=anthropic/claude-haiku-4-5`
+- V2 agents: `oracle=openai/gpt-5.3-codex`, `orchestrator=openai/gpt-5.3-codex`, `fixer=openai/gpt-5.1-codex-mini`, `designer=anthropic/claude-opus-4-6`, `librarian=chutes/kimi-k2.5`, `explorer=anthropic/claude-haiku-4-5`
+
+### C2 (curated)
+
+Active providers: OpenAI + Copilot + ZAI Plan + Gemini + OpenCode Free
+
+- V1 agents: `oracle=google/antigravity-gemini-3-pro`, `orchestrator=google/antigravity-gemini-3-pro`, `fixer=openai/gpt-5.1-codex-mini`, `designer=github-copilot/grok-code-fast-1`, `librarian=zai-coding-plan/glm-4.7`, `explorer=google/antigravity-gemini-3-flash`
+- V2 agents: same as V1 for this composition
+
+### C3 (curated)
+
+Active providers: Kimi + Gemini + Chutes + OpenCode Free
+
+- V1 agents: `oracle=google/antigravity-gemini-3-pro`, `orchestrator=google/antigravity-gemini-3-pro`, `fixer=chutes/minimax-m2.1`, `designer=kimi-for-coding/k2p5`, `librarian=google/antigravity-gemini-3-pro`, `explorer=google/antigravity-gemini-3-flash`
+- V2 agents: same except `fixer=chutes/kimi-k2.5`
+
+### R1 (random)
+
+Active providers: Anthropic + Copilot + OpenCode Free
+
+- V1 agents: `oracle=anthropic/claude-opus-4-6`, `orchestrator=github-copilot/grok-code-fast-1`, `fixer=github-copilot/grok-code-fast-1`, `designer=anthropic/claude-opus-4-6`, `librarian=github-copilot/grok-code-fast-1`, `explorer=anthropic/claude-haiku-4-5`
+- V2 agents: same as V1 for this composition
+
+### R2 (random)
+
+Active providers: OpenAI + Kimi + ZAI Plan + Chutes + OpenCode Free
+
+- V1 agents: `oracle=openai/gpt-5.3-codex`, `orchestrator=openai/gpt-5.3-codex`, `fixer=chutes/minimax-m2.1`, `designer=zai-coding-plan/glm-4.7`, `librarian=kimi-for-coding/k2p5`, `explorer=chutes/minimax-m2.1`
+- V2 agents: `oracle=openai/gpt-5.3-codex`, `orchestrator=openai/gpt-5.3-codex`, `fixer=chutes/kimi-k2.5`, `designer=kimi-for-coding/k2p5`, `librarian=zai-coding-plan/glm-4.7`, `explorer=chutes/minimax-m2.1`
+
+### R3 (random)
+
+Active providers: Gemini + Anthropic + Chutes + OpenCode Free
+
+- V1 agents: `oracle=google/antigravity-gemini-3-pro`, `orchestrator=google/antigravity-gemini-3-pro`, `fixer=chutes/minimax-m2.1`, `designer=anthropic/claude-opus-4-6`, `librarian=google/antigravity-gemini-3-pro`, `explorer=google/antigravity-gemini-3-flash`
+- V2 agents: `oracle=google/antigravity-gemini-3-pro`, `orchestrator=google/antigravity-gemini-3-pro`, `fixer=anthropic/claude-opus-4-6`, `designer=chutes/kimi-k2.5`, `librarian=google/antigravity-gemini-3-pro`, `explorer=google/antigravity-gemini-3-flash`
+
 ## Notes
 
 - This matrix shows deterministic `generateLiteConfig` output for the selected combinations.

+ 1 - 1
package.json

@@ -60,7 +60,7 @@
   "devDependencies": {
     "@biomejs/biome": "2.3.11",
     "bun-types": "latest",
-    "typescript": "^5.7.3"
+    "typescript": "^5.9.3"
   },
   "trustedDependencies": [
     "@ast-grep/cli"

+ 3 - 5
src/cli/config-io.test.ts

@@ -196,7 +196,7 @@ describe('config-io', () => {
     expect(detected.hasTmux).toBe(true);
   });
 
-  test('addChutesProvider configures chutes provider and detection', () => {
+  test('addChutesProvider keeps OpenCode auth-based chutes flow intact', () => {
     const configPath = join(tmpDir, 'opencode', 'opencode.json');
     const litePath = join(tmpDir, 'opencode', 'oh-my-opencode-slim.json');
     paths.ensureConfigDir();
@@ -221,10 +221,8 @@ describe('config-io', () => {
     expect(result.success).toBe(true);
 
     const saved = JSON.parse(readFileSync(configPath, 'utf-8'));
-    expect(saved.provider.chutes).toBeDefined();
-    expect(saved.provider.chutes.options.baseURL).toBe(
-      'https://llm.chutes.ai/v1',
-    );
+    expect(saved.plugin).toContain('oh-my-opencode-slim');
+    expect(saved.provider).toBeUndefined();
 
     const detected = detectCurrentConfig();
     expect(detected.hasChutes).toBe(true);

+ 4 - 16
src/cli/config-io.ts

@@ -343,7 +343,9 @@ export function addGoogleProvider(): ConfigMergeResult {
 export function addChutesProvider(): ConfigMergeResult {
   const configPath = getExistingConfigPath();
   try {
-    const { config: parsedConfig, error } = parseConfig(configPath);
+    // Chutes now follows the OpenCode auth flow (same as other providers).
+    // Keep this step as a no-op success for backward-compatible install output.
+    const { error } = parseConfig(configPath);
     if (error) {
       return {
         success: false,
@@ -351,26 +353,12 @@ export function addChutesProvider(): ConfigMergeResult {
         error: `Failed to parse config: ${error}`,
       };
     }
-    const config = parsedConfig ?? {};
-    const providers = (config.provider ?? {}) as Record<string, unknown>;
-
-    providers.chutes = {
-      npm: '@ai-sdk/openai-compatible',
-      name: 'Chutes',
-      options: {
-        baseURL: 'https://llm.chutes.ai/v1',
-        apiKey: '{env:CHUTES_API_KEY}',
-      },
-    };
-    config.provider = providers;
-
-    writeConfig(configPath, config);
     return { success: true, configPath };
   } catch (err) {
     return {
       success: false,
       configPath,
-      error: `Failed to add chutes provider: ${err}`,
+      error: `Failed to validate chutes provider config: ${err}`,
     };
   }
 }

+ 3 - 0
src/cli/config-manager.ts

@@ -1,9 +1,12 @@
 export * from './chutes-selection';
 export * from './config-io';
 export * from './dynamic-model-selection';
+export * from './external-rankings';
 export * from './model-selection';
 export * from './opencode-models';
 export * from './opencode-selection';
 export * from './paths';
+export * from './precedence-resolver';
 export * from './providers';
+export * from './scoring-v2';
 export * from './system';

+ 261 - 0
src/cli/dynamic-model-selection-matrix.test.ts

@@ -0,0 +1,261 @@
+/// <reference types="bun-types" />
+
+import { describe, expect, test } from 'bun:test';
+import { buildDynamicModelPlan } from './dynamic-model-selection';
+import type { DiscoveredModel, InstallConfig } from './types';
+
+function m(
+  input: Partial<DiscoveredModel> & { model: string },
+): DiscoveredModel {
+  const [providerID] = input.model.split('/');
+  return {
+    providerID: providerID ?? 'openai',
+    model: input.model,
+    name: input.name ?? input.model,
+    status: input.status ?? 'active',
+    contextLimit: input.contextLimit ?? 200000,
+    outputLimit: input.outputLimit ?? 32000,
+    reasoning: input.reasoning ?? true,
+    toolcall: input.toolcall ?? true,
+    attachment: input.attachment ?? false,
+    dailyRequestLimit: input.dailyRequestLimit,
+    costInput: input.costInput,
+    costOutput: input.costOutput,
+  };
+}
+
+const CATALOG: DiscoveredModel[] = [
+  m({ model: 'openai/gpt-5.3-codex' }),
+  m({ model: 'openai/gpt-5.1-codex-mini' }),
+  m({ model: 'anthropic/claude-opus-4-6' }),
+  m({ model: 'anthropic/claude-sonnet-4-5' }),
+  m({ model: 'anthropic/claude-haiku-4-5', reasoning: false }),
+  m({ model: 'github-copilot/grok-code-fast-1' }),
+  m({ model: 'zai-coding-plan/glm-4.7' }),
+  m({ model: 'google/antigravity-gemini-3-pro' }),
+  m({ model: 'google/antigravity-gemini-3-flash' }),
+  m({ model: 'chutes/kimi-k2.5' }),
+  m({ model: 'chutes/minimax-m2.1' }),
+  m({ model: 'kimi-for-coding/k2p5' }),
+  m({ model: 'opencode/glm-4.7-free' }),
+  m({ model: 'opencode/gpt-5-nano' }),
+  m({ model: 'opencode/big-pickle' }),
+];
+
+function baseConfig(): InstallConfig {
+  return {
+    hasKimi: false,
+    hasOpenAI: false,
+    hasAnthropic: false,
+    hasCopilot: false,
+    hasZaiPlan: false,
+    hasAntigravity: false,
+    hasChutes: false,
+    hasOpencodeZen: true,
+    useOpenCodeFreeModels: true,
+    selectedOpenCodePrimaryModel: 'opencode/glm-4.7-free',
+    selectedOpenCodeSecondaryModel: 'opencode/gpt-5-nano',
+    selectedChutesPrimaryModel: 'chutes/kimi-k2.5',
+    selectedChutesSecondaryModel: 'chutes/minimax-m2.1',
+    hasTmux: false,
+    installSkills: false,
+    installCustomSkills: false,
+    setupMode: 'quick',
+  };
+}
+
+describe('dynamic-model-selection matrix', () => {
+  const scenarios = [
+    {
+      name: 'C1 openai+anthropic+chutes+opencode',
+      config: {
+        ...baseConfig(),
+        hasOpenAI: true,
+        hasAnthropic: true,
+        hasChutes: true,
+      },
+      expectedV1: {
+        oracle: 'openai/gpt-5.3-codex',
+        orchestrator: 'openai/gpt-5.3-codex',
+        fixer: 'chutes/minimax-m2.1',
+        designer: 'chutes/kimi-k2.5',
+        librarian: 'anthropic/claude-opus-4-6',
+        explorer: 'chutes/minimax-m2.1',
+      },
+      expectedV2: {
+        oracle: 'openai/gpt-5.3-codex',
+        orchestrator: 'openai/gpt-5.3-codex',
+        fixer: 'chutes/minimax-m2.1',
+        designer: 'chutes/kimi-k2.5',
+        librarian: 'anthropic/claude-opus-4-6',
+        explorer: 'chutes/minimax-m2.1',
+      },
+    },
+    {
+      name: 'C2 openai+copilot+zai+google+opencode',
+      config: {
+        ...baseConfig(),
+        hasOpenAI: true,
+        hasCopilot: true,
+        hasZaiPlan: true,
+        hasAntigravity: true,
+      },
+      expectedV1: {
+        oracle: 'openai/gpt-5.3-codex',
+        orchestrator: 'openai/gpt-5.3-codex',
+        fixer: 'github-copilot/grok-code-fast-1',
+        designer: 'google/antigravity-gemini-3-pro',
+        librarian: 'zai-coding-plan/glm-4.7',
+        explorer: 'google/antigravity-gemini-3-flash',
+      },
+      expectedV2: {
+        oracle: 'openai/gpt-5.3-codex',
+        orchestrator: 'openai/gpt-5.3-codex',
+        fixer: 'github-copilot/grok-code-fast-1',
+        designer: 'zai-coding-plan/glm-4.7',
+        librarian: 'google/antigravity-gemini-3-pro',
+        explorer: 'google/antigravity-gemini-3-flash',
+      },
+    },
+    {
+      name: 'C3 kimi+google+chutes+opencode',
+      config: {
+        ...baseConfig(),
+        hasKimi: true,
+        hasAntigravity: true,
+        hasChutes: true,
+      },
+      expectedV1: {
+        oracle: 'google/antigravity-gemini-3-pro',
+        orchestrator: 'chutes/kimi-k2.5',
+        fixer: 'google/antigravity-gemini-3-pro',
+        designer: 'chutes/kimi-k2.5',
+        librarian: 'kimi-for-coding/k2p5',
+        explorer: 'google/antigravity-gemini-3-flash',
+      },
+      expectedV2: {
+        oracle: 'google/antigravity-gemini-3-pro',
+        orchestrator: 'chutes/kimi-k2.5',
+        fixer: 'google/antigravity-gemini-3-pro',
+        designer: 'chutes/kimi-k2.5',
+        librarian: 'kimi-for-coding/k2p5',
+        explorer: 'google/antigravity-gemini-3-flash',
+      },
+    },
+    {
+      name: 'R1 anthropic+copilot+opencode',
+      config: { ...baseConfig(), hasAnthropic: true, hasCopilot: true },
+      expectedV1: {
+        oracle: 'anthropic/claude-opus-4-6',
+        orchestrator: 'github-copilot/grok-code-fast-1',
+        fixer: 'github-copilot/grok-code-fast-1',
+        designer: 'anthropic/claude-opus-4-6',
+        librarian: 'github-copilot/grok-code-fast-1',
+        explorer: 'github-copilot/grok-code-fast-1',
+      },
+      expectedV2: {
+        oracle: 'anthropic/claude-opus-4-6',
+        orchestrator: 'github-copilot/grok-code-fast-1',
+        fixer: 'github-copilot/grok-code-fast-1',
+        designer: 'anthropic/claude-opus-4-6',
+        librarian: 'github-copilot/grok-code-fast-1',
+        explorer: 'github-copilot/grok-code-fast-1',
+      },
+    },
+    {
+      name: 'R2 openai+kimi+zai+chutes+opencode',
+      config: {
+        ...baseConfig(),
+        hasOpenAI: true,
+        hasKimi: true,
+        hasZaiPlan: true,
+        hasChutes: true,
+      },
+      expectedV1: {
+        oracle: 'openai/gpt-5.3-codex',
+        orchestrator: 'openai/gpt-5.3-codex',
+        fixer: 'chutes/minimax-m2.1',
+        designer: 'chutes/kimi-k2.5',
+        librarian: 'zai-coding-plan/glm-4.7',
+        explorer: 'chutes/minimax-m2.1',
+      },
+      expectedV2: {
+        oracle: 'openai/gpt-5.3-codex',
+        orchestrator: 'openai/gpt-5.3-codex',
+        fixer: 'chutes/minimax-m2.1',
+        designer: 'chutes/kimi-k2.5',
+        librarian: 'zai-coding-plan/glm-4.7',
+        explorer: 'chutes/minimax-m2.1',
+      },
+    },
+    {
+      name: 'R3 google+anthropic+chutes+opencode',
+      config: {
+        ...baseConfig(),
+        hasAntigravity: true,
+        hasAnthropic: true,
+        hasChutes: true,
+      },
+      expectedV1: {
+        oracle: 'google/antigravity-gemini-3-pro',
+        orchestrator: 'chutes/kimi-k2.5',
+        fixer: 'google/antigravity-gemini-3-pro',
+        designer: 'anthropic/claude-opus-4-6',
+        librarian: 'chutes/minimax-m2.1',
+        explorer: 'google/antigravity-gemini-3-flash',
+      },
+      expectedV2: {
+        oracle: 'google/antigravity-gemini-3-pro',
+        orchestrator: 'chutes/kimi-k2.5',
+        fixer: 'google/antigravity-gemini-3-pro',
+        designer: 'anthropic/claude-opus-4-6',
+        librarian: 'chutes/minimax-m2.1',
+        explorer: 'google/antigravity-gemini-3-flash',
+      },
+    },
+  ] as const;
+
+  for (const scenario of scenarios) {
+    test(scenario.name, () => {
+      const v1 = buildDynamicModelPlan(CATALOG, scenario.config, undefined, {
+        scoringEngineVersion: 'v1',
+      });
+      const shadow = buildDynamicModelPlan(
+        CATALOG,
+        scenario.config,
+        undefined,
+        {
+          scoringEngineVersion: 'v2-shadow',
+        },
+      );
+      const v2 = buildDynamicModelPlan(CATALOG, scenario.config, undefined, {
+        scoringEngineVersion: 'v2',
+      });
+
+      expect(v1).not.toBeNull();
+      expect(v2).not.toBeNull();
+      expect(shadow).not.toBeNull();
+
+      expect(v1?.agents).toMatchObject(
+        Object.fromEntries(
+          Object.entries(scenario.expectedV1).map(([agent, model]) => [
+            agent,
+            { model },
+          ]),
+        ),
+      );
+      expect(v2?.agents).toMatchObject(
+        Object.fromEntries(
+          Object.entries(scenario.expectedV2).map(([agent, model]) => [
+            agent,
+            { model },
+          ]),
+        ),
+      );
+
+      expect(shadow?.agents).toEqual(v1?.agents);
+      expect(shadow?.scoring?.engineVersionApplied).toBe('v1');
+      expect(shadow?.scoring?.shadowCompared).toBe(true);
+    });
+  }
+});

+ 150 - 3
src/cli/dynamic-model-selection.test.ts

@@ -1,7 +1,10 @@
 /// <reference types="bun-types" />
 
 import { describe, expect, test } from 'bun:test';
-import { buildDynamicModelPlan } from './dynamic-model-selection';
+import {
+  buildDynamicModelPlan,
+  rankModelsV1WithBreakdown,
+} from './dynamic-model-selection';
 import type { DiscoveredModel, InstallConfig } from './types';
 
 function m(
@@ -42,6 +45,7 @@ function baseInstallConfig(): InstallConfig {
     hasTmux: false,
     installSkills: false,
     installCustomSkills: false,
+    setupMode: 'quick',
   };
 }
 
@@ -83,9 +87,152 @@ describe('dynamic-model-selection', () => {
       'oracle',
       'orchestrator',
     ]);
-    expect(chains.oracle).toContain('openai/gpt-5.3-codex');
+    expect(agents.oracle?.model.startsWith('opencode/')).toBe(false);
+    expect(agents.orchestrator?.model.startsWith('opencode/')).toBe(false);
+    expect(chains.oracle.some((m: string) => m.startsWith('openai/'))).toBe(
+      true,
+    );
     expect(chains.orchestrator).toContain('chutes/kimi-k2.5');
     expect(chains.explorer).toContain('opencode/gpt-5-nano');
-    expect(chains.fixer[chains.fixer.length - 1]).toBe('opencode/big-pickle');
+    expect(chains.fixer[chains.fixer.length - 1]).toBe('opencode/gpt-5-nano');
+    expect(plan?.provenance?.oracle?.winnerLayer).toBe(
+      'dynamic-recommendation',
+    );
+    expect(plan?.scoring?.engineVersionApplied).toBe('v1');
+  });
+
+  test('supports v2-shadow mode without changing applied engine', () => {
+    const plan = buildDynamicModelPlan(
+      [
+        m({ model: 'openai/gpt-5.3-codex', reasoning: true, toolcall: true }),
+        m({ model: 'chutes/kimi-k2.5', reasoning: true, toolcall: true }),
+        m({ model: 'opencode/gpt-5-nano', reasoning: true, toolcall: true }),
+      ],
+      baseInstallConfig(),
+      undefined,
+      { scoringEngineVersion: 'v2-shadow' },
+    );
+
+    expect(plan).not.toBeNull();
+    expect(plan?.scoring?.engineVersionApplied).toBe('v1');
+    expect(plan?.scoring?.shadowCompared).toBe(true);
+    expect(plan?.scoring?.diffs?.oracle).toBeDefined();
+  });
+
+  test('balances provider usage when subscription mode is enabled', () => {
+    const plan = buildDynamicModelPlan(
+      [
+        m({ model: 'openai/gpt-5.3-codex', reasoning: true, toolcall: true }),
+        m({
+          model: 'openai/gpt-5.1-codex-mini',
+          reasoning: true,
+          toolcall: true,
+        }),
+        m({
+          model: 'zai-coding-plan/glm-4.7',
+          reasoning: true,
+          toolcall: true,
+        }),
+        m({
+          model: 'zai-coding-plan/glm-4.7-flash',
+          reasoning: true,
+          toolcall: true,
+        }),
+        m({
+          model: 'chutes/moonshotai/Kimi-K2.5-TEE',
+          reasoning: true,
+          toolcall: true,
+        }),
+        m({
+          model: 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
+          reasoning: true,
+          toolcall: true,
+        }),
+      ],
+      {
+        ...baseInstallConfig(),
+        hasCopilot: false,
+        balanceProviderUsage: true,
+      },
+      undefined,
+      { scoringEngineVersion: 'v2' },
+    );
+
+    expect(plan).not.toBeNull();
+    const usage = Object.values(plan?.agents ?? {}).reduce(
+      (acc, assignment) => {
+        const provider = assignment.model.split('/')[0] ?? 'unknown';
+        acc[provider] = (acc[provider] ?? 0) + 1;
+        return acc;
+      },
+      {} as Record<string, number>,
+    );
+
+    expect(usage.openai).toBe(2);
+    expect(usage['zai-coding-plan']).toBe(2);
+    expect(usage.chutes).toBe(2);
+  });
+
+  test('matches external signals for multi-segment chutes ids in v1', () => {
+    const ranked = rankModelsV1WithBreakdown(
+      [m({ model: 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE' })],
+      'fixer',
+      {
+        'qwen/qwen3-coder-480b-a35b-instruct': {
+          source: 'artificial-analysis',
+          qualityScore: 95,
+          codingScore: 92,
+        },
+      },
+    );
+
+    expect(ranked[0]?.externalSignalBoost).toBeGreaterThan(0);
+  });
+
+  test('prefers chutes kimi/minimax over qwen3 in v1 role scoring', () => {
+    const catalog = [
+      m({
+        model: 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
+        reasoning: true,
+        toolcall: true,
+      }),
+      m({
+        model: 'chutes/moonshotai/Kimi-K2.5-TEE',
+        reasoning: true,
+        toolcall: true,
+      }),
+      m({
+        model: 'chutes/minimax-m2.1',
+        reasoning: true,
+        toolcall: true,
+      }),
+    ];
+
+    const fixer = rankModelsV1WithBreakdown(catalog, 'fixer');
+    const explorer = rankModelsV1WithBreakdown(catalog, 'explorer');
+
+    expect(fixer[0]?.model).not.toContain('Qwen3-Coder-480B');
+    expect(explorer[0]?.model).toContain('minimax-m2.1');
+  });
+
+  test('does not apply a positive Gemini bonus in v1 scoring', () => {
+    const catalog = [
+      m({
+        model: 'google/antigravity-gemini-3-pro',
+        reasoning: true,
+        toolcall: true,
+      }),
+      m({ model: 'openai/gpt-5.3-codex', reasoning: true, toolcall: true }),
+    ];
+
+    const oracle = rankModelsV1WithBreakdown(catalog, 'oracle');
+    const orchestrator = rankModelsV1WithBreakdown(catalog, 'orchestrator');
+    const designer = rankModelsV1WithBreakdown(catalog, 'designer');
+    const librarian = rankModelsV1WithBreakdown(catalog, 'librarian');
+
+    expect(oracle[0]?.model).toBe('openai/gpt-5.3-codex');
+    expect(orchestrator[0]?.model).toBe('openai/gpt-5.3-codex');
+    expect(designer[0]?.model).toBe('openai/gpt-5.3-codex');
+    expect(librarian[0]?.model).toBe('openai/gpt-5.3-codex');
   });
 });

+ 1171 - 45
src/cli/dynamic-model-selection.ts

@@ -1,4 +1,13 @@
-import type { DiscoveredModel, DynamicModelPlan, InstallConfig } from './types';
+import { buildModelKeyAliases } from './model-key-normalization';
+import { resolveAgentWithPrecedence } from './precedence-resolver';
+import { rankModelsV2, scoreCandidateV2 } from './scoring-v2';
+import type {
+  DiscoveredModel,
+  DynamicModelPlan,
+  ExternalSignalMap,
+  InstallConfig,
+  ScoringEngineVersion,
+} from './types';
 
 const AGENTS = [
   'orchestrator',
@@ -11,6 +20,23 @@ const AGENTS = [
 
 type AgentName = (typeof AGENTS)[number];
 
+export type V1RankedScore = {
+  model: string;
+  totalScore: number;
+  baseScore: number;
+  externalSignalBoost: number;
+};
+
+const FREE_BIASED_PROVIDERS = new Set(['opencode']);
+const PRIMARY_ASSIGNMENT_ORDER: AgentName[] = [
+  'oracle',
+  'orchestrator',
+  'fixer',
+  'designer',
+  'librarian',
+  'explorer',
+];
+
 const ROLE_VARIANT: Record<AgentName, string | undefined> = {
   orchestrator: undefined,
   oracle: 'high',
@@ -44,7 +70,144 @@ function statusScore(status: DiscoveredModel['status']): number {
   return -40;
 }
 
-function baseScore(model: DiscoveredModel): number {
+type VersionFamilyInfo = {
+  family: string;
+  version: [number, number, number];
+  confidence: number;
+  prereleasePenalty: number;
+};
+
+function toVersionTuple(
+  major: string,
+  minor?: string,
+  patch?: string,
+): [number, number, number] {
+  return [
+    Number.parseInt(major, 10) || 0,
+    Number.parseInt(minor ?? '0', 10) || 0,
+    Number.parseInt(patch ?? '0', 10) || 0,
+  ];
+}
+
+function compareVersionTuple(
+  a: [number, number, number],
+  b: [number, number, number],
+): number {
+  if (a[0] !== b[0]) return a[0] - b[0];
+  if (a[1] !== b[1]) return a[1] - b[1];
+  return a[2] - b[2];
+}
+
+function extractVersionFamily(
+  model: DiscoveredModel,
+): VersionFamilyInfo | null {
+  const text = `${model.model} ${model.name}`.toLowerCase();
+
+  const gpt = text.match(/\bgpt[-_ ]?(\d+)(?:[.-](\d+))?(?:[.-](\d+))?\b/);
+  if (gpt) {
+    return {
+      family: 'gpt',
+      version: toVersionTuple(gpt[1] ?? '0', gpt[2], gpt[3]),
+      confidence: 1,
+      prereleasePenalty: /preview|experimental|exp|\brc\b/.test(text) ? -2 : 0,
+    };
+  }
+
+  const gemini = text.match(
+    /\bgemini[-_ ]?(\d+)(?:[.-](\d+))?(?:[.-](\d+))?\b/,
+  );
+  if (gemini) {
+    return {
+      family: 'gemini',
+      version: toVersionTuple(gemini[1] ?? '0', gemini[2], gemini[3]),
+      confidence: 1,
+      prereleasePenalty: /preview|experimental|exp|\brc\b/.test(text) ? -2 : 0,
+    };
+  }
+
+  const kimi = text.match(/\bkimi[-_ ]?k(\d+)(?:[.-]?(\d+))?(?:[.-](\d+))?\b/);
+  if (kimi) {
+    return {
+      family: 'kimi-k',
+      version: toVersionTuple(kimi[1] ?? '0', kimi[2], kimi[3]),
+      confidence: 1,
+      prereleasePenalty: /preview|experimental|exp|\brc\b/.test(text) ? -2 : 0,
+    };
+  }
+
+  const generic = text.match(
+    /\b([a-z][a-z0-9-]{1,20})[-_ ](\d+)(?:[.-](\d+))?(?:[.-](\d+))?\b/,
+  );
+  if (generic) {
+    return {
+      family: generic[1] ?? 'generic',
+      version: toVersionTuple(generic[2] ?? '0', generic[3], generic[4]),
+      confidence: 0.7,
+      prereleasePenalty: /preview|experimental|exp|\brc\b/.test(text) ? -2 : 0,
+    };
+  }
+
+  return null;
+}
+
+function getVersionRecencyMap(
+  models: DiscoveredModel[],
+): Record<string, number> {
+  const familyVersions = new Map<string, Array<[number, number, number]>>();
+  const modelInfo = new Map<string, VersionFamilyInfo>();
+
+  for (const model of models) {
+    const info = extractVersionFamily(model);
+    if (!info) continue;
+
+    modelInfo.set(model.model, info);
+    const current = familyVersions.get(info.family) ?? [];
+    current.push(info.version);
+    familyVersions.set(info.family, current);
+  }
+
+  const recencyMap: Record<string, number> = {};
+
+  for (const model of models) {
+    const info = modelInfo.get(model.model);
+    if (!info) {
+      recencyMap[model.model] = 0;
+      continue;
+    }
+
+    const versions = familyVersions.get(info.family) ?? [];
+    const unique = versions
+      .map((tuple) => `${tuple[0]}.${tuple[1]}.${tuple[2]}`)
+      .filter((value, index, arr) => arr.indexOf(value) === index)
+      .map((value) => {
+        const [major, minor, patch] = value
+          .split('.')
+          .map((v) => Number.parseInt(v, 10) || 0);
+        return [major, minor, patch] as [number, number, number];
+      })
+      .sort(compareVersionTuple);
+
+    if (unique.length === 0) {
+      recencyMap[model.model] = 0;
+      continue;
+    }
+
+    const index = unique.findIndex(
+      (tuple) => compareVersionTuple(tuple, info.version) === 0,
+    );
+    const percentile = unique.length === 1 ? 0.5 : index / (unique.length - 1);
+    const raw = -3 + percentile * (12 - -3);
+    const final = Math.max(
+      -3,
+      Math.min(12, raw * info.confidence + info.prereleasePenalty),
+    );
+    recencyMap[model.model] = final;
+  }
+
+  return recencyMap;
+}
+
+function baseScore(model: DiscoveredModel, versionRecencyBoost = 0): number {
   const lowered = `${model.model} ${model.name}`.toLowerCase();
   const context = Math.min(model.contextLimit, 1_000_000) / 50_000;
   const output = Math.min(model.outputLimit, 300_000) / 30_000;
@@ -56,14 +219,9 @@ function baseScore(model: DiscoveredModel): number {
   const fast = tokenScore(
     lowered,
     /(nano|flash|mini|lite|fast|turbo|haiku|small)/i,
-    12,
+    4,
   );
   const code = tokenScore(lowered, /(codex|coder|code|dev|program)/i, 12);
-  const versionBoost =
-    tokenScore(lowered, /gpt-5\.3/i, 12) +
-    tokenScore(lowered, /gpt-5\.2/i, 8) +
-    tokenScore(lowered, /k2\.5/i, 6);
-
   return (
     statusScore(model.status) +
     context +
@@ -71,12 +229,88 @@ function baseScore(model: DiscoveredModel): number {
     deep +
     fast +
     code +
-    versionBoost +
+    versionRecencyBoost +
     (model.toolcall ? 25 : 0)
   );
 }
 
-function roleScore(agent: AgentName, model: DiscoveredModel): number {
+function hasFlashToken(model: DiscoveredModel): boolean {
+  return /flash/i.test(`${model.model} ${model.name}`);
+}
+
+function isZai47Model(model: DiscoveredModel): boolean {
+  return (
+    model.providerID === 'zai-coding-plan' &&
+    /glm-4\.7/i.test(`${model.model} ${model.name}`)
+  );
+}
+
+function isKimiK25Model(model: DiscoveredModel): boolean {
+  return /kimi-k2\.?5|k2\.?5/i.test(`${model.model} ${model.name}`);
+}
+
+function geminiPreferenceAdjustment(
+  _agent: AgentName,
+  model: DiscoveredModel,
+): number {
+  const lowered = `${model.model} ${model.name}`.toLowerCase();
+  const isGemini25Pro = /gemini-2\.5-pro/.test(lowered);
+
+  return isGemini25Pro ? -14 : 0;
+}
+
+function chutesPreferenceAdjustment(
+  agent: AgentName,
+  model: DiscoveredModel,
+): number {
+  if (model.providerID !== 'chutes') return 0;
+
+  const lowered = `${model.model} ${model.name}`.toLowerCase();
+  const isQwen3 = /qwen3/.test(lowered);
+  const isKimiK25 = /kimi-k2\.5|k2\.5/.test(lowered);
+  const isMinimaxM21 = /minimax[-_ ]?m2\.1/.test(lowered);
+
+  const qwenPenalty: Record<AgentName, number> = {
+    oracle: -12,
+    orchestrator: -10,
+    fixer: -22,
+    designer: -14,
+    librarian: -18,
+    explorer: -10,
+  };
+  const kimiBonus: Record<AgentName, number> = {
+    oracle: 0,
+    orchestrator: 0,
+    fixer: 8,
+    designer: 6,
+    librarian: 5,
+    explorer: 4,
+  };
+  const minimaxBonus: Record<AgentName, number> = {
+    oracle: 0,
+    orchestrator: 0,
+    fixer: 10,
+    designer: 3,
+    librarian: 9,
+    explorer: 12,
+  };
+
+  return (
+    (isQwen3 ? qwenPenalty[agent] : 0) +
+    (isKimiK25 ? kimiBonus[agent] : 0) +
+    (isMinimaxM21 ? minimaxBonus[agent] : 0)
+  );
+}
+
+function modelLookupKeys(model: DiscoveredModel): string[] {
+  return buildModelKeyAliases(model.model);
+}
+
+function roleScore(
+  agent: AgentName,
+  model: DiscoveredModel,
+  versionRecencyBoost = 0,
+): number {
   const lowered = `${model.model} ${model.name}`.toLowerCase();
   const reasoning = model.reasoning ? 1 : 0;
   const toolcall = model.toolcall ? 1 : 0;
@@ -109,43 +343,620 @@ function roleScore(agent: AgentName, model: DiscoveredModel): number {
     return -5_000;
   }
 
-  const score = baseScore(model);
+  const score = baseScore(model, versionRecencyBoost);
+  const flash = hasFlashToken(model);
+  const isZai47 = isZai47Model(model);
+  const zai47Flash = isZai47 && flash;
+  const zai47NonFlash = isZai47 && !flash;
+  const providerBias =
+    model.providerID === 'openai'
+      ? 3
+      : model.providerID === 'anthropic'
+        ? 3
+        : model.providerID === 'kimi-for-coding'
+          ? 2
+          : model.providerID === 'google'
+            ? 2
+            : model.providerID === 'github-copilot'
+              ? 1
+              : model.providerID === 'zai-coding-plan'
+                ? 0
+                : model.providerID === 'chutes'
+                  ? 2
+                  : model.providerID === 'opencode'
+                    ? -2
+                    : 0;
+  const geminiAdjustment = geminiPreferenceAdjustment(agent, model);
+  const chutesAdjustment = chutesPreferenceAdjustment(agent, model);
 
   if (agent === 'orchestrator') {
+    const flashAdjustment = flash ? -22 : 0;
+    const zaiAdjustment = zai47NonFlash ? 16 : zai47Flash ? -18 : 0;
+    const nonReasoningFlashPenalty = flash && !model.reasoning ? -16 : 0;
     return (
-      score + reasoning * 40 + toolcall * 25 + deep * 10 + code * 8 + context
+      score +
+      reasoning * 40 +
+      toolcall * 25 +
+      deep * 10 +
+      code * 8 +
+      context +
+      flashAdjustment +
+      zaiAdjustment +
+      nonReasoningFlashPenalty +
+      geminiAdjustment +
+      chutesAdjustment +
+      providerBias
     );
   }
   if (agent === 'oracle') {
-    return score + reasoning * 55 + deep * 18 + context * 1.2 + toolcall * 10;
+    const flashAdjustment = flash ? -34 : 0;
+    const zaiAdjustment = zai47NonFlash ? 16 : zai47Flash ? -18 : 0;
+    const nonReasoningFlashPenalty = flash && !model.reasoning ? -16 : 0;
+    return (
+      score +
+      reasoning * 55 +
+      deep * 18 +
+      context * 1.2 +
+      toolcall * 10 +
+      flashAdjustment +
+      zaiAdjustment +
+      nonReasoningFlashPenalty +
+      geminiAdjustment +
+      chutesAdjustment +
+      providerBias
+    );
   }
   if (agent === 'designer') {
+    const flashAdjustment = flash ? -8 : 0;
+    const zaiAdjustment = zai47NonFlash ? 10 : zai47Flash ? -8 : 0;
     return (
       score +
       attachment * 25 +
       reasoning * 18 +
       toolcall * 15 +
       context * 0.8 +
-      output
+      output +
+      flashAdjustment +
+      zaiAdjustment +
+      geminiAdjustment +
+      chutesAdjustment +
+      providerBias
     );
   }
   if (agent === 'explorer') {
-    return score + fast * 35 + toolcall * 28 + reasoning * 8 + context * 0.7;
+    const flashAdjustment = flash ? 26 : -10;
+    const zaiAdjustment = zai47NonFlash ? 2 : zai47Flash ? 6 : 0;
+    const deepPenalty = deep * -18;
+    return (
+      score +
+      fast * 68 +
+      toolcall * 28 +
+      reasoning * 2 +
+      context * 0.2 +
+      flashAdjustment +
+      zaiAdjustment +
+      deepPenalty +
+      geminiAdjustment +
+      chutesAdjustment +
+      providerBias
+    );
   }
   if (agent === 'librarian') {
-    return score + context * 30 + toolcall * 22 + reasoning * 15 + output * 10;
+    const flashAdjustment = flash ? -12 : 0;
+    const zaiAdjustment = zai47NonFlash ? 16 : zai47Flash ? -18 : 0;
+    return (
+      score +
+      context * 30 +
+      toolcall * 22 +
+      reasoning * 15 +
+      output * 10 +
+      flashAdjustment +
+      zaiAdjustment +
+      geminiAdjustment +
+      chutesAdjustment +
+      providerBias
+    );
   }
 
+  const flashAdjustment = flash ? -18 : 0;
+  const zaiAdjustment = zai47NonFlash ? 16 : zai47Flash ? -18 : 0;
+  const nonReasoningFlashPenalty = flash && !model.reasoning ? -16 : 0;
   return (
-    score + code * 28 + toolcall * 24 + fast * 18 + reasoning * 14 + output * 8
+    score +
+    code * 28 +
+    toolcall * 24 +
+    fast * 18 +
+    reasoning * 14 +
+    output * 8 +
+    flashAdjustment +
+    zaiAdjustment +
+    nonReasoningFlashPenalty +
+    geminiAdjustment +
+    chutesAdjustment +
+    providerBias
   );
 }
 
+function getExternalSignalBoost(
+  agent: AgentName,
+  model: DiscoveredModel,
+  externalSignals: ExternalSignalMap | undefined,
+): number {
+  if (!externalSignals) return 0;
+
+  const signal = modelLookupKeys(model)
+    .map((key) => externalSignals[key])
+    .find((item) => item !== undefined);
+
+  if (!signal) return 0;
+
+  const qualityScore = signal.qualityScore ?? 0;
+  const codingScore = signal.codingScore ?? 0;
+  const latencySeconds = signal.latencySeconds;
+
+  const blendedPrice =
+    signal.inputPricePer1M !== undefined &&
+    signal.outputPricePer1M !== undefined
+      ? signal.inputPricePer1M * 0.75 + signal.outputPricePer1M * 0.25
+      : (signal.inputPricePer1M ?? signal.outputPricePer1M ?? 0);
+  if (agent === 'explorer') {
+    const qualityBoost = qualityScore * 0.05;
+    const codingBoost = codingScore * 0.08;
+    const latencyPenalty =
+      typeof latencySeconds === 'number' && Number.isFinite(latencySeconds)
+        ? Math.min(latencySeconds, 12) * 3.2 +
+          (latencySeconds > 7 ? 16 : latencySeconds > 4 ? 10 : 0)
+        : 0;
+    const pricePenalty = Math.min(blendedPrice, 30) * 0.03;
+    const qualityFloorPenalty =
+      qualityScore > 0 && qualityScore < 35 ? (35 - qualityScore) * 0.8 : 0;
+    const boost =
+      qualityBoost +
+      codingBoost -
+      latencyPenalty -
+      pricePenalty -
+      qualityFloorPenalty;
+    return Math.max(-90, Math.min(25, boost));
+  }
+
+  const qualityBoost = qualityScore * 0.16;
+  const codingBoost = codingScore * 0.24;
+  const latencyPenalty =
+    typeof latencySeconds === 'number' && Number.isFinite(latencySeconds)
+      ? Math.min(latencySeconds, 25) * 0.22
+      : 0;
+  const pricePenalty = Math.min(blendedPrice, 30) * 0.08;
+  const boost = qualityBoost + codingBoost - latencyPenalty - pricePenalty;
+  return Math.max(-30, Math.min(45, boost));
+}
+
 function rankModels(
   models: DiscoveredModel[],
   agent: AgentName,
+  externalSignals?: ExternalSignalMap,
 ): DiscoveredModel[] {
-  return [...models].sort((a, b) => roleScore(agent, b) - roleScore(agent, a));
+  const versionRecencyMap = getVersionRecencyMap(models);
+
+  return [...models].sort((a, b) => {
+    const scoreA =
+      roleScore(agent, a, versionRecencyMap[a.model] ?? 0) +
+      getExternalSignalBoost(agent, a, externalSignals);
+    const scoreB =
+      roleScore(agent, b, versionRecencyMap[b.model] ?? 0) +
+      getExternalSignalBoost(agent, b, externalSignals);
+    const scoreDelta = scoreB - scoreA;
+    if (scoreDelta !== 0) return scoreDelta;
+
+    const providerTieBreak = a.providerID.localeCompare(b.providerID);
+    if (providerTieBreak !== 0) return providerTieBreak;
+
+    return a.model.localeCompare(b.model);
+  });
+}
+
+export function rankModelsV1WithBreakdown(
+  models: DiscoveredModel[],
+  agent: AgentName,
+  externalSignals?: ExternalSignalMap,
+): V1RankedScore[] {
+  const versionRecencyMap = getVersionRecencyMap(models);
+  return [...models]
+    .map((model) => {
+      const base = roleScore(agent, model, versionRecencyMap[model.model] ?? 0);
+      const boost = getExternalSignalBoost(agent, model, externalSignals);
+      return {
+        model: model.model,
+        baseScore: Math.round(base * 1000) / 1000,
+        externalSignalBoost: Math.round(boost * 1000) / 1000,
+        totalScore: Math.round((base + boost) * 1000) / 1000,
+      };
+    })
+    .sort((a, b) => {
+      if (a.totalScore !== b.totalScore) return b.totalScore - a.totalScore;
+      return a.model.localeCompare(b.model);
+    });
+}
+
+function combinedScore(
+  agent: AgentName,
+  model: DiscoveredModel,
+  externalSignals?: ExternalSignalMap,
+  versionRecencyMap?: Record<string, number>,
+): number {
+  return (
+    roleScore(agent, model, versionRecencyMap?.[model.model] ?? 0) +
+    getExternalSignalBoost(agent, model, externalSignals)
+  );
+}
+
+function effectiveEngine(engineVersion: ScoringEngineVersion): 'v1' | 'v2' {
+  return engineVersion === 'v2' ? 'v2' : 'v1';
+}
+
+function scoreForEngine(
+  engineVersion: ScoringEngineVersion,
+  agent: AgentName,
+  model: DiscoveredModel,
+  externalSignals: ExternalSignalMap | undefined,
+  versionRecencyMap: Record<string, number>,
+): number {
+  if (effectiveEngine(engineVersion) === 'v2') {
+    return scoreCandidateV2(model, agent, externalSignals).totalScore;
+  }
+
+  return combinedScore(agent, model, externalSignals, versionRecencyMap);
+}
+
+function selectTopModelsPerProvider(
+  models: DiscoveredModel[],
+  engineVersion: ScoringEngineVersion,
+  externalSignals: ExternalSignalMap | undefined,
+  versionRecencyMap: Record<string, number>,
+): DiscoveredModel[] {
+  const byProvider = new Map<string, DiscoveredModel[]>();
+
+  for (const model of models) {
+    const current = byProvider.get(model.providerID) ?? [];
+    current.push(model);
+    byProvider.set(model.providerID, current);
+  }
+
+  const selected: DiscoveredModel[] = [];
+
+  for (const providerModels of byProvider.values()) {
+    if (providerModels.length <= 2) {
+      selected.push(...providerModels);
+      continue;
+    }
+
+    const ranked = [...providerModels]
+      .map((model) => {
+        const total = AGENTS.reduce((sum, agent) => {
+          return (
+            sum +
+            scoreForEngine(
+              engineVersion,
+              agent,
+              model,
+              externalSignals,
+              versionRecencyMap,
+            )
+          );
+        }, 0);
+
+        return {
+          model,
+          score: total / AGENTS.length,
+        };
+      })
+      .sort((a, b) => {
+        if (a.score !== b.score) return b.score - a.score;
+        return a.model.model.localeCompare(b.model.model);
+      })
+      .slice(0, 2)
+      .map((entry) => entry.model);
+
+    selected.push(...ranked);
+  }
+
+  return selected;
+}
+
+function countProviderUsage(
+  agents: Record<string, { model: string; variant?: string }>,
+): Map<string, number> {
+  const counts = new Map<string, number>();
+  for (const assignment of Object.values(agents)) {
+    const provider = assignment.model.split('/')[0];
+    if (!provider) continue;
+    counts.set(provider, (counts.get(provider) ?? 0) + 1);
+  }
+  return counts;
+}
+
+function rebalanceForSubscriptionMode(
+  agents: Record<string, { model: string; variant?: string }>,
+  chains: Record<string, string[]>,
+  provenance: Record<string, { winnerLayer: string; winnerModel: string }>,
+  paidProviders: string[],
+  getRankedModels: (agent: AgentName) => DiscoveredModel[],
+  getPinnedModelForProvider: (
+    agent: AgentName,
+    providerID: string,
+  ) => string | undefined,
+  targetByProvider: Record<string, number>,
+  externalSignals: ExternalSignalMap | undefined,
+  versionRecencyMap: Record<string, number>,
+  engineVersion: ScoringEngineVersion,
+): void {
+  if (paidProviders.length <= 1) return;
+
+  const MAX_ALLOWED_SCORE_LOSS = 20;
+
+  while (true) {
+    const providerUsage = countProviderUsage(agents);
+    const underProviders = paidProviders.filter(
+      (providerID) =>
+        (providerUsage.get(providerID) ?? 0) <
+        (targetByProvider[providerID] ?? 0),
+    );
+    const overProviders = paidProviders.filter(
+      (providerID) =>
+        (providerUsage.get(providerID) ?? 0) >
+        (targetByProvider[providerID] ?? 0),
+    );
+
+    if (underProviders.length === 0 || overProviders.length === 0) break;
+
+    let bestSwap:
+      | {
+          agent: AgentName;
+          candidate: DiscoveredModel;
+          loss: number;
+        }
+      | undefined;
+
+    for (const agent of PRIMARY_ASSIGNMENT_ORDER) {
+      const currentModelID = agents[agent]?.model;
+      if (!currentModelID) continue;
+
+      const currentProvider = currentModelID.split('/')[0];
+      if (!currentProvider || !overProviders.includes(currentProvider))
+        continue;
+
+      const ranked = getRankedModels(agent);
+      const currentModel =
+        ranked.find((model) => model.model === currentModelID) ??
+        ranked.find((model) => model.providerID === currentProvider);
+      if (!currentModel) continue;
+
+      const currentScore = scoreForEngine(
+        engineVersion,
+        agent,
+        currentModel,
+        externalSignals,
+        versionRecencyMap,
+      );
+
+      for (const underProvider of underProviders) {
+        const pinned = getPinnedModelForProvider(agent, underProvider);
+        const candidate =
+          ranked.find((model) => model.model === pinned) ??
+          ranked.find((model) => model.providerID === underProvider);
+        if (!candidate) continue;
+
+        const candidateScore = scoreForEngine(
+          engineVersion,
+          agent,
+          candidate,
+          externalSignals,
+          versionRecencyMap,
+        );
+        const loss = currentScore - candidateScore;
+
+        if (loss > MAX_ALLOWED_SCORE_LOSS) continue;
+        if (!bestSwap || loss < bestSwap.loss) {
+          bestSwap = { agent, candidate, loss };
+        }
+      }
+    }
+
+    if (!bestSwap) break;
+
+    agents[bestSwap.agent].model = bestSwap.candidate.model;
+    chains[bestSwap.agent] = dedupe([
+      bestSwap.candidate.model,
+      ...(chains[bestSwap.agent] ?? []),
+    ]).slice(0, 10);
+    provenance[bestSwap.agent] = {
+      winnerLayer: 'provider-fallback-policy',
+      winnerModel: bestSwap.candidate.model,
+    };
+  }
+}
+
+function chooseProviderRepresentative(
+  providerModels: DiscoveredModel[],
+  agent: AgentName,
+  externalSignals?: ExternalSignalMap,
+  versionRecencyMap?: Record<string, number>,
+): DiscoveredModel | null {
+  if (providerModels.length === 0) return null;
+
+  const flashBest = providerModels.find((model) => hasFlashToken(model));
+  const nonFlashBest = providerModels.find((model) => !hasFlashToken(model));
+
+  if (!nonFlashBest) return providerModels[0] ?? null;
+  if (!flashBest) return nonFlashBest;
+
+  const flashScore = combinedScore(
+    agent,
+    flashBest,
+    externalSignals,
+    versionRecencyMap,
+  );
+  const nonFlashScore = combinedScore(
+    agent,
+    nonFlashBest,
+    externalSignals,
+    versionRecencyMap,
+  );
+  const threshold = agent === 'explorer' ? -6 : 12;
+  return flashScore >= nonFlashScore + threshold ? flashBest : nonFlashBest;
+}
+
+function getQualityWindow(agent: AgentName): number {
+  if (agent === 'oracle' || agent === 'orchestrator') return 12;
+  if (agent === 'fixer') return 15;
+  if (agent === 'designer') return 16;
+  if (agent === 'librarian') return 18;
+  return 22;
+}
+
+function getProviderBundle(
+  providerModels: DiscoveredModel[],
+  agent: AgentName,
+  externalSignals?: ExternalSignalMap,
+  versionRecencyMap?: Record<string, number>,
+): string[] {
+  if (providerModels.length === 0) return [];
+
+  const representative = chooseProviderRepresentative(
+    providerModels,
+    agent,
+    externalSignals,
+    versionRecencyMap,
+  );
+  if (!representative) return [];
+
+  const second = providerModels.find((m) => m.model !== representative.model);
+  if (!second) return [representative.model];
+
+  const score1 = combinedScore(
+    agent,
+    representative,
+    externalSignals,
+    versionRecencyMap,
+  );
+  const score2 = combinedScore(
+    agent,
+    second,
+    externalSignals,
+    versionRecencyMap,
+  );
+  const gap = Math.abs(score1 - score2);
+  const includeSecond =
+    representative.providerID === 'chutes' ||
+    gap <=
+      (agent === 'oracle' || agent === 'orchestrator'
+        ? 8
+        : agent === 'designer' || agent === 'librarian'
+          ? 12
+          : agent === 'fixer'
+            ? 15
+            : 18);
+
+  return includeSecond
+    ? [representative.model, second.model]
+    : [representative.model];
+}
+
+function selectPrimaryWithDiversity(
+  candidates: DiscoveredModel[],
+  agent: AgentName,
+  providerUsage: Map<string, number>,
+  targetByProvider: Record<string, number>,
+  remainingSlots: number,
+  externalSignals?: ExternalSignalMap,
+  versionRecencyMap?: Record<string, number>,
+): DiscoveredModel | null {
+  if (candidates.length === 0) return null;
+
+  const candidateScores = candidates.map((model) => {
+    const usage = providerUsage.get(model.providerID) ?? 0;
+    const target = targetByProvider[model.providerID] ?? 1;
+    const softCap = target;
+    const hardCap = Math.min(target + 1, 4);
+    const deficit = Math.max(0, target - usage);
+    const softOverflow = Math.max(0, usage + 1 - softCap);
+    const hardOverflow = Math.max(0, usage + 1 - hardCap);
+    const rawScore = combinedScore(
+      agent,
+      model,
+      externalSignals,
+      versionRecencyMap,
+    );
+    const adjustedScore =
+      rawScore + deficit * 14 - softOverflow * 18 - hardOverflow * 100;
+
+    return {
+      model,
+      usage,
+      target,
+      rawScore,
+      adjustedScore: Math.round(adjustedScore * 1000) / 1000,
+    };
+  });
+
+  const bestRaw = Math.max(...candidateScores.map((item) => item.rawScore));
+  const window = getQualityWindow(agent);
+  let eligible = candidateScores.filter(
+    (item) => item.rawScore >= bestRaw - window,
+  );
+
+  const mustFillProviders = Object.entries(targetByProvider)
+    .filter(([providerID, target]) => {
+      const usage = providerUsage.get(providerID) ?? 0;
+      return Math.max(0, target - usage) >= remainingSlots;
+    })
+    .map(([providerID]) => providerID);
+
+  if (mustFillProviders.length > 0) {
+    const forced = eligible.filter((item) =>
+      mustFillProviders.includes(item.model.providerID),
+    );
+    if (forced.length > 0) eligible = forced;
+  }
+
+  eligible.sort((a, b) => {
+    const delta = b.adjustedScore - a.adjustedScore;
+    if (delta !== 0) return delta;
+
+    const ratioA = a.target > 0 ? a.usage / a.target : a.usage;
+    const ratioB = b.target > 0 ? b.usage / b.target : b.usage;
+    if (ratioA !== ratioB) return ratioA - ratioB;
+
+    if (a.rawScore !== b.rawScore) return b.rawScore - a.rawScore;
+
+    const providerTie = a.model.providerID.localeCompare(b.model.providerID);
+    if (providerTie !== 0) return providerTie;
+    return a.model.model.localeCompare(b.model.model);
+  });
+
+  let chosen = eligible[0] ?? candidateScores[0];
+  if (!chosen) return null;
+
+  if (chosen.usage >= 2) {
+    const bestUnused = candidateScores.find((item) => item.usage === 0);
+    if (bestUnused && bestUnused.adjustedScore >= chosen.adjustedScore - 9) {
+      chosen = bestUnused;
+    }
+  }
+
+  if (
+    agent !== 'explorer' &&
+    isZai47Model(chosen.model) &&
+    hasFlashToken(chosen.model)
+  ) {
+    const kimiCandidate = candidateScores.find((item) =>
+      isKimiK25Model(item.model),
+    );
+    if (kimiCandidate && kimiCandidate.rawScore >= chosen.rawScore - 2) {
+      chosen = kimiCandidate;
+    }
+  }
+
+  return chosen.model;
 }
 
 function dedupe(models: Array<string | undefined>): string[] {
@@ -159,64 +970,379 @@ function dedupe(models: Array<string | undefined>): string[] {
   return result;
 }
 
+function finalizeChainWithTail(
+  prefix: string[],
+  preferredTail: string | undefined,
+): string[] {
+  if (!preferredTail) {
+    return dedupe([...prefix, 'opencode/big-pickle']).slice(0, 10);
+  }
+
+  const withoutTail = prefix
+    .filter((model) => model !== preferredTail)
+    .slice(0, 9);
+  return [...withoutTail, preferredTail];
+}
+
+function ensureSyntheticModel(
+  models: DiscoveredModel[],
+  fullModelID: string | undefined,
+): DiscoveredModel[] {
+  if (!fullModelID) return models;
+  if (models.some((model) => model.model === fullModelID)) return models;
+
+  const [providerID, modelID] = fullModelID.split('/');
+  if (!providerID || !modelID) return models;
+
+  return [
+    ...models,
+    {
+      providerID,
+      model: fullModelID,
+      name: modelID,
+      status: 'active',
+      contextLimit: 200_000,
+      outputLimit: 32_000,
+      reasoning: true,
+      toolcall: true,
+      attachment: false,
+    },
+  ];
+}
+
 export function buildDynamicModelPlan(
   catalog: DiscoveredModel[],
   config: InstallConfig,
+  externalSignals?: ExternalSignalMap,
+  options?: {
+    scoringEngineVersion?: ScoringEngineVersion;
+  },
 ): DynamicModelPlan | null {
+  const catalogWithSelectedModels = [
+    config.selectedChutesPrimaryModel,
+    config.selectedChutesSecondaryModel,
+    config.selectedOpenCodePrimaryModel,
+    config.selectedOpenCodeSecondaryModel,
+  ].reduce((acc, modelID) => ensureSyntheticModel(acc, modelID), catalog);
+
   const enabledProviders = new Set(getEnabledProviders(config));
-  const providerCandidates = catalog.filter((m) =>
-    enabledProviders.has(m.providerID),
+  const providerUniverse = catalogWithSelectedModels.filter((m) => {
+    if (!enabledProviders.has(m.providerID)) return false;
+
+    if (m.providerID === 'chutes' && /qwen/i.test(m.model)) {
+      return false;
+    }
+
+    return true;
+  });
+  const engineVersion =
+    options?.scoringEngineVersion ?? config.scoringEngineVersion ?? 'v1';
+  const versionRecencyMap = getVersionRecencyMap(providerUniverse);
+
+  const providerCandidates = selectTopModelsPerProvider(
+    providerUniverse,
+    engineVersion,
+    externalSignals,
+    versionRecencyMap,
   );
 
   if (providerCandidates.length === 0) {
     return null;
   }
 
+  const hasPaidProviderEnabled =
+    config.hasOpenAI ||
+    config.hasAnthropic ||
+    config.hasCopilot ||
+    config.hasZaiPlan ||
+    config.hasKimi ||
+    config.hasAntigravity;
+
+  const paidProviders = dedupe(
+    providerCandidates
+      .map((model) => model.providerID)
+      .filter((providerID) => providerID !== 'opencode'),
+  ).sort((a, b) => a.localeCompare(b));
+
+  const targetByProvider: Record<string, number> = {};
+  if (paidProviders.length > 0) {
+    const baseTarget = Math.floor(AGENTS.length / paidProviders.length);
+    const extra = AGENTS.length % paidProviders.length;
+    for (const [index, providerID] of paidProviders.entries()) {
+      targetByProvider[providerID] = baseTarget + (index < extra ? 1 : 0);
+    }
+  }
+  const providerUsage = new Map<string, number>();
+  const rankCache = new Map<AgentName, DiscoveredModel[]>();
+  const shadowDiffs: Record<
+    string,
+    { v1TopModel?: string; v2TopModel?: string }
+  > = {};
+
   const agents: Record<string, { model: string; variant?: string }> = {};
   const chains: Record<string, string[]> = {};
+  const provenance: DynamicModelPlan['provenance'] = {};
+
+  const getSelectedChutesForAgent = (agent: AgentName): string | undefined => {
+    if (!config.hasChutes) return undefined;
+    return agent === 'explorer' || agent === 'librarian' || agent === 'fixer'
+      ? (config.selectedChutesSecondaryModel ??
+          config.selectedChutesPrimaryModel)
+      : config.selectedChutesPrimaryModel;
+  };
+
+  const getSelectedOpenCodeForAgent = (
+    agent: AgentName,
+  ): string | undefined => {
+    if (!config.useOpenCodeFreeModels) return undefined;
+    return agent === 'explorer' || agent === 'librarian' || agent === 'fixer'
+      ? (config.selectedOpenCodeSecondaryModel ??
+          config.selectedOpenCodePrimaryModel)
+      : config.selectedOpenCodePrimaryModel;
+  };
+
+  const getPinnedModelForProvider = (
+    agent: AgentName,
+    providerID: string,
+  ): string | undefined => {
+    if (providerID === 'chutes') return getSelectedChutesForAgent(agent);
+    if (providerID === 'opencode') return getSelectedOpenCodeForAgent(agent);
+    return undefined;
+  };
+
+  const getRankedModels = (agent: AgentName): DiscoveredModel[] => {
+    const cached = rankCache.get(agent);
+    if (cached) return cached;
+
+    const rankedV1 = rankModels(providerCandidates, agent, externalSignals);
+
+    if (engineVersion === 'v1') {
+      rankCache.set(agent, rankedV1);
+      return rankedV1;
+    }
+
+    const rankedV2 = rankModelsV2(
+      providerCandidates,
+      agent,
+      externalSignals,
+    ).map((candidate) => candidate.model);
 
-  for (const agent of AGENTS) {
-    const ranked = rankModels(providerCandidates, agent);
-    const primary = ranked[0];
+    if (engineVersion === 'v2-shadow') {
+      shadowDiffs[agent] = {
+        v1TopModel: rankedV1[0]?.model,
+        v2TopModel: rankedV2[0]?.model,
+      };
+      rankCache.set(agent, rankedV1);
+      return rankedV1;
+    }
+
+    rankCache.set(agent, rankedV2);
+    return rankedV2;
+  };
+
+  for (const [agentIndex, agent] of PRIMARY_ASSIGNMENT_ORDER.entries()) {
+    const ranked = getRankedModels(agent);
+    const primaryPool = hasPaidProviderEnabled
+      ? ranked.filter((model) => !FREE_BIASED_PROVIDERS.has(model.providerID))
+      : ranked;
+    const remainingSlots = PRIMARY_ASSIGNMENT_ORDER.length - agentIndex;
+    const primary =
+      selectPrimaryWithDiversity(
+        primaryPool.length > 0 ? primaryPool : ranked,
+        agent,
+        providerUsage,
+        targetByProvider,
+        remainingSlots,
+        externalSignals,
+        versionRecencyMap,
+      ) ?? ranked[0];
     if (!primary) continue;
 
+    providerUsage.set(
+      primary.providerID,
+      (providerUsage.get(primary.providerID) ?? 0) + 1,
+    );
+
     const providerOrder = dedupe(ranked.map((m) => m.providerID));
-    const perProviderBest = providerOrder
-      .map(
-        (providerID) => ranked.find((m) => m.providerID === providerID)?.model,
-      )
-      .filter((m): m is string => Boolean(m));
-
-    const selectedOpencode =
-      agent === 'explorer' || agent === 'librarian' || agent === 'fixer'
-        ? (config.selectedOpenCodeSecondaryModel ??
-          config.selectedOpenCodePrimaryModel)
-        : config.selectedOpenCodePrimaryModel;
+    const perProviderBest = providerOrder.flatMap((providerID) => {
+      const providerModels = ranked.filter((m) => m.providerID === providerID);
+      const pinned = getPinnedModelForProvider(agent, providerID);
+      if (pinned && providerModels.some((m) => m.model === pinned)) {
+        return [pinned];
+      }
+      return getProviderBundle(
+        providerModels,
+        agent,
+        externalSignals,
+        versionRecencyMap,
+      );
+    });
+    const nonFreePerProviderBest = perProviderBest.filter(
+      (model) => !model.startsWith('opencode/'),
+    );
+    const freePerProviderBest = perProviderBest.filter((model) =>
+      model.startsWith('opencode/'),
+    );
 
-    const selectedChutes =
-      agent === 'explorer' || agent === 'librarian' || agent === 'fixer'
-        ? (config.selectedChutesSecondaryModel ??
-          config.selectedChutesPrimaryModel)
-        : config.selectedChutesPrimaryModel;
+    const selectedOpencode = getSelectedOpenCodeForAgent(agent);
+    const selectedChutes = getSelectedChutesForAgent(agent);
 
     const chain = dedupe([
       primary.model,
-      ...perProviderBest,
+      ...nonFreePerProviderBest,
       selectedChutes,
       selectedOpencode,
-      'opencode/big-pickle',
-    ]).slice(0, 7);
+      ...freePerProviderBest,
+    ]);
+
+    const deterministicFreeTail =
+      selectedOpencode ??
+      freePerProviderBest[0] ??
+      ranked.find((model) => model.model.startsWith('opencode/'))?.model;
+
+    const finalizedChain = finalizeChainWithTail(chain, deterministicFreeTail);
+
+    const providerPolicyChain = dedupe([selectedChutes, selectedOpencode]);
+    const systemDefaultModel = selectedOpencode ?? 'opencode/big-pickle';
+    const resolved = resolveAgentWithPrecedence({
+      agentName: agent,
+      dynamicRecommendation: finalizedChain,
+      providerFallbackPolicy: providerPolicyChain,
+      systemDefault: [systemDefaultModel],
+    });
+
+    let finalModel = resolved.model;
+    let finalChain = resolved.chain;
+
+    const selectedChutesForAgent = getSelectedChutesForAgent(agent);
+    const selectedOpenCodeForAgent = getSelectedOpenCodeForAgent(agent);
+
+    const forceChutes =
+      finalModel.startsWith('chutes/') && Boolean(selectedChutesForAgent);
+    const forceOpenCode =
+      finalModel.startsWith('opencode/') && Boolean(selectedOpenCodeForAgent);
+
+    if (forceOpenCode && selectedOpenCodeForAgent) {
+      finalModel = selectedOpenCodeForAgent;
+      finalChain = dedupe([selectedOpenCodeForAgent, ...finalChain]);
+    }
+
+    if (forceChutes && selectedChutesForAgent) {
+      finalModel = selectedChutesForAgent;
+      finalChain = dedupe([selectedChutesForAgent, ...finalChain]);
+    }
+
+    const wasForced = forceChutes || forceOpenCode;
 
     agents[agent] = {
-      model: chain[0] ?? primary.model,
+      model: finalModel,
       variant: ROLE_VARIANT[agent],
     };
-    chains[agent] = chain;
+    chains[agent] = finalChain;
+    provenance[agent] = {
+      winnerLayer: wasForced
+        ? 'manual-user-plan'
+        : resolved.provenance.winnerLayer,
+      winnerModel: finalModel,
+    };
+  }
+
+  if (hasPaidProviderEnabled) {
+    for (const providerID of paidProviders) {
+      if ((providerUsage.get(providerID) ?? 0) > 0) continue;
+
+      let bestSwap:
+        | {
+            agent: AgentName;
+            candidateModel: string;
+            loss: number;
+          }
+        | undefined;
+
+      for (const agent of PRIMARY_ASSIGNMENT_ORDER) {
+        const currentModel = agents[agent]?.model;
+        if (!currentModel) continue;
+
+        const ranked = getRankedModels(agent);
+        const pinned = getPinnedModelForProvider(agent, providerID);
+        const candidate =
+          ranked.find((model) => model.model === pinned) ??
+          ranked.find((model) => model.providerID === providerID);
+        const current = ranked.find((model) => model.model === currentModel);
+        if (!candidate || !current) continue;
+
+        const currentScore = combinedScore(
+          agent,
+          current,
+          externalSignals,
+          versionRecencyMap,
+        );
+        const candidateScore = combinedScore(
+          agent,
+          candidate,
+          externalSignals,
+          versionRecencyMap,
+        );
+        const loss = currentScore - candidateScore;
+
+        if (!bestSwap || loss < bestSwap.loss) {
+          bestSwap = {
+            agent,
+            candidateModel: candidate.model,
+            loss,
+          };
+        }
+      }
+
+      if (!bestSwap) continue;
+
+      const existingProvider =
+        agents[bestSwap.agent]?.model.split('/')[0] ?? providerID;
+      agents[bestSwap.agent].model = bestSwap.candidateModel;
+      chains[bestSwap.agent] = dedupe([
+        bestSwap.candidateModel,
+        ...(chains[bestSwap.agent] ?? []),
+      ]).slice(0, 10);
+      provenance[bestSwap.agent] = {
+        winnerLayer: 'provider-fallback-policy',
+        winnerModel: bestSwap.candidateModel,
+      };
+
+      providerUsage.set(providerID, (providerUsage.get(providerID) ?? 0) + 1);
+      providerUsage.set(
+        existingProvider,
+        Math.max(0, (providerUsage.get(existingProvider) ?? 1) - 1),
+      );
+    }
+  }
+
+  if (config.balanceProviderUsage && hasPaidProviderEnabled) {
+    rebalanceForSubscriptionMode(
+      agents,
+      chains,
+      provenance,
+      paidProviders,
+      getRankedModels,
+      getPinnedModelForProvider,
+      targetByProvider,
+      externalSignals,
+      versionRecencyMap,
+      engineVersion,
+    );
   }
 
   if (Object.keys(agents).length === 0) {
     return null;
   }
 
-  return { agents, chains };
+  return {
+    agents,
+    chains,
+    provenance,
+    scoring: {
+      engineVersionApplied: engineVersion === 'v2' ? 'v2' : 'v1',
+      shadowCompared: engineVersion === 'v2-shadow',
+      diffs: engineVersion === 'v2-shadow' ? shadowDiffs : undefined,
+    },
+  };
 }

+ 255 - 0
src/cli/external-rankings.ts

@@ -0,0 +1,255 @@
+import { buildModelKeyAliases } from './model-key-normalization';
+import type { ExternalModelSignal, ExternalSignalMap } from './types';
+
+interface ArtificialAnalysisResponse {
+  data?: Array<{
+    id?: string;
+    name?: string;
+    slug?: string;
+    model_creator?: {
+      slug?: string;
+    };
+    evaluations?: {
+      artificial_analysis_intelligence_index?: number;
+      artificial_analysis_coding_index?: number;
+      livecodebench?: number;
+    };
+    pricing?: {
+      price_1m_input_tokens?: number;
+      price_1m_output_tokens?: number;
+      price_1m_blended_3_to_1?: number;
+    };
+    median_time_to_first_token_seconds?: number;
+  }>;
+}
+
+interface OpenRouterModelsResponse {
+  data?: Array<{
+    id?: string;
+    pricing?: {
+      prompt?: string;
+      completion?: string;
+    };
+  }>;
+}
+
+function normalizeKey(input: string): string {
+  return input.trim().toLowerCase();
+}
+
+function baseAliases(key: string): string[] {
+  return buildModelKeyAliases(normalizeKey(key));
+}
+
+function providerScopedAlias(alias: string, providerPrefix?: string): string {
+  if (!providerPrefix || alias.includes('/')) return alias;
+  return `${providerPrefix}/${alias}`;
+}
+
+function mergeSignal(
+  existing: ExternalModelSignal | undefined,
+  incoming: ExternalModelSignal,
+): ExternalModelSignal {
+  if (!existing) return incoming;
+
+  return {
+    qualityScore: incoming.qualityScore ?? existing.qualityScore,
+    codingScore: incoming.codingScore ?? existing.codingScore,
+    latencySeconds: incoming.latencySeconds ?? existing.latencySeconds,
+    inputPricePer1M: incoming.inputPricePer1M ?? existing.inputPricePer1M,
+    outputPricePer1M: incoming.outputPricePer1M ?? existing.outputPricePer1M,
+    source: 'merged',
+  };
+}
+
+function providerPrefixFromCreator(creatorSlug?: string): string | undefined {
+  if (!creatorSlug) return undefined;
+  const slug = creatorSlug.toLowerCase();
+  if (slug.includes('openai')) return 'openai';
+  if (slug.includes('anthropic')) return 'anthropic';
+  if (slug.includes('google')) return 'google';
+  if (slug.includes('chutes')) return 'chutes';
+  if (slug.includes('copilot') || slug.includes('github'))
+    return 'github-copilot';
+  if (slug.includes('zai') || slug.includes('z-ai')) return 'zai-coding-plan';
+  if (slug.includes('kimi')) return 'kimi-for-coding';
+  if (slug.includes('opencode')) return 'opencode';
+  return undefined;
+}
+
+function parseOpenRouterPrice(value: string | undefined): number | undefined {
+  if (!value) return undefined;
+  const parsed = Number.parseFloat(value);
+  if (!Number.isFinite(parsed)) return undefined;
+  return parsed * 1_000_000;
+}
+
+async function fetchJsonWithTimeout(
+  url: string,
+  init: RequestInit,
+  timeoutMs: number,
+): Promise<Response> {
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), timeoutMs);
+
+  try {
+    return await fetch(url, {
+      ...init,
+      signal: controller.signal,
+    });
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+import { getEnv } from '../utils';
+
+async function fetchArtificialAnalysisSignals(
+  apiKey: string,
+): Promise<ExternalSignalMap> {
+  const response = await fetchJsonWithTimeout(
+    'https://artificialanalysis.ai/api/v2/data/llms/models',
+    {
+      headers: {
+        'x-api-key': apiKey,
+      },
+    },
+    8000,
+  );
+
+  if (!response.ok) {
+    throw new Error(
+      `Artificial Analysis request failed (${response.status} ${response.statusText})`,
+    );
+  }
+
+  const parsed = (await response.json()) as ArtificialAnalysisResponse;
+  const map: ExternalSignalMap = {};
+
+  for (const model of parsed.data ?? []) {
+    const baseSignal: ExternalModelSignal = {
+      qualityScore: model.evaluations?.artificial_analysis_intelligence_index,
+      codingScore:
+        model.evaluations?.artificial_analysis_coding_index ??
+        model.evaluations?.livecodebench,
+      latencySeconds: model.median_time_to_first_token_seconds,
+      inputPricePer1M:
+        model.pricing?.price_1m_input_tokens ??
+        model.pricing?.price_1m_blended_3_to_1,
+      outputPricePer1M:
+        model.pricing?.price_1m_output_tokens ??
+        model.pricing?.price_1m_blended_3_to_1,
+      source: 'artificial-analysis',
+    };
+
+    const id = model.id ? normalizeKey(model.id) : undefined;
+    const slug = model.slug ? normalizeKey(model.slug) : undefined;
+    const name = model.name ? normalizeKey(model.name) : undefined;
+    const providerPrefix = providerPrefixFromCreator(model.model_creator?.slug);
+
+    for (const key of [id, slug, name]) {
+      if (!key) continue;
+      for (const alias of baseAliases(key)) {
+        if (!providerPrefix || alias.includes('/')) {
+          map[alias] = mergeSignal(map[alias], baseSignal);
+        }
+
+        const scopedAlias = providerScopedAlias(alias, providerPrefix);
+        map[scopedAlias] = mergeSignal(map[scopedAlias], baseSignal);
+      }
+    }
+  }
+
+  return map;
+}
+
+async function fetchOpenRouterSignals(
+  apiKey: string,
+): Promise<ExternalSignalMap> {
+  const response = await fetchJsonWithTimeout(
+    'https://openrouter.ai/api/v1/models',
+    {
+      headers: {
+        Authorization: `Bearer ${apiKey}`,
+      },
+    },
+    8000,
+  );
+
+  if (!response.ok) {
+    throw new Error(
+      `OpenRouter request failed (${response.status} ${response.statusText})`,
+    );
+  }
+
+  const parsed = (await response.json()) as OpenRouterModelsResponse;
+  const map: ExternalSignalMap = {};
+
+  for (const model of parsed.data ?? []) {
+    if (!model.id) continue;
+    const key = normalizeKey(model.id);
+    const providerPrefix = key.split('/')[0];
+    const signal: ExternalModelSignal = {
+      inputPricePer1M: parseOpenRouterPrice(model.pricing?.prompt),
+      outputPricePer1M: parseOpenRouterPrice(model.pricing?.completion),
+      source: 'openrouter',
+    };
+
+    for (const alias of baseAliases(key)) {
+      if (alias.includes('/')) {
+        map[alias] = mergeSignal(map[alias], signal);
+      }
+
+      const scopedAlias = providerScopedAlias(alias, providerPrefix);
+      map[scopedAlias] = mergeSignal(map[scopedAlias], signal);
+    }
+  }
+
+  return map;
+}
+
+export async function fetchExternalModelSignals(options?: {
+  artificialAnalysisApiKey?: string;
+  openRouterApiKey?: string;
+}): Promise<{
+  signals: ExternalSignalMap;
+  warnings: string[];
+}> {
+  const warnings: string[] = [];
+  const aggregate: ExternalSignalMap = {};
+
+  const aaKey =
+    options?.artificialAnalysisApiKey ?? getEnv('ARTIFICIAL_ANALYSIS_API_KEY');
+  const orKey = options?.openRouterApiKey ?? getEnv('OPENROUTER_API_KEY');
+
+  const aaPromise: Promise<ExternalSignalMap> = aaKey
+    ? fetchArtificialAnalysisSignals(aaKey)
+    : Promise.resolve({});
+  const orPromise: Promise<ExternalSignalMap> = orKey
+    ? fetchOpenRouterSignals(orKey)
+    : Promise.resolve({});
+
+  const [aaResult, orResult] = await Promise.allSettled([aaPromise, orPromise]);
+
+  if (aaResult.status === 'fulfilled') {
+    for (const [key, signal] of Object.entries(aaResult.value)) {
+      aggregate[key] = mergeSignal(aggregate[key], signal);
+    }
+  } else if (aaKey) {
+    warnings.push(
+      `Artificial Analysis unavailable: ${aaResult.reason instanceof Error ? aaResult.reason.message : String(aaResult.reason)}`,
+    );
+  }
+
+  if (orResult.status === 'fulfilled') {
+    for (const [key, signal] of Object.entries(orResult.value)) {
+      aggregate[key] = mergeSignal(aggregate[key], signal);
+    }
+  } else if (orKey) {
+    warnings.push(
+      `OpenRouter unavailable: ${orResult.reason instanceof Error ? orResult.reason.message : String(orResult.reason)}`,
+    );
+  }
+
+  return { signals: aggregate, warnings };
+}

+ 24 - 3
src/cli/index.ts

@@ -30,8 +30,18 @@ function parseArgs(args: string[]): InstallArgs {
       result.skills = arg.split('=')[1] as BooleanArg;
     } else if (arg.startsWith('--opencode-free=')) {
       result.opencodeFree = arg.split('=')[1] as BooleanArg;
+    } else if (arg.startsWith('--balanced-spend=')) {
+      result.balancedSpend = arg.split('=')[1] as BooleanArg;
     } else if (arg.startsWith('--opencode-free-model=')) {
       result.opencodeFreeModel = arg.split('=')[1];
+    } else if (arg.startsWith('--aa-key=')) {
+      result.aaKey = arg.slice('--aa-key='.length);
+    } else if (arg.startsWith('--openrouter-key=')) {
+      result.openrouterKey = arg.slice('--openrouter-key='.length);
+    } else if (arg === '--dry-run') {
+      result.dryRun = true;
+    } else if (arg === '--models-only') {
+      result.modelsOnly = true;
     } else if (arg === '-h' || arg === '--help') {
       printHelp();
       process.exit(0);
@@ -46,6 +56,7 @@ function printHelp(): void {
 oh-my-opencode-slim installer
 
 Usage: bunx oh-my-opencode-slim install [OPTIONS]
+       bunx oh-my-opencode-slim models [OPTIONS]
 
 Options:
   --kimi=yes|no          Kimi API access (yes/no)
@@ -56,23 +67,33 @@ Options:
   --antigravity=yes|no   Antigravity/Google models (yes/no)
   --chutes=yes|no        Chutes models (yes/no)
   --opencode-free=yes|no Use OpenCode free models (opencode/*)
+  --balanced-spend=yes|no Evenly spread usage across selected providers when score gaps are within tolerance
   --opencode-free-model  Preferred OpenCode model id or "auto"
+  --aa-key               Artificial Analysis API key (optional)
+  --openrouter-key       OpenRouter API key (optional)
   --tmux=yes|no          Enable tmux integration (yes/no)
   --skills=yes|no        Install recommended skills (yes/no)
   --no-tui               Non-interactive mode (requires all flags)
+  --dry-run              Simulate install without writing files or requiring OpenCode
+  --models-only          Update model assignments only (skip plugin/auth/skills)
   -h, --help             Show this help message
 
 Examples:
   bunx oh-my-opencode-slim install
-  bunx oh-my-opencode-slim install --no-tui --kimi=yes --openai=yes --anthropic=yes --copilot=no --zai-plan=no --antigravity=yes --chutes=no --opencode-free=yes --opencode-free-model=auto --tmux=no --skills=yes
+  bunx oh-my-opencode-slim models
+  bunx oh-my-opencode-slim install --no-tui --kimi=yes --openai=yes --anthropic=yes --copilot=no --zai-plan=no --antigravity=yes --chutes=no --opencode-free=yes --balanced-spend=yes --opencode-free-model=auto --aa-key=YOUR_AA_KEY --openrouter-key=YOUR_OR_KEY --tmux=no --skills=yes
 `);
 }
 
 async function main(): Promise<void> {
   const args = process.argv.slice(2);
 
-  if (args.length === 0 || args[0] === 'install') {
-    const installArgs = parseArgs(args.slice(args[0] === 'install' ? 1 : 0));
+  if (args.length === 0 || args[0] === 'install' || args[0] === 'models') {
+    const hasSubcommand = args[0] === 'install' || args[0] === 'models';
+    const installArgs = parseArgs(args.slice(hasSubcommand ? 1 : 0));
+    if (args[0] === 'models') {
+      installArgs.modelsOnly = true;
+    }
     const exitCode = await install(installArgs);
     process.exit(exitCode);
   } else if (args[0] === '-h' || args[0] === '--help') {

File diff suppressed because it is too large
+ 688 - 84
src/cli/install.ts


+ 21 - 0
src/cli/model-key-normalization.test.ts

@@ -0,0 +1,21 @@
+/// <reference types="bun-types" />
+
+import { describe, expect, test } from 'bun:test';
+import { buildModelKeyAliases } from './model-key-normalization';
+
+describe('model key normalization', () => {
+  test('normalizes multi-segment chutes model ids', () => {
+    const aliases = buildModelKeyAliases(
+      'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
+    );
+
+    expect(aliases).toContain('qwen/qwen3-coder-480b-a35b-instruct');
+    expect(aliases).toContain('qwen3-coder-480b-a35b-instruct');
+    expect(aliases).not.toContain('qwen3-coder-480b-a35b-instruct-fp8-tee');
+  });
+
+  test('treats spaces and hyphens as equivalent aliases', () => {
+    const aliases = buildModelKeyAliases('Qwen3 Coder 480B A35B Instruct');
+    expect(aliases).toContain('qwen3-coder-480b-a35b-instruct');
+  });
+});

+ 60 - 0
src/cli/model-key-normalization.ts

@@ -0,0 +1,60 @@
+function cleanupAlias(input: string, preserveSlash: boolean): string {
+  let value = input.toLowerCase().trim();
+  value = value.replace(/\bfp[a-z0-9.-]*\b/g, ' ');
+  value = value.replace(/\btee\b/g, ' ');
+
+  if (preserveSlash) {
+    value = value.replace(/[_\s]+/g, '-');
+    value = value.replace(/-+/g, '-');
+    value = value.replace(/\/+/g, '/');
+    value = value.replace(/\/-+/g, '/');
+    value = value.replace(/-+\//g, '/');
+    value = value.replace(/^\/+|\/+$/g, '');
+    value = value.replace(/^-+|-+$/g, '');
+    return value;
+  }
+
+  value = value.replace(/[/_\s]+/g, '-');
+  value = value.replace(/-+/g, '-');
+  value = value.replace(/^-+|-+$/g, '');
+  return value;
+}
+
+function addDerivedAliases(seed: string, aliases: Set<string>): void {
+  const slashAlias = cleanupAlias(seed, true);
+  const flatAlias = cleanupAlias(seed, false);
+
+  if (slashAlias) aliases.add(slashAlias);
+  if (flatAlias) aliases.add(flatAlias);
+
+  if (slashAlias) {
+    aliases.add(slashAlias.replace(/-(free|flash)$/i, ''));
+  }
+  if (flatAlias) {
+    aliases.add(flatAlias.replace(/-(free|flash)$/i, ''));
+  }
+
+  if (slashAlias.includes('/')) {
+    aliases.add(cleanupAlias(slashAlias.replace(/\//g, ' '), false));
+    aliases.add(cleanupAlias(slashAlias.replace(/\//g, '-'), false));
+    const lastPart = slashAlias.split('/').at(-1);
+    if (lastPart) {
+      addDerivedAliases(lastPart, aliases);
+    }
+  }
+}
+
+export function buildModelKeyAliases(input: string): string[] {
+  const normalized = input.trim().toLowerCase();
+  if (!normalized) return [];
+
+  const aliases = new Set<string>();
+  const slashIndex = normalized.indexOf('/');
+  const afterProvider =
+    slashIndex >= 0 ? normalized.slice(slashIndex + 1) : normalized;
+
+  addDerivedAliases(normalized, aliases);
+  addDerivedAliases(afterProvider, aliases);
+
+  return [...aliases].filter((alias) => alias.length > 0);
+}

+ 20 - 0
src/cli/opencode-models.test.ts

@@ -24,6 +24,16 @@ chutes/minimax-m2.1-5000
   "limit": { "context": 500000, "output": 64000 },
   "capabilities": { "reasoning": true, "toolcall": true, "attachment": false }
 }
+chutes/qwen3-coder-30b
+{
+  "id": "qwen3-coder-30b",
+  "providerID": "chutes",
+  "name": "Qwen3 Coder 30B",
+  "status": "active",
+  "cost": { "input": 0.4, "output": 0.8, "cache": { "read": 0, "write": 0 } },
+  "limit": { "context": 262144, "output": 32768 },
+  "capabilities": { "reasoning": true, "toolcall": true, "attachment": false }
+}
 `;
 
 describe('opencode-models parser', () => {
@@ -40,4 +50,14 @@ describe('opencode-models parser', () => {
     expect(models[0]?.model).toBe('chutes/minimax-m2.1-5000');
     expect(models[0]?.dailyRequestLimit).toBe(5000);
   });
+
+  test('includes non-free chutes models when freeOnly is disabled', () => {
+    const models = parseOpenCodeModelsVerboseOutput(
+      SAMPLE_OUTPUT,
+      'chutes',
+      false,
+    );
+    expect(models.length).toBe(2);
+    expect(models[1]?.model).toBe('chutes/qwen3-coder-30b');
+  });
 });

+ 25 - 14
src/cli/opencode-models.ts

@@ -1,3 +1,4 @@
+import { resolveOpenCodePath } from './system';
 import type { DiscoveredModel, OpenCodeFreeModel } from './types';
 
 interface OpenCodeModelVerboseRecord {
@@ -97,13 +98,13 @@ export function parseOpenCodeModelsVerboseOutput(
 ): DiscoveredModel[] {
   const lines = output.split(/\r?\n/);
   const models: DiscoveredModel[] = [];
+  const modelHeaderPattern = /^[a-z0-9-]+\/.+$/i;
 
   for (let index = 0; index < lines.length; index++) {
     const line = lines[index]?.trim();
     if (!line || !line.includes('/')) continue;
 
-    const isModelHeader = /^[a-z0-9-]+\/[a-z0-9._-]+$/i.test(line);
-    if (!isModelHeader) continue;
+    if (!modelHeaderPattern.test(line)) continue;
 
     let jsonStart = -1;
     for (let search = index + 1; search < lines.length; search++) {
@@ -112,7 +113,7 @@ export function parseOpenCodeModelsVerboseOutput(
         break;
       }
 
-      if (/^[a-z0-9-]+\/[a-z0-9._-]+$/i.test(lines[search]?.trim() ?? '')) {
+      if (modelHeaderPattern.test(lines[search]?.trim() ?? '')) {
         break;
       }
     }
@@ -158,12 +159,16 @@ export function parseOpenCodeModelsVerboseOutput(
   return models;
 }
 
-async function discoverFreeModelsByProvider(providerID?: string): Promise<{
-  models: OpenCodeFreeModel[];
+async function discoverModelsByProvider(
+  providerID?: string,
+  freeOnly = true,
+): Promise<{
+  models: DiscoveredModel[];
   error?: string;
 }> {
   try {
-    const proc = Bun.spawn(['opencode', 'models', '--refresh', '--verbose'], {
+    const opencodePath = resolveOpenCodePath();
+    const proc = Bun.spawn([opencodePath, 'models', '--refresh', '--verbose'], {
       stdout: 'pipe',
       stderr: 'pipe',
     });
@@ -180,11 +185,7 @@ async function discoverFreeModelsByProvider(providerID?: string): Promise<{
     }
 
     return {
-      models: parseOpenCodeModelsVerboseOutput(
-        stdout,
-        providerID,
-        true,
-      ) as OpenCodeFreeModel[],
+      models: parseOpenCodeModelsVerboseOutput(stdout, providerID, freeOnly),
     };
   } catch {
     return {
@@ -199,7 +200,8 @@ export async function discoverModelCatalog(): Promise<{
   error?: string;
 }> {
   try {
-    const proc = Bun.spawn(['opencode', 'models', '--refresh', '--verbose'], {
+    const opencodePath = resolveOpenCodePath();
+    const proc = Bun.spawn([opencodePath, 'models', '--refresh', '--verbose'], {
       stdout: 'pipe',
       stderr: 'pipe',
     });
@@ -230,12 +232,21 @@ export async function discoverOpenCodeFreeModels(): Promise<{
   models: OpenCodeFreeModel[];
   error?: string;
 }> {
-  return discoverFreeModelsByProvider('opencode');
+  const result = await discoverModelsByProvider('opencode', true);
+  return { models: result.models as OpenCodeFreeModel[], error: result.error };
 }
 
 export async function discoverProviderFreeModels(providerID: string): Promise<{
   models: OpenCodeFreeModel[];
   error?: string;
 }> {
-  return discoverFreeModelsByProvider(providerID);
+  const result = await discoverModelsByProvider(providerID, true);
+  return { models: result.models as OpenCodeFreeModel[], error: result.error };
+}
+
+export async function discoverProviderModels(providerID: string): Promise<{
+  models: DiscoveredModel[];
+  error?: string;
+}> {
+  return discoverModelsByProvider(providerID, false);
 }

+ 14 - 0
src/cli/paths.ts

@@ -29,6 +29,20 @@ export function getLiteConfig(): string {
   return join(getConfigDir(), 'oh-my-opencode-slim.json');
 }
 
+export function getLiteConfigJsonc(): string {
+  return join(getConfigDir(), 'oh-my-opencode-slim.jsonc');
+}
+
+export function getExistingLiteConfigPath(): string {
+  const jsonPath = getLiteConfig();
+  if (existsSync(jsonPath)) return jsonPath;
+
+  const jsoncPath = getLiteConfigJsonc();
+  if (existsSync(jsoncPath)) return jsoncPath;
+
+  return jsonPath;
+}
+
 export function getExistingConfigPath(): string {
   const jsonPath = getConfigJson();
   if (existsSync(jsonPath)) return jsonPath;

+ 37 - 0
src/cli/precedence-resolver.test.ts

@@ -0,0 +1,37 @@
+/// <reference types="bun-types" />
+
+import { describe, expect, test } from 'bun:test';
+import { resolveAgentWithPrecedence } from './precedence-resolver';
+
+describe('precedence-resolver', () => {
+  test('resolves deterministic winner with provenance', () => {
+    const result = resolveAgentWithPrecedence({
+      agentName: 'oracle',
+      manualUserPlan: ['openai/gpt-5.3-codex', 'openai/gpt-5.1-codex-mini'],
+      dynamicRecommendation: ['anthropic/claude-opus-4-6'],
+      providerFallbackPolicy: ['chutes/kimi-k2.5'],
+      systemDefault: ['opencode/big-pickle'],
+    });
+
+    expect(result.model).toBe('openai/gpt-5.3-codex');
+    expect(result.provenance.winnerLayer).toBe('manual-user-plan');
+    expect(result.chain).toEqual([
+      'openai/gpt-5.3-codex',
+      'openai/gpt-5.1-codex-mini',
+      'anthropic/claude-opus-4-6',
+      'chutes/kimi-k2.5',
+      'opencode/big-pickle',
+    ]);
+  });
+
+  test('uses system default when no other layer is provided', () => {
+    const result = resolveAgentWithPrecedence({
+      agentName: 'explorer',
+      systemDefault: ['opencode/gpt-5-nano'],
+    });
+
+    expect(result.model).toBe('opencode/gpt-5-nano');
+    expect(result.provenance.winnerLayer).toBe('system-default');
+    expect(result.chain).toEqual(['opencode/gpt-5-nano']);
+  });
+});

+ 93 - 0
src/cli/precedence-resolver.ts

@@ -0,0 +1,93 @@
+import type { AgentResolutionProvenance, ResolutionLayerName } from './types';
+
+export interface AgentLayerInput {
+  agentName: string;
+  openCodeDirectOverride?: string;
+  manualUserPlan?: string[];
+  pinnedModel?: string;
+  dynamicRecommendation?: string[];
+  providerFallbackPolicy?: string[];
+  systemDefault: string[];
+}
+
+export interface ResolvedAgentLayerResult {
+  model: string;
+  chain: string[];
+  provenance: AgentResolutionProvenance;
+}
+
+type LayerCandidate = {
+  layer: ResolutionLayerName;
+  models: string[];
+};
+
+function dedupe(models: Array<string | undefined>): string[] {
+  const seen = new Set<string>();
+  const result: string[] = [];
+  for (const model of models) {
+    if (!model || seen.has(model)) continue;
+    seen.add(model);
+    result.push(model);
+  }
+  return result;
+}
+
+function buildLayerOrder(input: AgentLayerInput): LayerCandidate[] {
+  return [
+    {
+      layer: 'opencode-direct-override',
+      models: input.openCodeDirectOverride
+        ? [input.openCodeDirectOverride]
+        : [],
+    },
+    {
+      layer: 'manual-user-plan',
+      models: input.manualUserPlan ?? [],
+    },
+    {
+      layer: 'pinned-model',
+      models: input.pinnedModel ? [input.pinnedModel] : [],
+    },
+    {
+      layer: 'dynamic-recommendation',
+      models: input.dynamicRecommendation ?? [],
+    },
+    {
+      layer: 'provider-fallback-policy',
+      models: input.providerFallbackPolicy ?? [],
+    },
+    {
+      layer: 'system-default',
+      models: input.systemDefault,
+    },
+  ];
+}
+
+export function resolveAgentWithPrecedence(
+  input: AgentLayerInput,
+): ResolvedAgentLayerResult {
+  const ordered = buildLayerOrder(input);
+  const firstWinningIndex = ordered.findIndex(
+    (layer) => layer.models.length > 0,
+  );
+  const winnerIndex =
+    firstWinningIndex >= 0 ? firstWinningIndex : ordered.length - 1;
+  const winnerLayer = ordered[winnerIndex];
+
+  const chain = dedupe(
+    ordered
+      .slice(winnerIndex)
+      .flatMap((layer) => layer.models)
+      .concat(input.systemDefault),
+  );
+  const model = chain[0] ?? input.systemDefault[0] ?? 'opencode/big-pickle';
+
+  return {
+    model,
+    chain,
+    provenance: {
+      winnerLayer: winnerLayer?.layer ?? 'system-default',
+      winnerModel: model,
+    },
+  };
+}

+ 60 - 0
src/cli/providers.ts

@@ -218,8 +218,65 @@ export function generateLiteConfig(
   const config: Record<string, unknown> = {
     preset: 'zen-free',
     presets: {},
+    balanceProviderUsage: installConfig.balanceProviderUsage ?? false,
   };
 
+  // Handle manual configuration mode
+  if (
+    installConfig.setupMode === 'manual' &&
+    installConfig.manualAgentConfigs
+  ) {
+    config.preset = 'manual';
+    const manualPreset: Record<string, unknown> = {};
+    const chains: Record<string, string[]> = {};
+
+    for (const agentName of AGENT_NAMES) {
+      const manualConfig = installConfig.manualAgentConfigs[agentName];
+      if (manualConfig) {
+        manualPreset[agentName] = {
+          model: manualConfig.primary,
+          skills:
+            agentName === 'orchestrator'
+              ? ['*']
+              : RECOMMENDED_SKILLS.filter(
+                  (s) =>
+                    s.allowedAgents.includes('*') ||
+                    s.allowedAgents.includes(agentName),
+                ).map((s) => s.skillName),
+          mcps:
+            DEFAULT_AGENT_MCPS[agentName as keyof typeof DEFAULT_AGENT_MCPS] ??
+            [],
+        };
+
+        // Build fallback chain from manual config
+        const fallbackChain = [
+          manualConfig.primary,
+          manualConfig.fallback1,
+          manualConfig.fallback2,
+          manualConfig.fallback3,
+        ].filter((m, i, arr) => m && arr.indexOf(m) === i); // dedupe
+        chains[agentName] = fallbackChain;
+      }
+    }
+
+    (config.presets as Record<string, unknown>).manual = manualPreset;
+    config.fallback = {
+      enabled: true,
+      timeoutMs: 15000,
+      chains,
+    };
+
+    if (installConfig.hasTmux) {
+      config.tmux = {
+        enabled: true,
+        layout: 'main-vertical',
+        main_pane_size: 60,
+      };
+    }
+
+    return config;
+  }
+
   // Determine active preset name
   let activePreset:
     | 'kimi'
@@ -357,6 +414,9 @@ export function generateLiteConfig(
     const hasExternalProviders =
       installConfig.hasKimi ||
       installConfig.hasOpenAI ||
+      installConfig.hasAnthropic ||
+      installConfig.hasCopilot ||
+      installConfig.hasZaiPlan ||
       installConfig.hasAntigravity;
 
     if (hasExternalProviders && activePreset !== 'chutes') return;

+ 155 - 0
src/cli/scoring-v2/engine.test.ts

@@ -0,0 +1,155 @@
+/// <reference types="bun-types" />
+
+import { describe, expect, test } from 'bun:test';
+import type { DiscoveredModel, ExternalSignalMap } from '../types';
+import { rankModelsV2, scoreCandidateV2 } from './engine';
+
+function model(
+  input: Partial<DiscoveredModel> & { model: string },
+): DiscoveredModel {
+  const [providerID] = input.model.split('/');
+  return {
+    providerID: providerID ?? 'openai',
+    model: input.model,
+    name: input.name ?? input.model,
+    status: input.status ?? 'active',
+    contextLimit: input.contextLimit ?? 200000,
+    outputLimit: input.outputLimit ?? 32000,
+    reasoning: input.reasoning ?? true,
+    toolcall: input.toolcall ?? true,
+    attachment: input.attachment ?? false,
+    dailyRequestLimit: input.dailyRequestLimit,
+    costInput: input.costInput,
+    costOutput: input.costOutput,
+  };
+}
+
+describe('scoring-v2', () => {
+  test('returns explain breakdown with deterministic total', () => {
+    const candidate = model({ model: 'openai/gpt-5.3-codex' });
+    const signalMap: ExternalSignalMap = {
+      'openai/gpt-5.3-codex': {
+        source: 'artificial-analysis',
+        qualityScore: 70,
+        codingScore: 75,
+        latencySeconds: 1.2,
+        inputPricePer1M: 1,
+        outputPricePer1M: 3,
+      },
+    };
+
+    const first = scoreCandidateV2(candidate, 'oracle', signalMap);
+    const second = scoreCandidateV2(candidate, 'oracle', signalMap);
+
+    expect(first.totalScore).toBe(second.totalScore);
+    expect(first.scoreBreakdown.features.quality).toBe(0.7);
+    expect(first.scoreBreakdown.weighted.coding).toBeGreaterThan(0);
+  });
+
+  test('uses stable tie-break when scores are equal', () => {
+    const ranked = rankModelsV2(
+      [
+        model({ model: 'zai-coding-plan/glm-4.7', reasoning: false }),
+        model({ model: 'openai/gpt-5.3-codex', reasoning: false }),
+      ],
+      'explorer',
+    );
+
+    expect(ranked[0]?.model.providerID).toBe('openai');
+    expect(ranked[1]?.model.providerID).toBe('zai-coding-plan');
+  });
+
+  test('matches external signals for multi-segment chutes ids', () => {
+    const candidate = model({
+      model: 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
+    });
+    const signalMap: ExternalSignalMap = {
+      'qwen/qwen3-coder-480b-a35b-instruct': {
+        source: 'artificial-analysis',
+        qualityScore: 95,
+        codingScore: 92,
+      },
+    };
+
+    const scored = scoreCandidateV2(candidate, 'fixer', signalMap);
+    expect(scored.scoreBreakdown.features.quality).toBe(0.95);
+    expect(scored.scoreBreakdown.features.coding).toBe(0.92);
+  });
+
+  test('applies designer output threshold rule', () => {
+    const belowThreshold = model({
+      model: 'chutes/moonshotai/Kimi-K2.5-TEE',
+      outputLimit: 63999,
+    });
+    const aboveThreshold = model({
+      model: 'zai-coding-plan/glm-4.7',
+      outputLimit: 64000,
+    });
+
+    const low = scoreCandidateV2(belowThreshold, 'designer');
+    const high = scoreCandidateV2(aboveThreshold, 'designer');
+
+    expect(low.scoreBreakdown.features.output).toBe(-1);
+    expect(low.scoreBreakdown.weighted.output).toBe(-10);
+    expect(high.scoreBreakdown.features.output).toBe(0);
+    expect(high.scoreBreakdown.weighted.output).toBe(0);
+  });
+
+  test('prefers kimi k2.5 over kimi k2 when otherwise equal', () => {
+    const ranked = rankModelsV2(
+      [
+        model({
+          model: 'chutes/moonshotai/Kimi-K2-TEE',
+          contextLimit: 262144,
+          outputLimit: 65535,
+          reasoning: true,
+          toolcall: true,
+          attachment: false,
+        }),
+        model({
+          model: 'chutes/moonshotai/Kimi-K2.5-TEE',
+          contextLimit: 262144,
+          outputLimit: 65535,
+          reasoning: true,
+          toolcall: true,
+          attachment: false,
+        }),
+      ],
+      'designer',
+    );
+
+    expect(ranked[0]?.model.model).toBe('chutes/moonshotai/Kimi-K2.5-TEE');
+    expect(ranked[1]?.model.model).toBe('chutes/moonshotai/Kimi-K2-TEE');
+  });
+
+  test('downranks chutes qwen3 against kimi/minimax priors', () => {
+    const ranked = rankModelsV2(
+      [
+        model({
+          model: 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
+          contextLimit: 262144,
+          outputLimit: 262144,
+          reasoning: true,
+          toolcall: true,
+        }),
+        model({
+          model: 'chutes/moonshotai/Kimi-K2.5-TEE',
+          contextLimit: 262144,
+          outputLimit: 65535,
+          reasoning: true,
+          toolcall: true,
+        }),
+        model({
+          model: 'chutes/minimax-m2.1',
+          contextLimit: 500000,
+          outputLimit: 64000,
+          reasoning: true,
+          toolcall: true,
+        }),
+      ],
+      'fixer',
+    );
+
+    expect(ranked[0]?.model.model).not.toContain('Qwen3-Coder-480B');
+  });
+});

+ 91 - 0
src/cli/scoring-v2/engine.ts

@@ -0,0 +1,91 @@
+import type { DiscoveredModel, ExternalSignalMap } from '../types';
+import { extractFeatureVector } from './features';
+import type {
+  FeatureVector,
+  FeatureWeights,
+  ScoredCandidate,
+  ScoringAgentName,
+} from './types';
+import { getFeatureWeights } from './weights';
+
+function weightedFeatures(
+  features: FeatureVector,
+  weights: FeatureWeights,
+): FeatureVector {
+  return {
+    status: features.status * weights.status,
+    context: features.context * weights.context,
+    output: features.output * weights.output,
+    versionBonus: features.versionBonus * weights.versionBonus,
+    reasoning: features.reasoning * weights.reasoning,
+    toolcall: features.toolcall * weights.toolcall,
+    attachment: features.attachment * weights.attachment,
+    quality: features.quality * weights.quality,
+    coding: features.coding * weights.coding,
+    latencyPenalty: features.latencyPenalty * weights.latencyPenalty,
+    pricePenalty: features.pricePenalty * weights.pricePenalty,
+  };
+}
+
+function sumFeatures(features: FeatureVector): number {
+  return (
+    features.status +
+    features.context +
+    features.output +
+    features.versionBonus +
+    features.reasoning +
+    features.toolcall +
+    features.attachment +
+    features.quality +
+    features.coding +
+    features.latencyPenalty +
+    features.pricePenalty
+  );
+}
+
+function withStableTieBreak(
+  left: ScoredCandidate,
+  right: ScoredCandidate,
+): number {
+  if (left.totalScore !== right.totalScore) {
+    return right.totalScore - left.totalScore;
+  }
+
+  const providerDelta = left.model.providerID.localeCompare(
+    right.model.providerID,
+  );
+  if (providerDelta !== 0) {
+    return providerDelta;
+  }
+
+  return left.model.model.localeCompare(right.model.model);
+}
+
+export function scoreCandidateV2(
+  model: DiscoveredModel,
+  agent: ScoringAgentName,
+  externalSignals?: ExternalSignalMap,
+): ScoredCandidate {
+  const features = extractFeatureVector(model, agent, externalSignals);
+  const weights = getFeatureWeights(agent);
+  const weighted = weightedFeatures(features, weights);
+
+  return {
+    model,
+    totalScore: Math.round(sumFeatures(weighted) * 1000) / 1000,
+    scoreBreakdown: {
+      features,
+      weighted,
+    },
+  };
+}
+
+export function rankModelsV2(
+  models: DiscoveredModel[],
+  agent: ScoringAgentName,
+  externalSignals?: ExternalSignalMap,
+): ScoredCandidate[] {
+  return models
+    .map((model) => scoreCandidateV2(model, agent, externalSignals))
+    .sort(withStableTieBreak);
+}

+ 116 - 0
src/cli/scoring-v2/features.ts

@@ -0,0 +1,116 @@
+import { buildModelKeyAliases } from '../model-key-normalization';
+import type {
+  DiscoveredModel,
+  ExternalModelSignal,
+  ExternalSignalMap,
+} from '../types';
+import type { FeatureVector, ScoringAgentName } from './types';
+
+function modelLookupKeys(model: DiscoveredModel): string[] {
+  return buildModelKeyAliases(model.model);
+}
+
+function findSignal(
+  model: DiscoveredModel,
+  externalSignals?: ExternalSignalMap,
+): ExternalModelSignal | undefined {
+  if (!externalSignals) return undefined;
+  return modelLookupKeys(model)
+    .map((key) => externalSignals[key])
+    .find((item) => item !== undefined);
+}
+
+function statusValue(status: DiscoveredModel['status']): number {
+  if (status === 'active') return 1;
+  if (status === 'beta') return 0.4;
+  if (status === 'alpha') return -0.25;
+  return -1;
+}
+
+function capability(value: boolean): number {
+  return value ? 1 : 0;
+}
+
+function blendedPrice(signal: ExternalModelSignal | undefined): number {
+  if (!signal) return 0;
+  if (
+    signal.inputPricePer1M !== undefined &&
+    signal.outputPricePer1M !== undefined
+  ) {
+    return signal.inputPricePer1M * 0.75 + signal.outputPricePer1M * 0.25;
+  }
+  return signal.inputPricePer1M ?? signal.outputPricePer1M ?? 0;
+}
+
+function kimiVersionBonus(
+  agent: ScoringAgentName,
+  model: DiscoveredModel,
+): number {
+  const lowered = `${model.model} ${model.name}`.toLowerCase();
+  const isChutes = model.providerID === 'chutes';
+  const isQwen3 = isChutes && /qwen3/.test(lowered);
+  const isKimiK25 = /kimi-k2\.5|k2\.5/.test(lowered);
+  const isMinimaxM21 = isChutes && /minimax[-_ ]?m2\.1/.test(lowered);
+
+  const qwenPenalty: Record<ScoringAgentName, number> = {
+    orchestrator: -6,
+    oracle: -6,
+    designer: -8,
+    explorer: -6,
+    librarian: -12,
+    fixer: -12,
+  };
+  const kimiBonus: Record<ScoringAgentName, number> = {
+    orchestrator: 1,
+    oracle: 1,
+    designer: 3,
+    explorer: 2,
+    librarian: 2,
+    fixer: 3,
+  };
+  const minimaxBonus: Record<ScoringAgentName, number> = {
+    orchestrator: 1,
+    oracle: 1,
+    designer: 2,
+    explorer: 4,
+    librarian: 4,
+    fixer: 4,
+  };
+
+  if (isQwen3) return qwenPenalty[agent];
+  if (isKimiK25) return kimiBonus[agent];
+  if (isMinimaxM21) return minimaxBonus[agent];
+  return 0;
+}
+
+export function extractFeatureVector(
+  model: DiscoveredModel,
+  agent: ScoringAgentName,
+  externalSignals?: ExternalSignalMap,
+): FeatureVector {
+  const signal = findSignal(model, externalSignals);
+  const latency = signal?.latencySeconds ?? 0;
+  const normalizedContext = Math.min(model.contextLimit, 1_000_000) / 100_000;
+  const normalizedOutput = Math.min(model.outputLimit, 300_000) / 30_000;
+  const designerOutputScore = model.outputLimit < 64_000 ? -1 : 0;
+  const versionBonus = kimiVersionBonus(agent, model);
+  const quality = (signal?.qualityScore ?? 0) / 100;
+  const coding = (signal?.codingScore ?? 0) / 100;
+  const pricePenalty = Math.min(blendedPrice(signal), 50) / 10;
+
+  const explorerLatencyMultiplier = agent === 'explorer' ? 1.4 : 1;
+
+  return {
+    status: statusValue(model.status),
+    context: normalizedContext,
+    output: agent === 'designer' ? designerOutputScore : normalizedOutput,
+    versionBonus,
+    reasoning: capability(model.reasoning),
+    toolcall: capability(model.toolcall),
+    attachment: capability(model.attachment),
+    quality,
+    coding,
+    latencyPenalty: Math.min(latency, 20) * explorerLatencyMultiplier,
+    pricePenalty,
+  };
+}

+ 8 - 0
src/cli/scoring-v2/index.ts

@@ -0,0 +1,8 @@
+export { rankModelsV2, scoreCandidateV2 } from './engine';
+export { extractFeatureVector } from './features';
+export type {
+  FeatureVector,
+  ScoredCandidate,
+  ScoringAgentName,
+} from './types';
+export { getFeatureWeights } from './weights';

+ 40 - 0
src/cli/scoring-v2/types.ts

@@ -0,0 +1,40 @@
+import type { DiscoveredModel, ExternalSignalMap } from '../types';
+
+export type ScoreFeatureName =
+  | 'status'
+  | 'context'
+  | 'output'
+  | 'versionBonus'
+  | 'reasoning'
+  | 'toolcall'
+  | 'attachment'
+  | 'quality'
+  | 'coding'
+  | 'latencyPenalty'
+  | 'pricePenalty';
+
+export type FeatureVector = Record<ScoreFeatureName, number>;
+
+export type FeatureWeights = Record<ScoreFeatureName, number>;
+
+export type ScoringAgentName =
+  | 'orchestrator'
+  | 'oracle'
+  | 'designer'
+  | 'explorer'
+  | 'librarian'
+  | 'fixer';
+
+export interface ScoringContext {
+  agent: ScoringAgentName;
+  externalSignals?: ExternalSignalMap;
+}
+
+export interface ScoredCandidate {
+  model: DiscoveredModel;
+  totalScore: number;
+  scoreBreakdown: {
+    features: FeatureVector;
+    weighted: FeatureVector;
+  };
+}

+ 67 - 0
src/cli/scoring-v2/weights.ts

@@ -0,0 +1,67 @@
+import type { FeatureWeights, ScoringAgentName } from './types';
+
+const BASE_WEIGHTS: FeatureWeights = {
+  status: 22,
+  context: 6,
+  output: 6,
+  versionBonus: 8,
+  reasoning: 10,
+  toolcall: 16,
+  attachment: 2,
+  quality: 14,
+  coding: 18,
+  latencyPenalty: -3,
+  pricePenalty: -2,
+};
+
+const AGENT_WEIGHT_OVERRIDES: Record<
+  ScoringAgentName,
+  Partial<FeatureWeights>
+> = {
+  orchestrator: {
+    reasoning: 22,
+    toolcall: 22,
+    quality: 16,
+    coding: 16,
+    latencyPenalty: -2,
+  },
+  oracle: {
+    reasoning: 26,
+    quality: 20,
+    coding: 18,
+    latencyPenalty: -2,
+    output: 7,
+  },
+  designer: {
+    attachment: 12,
+    output: 10,
+    quality: 16,
+    coding: 10,
+  },
+  explorer: {
+    latencyPenalty: -8,
+    toolcall: 24,
+    reasoning: 2,
+    context: 4,
+    output: 4,
+  },
+  librarian: {
+    context: 14,
+    output: 10,
+    quality: 18,
+    coding: 14,
+  },
+  fixer: {
+    coding: 28,
+    toolcall: 22,
+    reasoning: 12,
+    output: 10,
+  },
+};
+
+export function getFeatureWeights(agent: ScoringAgentName): FeatureWeights {
+  return {
+    ...BASE_WEIGHTS,
+    ...AGENT_WEIGHT_OVERRIDES[agent],
+  };
+}

+ 103 - 12
src/cli/system.ts

@@ -1,14 +1,96 @@
+import { statSync } from 'node:fs';
+
+let cachedOpenCodePath: string | null = null;
+
+function getOpenCodePaths(): string[] {
+  const home = process.env.HOME || process.env.USERPROFILE || '';
+
+  return [
+    // PATH (try this first)
+    'opencode',
+    // User local installations (Linux & macOS)
+    `${home}/.local/bin/opencode`,
+    `${home}/.opencode/bin/opencode`,
+    `${home}/bin/opencode`,
+    // System-wide installations
+    '/usr/local/bin/opencode',
+    '/opt/opencode/bin/opencode',
+    '/usr/bin/opencode',
+    '/bin/opencode',
+    // macOS specific
+    '/Applications/OpenCode.app/Contents/MacOS/opencode',
+    `${home}/Applications/OpenCode.app/Contents/MacOS/opencode`,
+    // Homebrew (macOS & Linux)
+    '/opt/homebrew/bin/opencode',
+    '/home/linuxbrew/.linuxbrew/bin/opencode',
+    `${home}/homebrew/bin/opencode`,
+    // macOS user Library
+    `${home}/Library/Application Support/opencode/bin/opencode`,
+    // Snap (Linux)
+    '/snap/bin/opencode',
+    '/var/snap/opencode/current/bin/opencode',
+    // Flatpak (Linux)
+    '/var/lib/flatpak/exports/bin/ai.opencode.OpenCode',
+    `${home}/.local/share/flatpak/exports/bin/ai.opencode.OpenCode`,
+    // Nix (Linux/macOS)
+    '/nix/store/opencode/bin/opencode',
+    `${home}/.nix-profile/bin/opencode`,
+    '/run/current-system/sw/bin/opencode',
+    // Cargo (Rust toolchain)
+    `${home}/.cargo/bin/opencode`,
+    // npm/npx global
+    `${home}/.npm-global/bin/opencode`,
+    '/usr/local/lib/node_modules/opencode/bin/opencode',
+    // Yarn global
+    `${home}/.yarn/bin/opencode`,
+    // PNPM
+    `${home}/.pnpm-global/bin/opencode`,
+  ];
+}
+
+export function resolveOpenCodePath(): string {
+  if (cachedOpenCodePath) {
+    return cachedOpenCodePath;
+  }
+
+  const paths = getOpenCodePaths();
+
+  for (const opencodePath of paths) {
+    if (opencodePath === 'opencode') continue;
+    try {
+      const stat = statSync(opencodePath);
+      if (stat.isFile()) {
+        cachedOpenCodePath = opencodePath;
+        return opencodePath;
+      }
+    } catch {
+      // Try next path
+    }
+  }
+
+  // Fallback to 'opencode' and hope it's in PATH
+  return 'opencode';
+}
+
 export async function isOpenCodeInstalled(): Promise<boolean> {
-  try {
-    const proc = Bun.spawn(['opencode', '--version'], {
-      stdout: 'pipe',
-      stderr: 'pipe',
-    });
-    await proc.exited;
-    return proc.exitCode === 0;
-  } catch {
-    return false;
+  const paths = getOpenCodePaths();
+
+  for (const opencodePath of paths) {
+    try {
+      const proc = Bun.spawn([opencodePath, '--version'], {
+        stdout: 'pipe',
+        stderr: 'pipe',
+      });
+      await proc.exited;
+      if (proc.exitCode === 0) {
+        cachedOpenCodePath = opencodePath;
+        return true;
+      }
+    } catch {
+      // Try next path
+    }
   }
+  return false;
 }
 
 export async function isTmuxInstalled(): Promise<boolean> {
@@ -25,17 +107,26 @@ export async function isTmuxInstalled(): Promise<boolean> {
 }
 
 export async function getOpenCodeVersion(): Promise<string | null> {
+  const opencodePath = resolveOpenCodePath();
   try {
-    const proc = Bun.spawn(['opencode', '--version'], {
+    const proc = Bun.spawn([opencodePath, '--version'], {
       stdout: 'pipe',
       stderr: 'pipe',
     });
     const output = await new Response(proc.stdout).text();
     await proc.exited;
-    return proc.exitCode === 0 ? output.trim() : null;
+    if (proc.exitCode === 0) {
+      return output.trim();
+    }
   } catch {
-    return null;
+    // Failed
   }
+  return null;
+}
+
+export function getOpenCodePath(): string | null {
+  const path = resolveOpenCodePath();
+  return path === 'opencode' ? null : path;
 }
 
 export async function fetchLatestVersion(

+ 55 - 1
src/cli/types.ts

@@ -12,7 +12,12 @@ export interface InstallArgs {
   tmux?: BooleanArg;
   skills?: BooleanArg;
   opencodeFree?: BooleanArg;
+  balancedSpend?: BooleanArg;
   opencodeFreeModel?: string;
+  aaKey?: string;
+  openrouterKey?: string;
+  dryRun?: boolean;
+  modelsOnly?: boolean;
 }
 
 export interface OpenCodeFreeModel {
@@ -48,11 +53,52 @@ export interface DynamicAgentAssignment {
   variant?: string;
 }
 
+export type ScoringEngineVersion = 'v1' | 'v2-shadow' | 'v2';
+
+export type ResolutionLayerName =
+  | 'opencode-direct-override'
+  | 'manual-user-plan'
+  | 'pinned-model'
+  | 'dynamic-recommendation'
+  | 'provider-fallback-policy'
+  | 'system-default';
+
+export interface AgentResolutionProvenance {
+  winnerLayer: ResolutionLayerName;
+  winnerModel: string;
+}
+
+export interface DynamicPlanScoringMeta {
+  engineVersionApplied: 'v1' | 'v2';
+  shadowCompared: boolean;
+  diffs?: Record<string, { v1TopModel?: string; v2TopModel?: string }>;
+}
+
 export interface DynamicModelPlan {
   agents: Record<string, DynamicAgentAssignment>;
   chains: Record<string, string[]>;
+  provenance?: Record<string, AgentResolutionProvenance>;
+  scoring?: DynamicPlanScoringMeta;
+}
+
+export interface ExternalModelSignal {
+  qualityScore?: number;
+  codingScore?: number;
+  latencySeconds?: number;
+  inputPricePer1M?: number;
+  outputPricePer1M?: number;
+  source: 'artificial-analysis' | 'openrouter' | 'merged';
 }
 
+export type ExternalSignalMap = Record<string, ExternalModelSignal>;
+
+export type ManualAgentConfig = {
+  primary: string;
+  fallback1: string;
+  fallback2: string;
+  fallback3: string;
+};
+
 export interface OpenCodeConfig {
   plugin?: string[];
   provider?: Record<string, unknown>;
@@ -76,11 +122,19 @@ export interface InstallConfig {
   availableOpenCodeFreeModels?: OpenCodeFreeModel[];
   selectedChutesPrimaryModel?: string;
   selectedChutesSecondaryModel?: string;
-  availableChutesFreeModels?: OpenCodeFreeModel[];
+  availableChutesModels?: DiscoveredModel[];
   dynamicModelPlan?: DynamicModelPlan;
+  scoringEngineVersion?: ScoringEngineVersion;
+  artificialAnalysisApiKey?: string;
+  openRouterApiKey?: string;
+  balanceProviderUsage?: boolean;
   hasTmux: boolean;
   installSkills: boolean;
   installCustomSkills: boolean;
+  setupMode: 'quick' | 'manual';
+  manualAgentConfigs?: Record<string, ManualAgentConfig>;
+  dryRun?: boolean;
+  modelsOnly?: boolean;
 }
 
 export interface ConfigMergeResult {

+ 87 - 2
src/config/loader.test.ts

@@ -49,6 +49,90 @@ describe('loadPluginConfig', () => {
     expect(config.agents?.oracle?.model).toBe('test/model');
   });
 
+  test('loads scoringEngineVersion flag when configured', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        scoringEngineVersion: 'v2-shadow',
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.scoringEngineVersion).toBe('v2-shadow');
+  });
+
+  test('loads balanceProviderUsage flag when configured', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        balanceProviderUsage: true,
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.balanceProviderUsage).toBe(true);
+  });
+
+  test('loads manual plan structure when configured', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        manualPlan: {
+          orchestrator: {
+            primary: 'openai/gpt-5.3-codex',
+            fallback1: 'anthropic/claude-opus-4-6',
+            fallback2: 'chutes/kimi-k2.5',
+            fallback3: 'opencode/gpt-5-nano',
+          },
+          oracle: {
+            primary: 'openai/gpt-5.3-codex',
+            fallback1: 'anthropic/claude-opus-4-6',
+            fallback2: 'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
+            fallback3: 'opencode/gpt-5-nano',
+          },
+          designer: {
+            primary: 'openai/gpt-5.3-codex',
+            fallback1: 'anthropic/claude-opus-4-6',
+            fallback2: 'chutes/kimi-k2.5',
+            fallback3: 'opencode/gpt-5-nano',
+          },
+          explorer: {
+            primary: 'openai/gpt-5.3-codex',
+            fallback1: 'anthropic/claude-opus-4-6',
+            fallback2: 'chutes/kimi-k2.5',
+            fallback3: 'opencode/gpt-5-nano',
+          },
+          librarian: {
+            primary: 'openai/gpt-5.3-codex',
+            fallback1: 'anthropic/claude-opus-4-6',
+            fallback2: 'chutes/kimi-k2.5',
+            fallback3: 'opencode/gpt-5-nano',
+          },
+          fixer: {
+            primary: 'openai/gpt-5.3-codex',
+            fallback1: 'anthropic/claude-opus-4-6',
+            fallback2: 'chutes/kimi-k2.5',
+            fallback3: 'opencode/gpt-5-nano',
+          },
+        },
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.manualPlan?.oracle?.fallback2).toBe(
+      'chutes/Qwen/Qwen3-Coder-480B-A35B-Instruct-FP8-TEE',
+    );
+  });
+
   test('ignores invalid config (schema violation or malformed JSON)', () => {
     const projectDir = path.join(tempDir, 'project');
     const projectConfigDir = path.join(projectDir, '.opencode');
@@ -297,7 +381,7 @@ describe('deepMerge behavior', () => {
     ]);
   });
 
-  test('rejects fallback chains with unsupported agent keys', () => {
+  test('preserves fallback chains with additional agent keys', () => {
     const projectDir = path.join(tempDir, 'project');
     const projectConfigDir = path.join(projectDir, '.opencode');
     fs.mkdirSync(projectConfigDir, { recursive: true });
@@ -312,7 +396,8 @@ describe('deepMerge behavior', () => {
       }),
     );
 
-    expect(loadPluginConfig(projectDir)).toEqual({});
+    const config = loadPluginConfig(projectDir);
+    expect(config.fallback?.chains.writing).toEqual(['openai/gpt-5.2-codex']);
   });
 });
 

+ 57 - 1
src/config/schema.ts

@@ -9,6 +9,59 @@ const FALLBACK_AGENT_NAMES = [
   'fixer',
 ] as const;
 
+const MANUAL_AGENT_NAMES = [
+  'orchestrator',
+  'oracle',
+  'designer',
+  'explorer',
+  'librarian',
+  'fixer',
+] as const;
+
+const ProviderModelIdSchema = z
+  .string()
+  .regex(
+    /^[^/\s]+\/[^\s]+$/,
+    'Expected provider/model format (provider/.../model)',
+  );
+
+export const ManualAgentPlanSchema = z
+  .object({
+    primary: ProviderModelIdSchema,
+    fallback1: ProviderModelIdSchema,
+    fallback2: ProviderModelIdSchema,
+    fallback3: ProviderModelIdSchema,
+  })
+  .superRefine((value, ctx) => {
+    const unique = new Set([
+      value.primary,
+      value.fallback1,
+      value.fallback2,
+      value.fallback3,
+    ]);
+    if (unique.size !== 4) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        message: 'primary and fallbacks must be unique per agent',
+      });
+    }
+  });
+
+export const ManualPlanSchema = z
+  .object({
+    orchestrator: ManualAgentPlanSchema,
+    oracle: ManualAgentPlanSchema,
+    designer: ManualAgentPlanSchema,
+    explorer: ManualAgentPlanSchema,
+    librarian: ManualAgentPlanSchema,
+    fixer: ManualAgentPlanSchema,
+  })
+  .strict();
+
+export type ManualAgentName = (typeof MANUAL_AGENT_NAMES)[number];
+export type ManualAgentPlan = z.infer<typeof ManualAgentPlanSchema>;
+export type ManualPlan = z.infer<typeof ManualPlanSchema>;
+
 const AgentModelChainSchema = z.array(z.string()).min(1);
 
 const FallbackChainsSchema = z
@@ -20,7 +73,7 @@ const FallbackChainsSchema = z
     librarian: AgentModelChainSchema.optional(),
     fixer: AgentModelChainSchema.optional(),
   })
-  .strict();
+  .catchall(AgentModelChainSchema);
 
 export type FallbackAgentName = (typeof FALLBACK_AGENT_NAMES)[number];
 
@@ -81,6 +134,9 @@ export type FailoverConfig = z.infer<typeof FailoverConfigSchema>;
 // Main plugin config
 export const PluginConfigSchema = z.object({
   preset: z.string().optional(),
+  scoringEngineVersion: z.enum(['v1', 'v2-shadow', 'v2']).optional(),
+  balanceProviderUsage: z.boolean().optional(),
+  manualPlan: ManualPlanSchema.optional(),
   presets: z.record(z.string(), PresetSchema).optional(),
   agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
   disabled_mcps: z.array(z.string()).optional(),

+ 0 - 1
src/tools/index.ts

@@ -1,7 +1,6 @@
 // AST-grep tools
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createBackgroundTools } from './background';
-
 // Grep tool (ripgrep-based)
 export { grep } from './grep';
 export {

+ 12 - 0
src/utils/env.ts

@@ -0,0 +1,12 @@
+export function getEnv(name: string): string | undefined {
+  const bunValue = (globalThis as { Bun?: { env?: Record<string, string> } })
+    .Bun?.env?.[name];
+  if (typeof bunValue === 'string' && bunValue.length > 0) return bunValue;
+
+  const processValue = (
+    globalThis as { process?: { env?: Record<string, string | undefined> } }
+  ).process?.env?.[name];
+  return typeof processValue === 'string' && processValue.length > 0
+    ? processValue
+    : undefined;
+}

+ 1 - 0
src/utils/index.ts

@@ -1,4 +1,5 @@
 export * from './agent-variant';
+export * from './env';
 export { log } from './logger';
 export * from './polling';
 export * from './tmux';

Some files were not shown because too many files changed in this diff