Quellcode durchsuchen

fix: revert fallback.chains removal (belongs in separate PR), move mid-file imports to top

adikpb vor 1 Monat
Ursprung
Commit
e95481365d

+ 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;
+}

+ 196 - 0
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.
@@ -65,3 +66,198 @@ describe('model array resolution', () => {
     expect(result).toBeNull();
   });
 });
+
+/**
+ * Tests for the fallback.chains merging logic that runs in the config hook.
+ * Mirrors the effectiveArrays construction in src/index.ts.
+ */
+describe('fallback.chains merging for foreground agents', () => {
+  /**
+   * Simulates the effectiveArrays construction + resolution from src/index.ts.
+   * Returns the resolved model string or null.
+   */
+  function resolveWithChains(opts: {
+    modelArray?: Array<{ id: string; variant?: string }>;
+    currentModel?: string;
+    chainModels?: string[];
+    preset?: string;
+    fallbackEnabled?: boolean;
+  }): string | null {
+    const {
+      modelArray,
+      currentModel,
+      chainModels,
+      preset,
+      fallbackEnabled = true,
+    } = opts;
+
+    // Build effectiveArrays (mirrors index.ts logic)
+    const effectiveArray: Array<{ id: string; variant?: string }> = modelArray
+      ? [...modelArray]
+      : [];
+
+    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 normalizedModels) {
+        if (!seen.has(chainModel)) {
+          seen.add(chainModel);
+          effectiveArray.push({ id: chainModel });
+        }
+      }
+    }
+
+    if (effectiveArray.length === 0) return null;
+
+    // Resolution: always use first model in effective array
+    return effectiveArray[0].id;
+  }
+
+  test('primary model wins regardless of provider config', () => {
+    const result = resolveWithChains({
+      currentModel: 'anthropic/claude-opus-4-5',
+      chainModels: ['openai/gpt-4o'],
+    });
+    expect(result).toBe('anthropic/claude-opus-4-5');
+  });
+
+  test('chain is ignored when fallback disabled', () => {
+    const result = resolveWithChains({
+      currentModel: 'anthropic/claude-opus-4-5',
+      chainModels: ['openai/gpt-4o'],
+      fallbackEnabled: false,
+    });
+    // chain not applied; no effectiveArray entry → falls through to null (no _modelArray either)
+    expect(result).toBeNull();
+  });
+
+  test('_modelArray entries take precedence and chain appends after', () => {
+    const result = resolveWithChains({
+      modelArray: [
+        { id: 'anthropic/claude-opus-4-5' },
+        { id: 'anthropic/claude-sonnet-4-5' },
+      ],
+      chainModels: ['openai/gpt-4o'],
+    });
+    // First entry in _modelArray wins; chain only used for runtime failover
+    expect(result).toBe('anthropic/claude-opus-4-5');
+  });
+
+  test('duplicate model ids across array and chain are deduplicated', () => {
+    const result = resolveWithChains({
+      modelArray: [
+        { id: 'anthropic/claude-opus-4-5' },
+        { id: 'openai/gpt-4o' },
+      ],
+      chainModels: ['openai/gpt-4o', 'google/gemini-pro'],
+    });
+    expect(result).toBe('anthropic/claude-opus-4-5');
+  });
+
+  test('no currentModel and no _modelArray with chain still resolves', () => {
+    const result = resolveWithChains({
+      chainModels: ['openai/gpt-4o', 'anthropic/claude-sonnet-4-5'],
+    });
+    expect(result).toBe('openai/gpt-4o');
+  });
+
+  test('built-in provider not skipped when other providers are configured', () => {
+    // Regression test: github-copilot is auto-loaded by opencode and doesn't
+    // need an entry in opencodeConfig.provider. The resolver must not skip
+    // it in favor of a configured provider later in the chain.
+    const result = resolveWithChains({
+      currentModel: 'github-copilot/claude-opus-4.6',
+      chainModels: [
+        'github-copilot/gemini-3.1-pro-preview',
+        'zai-coding-plan/glm-5',
+      ],
+    });
+    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'],
+    });
+  });
+});

