Просмотр исходного кода

fix(config): allow spaces in provider model names

zjm54321 1 неделя назад
Родитель
Сommit
a3a0ca5931

+ 4 - 0
docs/configuration.md

@@ -240,6 +240,10 @@ subprocess.
 
 ### Council configuration note
 
+- Councillor `model` and ACP `wrapperModel` values use `provider/model`
+  references. The provider must be nonempty and cannot contain whitespace or
+  `/`; the nonempty model remainder is retained verbatim and may contain spaces
+  and nested `/` values, such as `opencode-omniroute-live/of/MiniMax M3`.
 - The **Council agent model** is configured like any other agent, for example in
   `presets.<name>.council.model`.
 - The **councillor models** are configured separately under

+ 2 - 2
oh-my-opencode-slim.schema.json

@@ -1166,7 +1166,7 @@
           "properties": {
             "defaultConcurrency": {
               "default": 0,
-              "description": "Maximum concurrently running native background tasks. 0 means unlimited.",
+              "description": "Maximum concurrently running native background tasks. 0 disables the default cap.",
               "type": "integer",
               "minimum": 0,
               "maximum": 1000
@@ -1404,7 +1404,7 @@
           },
           "wrapperModel": {
             "type": "string",
-            "pattern": "^[^/\\s]+\\/[^\\s]+$"
+            "pattern": "^[^/\\s]+\\/.+$"
           },
           "timeoutMs": {
             "default": 0,

+ 120 - 0
src/agents/index.test.ts

@@ -534,6 +534,126 @@ describe('per-model variant in array config', () => {
   });
 });
 
