Browse Source

Merge PR #875: add configurable webfetch

Alvin Unreal 1 week ago
parent
commit
561037620f

+ 8 - 2
docs/tools.md

@@ -10,11 +10,17 @@ Slim only intercepts `apply_patch` before the native tool runs. It rewrites reco
 
 
 ## Web Fetch
 ## Web Fetch
 
 
-Fetch remote pages with content extraction tuned for docs/static sites.
+Enhanced version of OpenCode's built-in `webfetch`. Overrides the default when
+this plugin is active. Fetch remote pages with content extraction tuned for
+docs/static sites.
 
 
 | Tool | Description |
 | Tool | Description |
 |------|-------------|
 |------|-------------|
-| `webfetch` | Fetch a URL, optionally prefer `llms.txt`, extract main content from HTML, include metadata, and optionally save binary responses |
+| `webfetch` | Fetch a URL, optionally prefer `llms.txt`, extract main content from HTML, include metadata, optionally save binary responses, and optionally run secondary-model extraction |
+
+See the full [Webfetch documentation](webfetch.md) for parameters, output
+format, caching, llms.txt probing, redirect policy, secondary-model
+summarization, binary detection, and implementation details.
 
 
 `webfetch` blocks cross-origin redirects unless the requested URL or derived permission patterns explicitly allow them, and it can fall back to the raw fetched content when secondary-model summarization is unavailable.
 `webfetch` blocks cross-origin redirects unless the requested URL or derived permission patterns explicitly allow them, and it can fall back to the raw fetched content when secondary-model summarization is unavailable.
 
 

+ 281 - 0
docs/webfetch.md