+ 25 - 0
src/config/schema.ts

@@ -2,6 +2,30 @@ import { z } from 'zod';
 import { AGENT_ALIASES, ALL_AGENT_NAMES } from './constants';
 import { CouncilConfigSchema } from './council-schema';
 
+const FALLBACK_AGENT_NAMES = [
+  'orchestrator',
+  'oracle',
+  'designer',
+  'explorer',
+  'librarian',
+  'fixer',
+] as const;
+
+const AgentModelChainSchema = z.array(z.string()).min(1);
+
+const FallbackChainsSchema = z
+  .object({
+    orchestrator: AgentModelChainSchema.optional(),
+    oracle: AgentModelChainSchema.optional(),
+    designer: AgentModelChainSchema.optional(),
+    explorer: AgentModelChainSchema.optional(),
+    librarian: AgentModelChainSchema.optional(),
+    fixer: AgentModelChainSchema.optional(),
+  })
+  .catchall(AgentModelChainSchema);
+
+export type FallbackAgentName = (typeof FALLBACK_AGENT_NAMES)[number];
+
 // Agent override configuration (distinct from SDK's AgentConfig)
 export const AgentOverrideConfigSchema = z
   .object({
@@ -119,6 +143,7 @@ export type BackgroundJobsConfig = z.infer<typeof BackgroundJobsConfigSchema>;
 
 export const FailoverConfigSchema = z.object({
   enabled: z.boolean().default(true),
+  chains: FallbackChainsSchema.default({}),
   retry_on_empty: z
     .boolean()
     .default(true)

+ 5 - 4
src/hooks/foreground-fallback/index.ts

@@ -16,7 +16,10 @@
 
 import type { PluginInput } from '@opencode-ai/plugin';
 import { log } from '../../utils/logger';
-import { abortSessionWithTimeout } from '../../utils/session';
+import {
+  abortSessionWithTimeout,
+  parseModelReference,
+} from '../../utils/session';
 
 type OpencodeClient = PluginInput['client'];
 
@@ -59,8 +62,6 @@ export function isRateLimitError(error: unknown): boolean {
 // Helpers
 // ---------------------------------------------------------------------------
 
-import { parseModelReference } from '../../utils/session';
-
 /** Prevent re-triggering within this window for the same session. */
 const DEDUP_WINDOW_MS = 5_000;
 const REPROMPT_DELAY_MS = 500;
@@ -73,7 +74,7 @@ const REPROMPT_DELAY_MS = 500;
  * Manages runtime model fallback for foreground agent sessions.
  *
  * Constructed at plugin init with the ordered fallback chains for each agent
- * (built from _modelArray entries in agents.<name>.model).
+ * (built from _modelArray entries merged with fallback.chains config).
  */
 export class ForegroundFallbackManager {
   /** sessionID → last observed model string ("providerID/modelID") */

+ 1 - 2
src/hooks/task-session-manager/index.ts

@@ -11,6 +11,7 @@ import {
   parseTaskStatusOutput,
   SLIM_INTERNAL_INITIATOR_MARKER,
 } from '../../utils';
+import { isRecord as isObjectRecord } from '../../utils/guards';
 import { log } from '../../utils/logger';
 
 interface TaskArgs {
@@ -126,8 +127,6 @@ function isAgentName(value: unknown): value is AgentName {
   return typeof value === 'string' && AGENT_NAME_SET.has(value as AgentName);
 }
 
-import { isRecord as isObjectRecord } from '../../utils/guards';
-
 function extractPath(output: string): string | undefined {
   return /<path>([^<]+)<\/path>/.exec(output)?.[1];
 }

+ 66 - 4
src/index.ts

@@ -10,6 +10,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,
@@ -190,14 +191,34 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     }
     // Build runtime fallback chains for all foreground agents. Each chain
     // is an ordered list of model strings to try when the current model is
-    // rate-limited. Populated from _modelArray entries (when the user
-    // configures model as an array in agents.<name>.model).
+    // rate-limited. Seeds from _modelArray entries (when the user
+    // configures model as an array), then appends fallback.chains entries.
     runtimeChains = {} as Record<string, string[]>;
     for (const agentDef of agentDefs) {
       if (agentDef._modelArray?.length) {
         runtimeChains[agentDef.name] = agentDef._modelArray.map((m) => m.id);
       }
     }
+    const activePresetForFallback =
+      getActiveRuntimePreset() ?? config.preset ?? null;
+
+    if (config.fallback?.enabled !== false) {
+      const chains = normalizeFallbackChainsForPreset(
+        (config.fallback?.chains as Record<string, string[] | undefined>) ?? {},
+        activePresetForFallback,
+      );
+      for (const [agentName, chainModels] of Object.entries(chains)) {
+        const existing = runtimeChains[agentName] ?? [];
+        const seen = new Set(existing);
+        for (const m of chainModels) {
+          if (!seen.has(m)) {
+            seen.add(m);
+            existing.push(m);
+          }
+        }
+        runtimeChains[agentName] = existing;
+      }
+    }
 
     // Parse multiplexer config with defaults
     multiplexerConfig = {
@@ -424,12 +445,27 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
       const configAgent = opencodeConfig.agent as Record<string, unknown>;
 
-      // Model resolution for foreground agents: use _modelArray entries
-      // to pick the first model for startup-time selection.
+      // Model resolution for foreground agents: combine _modelArray
+      // entries with fallback.chains config, then pick the first model in
+      // the effective array for startup-time selection.
       //
       // 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
+        ? normalizeFallbackChainsForPreset(
+            (config.fallback?.chains as Record<string, string[] | undefined>) ??
+              {},
+            activePresetForFallback,
+          )
+        : {};
+
+      // Build effective model arrays: seed from _modelArray, then append
+      // fallback.chains entries so the resolver considers the full chain
+      // when picking the best available provider at startup.
       const effectiveArrays: Record<
         string,
         Array<{ id: string; variant?: string }>
@@ -439,6 +475,32 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         effectiveArrays[agentName] = [...models];
       }
 
+      for (const [agentName, chainModels] of Object.entries(fallbackChains)) {
+        if (!chainModels || chainModels.length === 0) continue;
+
+        if (!effectiveArrays[agentName]) {
+          // Agent has no _modelArray — seed from its current string model
+          // so the fallback chain appends after it rather than replacing
+          // it.
+          const entry = configAgent[agentName] as
+            | Record<string, unknown>
+            | undefined;
+          const currentModel =
+            typeof entry?.model === 'string' ? entry.model : undefined;
+          effectiveArrays[agentName] = currentModel
+            ? [{ id: currentModel }]
+            : [];
+        }
+
+        const seen = new Set(effectiveArrays[agentName].map((m) => m.id));
+        for (const chainModel of chainModels) {
+          if (!seen.has(chainModel)) {
+            seen.add(chainModel);
+            effectiveArrays[agentName].push({ id: chainModel });
+          }
+        }
+      }
+
       if (Object.keys(effectiveArrays).length > 0) {
         for (const [agentName, modelArray] of Object.entries(effectiveArrays)) {
           if (modelArray.length === 0) continue;

+ 1 - 2
src/tools/cancel-task.ts

@@ -4,6 +4,7 @@ import {
   tool,
 } from '@opencode-ai/plugin';
 import type { BackgroundJobBoard } from '../utils/background-job-board';
+import { isRecord as isObjectRecord } from '../utils/guards';
 import { log } from '../utils/logger';
 import { abortSessionWithTimeout, withTimeout } from '../utils/session';
 
@@ -460,8 +461,6 @@ function delay(ms: number): Promise<void> {
   return new Promise((resolve) => setTimeout(resolve, ms));
 }
 
-import { isRecord as isObjectRecord } from '../utils/guards';
-
 function isSessionID(value: string): boolean {
   return /^ses_[\w-]+$/.test(value);
 }