Browse Source

feat(config): support provider-specific model options in agent/preset config (#272)

ReqX 1 day ago
parent
commit
2652e407d2

+ 1 - 0
docs/configuration.md

@@ -88,6 +88,7 @@ All config files support **JSONC** (JSON with Comments):
 | `presets.<name>.<agent>.variant` | string | — | Reasoning effort: `"low"`, `"medium"`, `"high"` |
 | `presets.<name>.<agent>.variant` | string | — | Reasoning effort: `"low"`, `"medium"`, `"high"` |
 | `presets.<name>.<agent>.skills` | string[] | — | Skills the agent can use (`"*"`, `"!item"`, explicit list) |
 | `presets.<name>.<agent>.skills` | string[] | — | Skills the agent can use (`"*"`, `"!item"`, explicit list) |
 | `presets.<name>.<agent>.mcps` | string[] | — | MCPs the agent can use (`"*"`, `"!item"`, explicit list) |
 | `presets.<name>.<agent>.mcps` | string[] | — | MCPs the agent can use (`"*"`, `"!item"`, explicit list) |
+| `presets.<name>.<agent>.options` | object | — | Provider-specific model options passed to the AI SDK (e.g., `textVerbosity`, `thinking` budget) |
 | `tmux.enabled` | boolean | `false` | Enable tmux pane spawning |
 | `tmux.enabled` | boolean | `false` | Enable tmux pane spawning |
 | `tmux.layout` | string | `"main-vertical"` | Layout: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical` |
 | `tmux.layout` | string | `"main-vertical"` | Layout: `main-vertical`, `main-horizontal`, `tiled`, `even-horizontal`, `even-vertical` |
 | `tmux.main_pane_size` | number | `60` | Main pane size as percentage (20–80) |
 | `tmux.main_pane_size` | number | `60` | Main pane size as percentage (20–80) |

+ 70 - 0
docs/provider-configurations.md

@@ -175,3 +175,73 @@ The plugin can automatically fail over from one model to the next when a prompt
 - Model IDs must use `provider/model` format
 - Model IDs must use `provider/model` format
 - Chains are per agent: `orchestrator`, `oracle`, `designer`, `explorer`, `librarian`, `fixer`, `councillor`, `council-master`
 - Chains are per agent: `orchestrator`, `oracle`, `designer`, `explorer`, `librarian`, `fixer`, `councillor`, `council-master`
 - If an agent has no chain configured, only its primary model is used
 - If an agent has no chain configured, only its primary model is used
+
+---
+
+## Provider-Specific Options
+
+You can pass provider-specific model parameters via the `options` field on any agent config. These are forwarded directly to the AI SDK's `providerOptions` and affect model behavior at the API level.
+
+### OpenAI — Concise Responses
+
+```jsonc
+{
+  "presets": {
+    "openai": {
+      "oracle": {
+        "model": "openai/gpt-5.4",
+        "options": {
+          "textVerbosity": "low"  // "low" | "medium" | "high"
+        }
+      }
+    }
+  }
+}
+```
+
+### Anthropic — Extended Thinking
+
+```jsonc
+{
+  "presets": {
+    "anthropic": {
+      "oracle": {
+        "model": "anthropic/claude-sonnet-4-6",
+        "options": {
+          "thinking": {
+            "type": "enabled",
+            "budgetTokens": 16000
+          }
+        }
+      }
+    }
+  }
+}
+```
+
+### Google — Thinking Budget
+
+```jsonc
+{
+  "presets": {
+    "google": {
+      "oracle": {
+        "model": "google/gemini-3.1-pro",
+        "options": {
+          "thinkingConfig": {
+            "includeThoughts": true,
+            "thinkingBudget": 16000
+          }
+        }
+      }
+    }
+  }
+}
+```
+
+**Notes:**
+- `options` works per-agent and per-preset, just like `model` and `variant`
+- Options are **static** — they don't swap when fallback chains switch providers
+- Provider-specific keys are namespaced by the SDK, so OpenAI options are safely ignored by Anthropic and vice versa
+- Options from presets and root config are deep-merged (root keys override preset keys)
+- Nested objects in options are recursively merged by key — to fully replace a nested object (e.g., disable a preset's `thinking` config), set all subkeys explicitly

+ 10 - 0
oh-my-opencode-slim.schema.json

@@ -258,6 +258,11 @@
               "items": {
               "items": {
                 "type": "string"
                 "type": "string"
               }
               }
+            },
+            "options": {
+              "type": "object",
+              "additionalProperties": true,
+              "description": "Provider-specific model options (e.g., textVerbosity, thinking budget)"
             }
             }
           }
           }
         }
         }
@@ -321,6 +326,11 @@
             "items": {
             "items": {
               "type": "string"
               "type": "string"
             }
             }
+          },
+          "options": {
+            "type": "object",
+            "additionalProperties": true,
+            "description": "Provider-specific model options (e.g., textVerbosity, thinking budget)"
           }
           }
         }
         }
       }
       }

+ 154 - 1
src/agents/index.test.ts

@@ -1,6 +1,10 @@
 import { describe, expect, test } from 'bun:test';
 import { describe, expect, test } from 'bun:test';
 import type { PluginConfig } from '../config';
 import type { PluginConfig } from '../config';
-import { DEFAULT_MODELS, SUBAGENT_NAMES } from '../config';
+import {
+  AgentOverrideConfigSchema,
+  DEFAULT_MODELS,
+  SUBAGENT_NAMES,
+} from '../config';
 import { createAgents, getAgentConfigs, isSubagent } from './index';
 import { createAgents, getAgentConfigs, isSubagent } from './index';
 
 
 describe('agent alias backward compatibility', () => {
 describe('agent alias backward compatibility', () => {
@@ -371,3 +375,152 @@ describe('council agent model resolution', () => {
     expect(councilMaster?.config.model).toBe(DEFAULT_MODELS['council-master']);
     expect(councilMaster?.config.model).toBe(DEFAULT_MODELS['council-master']);
   });
   });
 });
 });
+
+describe('options passthrough', () => {
+  test('options are applied to agent config via overrides', () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-5.4',
+          options: { textVerbosity: 'low' },
+        },
+      },
+    };
+    const agents = createAgents(config);
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.options).toEqual({ textVerbosity: 'low' });
+  });
+
+  test('options with nested objects are passed through', () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: {
+          model: 'anthropic/claude-sonnet-4-6',
+          options: {
+            thinking: { type: 'enabled', budgetTokens: 16000 },
+          },
+        },
+      },
+    };
+    const agents = createAgents(config);
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.options).toEqual({
+      thinking: { type: 'enabled', budgetTokens: 16000 },
+    });
+  });
+
+  test('options work with other overrides', () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-5.4',
+          variant: 'high',
+          temperature: 0.7,
+          options: { textVerbosity: 'low', reasoningEffort: 'medium' },
+        },
+      },
+    };
+    const agents = createAgents(config);
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.model).toBe('openai/gpt-5.4');
+    expect(oracle?.config.variant).toBe('high');
+    expect(oracle?.config.temperature).toBe(0.7);
+    expect(oracle?.config.options).toEqual({
+      textVerbosity: 'low',
+      reasoningEffort: 'medium',
+    });
+  });
+
+  test('options are absent when not configured', () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: { model: 'openai/gpt-5.4' },
+      },
+    };
+    const agents = createAgents(config);
+    const oracle = agents.find((a) => a.name === 'oracle');
+    expect(oracle?.config.options).toBeUndefined();
+  });
+
+  test('options flow through getAgentConfigs to SDK output', () => {
+    const config: PluginConfig = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-5.4',
+          options: { textVerbosity: 'low' },
+        },
+      },
+    };
+    const configs = getAgentConfigs(config);
+    expect(configs.oracle.options).toEqual({ textVerbosity: 'low' });
+  });
+
+  test('options are shallow-merged with existing agent config options', () => {
+    // Simulate an agent factory setting default options
+    const config: PluginConfig = {
+      agents: {
+        oracle: {
+          model: 'openai/gpt-5.4',
+          options: { reasoningEffort: 'medium' },
+        },
+      },
+    };
+    const agents = createAgents(config);
+    const oracle = agents.find((a) => a.name === 'oracle');
+    // Override options should merge with (not replace) any factory defaults
+    expect(oracle?.config.options).toEqual({ reasoningEffort: 'medium' });
+  });
+});
+
+describe('AgentOverrideConfigSchema options validation', () => {
+  test('accepts valid options object', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      options: { textVerbosity: 'low' },
+    });
+    expect(result.success).toBe(true);
+  });
+
+  test('accepts empty options object', () => {
+    const result = AgentOverrideConfigSchema.safeParse({ options: {} });
+    expect(result.success).toBe(true);
+  });
+
+  test('accepts nested values in options', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      options: {
+        thinking: { type: 'enabled', budgetTokens: 16000 },
+      },
+    });
+    expect(result.success).toBe(true);
+  });
+
+  test('accepts options alongside other fields', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.4',
+      variant: 'high',
+      temperature: 0.7,
+      options: { textVerbosity: 'low' },
+    });
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.options).toEqual({ textVerbosity: 'low' });
+    }
+  });
+
+  test('config without options is valid', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.4',
+    });
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.options).toBeUndefined();
+    }
+  });
+
+  test('rejects non-object options', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      options: 'not-an-object',
+    });
+    expect(result.success).toBe(false);
+  });
+});

+ 6 - 0
src/agents/index.ts

@@ -53,6 +53,12 @@ function applyOverrides(
   if (override.variant) agent.config.variant = override.variant;
   if (override.variant) agent.config.variant = override.variant;
   if (override.temperature !== undefined)
   if (override.temperature !== undefined)
     agent.config.temperature = override.temperature;
     agent.config.temperature = override.temperature;
+  if (override.options) {
+    agent.config.options = {
+      ...agent.config.options,
+      ...override.options,
+    };
+  }
 }
 }
 
 
 /**
 /**

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

@@ -630,6 +630,99 @@ describe('preset resolution', () => {
     const warningMessage = consoleWarnSpy.mock.calls[0][0] as string;
     const warningMessage = consoleWarnSpy.mock.calls[0][0] as string;
     expect(warningMessage).toContain('Preset "nonexistent" not found');
     expect(warningMessage).toContain('Preset "nonexistent" not found');
   });
   });
+
+  test('options from preset are deep-merged with root agents', () => {
+    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({
+        preset: 'openai',
+        presets: {
+          openai: {
+            oracle: {
+              model: 'openai/gpt-5.4',
+              options: { textVerbosity: 'low' },
+            },
+          },
+        },
+        agents: {
+          oracle: {
+            options: { reasoningEffort: 'medium' },
+          },
+        },
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('openai/gpt-5.4');
+    // deepMerge should combine both option keys
+    expect(config.agents?.oracle?.options).toEqual({
+      textVerbosity: 'low',
+      reasoningEffort: 'medium',
+    });
+  });
+
+  test('options from preset only work without root agents', () => {
+    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({
+        preset: 'anthropic-thinking',
+        presets: {
+          'anthropic-thinking': {
+            oracle: {
+              model: 'anthropic/claude-sonnet-4-6',
+              options: {
+                thinking: { type: 'enabled', budgetTokens: 16000 },
+              },
+            },
+          },
+        },
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('anthropic/claude-sonnet-4-6');
+    expect(config.agents?.oracle?.options).toEqual({
+      thinking: { type: 'enabled', budgetTokens: 16000 },
+    });
+  });
+
+  test('root options override preset options for same key', () => {
+    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({
+        preset: 'concise',
+        presets: {
+          concise: {
+            oracle: {
+              model: 'openai/gpt-5.4',
+              options: { textVerbosity: 'low' },
+            },
+          },
+        },
+        agents: {
+          oracle: {
+            options: { textVerbosity: 'high' },
+          },
+        },
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe('openai/gpt-5.4');
+    // root wins over preset for same key
+    expect(config.agents?.oracle?.options).toEqual({
+      textVerbosity: 'high',
+    });
+  });
 });
 });
 
 
 describe('environment variable preset override', () => {
 describe('environment variable preset override', () => {

+ 1 - 0
src/config/schema.ts

@@ -98,6 +98,7 @@ export const AgentOverrideConfigSchema = z.object({
   variant: z.string().optional().catch(undefined),
   variant: z.string().optional().catch(undefined),
   skills: z.array(z.string()).optional(), // skills this agent can use ("*" = all, "!item" = exclude)
   skills: z.array(z.string()).optional(), // skills this agent can use ("*" = all, "!item" = exclude)
   mcps: z.array(z.string()).optional(), // MCPs this agent can use ("*" = all, "!item" = exclude)
   mcps: z.array(z.string()).optional(), // MCPs this agent can use ("*" = all, "!item" = exclude)
+  options: z.record(z.string(), z.unknown()).optional(), // provider-specific model options (e.g., textVerbosity, thinking budget)
 });
 });
 
 
 // Multiplexer type options
 // Multiplexer type options