mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Addresses two live-validation findings on the Run-menu custom-model picker: 1. Both claude.ai and ANTHROPIC_API_KEY set warning. Claude Code still coexists an OAuth login with an injected ANTHROPIC_API_KEY in the same config directory and warns about it (confirmed cosmetic - the API key wins for actual requests, verified via a real session's own API Usage Billing line). A custom-model claude session now gets an isolated CLAUDE_CONFIG_DIR (registry-declared via a new configDirVar field, empty, no files written into it) so there is nothing to conflict with. projects is symlinked (junction on Windows) back into the real config dir so the response viewer, subagent windows and Read My Mind keep working for that session, best-effort. 2. Context-window overflow. Claude Code assumes a large default context window for a model id it doesn't recognise and never compacts, so a custom endpoint's real, much smaller context (verified live: a 400 exceeding a 16384-token llama-swap model with a stock ~33.7K-token system prompt) silently overflows. Discovery now also learns each model's real context length from llama.cpp/llama-swap's GET /props?model=<id> (n_ctx), but ONLY for a model llama-swap's own /v1/models response already marks status.value === 'loaded' - never an unloaded one, since llama-swap treats ?model= as a routing hint and probing an unloaded model risks triggering an actual, slow, GPU-swapping load as a side effect of read-only discovery. A server with no status field at all gets no enrichment rather than a guess; a model not probed this round keeps its previously-learned value until it disappears from the list entirely. Stored per model (CustomModelHost.modelContextLengths) and applied via a new contextLengthVar registry field, set to CLAUDE_CODE_MAX_CONTEXT_TOKENS for claude. Both new fields live on the existing env-kind customModelInjection capability shape, declared only on claude's registry entry - every other CLI's injection is unaffected (pinned by test). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
207 lines
9.7 KiB
TypeScript
207 lines
9.7 KiB
TypeScript
/**
|
|
* @fileoverview Tests for the Custom Model Endpoint Profiles pure builder.
|
|
* Uses the real CLI registry entries (getCli) rather than hand-rolled
|
|
* fixtures, so a change to a real entry's customModelInjection declaration
|
|
* is exercised here automatically instead of silently diverging.
|
|
*
|
|
* Port: N/A (no server needed)
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { getCli } from '../src/config/cli-registry/index.js';
|
|
import {
|
|
buildCustomModelInjection,
|
|
withV1Suffix,
|
|
GROK_CUSTOM_MODEL_NAME,
|
|
type CustomModelEndpoint,
|
|
} from '../src/custom-model-injection.js';
|
|
|
|
const endpoint: CustomModelEndpoint = {
|
|
id: 'ep1',
|
|
label: 'llama.cpp box',
|
|
baseUrl: 'http://192.168.1.50:8080',
|
|
apiKey: 'my-key',
|
|
};
|
|
|
|
function entryOrThrow(id: string) {
|
|
const entry = getCli(id);
|
|
if (!entry) throw new Error(`missing CLI registry entry: ${id}`);
|
|
return entry;
|
|
}
|
|
|
|
describe('withV1Suffix', () => {
|
|
it('appends /v1 when missing', () => {
|
|
expect(withV1Suffix('http://host:8080')).toBe('http://host:8080/v1');
|
|
});
|
|
|
|
it('is idempotent when already present', () => {
|
|
expect(withV1Suffix('http://host:8080/v1')).toBe('http://host:8080/v1');
|
|
expect(withV1Suffix('http://host:8080/v1/')).toBe('http://host:8080/v1');
|
|
});
|
|
|
|
it('strips a trailing slash with no /v1', () => {
|
|
expect(withV1Suffix('http://host:8080/')).toBe('http://host:8080/v1');
|
|
});
|
|
});
|
|
|
|
describe('buildCustomModelInjection', () => {
|
|
it('claude: env kind sets base URL, api key, and all three tier model vars', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3');
|
|
expect(result.kind).toBe('env');
|
|
if (result.kind !== 'env') throw new Error('unreachable');
|
|
expect(result.envOverrides).toEqual({
|
|
ANTHROPIC_BASE_URL: 'http://192.168.1.50:8080',
|
|
ANTHROPIC_API_KEY: 'my-key',
|
|
ANTHROPIC_DEFAULT_SONNET_MODEL: 'qwen3',
|
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'qwen3',
|
|
ANTHROPIC_DEFAULT_OPUS_MODEL: 'qwen3',
|
|
});
|
|
});
|
|
|
|
it('claude: also declares configDirVar (CLAUDE_CONFIG_DIR isolation) on the env-kind result', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3');
|
|
if (result.kind !== 'env') throw new Error('unreachable');
|
|
expect(result.configDirVar).toBe('CLAUDE_CONFIG_DIR');
|
|
});
|
|
|
|
it('claude: injects CLAUDE_CODE_MAX_CONTEXT_TOKENS when a context length is known', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', 16384);
|
|
if (result.kind !== 'env') throw new Error('unreachable');
|
|
expect(result.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('16384');
|
|
});
|
|
|
|
it('claude: omits CLAUDE_CODE_MAX_CONTEXT_TOKENS when the context length is unknown', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3');
|
|
if (result.kind !== 'env') throw new Error('unreachable');
|
|
expect(result.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined();
|
|
});
|
|
|
|
it('claude: falls back to a dummy key when the endpoint has none', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('claude'), { ...endpoint, apiKey: undefined }, 'qwen3');
|
|
if (result.kind !== 'env') throw new Error('unreachable');
|
|
expect(result.envOverrides.ANTHROPIC_API_KEY).toBe('local-dummy-key');
|
|
});
|
|
|
|
it('opencode: configContentEnv carries a JSON blob in OPENCODE_CONFIG_CONTENT', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3');
|
|
expect(result.kind).toBe('env');
|
|
if (result.kind !== 'env') throw new Error('unreachable');
|
|
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
|
|
expect(parsed.model).toBe('custom/qwen3');
|
|
expect(parsed.provider.custom.options.baseURL).toBe('http://192.168.1.50:8080/v1');
|
|
expect(parsed.provider.custom.options.apiKey).toBe('my-key');
|
|
expect(parsed.provider.custom.models.qwen3).toEqual({});
|
|
});
|
|
|
|
it('codex: configDir writes an isolated config.toml with model/base_url, and the key rides as extraEnv (never a literal TOML field)', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3');
|
|
expect(result.kind).toBe('configDir');
|
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
|
expect(result.dirEnvVar).toBe('CODEX_HOME');
|
|
expect(result.files).toHaveLength(1);
|
|
expect(result.files[0].relPath).toBe('config.toml');
|
|
expect(result.files[0].content).toContain('model = "qwen3"');
|
|
expect(result.files[0].content).toContain('base_url = "http://192.168.1.50:8080/v1"');
|
|
expect(result.files[0].content).toContain('wire_api = "responses"');
|
|
expect(result.files[0].content).not.toContain('api_key ='); // never a literal TOML field
|
|
expect(result.files[0].content).toContain('env_key = "CODEMAN_CUSTOM_MODEL_API_KEY"');
|
|
expect(result.extraEnv).toEqual({ CODEMAN_CUSTOM_MODEL_API_KEY: 'my-key' });
|
|
});
|
|
|
|
it('codex: escapes a quote in the model id so it cannot break out of the TOML string', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'weird"model');
|
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
|
expect(result.files[0].content).toContain('model = "weird\\"model"');
|
|
});
|
|
|
|
it('pi: configDir writes .pi/agent/models.json, redirected via HOME (verified live — PI_CONFIG_DIR does nothing for pi)', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3');
|
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
|
expect(result.dirEnvVar).toBe('HOME');
|
|
expect(result.files[0].relPath).toBe('.pi/agent/models.json');
|
|
const parsed = JSON.parse(result.files[0].content);
|
|
expect(parsed.providers.custom.baseUrl).toBe('http://192.168.1.50:8080/v1');
|
|
expect(parsed.providers.custom.authHeader).toBe(true);
|
|
expect(parsed.providers.custom.models).toEqual([{ id: 'qwen3' }]); // array, NOT keyed by id
|
|
});
|
|
|
|
it('omp: configDir writes .omp/agent/models.yml, redirected via HOME (verified live end-to-end)', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('omp'), endpoint, 'qwen3');
|
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
|
expect(result.dirEnvVar).toBe('HOME');
|
|
expect(result.files[0].relPath).toBe('.omp/agent/models.yml');
|
|
expect(result.files[0].content).toContain('baseUrl: "http://192.168.1.50:8080/v1"');
|
|
expect(result.files[0].content).toContain('authHeader: true');
|
|
expect(result.files[0].content).toContain('- id: "qwen3"');
|
|
});
|
|
|
|
it('gemini: env kind sets GOOGLE_GEMINI_BASE_URL/GEMINI_API_KEY/GEMINI_MODEL', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('gemini'), endpoint, 'qwen3');
|
|
if (result.kind !== 'env') throw new Error('unreachable');
|
|
expect(result.envOverrides).toEqual({
|
|
GOOGLE_GEMINI_BASE_URL: 'http://192.168.1.50:8080',
|
|
GEMINI_API_KEY: 'my-key',
|
|
GEMINI_MODEL: 'qwen3',
|
|
});
|
|
});
|
|
|
|
it('grok: configDir writes a config.toml [model.<name>] block, key rides as extraEnv (XAI_API_KEY)', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('grok'), endpoint, 'qwen3');
|
|
expect(result.kind).toBe('configDir');
|
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
|
expect(result.dirEnvVar).toBe('GROK_HOME');
|
|
expect(result.files).toHaveLength(1);
|
|
expect(result.files[0].relPath).toBe('config.toml');
|
|
expect(result.files[0].content).toContain('model = "qwen3"');
|
|
expect(result.files[0].content).toContain('base_url = "http://192.168.1.50:8080/v1"');
|
|
expect(result.files[0].content).toContain('api_backend = "chat_completions"');
|
|
expect(result.files[0].content).toContain('env_key = "XAI_API_KEY"');
|
|
expect(result.files[0].content).not.toContain('api_key ='); // never a literal TOML field
|
|
expect(result.extraEnv).toEqual({ XAI_API_KEY: 'my-key' });
|
|
});
|
|
|
|
it('deepseek: env kind sets base URL/key only, no model var', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3');
|
|
if (result.kind !== 'env') throw new Error('unreachable');
|
|
expect(result.envOverrides).toEqual({
|
|
DEEPSEEK_BASE_URL: 'http://192.168.1.50:8080',
|
|
DEEPSEEK_API_KEY: 'my-key',
|
|
});
|
|
});
|
|
|
|
it('antigravity: unsupported', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('antigravity'), endpoint, 'qwen3');
|
|
expect(result).toEqual({ kind: 'unsupported' });
|
|
});
|
|
|
|
it('shell: unsupported', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('shell'), endpoint, 'qwen3');
|
|
expect(result).toEqual({ kind: 'unsupported' });
|
|
});
|
|
});
|
|
|
|
describe('launchModel (the model launch param that selects the injected provider)', () => {
|
|
it('pi and omp get --model custom/<modelId>: the config file alone leaves them on their default provider', () => {
|
|
for (const id of ['pi', 'omp']) {
|
|
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3.5-0.8b');
|
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
|
expect(result.launchModel, id).toBe('custom/qwen3.5-0.8b');
|
|
}
|
|
});
|
|
|
|
it('grok gets the [model.<name>] block name, pinned to the constant the config template writes', () => {
|
|
const result = buildCustomModelInjection(entryOrThrow('grok'), endpoint, 'qwen3');
|
|
if (result.kind !== 'configDir') throw new Error('unreachable');
|
|
expect(result.launchModel).toBe(GROK_CUSTOM_MODEL_NAME);
|
|
expect(result.files[0].content).toContain(`[model.${GROK_CUSTOM_MODEL_NAME}]`);
|
|
});
|
|
|
|
it('CLIs whose config selects the model on its own declare none', () => {
|
|
for (const id of ['claude', 'opencode', 'codex', 'gemini', 'deepseek']) {
|
|
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3');
|
|
if (result.kind === 'unsupported') throw new Error('unreachable');
|
|
expect(result.launchModel, id).toBeUndefined();
|
|
}
|
|
});
|
|
});
|