Browse Source

feat(fallback): support preset-scoped fallback chain keys

Allow fallback chains to be scoped per preset using the
preset:agentName key format, so different presets can have
different fallback model chains for the same agent.
Knowingthesea_Qesire 2 months ago
parent
commit
b4bdf1fb44
3 changed files with 137 additions and 6 deletions
  1. 34 0
      src/config/fallback-chains.ts
  2. 88 1
      src/config/model-resolution.test.ts
  3. 15 5
      src/index.ts

+ 34 - 0
src/config/fallback-chains.ts

@@ -0,0 +1,34 @@
+export function normalizeFallbackChainsForPreset(
+  chains: Record<string, string[] | undefined>,
+  presetName: string | null | undefined,
+): Record<string, string[]> {
+  const normalized: Record<string, string[]> = {};
+
+  for (const [rawKey, chainModels] of Object.entries(chains)) {
+    if (!chainModels?.length) continue;
+
+    const separatorIndex = rawKey.indexOf(':');
+    const hasPresetScope = separatorIndex !== -1;
+    const scopedPreset = hasPresetScope ? rawKey.slice(0, separatorIndex) : '';
+    const agentName = hasPresetScope
+      ? rawKey.slice(separatorIndex + 1)
+      : rawKey;
+
+    if (!agentName) continue;
+    if (hasPresetScope && scopedPreset !== presetName) continue;
+
+    const existing = normalized[agentName] ?? [];
+    const seen = new Set(existing);
+    for (const chainModel of chainModels) {
+      if (seen.has(chainModel)) continue;
+      seen.add(chainModel);
+      existing.push(chainModel);
+    }
+
+    if (existing.length > 0) {
+      normalized[agentName] = existing;
+    }
+  }
+
+  return normalized;
+}

+ 88 - 1
src/config/model-resolution.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, test } from 'bun:test';
 import type { ModelEntry } from '../config/schema';
