feat(custom-model): skip Claude Code's first-run wizard on custom-model launches

A fresh, isolated CLAUDE_CONFIG_DIR (used to keep an injected API key
away from a stored claude.ai OAuth login) looks like a brand-new Claude
Code profile to the CLI, so it replays its ENTIRE first-run sequence on
every single launch: the theme picker, the security-notes screen, the
per-project "trust this folder?" dialog, and (running with
--dangerously-skip-permissions) a one-time bypass-permissions warning —
confirmed live, none of which a real, already-onboarded profile shows
again.

- New registry-declared env-kind field `skipFirstRunPrompts` (alongside
  apiKeyTrustFile, which it reuses) — claude's entry only, carried
  through buildCustomModelInjection (pure) into
  applyCustomModelInjection (IO).
- seedFirstRunOnboardingState(): merges hasCompletedOnboarding: true and
  this session's own projects[workingDir].hasTrustDialogAccepted: true
  into the same <configDir>/.claude.json the API-key trust file already
  writes to — other projects and other fields on this session's own
  entry are left untouched.
- seedSkipBypassPermissionsPrompt(): merges
  skipDangerousModePermissionPrompt: true into <configDir>/settings.json,
  a separate file, same corrupt-tolerant merge behavior.
- applyCustomModelInjection() gains an optional workingDir parameter,
  threaded from session.workingDir (dedicated apply route) /
  resolvedCasePath (quick-start route) — boot recovery omits it
  (a dialog already answered once needs no re-seed on the same,
  persisted isolated directory).