@@ -0,0 +1,281 @@
+# Webfetch (smartfetch)
+
+The `webfetch` tool fetches remote URLs and returns their content with intelligent
+extraction designed for documentation, static pages, and structured text. It
+provides caching, `llms.txt` probing, binary content handling, and optional
+secondary-model summarization.
+
+`webfetch` is already a built-in tool in OpenCode. This plugin replaces it with
+an enhanced version — the implementation lives in `src/tools/smartfetch/`, and
+the tool is registered under the same `webfetch` name to override the default.
+
+## Parameters
+
+| Parameter | Type | Default | Description |
+|-----------|------|---------|-------------|
+| `url` | URL (string) | **required** | The URL to fetch. Must be a valid HTTP/HTTPS URL. |
+| `format` | `"text"` \| `"markdown"` \| `"html"` | `"markdown"` | Output format for the fetched content. |
+| `timeout` | number | `30` | Timeout in seconds (max `120`). |
+| `prompt` | string | optional | An extraction task for the secondary model to run against the fetched content (see [Secondary Model](#secondary-model)). |
+| `extract_main` | boolean | `true` | Extract main content from HTML using Mozilla Readability. When disabled, returns the full page body. |
+| `prefer_llms_txt` | `"auto"` \| `"always"` \| `"never"` | `"auto"` | Prefer `/llms.txt` or `/llms-full.txt` over the page itself. `"auto"` probes only for docs-like domains (readthedocs, gitbook, netlify, vercel, etc.). |
+| `include_metadata` | boolean | `true` | Include YAML frontmatter with fetch metadata (status code, content type, charset, redirect chain, cache info, etc.). |
+| `save_binary` | boolean | `false` | Save binary payloads (images, PDFs, audio, video) to disk under the system temp dir. When disabled, binary content reports metadata-only. |
+
+## Output
+
+### Text content (HTML, plain text, llms.txt)
+
+Returns the fetched content in the requested `format`. When `include_metadata`
+is enabled (default), the response is prefixed with YAML frontmatter containing
+metadata about the fetch:
+
+```yaml
+---
+requested_url: "https://example.com/docs"
+final_url: "https://example.com/docs"
+canonical_url: "https://example.com/docs"
+status_code: 200
+source_content_type: "text/html"
+source_kind: "html"
+title: "Documentation"
+headings:
+  - "Getting Started"
+  - "API Reference"
+used_llms_txt: false
+extracted_main: true
+redirect_chain: []
+upgraded_to_https: true
+cache_hit: false
+word_count: 1420
+quality_signals: []
+truncated: false
+---
+```
+
+The `quality_signals` field flags potential issues:
+- `very_short_content` — fewer than 60 words
+- `possible_paywall` — content matches paywall/login keywords
+- `high_boilerplate_ratio` — large HTML-to-text ratio without Readability extraction
+
+### Binary content
+
+Binary responses (images, PDFs, audio, video) return metadata about the file:
+
+- Content type and size
+- Filename (from `Content-Disposition` or URL path)
+- Binary kind (`image`, `audio`, `video`, `pdf`, `binary`)
+
+Two modes:
+
+1. **Metadata-only** — content exceeds the download limit (2 MiB without
+   `save_binary`, 10 MiB with it). Reports size and type without the body.
+2. **Saved to disk** — when `save_binary=true`, the binary is written to
+   `<tmpdir>/opencode-smartfetch/<filename>` and the response includes the
+   filesystem path.
+
+### Blocked redirects
+
+When a cross-origin redirect is blocked by policy, the response explains which
+URL was attempted and provides the redirect URL so you can fetch it directly.
+
+## Secondary Model
+
+When a `prompt` parameter is supplied, `webfetch` can route the fetched content
+through a secondary (cheaper) model for focused extraction. This lets you ask
+questions like "summarize this page" or "extract the code examples" in one step.
+
+**How it works:**
+
+1. Content is fetched and cached normally.
+2. A temporary OpenCode session is created with all tools disabled.
+3. The fetched content and your prompt are sent to a secondary model.
+4. The session is cleaned up after the response.
+
+**Which model is used** (in priority order):
+
+1. `webfetch.model` (dedicated — highest priority, supports array for fallback)
+2. `small_model` from the OpenCode configuration (`opencode.json` / `opencode.jsonc`)
+3. The configured `explorer` agent model
+4. The configured `librarian` agent model
+
+The secondary model is called only when all of these are true:
+- A `prompt` parameter is provided
+- A secondary model is configured
+- The fetched content has at least 25 words
+
+If the secondary model fails (timeout, error, empty response), `webfetch`
+returns the raw fetched content as a graceful fallback.
+
+## Caching
+
+Fetches are cached in memory with an LRU cache (50 MiB max, 15-minute TTL).
+The cache key includes the URL plus behavior-affecting options (`extract_main`,
+`prefer_llms_txt`, `save_binary`), so changing these re-fetches the URL.
+
+**Revalidation:** Cache entries with `ETag` or `Last-Modified` headers support
+conditional revalidation. When a stale entry exists, `webfetch` sends
+`If-None-Match` / `If-Modified-Since` headers. A `304 Not Modified` response
+refreshes the TTL without re-downloading.
+
+**llms.txt validation:** Cached `llms.txt` results are validated — if the
+cached entry doesn't actually look like an llms.txt response (wrong path,
+HTML content, login page), it is evicted and re-fetched.
+
+## llms.txt Probing
+
+For documentation sites, `webfetch` probes for `/llms-full.txt` then `/llms.txt`
+before falling back to the page itself.
+
+**Probing behavior** depends on the `prefer_llms_txt` parameter:
+
+- `"auto"` (default) — probes only when the domain looks documentation-adjacent
+  (suffixes like `.readthedocs.io`, `.gitbook.io`, `docs.rs`; prefixes like
+  `docs.`, `developer.`, `dev.`, `wiki.`)
+- `"always"` — always probes; fails with a message if neither llms.txt variant
+  exists
+- `"never"` — skips probing entirely
+
+The probe respects cross-origin redirect policy (same origin only). If the
+`llms.txt` response is HTML or a login page, the probe is rejected.
+
+## Redirect Policy
+
+`webfetch` follows up to 10 redirects per request, but only within same-origin
+scopes. Cross-origin redirects are blocked and the caller is instructed to
+fetch the new URL directly.
+
+For URLs entered as `http://`, `webfetch` first tries `https://` and falls
+back to `http://` if the HTTPS attempt fails (connection error, blocked
+redirect, or non-2xx status).
+
+## Binary Detection
+
+Content type detection follows this flow:
+
+1. Explicit binary MIME types (`image/*`, `audio/*`, `video/*`,
+   `application/pdf`, `application/zip`, `application/octet-stream`) are
+   treated as binary.
+2. `application/octet-stream` and known text types are re-examined — the
+   first 2 KiB is scanned for null bytes and non-printable characters to
+   distinguish text from binary.
+3. Content declared as text/plain that looks like HTML is upgraded to
+   `text/html` for better content extraction.
+
+## Tool Timeouts
+
+- Default timeout: 30 seconds
+- Maximum timeout: 120 seconds
+- llms.txt probe timeout: capped at 8 seconds within the overall timeout
+- Multiple scoped timeouts run in parallel (llms.txt probing and page fetch
+  are independent within a single call)
+
+## Configuration
+
+### Disabling
+
+Set `webfetch.enabled` to `false` to skip registering the enhanced version and
+use OpenCode's built-in `webfetch` instead:
+
+```jsonc
+{
+  "webfetch": {
+    "enabled": false
+  }
+}
+```
+
+### Dedicated secondary model
+
+The `webfetch.model` option sets a dedicated model (or array of fallback
+models) for secondary-model summarization. Takes priority over all other model
+resolution sources. Accepts the same format as agent model configs:
+
+```jsonc
+{
+  "webfetch": {
+    "model": "openai/gpt-4o-mini"
+  }
+}
+```
+
+Multiple fallback models in priority order:
+
+```jsonc
+{
+  "webfetch": {
+    "model": ["openai/gpt-4o-mini", "anthropic/claude-3-haiku"]
+  }
+}
+```
+
+With optional variant:
+
+```jsonc
+{
+  "webfetch": {
+    "model": [
+      "openai/gpt-4o-mini",
+      { "id": "anthropic/claude-3-haiku", "variant": "low-latency" }
+    ]
+  }
+}
+```
+
+Each entry is tried in turn; the first to return usable text is used.
+
+### Secondary model fallback chain
+
+The [secondary model](#secondary-model) is resolved from these sources (in
+priority order):
+
+1. `webfetch.model` (dedicated — highest priority, supports array for fallback)
+2. `small_model` in the OpenCode config (`opencode.json` / `opencode.jsonc` at
+   project or user level)
+3. The plugin's `agents.explorer.model` config
+4. The plugin's `agents.librarian.model` config
+
+Example `opencode.jsonc`:
+
+```jsonc
+{
+  "small_model": "openai/gpt-4o-mini"
+}
+```
+
+Or in the plugin's `opencode.json` preset or project config:
+
+```jsonc
+{
+  "agents": {
+    "explorer": { "model": "anthropic/claude-3-haiku" },
+    "librarian": { "model": "openai/gpt-4o-mini" }
+  }
+}
+```
+
+### Permissions
+
+The `webfetch` permission can be configured in the plugin's permission rules.
+See [Configuration](configuration.md) for details.
+
+## Registration
+
+The tool is registered under the name `webfetch` in `src/index.ts`, which
+overrides OpenCode's built-in `webfetch` when this plugin is active.
+
+## Implementation
+
+The enhanced `webfetch` lives in `src/tools/smartfetch/` (the internal module is
+named "smartfetch", while the public tool name is `webfetch`). It is composed of
+these modules:
+
+| Module | Responsibility |
+|--------|---------------|
+| `tool.ts` | Entry point — permission prompts, cache lookup, llms.txt preference logic, binary-vs-text branching, metadata emission, secondary-model integration |
+| `network.ts` | URL normalization, redirect policy, charset/body decoding, header extraction, llms.txt probing, HTTP fetch with HTTPS upgrade fallback |
+| `utils.ts` | HTML extraction (Mozilla Readability + Turndown), heading cleanup, markdown/text cleaning, frontmatter generation, quality signal detection |
+| `cache.ts` | LRU cache keyed by URL + behavioral options, conditional revalidation, canonical URL aliasing, llms result invalidation |
+| `binary.ts` | Binary content persistence to disk, MIME-to-extension mapping, safe filename allocation |
+| `secondary-model.ts` | Dedicated webfetch/`small_model` config resolution, temporary session creation, content truncation, model fallback chain |
+| `constants.ts` | Timeouts, size limits, docs domain heuristics, binary MIME prefixes, tool description |

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

@@ -1185,6 +1185,50 @@
         }
         }
       }
       }
     },
     },
