Download cli/src/tools.test.ts from automindy/fokemal: direct link, hf CLI and curl.
- Browser
- Download file 30 kB
-
https://huggingface.co/spaces/automindy/fokemal/resolve/main/cli/src/tools.test.ts
- Command line
-
hf download hf://spaces/automindy/fokemal/cli/src/tools.test.ts
-
curl -L -o tools.test.ts https://huggingface.co/spaces/automindy/fokemal/resolve/main/cli/src/tools.test.ts
30 kB
| import fs from 'node:fs'; | |
| import path from 'node:path'; | |
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | |
| import { tools } from './tools.js'; | |
| import type { GenerateContext } from './types.js'; | |
| // CI runners export XDG_CONFIG_HOME (and could export MIMOCODE_HOME / DSH_HOME), | |
| // which the XDG-aware generators honour over ctx.homeDir. Pin them so golden | |
| // output is stable everywhere. | |
| beforeEach(() => { | |
| vi.stubEnv('XDG_CONFIG_HOME', ''); | |
| vi.stubEnv('MIMOCODE_HOME', ''); | |
| vi.stubEnv('DSH_HOME', ''); | |
| vi.stubEnv('OPENCLAW_HOME', ''); | |
| vi.stubEnv('OPENCLAW_STATE_DIR', ''); | |
| vi.stubEnv('OPENCLAW_CONFIG_PATH', ''); | |
| vi.stubEnv('HERMES_HOME', ''); | |
| vi.stubEnv('PI_CODING_AGENT_DIR', ''); | |
| vi.stubEnv('REASONIX_HOME', ''); | |
| }); | |
| afterEach(() => { | |
| vi.unstubAllEnvs(); | |
| }); | |
| const context: GenerateContext = { | |
| url: 'http://localhost:3000', | |
| apiKey: 'freellmapi-test-key', | |
| profile: 'default', | |
| homeDir: '/home/tester', | |
| models: [ | |
| { | |
| id: 'fast-coder', | |
| name: 'Fast Coder', | |
| available: true, | |
| context_window: 131072, | |
| }, | |
| { | |
| id: 'reasoning-model', | |
| name: 'Reasoning Model', | |
| available: true, | |
| context_window: 262144, | |
| }, | |
| ], | |
| }; | |
| describe('tool generators', () => { | |
| it('defaults to auto, never fusion, when the live catalog lists virtual models first', () => { | |
| const liveContext: GenerateContext = { | |
| ...context, | |
| models: [ | |
| { id: 'auto', name: 'Auto', available: true, context_window: 200_000 }, | |
| { id: 'fusion', name: 'Fusion', available: true, context_window: 2_000_000 }, | |
| ...context.models, | |
| ], | |
| }; | |
| const claude = tools.find(tool => tool.id === 'claude')!.generate(liveContext); | |
| const settings = claude.files[0].value as { env: Record<string, string> }; | |
| expect(settings.env.ANTHROPIC_MODEL).toBe('auto'); | |
| const codex = tools.find(tool => tool.id === 'codex')!.generate(liveContext); | |
| expect(codex.files[0].content).toContain('model = "auto"'); | |
| expect(codex.files[0].content).not.toContain('"fusion"'); | |
| }); | |
| for (const tool of tools) { | |
| it(`${tool.command} has stable golden output`, () => { | |
| expect(tool.generate(context)).toMatchSnapshot(); | |
| }); | |
| } | |
| it('honours an explicit --model instead of the default-model heuristic', () => { | |
| // `--model` was parsed into CliOptions and then dropped: it never reached | |
| // GenerateContext, so `setup-claude --model X` wrote whatever | |
| // primaryModel() preferred and said nothing about ignoring the flag. | |
| const pinned = { ...context, requestedModelId: 'reasoning-model' }; | |
| const claude = tools.find(tool => tool.id === 'claude')!.generate(pinned); | |
| expect(JSON.stringify(claude.files)).toContain('reasoning-model'); | |
| }); | |
| it('pins a requested model that the available-roster does not carry', () => { | |
| // Validated against the UNFILTERED catalog upstream, so an id missing from | |
| // ctx.models here is registered-but-out-of-quota, not a typo. Writing a | |
| // different model into the user's config would be the wrong repair. | |
| const pinned = { ...context, requestedModelId: 'benched-model' }; | |
| const claude = tools.find(tool => tool.id === 'claude')!.generate(pinned); | |
| expect(JSON.stringify(claude.files)).toContain('benched-model'); | |
| }); | |
| it('setup-codex with a named profile has stable golden output', () => { | |
| const generation = tools.find(tool => tool.id === 'codex')! | |
| .generate({ ...context, profile: 'work' }); | |
| expect(generation).toMatchSnapshot(); | |
| }); | |
| it('writes named Codex profiles as [profiles.NAME] inside config.toml', () => { | |
| const generation = tools.find(tool => tool.id === 'codex')! | |
| .generate({ ...context, profile: 'work' }); | |
| expect(generation.files).toHaveLength(1); | |
| const file = generation.files[0]; | |
| // Codex only reads ~/.codex/config.toml; per-profile files are ignored. | |
| expect(file.path).toBe('/home/tester/.codex/config.toml'); | |
| expect(file.content).toContain('[profiles.work]'); | |
| expect(file.content).toContain('model = "fast-coder"'); | |
| expect(file.content).toContain('model_provider = "freellmapi"'); | |
| expect(file.content).toContain('[model_providers.freellmapi]'); | |
| // A named profile must not hijack the root/default model selection. | |
| expect(file.content!.split('\n').findIndex(line => line.startsWith('model ='))) | |
| .toBeGreaterThan(file.content!.split('\n').indexOf('[profiles.work]')); | |
| expect(generation.notes.join('\n')).toContain('codex --profile work'); | |
| }); | |
| it('generates Cline current provider settings with repeatable selection', () => { | |
| const file = tools.find(tool => tool.id === 'cline')!.generate(context).files[0]; | |
| const state = file.value as any; | |
| expect(file.path).toBe('/home/tester/.cline/data/settings/providers.json'); | |
| expect(state).toMatchObject({ | |
| version: 1, | |
| lastUsedProvider: 'openai-compatible', | |
| }); | |
| expect(state.providers['openai-compatible']).toMatchObject({ | |
| settings: { | |
| provider: 'openai-compatible', | |
| protocol: 'openai-chat', | |
| client: 'openai-compatible', | |
| model: 'fast-coder', | |
| baseUrl: 'http://localhost:3000/v1', | |
| apiKey: 'freellmapi-test-key', | |
| contextWindow: 131072, | |
| capabilities: ['streaming', 'tools'], | |
| }, | |
| tokenSource: 'manual', | |
| }); | |
| }); | |
| it('generates a complete Continue v1 config with Agent tool support', () => { | |
| const config = tools.find(tool => tool.id === 'continue')! | |
| .generate(context).files[0].content!; | |
| expect(config).toContain('name: FreeLLMAPI'); | |
| expect(config).toContain('version: 1.0.0'); | |
| expect(config).toContain('schema: v1'); | |
| expect(config).toContain(' - tool_use'); | |
| expect(config).toContain(' apiKey: ${{ secrets.FREELLMAPI_API_KEY }}'); | |
| }); | |
| it('generates Goose custom-provider registration without persisting the key', () => { | |
| const generation = tools.find(tool => tool.id === 'goose')!.generate(context); | |
| const provider = generation.files.find(file => file.path.endsWith('freellmapi.json'))!.value as any; | |
| const selection = generation.files.find(file => file.path.endsWith('config.yaml'))!.content!; | |
| expect(provider).toMatchObject({ | |
| name: 'freellmapi', | |
| engine: 'openai', | |
| api_key_env: 'FREELLMAPI_API_KEY', | |
| base_url: 'http://localhost:3000/v1', | |
| requires_auth: true, | |
| dynamic_models: false, | |
| }); | |
| expect(provider.models.map((model: any) => model.name)).toEqual([ | |
| 'fast-coder', | |
| 'reasoning-model', | |
| ]); | |
| expect(selection).toContain('GOOSE_PROVIDER: freellmapi'); | |
| expect(JSON.stringify(generation)).not.toContain('freellmapi-test-key'); | |
| }); | |
| it('generates Qwen Code provider catalogs using a supported auth type', () => { | |
| const generation = tools.find(tool => tool.id === 'qwen')!.generate(context); | |
| const settings = generation.files.find(file => file.path.endsWith('settings.json'))!.value as any; | |
| expect(settings.security.auth.selectedType).toBe('openai'); | |
| expect(settings.model).toEqual({ name: 'fast-coder' }); | |
| expect(settings.modelProviders.openai.protocol).toBe('openai'); | |
| expect(settings.modelProviders.openai.models).toHaveLength(2); | |
| expect(settings.modelProviders.openai.models[0]).toMatchObject({ | |
| id: 'fast-coder', | |
| envKey: 'FREELLMAPI_API_KEY', | |
| baseUrl: 'http://localhost:3000/v1', | |
| generationConfig: { contextWindowSize: 131072 }, | |
| }); | |
| expect(settings.modelProviders).not.toHaveProperty('freellmapi'); | |
| }); | |
| it('wraps Roo Code imports in the documented provider profile envelope', () => { | |
| const config = tools.find(tool => tool.id === 'roo')! | |
| .generate(context).files[0].value as any; | |
| expect(config.providerProfiles.currentApiConfigName).toBe('freellmapi'); | |
| expect(config.providerProfiles.apiConfigs.freellmapi).toMatchObject({ | |
| apiProvider: 'openai', | |
| openAiBaseUrl: 'http://localhost:3000/v1', | |
| openAiModelId: 'fast-coder', | |
| }); | |
| expect(config.globalSettings).toEqual({}); | |
| }); | |
| it('writes Kilo trusted global config with every catalog model', () => { | |
| const file = tools.find(tool => tool.id === 'kilo')!.generate(context).files[0]; | |
| const config = file.value as any; | |
| expect(file.path).toBe('/home/tester/.config/kilo/kilo.jsonc'); | |
| expect(config.$schema).toBe('https://app.kilo.ai/config.json'); | |
| expect(config.model).toBe('openai-compatible/fast-coder'); | |
| expect(config.provider['openai-compatible'].options).toEqual({ | |
| apiKey: '{env:FREELLMAPI_API_KEY}', | |
| baseURL: 'http://localhost:3000/v1', | |
| }); | |
| expect(Object.keys(config.provider['openai-compatible'].models)).toEqual([ | |
| 'fast-coder', | |
| 'reasoning-model', | |
| ]); | |
| expect(config.provider['openai-compatible'].models['fast-coder']).toMatchObject({ | |
| tool_call: true, | |
| limit: { context: 131072, output: 8192 }, | |
| }); | |
| expect(JSON.stringify(config)).not.toContain('freellmapi-test-key'); | |
| }); | |
| it('uses Crush openai-compat schema, lists every catalog model, and selects the default', () => { | |
| const config = tools.find(tool => tool.id === 'crush')! | |
| .generate(context).files[0].value as any; | |
| expect(config.$schema).toBe('https://charm.land/crush.json'); | |
| expect(config.providers.freellmapi.type).toBe('openai-compat'); | |
| expect(config.providers.freellmapi.models.map((model: any) => model.id)).toEqual([ | |
| 'fast-coder', | |
| 'reasoning-model', | |
| ]); | |
| // A provider entry alone is never used: Crush auto-detects from whatever | |
| // credentials it finds on the machine unless `models.large`/`small` name it. | |
| expect(config.models).toEqual({ | |
| large: { provider: 'freellmapi', model: 'fast-coder' }, | |
| small: { provider: 'freellmapi', model: 'fast-coder' }, | |
| }); | |
| for (const model of config.providers.freellmapi.models) { | |
| expect(model).toMatchObject({ | |
| cost_per_1m_in: 0, | |
| cost_per_1m_out: 0, | |
| cost_per_1m_in_cached: 0, | |
| cost_per_1m_out_cached: 0, | |
| can_reason: false, | |
| supports_attachments: false, | |
| }); | |
| } | |
| }); | |
| it('keeps `auto` in the OpenCode, Crush and Goose rosters when it is the default', () => { | |
| // OpenCode resolves `freellmapi/auto` only if `auto` is a declared model, | |
| // and Crush's `models.large` must name a listed model; with `auto` in the | |
| // live catalog all three generators put it first rather than filter it out. | |
| const liveContext: GenerateContext = { | |
| ...context, | |
| models: [{ id: 'auto', name: 'Auto', available: true, context_window: 200_000 }, ...context.models], | |
| }; | |
| const opencode = tools.find(tool => tool.id === 'opencode')!.generate(liveContext).files[0].value as any; | |
| expect(opencode.model).toBe('freellmapi/auto'); | |
| expect(Object.keys(opencode.provider.freellmapi.models)).toEqual(['auto', 'fast-coder', 'reasoning-model']); | |
| const crush = tools.find(tool => tool.id === 'crush')!.generate(liveContext).files[0].value as any; | |
| expect(crush.models.large.model).toBe('auto'); | |
| expect(crush.providers.freellmapi.models[0].id).toBe('auto'); | |
| const goose = tools.find(tool => tool.id === 'goose')!.generate(liveContext); | |
| const provider = goose.files.find(file => file.path.endsWith('freellmapi.json'))!.value as any; | |
| expect(provider.models[0].name).toBe('auto'); | |
| // Qwen Code silently falls back to the first listed model when the selected | |
| // one is missing (it picked `fusion`); Kilo refuses to start outright. | |
| const qwen = tools.find(tool => tool.id === 'qwen')!.generate(liveContext).files[0].value as any; | |
| expect(qwen.modelProviders.openai.models[0].id).toBe('auto'); | |
| const kilo = tools.find(tool => tool.id === 'kilo')!.generate(liveContext).files[0].value as any; | |
| expect(kilo.model).toBe('openai-compatible/auto'); | |
| expect(Object.keys(kilo.provider['openai-compatible'].models)[0]).toBe('auto'); | |
| }); | |
| it('delivers the Continue secret through ~/.continue/.env', () => { | |
| const generation = tools.find(tool => tool.id === 'continue')!.generate(context); | |
| const env = generation.files.find(file => file.path.endsWith('.env'))!; | |
| expect(env.path).toBe('/home/tester/.continue/.env'); | |
| expect(env.sensitive).toBe(true); | |
| expect(env.content).toBe('FREELLMAPI_API_KEY=freellmapi-test-key\n'); | |
| const config = generation.files.find(file => file.path.endsWith('config.yaml'))!.content!; | |
| expect(config).not.toContain('freellmapi-test-key'); | |
| }); | |
| it('gives Roo an openai profile for the extension and a separate CLI import with an openrouter one', () => { | |
| const files = tools.find(tool => tool.id === 'roo')!.generate(context).files; | |
| const value = files[0].value as any; | |
| const configs = value.providerProfiles.apiConfigs; | |
| expect(value.providerProfiles.currentApiConfigName).toBe('freellmapi'); | |
| // The CLI forces its provider onto the CURRENT profile of the file it | |
| // imports, so its file must make the openrouter-type profile current. | |
| const cli = files[1].value as any; | |
| expect(files[1].path).toBe('/home/tester/.roo/freellmapi-cli.json'); | |
| expect(cli.providerProfiles.currentApiConfigName).toBe('freellmapi-cli'); | |
| expect(cli.providerProfiles.apiConfigs['freellmapi-cli']).toEqual(configs['freellmapi-cli']); | |
| expect(files[2].path).toBe('/home/tester/.roo/cli-settings.json'); | |
| expect(files[2].value).toEqual({ provider: 'openrouter', model: 'fast-coder' }); | |
| expect(configs.freellmapi).toMatchObject({ | |
| apiProvider: 'openai', | |
| openAiBaseUrl: 'http://localhost:3000/v1', | |
| openAiModelId: 'fast-coder', | |
| openAiCustomModelInfo: { contextWindow: 131072, maxTokens: 8192 }, | |
| }); | |
| expect(configs['freellmapi-cli']).toEqual({ | |
| apiProvider: 'openrouter', | |
| openRouterBaseUrl: 'http://localhost:3000/v1', | |
| openRouterApiKey: 'freellmapi-test-key', | |
| openRouterModelId: 'fast-coder', | |
| }); | |
| }); | |
| it('declares a complete DeepSeek Harness route and claims the default model', () => { | |
| const dsh = tools.find(tool => tool.id === 'dsh')!; | |
| const generation = dsh.generate(context); | |
| const [settings, env] = generation.files; | |
| expect(settings.path).toBe('/home/tester/.dsh/settings.yaml'); | |
| expect(settings.format).toBe('yaml'); | |
| const value = settings.value as { | |
| 'llm-pi-ai': { providers: Record<string, { api: string; baseURL: string; apiKeyEnv: string; models: { id: string }[] }> }; | |
| 'agent-default-model': { provider: string; model: string; reasoningEffort?: string }; | |
| }; | |
| const route = value['llm-pi-ai'].providers.freellmapi; | |
| // A hand-declared route must carry api, baseURL, and a non-empty models | |
| // list, or DSH refuses the whole section where it is written. | |
| expect(route.api).toBe('openai-completions'); | |
| expect(route.baseURL).toBe('http://localhost:3000/v1'); | |
| expect(route.apiKeyEnv).toBe('FREELLMAPI_API_KEY'); | |
| expect(route.models.map(model => model.id)).toEqual(['fast-coder', 'reasoning-model']); | |
| expect(value['agent-default-model']).toEqual({ | |
| provider: 'freellmapi', | |
| model: 'fast-coder', | |
| reasoningEffort: undefined, | |
| }); | |
| expect('reasoningEffort' in value['agent-default-model']).toBe(true); | |
| // DSH loads $DSH_HOME/.env as its user environment layer, which is how | |
| // `apiKeyEnv` resolves without an export. | |
| expect(env).toMatchObject({ | |
| path: '/home/tester/.dsh/.env', | |
| format: 'env', | |
| sensitive: true, | |
| content: 'FREELLMAPI_API_KEY=freellmapi-test-key\n', | |
| }); | |
| }); | |
| it('adds a named DeepSeek Harness profile as a second route without moving the default', () => { | |
| const dsh = tools.find(tool => tool.id === 'dsh')!; | |
| const value = dsh.generate({ ...context, profile: 'Work Laptop' }).files[0].value as Record<string, unknown>; | |
| const providers = (value['llm-pi-ai'] as { providers: Record<string, unknown> }).providers; | |
| expect(Object.keys(providers)).toEqual(['freellmapi-work-laptop']); | |
| expect(value['agent-default-model']).toBeUndefined(); | |
| }); | |
| it('writes MiMo Code a custom provider its own schema accepts', () => { | |
| const mimo = tools.find(tool => tool.id === 'mimo')!; | |
| const [config] = mimo.generate(context).files; | |
| // The global config directory is XDG-based, and `config.json` is the | |
| // weakest of the three names it merges, so a hand-written | |
| // `mimocode.json` still wins. | |
| expect(config.path).toBe('/home/tester/.config/mimocode/config.json'); | |
| const value = config.value as { | |
| model: string; | |
| provider: { | |
| freellmapi: { | |
| npm: string; | |
| options: Record<string, string>; | |
| models: Record<string, { limit: Record<string, number> }>; | |
| }; | |
| }; | |
| }; | |
| const provider = value.provider.freellmapi; | |
| expect(provider.npm).toBe('@ai-sdk/openai-compatible'); | |
| expect(provider.options).toEqual({ | |
| baseURL: 'http://localhost:3000/v1', | |
| apiKey: '{env:FREELLMAPI_API_KEY}', | |
| }); | |
| // `provider.<id>.models.<id>.limit` requires context AND output. | |
| expect(provider.models['fast-coder'].limit).toEqual({ context: 131072, output: 8192 }); | |
| // The default model has to name an entry that exists in that map. | |
| expect(value.model).toBe('freellmapi/fast-coder'); | |
| expect(Object.keys(provider.models)).toContain(value.model.split('/')[1]); | |
| // MIMOCODE_API_KEY and MIMOCODE_BASE_URL do not exist. | |
| expect(JSON.stringify(mimo.generate(context))).not.toContain('MIMOCODE_'); | |
| }); | |
| it('keeps the MiMo Code default model in its own provider map when auto leads', () => { | |
| const liveContext: GenerateContext = { | |
| ...context, | |
| models: [ | |
| { id: 'auto', name: 'Auto', available: true, context_window: 200_000 }, | |
| ...context.models, | |
| ], | |
| }; | |
| const value = tools.find(tool => tool.id === 'mimo')! | |
| .generate(liveContext).files[0].value as { | |
| model: string; | |
| provider: { freellmapi: { models: Record<string, unknown> } }; | |
| }; | |
| expect(value.model).toBe('freellmapi/auto'); | |
| expect(Object.keys(value.provider.freellmapi.models)).toContain('auto'); | |
| }); | |
| it('writes OpenClaw as a custom provider plus the default model ref, key via its own .env', () => { | |
| // OpenClaw resolves `${VAR}` in openclaw.json from the global | |
| // ~/.openclaw/.env it loads itself, so the pair works with nothing | |
| // exported. A custom origin gets no attribution header from OpenClaw, so | |
| // the provider carries a static User-Agent the gateway can classify. | |
| const [config, env] = tools.find(tool => tool.id === 'openclaw')!.generate(context).files; | |
| expect(config.path).toBe('/home/tester/.openclaw/openclaw.json'); | |
| expect(config.format).toBe('json'); | |
| const value = config.value as { | |
| agents: { defaults: { model: { primary: string } } } | |
| models: { providers: Record<string, { baseUrl: string; apiKey: string; api: string; headers: Record<string, string>; models: { id: string }[] }> } | |
| }; | |
| expect(value.agents.defaults.model.primary).toBe('freellmapi/fast-coder'); | |
| const provider = value.models.providers.freellmapi; | |
| expect(provider.baseUrl).toBe('http://localhost:3000/v1'); | |
| expect(provider.apiKey).toBe('${FREELLMAPI_API_KEY}'); | |
| expect(provider.api).toBe('openai-completions'); | |
| expect(provider.headers).toEqual({ 'User-Agent': 'openclaw' }); | |
| expect(provider.models.map(model => model.id)).toEqual(['fast-coder', 'reasoning-model']); | |
| expect(env.path).toBe('/home/tester/.openclaw/.env'); | |
| expect(env.sensitive).toBe(true); | |
| expect(env.content).toBe('FREELLMAPI_API_KEY=freellmapi-test-key\n'); | |
| }); | |
| it('gives a named OpenClaw profile its own provider without touching the default model', () => { | |
| const generation = tools.find(tool => tool.id === 'openclaw')!.generate({ ...context, profile: 'Work Box' }); | |
| const value = generation.files[0].value as { agents?: unknown; models: { providers: Record<string, unknown> } }; | |
| expect(value.agents).toBeUndefined(); | |
| expect(Object.keys(value.models.providers)).toEqual(['freellmapi-work-box']); | |
| }); | |
| it('honours OPENCLAW_CONFIG_PATH and OPENCLAW_STATE_DIR the way OpenClaw does', () => { | |
| vi.stubEnv('OPENCLAW_STATE_DIR', '/srv/claw'); | |
| const [config, env] = tools.find(tool => tool.id === 'openclaw')!.generate(context).files; | |
| expect(config.path).toBe('/srv/claw/openclaw.json'); | |
| expect(env.path).toBe('/srv/claw/.env'); | |
| vi.stubEnv('OPENCLAW_CONFIG_PATH', '/etc/openclaw.json'); | |
| expect(tools.find(tool => tool.id === 'openclaw')!.generate(context).files[0].path).toBe('/etc/openclaw.json'); | |
| }); | |
| it('writes Hermes Agent as a custom model block with the key routed through its .env', () => { | |
| // config.yaml is Hermes's single source of truth for the endpoint: | |
| // OPENAI_BASE_URL is ignored for non-OpenAI hosts and OPENAI_API_KEY is only | |
| // sent to OpenAI hosts, so both go in the `model` block, the key as the | |
| // `${VAR}` form Hermes expands from ~/.hermes/.env. Leftover key_env keys | |
| // from the wizard are retired so they cannot shadow api_key. | |
| const [config, env] = tools.find(tool => tool.id === 'hermes')!.generate(context).files; | |
| expect(config.path).toBe('/home/tester/.hermes/config.yaml'); | |
| expect(config.format).toBe('yaml'); | |
| expect(config.value).toEqual({ | |
| model: { | |
| provider: 'custom', | |
| default: 'fast-coder', | |
| base_url: 'http://localhost:3000/v1', | |
| api_key: '${FREELLMAPI_API_KEY}', | |
| api_mode: 'chat_completions', | |
| context_length: 131072, | |
| default_headers: { 'User-Agent': 'hermes-agent' }, | |
| key_env: undefined, | |
| api_key_env: undefined, | |
| }, | |
| }); | |
| expect(env.path).toBe('/home/tester/.hermes/.env'); | |
| expect(env.sensitive).toBe(true); | |
| expect(env.content).toBe('FREELLMAPI_API_KEY=freellmapi-test-key\n'); | |
| }); | |
| it('gives a named Hermes profile a providers entry instead of the default model', () => { | |
| vi.stubEnv('HERMES_HOME', '/srv/hermes'); | |
| const [config] = tools.find(tool => tool.id === 'hermes')!.generate({ ...context, profile: 'lab' }).files; | |
| expect(config.path).toBe('/srv/hermes/config.yaml'); | |
| const value = config.value as { model?: unknown; providers: Record<string, { api: string; default_model: string }> }; | |
| expect(value.model).toBeUndefined(); | |
| expect(value.providers['freellmapi-lab'].api).toBe('http://localhost:3000/v1'); | |
| expect(value.providers['freellmapi-lab'].default_model).toBe('fast-coder'); | |
| }); | |
| it('writes Pi as a models.json provider plus the startup default in settings.json', () => { | |
| // Pi has no .env of its own, so the key is literal in the 0600 models.json. | |
| // A provider entry only fills /model; defaultProvider + defaultModel in | |
| // settings.json is what a fresh `pi` starts on. A custom origin gets the | |
| // bare openai-node UA from Pi, so the provider names the client itself. | |
| const [models, settings] = tools.find(tool => tool.id === 'pi')!.generate(context).files; | |
| expect(models.path).toBe('/home/tester/.pi/agent/models.json'); | |
| expect(models.format).toBe('json'); | |
| expect(models.sensitive).toBe(true); | |
| const provider = (models.value as { providers: Record<string, any> }).providers.freellmapi; | |
| expect(provider).toMatchObject({ | |
| name: 'FreeLLMAPI', | |
| baseUrl: 'http://localhost:3000/v1', | |
| api: 'openai-completions', | |
| apiKey: 'freellmapi-test-key', | |
| headers: { 'User-Agent': 'pi-coding-agent' }, | |
| }); | |
| expect(provider.models.map((model: { id: string }) => model.id)).toEqual(['fast-coder', 'reasoning-model']); | |
| expect(provider.models[0]).toEqual({ | |
| id: 'fast-coder', | |
| name: 'Fast Coder', | |
| reasoning: false, | |
| input: ['text'], | |
| cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, | |
| contextWindow: 131072, | |
| maxTokens: 8192, | |
| }); | |
| expect(settings.path).toBe('/home/tester/.pi/agent/settings.json'); | |
| expect(settings.sensitive).toBeUndefined(); | |
| expect(settings.value).toEqual({ defaultProvider: 'freellmapi', defaultModel: 'fast-coder' }); | |
| }); | |
| it('keeps `auto` first in the Pi roster when it is the default', () => { | |
| const liveContext: GenerateContext = { | |
| ...context, | |
| models: [{ id: 'auto', name: 'Auto', available: true, context_window: 200_000 }, ...context.models], | |
| }; | |
| const [models, settings] = tools.find(tool => tool.id === 'pi')!.generate(liveContext).files; | |
| const provider = (models.value as { providers: Record<string, { models: { id: string }[] }> }).providers.freellmapi; | |
| expect(provider.models.map(model => model.id)).toEqual(['auto', 'fast-coder', 'reasoning-model']); | |
| expect(settings.value).toEqual({ defaultProvider: 'freellmapi', defaultModel: 'auto' }); | |
| }); | |
| it('gives a named Pi profile its own provider and leaves the default model alone', () => { | |
| vi.stubEnv('PI_CODING_AGENT_DIR', '/srv/pi'); | |
| const files = tools.find(tool => tool.id === 'pi')!.generate({ ...context, profile: 'Work Box' }).files; | |
| expect(files).toHaveLength(1); | |
| expect(files[0].path).toBe('/srv/pi/models.json'); | |
| const providers = (files[0].value as { providers: Record<string, { name: string }> }).providers; | |
| expect(Object.keys(providers)).toEqual(['freellmapi-work-box']); | |
| expect(providers['freellmapi-work-box'].name).toBe('FreeLLMAPI (Work Box)'); | |
| }); | |
| it('writes Reasonix as a [[providers]] entry plus its key in the Reasonix .env', () => { | |
| // Reasonix names the key variable in config.toml and keeps the value in the | |
| // .env beside it, which it loads itself. `default_model` is provider/model | |
| // and must sit in the root region, above the array entry. | |
| const [config, env] = tools.find(tool => tool.id === 'reasonix')!.generate(context).files; | |
| expect(config.path).toBe('/home/tester/.reasonix/config.toml'); | |
| expect(config.format).toBe('toml'); | |
| expect(config.sensitive).toBeUndefined(); | |
| const lines = (config.content ?? '').split('\n'); | |
| expect(lines[0]).toBe('default_model = "freellmapi/fast-coder"'); | |
| expect(lines.indexOf('[[providers]]')).toBeGreaterThan(0); | |
| expect(lines).toContain('name = "freellmapi"'); | |
| expect(lines).toContain('kind = "openai"'); | |
| expect(lines).toContain('base_url = "http://localhost:3000/v1"'); | |
| expect(lines).toContain('models = ["fast-coder", "reasoning-model"]'); | |
| expect(lines).toContain('default = "fast-coder"'); | |
| expect(lines).toContain('api_key_env = "FREELLMAPI_API_KEY"'); | |
| expect(lines).toContain('context_window = 131072'); | |
| expect(config.content).not.toContain('freellmapi-test-key'); | |
| expect(env).toEqual({ | |
| path: '/home/tester/.reasonix/.env', | |
| format: 'env', | |
| sensitive: true, | |
| content: 'FREELLMAPI_API_KEY=freellmapi-test-key\n', | |
| }); | |
| }); | |
| it('keeps `auto` first in the Reasonix roster when it is the default', () => { | |
| const liveContext: GenerateContext = { | |
| ...context, | |
| models: [{ id: 'auto', name: 'Auto', available: true, context_window: 200_000 }, ...context.models], | |
| }; | |
| const [config] = tools.find(tool => tool.id === 'reasonix')!.generate(liveContext).files; | |
| expect(config.content).toContain('default_model = "freellmapi/auto"'); | |
| expect(config.content).toContain('models = ["auto", "fast-coder", "reasoning-model"]'); | |
| }); | |
| it('gives a named Reasonix profile its own provider and leaves the default model alone', () => { | |
| vi.stubEnv('REASONIX_HOME', '/srv/reasonix'); | |
| const [config, env] = tools.find(tool => tool.id === 'reasonix')!.generate({ ...context, profile: 'Work Box' }).files; | |
| expect(config.path).toBe('/srv/reasonix/config.toml'); | |
| expect(env.path).toBe('/srv/reasonix/.env'); | |
| expect(config.content).not.toContain('default_model'); | |
| expect(config.content!.split('\n')[0]).toBe('[[providers]]'); | |
| expect(config.content).toContain('name = "freellmapi-work-box"'); | |
| expect(config.content).toContain('display_name = "FreeLLMAPI (Work Box)"'); | |
| }); | |
| it('writes AtomCode as default_provider plus a typed [providers.freellmapi] table', () => { | |
| // AtomCode ignores a bare `[provider]` table: it looks up the table named | |
| // by the root `default_provider` key, and only a `type = "openai"` table | |
| // speaks the OpenAI-compatible wire. `context_window` is its own key. | |
| const [config] = tools.find(tool => tool.id === 'atomcode')!.generate(context).files; | |
| expect(config.path).toBe('/home/tester/.atomcode/config.toml'); | |
| expect(config.format).toBe('toml'); | |
| expect(config.sensitive).toBe(true); | |
| const lines = (config.content ?? '').split('\n'); | |
| expect(lines[0]).toBe('default_provider = "freellmapi"'); | |
| expect(lines).toContain('[providers.freellmapi]'); | |
| expect(lines.indexOf('[providers.freellmapi]')).toBeGreaterThan(0); | |
| expect(lines).toContain('type = "openai"'); | |
| expect(lines).toContain('base_url = "http://localhost:3000/v1"'); | |
| expect(lines).toContain('api_key = "freellmapi-test-key"'); | |
| expect(lines).toContain('model = "fast-coder"'); | |
| expect(lines).toContain('context_window = 131072'); | |
| expect(config.content).not.toContain('[provider]'); | |
| }); | |
| it('uses /v1 for every OpenAI-compatible generated base URL', () => { | |
| for (const tool of tools.filter(entry => entry.protocol.startsWith('OpenAI'))) { | |
| expect(tool.baseUrlSupport, tool.id).toBe('/v1'); | |
| } | |
| expect(tools.find(tool => tool.id === 'claude')!.baseUrlSupport).toBe('root'); | |
| }); | |
| it('keeps the dashboard metadata export in sync with the tool catalog', () => { | |
| const expected = tools.map(({ generate: _generate, ...tool }) => tool); | |
| const packageMetadata = JSON.parse(fs.readFileSync( | |
| path.resolve(import.meta.dirname, '../tools.json'), | |
| 'utf8', | |
| )); | |
| const dashboardMetadata = JSON.parse(fs.readFileSync( | |
| path.resolve(import.meta.dirname, '../../client/src/data/agent-tools.json'), | |
| 'utf8', | |
| )); | |
| expect(packageMetadata).toEqual(expected); | |
| expect(dashboardMetadata).toEqual(expected); | |
| }); | |
| }); | |