Skip to content

Commit a1b3b28

Browse files
jahoomaclaude
andcommitted
Address second review pass on freebuff model selector
- Drop dead x-freebuff-model header on GET — the server only reads it on POST, and tick() always POSTs first so GET-before-POST never happens. - Derive FREEBUFF_MODEL_OVERRIDABLE_AGENT_IDS from the server's FREE_MODE_AGENT_MODELS (agents whose allowlist includes every freebuff model) so adding a new model doesn't require updating two lists. - Extract shouldReleaseSlot() — DELETE-eligibility predicate was inlined in two places. - Probe Fireworks once per admission tick instead of N times (N = number of models). Adds a TODO for when we add a non-Fireworks model. - Tighten model-selector key handler to /^[1-9]$/ so "1abc" isn't treated as 1. - Make FREEBUFF_MODELS a literal tuple so isFreebuffModelId narrows to the actual id union instead of plain string. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 06419bd commit a1b3b28

6 files changed

Lines changed: 54 additions & 42 deletions

File tree

cli/src/components/freebuff-model-selector.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ export const FreebuffModelSelector: React.FC<FreebuffModelSelectorProps> = ({
4747
useCallback(
4848
(key: KeyEvent) => {
4949
if (disabled || pending) return
50-
const digit = parseInt(key.name ?? '', 10)
51-
if (!Number.isFinite(digit) || digit < 1 || digit > FREEBUFF_MODELS.length) {
52-
return
53-
}
50+
const name = key.name ?? ''
51+
if (!/^[1-9]$/.test(name)) return
52+
const digit = Number(name)
53+
if (digit > FREEBUFF_MODELS.length) return
5454
const target = FREEBUFF_MODELS[digit - 1]
5555
if (target && target.id !== selectedModel) {
5656
key.preventDefault?.()

cli/src/hooks/use-freebuff-session.ts

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,7 @@ const POLL_INTERVAL_ERROR_MS = 10_000
2020
* account has rotated the id and respond with `{ status: 'superseded' }`. */
2121
const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id'
2222

23-
/** Header sent on POST/GET telling the server which model's queue we want.
24-
* POST uses it to (re-)join that model's queue; GET uses it only for the
25-
* rare GET-before-POST edge where there's no row yet. */
23+
/** Header sent on POST telling the server which model's queue to join. */
2624
const FREEBUFF_MODEL_HEADER = 'x-freebuff-model'
2725

2826
/** Play the terminal bell so users get an audible notification on admission. */
@@ -48,7 +46,7 @@ async function callSession(
4846
if (method === 'GET' && opts.instanceId) {
4947
headers[FREEBUFF_INSTANCE_HEADER] = opts.instanceId
5048
}
51-
if ((method === 'POST' || method === 'GET') && opts.model) {
49+
if (method === 'POST' && opts.model) {
5250
headers[FREEBUFF_MODEL_HEADER] = opts.model
5351
}
5452
const resp = await fetch(sessionEndpoint(), {
@@ -216,6 +214,21 @@ export function markFreebuffSessionEnded(): void {
216214
controller?.apply({ status: 'ended' })
217215
}
218216

217+
/** True when the session row represents a server-side slot the caller is
218+
* holding (queued, active, or in the post-expiry grace window with a live
219+
* instance id). DELETE only matters in those states; otherwise we'd fire a
220+
* spurious request the server has nothing to act on. */
221+
function shouldReleaseSlot(
222+
current: FreebuffSessionResponse | null,
223+
): boolean {
224+
if (!current) return false
225+
return (
226+
current.status === 'queued' ||
227+
current.status === 'active' ||
228+
(current.status === 'ended' && Boolean(current.instanceId))
229+
)
230+
}
231+
219232
/**
220233
* Best-effort DELETE of the caller's session row. Used by exit paths that
221234
* skip React unmount (process.exit on Ctrl+C) so the seat frees up quickly
@@ -224,13 +237,7 @@ export function markFreebuffSessionEnded(): void {
224237
export async function endFreebuffSessionBestEffort(): Promise<void> {
225238
if (!IS_FREEBUFF) return
226239
const current = useFreebuffSessionStore.getState().session
227-
if (!current) return
228-
// Only fire DELETE if we actually held a slot.
229-
const heldSlot =
230-
current.status === 'queued' ||
231-
current.status === 'active' ||
232-
(current.status === 'ended' && Boolean(current.instanceId))
233-
if (!heldSlot) return
240+
if (!shouldReleaseSlot(current)) return
234241
const { token } = getAuthTokenDetails()
235242
if (!token) return
236243
try {
@@ -389,12 +396,7 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
389396

390397
// Fire-and-forget DELETE. Only release if we actually held a slot so
391398
// we don't generate spurious DELETEs (e.g. HMR before POST completes).
392-
if (
393-
current &&
394-
(current.status === 'queued' ||
395-
current.status === 'active' ||
396-
(current.status === 'ended' && current.instanceId))
397-
) {
399+
if (shouldReleaseSlot(current)) {
398400
callSession('DELETE', token).catch(() => {})
399401
}
400402
setSession(null)

cli/src/utils/local-agent-registry.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,25 @@ import { loadLocalAgents as sdkLoadLocalAgents, loadMCPConfigSync } from '@codeb
77

88
import type { MCPConfig } from '@codebuff/common/types/mcp'
99

10+
import { FREE_MODE_AGENT_MODELS } from '@codebuff/common/constants/free-agents'
11+
import { FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models'
12+
1013
import { getSelectedFreebuffModel } from '../state/freebuff-model-store'
1114
import { getProjectRoot } from '../project-files'
1215
import { AGENT_MODE_TO_ID, IS_FREEBUFF, type AgentMode } from './constants'
1316
import { logger } from './logger'
1417
import * as bundledAgentsModule from '../agents/bundled-agents.generated'
1518

1619
/** Agents whose hardcoded model gets swapped out for the user's currently
17-
* selected freebuff model. Each entry must also be allowlisted under the
18-
* matching id in `FREE_MODE_AGENT_MODELS` (server-side check) for both
19-
* glm-5.1 and minimax-m2.7 — otherwise the chat-completions endpoint will
20-
* reject the request with `free_mode_invalid_agent_model`. */
21-
const FREEBUFF_MODEL_OVERRIDABLE_AGENT_IDS = new Set([
22-
'base2-free',
23-
'editor-lite',
24-
'code-reviewer-lite',
25-
])
20+
* selected freebuff model. Derived from the server's
21+
* `FREE_MODE_AGENT_MODELS` — any agent whose allowlist contains every
22+
* freebuff model is safe to retarget client-side without tripping the
23+
* server's `free_mode_invalid_agent_model` rejection. */
24+
const FREEBUFF_MODEL_OVERRIDABLE_AGENT_IDS: ReadonlySet<string> = new Set(
25+
Object.entries(FREE_MODE_AGENT_MODELS)
26+
.filter(([, allowed]) => FREEBUFF_MODELS.every((m) => allowed.has(m.id)))
27+
.map(([agentId]) => agentId),
28+
)
2629

2730
import type { AgentDefinition } from '@codebuff/common/templates/initial-agents-dir/types/agent-definition'
2831

common/src/constants/freebuff-models.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ export interface FreebuffModelOption {
1515
tagline: string
1616
}
1717

18-
export const FREEBUFF_MODELS: readonly FreebuffModelOption[] = [
18+
export const FREEBUFF_MODELS = [
1919
{
2020
id: 'z-ai/glm-5.1',
2121
displayName: 'GLM 5.1',
@@ -26,16 +26,22 @@ export const FREEBUFF_MODELS: readonly FreebuffModelOption[] = [
2626
displayName: 'MiniMax M2.7',
2727
tagline: 'Fast, lighter wait.',
2828
},
29-
] as const
29+
] as const satisfies readonly FreebuffModelOption[]
3030

31-
export const DEFAULT_FREEBUFF_MODEL_ID: string = FREEBUFF_MODELS[0].id
31+
export type FreebuffModelId = (typeof FREEBUFF_MODELS)[number]['id']
3232

33-
export function isFreebuffModelId(id: string | null | undefined): id is string {
33+
export const DEFAULT_FREEBUFF_MODEL_ID: FreebuffModelId = FREEBUFF_MODELS[0].id
34+
35+
export function isFreebuffModelId(
36+
id: string | null | undefined,
37+
): id is FreebuffModelId {
3438
if (!id) return false
3539
return FREEBUFF_MODELS.some((m) => m.id === id)
3640
}
3741

38-
export function resolveFreebuffModel(id: string | null | undefined): string {
42+
export function resolveFreebuffModel(
43+
id: string | null | undefined,
44+
): FreebuffModelId {
3945
return isFreebuffModelId(id) ? id : DEFAULT_FREEBUFF_MODEL_ID
4046
}
4147

web/src/app/api/v1/freebuff/session/_handlers.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,7 @@ function countryBlockedResponse(req: NextRequest): NextResponse | null {
3939
/** Header the CLI uses to identify which instance is polling. Used by GET to
4040
* detect when another CLI on the same account has rotated the id. */
4141
export const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id'
42-
/** Header the CLI uses to communicate which freebuff model it wants to be in
43-
* the queue for. Used by both POST (join/switch) and GET (read-only — the
44-
* server doesn't change the model on a GET, but uses the header for the
45-
* rare GET-before-POST case where there's no row yet). */
42+
/** Header the CLI sends on POST to pick which model's queue to join. */
4643
export const FREEBUFF_MODEL_HEADER = 'x-freebuff-model'
4744

4845
export interface FreebuffSessionDeps {

web/src/server/free-session/admission.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ export async function runAdmissionTick(
8181

8282
const models = deps.models ?? FREEBUFF_MODELS.map((m) => m.id)
8383

84+
// Probe upstream health once per tick. Today every model shares a Fireworks
85+
// deployment so a single probe gates them all — TODO: when we add a
86+
// non-Fireworks model, plumb a model/deploymentId into the probe.
87+
const health = await deps.getFireworksHealth()
88+
const sharedHealth = async () => health
89+
8490
// Run per-model admission in parallel — they only contend on independent
8591
// advisory locks and a single update each.
8692
const perModel = await Promise.all(
@@ -89,7 +95,7 @@ export async function runAdmissionTick(
8995
model,
9096
sessionLengthMs: deps.sessionLengthMs,
9197
now,
92-
getFireworksHealth: deps.getFireworksHealth,
98+
getFireworksHealth: sharedHealth,
9399
})
94100
const depth = await deps.queueDepth({ model })
95101
return { model, admittedCount: admitted.length, depth, skipped }
@@ -101,8 +107,6 @@ export async function runAdmissionTick(
101107
const queueDepthByModel = Object.fromEntries(
102108
perModel.map((r) => [r.model, r.depth]),
103109
)
104-
// Use the most-degraded skipped reason for the top-level result. They all
105-
// come from the same shared probe so they'll usually agree anyway.
106110
const skipped = perModel.find((r) => r.skipped)?.skipped ?? null
107111

108112
return {

0 commit comments

Comments
 (0)