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 2 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');
   });
 });

File diff suppressed because it is too large
+ 1171 - 45
src/cli/dynamic-model-selection.ts


+ 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
+ 766 - 119
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';