fokemal / cli /src /tools.ts
automindy's picture
Sync to upstream v0.13.7, keep HF Space adaptations
dcedb25
Raw History Blame Contribute Delete
45.4 kB
import path from 'node:path';
import type {
CatalogModel,
GenerateContext,
Generation,
ToolDefinition,
} from './types.js';
function rootUrl(url: string): string {
return url.trim().replace(/\/+$/, '').replace(/\/v1$/i, '');
}
function v1Url(url: string): string {
return `${rootUrl(url)}/v1`;
}
function primaryModel(models: CatalogModel[], requestedId?: string): CatalogModel {
// An explicit --model wins over every heuristic below. It is validated
// against the UNFILTERED catalog before we get here, so an id absent from
// `models` (the available-only roster) is a real, registered model that is
// merely out of quota right now β€” pin it anyway rather than silently writing
// a different model into the user's config.
if (requestedId) return models.find(model => model.id === requestedId) ?? { id: requestedId };
// `auto` β€” the router picking the best model per request β€” is the whole
// point of the gateway and the right default for a generated config. Never
// fall back to `fusion` (multi-model fan-out) by accident.
return models.find(model => model.id === 'auto')
?? models.find(model => model.id !== 'fusion' && model.available !== false)
?? models.find(model => model.id !== 'fusion')
?? { id: 'auto', name: 'Auto', context_window: 128_000 };
}
function catalogModels(models: CatalogModel[]): CatalogModel[] {
const available = models.filter(model => model.id !== 'auto' && model.available !== false);
if (available.length) return available;
const nonAuto = models.filter(model => model.id !== 'auto');
return nonAuto.length ? nonAuto : [primaryModel(models)];
}
function contextWindow(model: CatalogModel): number {
return model.context_window ?? model.context_length ?? 128_000;
}
function outputLimit(model: CatalogModel): number {
return Math.min(8192, contextWindow(model));
}
function yamlString(value: string): string {
return JSON.stringify(value);
}
function claude(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
const directory = ctx.profile === 'default'
? path.join(ctx.homeDir, '.claude')
: path.join(ctx.homeDir, '.claude', 'profiles', ctx.profile);
return {
files: [{
path: path.join(directory, 'settings.json'),
format: 'json',
sensitive: true,
value: {
env: {
ANTHROPIC_BASE_URL: rootUrl(ctx.url),
ANTHROPIC_AUTH_TOKEN: ctx.apiKey,
ANTHROPIC_MODEL: model.id,
ANTHROPIC_DEFAULT_OPUS_MODEL: model.id,
ANTHROPIC_DEFAULT_SONNET_MODEL: model.id,
ANTHROPIC_DEFAULT_HAIKU_MODEL: model.id,
CLAUDE_CODE_AUTO_COMPACT_WINDOW: String(contextWindow(model)),
CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY: '1',
},
},
}],
notes: [
ctx.profile === 'default'
? 'Claude Code will read this configuration automatically.'
: `Launch with CLAUDE_CONFIG_DIR=${directory} claude`,
'For zero-persistence credentials, prefer: freellmapi launch',
],
};
}
function codex(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
const compact = Math.max(16_000, Math.floor(contextWindow(model) * 0.9));
const providerTable = [
'[model_providers.freellmapi]',
'name = "FreeLLMAPI"',
`base_url = ${JSON.stringify(v1Url(ctx.url))}`,
'wire_api = "responses"',
'env_key = "FREELLMAPI_API_KEY"',
'requires_openai_auth = false',
];
// Codex reads exactly one file, ~/.codex/config.toml. Named profiles are
// `[profiles.NAME]` tables in that same file, selected with
// `codex --profile NAME`; separate per-profile files are never read.
const content = ctx.profile === 'default'
? [
'# freellmapi:start',
`model = ${JSON.stringify(model.id)}`,
'model_provider = "freellmapi"',
`model_context_window = ${contextWindow(model)}`,
`model_auto_compact_token_limit = ${compact}`,
'tool_output_token_limit = 20000',
'',
...providerTable,
'# freellmapi:end',
'',
]
: [
'# freellmapi:start',
...providerTable,
'',
`[profiles.${/^[A-Za-z0-9_-]+$/.test(ctx.profile) ? ctx.profile : JSON.stringify(ctx.profile)}]`,
`model = ${JSON.stringify(model.id)}`,
'model_provider = "freellmapi"',
'# freellmapi:end',
'',
];
return {
files: [{
path: path.join(ctx.homeDir, '.codex', 'config.toml'),
format: 'toml',
content: content.join('\n'),
}],
notes: [
'Export FREELLMAPI_API_KEY before running codex; the key is not written to config.toml.',
...(ctx.profile === 'default'
? []
: [`Activate this profile with: codex --profile ${ctx.profile}`]),
`Selected ${model.id} with a ${contextWindow(model)} token context window.`,
],
};
}
function cline(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
return {
files: [{
path: path.join(ctx.homeDir, '.cline', 'data', 'settings', 'providers.json'),
format: 'json',
sensitive: true,
value: {
version: 1,
lastUsedProvider: 'openai-compatible',
providers: {
'openai-compatible': {
settings: {
provider: 'openai-compatible',
protocol: 'openai-chat',
client: 'openai-compatible',
model: model.id,
baseUrl: v1Url(ctx.url),
apiKey: ctx.apiKey,
contextWindow: contextWindow(model),
capabilities: ['streaming', 'tools'],
},
// The field is required by Cline's persisted schema. Keeping it
// deterministic makes repeated setup runs idempotent.
updatedAt: '2026-07-27T00:00:00.000Z',
tokenSource: 'manual',
},
},
},
}],
notes: ['Cline will use the generated OpenAI Compatible provider immediately.'],
};
}
function continueDev(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
return {
files: [{
path: path.join(ctx.homeDir, '.continue', 'config.yaml'),
format: 'yaml',
content: [
'# freellmapi:start',
'name: FreeLLMAPI',
'version: 1.0.0',
'schema: v1',
'models:',
' - name: FreeLLMAPI',
' provider: openai',
` model: ${yamlString(model.id)}`,
` apiBase: ${yamlString(v1Url(ctx.url))}`,
' apiKey: ${{ secrets.FREELLMAPI_API_KEY }}',
' capabilities:',
' - tool_use',
' defaultCompletionOptions:',
` contextLength: ${contextWindow(model)}`,
'# freellmapi:end',
'',
].join('\n'),
},
// `${{ secrets.NAME }}` is resolved, in order, from the process
// environment, ~/.continue/.env, <cwd>/.continue/.env and <cwd>/.env
// before Continue's Hub is consulted. The IDE extensions cannot see the
// shell environment, so ~/.continue/.env is the one place that serves the
// CLI and the extensions alike β€” and it keeps the raw key out of the YAML.
{
path: path.join(ctx.homeDir, '.continue', '.env'),
format: 'env',
sensitive: true,
content: `FREELLMAPI_API_KEY=${ctx.apiKey}\n`,
}],
notes: [
'The key is in ~/.continue/.env (read by the Continue CLI and the IDE extensions); config.yaml references it as a secret.',
'One-shot check with the CLI (npm install -g @continuedev/cli): cn -p "Say hello"',
],
};
}
function aider(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
return {
files: [{
path: path.join(ctx.homeDir, '.aider.conf.yml'),
format: 'yaml',
sensitive: true,
content: [
'# freellmapi:start',
`openai-api-base: ${yamlString(v1Url(ctx.url))}`,
`openai-api-key: ${yamlString(ctx.apiKey)}`,
`model: ${yamlString(`openai/${model.id}`)}`,
'# freellmapi:end',
'',
].join('\n'),
}],
notes: [
`Environment-only alternative: OPENAI_API_BASE=${v1Url(ctx.url)} OPENAI_API_KEY=… aider --model openai/${model.id}`,
],
};
}
// OpenCode resolves `provider/model` refs only against the ids a provider
// declares, so the default model has to be in the `models` map β€” `auto`, the
// usual default, is filtered out of the plain catalog roster, so it goes back
// at the front. And a provider entry alone is inert: without a top-level
// `model` OpenCode keeps whatever it used before (or nothing on a fresh
// install), so the generated config names the default too.
function opencode(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
const roster = catalogModels(ctx.models);
const modelEntries = Object.fromEntries(
[model, ...roster.filter(entry => entry.id !== model.id)].map(entry => [entry.id, {
name: entry.name ?? entry.id,
limit: { context: contextWindow(entry), output: outputLimit(entry) },
}]),
);
return {
files: [{
path: path.join(ctx.homeDir, '.config', 'opencode', 'opencode.json'),
format: 'json',
value: {
$schema: 'https://opencode.ai/config.json',
model: `freellmapi/${model.id}`,
provider: {
freellmapi: {
npm: '@ai-sdk/openai-compatible',
name: 'FreeLLMAPI',
options: {
baseURL: v1Url(ctx.url),
apiKey: '{env:FREELLMAPI_API_KEY}',
},
models: modelEntries,
},
},
},
}],
notes: [
'Export FREELLMAPI_API_KEY before starting OpenCode.',
`freellmapi/${model.id} is now the default model.`,
],
};
}
function goose(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
// GOOSE_MODEL names the default, so it belongs in the provider's list too;
// `auto` is filtered out of the plain roster and goes back at the front.
const roster = catalogModels(ctx.models);
const models = [model, ...roster.filter(entry => entry.id !== model.id)].map(entry => ({
name: entry.id,
context_limit: contextWindow(entry),
}));
return {
files: [
{
path: path.join(
ctx.homeDir,
'.config',
'goose',
'custom_providers',
'freellmapi.json',
),
format: 'json',
value: {
name: 'freellmapi',
engine: 'openai',
display_name: 'FreeLLMAPI',
description: 'FreeLLMAPI OpenAI-compatible gateway',
api_key_env: 'FREELLMAPI_API_KEY',
base_url: v1Url(ctx.url),
models,
supports_streaming: true,
requires_auth: true,
dynamic_models: false,
preserves_thinking: true,
},
},
{
path: path.join(ctx.homeDir, '.config', 'goose', 'config.yaml'),
format: 'yaml',
content: [
'# freellmapi:start',
'GOOSE_PROVIDER: freellmapi',
`GOOSE_MODEL: ${yamlString(model.id)}`,
'# freellmapi:end',
'',
].join('\n'),
},
],
notes: ['Export FREELLMAPI_API_KEY before starting Goose.'],
};
}
function qwen(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
const dir = path.join(ctx.homeDir, '.qwen');
// Qwen Code looks the selected model up in the provider list and, finding
// nothing, silently falls back to the FIRST listed model β€” with `auto`
// filtered out of the roster that was `fusion`, a multi-model fan-out. The
// default goes first so the lookup always succeeds.
const roster = catalogModels(ctx.models);
const models = [model, ...roster.filter(entry => entry.id !== model.id)].map(entry => ({
id: entry.id,
name: entry.name ?? entry.id,
envKey: 'FREELLMAPI_API_KEY',
baseUrl: v1Url(ctx.url),
generationConfig: {
contextWindowSize: contextWindow(entry),
},
}));
return {
files: [
{
path: path.join(dir, 'settings.json'),
format: 'json',
value: {
model: { name: model.id },
modelProviders: {
openai: {
protocol: 'openai',
models,
},
},
security: { auth: { selectedType: 'openai' } },
},
},
{
path: path.join(dir, '.env'),
format: 'env',
sensitive: true,
content: `FREELLMAPI_API_KEY=${ctx.apiKey}\n`,
},
],
notes: [
`Native Gemini mode is also available at ${rootUrl(ctx.url)}/v1beta with GEMINI_API_KEY.`,
],
};
}
// Roo Code imports a settings file named by `roo-cline.autoImportSettingsPath`
// and makes that file's `currentApiConfigName` the active profile. The VS Code
// extension can use an `openai`-type profile (Roo's "OpenAI Compatible"), but
// the Roo CLI only starts with a provider from its own short list and then
// forces `apiProvider: openrouter` plus the --model onto whatever profile is
// CURRENT β€” so the CLI is served by a separate import file whose current
// profile is an `openrouter`-type one with the base URL overridden. The two
// stores never meet: the extension reads VS Code settings, the CLI reads
// ~/.vscode-mock/global-storage/global-state.json.
function roo(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
const importPath = path.join(ctx.homeDir, '.roo', 'freellmapi.json');
const cliImportPath = path.join(ctx.homeDir, '.roo', 'freellmapi-cli.json');
const cliProfile = {
apiProvider: 'openrouter',
openRouterBaseUrl: v1Url(ctx.url),
openRouterApiKey: ctx.apiKey,
openRouterModelId: model.id,
};
return {
files: [
{
path: importPath,
format: 'json',
sensitive: true,
value: {
providerProfiles: {
currentApiConfigName: 'freellmapi',
apiConfigs: {
freellmapi: {
apiProvider: 'openai',
openAiBaseUrl: v1Url(ctx.url),
openAiApiKey: ctx.apiKey,
openAiModelId: model.id,
openAiStreamingEnabled: true,
openAiCustomModelInfo: {
contextWindow: contextWindow(model),
maxTokens: outputLimit(model),
supportsPromptCache: false,
},
},
'freellmapi-cli': cliProfile,
},
},
globalSettings: {},
},
},
{
path: cliImportPath,
format: 'json',
sensitive: true,
value: {
providerProfiles: {
currentApiConfigName: 'freellmapi-cli',
apiConfigs: { 'freellmapi-cli': cliProfile },
},
globalSettings: {},
},
},
// The CLI's own defaults file. `provider` is honoured (it guards against
// the CLI defaulting to Roo Cloud when a Roo token exists); `model` is
// shadowed in Roo CLI 0.1.x by the -m flag's built-in default, so -m is
// still needed on the command line until Roo fixes that.
{
path: path.join(ctx.homeDir, '.roo', 'cli-settings.json'),
format: 'json',
value: { provider: 'openrouter', model: model.id },
},
],
notes: [
`VS Code: add "roo-cline.autoImportSettingsPath": "${importPath}" to settings.json and reload (or Roo Settings β†’ Import); the freellmapi profile becomes current.`,
`Roo CLI: merge {"roo-cline.autoImportSettingsPath": "${cliImportPath}"} into ~/.vscode-mock/global-storage/global-state.json (create it if missing), then:`,
` export OPENROUTER_API_KEY=<unified-key> # the CLI refuses to start without it, even with the key in the profile`,
` roo -m ${model.id} "Say hello" # -m is required: the CLI's built-in default model shadows cli-settings.json`,
],
};
}
function kilo(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
// Kilo refuses to start (`Model not found: openai-compatible/auto`) unless
// the selected model is in the provider's map, so the default goes first.
const roster = catalogModels(ctx.models);
const models = Object.fromEntries([model, ...roster.filter(entry => entry.id !== model.id)].map(entry => [
entry.id,
{
name: entry.name ?? entry.id,
tool_call: true,
limit: {
context: contextWindow(entry),
output: outputLimit(entry),
},
},
]));
return {
files: [{
path: path.join(ctx.homeDir, '.config', 'kilo', 'kilo.jsonc'),
format: 'json',
value: {
$schema: 'https://app.kilo.ai/config.json',
model: `openai-compatible/${model.id}`,
provider: {
'openai-compatible': {
options: {
apiKey: '{env:FREELLMAPI_API_KEY}',
baseURL: v1Url(ctx.url),
},
models,
},
},
},
}],
notes: [
'Export FREELLMAPI_API_KEY before starting Kilo; global config is trusted for {env:…} expansion.',
`One-shot check: kilo run "Say hello" β€” openai-compatible/${model.id} is the default model.`,
],
};
}
// Crush picks its provider by the top-level `models.large` / `models.small`
// selection, and with neither set it auto-detects from whatever credentials
// are on the machine (an AWS profile, ANTHROPIC_API_KEY, a Charm login) β€” a
// provider entry alone never gets used. Both slots are pointed at the chosen
// model, which therefore has to be in the provider's list; `auto` is filtered
// out of the plain roster, so it goes back at the front.
function crush(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
const roster = catalogModels(ctx.models);
const models = [model, ...roster.filter(entry => entry.id !== model.id)].map(entry => ({
id: entry.id,
name: entry.name ?? entry.id,
cost_per_1m_in: 0,
cost_per_1m_out: 0,
cost_per_1m_in_cached: 0,
cost_per_1m_out_cached: 0,
context_window: contextWindow(entry),
default_max_tokens: outputLimit(entry),
can_reason: false,
supports_attachments: false,
}));
return {
files: [{
path: path.join(ctx.homeDir, '.config', 'crush', 'crush.json'),
format: 'json',
value: {
$schema: 'https://charm.land/crush.json',
models: {
large: { provider: 'freellmapi', model: model.id },
small: { provider: 'freellmapi', model: model.id },
},
providers: {
freellmapi: {
name: 'FreeLLMAPI',
type: 'openai-compat',
base_url: v1Url(ctx.url),
api_key: '$FREELLMAPI_API_KEY',
models,
},
},
},
}],
notes: [
'Export FREELLMAPI_API_KEY before starting Crush.',
`freellmapi/${model.id} is now the large and small model.`,
],
};
}
// DeepSeek Harness (dsh) reads one YAML settings document, $DSH_HOME/settings.yaml
// (default ~/.dsh). Every provider is a route under `llm-pi-ai.providers`;
// a route pi-ai's installed catalog does not ship must spell out `api`,
// `baseURL`, and a non-empty `models` list, which is exactly what a gateway
// with a per-user live catalog is. The key travels through `apiKeyEnv`:
// DSH loads $DSH_HOME/.env as its user environment layer, so writing the key
// there (0600) makes the route work on the next request without an export.
// No `compat` switches: the gateway already maps `developer` β†’ `system` and
// honours `max_completion_tokens`, the two things pi-ai sends to an endpoint
// it cannot recognize.
function dsh(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
// DSH_HOME relocates the whole harness home; nothing else about the
// layout changes, so honour it the way dsh itself does.
const home = process.env.DSH_HOME?.trim() || path.join(ctx.homeDir, '.dsh');
// A route id is permanent in DSH (sessions, defaults, and credential refs
// name it) and must be lowercase; a named profile becomes a second route
// beside the default one rather than replacing it.
const route = ctx.profile === 'default'
? 'freellmapi'
: `freellmapi-${ctx.profile.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')}`;
const roster = catalogModels(ctx.models);
const entries = [model, ...roster.filter(entry => entry.id !== model.id)]
.map(entry => ({
id: entry.id,
name: entry.name ?? entry.id,
contextWindow: contextWindow(entry),
maxTokens: outputLimit(entry),
}));
return {
files: [
{
path: path.join(home, 'settings.yaml'),
format: 'yaml',
value: {
'llm-pi-ai': {
providers: {
[route]: {
displayName: ctx.profile === 'default' ? 'FreeLLMAPI' : `FreeLLMAPI (${ctx.profile})`,
apiKeyEnv: 'FREELLMAPI_API_KEY',
api: 'openai-completions',
baseURL: v1Url(ctx.url),
models: entries,
},
},
},
// The default model is only claimed for the default profile; a
// named route is an extra option in the picker, not a takeover.
// A previous default's `reasoningEffort` must go with it: DSH
// refuses a request naming a level the selected model cannot
// take, and a hand-declared model declares none.
...(ctx.profile === 'default'
? {
'agent-default-model': {
provider: route,
model: model.id,
reasoningEffort: undefined,
},
}
: {}),
},
},
{
path: path.join(home, '.env'),
format: 'env',
sensitive: true,
content: `FREELLMAPI_API_KEY=${ctx.apiKey}\n`,
},
],
notes: [
`Start DeepSeek Harness with: npx @deepseek-ai/dsh web β€” ${route}/${model.id} is ${ctx.profile === 'default' ? 'the default model' : 'in the model picker'}.`,
'One-shot check: npx @deepseek-ai/dsh --profile headless "Say hello"',
'Settings are hot-reloaded, so a running dsh picks this up on its next request.',
`Models are declared text-only; give a vision model \`input: [text, image]\` under ${route}.models in settings.yaml to send it images.`,
],
};
}
// MiMo Code is an OpenCode derivative, so the config is the same shape as the
// OpenCode one above: a custom `provider` entry backed by the AI SDK's
// openai-compatible package, with credentials as `options.apiKey` /
// `options.baseURL` (mimo.xiaomi.com/mimocode/models-provider). Two things
// differ from OpenCode and both matter:
// * the file lives in MiMo's own global config directory. That is
// `$XDG_CONFIG_HOME/mimocode` (`~/.config/mimocode` on macOS and Linux),
// relocated wholesale to `$MIMOCODE_HOME/config` when that is set. The
// directory accepts `config.json`, `mimocode.json` and `mimocode.jsonc`
// and merges them in that order, so writing `config.json` β€” the first and
// weakest layer β€” leaves a hand-written `mimocode.json` in charge.
// * `provider.<id>.models.<id>.limit` requires BOTH `context` and `output`
// in MiMo's schema, the same as OpenCode's (https://opencode.ai/config.json).
// There is no MIMOCODE_API_KEY or MIMOCODE_BASE_URL: MiMo's environment
// variables locate resources and toggle features, they are not a fallback for
// config fields. The key travels through the config file's own `{env:VAR}`
// substitution instead, which keeps it off disk.
function mimo(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
const configDir = process.env.MIMOCODE_HOME?.trim()
? path.join(process.env.MIMOCODE_HOME.trim(), 'config')
: path.join(
process.env.XDG_CONFIG_HOME?.trim() || path.join(ctx.homeDir, '.config'),
'mimocode',
);
// The default model is named as `freellmapi/<id>`, so it has to exist in the
// provider's own `models` map β€” `auto`, the usual default, is filtered out
// of the plain catalog roster, so put the chosen model back at the front.
const roster = catalogModels(ctx.models);
const modelEntries = Object.fromEntries(
[model, ...roster.filter(entry => entry.id !== model.id)].map(entry => [entry.id, {
name: entry.name ?? entry.id,
limit: { context: contextWindow(entry), output: outputLimit(entry) },
}]),
);
return {
files: [{
path: path.join(configDir, 'config.json'),
format: 'json',
value: {
$schema: 'https://mimo.xiaomi.com/mimocode/config.json',
provider: {
freellmapi: {
npm: '@ai-sdk/openai-compatible',
name: 'FreeLLMAPI',
options: {
baseURL: v1Url(ctx.url),
apiKey: '{env:FREELLMAPI_API_KEY}',
},
models: modelEntries,
},
},
model: `freellmapi/${model.id}`,
},
}],
notes: [
'Install MiMo Code with: curl -fsSL https://mimo.xiaomi.com/install | bash',
'Export FREELLMAPI_API_KEY before starting MiMo Code.',
`Start it with \`mimo\` β€” freellmapi/${model.id} is the default model.`,
'One-shot check: mimo run "Say hello"',
],
};
}
// OpenClaw reads one JSON5 document, $OPENCLAW_CONFIG_PATH (default
// ~/.openclaw/openclaw.json, the state dir being relocatable through
// OPENCLAW_STATE_DIR / OPENCLAW_HOME). Every model endpoint is an entry under
// `models.providers`, and a provider its bundled catalog does not know must
// spell out `baseUrl`, `api` and a non-empty `models` list β€” exactly what a
// gateway with a per-user live catalog is. `api: openai-completions` is the
// adapter for a /v1/chat/completions backend; OpenClaw already strips the
// developer role and attribution headers for a custom origin, so no `compat`
// switches are needed. The key travels as `${FREELLMAPI_API_KEY}`, OpenClaw's
// own env substitution, and the value lands in $OPENCLAW_STATE_DIR/.env (0600),
// the global env file OpenClaw loads on its own β€” so the route works with
// nothing exported. The default model is `freellmapi/<id>` under
// `agents.defaults.model.primary`; the patch keeps any `fallbacks` already
// declared beside it.
function openclawStateDir(homeDir: string): string {
return process.env.OPENCLAW_STATE_DIR?.trim()
|| path.join(process.env.OPENCLAW_HOME?.trim() || homeDir, '.openclaw');
}
function openclaw(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
const stateDir = openclawStateDir(ctx.homeDir);
const configPath = process.env.OPENCLAW_CONFIG_PATH?.trim()
|| path.join(stateDir, 'openclaw.json');
// A provider id is referenced by every model ref (`<provider>/<model>`) and
// by session state, so it is permanent; a named profile becomes a second
// provider beside the default one rather than replacing it.
const provider = ctx.profile === 'default'
? 'freellmapi'
: `freellmapi-${ctx.profile.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')}`;
const roster = catalogModels(ctx.models);
const entries = [model, ...roster.filter(entry => entry.id !== model.id)]
.map(entry => ({
id: entry.id,
name: entry.name ?? entry.id,
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: contextWindow(entry),
maxTokens: outputLimit(entry),
}));
return {
files: [
{
path: configPath,
format: 'json',
value: {
...(ctx.profile === 'default'
? { agents: { defaults: { model: { primary: `${provider}/${model.id}` } } } }
: {}),
models: {
providers: {
[provider]: {
baseUrl: v1Url(ctx.url),
apiKey: '${FREELLMAPI_API_KEY}',
api: 'openai-completions',
// OpenClaw attaches its own `openclaw/<version>` identity only
// to endpoints it recognises; a custom origin gets the bare
// openai-node SDK user agent, which the gateway's analytics
// would file under "openai-sdk". A static provider header
// (verified to reach chat requests) names the client instead.
headers: { 'User-Agent': 'openclaw' },
models: entries,
},
},
},
},
},
{
path: path.join(stateDir, '.env'),
format: 'env',
sensitive: true,
content: `FREELLMAPI_API_KEY=${ctx.apiKey}\n`,
},
],
notes: [
'Install OpenClaw with: npm install -g openclaw@latest --allow-scripts=openclaw',
`Try it without the gateway daemon: openclaw agent exec --model ${provider}/${model.id} "Say hello"`,
ctx.profile === 'default'
? `${provider}/${model.id} is the default model; a running \`openclaw gateway\` needs a restart to pick the change up.`
: `${provider}/${model.id} is in the model picker; the default model is unchanged.`,
`Models are declared text-only; give a vision model \`input: ["text", "image"]\` under models.providers.${provider} to send it images.`,
],
};
}
// Hermes Agent (Nous Research) reads one YAML document, $HERMES_HOME/config.yaml
// (default ~/.hermes), and treats it as the single source of truth for the
// endpoint: OPENAI_BASE_URL is deliberately ignored for anything but
// api.openai.com, and OPENAI_API_KEY is only sent to OpenAI hosts. So the
// gateway goes into the `model` block as `provider: custom` with `base_url`,
// and the key travels as `api_key: "${FREELLMAPI_API_KEY}"` β€” Hermes's own
// substitution β€” with the value in $HERMES_HOME/.env (0600), the file Hermes
// loads on its own, so nothing needs exporting. A fresh install ships
// `model: ""` (a "not configured" sentinel); replacing it with the mapping is
// exactly what `hermes setup` would do, and it is what lets a headless first
// run skip the wizard. A named profile becomes an entry in the `providers`
// dict instead β€” picked with `/model custom:<name>:<model>` β€” leaving the
// default model alone.
function hermes(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
const home = process.env.HERMES_HOME?.trim() || path.join(ctx.homeDir, '.hermes');
const provider = `freellmapi-${ctx.profile.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')}`;
// Hermes only identifies itself to hosts it knows; a custom endpoint gets the
// bare openai-python user agent. `default_headers` is its documented way to
// override that, and it is what lets the gateway's analytics name the client.
const headers = { 'User-Agent': 'hermes-agent' };
const value: Record<string, unknown> = ctx.profile === 'default'
? {
model: {
provider: 'custom',
default: model.id,
base_url: v1Url(ctx.url),
api_key: '${FREELLMAPI_API_KEY}',
api_mode: 'chat_completions',
context_length: contextWindow(model),
default_headers: headers,
// The wizard strips these when it writes api_key; a leftover would
// point the key lookup at a variable this route never sets.
key_env: undefined,
api_key_env: undefined,
},
}
: {
providers: {
[provider]: {
name: `FreeLLMAPI (${ctx.profile})`,
api: v1Url(ctx.url),
api_key: '${FREELLMAPI_API_KEY}',
transport: 'chat_completions',
default_model: model.id,
context_length: contextWindow(model),
extra_headers: headers,
},
},
};
return {
files: [
{ path: path.join(home, 'config.yaml'), format: 'yaml', value },
{
path: path.join(home, '.env'),
format: 'env',
sensitive: true,
content: `FREELLMAPI_API_KEY=${ctx.apiKey}\n`,
},
],
notes: [
'Install Hermes Agent with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --skip-setup',
ctx.profile === 'default'
? `Try it with: hermes -z "Say hello" β€” custom/${model.id} is the default model.`
: `Pick it inside a chat with: /model custom:${provider}:${model.id} β€” the default model is unchanged.`,
'A running `hermes gateway` (Telegram, Discord, …) needs a restart to pick the change up.',
],
};
}
// Pi (pi.dev, @earendil-works/pi-coding-agent) keeps user configuration in its
// agent directory, $PI_CODING_AGENT_DIR (default ~/.pi/agent). An endpoint Pi
// does not ship is a provider entry in models.json β€” `baseUrl`, `api` and the
// `models` it serves β€” and `api: openai-completions` is the adapter for a
// /v1/chat/completions backend. Pi does not load a .env file of its own, so the
// key is written literally (the file is created 0600); its `$NAME` form would
// need the variable exported in every shell that starts Pi. A provider entry
// alone only adds models to `/model`: the startup choice is `defaultProvider`
// plus `defaultModel` in settings.json, which a named profile leaves alone.
function piAgentDir(homeDir: string): string {
return process.env.PI_CODING_AGENT_DIR?.trim() || path.join(homeDir, '.pi', 'agent');
}
function pi(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
const dir = piAgentDir(ctx.homeDir);
const provider = ctx.profile === 'default'
? 'freellmapi'
: `freellmapi-${ctx.profile.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')}`;
const roster = catalogModels(ctx.models);
const entries = [model, ...roster.filter(entry => entry.id !== model.id)]
.map(entry => ({
id: entry.id,
name: entry.name ?? entry.id,
// The gateway picks the upstream per request, so no model can promise a
// thinking API; Pi then sends no reasoning effort at all.
reasoning: false,
input: ['text'],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: contextWindow(entry),
maxTokens: outputLimit(entry),
}));
const files: Generation['files'] = [{
path: path.join(dir, 'models.json'),
format: 'json',
sensitive: true,
value: {
providers: {
[provider]: {
name: ctx.profile === 'default' ? 'FreeLLMAPI' : `FreeLLMAPI (${ctx.profile})`,
baseUrl: v1Url(ctx.url),
api: 'openai-completions',
apiKey: ctx.apiKey,
// Pi names itself only to a few hosts it knows; a custom endpoint gets
// the bare openai-node SDK user agent, which the gateway's analytics
// would file under "openai-sdk". This is the UA Pi already sends to
// Cloudflare, so analytics can name the client.
headers: { 'User-Agent': 'pi-coding-agent' },
models: entries,
},
},
},
}];
if (ctx.profile === 'default') {
files.push({
path: path.join(dir, 'settings.json'),
format: 'json',
value: { defaultProvider: provider, defaultModel: model.id },
});
}
return {
files,
notes: [
'Install Pi with: npm install -g @earendil-works/pi-coding-agent (or curl -fsSL https://pi.dev/install.sh | sh)',
ctx.profile === 'default'
? `Try it with: pi -p "Say hello" β€” ${provider}/${model.id} is the default model.`
: `Try it with: pi --model ${provider}/${model.id} -p "Say hello" β€” the default model is unchanged.`,
'Every catalog model is in /model; a running Pi session picks the change up when /model is opened.',
`Models are declared text-only; give a vision model \`input: ["text", "image"]\` under providers.${provider} in models.json to send it images.`,
],
};
}
// Reasonix (reasonix.io, esengine/DeepSeek-Reasonix) reads one global TOML
// document, <Reasonix home>/config.toml β€” ~/.reasonix, %APPDATA%\reasonix on
// Windows, or $REASONIX_HOME when set. Every endpoint is a `[[providers]]`
// entry; `kind = "openai"` posts to `<base_url>/chat/completions`, and a
// `models` list exposes the whole catalog under one connection. The entry only
// names its key variable (`api_key_env`); Reasonix keeps the value in the .env
// beside config.toml (0600) and reads it on its own, so nothing needs
// exporting. `default_model` is `provider/model`. Reasonix already sends
// `User-Agent: Reasonix/<version>` to every endpoint, so no header is needed.
function reasonixHome(homeDir: string): string {
if (process.env.REASONIX_HOME?.trim()) return process.env.REASONIX_HOME.trim();
if (process.platform === 'win32') {
return path.join(process.env.APPDATA?.trim() || path.join(homeDir, 'AppData', 'Roaming'), 'reasonix');
}
return path.join(homeDir, '.reasonix');
}
function reasonix(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
const home = reasonixHome(ctx.homeDir);
// A provider name is what every `provider/model` ref and saved session points
// at, so a named profile becomes a second entry rather than replacing it.
const provider = ctx.profile === 'default'
? 'freellmapi'
: `freellmapi-${ctx.profile.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')}`;
const roster = catalogModels(ctx.models);
const models = [model, ...roster.filter(entry => entry.id !== model.id)].map(entry => entry.id);
return {
files: [
{
path: path.join(home, 'config.toml'),
format: 'toml',
content: [
...(ctx.profile === 'default' ? [`default_model = ${JSON.stringify(`${provider}/${model.id}`)}`, ''] : []),
'[[providers]]',
`name = ${JSON.stringify(provider)}`,
`display_name = ${JSON.stringify(ctx.profile === 'default' ? 'FreeLLMAPI' : `FreeLLMAPI (${ctx.profile})`)}`,
'kind = "openai"',
`base_url = ${JSON.stringify(v1Url(ctx.url))}`,
`models = [${models.map(id => JSON.stringify(id)).join(', ')}]`,
`default = ${JSON.stringify(model.id)}`,
'api_key_env = "FREELLMAPI_API_KEY"',
`context_window = ${contextWindow(model)}`,
// Without a price Reasonix bills unknown models at a guessed rate;
// the gateway's models are free.
'price = { cache_hit = 0, input = 0, output = 0, currency = "$" }',
].join('\n'),
},
{
path: path.join(home, '.env'),
format: 'env',
sensitive: true,
content: `FREELLMAPI_API_KEY=${ctx.apiKey}\n`,
},
],
notes: [
'Install Reasonix with: npm install -g reasonix (or brew install esengine/reasonix/reasonix)',
ctx.profile === 'default'
? `Try it with: reasonix -p "Say hello" β€” ${provider}/${model.id} is the default model.`
: `Try it with: reasonix --model ${provider}/${model.id} -p "Say hello" β€” the default model is unchanged.`,
`Every catalog model is in /model as ${provider}/<id>; other [[providers]] entries in config.toml are kept.`,
`Reasonix Studio lists only the providers in [desktop].provider_access when that list is set; add "${provider}" there, or under Settings β†’ Model β†’ Access.`,
],
};
}
function cursor(_ctx: GenerateContext): Generation {
return {
files: [],
notes: [
'Cursor sends model traffic through its cloud service, so localhost is not reachable from Cursor.',
`Expose the gateway through a trusted HTTPS tunnel, then open Cursor Settings β†’ Models, enable Override OpenAI Base URL, and enter <public-url>/v1.`,
'Cursor only documents custom keys for standard chat models; custom base-URL overrides may not support Responses-based or built-in models.',
'The override affects Cursor globally while enabled, so disable it before switching back to built-in providers.',
'Use a separately revocable URL token if the client cannot set an Authorization header.',
],
};
}
function atomcode(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
const configDir = path.join(ctx.homeDir, '.atomcode');
// AtomCode picks its provider by name: a root `default_provider` key names
// one `[providers.<id>]` table, and `type = "openai"` is what makes that
// table speak the OpenAI-compatible wire. Root key first, table second, so
// mergeToml can slot the key above any tables already in the file.
return {
files: [{
path: path.join(configDir, 'config.toml'),
format: 'toml',
sensitive: true,
content: [
'default_provider = "freellmapi"',
'',
'[providers.freellmapi]',
'type = "openai"',
`base_url = ${JSON.stringify(v1Url(ctx.url))}`,
`api_key = ${JSON.stringify(ctx.apiKey)}`,
`model = ${JSON.stringify(model.id)}`,
`context_window = ${contextWindow(model)}`,
].join('\n'),
}],
notes: [
'AtomCode reads ~/.atomcode/config.toml; other [providers.*] tables in it are kept.',
`default_provider now points at [providers.freellmapi] with ${model.id} as its model.`,
'Point base_url at the unified /v1 endpoint; api_key is the unified key shown on the Agents page.',
'One-shot check: atomcode -p "Say hello" (telemetry is on by default: atomcode telemetry disable).',
],
};
}
function generic(ctx: GenerateContext): Generation {
const model = primaryModel(ctx.models, ctx.requestedModelId);
return {
files: [],
notes: [
`OPENAI_BASE_URL=${v1Url(ctx.url)}`,
`OPENAI_API_KEY=${ctx.apiKey || '<unified-key>'}`,
`OPENAI_MODEL=${model.id}`,
`curl ${v1Url(ctx.url)}/chat/completions -H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" -d '{"model":"${model.id}","messages":[{"role":"user","content":"Hello"}]}'`,
],
};
}
const metadata = [
['claude', 'Claude Code', 'code', 'file', 'Anthropic Messages', 'root', 'setup-claude', 'https://docs.anthropic.com/en/docs/claude-code', claude],
['codex', 'Codex CLI', 'code', 'file', 'OpenAI Responses', '/v1', 'setup-codex', 'https://developers.openai.com/codex', codex],
['cline', 'Cline', 'code', 'file', 'OpenAI Chat', '/v1', 'setup-cline', 'https://docs.cline.bot', cline],
['continue', 'Continue', 'code', 'file', 'OpenAI Chat', '/v1', 'setup-continue', 'https://docs.continue.dev', continueDev],
['aider', 'Aider', 'code', 'file', 'OpenAI Chat', '/v1', 'setup-aider', 'https://aider.chat/docs', aider],
['opencode', 'OpenCode', 'code', 'file', 'OpenAI Chat', '/v1', 'setup-opencode', 'https://opencode.ai/docs', opencode],
['goose', 'Goose', 'agent', 'file', 'OpenAI Chat', '/v1', 'setup-goose', 'https://block.github.io/goose', goose],
['qwen', 'Qwen Code', 'code', 'file', 'OpenAI or Gemini', '/v1', 'setup-qwen', 'https://qwenlm.github.io/qwen-code-docs', qwen],
['roo', 'Roo Code', 'code', 'file', 'OpenAI Chat', '/v1', 'setup-roo', 'https://docs.roocode.com', roo],
['kilo', 'Kilo Code', 'code', 'file', 'OpenAI Chat', '/v1', 'setup-kilo', 'https://kilocode.ai/docs', kilo],
['crush', 'Crush', 'code', 'file', 'OpenAI Chat', '/v1', 'setup-crush', 'https://github.com/charmbracelet/crush', crush],
['dsh', 'DeepSeek Harness', 'agent', 'file', 'OpenAI Chat', '/v1', 'setup-dsh', 'https://github.com/deepseek-ai/deepseek-harness', dsh],
['mimo', 'MiMo Code', 'code', 'file', 'OpenAI Chat', '/v1', 'setup-mimo', 'https://mimo.xiaomi.com/mimocode', mimo],
['atomcode', 'AtomCode', 'code', 'file', 'OpenAI Chat', '/v1', 'setup-atomcode', 'https://atomcode.atomgit.com/docs/en/', atomcode],
['openclaw', 'OpenClaw', 'agent', 'file', 'OpenAI Chat', '/v1', 'setup-openclaw', 'https://docs.openclaw.ai/gateway/config-tools/custom-providers', openclaw],
['hermes', 'Hermes Agent', 'agent', 'file', 'OpenAI Chat', '/v1', 'setup-hermes', 'https://hermes-agent.nousresearch.com/docs/', hermes],
['pi', 'Pi', 'code', 'file', 'OpenAI Chat', '/v1', 'setup-pi', 'https://pi.dev/docs/latest/models', pi],
['reasonix', 'Reasonix', 'code', 'file', 'OpenAI Chat', '/v1', 'setup-reasonix', 'https://github.com/esengine/DeepSeek-Reasonix/blob/studio/docs/CLI.md#configure-providers', reasonix],
['cursor', 'Cursor', 'code', 'guide', 'OpenAI Chat', '/v1', 'setup-cursor', 'https://docs.cursor.com', cursor],
['generic', 'Generic OpenAI client', 'agent', 'guide', 'OpenAI Chat', '/v1', 'setup-generic', 'https://github.com/tashfeenahmed/freellmapi', generic],
] as const;
export const tools: ToolDefinition[] = metadata.map(([
id,
name,
category,
configType,
protocol,
baseUrlSupport,
command,
docsUrl,
generate,
]) => ({
id,
name,
category,
configType,
protocol,
baseUrlSupport,
command,
docsUrl,
generate,
}));
export function getTool(id: string): ToolDefinition | undefined {
return tools.find(tool => tool.id === id);
}