+    "webfetch": {
+      "type": "object",
+      "properties": {
+        "enabled": {
+          "default": true,
+          "description": "When false, skip registering this enhanced webfetch so OpenCode uses its built-in version.",
+          "type": "boolean"
+        },
+        "model": {
+          "description": "Dedicated model(s) for smartfetch secondary-model summarization. Same shape as agent model config (string, array of strings/objects with id+variant). Takes priority over small_model, agents.explorer.model, and agents.librarian.model.",
+          "anyOf": [
+            {
+              "type": "string"
+            },
+            {
+              "minItems": 1,
+              "type": "array",
+              "items": {
+                "anyOf": [
+                  {
+                    "type": "string"
+                  },
+                  {
+                    "type": "object",
+                    "properties": {
+                      "id": {
+                        "type": "string"
+                      },
+                      "variant": {
+                        "type": "string"
+                      }
+                    },
+                    "required": [
+                      "id"
+                    ]
+                  }
+                ]
+              }
+            }
+          ]
+        }
+      },
+      "additionalProperties": false
+    },
     "acpAgents": {
     "acpAgents": {
       "type": "object",
       "type": "object",
       "propertyNames": {
       "propertyNames": {

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

@@ -66,6 +66,60 @@ describe('loadPluginConfig', () => {
     expect(config.autoUpdate).toBe(false);
     expect(config.autoUpdate).toBe(false);
   });
   });
 
 
+  test('deep-merges webfetch settings across user and project configs', () => {
+    const userConfigPath = path.join(userConfigDir, 'opencode');
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(userConfigPath, { recursive: true });
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userConfigPath, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        webfetch: { model: 'user/provider-model' },
+      }),
+    );
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        webfetch: { enabled: true },
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir, { silent: true });
+
+    expect(config.webfetch).toEqual({
+      enabled: true,
+      model: 'user/provider-model',
+    });
+  });
+
+  test('does not let a defaulted project webfetch enabled override user false', () => {
+    const userConfigPath = path.join(userConfigDir, 'opencode');
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(userConfigPath, { recursive: true });
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(userConfigPath, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        webfetch: { enabled: false },
+      }),
+    );
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        webfetch: { model: 'project/provider-model' },
+      }),
+    );
+
+    const config = loadPluginConfig(projectDir, { silent: true });
+
+    expect(config.webfetch).toEqual({
+      enabled: false,
+      model: 'project/provider-model',
+    });
+  });
+
   test('validates auto image routing after project enables Observer', () => {
   test('validates auto image routing after project enables Observer', () => {
     const userConfigPath = path.join(userConfigDir, 'opencode');
     const userConfigPath = path.join(userConfigDir, 'opencode');
     const projectDir = path.join(tempDir, 'project');
     const projectDir = path.join(tempDir, 'project');

+ 33 - 1
src/config/loader.ts

@@ -3,7 +3,11 @@ import * as path from 'node:path';
 import { stripJsonComments } from '../cli/config-io';
 import { stripJsonComments } from '../cli/config-io';
 import { getConfigSearchDirs } from '../cli/paths';
 import { getConfigSearchDirs } from '../cli/paths';
 import { DEFAULT_DISABLED_AGENTS } from './constants';
 import { DEFAULT_DISABLED_AGENTS } from './constants';
-import { type PluginConfig, PluginConfigSchema } from './schema';
+import {
+  type PluginConfig,
+  PluginConfigSchema,
+  WebfetchConfigSchema,
+} from './schema';
 
 
 /**
 /**
  * Warning kinds produced during config loading.
  * Warning kinds produced during config loading.
@@ -141,6 +145,26 @@ function loadConfigFromPath(
       return null;
       return null;
     }
     }
 
 
+    // Zod applies webfetch.enabled's default while parsing each layer. Keep
+    // that default from masquerading as an explicitly configured override;
+    // the merged webfetch config is normalized after all layers are merged.
+    if (
+      result.data.webfetch &&
+      typeof rawConfig === 'object' &&
+      rawConfig !== null &&
+      'webfetch' in rawConfig &&
+      typeof rawConfig.webfetch === 'object' &&
+      rawConfig.webfetch !== null &&
+      !Array.isArray(rawConfig.webfetch) &&
+      !Object.hasOwn(rawConfig.webfetch, 'enabled')
+    ) {
+      const { enabled: _enabled, ...webfetch } = result.data.webfetch;
+      return {
+        ...result.data,
+        webfetch: webfetch as PluginConfig['webfetch'],
+      };
+    }
+
     return result.data;
     return result.data;
   } catch (error) {
   } catch (error) {
     // File doesn't exist or isn't readable - this is expected and fine
     // File doesn't exist or isn't readable - this is expected and fine
@@ -283,6 +307,10 @@ export function mergePluginConfigs(
     backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs),
     backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs),
     fallback: deepMerge(base.fallback, override.fallback),
     fallback: deepMerge(base.fallback, override.fallback),
     council: deepMerge(base.council, override.council),
     council: deepMerge(base.council, override.council),
+    webfetch: deepMerge(
+      base.webfetch as Record<string, unknown> | undefined,
+      override.webfetch as Record<string, unknown> | undefined,
+    ) as PluginConfig['webfetch'],
     acpAgents: deepMerge(base.acpAgents, override.acpAgents),
     acpAgents: deepMerge(base.acpAgents, override.acpAgents),
     companion: deepMerge(
     companion: deepMerge(
       base.companion as Record<string, unknown> | undefined,
       base.companion as Record<string, unknown> | undefined,
@@ -364,6 +392,10 @@ export function loadPluginConfig(
     config = mergePluginConfigs(config, projectConfig);
     config = mergePluginConfigs(config, projectConfig);
   }
   }
 
 
+  if (config.webfetch) {
+    config.webfetch = WebfetchConfigSchema.parse(config.webfetch);
+  }
+
   // Override preset from environment variable if set
   // Override preset from environment variable if set
   const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;
   const envPreset = process.env.OH_MY_OPENCODE_SLIM_PRESET;
   if (envPreset) {
   if (envPreset) {

+ 24 - 0
src/config/schema.test.ts

@@ -40,6 +40,30 @@ describe('PluginConfigSchema image_routing', () => {
   });
   });
 });
 });
 
 
+describe('PluginConfigSchema webfetch', () => {
+  it('defaults the enhanced webfetch tool to enabled', () => {
+    const result = PluginConfigSchema.safeParse({ webfetch: {} });
+
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.webfetch?.enabled).toBe(true);
+    }
+  });
+
+  it('accepts dedicated model fallback entries with variants', () => {
+    const result = PluginConfigSchema.safeParse({
+      webfetch: {
+        model: [
+          'openai/gpt-4o-mini',
+          { id: 'anthropic/claude-3-haiku', variant: 'low-latency' },
+        ],
+      },
+    });
+
+    expect(result.success).toBe(true);
+  });
+});
+
 describe('PluginConfigSchema backgroundJobs', () => {
 describe('PluginConfigSchema backgroundJobs', () => {
   it('defaults board injection to the legacy latest strategy', () => {
   it('defaults board injection to the legacy latest strategy', () => {
     const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });
     const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });

+ 19 - 0
src/config/schema.ts

@@ -295,6 +295,24 @@ export const CompanionConfigSchema = z.object({
 
 
 export type CompanionConfig = z.infer<typeof CompanionConfigSchema>;
 export type CompanionConfig = z.infer<typeof CompanionConfigSchema>;
 
 
+export const WebfetchConfigSchema = z
+  .object({
+    enabled: z
+      .boolean()
+      .default(true)
+      .describe(
+        'When false, skip registering this enhanced webfetch so OpenCode uses its built-in version.',
+      ),
+    model: AgentOverrideConfigSchema.shape.model.describe(
+      'Dedicated model(s) for smartfetch secondary-model summarization. ' +
+        'Same shape as agent model config (string, array of strings/objects with id+variant). ' +
+        'Takes priority over small_model, agents.explorer.model, and agents.librarian.model.',
+    ),
+  })
+  .strict();
+
+export type WebfetchConfig = z.infer<typeof WebfetchConfigSchema>;
+
 export const AcpAgentPermissionModeSchema = z.enum(['ask', 'allow', 'reject']);
 export const AcpAgentPermissionModeSchema = z.enum(['ask', 'allow', 'reject']);
 
 
 export const MAX_ACP_TIMEOUT_MS = 2_147_483_647;
 export const MAX_ACP_TIMEOUT_MS = 2_147_483_647;
@@ -417,6 +435,7 @@ export const PluginConfigSchema = z
     fallback: FailoverConfigSchema.optional(),
     fallback: FailoverConfigSchema.optional(),
     council: CouncilConfigSchema.optional(),
     council: CouncilConfigSchema.optional(),
     companion: CompanionConfigSchema.optional(),
     companion: CompanionConfigSchema.optional(),
+    webfetch: WebfetchConfigSchema.optional(),
     acpAgents: AcpAgentsConfigSchema.optional(),
     acpAgents: AcpAgentsConfigSchema.optional(),
   })
   })
   .superRefine((value, ctx) => {
   .superRefine((value, ctx) => {

+ 3 - 0
src/health-check.test.ts

@@ -9,6 +9,9 @@ describe('plugin health thresholds', () => {
       4,
       4,
     );
     );
     expect(minimumExpectedToolCount(['unknown_tool'])).toBe(5);
     expect(minimumExpectedToolCount(['unknown_tool'])).toBe(5);
+    expect(minimumExpectedToolCount([], false)).toBe(4);
+    expect(minimumExpectedToolCount(['wait_for_user'], false)).toBe(3);
+    expect(minimumExpectedToolCount(['webfetch'], false)).toBe(4);
   });
   });
 
 
   test('never throws when disabledTools is not an array', () => {
   test('never throws when disabledTools is not an array', () => {

+ 11 - 2
src/health-check.ts

@@ -36,10 +36,12 @@ const BASELINE_TOOL_NAMES = new Set([
  * @param disabledTools - Tool names disabled via config; non-array/malformed
  * @param disabledTools - Tool names disabled via config; non-array/malformed
  *   values (which should never occur post-validation, but are not trusted at
  *   values (which should never occur post-validation, but are not trusted at
  *   runtime) are treated as "nothing disabled".
  *   runtime) are treated as "nothing disabled".
+ * @param webfetchEnabled - Whether the enhanced webfetch tool is registered.
  * @returns The adjusted minimum expected tool count
  * @returns The adjusted minimum expected tool count
  */
  */
 export function minimumExpectedToolCount(
 export function minimumExpectedToolCount(
   disabledTools: readonly string[] = [],
   disabledTools: readonly string[] = [],
+  webfetchEnabled = true,
 ): number {
 ): number {
   // Config values come from user-edited JSON/JSONC (and can be re-derived
   // Config values come from user-edited JSON/JSONC (and can be re-derived
   // via runtime preset switches); never trust the declared type at
   // via runtime preset switches); never trust the declared type at
@@ -47,7 +49,14 @@ export function minimumExpectedToolCount(
   // init if this isn't actually an array.
   // init if this isn't actually an array.
   const safeDisabledTools = Array.isArray(disabledTools) ? disabledTools : [];
   const safeDisabledTools = Array.isArray(disabledTools) ? disabledTools : [];
   const disabledBaselineTools = new Set(
   const disabledBaselineTools = new Set(
-    safeDisabledTools.filter((toolName) => BASELINE_TOOL_NAMES.has(toolName)),
+    safeDisabledTools.filter(
+      (toolName) =>
+        BASELINE_TOOL_NAMES.has(toolName) &&
+        (toolName !== 'webfetch' || webfetchEnabled),
+    ),
+  );
+  const webfetchAdjustment = webfetchEnabled ? 0 : 1;
+  return (
+    HEALTH_CHECK.minTools - webfetchAdjustment - disabledBaselineTools.size
   );
   );
-  return HEALTH_CHECK.minTools - disabledBaselineTools.size;
 }
 }

+ 30 - 4
src/index.ts

@@ -264,7 +264,30 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       Object.keys(config.acpAgents ?? {}).length > 0
       Object.keys(config.acpAgents ?? {}).length > 0
         ? { acp_run: createAcpRunTool(config.acpAgents) }
         ? { acp_run: createAcpRunTool(config.acpAgents) }
         : {};
         : {};
-    webfetch = createWebfetchTool(ctx);
+    const webfetchModel = config.webfetch?.model;
+    const webfetchModels = (() => {
+      if (!webfetchModel) return undefined;
+      const entries = Array.isArray(webfetchModel)
+        ? webfetchModel
+        : [webfetchModel];
+      type ModelRefInput = string | { id: string; variant?: string };
+      const models: Array<{ id: string; variant?: string }> = [];
+      for (const entry of entries as ModelRefInput[]) {
+        const id = typeof entry === 'string' ? entry : entry.id;
+        if (!id) continue;
+        models.push({
+          id,
+          ...(typeof entry === 'object' && entry.variant
+            ? { variant: entry.variant }
+            : {}),
+        });
+      }
+      return models.length > 0 ? models : undefined;
+    })();
+    webfetch = createWebfetchTool(ctx, {
+      binaryDir: undefined,
+      webfetchModels,
+    });
     backgroundJobBoard = new BackgroundJobBoard({
     backgroundJobBoard = new BackgroundJobBoard({
       maxReusablePerAgent:
       maxReusablePerAgent:
         config.backgroundJobs?.maxSessionsPerAgent ??
         config.backgroundJobs?.maxSessionsPerAgent ??
@@ -432,11 +455,12 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         taskSessionManagerHook.beginUserWait(sessionID),
         taskSessionManagerHook.beginUserWait(sessionID),
     });
     });
 
 
+    const shouldRegisterWebfetch = config.webfetch?.enabled !== false;
     tools = {
     tools = {
       ...cancelTaskTools,
       ...cancelTaskTools,
       ...waitForUserTools,
       ...waitForUserTools,
       ...acpRunTools,
       ...acpRunTools,
-      webfetch,
+      ...(shouldRegisterWebfetch ? { webfetch } : {}),
       ast_grep_search,
       ast_grep_search,
       ast_grep_replace,
       ast_grep_replace,
     };
     };
@@ -471,8 +495,10 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     Array.isArray(config.disabled_mcps) && config.disabled_mcps.length > 0
     Array.isArray(config.disabled_mcps) && config.disabled_mcps.length > 0
       ? 0
       ? 0
       : HEALTH_CHECK.minMcps;
       : HEALTH_CHECK.minMcps;
-  const toolThreshold = minimumExpectedToolCount(config.disabled_tools);
-
+  const toolThreshold = minimumExpectedToolCount(
+    config.disabled_tools,
+    config.webfetch?.enabled !== false,
+  );
   if (
   if (
     agentCount < HEALTH_CHECK.minAgents ||
     agentCount < HEALTH_CHECK.minAgents ||
     toolCount < toolThreshold ||
     toolCount < toolThreshold ||

+ 53 - 1
src/tools/smartfetch/secondary-model.test.ts

@@ -1,5 +1,12 @@
 import { afterEach, describe, expect, mock, test } from 'bun:test';
 import { afterEach, describe, expect, mock, test } from 'bun:test';
-import { _testConfig, runSecondaryModelWithFallback } from './secondary-model';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import {
+  _testConfig,
+  readSecondaryModelFromConfig,
+  runSecondaryModelWithFallback,
+} from './secondary-model';
 import type { SecondaryModel } from './types';
 import type { SecondaryModel } from './types';
 
 
 type PromptStep = {
 type PromptStep = {
@@ -56,6 +63,51 @@ describe('smartfetch/secondary-model', () => {
     mock.restore();
     mock.restore();
   });
   });
 
 
+  test('gives dedicated webfetch models precedence over fallback sources', async () => {
+    const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'smartfetch-test-'));
+    const projectConfigDir = path.join(tempDir, '.opencode');
+    const userConfigDir = path.join(tempDir, 'user-config');
+    const originalEnv = { ...process.env };
+
+    try {
+      fs.mkdirSync(projectConfigDir, { recursive: true });
+      fs.mkdirSync(path.join(userConfigDir, 'opencode'), { recursive: true });
+      fs.writeFileSync(
+        path.join(projectConfigDir, 'opencode.json'),
+        JSON.stringify({ small_model: 'small/provider-model' }),
+      );
+      fs.writeFileSync(
+        path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+        JSON.stringify({
+          agents: {
+            explorer: { model: 'explorer/provider-model' },
+            librarian: { model: 'librarian/provider-model' },
+          },
+        }),
+      );
+      delete process.env.OPENCODE_CONFIG_DIR;
+      process.env.XDG_CONFIG_HOME = userConfigDir;
+
+      await expect(
+        readSecondaryModelFromConfig(tempDir, [
+          { id: 'dedicated/provider-model', variant: 'fast' },
+        ]),
+      ).resolves.toEqual([
+        {
+          providerID: 'dedicated',
+          modelID: 'provider-model',
+          variant: 'fast',
+        },
+        { providerID: 'small', modelID: 'provider-model' },
+        { providerID: 'explorer', modelID: 'provider-model' },
+        { providerID: 'librarian', modelID: 'provider-model' },
+      ]);
+    } finally {
+      process.env = originalEnv;
+      fs.rmSync(tempDir, { recursive: true, force: true });
+    }
+  });
+
   test('falls back when the first model returns empty text', async () => {
   test('falls back when the first model returns empty text', async () => {
     const client = createMockClient([
     const client = createMockClient([
       { text: '   ' },
       { text: '   ' },

+ 29 - 11
src/tools/smartfetch/secondary-model.ts

@@ -72,26 +72,36 @@ async function readEffectiveOpenCodeConfig(directory: string) {
   };
   };
 }
 }
 
 
-export async function readSecondaryModelFromConfig(directory: string) {
+export async function readSecondaryModelFromConfig(
+  directory: string,
+  webfetchModels?: Array<{ id: string; variant?: string }>,
+) {
   try {
   try {
     const models: SecondaryModel[] = [];
     const models: SecondaryModel[] = [];
     const seen = new Set<string>();
     const seen = new Set<string>();
-    const pushModel = (value: unknown) => {
-      if (typeof value !== 'string') return;
-      const parsedModel = parseModelRef(value);
-      if (!parsedModel) return;
-      const key = `${parsedModel.providerID}/${parsedModel.modelID}`;
+    const addModel = (model: SecondaryModel) => {
+      const key = `${model.providerID}/${model.modelID}${model.variant ? `#${model.variant}` : ''}`;
       if (seen.has(key)) return;
       if (seen.has(key)) return;
       seen.add(key);
       seen.add(key);
-      models.push(parsedModel);
+      models.push(model);
     };
     };
 
 
+    // Dedicated webfetch model(s) take highest priority, in order
+    if (webfetchModels) {
+      for (const ref of webfetchModels) {
+        const parsedModel = parseModelRef(ref.id);
+        if (!parsedModel) continue;
+        addModel({ ...parsedModel, variant: ref.variant });
+      }
+    }
+
     const opencodeConfig = await readEffectiveOpenCodeConfig(directory);
     const opencodeConfig = await readEffectiveOpenCodeConfig(directory);
-    pushModel(
+    const parsedSmall = parseModelRef(
       typeof opencodeConfig.small_model === 'string'
       typeof opencodeConfig.small_model === 'string'
         ? opencodeConfig.small_model
         ? opencodeConfig.small_model
         : undefined,
         : undefined,
     );
     );
+    if (parsedSmall) addModel(parsedSmall);
 
 
     const pluginConfig = loadPluginConfig(directory);
     const pluginConfig = loadPluginConfig(directory);
     const explorerModel = pickAgentModelRef(
     const explorerModel = pickAgentModelRef(
@@ -101,8 +111,15 @@ export async function readSecondaryModelFromConfig(directory: string) {
       pluginConfig.agents?.librarian?.model,
       pluginConfig.agents?.librarian?.model,
     );
     );
 
 
-    pushModel(explorerModel);
-    pushModel(librarianModel);
+    const parsedExplorer = explorerModel
+      ? parseModelRef(explorerModel)
+      : undefined;
+    if (parsedExplorer) addModel(parsedExplorer);
+
+    const parsedLibrarian = librarianModel
+      ? parseModelRef(librarianModel)
+      : undefined;
+    if (parsedLibrarian) addModel(parsedLibrarian);
 
 
     return models;
     return models;
   } catch {
   } catch {
@@ -255,7 +272,8 @@ async function runSecondaryModel(
         path: { id: sessionId },
         path: { id: sessionId },
         query: { directory },
         query: { directory },
         body: {
         body: {
-          model,
+          model: { providerID: model.providerID, modelID: model.modelID },
+          ...(model.variant ? { variant: model.variant } : {}),
           system:
           system:
             'Answer only from the supplied content. Do not use tools or outside knowledge.',
             'Answer only from the supplied content. Do not use tools or outside knowledge.',
           tools: disabledTools,
           tools: disabledTools,

+ 2 - 1
src/tools/smartfetch/tool.ts

@@ -102,6 +102,7 @@ export function createWebfetchTool(
     async execute(args, ctx) {
     async execute(args, ctx) {
       const secondaryModels = await readSecondaryModelFromConfig(
       const secondaryModels = await readSecondaryModelFromConfig(
         ctx.directory || pluginCtx.directory,
         ctx.directory || pluginCtx.directory,
+        options.webfetchModels,
       );
       );
       const normalized = normalizeUrl(args.url);
       const normalized = normalizeUrl(args.url);
       const url = new URL(normalized.url);
       const url = new URL(normalized.url);
@@ -805,7 +806,7 @@ export function createWebfetchTool(
               secondary_model_input_truncated: secondaryRun.inputTruncated,
               secondary_model_input_truncated: secondaryRun.inputTruncated,
               secondary_model_input_chars: secondaryRun.inputChars,
               secondary_model_input_chars: secondaryRun.inputChars,
               secondary_model_source_chars: secondaryRun.sourceChars,
               secondary_model_source_chars: secondaryRun.sourceChars,
-              secondary_model: `${secondaryRun.model.providerID}/${secondaryRun.model.modelID}`,
+              secondary_model: `${secondaryRun.model.providerID}/${secondaryRun.model.modelID}${secondaryRun.model.variant ? `#${secondaryRun.model.variant}` : ''}`,
             })
             })
           : '';
           : '';
         const secondaryRaw =
         const secondaryRaw =

+ 14 - 0
src/tools/smartfetch/types.ts

@@ -1,10 +1,24 @@
+export type ModelRef = {
+  /** Provider/model string (e.g. "openai/gpt-4o-mini"). */
+  id: string;
+  /** Optional model variant annotation. */
+  variant?: string;
+};
+
 export type SmartfetchOptions = {
 export type SmartfetchOptions = {
   binaryDir?: string;
   binaryDir?: string;
+  /**
+   * Dedicated model(s) for secondary-model summarization.
+   * Each entry is tried in order; the first to return usable text is used.
+   */
+  webfetchModels?: ModelRef[];
 };
 };
 
 
 export type SecondaryModel = {
 export type SecondaryModel = {
   providerID: string;
   providerID: string;
   modelID: string;
   modelID: string;
+  /** Optional model variant passed at the body level. */
+  variant?: string;
 };
 };
 
 
 export type RedirectStep = {
 export type RedirectStep = {