+describe('spaced model ID registrations', () => {
+  const primary = 'opencode-omniroute-live/of/MiniMax M3';
+  const secondary = 'of/Kimi K2.6';
+  const fallback = 'opencode-omniroute-live/of/Qwen3.8 27b';
+
+  test('preserves named-preset arrays for built-in and custom subagents', () => {
+    const config = PluginConfigSchema.parse({
+      preset: 'spaced',
+      presets: {
+        spaced: {
+          explorer: {
+            model: [
+              { id: primary, variant: 'fast' },
+              { id: secondary, variant: 'balanced' },
+            ],
+          },
+          librarian: { model: primary, variant: 'direct' },
+          reviewer: {
+            model: [
+              { id: secondary, variant: 'precise' },
+              { id: fallback, variant: 'economy' },
+            ],
+          },
+        },
+      },
+      // Declaring the custom agent at the root lets the named preset supply
+      // its model plan while retaining the custom agent registration.
+      agents: { reviewer: { temperature: 0.2 } },
+    });
+    const runtime = runtimeFor(config);
+    const agents = createAgents(runtime);
+    const configs = getAgentConfigs(runtime);
+
+    expect(runtime.agent('explorer')?.model).toEqual([
+      { id: primary, variant: 'fast' },
+      { id: secondary, variant: 'balanced' },
+    ]);
+    expect(runtime.agent('reviewer')?.model).toEqual([
+      { id: secondary, variant: 'precise' },
+      { id: fallback, variant: 'economy' },
+    ]);
+    expect(runtime.agent('librarian')).toMatchObject({
+      model: primary,
+      variant: 'direct',
+    });
+
+    expect(
+      agents.find((agent) => agent.name === 'explorer')?._modelArray,
+    ).toEqual([
+      { id: primary, variant: 'fast' },
+      { id: secondary, variant: 'balanced' },
+    ]);
+    expect(
+      agents.find((agent) => agent.name === 'reviewer')?._modelArray,
+    ).toEqual([
+      { id: secondary, variant: 'precise' },
+      { id: fallback, variant: 'economy' },
+    ]);
+    expect(configs.explorer).toMatchObject({
+      model: primary,
+      variant: 'fast',
+      mode: 'subagent',
+    });
+    expect(configs.librarian).toMatchObject({
+      model: primary,
+      variant: 'direct',
+      mode: 'subagent',
+    });
+    expect(configs.reviewer).toMatchObject({
+      model: secondary,
+      variant: 'precise',
+      mode: 'subagent',
+    });
+  });
+
+  test('registers parsed council seats and ACP wrappers with spaced model IDs', () => {
+    const config = PluginConfigSchema.parse({
+      council: {
+        default_preset: 'spaced',
+        presets: {
+          spaced: {
+            alpha: {
+              model: [
+                { id: primary, variant: 'reasoning' },
+                { id: secondary, variant: 'fast' },
+              ],
+            },
+          },
+        },
+      },
+      acpAgents: {
+        bridge: {
+          command: 'bridge-acp',
+          wrapperModel: fallback,
+        },
+      },
+    });
+    const runtime = runtimeFor(config);
+    const agents = createAgents(runtime);
+    const configs = getAgentConfigs(runtime);
+    const councillor = agents.find(
+      (agent) => agent.name === 'councillor-alpha',
+    );
+
+    expect(councillor?._modelArray).toEqual([
+      { id: primary, variant: 'reasoning' },
+      { id: secondary, variant: 'fast' },
+    ]);
+    expect(councillor?.config.model).toBeUndefined();
+    expect(configs['councillor-alpha']).toMatchObject({
+      mode: 'subagent',
+      hidden: true,
+    });
+    expect(configs.bridge).toMatchObject({
+      model: fallback,
+      mode: 'subagent',
+    });
+  });
+});
+
 describe('skill permissions', () => {
   test('orchestrator gets command-style bundled skills allowed by default', () => {
     const agents = createAgents(runtimeFor());

+ 19 - 13
src/config/council-schema.test.ts

@@ -7,36 +7,42 @@ import {
 } from './council-schema';
 
 describe('CouncillorConfigSchema', () => {
-  test('validates config with model and optional variant', () => {
+  test('accepts and preserves a scalar model ID with spaces', () => {
     const result = CouncillorConfigSchema.safeParse({
-      model: 'openai/gpt-5.6-luna',
+      model: 'of/MiniMax M3',
       variant: 'low',
     });
     expect(result.success).toBe(true);
     if (result.success) {
-      expect(result.data.model).toBe('openai/gpt-5.6-luna');
+      expect(result.data.model).toBe('of/MiniMax M3');
       expect(result.data.variant).toBe('low');
       // A single-model config normalizes to a one-entry chain.
       expect(result.data.models).toEqual([
-        { id: 'openai/gpt-5.6-luna', variant: 'low' },
+        { id: 'of/MiniMax M3', variant: 'low' },
       ]);
     }
   });
 
-  test('accepts an ordered model fallback chain', () => {
+  test('preserves ordered mixed fallback entries with spaced model IDs', () => {
     const result = CouncillorConfigSchema.safeParse({
       model: [
-        'openai/gpt-5.6-luna',
-        { id: 'google/gemini-3-pro', variant: 'high' },
+        'of/Kimi K2.6',
+        {
+          id: 'opencode-omniroute-live/of/Qwen3.8 27b',
+          variant: 'high',
+        },
       ],
     });
     expect(result.success).toBe(true);
     if (result.success) {
       // Primary model stays on `model` for backward compatibility.
-      expect(result.data.model).toBe('openai/gpt-5.6-luna');
+      expect(result.data.model).toBe('of/Kimi K2.6');
       expect(result.data.models).toEqual([
-        { id: 'openai/gpt-5.6-luna', variant: undefined },
-        { id: 'google/gemini-3-pro', variant: 'high' },
+        { id: 'of/Kimi K2.6', variant: undefined },
+        {
+          id: 'opencode-omniroute-live/of/Qwen3.8 27b',
+          variant: 'high',
+        },
       ]);
     }
   });
@@ -99,12 +105,12 @@ test('preset with only legacy "master" key results in empty councillors', () =>
   }
 });
 
-test('unwraps legacy nested "councillors" key in preset', () => {
+test('unwraps legacy nested "councillors" key with spaced model IDs', () => {
   const config = {
     presets: {
       default: {
         councillors: {
-          alpha: { model: 'openai/gpt-5.6-luna' },
+          alpha: { model: 'of/MiniMax M3' },
           beta: { model: 'openai/gpt-5.3-codex' },
         },
       },
@@ -117,7 +123,7 @@ test('unwraps legacy nested "councillors" key in preset', () => {
   if (result.success) {
     const preset = result.data.presets.default;
     expect(Object.keys(preset)).toEqual(['alpha', 'beta']);
-    expect(preset.alpha.model).toBe('openai/gpt-5.6-luna');
+    expect(preset.alpha.model).toBe('of/MiniMax M3');
     expect(preset.beta.model).toBe('openai/gpt-5.3-codex');
   }
 });

+ 6 - 14
src/config/council-schema.ts

@@ -3,22 +3,12 @@ import {
   type CouncillorModelEntry,
   normalizeCouncillorModels,
 } from '../utils/councillor-models';
+import { ProviderModelIdSchema } from './model-id-schema';
 
 export type { CouncillorModelEntry };
 
-/**
- * Validates model IDs in "provider/model" format.
- * Inlined here to avoid circular dependency with schema.ts.
- */
-const ModelIdSchema = z
-  .string()
-  .regex(
-    /^[^/\s]+\/[^\s]+$/,
-    'Expected provider/model format (e.g. "openai/gpt-5.6-luna")',
-  );
-
 const CouncillorModelEntrySchema = z.object({
-  id: ModelIdSchema,
+  id: ProviderModelIdSchema,
   variant: z.string().optional(),
 });
 
@@ -29,8 +19,10 @@ const CouncillorModelEntrySchema = z.object({
  */
 const CouncillorModelSchema = z
   .union([
-    ModelIdSchema,
-    z.array(z.union([ModelIdSchema, CouncillorModelEntrySchema])).min(1),
+    ProviderModelIdSchema,
+    z
+      .array(z.union([ProviderModelIdSchema, CouncillorModelEntrySchema]))
+      .min(1),
   ])
   .describe(
     'Model ID in provider/model format (e.g. "openai/gpt-5.6-luna"), or an ' +

+ 45 - 0
src/config/loader.test.ts

@@ -549,6 +549,51 @@ describe('onWarning callback', () => {
     expect(config.agents?.oracle?.model).toBe('valid/model');
   });
 
+  test('loads a council with spaced model IDs without an invalid-schema warning', () => {
+    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({
+        agents: { oracle: { model: 'openai/gpt-5.6-luna' } },
+        council: {
+          default_preset: 'spaced',
+          presets: {
+            spaced: {
+              scalar: { model: 'of/MiniMax M3' },
+              fallback: {
+                model: [
+                  'of/Kimi K2.6',
+                  {
+                    id: 'opencode-omniroute-live/of/Qwen3.8 27b',
+                    variant: 'high',
+                  },
+                ],
+              },
+            },
+          },
+        },
+      }),
+    );
+
+    const warnings: ConfigLoadWarning[] = [];
+    const config = loadPluginConfig(projectDir, {
+      onWarning: (warning) => warnings.push(warning),
+    });
+
+    expect(warnings).toEqual([]);
+    expect(config.agents?.oracle?.model).toBe('openai/gpt-5.6-luna');
+    expect(config.council?.presets.spaced?.scalar?.model).toBe('of/MiniMax M3');
+    expect(config.council?.presets.spaced?.fallback?.models).toEqual([
+      { id: 'of/Kimi K2.6', variant: undefined },
+      {
+        id: 'opencode-omniroute-live/of/Qwen3.8 27b',
+        variant: 'high',
+      },
+    ]);
+  });
+
   test('deprecated tmux key calls onWarning with deprecated-key and still loads', () => {
     const projectDir = path.join(tempDir, 'project');
     const projectConfigDir = path.join(projectDir, '.opencode');

+ 15 - 0
src/config/model-id-schema.ts

@@ -0,0 +1,15 @@
+import { z } from 'zod';
+
+/**
+ * A provider ID followed by a nonempty, verbatim model remainder.
+ *
+ * Providers cannot contain slashes or whitespace. Model remainders may contain
+ * spaces and additional slashes because provider adapters may expose nested or
+ * human-readable model names.
+ */
+export const ProviderModelIdSchema = z
+  .string()
+  .regex(
+    /^[^/\s]+\/.+$/,
+    'Expected provider/model format (provider/.../model)',
+  );

+ 35 - 0
src/config/runtime.test.ts

@@ -368,6 +368,41 @@ describe('RuntimeConfig', () => {
     ]);
   });
 
+  test('modelArrays and runtimeChains retain nested spaced IDs and variants', () => {
+    resetRegistry();
+    const runtime = RuntimeConfig.init(DIRECTORY, {
+      preset: 'spaced',
+      presets: {
+        spaced: {
+          explorer: {
+            model: [
+              {
+                id: 'opencode-omniroute-live/of/MiniMax M3',
+                variant: 'fast',
+              },
+              { id: 'of/Kimi K2.6', variant: 'balanced' },
+              'opencode-omniroute-live/of/Qwen3.8 27b',
+            ],
+          },
+        },
+      },
+    });
+
+    expect(runtime.modelArrays.explorer).toEqual([
+      {
+        id: 'opencode-omniroute-live/of/MiniMax M3',
+        variant: 'fast',
+      },
+      { id: 'of/Kimi K2.6', variant: 'balanced' },
+      { id: 'opencode-omniroute-live/of/Qwen3.8 27b' },
+    ]);
+    expect(runtime.runtimeChains.explorer).toEqual([
+      'opencode-omniroute-live/of/MiniMax M3',
+      'of/Kimi K2.6',
+      'opencode-omniroute-live/of/Qwen3.8 27b',
+    ]);
+  });
+
   test('modelArrays excludes disabled councillor seats', () => {
     resetRegistry();
     const runtime = RuntimeConfig.init(DIRECTORY, {

+ 55 - 1
src/config/schema.test.ts

@@ -1,5 +1,59 @@
 import { describe, expect, it } from 'bun:test';
-import { InterviewConfigSchema, PluginConfigSchema } from './schema';
+import {
+  InterviewConfigSchema,
+  PluginConfigSchema,
+  ProviderModelIdSchema,
+} from './schema';
+
+describe('ProviderModelIdSchema', () => {
+  it('accepts and preserves model remainders with spaces and nested segments', () => {
+    const ids = [
+      'of/MiniMax M3',
+      'of/Kimi K2.6',
+      'opencode-omniroute-live/of/Qwen3.8 27b',
+      'openai/gpt-5.6-luna',
+    ];
+
+    for (const id of ids) {
+      const result = ProviderModelIdSchema.safeParse(id);
+      expect(result.success).toBe(true);
+      if (result.success) {
+        expect(result.data).toBe(id);
+      }
+    }
+  });
+
+  it('rejects missing provider/model parts and whitespace in the provider', () => {
+    for (const id of [
+      'model',
+      '/model',
+      'provider/',
+      ' provider/model',
+      'provider name/model',
+    ]) {
+      expect(ProviderModelIdSchema.safeParse(id).success).toBe(false);
+    }
+  });
+});
+
+describe('PluginConfigSchema ACP wrapper models', () => {
+  it('accepts and preserves a wrapper model ID with spaces and nested segments', () => {
+    const wrapperModel = 'opencode-omniroute-live/of/MiniMax M3';
+    const result = PluginConfigSchema.safeParse({
+      acpAgents: {
+        helper: {
+          command: 'acp-helper',
+          wrapperModel,
+        },
+      },
+    });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.acpAgents?.helper?.wrapperModel).toBe(wrapperModel);
+    }
+  });
+});
 
 describe('PluginConfigSchema image_routing', () => {
   it('accepts image_routing: direct with observer disabled', () => {

+ 2 - 6
src/config/schema.ts

@@ -4,13 +4,9 @@ import {
   DEFAULT_MAX_RETAINED_SNAPSHOTS,
 } from './constants';
 import { CouncilConfigSchema } from './council-schema';
+import { ProviderModelIdSchema } from './model-id-schema';
 
-export const ProviderModelIdSchema = z
-  .string()
-  .regex(
-    /^[^/\s]+\/[^\s]+$/,
-    'Expected provider/model format (provider/.../model)',
-  );
+export { ProviderModelIdSchema } from './model-id-schema';
 
 // Permission schemas — mirror the SDK's PermissionConfig type with shallow
 // validation. Action values are validated; unknown tool keys pass through.

+ 43 - 0
src/hooks/foreground-fallback/index.test.ts

@@ -983,6 +983,49 @@ describe('ForegroundFallbackManager session.error', () => {
     expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
     expect(showToast).not.toHaveBeenCalled();
   });
+
+  test('preserves nested spaced model IDs in the fallback prompt request', async () => {
+    const { mocks } = createMockClient();
+    const mgr = new ForegroundFallbackManager(
+      {
+        explorer: [
+          'opencode-omniroute-live/of/MiniMax M3',
+          'opencode-omniroute-live/of/Qwen3.8 27b',
+        ],
+      },
+      true,
+      { directory: '/test' } as any,
+    );
+
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-spaced-model-id',
+          agent: 'explorer',
+          providerID: 'opencode-omniroute-live',
+          modelID: 'of/MiniMax M3',
+          role: 'assistant',
+        },
+      },
+    });
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'sess-spaced-model-id',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+
+    expect(mocks.promptAsync).toHaveBeenCalledTimes(1);
+    const call = mocks.promptAsync.mock.calls[0] as [
+      { body: { model: { providerID: string; modelID: string } } },
+    ];
+    expect(call[0].body.model).toEqual({
+      providerID: 'opencode-omniroute-live',
+      modelID: 'of/Qwen3.8 27b',
+    });
+  });
 });
 
 // ---------------------------------------------------------------------------

+ 7 - 0
src/v2/adapters.test.ts

@@ -15,6 +15,13 @@ describe('parseModelRef', () => {
     });
   });
 
+  test('retains nested spaced model suffixes after the first slash', () => {
+    expect(parseModelRef('opencode-omniroute-live/of/MiniMax M3')).toEqual({
+      providerID: 'opencode-omniroute-live',
+      id: 'of/MiniMax M3',
+    });
+  });
+
   test('undefined for non-string', () => {
     expect(parseModelRef(undefined)).toBeUndefined();
     expect(parseModelRef(42)).toBeUndefined();