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
2 changes: 0 additions & 2 deletions .opencode/agent/translator.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,7 +565,6 @@ AICTRL_DISABLE_AUTOCOMPACT
AICTRL_DISABLE_AUTOUPDATE
AICTRL_DISABLE_DEFAULT_PLUGINS
AICTRL_DISABLE_FILETIME_CHECK
AICTRL_DISABLE_MODELS_FETCH
AICTRL_DISABLE_PRUNE
AICTRL_ENABLE_EXA
AICTRL_ENABLE_EXPERIMENTAL_MODELS
Expand All @@ -577,7 +576,6 @@ AICTRL_EXPERIMENTAL_OXFMT
AICTRL_FAKE_VCS
AICTRL_GIT_BASH_PATH
AICTRL_MODEL
AICTRL_MODELS_URL
AICTRL_PERMISSION
AICTRL_PORT
PROJECT_ROOT
Expand Down
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
sst-env.d.ts
packages/desktop/src/bindings.ts
packages/cli/src/provider/catalog.json
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you

## Adding New Providers

New providers shouldn't require many if ANY code changes, but if you want to add support for a new provider first make a PR to:
https://github.com/anomalyco/models.dev
Built-in providers and models live in the static catalog at `packages/cli/src/provider/catalog.json` (one model per line, in the `Provider.Model` shape; `api.url` is left out for providers whose SDK knows its own endpoint, and each provider also carries `npm`/`api` defaults for models added through config). To add or update a built-in model, edit that file; providers that need custom loading also need an entry in `CUSTOM_LOADERS` in `packages/cli/src/provider/provider.ts`.

Any other OpenAI-compatible provider does not need a code change: users define it under `provider` in `aictrl.json`.

## Developing Aictrl

Expand Down
2 changes: 1 addition & 1 deletion EVENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ For compatibility with sessions written before usage provenance was persisted, a
**`context`** (context-window utilization):

- `used` (number) — tokens occupying the model's context window this turn: `input + cache.read + cache.write`.
- `limit` (number) — the model's total context-window size in tokens, sourced from the models.dev registry (`model.limit.context`).
- `limit` (number) — the model's total context-window size in tokens, sourced from the provider registry: the built-in model catalog plus `provider` config (`model.limit.context`). With `aictrl run --attach`, the attached server's provider registry is used instead.
- `ratio` (number) — `used / limit` (≥0; may exceed 1 if usage exceeds the model's registered limit). A value approaching or exceeding 1 signals context-exhaustion risk.
- `null` — emitted when the model's context limit is not known (e.g. unregistered custom endpoint), or usage is missing.

Expand Down
6 changes: 1 addition & 5 deletions nix/opencode.nix
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
bun,
sysctl,
makeBinaryWrapper,
models-dev,
ripgrep,
installShellFiles,
versionCheckHook,
Expand All @@ -21,7 +20,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
bun
installShellFiles
makeBinaryWrapper
models-dev
writableTmpDirAsHomeHook
];

Expand All @@ -33,8 +31,6 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook postConfigure
'';

env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json";
env.AICTRL_DISABLE_MODELS_FETCH = true;
env.AICTRL_VERSION = finalAttrs.version;
env.AICTRL_CHANNEL = "local";

Expand Down Expand Up @@ -80,7 +76,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
writableTmpDirAsHomeHook
];
doInstallCheck = true;
versionCheckKeepEnvironment = [ "HOME" "AICTRL_DISABLE_MODELS_FETCH" ];
versionCheckKeepEnvironment = [ "HOME" ];
versionCheckProgramArg = "--version";

