Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
72 changes: 65 additions & 7 deletions packages/host/app/components/ai-assistant/llm-select.gts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export interface LLMOption {
id: string;
modelId: string;
name: string;
// Glanceable cost tier ('Free' | '$' … '$$$$'); omitted when unknown.
costTierLabel?: string;
}

interface Signature {
Expand Down Expand Up @@ -47,6 +49,14 @@ export default class LLMSelect extends Component<Signature> {
>
{{this.displayName}}
</span>
{{#if this.selectedCostTierLabel}}
<span
class='llm-cost'
data-test-llm-cost-selected={{this.selectedCostTierLabel}}
>
{{this.selectedCostTierLabel}}
</span>
{{/if}}
</div>
</:headerDetail>
<:content>
Expand All @@ -66,10 +76,20 @@ export default class LLMSelect extends Component<Signature> {
class='llm-button'
{{on 'click' (fn this.handleOptionClick option.id)}}
>
{{option.name}}
{{#if (eq @selected option.id)}}
<Check class='selected-icon' />
{{/if}}
<span class='llm-name'>{{option.name}}</span>
<span class='llm-meta'>
{{#if option.costTierLabel}}
<span
class='llm-cost'
data-test-llm-cost={{option.costTierLabel}}
>
{{option.costTierLabel}}
</span>
{{/if}}
{{#if (eq @selected option.id)}}
<Check class='selected-icon' />
{{/if}}
</span>
</button>
</li>
{{/each}}
Expand All @@ -84,17 +104,31 @@ export default class LLMSelect extends Component<Signature> {
}

.selected-llm-wrapper {
display: flex;
align-items: center;
gap: var(--boxel-sp-xxxs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}

.selected-llm {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
color: var(--boxel-dark);
font: 700 var(--boxel-font-xs);
}

/* Cost tier chip ($…$$$$ / Free). Deliberately muted and distinct from
the green per-token prices shown on the model cards. */
.llm-cost {
flex-shrink: 0;
color: var(--boxel-450);
font: 600 var(--boxel-font-xs);
letter-spacing: 0.03em;
}

.llm-list {
list-style: none;
padding: 0;
Expand Down Expand Up @@ -139,6 +173,22 @@ export default class LLMSelect extends Component<Signature> {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--boxel-sp-xs);
}

.llm-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
text-align: left;
}

.llm-meta {
flex-shrink: 0;
display: flex;
align-items: center;
gap: var(--boxel-sp-xxxs);
}

.llm-option.selected .llm-button {
Expand All @@ -147,8 +197,16 @@ export default class LLMSelect extends Component<Signature> {
</style>
</template>

private get selectedOption() {
return this.args.options.find((o) => o.id === this.args.selected);
}

private get displayName() {
return this.args.options.find((o) => o.id === this.args.selected)?.name;
return this.selectedOption?.name;
}

private get selectedCostTierLabel() {
return this.selectedOption?.costTierLabel;
}

@action
Expand Down
106 changes: 106 additions & 0 deletions packages/host/app/components/matrix/room.gts
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,12 @@ import {
internalKeyFor,
isCardInstance,
resolveFileDefCodeRef,
rri,
stringifyErrorForLog,
SupportedMimeType,
} from '@cardstack/runtime-common';
import { DEFAULT_FALLBACK_MODELS } from '@cardstack/runtime-common/matrix-constants';
import type { Query } from '@cardstack/runtime-common/query';

import ENV from '@cardstack/host/config/environment';
import type { FileUploadState } from '@cardstack/host/lib/file-upload-state';
Expand Down Expand Up @@ -1110,6 +1112,19 @@ export default class Room extends Component<Signature> {
}

private get llmsForSelectMenu(): LLMOption[] {
this.ensureModelCostTiersLoaded();
let costTierByModelId = this.modelCostTierByModelId;
return this.baseLlmOptions.map((option) => ({
...option,
costTierLabel: costTierByModelId.get(option.modelId),
}));
}

// The pickable models, before cost tiers are attached. Single source of
// truth for both the rendered options and the cost-tier lookup's model-id
// set, so the two can't drift apart.
@cached
private get baseLlmOptions(): LLMOption[] {
// Read from the system card if available
let systemCard = this.matrixService.systemCard;
if (systemCard?.modelConfigurations) {
Expand Down Expand Up @@ -1159,6 +1174,97 @@ export default class Room extends Component<Signature> {
}));
}

// The distinct OpenRouter model ids that can appear in the picker.
@cached
private get pickerModelIds(): string[] {
return [...new Set(this.baseLlmOptions.map((option) => option.modelId))];
}

// Cost tier ($…$$$$ / Free) per model id, populated by `loadModelCostTiers`.
@tracked private modelCostTierByModelId: Map<string, string> = new Map();
// Content key of the last load (realm + sorted model ids). Deliberately a
// plain field, not tracked: it gates the load without being part of the
// reactive graph.
private lastModelCostTierKey = '';

// Kicks a cost-tier load whenever the pickable model set changes. Keyed on
// model-id *content* (not array identity), so the live matrix churn behind
// `pickerModelIds` — new `usedLLMs` array each sync — can't retrigger it.
// Called during render (from `llmsForSelectMenu`), so nothing on the
// synchronous path here may write tracked state: when there is nothing to
// load (realm unconfigured, no models) we return without performing the
// task, and the task body itself only writes after its await.
private ensureModelCostTiersLoaded() {
let modelIds = this.pickerModelIds;
let realmURL = ENV.resolvedOpenRouterRealmURL;
let key =
realmURL && modelIds.length > 0
? `${realmURL}::${[...modelIds].sort().join('|')}`
: '';
if (key === this.lastModelCostTierKey) {
return;
}
this.lastModelCostTierKey = key;
if (!key) {
return;
}
this.loadModelCostTiers.perform(modelIds, realmURL!, key);
}

// Looks up the cost tier for each pickable model by matching its `modelId`
// against the OpenRouter model catalog. The tier is a `computeVia` field on
// the `OpenRouterModel` card (derived there from its own pricing), which is
// correlated to a model configuration by the `modelId` string, not a link —
// so we search that realm and read each match's `costTierLabel`. Degrades to
// an empty map when the OpenRouter realm is unconfigured, unreachable, or a
// model has no matching catalog card — the badge is cosmetic and must never
// surface an error.
//
// This is a task rather than a `trackedFunction`/resource on purpose:
// `store.search` hydrates cards (mutating tracked store state) and we then
// read those cards' fields, so a reactive body would self-invalidate into an
// endless re-run and never settle. The task runs the search + field reads off
// the render's reactive path and only writes the tracked map the picker reads.
private loadModelCostTiers = restartableTask(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[Claude Code 🤖] Confirmation (nothing to change) + one limitation worth naming.

The choice to run this as a restartableTask gated on a plain (untracked) content key, rather than a trackedFunction/resource, is correct and worth keeping. this.store.search(...) hydrates cards — which mutates tracked store state — and the tier derivation then reads those hydrated cards' pricing fields; inside a reactive computation that read-after-write self-invalidates and never settles, so the badge would never appear. Two guard rails keep the current shape safe, and it's worth being explicit about them because they're load-bearing:

  1. The only tracked write, this.modelCostTierByModelId = tiers, happens after await this.store.search(...) — i.e. in a later microtask — so even though llmsForSelectMenu reads that same tracked field, the write lands in a separate runloop and can't trip Ember's backtracking-rerender assertion.
  2. lastModelCostTierKey is deliberately untracked, so setting it on the synchronous render path (ensureModelCostTiersLoaded) invalidates nothing and can't retrigger the getter.

This stops being safe the moment either (a) llmsForSelectMenu reads any of the task's own tracked state (this.loadModelCostTiers.isRunning / .last / .performCount), or (b) anything on the synchronous path in ensureModelCostTiersLoaded writes tracked state. A one-line note to that effect near the getter would help the next editor who's tempted to add a spinner.

Limitation to name (non-blocking): the content key is realm + sorted model ids only — pricing is not part of it. So if the OpenRouter catalog updates a model's pricing while the model-id set stays the same, the badge won't refresh within the session (only a new/removed model id, or a page reload, forces a re-derive). Given badges are cosmetic and catalog pricing rarely moves mid-session, that's a reasonable trade — flagging it so it reads as a deliberate choice rather than an oversight.


Generated by Claude Code

async (modelIds: string[], realmURL: string, key: string) => {
let tiers = new Map<string, string>();
try {
let query: Query = {
filter: {
on: {
module: rri(new URL('openrouter-model', realmURL).href),
name: 'OpenRouterModel',
},
in: { modelId: modelIds },
},
};
let instances = await this.store.search(query, [realmURL]);
for (let instance of instances) {
let model = instance as unknown as {
modelId?: string;
costTierLabel?: string;
};
if (!model.modelId || !model.costTierLabel) {
continue;
}
tiers.set(model.modelId, model.costTierLabel);
}
} catch (e) {
console.warn('Failed to load model cost tiers', e);
// Keep whatever map we already have and clear the gate key (if no
// newer load has claimed it) so a later render retries. Skipping the
// tracked-map write matters: writing it would invalidate the picker
// and re-render straight back into a retry loop while the catalog
// realm is down.
if (this.lastModelCostTierKey === key) {
this.lastModelCostTierKey = '';
}
return;
}
this.modelCostTierByModelId = tiers;
},
);

private get systemCardId(): string | undefined {
return this.matrixService.systemCard?.id;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/host/app/lib/externals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ export function shimExternals(virtualNetwork: VirtualNetwork) {
id: '@cardstack/runtime-common/helpers/ai',
resolve: () => import('@cardstack/runtime-common/helpers/ai'),
});
virtualNetwork.shimAsyncModule({
id: '@cardstack/runtime-common/model-cost',
resolve: () => import('@cardstack/runtime-common/model-cost'),
});

shimModulesForLiveTests(virtualNetwork);

Expand Down
3 changes: 3 additions & 0 deletions packages/host/config/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ module.exports = function (environment) {

// Catalog realms are not available in test environment
ENV.resolvedCatalogRealmURL = undefined;
// Neither is the OpenRouter catalog realm; tests that exercise the model
// cost-tier lookup point this at their own test realm explicitly.
ENV.resolvedOpenRouterRealmURL = undefined;
ENV.defaultSystemCardId = 'http://test-realm/test/SystemCard/default';
ENV.defaultFieldSpecId = 'http://test-realm/test/fields/field';
}
Expand Down
2 changes: 1 addition & 1 deletion packages/host/tests/helpers/index.gts
Original file line number Diff line number Diff line change
Expand Up @@ -996,7 +996,7 @@ export function setupOnSave(hooks: NestedHooks) {
});
}

interface RealmContents {
export interface RealmContents {
[key: string]:
| CardDef
| FieldDef
Expand Down
Loading
Loading