Tests added at the pure-builder, IO-wrapper (including merge-preserves-
other-fields and corrupt-file-tolerance cases), and existing directory-
listing assertions updated for the new settings.json file. Typecheck/
lint/format clean; full suite shows no new regressions (baseline
pre-existing Windows-environment failures unchanged, 8 more passing
tests than before — the ones added here).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
This commit is contained in:
Devvyn
2026-09-17 09:21:36 +08:00
co-authored by Claude Sonnet 5
parent 2c89359d42
commit 211b872335
11 changed files with 272 additions and 6 deletions
@@ -13,6 +13,10 @@ Everything below was found and fixed against a **real llama-swap server**, not j
- **Context floor too small for Claude Code to even start.** Fixing the overflow above surfaced a second, unfixable-by-injection failure: Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on their own (confirmed live via an `in:0 out:0` failure on the very first message), which can exceed a small model's entire real context before any conversation history exists to trim — no `CLAUDE_CODE_MAX_CONTEXT_TOKENS` value fixes that, since it only governs when history gets compacted. Applying such a model now returns a warning (gated on the CLI registry declaring a `contextLengthVar`, so it's a no-op for every other harness) instead of launching straight into a guaranteed first-message failure, and the Run-menu picker shows it as an in-app dialog naming the model, its discovered context and the ~40K safe floor, with the actual fix spelled out: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on auto-fit, which optimizes for the biggest model that fits rather than the biggest context. "Launch anyway" is still one click away.
- **The real root cause of "it still says opus, not my model."** llama.cpp runs exactly one model at a time; llama-swap unloads and reloads it on demand, which can take anywhere from a few seconds to well over a minute — long enough that a session mid-swap is indistinguishable from one that never left the native backend. Applying a selection now checks llama-swap's own `GET /running` first (feature-detected; a plain llama.cpp/OpenAI-compatible server has no such endpoint and is never checked); if switching would unload a model **another live session is actively using**, the apply is refused with a warning naming that session instead of silently switching, and a confirmation retry proceeds anyway. Either way, a sticky "loading model…" toast now covers the actual swap window until llama-swap reports the target model ready, so a prompt sent mid-swap reads as "loading," never as silence or an answer from whatever was loaded a moment before.
- **Claude's whole first-run sequence, on every single launch.** A fresh, otherwise-empty `CLAUDE_CONFIG_DIR` isn't just missing the API-key approval above — Claude Code treats it as a brand-new profile and replays the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running bypassed) a one-time permissions-bypass warning, every time, confirmed live. None of that shows up again for a real, already-onboarded profile. `customModelInjection`'s new `skipFirstRunPrompts` (claude's entry only) pre-seeds that same "already been through this" state — `hasCompletedOnboarding` and this session's own project trust into the same `.claude.json` the API-key approval merges into, `skipDangerousModePermissionPrompt` into `settings.json` — so a custom-model launch reaches the conversation exactly as fast as a native cloud one, with nobody there to click through a wizard.
Two more, from actually clicking through the swap-confirm and context-warning dialogs live: their z-index sat under the centred status banner, so a dialog could render fully hidden behind "Claude started — switching to llama-swap…"; and their Cancel/confirm buttons stacked instead of sitting side by side (`.btn-toolbar`'s own `display: flex` needs a row-layout parent it never had). Both dialogs now clear the banner and lay their buttons out centred, side by side.
Remote (SSH) and Docker sessions are refused for now (400) — their restart reattaches the durable remote/in-container tmux rather than relaunching the agent.
**One more, from watching it launch live: opencode, Codex, Gemini, Pi, Grok, DeepSeek and OMP now launch directly on the endpoint, with no restart at all.** Picking one of these seven from the Run-menu picker used to launch natively first, wait for it to settle, then restart it in place with the endpoint applied — a deliberate two-step design, but visibly a native boot immediately followed by a second one, worst on a CLI whose TUI fully reinitializes on a restart (confirmed live on Codex). `POST /api/quick-start` now accepts a `customModel` field and computes the same injection _before_ the session exists, launching straight onto the endpoint the first time — no visible relaunch, and it also runs the same llama-swap conflict check (warns before unloading a model another live session is using) at create time. Claude still uses the original launch-then-restart path for now (its own `--resume`-based restart is far less jarring, and `runClaude()`'s multi-tab and docker-config-drift-retry logic make folding it into the one-shot path separate work).
+21
View File
@@ -285,6 +285,27 @@ earlier launch in the same isolated directory (`userID`, `numStartups`,
earlier approved keys), and a missing or corrupt file is treated as empty
rather than failing the apply.
**A fresh `CLAUDE_CONFIG_DIR` isn't just missing that one approval — Claude
Code treats it as a brand-new profile and replays its ENTIRE first-run
sequence on every launch: the theme picker, the security-notes screen, the
per-project "trust this folder?" dialog, and (running with
`--dangerously-skip-permissions`) a one-time warning about bypassing
permissions.** Confirmed live: none of these show up again for a real,
already-onboarded profile, but every custom-model session gets a fresh,
otherwise-empty isolated directory, so it saw all four every single time.
`customModelInjection`'s `skipFirstRunPrompts` (`true` on claude's entry,
requires `apiKeyTrustFile` since it reuses the same file) pre-seeds the
state a real profile accumulates from answering all of that once:
`hasCompletedOnboarding: true` and the launching session's own
`projects[workingDir].hasTrustDialogAccepted: true` go into the same
`<configDir>/.claude.json` the API-key approval above already merges into
(other projects, and other fields on this session's own project entry, are
left untouched), and `skipDangerousModePermissionPrompt: true` goes into
`<configDir>/settings.json` — a different file, merged the same
corrupt-tolerant way. `workingDir` is used exactly as the session was
launched with as its cwd, never realpath'd or slash-normalized, since
that's the literal string Claude Code itself uses as the project key.
**llama-swap gets two more fixes on top of the context-length/config-dir
ones above, both from watching a real switch live.** llama.cpp only ever
runs one model at a time; llama-swap swaps the backing process on demand,
+9 -1
View File
@@ -89,7 +89,7 @@ error telling you to check the llama-swap server's own logs, and **the session t
for is closed automatically** — a console left open and pointed at a model that never
finished loading would just be confusing to leave sitting there.
**Claude Code specifically gets two extra fixes applied automatically:**
**Claude Code specifically gets three extra fixes applied automatically:**
- Its discovered context length (see above) is passed through as
`CLAUDE_CODE_MAX_CONTEXT_TOKENS`, so it doesn't send a full-size prompt against a much
@@ -103,6 +103,14 @@ finished loading would just be confusing to leave sitting there.
own, so Codeman also pre-approves the injected key the same way answering Claude Code's own
"Detected a custom API key" prompt once would — without it, that prompt would otherwise
reappear on every single launch with nobody there to answer it.
- **That same fresh isolated directory also looks like a brand-new Claude Code profile**, so
without this fix it replayed the WHOLE first-run sequence every single launch: the theme
picker, the security-notes screen, the "trust this folder?" dialog, and a one-time warning
about running with permissions bypassed — none of which a real, already-used profile shows
again. Codeman now pre-seeds that same "already been through this once" state (onboarding
completed, this session's own project marked trusted, the bypass-permissions warning
acknowledged) so a custom-model launch reaches the actual conversation exactly as fast as a
native cloud one does, instead of stopping at a wizard with nobody there to click through it.
**If a model's real context is too small for Claude Code to even get started, you get a
warning instead of a confusing failure.** Claude Code's own system prompt and tools take up
+6
View File
@@ -358,6 +358,12 @@ const capabilitiesSchema = z
.object({ relPath: z.string().min(1).max(80), shape: z.literal('claude-api-key-responses') })
.strict()
.optional(),
// An isolated config directory replays the CLI's whole first-run sequence (theme
// picker, security notes, per-project trust dialog, bypass-permissions warning)
// on every launch, same root cause as apiKeyTrustFile above — this reuses that
// same file to pre-seed the state a real, already-onboarded profile carries. See
// the customModelInjection doc comment in cli-registry/types.ts.
skipFirstRunPrompts: z.boolean().optional(),
})
.strict(),
z
+8
View File
@@ -271,6 +271,14 @@ const CLAUDE: CliEntry = {
// Pre-seeding this file's customApiKeyResponses.approved list (verified against a real
// ~/.claude.json after answering the prompt once by hand) answers it in advance instead.
apiKeyTrustFile: { relPath: '.claude.json', shape: 'claude-api-key-responses' },
// ⚠️ Same isolated-directory root cause, one step further: verified live that on top
// of the API-key prompt above, a fresh CLAUDE_CONFIG_DIR also replays claude's ENTIRE
// first-run sequence on every launch — the theme picker, the security-notes screen,
// the per-project "trust this folder?" dialog, and (running with
// --dangerously-skip-permissions) a one-time bypass-permissions warning — none of
// which a real, already-onboarded profile shows again. Pre-seeds that same
// already-onboarded state instead of leaving a human to click through it.
skipFirstRunPrompts: true,
},
},
overlays: {
+14
View File
@@ -522,6 +522,19 @@ export interface CliCapabilities {
* `customApiKeyResponses.approved` field this pre-seeds — the exact field a real answered
* prompt itself writes to, so this isn't bypassing the check, just answering it the same
* way a one-off prior approval on a shared profile already would.
*
* `skipFirstRunPrompts` (env kind only, alongside apiKeyTrustFile): an isolated config
* directory is not just missing API-key approvals — it is a brand-new profile as far as
* the CLI is concerned, so it also replays its ENTIRE first-run sequence on every launch:
* the theme picker, the security-notes screen, the per-project "trust this folder?"
* dialog, and (running with a bypass-permissions flag) a one-time warning about it —
* confirmed live, none of which a real, long-used profile ever shows again. `true`
* pre-seeds the same state a real profile accumulates from having answered all of that
* once: `hasCompletedOnboarding` and the launching session's own project entry in the
* `apiKeyTrustFile` (claude's `.claude.json`), plus `skipDangerousModePermissionPrompt`
* in claude's `settings.json` — see `seedFirstRunState`/`seedSkipBypassPermissionsPrompt`
* in custom-model-injection-apply.ts. Requires `apiKeyTrustFile` to be set too, since it
* reuses that file.
*/
customModelInjection:
| {
@@ -533,6 +546,7 @@ export interface CliCapabilities {
contextLengthVar?: string;
apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' };
configDirVar?: string;
skipFirstRunPrompts?: boolean;
}
| { kind: 'configContentEnv'; envVar: string; template: 'opencode-json'; launchModel?: string }
| {
+84 -1
View File
@@ -118,6 +118,79 @@ function seedApiKeyTrustFile(
}
}
/**
* Pre-seeds the two remaining pieces of "already been onboarded" state a fresh
* `CLAUDE_CONFIG_DIR` has none of (`customModelInjection.skipFirstRunPrompts`, alongside
* apiKeyTrustFile): claude replays its whole first-run sequence — the theme picker, the
* security-notes screen, and (per-project) the "trust this folder?" dialog — against ANY
* config directory that has never completed it, confirmed live against a genuinely fresh
* isolated directory. `hasCompletedOnboarding` skips the theme/security-notes screens
* outright; `projects[workingDir].hasTrustDialogAccepted` answers the trust dialog for
* THIS session's own working directory the same way a real profile's own prior approval
* would — other projects in the file are left alone, and `workingDir` is used verbatim
* (never realpath'd or slash-normalized) since that's the literal string claude itself
* uses as the project key, being whatever string the session was actually launched with
* as its cwd.
*
* Same merge-not-overwrite and corrupt-file-tolerant behavior as `seedApiKeyTrustFile`
* (same file, so a second sequential read-modify-write here is deliberate rather than
* folding both into one pass — keeps each seed independently testable and optional).
*/
function seedFirstRunOnboardingState(
configDir: string,
trustFile: { relPath: string; shape: 'claude-api-key-responses' },
workingDir: string
): void {
const filePath = join(configDir, trustFile.relPath);
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(readFileSync(filePath, 'utf8')) as Record<string, unknown>;
} catch {
existing = {};
}
existing.hasCompletedOnboarding = true;
const projects =
existing.projects && typeof existing.projects === 'object' && !Array.isArray(existing.projects)
? (existing.projects as Record<string, Record<string, unknown>>)
: {};
const existingProject = projects[workingDir] && typeof projects[workingDir] === 'object' ? projects[workingDir] : {};
projects[workingDir] = { ...existingProject, hasTrustDialogAccepted: true };
existing.projects = projects;
try {
writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: 'utf8', mode: 0o600 });
chmodSync(filePath, 0o600);
} catch {
// best-effort only — the interactive dialogs return instead of a hard failure here
}
}
/**
* Pre-seeds the "skip the bypass-permissions warning" setting (`customModelInjection.
* skipFirstRunPrompts`, alongside apiKeyTrustFile) into an isolated config directory's
* `settings.json` — a real, already-onboarded profile answers claude's one-time warning
* about running with a bypass-permissions flag once and never sees it again, but every
* custom-model session launches with a fresh, otherwise-empty CLAUDE_CONFIG_DIR that
* carries none of that (confirmed live). A different file from apiKeyTrustFile's
* `.claude.json` — this is claude's own global `settings.json`, not project-keyed —
* so it gets its own merge-not-overwrite read-modify-write.
*/
function seedSkipBypassPermissionsPrompt(configDir: string): void {
const filePath = join(configDir, 'settings.json');
let existing: Record<string, unknown> = {};
try {
existing = JSON.parse(readFileSync(filePath, 'utf8')) as Record<string, unknown>;
} catch {
existing = {};
}
existing.skipDangerousModePermissionPrompt = true;
try {
writeFileSync(filePath, JSON.stringify(existing, null, 2), { encoding: 'utf8', mode: 0o600 });
chmodSync(filePath, 0o600);
} catch {
// best-effort only — the interactive warning returns instead of a hard failure here
}
}
/** Best-effort recursive removal of a previously-written configDir. Never throws. */
export function removeConfigDir(dir: string | undefined): void {
if (!dir) return;
@@ -151,7 +224,13 @@ export function applyCustomModelInjection(
modelId: string,
sessionId: string,
/** Discovered context-window size for `modelId`, if known — see `contextLengthVar`. */
contextLength?: number
contextLength?: number,
/**
* The session's own working directory — only used for `skipFirstRunPrompts`'s per-project
* trust-dialog seed, and only when provided (boot recovery, which has no reason to
* re-answer a dialog that already fired once, omits it rather than re-deriving it).
*/
workingDir?: string
): AppliedCustomModel | undefined {
const injection = buildCustomModelInjection(entry, endpoint, modelId, contextLength);
if (injection.kind === 'unsupported') return undefined;
@@ -170,6 +249,10 @@ export function applyCustomModelInjection(
if (injection.apiKeyTrustFile && injection.apiKey) {
seedApiKeyTrustFile(configDir, injection.apiKeyTrustFile, injection.apiKey);
}
if (injection.skipFirstRunPrompts && injection.apiKeyTrustFile) {
if (workingDir) seedFirstRunOnboardingState(configDir, injection.apiKeyTrustFile, workingDir);
seedSkipBypassPermissionsPrompt(configDir);
}
envOverrides = { ...envOverrides, [injection.configDirVar]: configDir };
}
return {
+3
View File
@@ -56,6 +56,8 @@ export interface EnvInjection {
apiKeyTrustFile?: { relPath: string; shape: 'claude-api-key-responses' };
/** The literal API key value this injection used, for `apiKeyTrustFile` to pre-approve. */
apiKey?: string;
/** See `customModelInjection.skipFirstRunPrompts` — carried through so the IO wrapper can seed it. */
skipFirstRunPrompts?: boolean;
}
export interface ConfigDirInjection {
@@ -122,6 +124,7 @@ export function buildCustomModelInjection(
let result: EnvInjection = withLaunchModel({ kind: 'env', envOverrides }, cap.launchModel, modelId);
if (cap.configDirVar) result = { ...result, configDirVar: cap.configDirVar };
if (cap.apiKeyTrustFile) result = { ...result, apiKeyTrustFile: cap.apiKeyTrustFile, apiKey };
if (cap.skipFirstRunPrompts) result = { ...result, skipFirstRunPrompts: true };
return result;
}
+10 -2
View File
@@ -1272,7 +1272,14 @@ export function registerSessionRoutes(
// fails its pattern rather than quoting it, which would silently launch the CLI on its
// own default provider again, so refuse an id the pattern cannot carry up front.
const modelSpec = entry.launch.params.model;
const applied = applyCustomModelInjection(entry, endpoint, body.modelId, session.id, contextLength);
const applied = applyCustomModelInjection(
entry,
endpoint,
body.modelId,
session.id,
contextLength,
session.workingDir
);
if (!applied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${session.mode} has no known custom-model mechanism`);
}
@@ -3643,7 +3650,8 @@ export function registerSessionRoutes(
cmEndpoint,
customModel.modelId,
qsCustomModelSessionId,
cmContextLength
cmContextLength,
resolvedCasePath
);
if (!cmApplied) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `${mode} has no known custom-model mechanism`);
+101 -2
View File
@@ -68,9 +68,10 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => {
expect(applied?.envOverrides.CLAUDE_CONFIG_DIR).toBe(expectedDir);
expect(applied?.configDir).toBe(expectedDir);
expect(existsSync(expectedDir)).toBe(true);
// Only the trust-seed file and the projects link — no real OAuth credential/config.
// The trust-seed file, the skipFirstRunPrompts settings.json, and the projects link —
// no real OAuth credential/config.
const entries = readdirSync(expectedDir).filter((name) => name !== 'projects');
expect(entries).toEqual(['.claude.json']);
expect(entries.sort()).toEqual(['.claude.json', 'settings.json']);
});
it('claude: symlinks (or junctions) projects back to the real config dir so the response viewer keeps working', () => {
@@ -189,6 +190,104 @@ describe('applyCustomModelInjection: apiKeyTrustFile (pre-approves the injected
});
});
describe("applyCustomModelInjection: skipFirstRunPrompts (an isolated dir replays claude's whole first-run sequence)", () => {
it("claude: seeds hasCompletedOnboarding and this session's own project trust into .claude.json", () => {
const sessionId = 'sess-firstrun-1';
sessionsToClean.push(sessionId);
const applied = applyCustomModelInjection(
entryOrThrow('claude'),
endpoint,
'qwen3',
sessionId,
undefined,
'/home/user/myproject'
);
const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as {
hasCompletedOnboarding: boolean;
projects: Record<string, { hasTrustDialogAccepted: boolean }>;
};
expect(written.hasCompletedOnboarding).toBe(true);
expect(written.projects['/home/user/myproject'].hasTrustDialogAccepted).toBe(true);
});
it('claude: seeds skipDangerousModePermissionPrompt into settings.json', () => {
const sessionId = 'sess-firstrun-2';
sessionsToClean.push(sessionId);
const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId);
const written = JSON.parse(readFileSync(join(applied!.configDir!, 'settings.json'), 'utf8')) as {
skipDangerousModePermissionPrompt: boolean;
};
expect(written.skipDangerousModePermissionPrompt).toBe(true);
});
it('claude: with no workingDir given (boot recovery), hasCompletedOnboarding/settings still seed, but no project entry is added', () => {
const sessionId = 'sess-firstrun-3';
sessionsToClean.push(sessionId);
const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId);
const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as {
hasCompletedOnboarding: boolean;
projects?: Record<string, unknown>;
};
expect(written.hasCompletedOnboarding).toBeUndefined();
expect(written.projects).toBeUndefined();
});
it("claude: merges onto an existing project entry's other fields rather than overwriting them", () => {
const sessionId = 'sess-firstrun-4';
sessionsToClean.push(sessionId);
const configDir = customModelConfigDir(sessionId);
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, '.claude.json'),
JSON.stringify({ projects: { '/home/user/myproject': { allowedTools: ['Bash'] } } })
);
const applied = applyCustomModelInjection(
entryOrThrow('claude'),
endpoint,
'qwen3',
sessionId,
undefined,
'/home/user/myproject'
);
const written = JSON.parse(readFileSync(join(applied!.configDir!, '.claude.json'), 'utf8')) as {
projects: Record<string, { allowedTools: string[]; hasTrustDialogAccepted: boolean }>;
};
expect(written.projects['/home/user/myproject'].allowedTools).toEqual(['Bash']);
expect(written.projects['/home/user/myproject'].hasTrustDialogAccepted).toBe(true);
});
it('claude: a corrupt existing settings.json is treated as absent rather than failing the apply', () => {
const sessionId = 'sess-firstrun-5';
sessionsToClean.push(sessionId);
const configDir = customModelConfigDir(sessionId);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'settings.json'), '{ not valid json');
expect(() => applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId)).not.toThrow();
const written = JSON.parse(readFileSync(join(configDir, 'settings.json'), 'utf8')) as {
skipDangerousModePermissionPrompt: boolean;
};
expect(written.skipDangerousModePermissionPrompt).toBe(true);
});
it('pi: has no skipFirstRunPrompts concept (no apiKeyTrustFile either) — nothing beyond its own config file', () => {
const sessionId = 'sess-firstrun-pi';
sessionsToClean.push(sessionId);
const applied = applyCustomModelInjection(
entryOrThrow('pi'),
endpoint,
'qwen3',
sessionId,
undefined,
'/home/user/myproject'
);
const entries = readdirSync(applied!.configDir!);
expect(entries).not.toContain('settings.json');
});
});
describe('applyCustomModelInjection: pre-existing behavior unaffected', () => {
it('opencode: still returns a plain env-kind result with no configDir', () => {
const sessionId = 'sess-opencode-1';
+12
View File
@@ -83,6 +83,18 @@ describe('buildCustomModelInjection', () => {
expect(result.apiKey).toBe('my-key');
});
it('claude: also declares skipFirstRunPrompts on the env-kind result', () => {
const result = buildCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3');
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.skipFirstRunPrompts).toBe(true);
});
it('opencode: has no skipFirstRunPrompts (no apiKeyTrustFile/configDirVar concept for it either)', () => {
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3');
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.skipFirstRunPrompts).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');