Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ dist/
.slim/deepwork/
.slim/clonedeps/
docs/superpowers/
.vscode/
34 changes: 17 additions & 17 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

## OVERVIEW

TypeScript ESM plugin for OpenCode. Runtime accepts explicit valid live 9router IDs, regardless of optional `kind`, and injects provider/model config without hardcoded lists in `opencode.json`. Non-LLM routes may appear. Reviewed static catalog supplies canonical identity, reasoning, and variants. `models.dev` enriches allowlisted metadata only. Unmatched models use conservative fallback.
TypeScript ESM plugin for OpenCode. Runtime accepts explicit valid live 9router IDs, regardless of optional `kind`, preserves bounded normalized live metadata, and injects provider/model config without hardcoded lists in `opencode.json`. Non-LLM routes may appear. Reviewed static catalog supplies canonical lookup, fallback reasoning, and variants. `models.dev` enriches nonboolean metadata only. Unmatched models use conservative fallback.

## STRUCTURE

Expand All @@ -32,19 +32,19 @@ opencode-9router-plus/

## WHERE TO LOOK

| Task | Location | Notes |
| -------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------------- |
| Runtime provider injection and listing | `src/index.ts` | Uses live `${baseUrl}/models`; default base URL ends in `/v1`, with compatibility fallbacks. |
| Model entry mapping | `src/model-mapper.ts` | Applies catalog capabilities, optional metadata enrichment, safe fallback. |
| Catalog route matching | `src/llm-catalog.ts` | Validates catalog and matches runtime route IDs. |
| Reasoning variants | `src/capability-resolver.ts` | Resolves allowed static catalog variants. |
| Generated catalog | `src/generated/9router-llm-catalog.ts` | Reviewed static catalog output. |
| Catalog extraction | `scripts/extract-9router-llm-catalog.ts` | Extracts only audited upstream inputs. |
| Upstream watch | renderer and workflow | Watch renders reports; audited refresh is separate. |
| Metadata enrichment | `src/models-dev.ts`, `src/cache.ts` | `models.dev` metadata only; cache TTL 24h. |
| Config CLI | `src/cli.ts` | Safe install, check, uninstall, JSONC refusal. |
| Tests | `tests/` | Run with `bun test`. |
| Public docs | `README.md` | Install, env, catalog, diagnostics, uninstall. |
| Task | Location | Notes |
| -------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Runtime provider injection and listing | `src/index.ts` | Uses live `${baseUrl}/models`, preserves bounded normalized metadata; default base URL ends in `/v1`, with compatibility fallbacks. |
| Model entry mapping | `src/model-mapper.ts` | Applies canonical catalog lookup, fallback reasoning/variants, optional metadata enrichment, safe fallback. |
| Catalog route matching | `src/llm-catalog.ts` | Broadly validates catalog structure and matches runtime route IDs. |
| Reasoning variants | `src/capability-resolver.ts` | Resolves allowed static catalog variants. |
| Generated catalog | `src/generated/9router-llm-catalog.ts` | Reviewed static catalog output. |
| Catalog extraction | `scripts/extract-9router-llm-catalog.ts` | Extracts only audited upstream inputs. |
| Upstream watch | renderer and workflow | Watch renders reports; audited refresh is separate. |
| Metadata enrichment | `src/models-dev.ts`, `src/cache.ts` | `models.dev` metadata only; cache TTL 24h. |
| Config CLI | `src/cli.ts` | Safe install, check, uninstall, JSONC refusal. |
| Tests | `tests/` | Run with `bun test`. |
| Public docs | `README.md` | Install, env, catalog, diagnostics, uninstall. |

## CODE MAP

