From c5ae461bd23d60296ecb84c2ea3a3188fc267e52 Mon Sep 17 00:00:00 2001 From: Oren-Schmerling Date: Tue, 20 Oct 2026 22:08:49 -0400 Subject: [PATCH] fix: accept slashes, tildes, and colons in fallback model IDs --- README.md | 2 ++ src/config/agent-loader.ts | 2 +- src/config/schema.ts | 2 +- test/agent-loader.test.ts | 71 ++++++++++++++++++++++++++++++++++++++ test/config.test.ts | 59 +++++++++++++++++++++++++++++++ 5 files changed, 134 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 0ee84d6..7a14fee 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,8 @@ Minimal example: } ``` +Model IDs may contain `/`, `~`, and `:` (for example OpenRouter keys such as `openrouter/~deepseek/deepseek-flash-latest`). + Most important knobs: - `defaults.fallbackOn` - which error categories trigger fallback diff --git a/src/config/agent-loader.ts b/src/config/agent-loader.ts index a0ded15..8140f6d 100644 --- a/src/config/agent-loader.ts +++ b/src/config/agent-loader.ts @@ -4,7 +4,7 @@ import { homedir } from "os"; import { basename, extname, isAbsolute, join, relative, resolve } from "path"; import type { AgentConfig } from "../types.js"; -const MODEL_KEY_RE = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._-]{1,100}$/; +const MODEL_KEY_RE = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._~\/:-]{1,200}$/; function isPathInside(baseDir: string, targetPath: string): boolean { const rel = relative(baseDir, targetPath); diff --git a/src/config/schema.ts b/src/config/schema.ts index 47885ef..03b9662 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -3,7 +3,7 @@ import { isAbsolute, relative, resolve } from "path"; import { z } from "zod"; import { DEFAULT_CONFIG } from "./defaults.js"; -const MODEL_KEY_RE = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._-]{1,100}$/; +const MODEL_KEY_RE = /^[a-zA-Z0-9_-]{1,100}\/[a-zA-Z0-9._~\/:-]{1,200}$/; const home = resolve(homedir()); function formatPath(path: Array): string { diff --git a/test/agent-loader.test.ts b/test/agent-loader.test.ts index 815b26d..5ca3104 100644 --- a/test/agent-loader.test.ts +++ b/test/agent-loader.test.ts @@ -142,6 +142,77 @@ fallback: ]); }); + it("accepts multi-segment, tilde, and colon model IDs from agent files", () => { + mkdirSync(agentsDir, { recursive: true }); + writeFileSync( + join(agentsDir, "openrouter.md"), + `--- +name: OpenRouterAgent +fallback: + models: + - opencode/nemotron-3-ultra-free + - openrouter/openrouter/free + - openrouter/~deepseek/deepseek-flash-latest + - openrouter/anthropic/claude-3.5-sonnet:beta +--- +` + ); + + const configs = loadAgentFallbackConfigs(dir, dir); + expect(configs["OpenRouterAgent"]).toBeDefined(); + expect(configs["OpenRouterAgent"].fallbackModels).toEqual([ + "opencode/nemotron-3-ultra-free", + "openrouter/openrouter/free", + "openrouter/~deepseek/deepseek-flash-latest", + "openrouter/anthropic/claude-3.5-sonnet:beta", + ]); + }); + + it("skips malformed keys but keeps OpenRouter-style entries", () => { + mkdirSync(agentsDir, { recursive: true }); + writeFileSync( + join(agentsDir, "malformed.md"), + `--- +name: MalformedAgent +fallback: + models: + - "" + - opencode + - opencode/ + - /model + - "opencode/ spaced" + - "opencode/ model" + - ${"p".repeat(101)}/model + - openrouter/~deepseek/deepseek-flash-latest +--- +` + ); + + const configs = loadAgentFallbackConfigs(dir, dir); + expect(configs["MalformedAgent"]).toBeDefined(); + expect(configs["MalformedAgent"].fallbackModels).toEqual([ + "openrouter/~deepseek/deepseek-flash-latest", + ]); + }); + + it("accepts a model ID longer than 100 characters up to 200 from agent files", () => { + mkdirSync(agentsDir, { recursive: true }); + const key = `opencode/${"m".repeat(150)}`; + writeFileSync( + join(agentsDir, "long-id.md"), + `--- +name: LongIdAgent +fallback: + models: + - ${key} +--- +` + ); + + const configs = loadAgentFallbackConfigs(dir, dir); + expect(configs["LongIdAgent"].fallbackModels).toEqual([key]); + }); + it("skips file entirely when all model keys are invalid", () => { mkdirSync(agentsDir, { recursive: true }); writeFileSync( diff --git a/test/config.test.ts b/test/config.test.ts index b964c35..785ec0e 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -27,6 +27,65 @@ describe("parseConfig", () => { expect(warnings.length).toBeGreaterThan(0); }); + it("accepts multi-segment, tilde, and colon model IDs", () => { + const keys = [ + "opencode/nemotron-3-ultra-free", + "openrouter/openrouter/free", + "openrouter/~deepseek/deepseek-flash-latest", + "openrouter/anthropic/claude-3.5-sonnet:beta", + ]; + const raw = { + agents: { "*": { fallbackModels: keys } }, + }; + const { config, warnings } = parseConfig(raw); + expect(warnings).toHaveLength(0); + expect(config.agents?.["*"].fallbackModels).toEqual(keys); + }); + + it("keeps an OpenRouter-style chain instead of dropping it", () => { + const raw = { + agents: { + build: { + fallbackModels: [ + "openrouter/~deepseek/deepseek-flash-latest", + "openrouter/openrouter/free", + ], + }, + }, + }; + const { config, warnings } = parseConfig(raw); + expect(warnings).toHaveLength(0); + expect(config.agents?.build.fallbackModels).toEqual([ + "openrouter/~deepseek/deepseek-flash-latest", + "openrouter/openrouter/free", + ]); + }); + + it("rejects malformed model keys", () => { + const invalid = [ + "", + "opencode", + "opencode/", + "/model", + "opencode/ spaced", + "opencode/ model", + `${"p".repeat(101)}/model`, + ]; + for (const key of invalid) { + const raw = { agents: { "*": { fallbackModels: [key] } } }; + const { warnings } = parseConfig(raw); + expect(warnings.length).toBeGreaterThan(0); + } + }); + + it("accepts a model ID longer than 100 characters up to 200", () => { + const key = `opencode/${"m".repeat(150)}`; + const raw = { agents: { "*": { fallbackModels: [key] } } }; + const { config, warnings } = parseConfig(raw); + expect(warnings).toHaveLength(0); + expect(config.agents?.["*"].fallbackModels).toEqual([key]); + }); + it("rejects cooldownMs below minimum", () => { const raw = { defaults: { cooldownMs: 100 },