Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
796e10f
docs(roadmap): 2.6.C merged (PR #75) — and file the e2e/real-history.…
cemililik Jul 13, 2026
eaa7511
docs(adr): ADR-0071 — models.dev becomes the model-metadata source; t…
cemililik Jul 13, 2026
996d0fb
docs(adr): ADR-0071 §10 — the section I dropped in the rewrite, incl.…
cemililik Jul 13, 2026
394acb7
feat(llm): the generated model catalog — 90 models, priced offline, w…
cemililik Jul 13, 2026
4bd00a7
style: prettier-format package.json after the sync:models script addi…
cemililik Jul 13, 2026
2d7b051
fix(llm): fold the Step-1 review — both money guards were blind, and …
cemililik Jul 13, 2026
79e0d95
feat(llm): the effort bridge — the accepted tiers are COMPUTED from t…
cemililik Jul 13, 2026
344bc83
fix(llm): the reasoning field is chosen PER MODEL — the live bug, in …
cemililik Jul 13, 2026
1395b9a
fix(llm): the adapters asked whether an effort axis EXISTED, not whet…
cemililik Jul 13, 2026
bda0b10
feat(core,cli): the effort gate and the picker now ask the MODEL whic…
cemililik Jul 13, 2026
fbb83ca
fix(llm): the acceptance set described ONE axis of a model that publi…
cemililik Jul 13, 2026
edaffac
fix(cli,core): the CLI had FOUR answers to "can this model take an ef…
cemililik Jul 13, 2026
466445e
fix(cli,llm): the withheld-tier notice was wired at TWO of the nine p…
cemililik Jul 13, 2026
9030339
fix(llm): the output cap had no ceiling to check against, and the fie…
cemililik Jul 13, 2026
94636a1
fix(llm): renaming the cap field stopped it shadowing the escape hatc…
cemililik Jul 13, 2026
ce6cfbf
fix(llm,core,cli): the estimate clamped a gateway it must not — under…
cemililik Jul 13, 2026
5216c43
feat(llm,cli)!: retire the hand-typed price table — the catalog is th…
cemililik Jul 13, 2026
0b0b9b2
fix(llm,cli): the swap shipped three of its own safety properties bro…
cemililik Jul 13, 2026
e9c4367
fix(cli,db,llm): a partial override was not partial, and I told the u…
cemililik Jul 13, 2026
dddb001
feat(cli,llm,shared): models refresh gets its second axis — and it st…
cemililik Jul 13, 2026
3f3d084
fix(llm,cli): the refresh "floor" was `if (true)` — a downgrade recor…
cemililik Jul 13, 2026
77c88ac
fix(cli): the refresh count could exceed what installed, and the byte…
cemililik Jul 13, 2026
785468d
feat(core,cli,shared): an unpriced model is said out loud, and strict…
cemililik Jul 13, 2026
70f086e
fix(cli): the unpriced notice was dropped on the in-Home chat and two…
cemililik Jul 13, 2026
3945c0d
fix(core,cli): WorkflowEngine took onUnpriced + resolveEndpoint and d…
cemililik Jul 13, 2026
ec0b2ee
ci(llm): the two ADR-0071 §9 guards — a weekly price-change check, a …
cemililik Jul 13, 2026
21943b4
fix(llm,ci): the Gemini effort probe was a tautology — on the one pro…
cemililik Jul 13, 2026
95a5880
fix(llm,repo): the gate meant to stop a tautological probe did not ch…
cemililik Jul 13, 2026
987d8df
fix: fold the PR #76 review — CI unblock, real bugs, and Sonar/comple…
cemililik Jul 13, 2026
7a12a5a
test(cli): de-flake the reseat-carry O(n) perf guard (min-of-21, thre…
cemililik Jul 13, 2026
aff4960
test(cli): make the O(n) wrap SHAPE ratio local-only; consolidate a d…
cemililik Jul 13, 2026
5702ee0
style: prettier --write the repo (format:check drift); test(cli): per…
cemililik Jul 13, 2026
589b103
fix(cli): persist the picked model's provider so a chat over an un-in…
cemililik Jul 13, 2026
af5281d
fix(cli): couple default_provider to default_model's config layer (Bu…
cemililik Jul 13, 2026
ff71911
fix(llm): rescue an alias↔dated-pin sibling from a wrong not-on-key dim
cemililik Jul 13, 2026
953d9d4
test(llm): anchor Bug-2's anthropic-only + fabrication-guard scoping …
cemililik Jul 13, 2026
d509f6b
feat(cli): present reasoning effort as "one row per distinct outcome"
cemililik Jul 13, 2026
e96735d
fix(cli): close the global-boundary coupling gap; normalize the provi…
cemililik Jul 13, 2026
78c5115
fix(cli): fold the Bug-1 review — project the picker highlight/✓/labe…
cemililik Jul 13, 2026
d7697ac
fix(ci,repo): build @relavium/shared before the model-catalog guards …
cemililik Jul 13, 2026
9ff9446
fix(core,llm,cli): key the pre-egress endpoint on the routing provide…
cemililik Jul 13, 2026
5aecd84
fix(db): reset the user pricing columns on clearUserPricing so a re-p…
cemililik Jul 13, 2026
be165bc
fix(cli): couple the in-Home default model/provider and buffer pre-st…
cemililik Jul 13, 2026
fb9e40e
fix(llm): send OpenAI's per-model top reasoning tier so `max` reaches…
cemililik Jul 14, 2026
e1809ee
feat(core,llm,cli): surface the cap-driven reasoning withhold as a `c…
cemililik Jul 14, 2026
8278057
fix(llm): parse models.dev enrichment fields leniently so a shape cha…
cemililik Jul 14, 2026
b839edd
feat(llm): gate request parameters on per-model capability so a model…
cemililik Jul 14, 2026
665aca8
feat(llm,cli): collapse alias↔dated-pin pairs to the rolling alias in…
cemililik Jul 14, 2026
cfd4517
fix(llm,cli): review lows — cost-cap safety, sanitization, and a misl…
cemililik Jul 14, 2026
9315bd8
docs: fold the 2.6.Q doc-drift lows — pricing precedence, the phantom…
cemililik Jul 14, 2026
d674a1b
fix(db,cli,ci): fold the 2.6.Q polish — migration idiom, redirect mes…
cemililik Jul 14, 2026
957db49
fix(llm): tolerate a reasoning_options CONTAINER shape change too (ad…
cemililik Jul 14, 2026
14f6817
refactor(llm,core,docs): fold the review nits — Sonar smells, one cat…
cemililik Jul 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions .claude/skills/add-llm-adapter/SKILL.md

Large diffs are not rendered by default.

115 changes: 115 additions & 0 deletions .github/workflows/models-catalog.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# The model-catalog guards (ADR-0071 §9). Two scheduled lanes, neither of which gates a PR:
#
# • weekly-catalog-check — did any ALREADY-SHIPPED model's price MOVE or VANISH? A moved price feeds the
# ADR-0028 cost cap, so it is a HUMAN decision surfaced as a red check, never a silent bot commit. `pnpm
# sync:models` exits non-zero on a moved/vanished shipped price; benign additive drift stays green (a weekly
# red-no-matter-what trains the maintainer to ignore it — the erosion sync.mjs's own comments warn against).
#
# • nightly-effort-conformance — the ONLY mechanism that catches a stale catalog re-introducing the reasoning
# bug: for each shipped reasoning model it asks the REAL provider whether it accepts every tier the catalog
# claims. A one-off manual probe proves a fact once; the catalog drifts continuously, so this runs nightly.
#
# Third-party actions are pinned to a full commit SHA (the `# vX.Y.Z` comment tracks the release) — the same
# convention ci.yml uses, so a moved tag cannot inject unreviewed code.
name: model-catalog

on:
schedule:
# Monday 06:00 UTC — the drift check. Ahead of a typical review week, so a red one is seen on Monday.
- cron: '0 6 * * 1'
# 07:00 UTC nightly — the live effort conformance (key-gated; skips with no key).
- cron: '0 7 * * *'
# Manual trigger for both, so a maintainer can run either on demand without waiting for the cron.
workflow_dispatch: {}

permissions:
contents: read

concurrency:
group: model-catalog-${{ github.event_name }}
cancel-in-progress: false

jobs:
weekly-catalog-check:
name: shipped-price-change guard
# Only the weekly cron (and a manual run); the nightly cron skips this job.
if: github.event.schedule != '0 7 * * *'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
Comment on lines +40 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Resolve current major-version tags to their commit SHAs and compare against the pinned ones.
gh api repos/actions/checkout/commits/v4 --jq '.sha'
gh api repos/actions/setup-node/commits/v4 --jq '.sha'
gh api repos/pnpm/action-setup/commits/v4 --jq '.sha'
gh api repos/actions/checkout/tags --paginate --jq '.[] | select(.name | startswith("v5") or startswith("v6")) | {name, sha: .commit.sha}'

Repository: HodeTech/Relavium

Length of output: 874


🌐 Web query:

GitHub Actions Node 20 deprecation actions/checkout v4 setup-node v4 pnpm action-setup v4 Node 24 runners June 2 2026 September 16 2026

💡 Result:

GitHub Actions has deprecated Node.js 20 in favor of Node.js 24 as part of the runner runtime migration [1][2]. The transition involves two key dates in 2026: 1. June 16, 2026: Node.js 24 became the default runtime for GitHub Actions [3][4]. Actions that rely on Node.js 20 will trigger deprecation warnings if they have not been updated to support Node.js 24 [5]. 2. September 16, 2026: Node.js 20 is scheduled to be removed from GitHub Actions runners [5]. To address deprecation warnings and ensure compatibility, maintainers must update their workflows to use versions of actions that support Node.js 24 (often v5 or higher for core actions like actions/checkout, actions/setup-node, and pnpm/action-setup) [6]. Migration details: - Environment Variables: During the transition, users could use FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true to force Node.js 24 usage or ACTIONS_ALLOW_USE_UNSECURE_NODE_VERSION=true to temporarily continue using Node.js 20 after it was deprecated [2][5]. - Action Compatibility: Standard actions such as actions/checkout@v4 and pnpm/action-setup@v4 may trigger warnings if they are not updated to a version explicitly configured for the Node.js 24 runtime environment [7][6][5]. Users are encouraged to bump these to the latest available major versions [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the workflow locations mentioned in the comment.
sed -n '1,120p' .github/workflows/models-catalog.yml

# Check whether the pinned SHAs exist and which major tags currently point to them.
gh api repos/actions/checkout/commits/34e114876b0b11c390a56381ad16ebd13914f8d5 --jq '.sha'
gh api repos/actions/setup-node/commits/49933ea5288caeca8642d1e84afbd3f7d6820020 --jq '.sha'
gh api repos/pnpm/action-setup/commits/b906affcce14559ad1aafd4ab0e942779e9f58b1 --jq '.sha'

# Pull the action metadata from tags where available.
for repo in actions/checkout actions/setup-node pnpm/action-setup; do
  echo "== $repo =="
  gh api "repos/$repo/tags" --paginate --jq '.[] | select(.name=="v4" or .name=="v5" or .name=="v6") | {name, sha: .commit.sha}'
done

Repository: HodeTech/Relavium

Length of output: 5753


🌐 Web query:

actions/checkout v5 node24 runtime release notes actions/setup-node v5 node24 runtime release notes pnpm/action-setup v5 node24 runtime release notes GitHub Actions Node 20 deprecation warning current runners 2026

💡 Result:

As of July 13, 2026, the migration from Node.js 20 to Node.js 24 for GitHub Actions is in effect. Node.js 20 reached end-of-life in April 2026 [1][2][3], and GitHub has deprecated Node.js 20 support on GitHub Actions runners, with an enforcement date of June 2, 2026 [4][5]. Key Details: - Official Actions Support: The major versions of standard actions, including actions/checkout (v5.0.0+) [6][7] and actions/setup-node (v5.0.0+) [8][9], have been updated to use the Node.js 24 runtime. - Runner Requirement: To use these v5 versions, your GitHub Actions runner must be updated to at least version v2.327.1 [10][8][11]. - Deprecation Status: Node.js 20-based actions will trigger a deprecation warning annotation at the end of job runs [5]. Users are expected to migrate their workflows to use actions that support the Node.js 24 runtime [1][5]. - pnpm/action-setup: While specific v5 release notes for pnpm/action-setup were not explicitly surfaced in the same format as official actions/ repositories, the broader migration requires that all actions running on GitHub-hosted runners be compatible with the Node.js 24 environment [5]. Users should check the repository's latest release for v5+ updates to ensure Node.js 24 compatibility.

Citations:


Upgrade these pinned GitHub Actions off the Node 20 line .github/workflows/models-catalog.yml:40-46,70-76actions/checkout, actions/setup-node, and pnpm/action-setup are still on v4 releases; move them to the current Node 24-compatible majors to avoid deprecation warnings and future runner failures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/models-catalog.yml around lines 40 - 46, Update the pinned
actions in the workflow’s checkout, pnpm setup, and Node setup steps—including
the corresponding steps around the later referenced block—from their v4 releases
to the current Node 24-compatible major releases, and replace each SHA with the
matching verified commit for that major while preserving the existing pinning
and version comments.

with:
node-version-file: .nvmrc
cache: pnpm
- name: Install (frozen lockfile)
run: pnpm install --frozen-lockfile
# `pnpm sync:models` builds `@relavium/llm` AND its `@relavium/shared` dependency first (via turbo `^build`) —
# the `@relavium/*` `exports` resolve only to `dist/`, which is gitignored, so a fresh runner has no built
# `@relavium/shared` and the sync's own `tsc`/dist import would otherwise die on TS2307 before any guard runs.
# `pnpm sync:models` fetches models.dev, normalizes it, and applies the money guards. It fails RED on the ONE
# thing that must be a human decision: a MOVED or VANISHED price on a model we already ship (a rate that
# silently moves also silently moves how much the ADR-0028 cost cap protects). It does NOT red on benign
# additive drift — models.dev adds models in our four providers constantly, and a check that is red every
# week trains the maintainer to ignore it, which is exactly how the price-change protection would erode
# (sync.mjs says so in its own comments). New models "merge automatically" via the deferred auto-PR
# (deferred-tasks.md); until it lands they are simply picked up by the next local `pnpm sync:models`. The
# snapshot this job writes into the ephemeral CI checkout is discarded — only the exit code is the guard.
- name: No ALREADY-SHIPPED model's price moved or vanished
run: pnpm sync:models

nightly-effort-conformance:
name: effort conformance (live)
# Only the nightly cron (and a manual run); the weekly cron skips this job.
if: github.event.schedule != '0 6 * * 1'
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- name: Set up pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- name: Set up Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version-file: .nvmrc
cache: pnpm
- name: Install (frozen lockfile)
run: pnpm install --frozen-lockfile
# Build the dependency closure first: the conformance suite imports `@relavium/shared`, whose `exports` resolve
# only to a gitignored `dist/`. Without this a fresh runner cannot resolve the cross-package entry and EVERY
# conformance file errors out before a single live probe — a red that looks identical to a real catalog drift.
# Turbo `^build` builds `@relavium/shared` ahead of `@relavium/llm` (mirrors ci.yml's pre-test build).
- name: Build workspace dependencies
run: pnpm turbo run build --filter=@relavium/llm
# Guard against a FALSE-GREEN nightly: the live cases are `it.skipIf(<key> === '')`, so with NO provider secret
# configured EVERY probe skips and the suite passes — a green that means "nothing was checked", indistinguishable
# from "all tiers verified". Fail loudly instead, so a green nightly is proof that probes actually ran.
- name: Require at least one provider secret
run: |
if [ -z "${ANTHROPIC_API_KEY}${OPENAI_API_KEY}${GEMINI_API_KEY}${DEEPSEEK_API_KEY}" ]; then
echo "::error::No provider secret is configured — every live effort probe would SKIP, and a green run would falsely read as 'all tiers verified'. Configure at least one of ANTHROPIC/OPENAI/GEMINI/DEEPSEEK_API_KEY."
exit 1
fi
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
# The conformance suite (fixtures + the `it.skipIf(<key> === '')` live cases). With the provider keys set here
# the live cases execute — and a catalog that claims a tier a model rejects fails red — while a missing key
# simply skips that provider's lane. Scoped to `src/conformance` so a flaky live case cannot widen the red
# surface to the whole package's unit suite.
- name: Live effort conformance
run: pnpm --filter @relavium/llm exec vitest run src/conformance
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
6 changes: 4 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,10 @@ inbound MCP client, and the full YAML lifecycle (`create`/`import`/`export`).
Home, the two-registry command system (`/` palette + shell commands), per-tool
approval/modes (ask/plan/accept-edits/auto), `@`-mention file injection, context
compaction, the onboarding wizard + live model catalog, reasoning rendering, and regression
hardening. **Phase 2.6 (Conversational Authoring and the First-Class CLI) is next up**
— a full-screen Home-managed CLI with conversational workflow authoring, management
hardening. **Phase 2.6 (Conversational Authoring and the First-Class CLI) is in progress** — two
workstreams are merged to `main`: **2.6.F** (the platform floor + the full-screen TUI renderer,
PR #74, 2026-07-11) and **2.6.C** (the reseat transcript-carry + the `/cost` per-model breakdown,
PR #75, 2026-07-13). The phase is a full-screen Home-managed CLI with conversational workflow authoring, management
browsers, competitor-breadth tools, settings/theming/`en`+`tr` localization, and the
run-ops resume follow-up.

Expand Down
27 changes: 27 additions & 0 deletions apps/cli/src/chat/agent-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,33 @@ describe('resolveChatAgent', () => {
expect(agent.provider).toBe('openai');
});

it('passes opts.defaultProvider VERBATIM to the default agent — an unplaceable id starts (ADR-0059, Bug-3 wiring)', () => {
// The decisive Bug-3 hop: a persisted provider carried into the default agent so a live-discovered id the prefix
// map cannot place (`chat-latest`) still resolves. Without this passthrough it throws "cannot infer a provider".
const agent = resolveChatAgent(undefined, {
cwd: dir,
projectConfigDir: undefined,
defaultModel: 'chat-latest',
defaultProvider: 'openai',
});
expect(agent.model).toBe('chat-latest');
expect(agent.provider).toBe('openai');
});

it('still throws a clean CliError for an unplaceable id when NO defaultProvider is persisted', () => {
let caught: unknown;
try {
resolveChatAgent(undefined, {
cwd: dir,
projectConfigDir: undefined,
defaultModel: 'chat-latest',
});
} catch (err) {
caught = err;
}
expect(isCliError(caught)).toBe(true);
});

it('falls back to DEFAULT_CHAT_MODEL when neither --agent nor [chat].default_model is set', () => {
const agent = resolveChatAgent(undefined, {
cwd: dir,
Expand Down
11 changes: 9 additions & 2 deletions apps/cli/src/chat/agent-source.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { parseAgent, type AgentDefinition } from '@relavium/core';
import type { ReasoningEffort } from '@relavium/shared';
import type { LlmProviderId, ReasoningEffort } from '@relavium/shared';

import { resolveYamlSource } from '../workflows/resolve.js';
import { buildDefaultChatAgent, DEFAULT_CHAT_MODEL } from './default-agent.js';
Expand All @@ -9,6 +9,9 @@ export interface ResolveChatAgentOptions {
readonly projectConfigDir: string | undefined;
/** The resolved `[chat].default_model` — used only to build the default agent when no `--agent` is given. */
readonly defaultModel: string | undefined;
/** The resolved `[chat].default_provider` (ADR-0059) — persisted at pick time, used verbatim for the DEFAULT
* agent so a live-discovered id whose prefix the inference cannot place still resolves. Absent ⇒ inference. */
readonly defaultProvider?: LlmProviderId;
/** The resolved `[chat].reasoning_effort` (ADR-0066) — baked onto the DEFAULT agent only (an authored `--agent`
* owns its own `reasoning_effort`, never overridden by config). Absent ⇒ no reasoning control. */
readonly reasoningEffort?: ReasoningEffort;
Expand All @@ -28,7 +31,11 @@ export function resolveChatAgent(
opts: ResolveChatAgentOptions,
): AgentDefinition {
if (agentRef === undefined) {
return buildDefaultChatAgent(opts.defaultModel ?? DEFAULT_CHAT_MODEL, opts.reasoningEffort);
return buildDefaultChatAgent(
opts.defaultModel ?? DEFAULT_CHAT_MODEL,
opts.reasoningEffort,
opts.defaultProvider,
);
}
const source = resolveYamlSource(agentRef, {
cwd: opts.cwd,
Expand Down
23 changes: 23 additions & 0 deletions apps/cli/src/chat/default-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ describe('inferProviderFromModel', () => {
expect(inferProviderFromModel('llama-3')).toBeUndefined();
expect(inferProviderFromModel('')).toBeUndefined();
});

it('resolves a catalog-known id via the catalog branch (ADR-0071 hardening)', () => {
// Every SHIPPED id ALSO matches a prefix, so the catalog and the heuristic agree — this cannot prove the catalog
// is consulted FIRST, only that the catalog branch is wired and returns the right provider. The catalog-first
// ORDER only bites for a live-only id the prefix cannot place (e.g. `chatgpt-4o-latest`), which no shipped
// catalog id exercises; there the PERSISTED provider (see buildDefaultChatAgent) is the decisive fix.
expect(inferProviderFromModel('gpt-5-chat-latest')).toBe('openai'); // a real catalog id
});
});

describe('buildDefaultChatAgent', () => {
Expand Down Expand Up @@ -56,6 +64,21 @@ describe('buildDefaultChatAgent', () => {
expect(buildDefaultChatAgent('gemini-2.5-pro').provider).toBe('gemini');
});

it('uses a persisted knownProvider VERBATIM, skipping inference — the Bug-3 fix (ADR-0059)', () => {
// `chat-latest` is a live OpenAI id `keepOpenAiModelId` admits; it has no gpt/o-digit prefix and is not in the
// catalog, so inference returns undefined and the chat used to crash "cannot infer a provider". A provider
// persisted at pick time is used as-is, so the chat starts on the right provider.
const agent = buildDefaultChatAgent('chat-latest', undefined, 'openai');
expect(agent.provider).toBe('openai');
expect(agent.model).toBe('chat-latest');
});

it('a knownProvider makes the throw path unreachable even for an id inference cannot place', () => {
expect(buildDefaultChatAgent('mystery-model-9', undefined, 'anthropic').provider).toBe(
'anthropic',
);
});

it('bakes the [chat].reasoning_effort default onto the agent, and OMITS it when absent (ADR-0066)', () => {
expect(buildDefaultChatAgent('claude-opus-4-8', 'high').reasoning_effort).toBe('high');
// Absent ⇒ the key is not present (never an explicit `undefined` under exactOptionalPropertyTypes).
Expand Down
49 changes: 34 additions & 15 deletions apps/cli/src/chat/default-agent.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { ProviderId } from '@relavium/llm';
import { catalogModel, type ProviderId } from '@relavium/llm';
import type { Agent, ReasoningEffort } from '@relavium/shared';

import { CliError } from '../process/errors.js';
Expand Down Expand Up @@ -35,17 +35,27 @@ const DEFAULT_CHAT_SYSTEM_PROMPT =
export const DEFAULT_CHAT_TOOLS: readonly string[] = ['read_file', 'list_directory', 'git_status'];

/**
* Infer the provider that serves a model id from its well-known prefix. Phase-1 has no model→provider
* catalog lookup wired for the chat path, and a default agent must name a provider ({@link Agent.provider});
* this covers the four `@relavium/llm` seam providers (ADR-0011) and returns `undefined` for an unrecognized
* model so the caller fails with a clear "bind an explicit `--agent`" message rather than guess wrong.
* Infer the provider that serves a model id — **catalog first** (ADR-0071), then a well-known-prefix fallback.
*
* A default agent must name a provider ({@link Agent.provider}). The catalog knows the provider of every model it
* carries AUTHORITATIVELY, regardless of how the id is spelled — `chatgpt-4o-latest` has no `gpt`/`o<digit>` prefix
* yet is unmistakably openai — so a catalog hit is trusted before the heuristic. The prefix map remains the fallback
* for an id the catalog does not carry (a brand-new model, or a custom `base_url` endpoint), covering the four
* `@relavium/llm` seam providers (ADR-0011). Returns `undefined` for an id neither knows, so the caller fails with a
* clear "bind an explicit `--agent`" message rather than guess wrong.
*
* The truly robust source is a PERSISTED provider from pick time (the picker knows it authoritatively — the live
* `/models` list is per-provider); {@link buildDefaultChatAgent} prefers that and only falls back to this inference.
*/
export function inferProviderFromModel(model: string): ProviderId | undefined {
// Normalize ONCE — catalog ids are lowercase, and the prefix fallback lowercases too, so a mixed-case id
// (`Claude-Opus-4-8`) resolves via the CATALOG (authoritative) instead of only via the prefix heuristic.
const m = model.toLowerCase();
const cataloged = catalogModel(m)?.provider;
if (cataloged !== undefined) return cataloged;
if (m.startsWith('claude')) return 'anthropic';
// OpenAI's GPT family + the o-series reasoning models. `/^o\d/` matches the whole o-series (o1/o3/o4 and
// future o5+) in one expression rather than enumerating each prefix. (A model→provider catalog lookup is
// the eventual robust source; this prefix map is the deliberate Phase-1 zero-config default.)
// future o5+) in one expression rather than enumerating each prefix.
if (m.startsWith('gpt') || /^o\d/.test(m)) return 'openai';
if (m.startsWith('gemini')) return 'gemini';
if (m.startsWith('deepseek')) return 'deepseek';
Expand All @@ -54,19 +64,28 @@ export function inferProviderFromModel(model: string): ProviderId | undefined {

/**
* Build the built-in default chat agent over `model` (the resolved `[chat].default_model`, or
* {@link DEFAULT_CHAT_MODEL}). Throws a clean exit-2 {@link CliError} when the provider cannot be inferred
* from the model id — guiding the user to set a known `[chat].default_model` or bind an explicit `--agent`.
* `reasoningEffort` (the resolved `[chat].reasoning_effort`, ADR-0066) is baked onto the agent so the default
* chat honors the config default; absent ⇒ no reasoning control (the provider default).
* {@link DEFAULT_CHAT_MODEL}).
*
* `knownProvider` — the provider PERSISTED alongside the model (`[chat]`/`[preferences].default_provider`, written by
* the picker/wizard at pick time, ADR-0059's "the provider is authoritative, never re-inferred"). When present it is
* used verbatim and inference is SKIPPED, so a model whose id the prefix map cannot place — a live-discovered
* `chatgpt-4o-latest`, a custom-endpoint id — still starts. Only when it is absent does this fall back to
* {@link inferProviderFromModel}; a clean exit-2 {@link CliError} then guides the user to a known model or `--agent`.
* `reasoningEffort` (the resolved `[chat].reasoning_effort`, ADR-0066) is baked onto the agent so the default chat
* honors the config default; absent ⇒ no reasoning control (the provider default).
*/
export function buildDefaultChatAgent(model: string, reasoningEffort?: ReasoningEffort): Agent {
const provider = inferProviderFromModel(model);
export function buildDefaultChatAgent(
model: string,
reasoningEffort?: ReasoningEffort,
knownProvider?: ProviderId,
): Agent {
const provider = knownProvider ?? inferProviderFromModel(model);
if (provider === undefined) {
throw new CliError(
'invalid_invocation',
`cannot infer a provider for chat model '${model}'. Set [chat].default_model to a known model ` +
`(claude-*, gpt-*, gemini-*, deepseek-*), or bind an explicit agent with ` +
`'relavium chat --agent <ref>'.`,
`(claude-*, gpt-*, gemini-*, deepseek-*) — or set [chat].default_provider, or bind an explicit agent ` +
`with 'relavium chat --agent <ref>'.`,
);
}
return {
Expand Down
Loading
Loading