Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/config/agent-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PropertyKey>): string {
Expand Down
71 changes: 71 additions & 0 deletions test/agent-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
59 changes: 59 additions & 0 deletions test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down