+import { normalizeFallbackChainsForPreset } from './fallback-chains';
 
 /**
  * Test the model array resolution logic that runs in the config hook.
@@ -79,12 +80,14 @@ describe('fallback.chains merging for foreground agents', () => {
     modelArray?: Array<{ id: string; variant?: string }>;
     currentModel?: string;
     chainModels?: string[];
+    preset?: string;
     fallbackEnabled?: boolean;
   }): string | null {
     const {
       modelArray,
       currentModel,
       chainModels,
+      preset,
       fallbackEnabled = true,
     } = opts;
 
@@ -94,11 +97,18 @@ describe('fallback.chains merging for foreground agents', () => {
       : [];
 
     if (fallbackEnabled && chainModels && chainModels.length > 0) {
+      const normalized = normalizeFallbackChainsForPreset(
+        {
+          [currentModel ?? 'orchestrator']: chainModels,
+        },
+        preset,
+      );
+      const normalizedModels = Object.values(normalized)[0] ?? [];
       if (effectiveArray.length === 0 && currentModel) {
         effectiveArray.push({ id: currentModel });
       }
       const seen = new Set(effectiveArray.map((m) => m.id));
-      for (const chainModel of chainModels) {
+      for (const chainModel of normalizedModels) {
         if (!seen.has(chainModel)) {
           seen.add(chainModel);
           effectiveArray.push({ id: chainModel });
@@ -173,4 +183,81 @@ describe('fallback.chains merging for foreground agents', () => {
     });
     expect(result).toBe('github-copilot/claude-opus-4.6');
   });
+
+  test('normalizes scoped fallback chains for the active preset', () => {
+    const result = normalizeFallbackChainsForPreset(
+      {
+        'gpt-plus-max:orchestrator': [
+          'openai/o3',
+          'anthropic/claude-sonnet-4-6',
+        ],
+        'gpt-plus-max:oracle': ['anthropic/claude-opus-4-5'],
+      },
+      'gpt-plus-max',
+    );
+
+    expect(result).toEqual({
+      orchestrator: ['openai/o3', 'anthropic/claude-sonnet-4-6'],
+      oracle: ['anthropic/claude-opus-4-5'],
+    });
+  });
+
+  test('ignores scoped fallback chains for other presets', () => {
+    const result = normalizeFallbackChainsForPreset(
+      {
+        'gpt-plus-max:orchestrator': ['openai/o3'],
+        orchestrator: ['anthropic/claude-sonnet-4-6'],
+      },
+      'other-preset',
+    );
+
+    expect(result).toEqual({
+      orchestrator: ['anthropic/claude-sonnet-4-6'],
+    });
+  });
+
+  test('does not emit scoped keys in normalized output', () => {
+    const result = normalizeFallbackChainsForPreset(
+      {
+        'gpt-plus-max:orchestrator': ['openai/o3'],
+      },
+      'gpt-plus-max',
+    );
+
+    expect(Object.keys(result).some((key) => key.includes(':'))).toBe(false);
+  });
+
+  test('ignores empty fallback chain agent names', () => {
+    const result = normalizeFallbackChainsForPreset(
+      {
+        '': ['openai/o3'],
+        'gpt-plus-max:': ['anthropic/claude-sonnet-4-6'],
+        orchestrator: ['ustc-deepseek/deepseek-v4-pro'],
+      },
+      'gpt-plus-max',
+    );
+
+    expect(result).toEqual({
+      orchestrator: ['ustc-deepseek/deepseek-v4-pro'],
+    });
+    expect(result).not.toHaveProperty('');
+  });
+
+  test('runtime preset takes precedence over config preset for scoped fallback chains', () => {
+    const configPreset = 'cheap';
+    const runtimePreset = 'powerful';
+    const activePreset = runtimePreset ?? configPreset ?? null;
+
+    const result = normalizeFallbackChainsForPreset(
+      {
+        'cheap:orchestrator': ['cheap/model'],
+        'powerful:orchestrator': ['powerful/model'],
+      },
+      activePreset,
+    );
+
+    expect(result).toEqual({
+      orchestrator: ['powerful/model'],
+    });
+  });
 });

+ 15 - 5
src/index.ts

@@ -9,6 +9,7 @@ import {
 } from './config';
 import { parseList } from './config/agent-mcps';
 import { AGENT_ALIASES } from './config/constants';
+import { normalizeFallbackChainsForPreset } from './config/fallback-chains';
 import {
   getActiveRuntimePreset,
   getPreviousRuntimePreset,
@@ -202,11 +203,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
       }
     }
+    const activePresetForFallback =
+      getActiveRuntimePreset() ?? config.preset ?? null;
+
     if (config.fallback?.enabled !== false) {
-      const chains =
-        (config.fallback?.chains as Record<string, string[] | undefined>) ?? {};
+      const chains = normalizeFallbackChainsForPreset(
+        (config.fallback?.chains as Record<string, string[] | undefined>) ?? {},
+        activePresetForFallback,
+      );
       for (const [agentName, chainModels] of Object.entries(chains)) {
-        if (!chainModels?.length) continue;
         const existing = runtimeChains[agentName] ?? [];
         const seen = new Set(existing);
         for (const m of chainModels) {
@@ -451,10 +456,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       // Runtime failover on API errors (e.g. rate limits
       // mid-conversation) is handled separately by
       // ForegroundFallbackManager via the event hook.
+      const activePresetForFallback =
+        getActiveRuntimePreset() ?? config.preset ?? null;
       const fallbackChainsEnabled = config.fallback?.enabled !== false;
       const fallbackChains = fallbackChainsEnabled
-        ? ((config.fallback?.chains as Record<string, string[] | undefined>) ??
-          {})
+        ? normalizeFallbackChainsForPreset(
+            (config.fallback?.chains as Record<string, string[] | undefined>) ??
+              {},
+            activePresetForFallback,
+          )
         : {};
 
       // Build effective model arrays: seed from _modelArray, then append