passthru = {
Expand Down
1 change: 0 additions & 1 deletion packages/cli/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,3 @@ research
dist
gen
app.log
src/provider/models-snapshot.ts
22 changes: 0 additions & 22 deletions packages/cli/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,6 @@ const singleFlag = process.argv.includes("--single") || process.env.AICTRL_BUILD
const baselineFlag = process.argv.includes("--baseline")
const liteFlag = process.argv.includes("--lite")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 --lite build embeds full 926KB catalog, no slimming path.

🤖 Fix with your agent
Fix this code review finding (aictrl-dev/cli PR #202, packages/cli/script/build.ts:21):

Problem: --lite build embeds full 926KB catalog, no slimming path
Detail: The deleted build.ts block filtered the models.dev data to 7 providers for --lite builds before embedding the snapshot. The replacement catalog.json is ~926KB / 18 providers / 1354 models and is imported unconditionally (`import catalog from "./catalog.json"` in catalog.ts), so lite builds (the headless/embedded target that still exists — liteFlag still selects src/headless.ts as entrypoint at build.ts:167) now ship and parse the full catalog with no filtering path. Binary size and cold-start JSON parse cost grow for the lite profile while nothing replaces the slimming behavior.
Suggested fix: Either accept the full catalog for lite builds and document that, or emit a filtered catalog variant (e.g. script derives catalog.lite.json from catalog.json for lite builds) so lite targets keep a reduced footprint.

Implement the fix on the PR head branch and add a regression test that fails before the fix and passes after.
Why this matters

The deleted build.ts block filtered the models.dev data to 7 providers for --lite builds before embedding the snapshot. The replacement catalog.json is ~926KB / 18 providers / 1354 models and is imported unconditionally (import catalog from "./catalog.json" in catalog.ts), so lite builds (the headless/embedded target that still exists — liteFlag still selects src/headless.ts as entrypoint at build.ts:167) now ship and parse the full catalog with no filtering path. Binary size and cold-start JSON parse cost grow for the lite profile while nothing replaces the slimming behavior.

const baselineFlag = process.argv.includes(\"--baseline\")\nconst skipInstall = process.argv.includes(\"--skip-install\")\nconst liteFlag = process.argv.includes(\"--lite\")\n// (deleted here: fetch models.dev, filter to liteProviders when liteFlag, write src/provider/models-snapshot.ts)

const modelsUrl = process.env.AICTRL_MODELS_URL || "https://models.dev"
// Fetch and generate models.dev snapshot
let modelsData = process.env.MODELS_DEV_API_JSON
? await Bun.file(process.env.MODELS_DEV_API_JSON).text()
: await fetch(`${modelsUrl}/api.json`).then((x) => x.text())

if (liteFlag) {
const allModels = JSON.parse(modelsData)
const liteProviders = ["anthropic", "openai", "google", "google-vertex", "openrouter", "zai", "aictrl"]
const filtered: Record<string, any> = {}
for (const key of liteProviders) {
if (allModels[key]) filtered[key] = allModels[key]
}
modelsData = JSON.stringify(filtered)
}

await Bun.write(
path.join(dir, "src/provider/models-snapshot.ts"),
`// Auto-generated by build.ts - do not edit\nexport const snapshot = ${modelsData} as const\n`,
)
console.log("Generated models-snapshot.ts")

// Load migrations from migration directories
const migrationDirs = (
await fs.promises.readdir(path.join(dir, "migration"), {
Expand Down
40 changes: 13 additions & 27 deletions packages/cli/src/cli/cmd/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { Auth } from "../../auth"
import { cmd } from "./cmd"
import * as prompts from "@clack/prompts"
import { UI } from "../ui"
import { ModelsDev } from "../../provider/models"
import { map, pipe, sortBy, values } from "remeda"
import { ModelCatalog } from "../../provider/catalog"
import { map, pickBy, pipe, sortBy, values } from "remeda"
import path from "path"
import os from "os"
import { Config } from "../../config/config"
Expand Down Expand Up @@ -163,7 +163,7 @@ async function handlePluginAuth(plugin: { auth: PluginAuth }, provider: string):

/**
* Build a deduplicated list of plugin-registered auth providers that are not
* already present in models.dev, respecting enabled/disabled provider lists.
* already built into the model catalog, respecting enabled/disabled provider lists.
* Pure function with no side effects; safe to test without mocking.
*/
export function resolvePluginProviders(input: {
Expand Down Expand Up @@ -212,7 +212,7 @@ export const AuthListCommand = cmd({
const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath
prompts.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`)
const results = Object.entries(await Auth.all())
const database = await ModelsDev.get()
const database = ModelCatalog.providers()

for (const [providerID, result] of results) {
const name = database[providerID]?.name || providerID
Expand Down Expand Up @@ -288,30 +288,21 @@ export const AuthLoginCommand = cmd({
prompts.outro("Done")
return
}
await ModelsDev.refresh().catch(() => {})

const config = await Config.get()

const disabled = new Set(config.disabled_providers ?? [])
const enabled = config.enabled_providers ? new Set(config.enabled_providers) : undefined

const providers = await ModelsDev.get().then((x) => {
const filtered: Record<string, (typeof x)[string]> = {}
for (const [key, value] of Object.entries(x)) {
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
filtered[key] = value
}
}
return filtered
})
const providers = pickBy(
ModelCatalog.providers(),
(_, id) => (enabled ? enabled.has(id) : true) && !disabled.has(id),
)

const priority: Record<string, number> = {
aictrl: 0,
anthropic: 1,
openai: 2,
google: 3,
openrouter: 4,
vercel: 5,
anthropic: 0,
openai: 1,
openrouter: 2,
vercel: 3,
}
const pluginProviders = resolvePluginProviders({
hooks: await Plugin.list(),
Expand All @@ -335,7 +326,6 @@ export const AuthLoginCommand = cmd({
label: x.name,
value: x.id,
hint: {
aictrl: "recommended",
anthropic: "Claude Max or API key",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
Expand Down Expand Up @@ -392,10 +382,6 @@ export const AuthLoginCommand = cmd({
)
}

if (provider === "aictrl") {
prompts.log.info("Create an api key at https://aictrl.ai/auth")
}

if (provider === "vercel") {
prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
}
Expand Down Expand Up @@ -433,7 +419,7 @@ export const AuthLogoutCommand = cmd({
prompts.log.error("No credentials found")
return
}
const database = await ModelsDev.get()
const database = ModelCatalog.providers()
const providerID = await prompts.select({
message: "Select provider",
options: credentials.map(([key, value]) => ({
Expand Down
18 changes: 1 addition & 17 deletions packages/cli/src/cli/cmd/models.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { Argv } from "yargs"
import { Instance } from "../../project/instance"
import { Provider } from "../../provider/provider"
import { ModelsDev } from "../../provider/models"
import { cmd } from "./cmd"
import { UI } from "../ui"
import { EOL } from "os"
Expand All @@ -20,17 +19,8 @@ export const ModelsCommand = cmd({
describe: "use more verbose model output (includes metadata like costs)",
type: "boolean",
})
.option("refresh", {
describe: "refresh the models cache from models.dev",
type: "boolean",
})
},
handler: async (args) => {
if (args.refresh) {
await ModelsDev.refresh()
UI.println(UI.Style.TEXT_SUCCESS_BOLD + "Models cache refreshed" + UI.Style.TEXT_NORMAL)
}

await Instance.provide({
directory: process.cwd(),
async fn() {
Expand Down Expand Up @@ -60,13 +50,7 @@ export const ModelsCommand = cmd({
return
}

const providerIDs = Object.keys(providers).sort((a, b) => {
const aIsOpencode = a.startsWith("aictrl")
const bIsOpencode = b.startsWith("aictrl")
if (aIsOpencode && !bIsOpencode) return -1
if (!aIsOpencode && bIsOpencode) return 1
return a.localeCompare(b)
})
const providerIDs = Object.keys(providers).sort((a, b) => a.localeCompare(b))

for (const providerID of providerIDs) {
printModels(providerID, args.verbose)
Expand Down
20 changes: 14 additions & 6 deletions packages/cli/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { Filesystem } from "../../util/filesystem"
import { createAictrlClient } from "@aictrl/sdk"
import type { ToolPart } from "@aictrl/sdk/v2"
import { Provider } from "../../provider/provider"
import { ModelsDev } from "../../provider/models"
import { lazy } from "../../util/lazy"
import { Agent } from "../../agent/agent"
import { PermissionNext } from "../../permission/next"
import { Session } from "../../session"
Expand Down Expand Up @@ -120,9 +120,12 @@ export function buildContextWindow(
export async function attachedContextLimit(
providerID: string,
modelID: string,
providers = ModelsDev.get(),
providers: Promise<Provider.Info[]>,
): Promise<number | null> {
return providers.then((providers) => providers[providerID]?.models[modelID]?.limit.context ?? null).catch(() => null)
return providers
.then((providers) => providers.find((provider) => provider.id === providerID))
.then((provider) => provider?.models[modelID]?.limit.context ?? null)
.catch(() => null)
}

type TerminalUsageInfo = Pick<MessageV2.Assistant, "finish" | "usageStatus" | "tokens">
Expand Down Expand Up @@ -542,6 +545,11 @@ export const RunCommand = cmd({
let promptResult: Promise<any> = Promise.resolve()

async function execute(sdk: any) {
// An attached server resolves models from its own provider registry, so ask it once per run.
const attachedProviders = lazy(
(): Promise<Provider.Info[]> => sdk.config.providers().then((res: any) => res.data?.providers ?? []),
)

function tool(part: ToolPart) {
try {
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
Expand Down Expand Up @@ -697,10 +705,10 @@ export const RunCommand = cmd({
// part of the prompt sent to the model and count against the context
// window, just billed at the cache-write rate. Excluding them
// undercounts utilization on the first turn of a conversation.
// limit comes from the model registry (models.dev). On lookup failure
// (or limit===0 for custom models) buildContextWindow returns null.
// limit comes from the provider registry (the built-in catalog plus config, or the attached server's).
// On lookup failure (or limit===0 for custom models) buildContextWindow returns null.
const contextLimit = await (args.attach
? attachedContextLimit(info.providerID, info.modelID)
? attachedContextLimit(info.providerID, info.modelID, attachedProviders())
: Provider.getModel(info.providerID, info.modelID)
.then((m) => m.limit.context)
.catch((e) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ export function FormatError(input: unknown) {
...(Array.isArray(suggestions) && suggestions.length ? ["Did you mean: " + suggestions.join(", ")] : []),
`Try: \`aictrl models\` to list available models`,
`Or check your config (aictrl.json) provider/model names`,
`Providers that are not built in must be defined under "provider" in aictrl.json`,
].join("\n")
}
if (Provider.BaseURLMissingError.isInstance(input)) return input.message
if (Provider.InitError.isInstance(input)) {
return `Failed to initialize provider "${input.data.providerID}". Check credentials and configuration.`
}
Expand Down
Loading
Loading