Expand All @@ -62,8 +62,8 @@ opencode-9router-plus/
- ESM package: local TypeScript imports use `.js` suffix.
- TypeScript: ES2022, NodeNext, declarations, strict mode, unused local and parameter checks.
- Runtime env: `OPENCODE_9ROUTER_URL`, `OPENCODE_9ROUTER_API_KEY`, `OPENCODE_9ROUTER_TIMEOUT_MS`.
- Live discovery trusts only explicit valid `id`; `kind` is optional and ignored. Static reviewed catalog decides canonical identity, reasoning, and variants.
- `models.dev` uses exact canonical/provider metadata lookup only. Reviewed Codex routes canonicalize to `openai` for metadata. Non-catalog routes may use global metadata only when exactly one key shares its exact case-sensitive final path segment. It does not decide listing, capabilities, route ownership, reasoning, or variants. Unmatched models use all-false conservative fallback.
- Live discovery trusts only explicit valid `id`; `kind` is optional and ignored. It preserves bounded normalized name, capabilities, and limits. Live values take priority for those fields; reviewed static catalog supplies canonical lookup, fallback reasoning, and variants.
- `models.dev` uses exact canonical/provider metadata lookup only. Reviewed Codex routes canonicalize to `openai` for metadata. Non-catalog routes may use global metadata only when exactly one key shares its exact case-sensitive final path segment. It supplies nonboolean metadata and atomic limits only after complete live limit pairs; it has no boolean capability authority. Unmatched models use all-false conservative fallback.
- CLI config writes back up existing files and write atomically. Commented `.jsonc` fallback edits are refused.
- Prefer `bun run check` for Prettier format check, Biome lint, unused-code, typecheck, and deterministic tests. `bun run format` writes source, config, and docs formatting without lockfiles or `dist/`. `bun run build` creates `dist/`. Use `bun run test:live` only with `OPENCODE_9ROUTER_API_KEY`. Release runs `bun run check` before `npm publish`; `prepublishOnly` retains `npm run clean && npm run build`.

Expand All @@ -79,7 +79,7 @@ opencode-9router-plus/

## NOTES

- Catalog route matching runs before models.dev enrichment. Provider metadata is preferred field-by-field; global metadata fills missing fields. Exact lookup rejects ambiguity. Models without unambiguous metadata retain conservative fallback.
- Catalog route matching runs before models.dev enrichment. Live name, capabilities, and limits take precedence; provider `models.dev` metadata is preferred field-by-field, with global metadata filling missing nonboolean fields. Exact lookup rejects ambiguity. Models without unambiguous metadata retain conservative fallback.
- Catalog extractor, upstream watch renderer, runtime, and mapping are covered by Bun tests.
- Upstream watch workflow reports changes only. It does not refresh catalog, publish, tag, or release.
- `models.dev` cache: `~/.cache/opencode-9router-plus/models-dev-api.json` (5 MiB cap) and `~/.cache/opencode-9router-plus/models-dev-models.json` (1 MiB cap).
Expand Down
146 changes: 127 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,25 @@ import {
type OpenCodeModelEntry,
} from "./model-mapper.js";
import { createModelsDevClient, type ModelsDevClient } from "./models-dev.js";
export type AcceptedDiscoveryEntry = { id: string; kind: "llm" };

type LiveModelMetadata = {
name?: string;
capabilities?: {
reasoning?: boolean;
tools?: boolean;
vision?: boolean;
pdf?: boolean;
contextWindow?: number;
maxOutput?: number;
};
context_length?: number;
max_completion_tokens?: number;
};
export type AcceptedDiscoveryEntry = {
id: string;
kind: "llm";
live?: LiveModelMetadata;
};
type AnyCfg = Record<string, any>;
export interface PluginDependencies {
env?: NodeJS.ProcessEnv;
Expand All @@ -15,7 +33,7 @@ export interface PluginDependencies {
apiKey: string,
): Promise<unknown>;
resolveModel(
fullId: string,
input: { id: string; live?: LiveModelMetadata },
client: ModelsDevClient,
): Promise<OpenCodeModelEntry>;
modelsDevClient: ModelsDevClient;
Expand All @@ -30,26 +48,81 @@ const safe = (id: unknown): id is string =>
id === id.trim() &&
![...id].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127) &&
!["__proto__", "prototype", "constructor"].includes(id);
const text = (value: unknown): value is string =>
typeof value === "string" &&
value.length > 0 &&
value.length <= 512 &&
value === value.trim() &&
![...value].some((char) => {
const code = char.charCodeAt(0);
return code < 32 || code === 127;
});
const boolean = (value: unknown) =>
value === true || value === "true"
? true
: value === false || value === "false"
? false
: undefined;
const positiveSafeInt = (value: unknown) => {
const n =
typeof value === "number"
? value
: typeof value === "string" && /^[0-9]+$/.test(value)
? Number(value)
: NaN;
return Number.isSafeInteger(n) && n > 0 ? n : undefined;
};
const value = (record: object, key: string) => {
const descriptor = Object.getOwnPropertyDescriptor(record, key);
return descriptor && "value" in descriptor ? descriptor.value : undefined;
};
const record = (value: unknown): value is object =>
!!value && typeof value === "object" && !Array.isArray(value);
function liveMetadata(input: object): LiveModelMetadata {
if (Object.keys(input).length > 32) return {};
const capabilities = value(input, "capabilities");
if (record(capabilities) && Object.keys(capabilities).length > 32) return {};
const live: LiveModelMetadata = {};
const name = value(input, "name");
if (text(name)) live.name = name;
if (record(capabilities) && Object.keys(capabilities).length <= 32) {
const parsed: NonNullable<LiveModelMetadata["capabilities"]> = {};
for (const key of ["reasoning", "tools", "vision", "pdf"] as const) {
const normalized = boolean(value(capabilities, key));
if (normalized !== undefined) parsed[key] = normalized;
}
for (const key of ["contextWindow", "maxOutput"] as const) {
const normalized = positiveSafeInt(value(capabilities, key));
if (normalized !== undefined) parsed[key] = normalized;
}
if (Object.keys(parsed).length) live.capabilities = parsed;
}
for (const key of ["context_length", "max_completion_tokens"] as const) {
const normalized = positiveSafeInt(value(input, key));
if (normalized !== undefined) live[key] = normalized;
}
return live;
}
export function acceptDiscoveryEntries(
json: unknown,
): AcceptedDiscoveryEntry[] {
const values: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && Array.isArray((json as any).models)
? (json as any).models
: json && typeof json === "object" && Array.isArray((json as any).data)
? (json as any).data
: record(json) && Array.isArray(value(json, "models"))
? value(json, "models")
: record(json) && Array.isArray(value(json, "data"))
? value(json, "data")
: [];
if (values.length > 2000) return [];
const seen = new Set<string>();
return values.flatMap((v) =>
!v ||
typeof v !== "object" ||
Array.isArray(v) ||
!record(v) ||
!own(v, "id") ||
!safe((v as any).id) ||
seen.has((v as any).id)
!safe(value(v, "id")) ||
seen.has(value(v, "id"))
? []
: (seen.add((v as any).id), [{ id: (v as any).id, kind: "llm" }]),
: (seen.add(value(v, "id")),
[{ id: value(v, "id"), kind: "llm", live: liveMetadata(v) }]),
);
}
export const extractDiscoveryEntries = acceptDiscoveryEntries;
Expand All @@ -65,7 +138,31 @@ async function fetchJson(
: { Accept: "application/json" },
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
const reader = r.body?.getReader();
if (!reader) return JSON.parse(await r.text());
const chunks: Uint8Array[] = [];
let bytes = 0;
try {
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > 5 * 1024 * 1024) throw new Error("Response too large");
chunks.push(chunk.value);
}
} catch (error) {
await reader.cancel();
throw error;
} finally {
reader.releaseLock();
}
const body = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.length;
}
return JSON.parse(new TextDecoder().decode(body));
}
export async function listModels(
baseUrl: string,
Expand Down Expand Up @@ -109,9 +206,11 @@ export function createPlugin(
timeoutMs = Number(env.OPENCODE_9ROUTER_TIMEOUT_MS || DEFAULT_TIMEOUT_MS);
let entries: AcceptedDiscoveryEntry[] = [];
try {
entries = acceptDiscoveryEntries(
await discover(baseUrl, timeoutMs, apiKey),
);
entries = (await discover(
baseUrl,
timeoutMs,
apiKey,
)) as AcceptedDiscoveryEntry[];
} catch {}
const selected = pickDefaultModel(entries);
return {
Expand All @@ -131,14 +230,23 @@ export function createPlugin(
Array.isArray(provider.models)
)
throw new TypeError("9router models must be an object");
for (const { id } of entries)
if (!own(provider.models, id))
for (const { id, live = {} } of entries)
if (!own(provider.models, id)) {
const mapped = await mapper({ id, live }, md).catch(() => ({
id,
name: id,
attachment: false,
reasoning: false,
temperature: false,
tool_call: false,
}));
Object.defineProperty(provider.models, id, {
value: await mapper(id, md),
value: mapped,
enumerable: true,
configurable: true,
writable: true,
});
}
if (!cfg.model && selected) cfg.model = `9router/${selected}`;
},
};
Expand Down
Loading
Loading