Skip to content

Commit 64edebb

Browse files
authored
feat: add deployment hours availability for freebuff GLM 5.1 model (#540)
1 parent 2f95613 commit 64edebb

25 files changed

Lines changed: 600 additions & 362 deletions

File tree

agents/__tests__/editor.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ describe('editor agent', () => {
6767
expect(glmEditor.model).toBe('z-ai/glm-5.1')
6868
})
6969

70+
test('creates minimax editor', () => {
71+
const minimaxEditor = createCodeEditor({ model: 'minimax' })
72+
expect(minimaxEditor.model).toBe('minimax/minimax-m2.7')
73+
})
74+
7075
test('gpt-5 editor does not include think tags in instructions', () => {
7176
const gpt5Editor = createCodeEditor({ model: 'gpt-5' })
7277
expect(gpt5Editor.instructionsPrompt).not.toContain('<think>')
@@ -79,6 +84,12 @@ describe('editor agent', () => {
7984
expect(glmEditor.instructionsPrompt).not.toContain('</think>')
8085
})
8186

87+
test('minimax editor does not include think tags in instructions', () => {
88+
const minimaxEditor = createCodeEditor({ model: 'minimax' })
89+
expect(minimaxEditor.instructionsPrompt).not.toContain('<think>')
90+
expect(minimaxEditor.instructionsPrompt).not.toContain('</think>')
91+
})
92+
8293
test('opus editor includes think tags in instructions', () => {
8394
const opusEditor = createCodeEditor({ model: 'opus' })
8495
expect(opusEditor.instructionsPrompt).toContain('<think>')

agents/editor/editor.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import { publisher } from '../constants'
44
import type { AgentDefinition } from '../types/agent-definition'
55

66
export const createCodeEditor = (options: {
7-
model: 'gpt-5' | 'opus' | 'glm'
7+
model: 'gpt-5' | 'opus' | 'glm' | 'minimax'
88
}): Omit<AgentDefinition, 'id'> => {
99
const { model } = options
1010
return {
1111
publisher,
1212
model:
1313
options.model === 'gpt-5'
1414
? 'openai/gpt-5.1'
15+
: options.model === 'minimax'
16+
? 'minimax/minimax-m2.7'
1517
: options.model === 'glm'
1618
? 'z-ai/glm-5.1'
1719
: 'anthropic/claude-opus-4.7',
@@ -65,7 +67,7 @@ OR for new files or major rewrites:
6567
}
6668
</codebuff_tool_call>
6769
68-
${model === 'gpt-5' || model === 'glm'
70+
${model === 'gpt-5' || model === 'glm' || model === 'minimax'
6971
? ''
7072
: `Before you start writing your implementation, you should use <think> tags to think about the best way to implement the changes.
7173

agents/types/agent-definition.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,8 +423,6 @@ export type ModelName =
423423
// Other open source models
424424
| 'moonshotai/kimi-k2'
425425
| 'moonshotai/kimi-k2:nitro'
426-
| 'moonshotai/kimi-k2.5'
427-
| 'moonshotai/kimi-k2.5:nitro'
428426
| 'z-ai/glm-5'
429427
| 'z-ai/glm-5.1'
430428
| 'z-ai/glm-4.6'

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

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,28 @@ import { useKeyboard } from '@opentui/react'
33
import React, { useCallback, useEffect, useMemo, useState } from 'react'
44

55
import { Button } from './button'
6-
import { FREEBUFF_MODELS } from '@codebuff/common/constants/freebuff-models'
6+
import {
7+
DEFAULT_FREEBUFF_MODEL_ID,
8+
FREEBUFF_DEPLOYMENT_HOURS_LABEL,
9+
FREEBUFF_GLM_MODEL_ID,
10+
FREEBUFF_MODELS,
11+
isFreebuffModelAvailable,
12+
} from '@codebuff/common/constants/freebuff-models'
713

814
import { joinFreebuffQueue } from '../hooks/use-freebuff-session'
15+
import { useNow } from '../hooks/use-now'
916
import { useFreebuffModelStore } from '../state/freebuff-model-store'
1017
import { useFreebuffSessionStore } from '../state/freebuff-session-store'
1118
import { useTerminalDimensions } from '../hooks/use-terminal-dimensions'
1219
import { useTheme } from '../hooks/use-theme'
1320

1421
import type { KeyEvent } from '@opentui/core'
1522

23+
const FREEBUFF_MODEL_SELECTOR_MODELS = [
24+
...FREEBUFF_MODELS.filter((model) => model.id === FREEBUFF_GLM_MODEL_ID),
25+
...FREEBUFF_MODELS.filter((model) => model.id !== FREEBUFF_GLM_MODEL_ID),
26+
]
27+
1628
/**
1729
* Dual-purpose model picker:
1830
* - Pre-chat landing (session 'none'): user hasn't joined any queue. Picking
@@ -33,7 +45,9 @@ export const FreebuffModelSelector: React.FC = () => {
3345
const theme = useTheme()
3446
const { terminalWidth } = useTerminalDimensions()
3547
const selectedModel = useFreebuffModelStore((s) => s.selectedModel)
48+
const setSelectedModel = useFreebuffModelStore((s) => s.setSelectedModel)
3649
const session = useFreebuffSessionStore((s) => s.session)
50+
const now = useNow(60_000)
3751
const [pending, setPending] = useState<string | null>(null)
3852
const [hoveredId, setHoveredId] = useState<string | null>(null)
3953
// Keyboard cursor — separate from the actually-selected model so that
@@ -45,6 +59,15 @@ export const FreebuffModelSelector: React.FC = () => {
4559
setFocusedId(selectedModel)
4660
}, [selectedModel])
4761

62+
useEffect(() => {
63+
if (
64+
(session?.status === 'none' || !session) &&
65+
!isFreebuffModelAvailable(selectedModel, new Date(now))
66+
) {
67+
setSelectedModel(DEFAULT_FREEBUFF_MODEL_ID)
68+
}
69+
}, [now, selectedModel, session, setSelectedModel])
70+
4871
// Landing ('none'): depths come from the server snapshot, no "self" to
4972
// subtract. In-queue ('queued'): for the user's queue, "ahead" is
5073
// `position - 1` (themselves don't count); for every other queue, switching
@@ -85,18 +108,22 @@ export const FreebuffModelSelector: React.FC = () => {
85108
)
86109

87110
// Decide row vs column layout based on whether both buttons actually fit
88-
// side-by-side. Each button's inner text is "● {displayName} · {tagline} {hint}",
111+
// side-by-side. Each button's inner text is
112+
// "● {displayName} · {tagline} · {hours} {hint}",
89113
// plus 2 cols of border and 2 cols of padding. Buttons are separated by a
90114
// gap of 2. If the total exceeds the terminal width, stack vertically.
91115
const stackVertically = useMemo(() => {
92116
const BUTTON_CHROME = 4 // 2 border + 2 padding
93117
const GAP = 2
94-
const total = FREEBUFF_MODELS.reduce((sum, model, idx) => {
118+
const total = FREEBUFF_MODEL_SELECTOR_MODELS.reduce((sum, model, idx) => {
95119
const inner =
96120
2 /* indicator + space */ +
97121
model.displayName.length +
98122
3 /* " · " */ +
99123
model.tagline.length +
124+
(model.availability === 'deployment_hours'
125+
? 3 + FREEBUFF_DEPLOYMENT_HOURS_LABEL.length
126+
: 0) +
100127
2 /* " " */ +
101128
hintWidth
102129
return sum + inner + BUTTON_CHROME + (idx > 0 ? GAP : 0)
@@ -115,10 +142,11 @@ export const FreebuffModelSelector: React.FC = () => {
115142
(modelId: string) => {
116143
if (pending) return
117144
if (modelId === committedModelId) return
145+
if (!isFreebuffModelAvailable(modelId, new Date(now))) return
118146
setPending(modelId)
119147
joinFreebuffQueue(modelId).finally(() => setPending(null))
120148
},
121-
[pending, committedModelId],
149+
[pending, committedModelId, now],
122150
)
123151

124152
// Tab / Shift+Tab and arrow keys move the focus highlight only; Enter or
@@ -136,25 +164,30 @@ export const FreebuffModelSelector: React.FC = () => {
136164
const isCommit = name === 'return' || name === 'enter' || name === 'space'
137165
if (!isForward && !isBackward && !isCommit) return
138166
if (isCommit) {
139-
if (focusedId !== committedModelId) {
167+
if (
168+
focusedId !== committedModelId &&
169+
isFreebuffModelAvailable(focusedId, new Date(now))
170+
) {
140171
key.preventDefault?.()
141172
pick(focusedId)
142173
}
143174
return
144175
}
145-
const currentIdx = FREEBUFF_MODELS.findIndex((m) => m.id === focusedId)
176+
const currentIdx = FREEBUFF_MODEL_SELECTOR_MODELS.findIndex(
177+
(m) => m.id === focusedId,
178+
)
146179
if (currentIdx === -1) return
147-
const len = FREEBUFF_MODELS.length
180+
const len = FREEBUFF_MODEL_SELECTOR_MODELS.length
148181
const nextIdx = isForward
149182
? (currentIdx + 1) % len
150183
: (currentIdx - 1 + len) % len
151-
const target = FREEBUFF_MODELS[nextIdx]
184+
const target = FREEBUFF_MODEL_SELECTOR_MODELS[nextIdx]
152185
if (target) {
153186
key.preventDefault?.()
154187
setFocusedId(target.id)
155188
}
156189
},
157-
[pending, pick, focusedId, committedModelId],
190+
[pending, pick, focusedId, committedModelId, now],
158191
),
159192
)
160193

@@ -173,23 +206,30 @@ export const FreebuffModelSelector: React.FC = () => {
173206
alignItems: 'flex-start',
174207
}}
175208
>
176-
{FREEBUFF_MODELS.map((model) => {
209+
{FREEBUFF_MODEL_SELECTOR_MODELS.map((model) => {
177210
// 'Selected' means the dot is filled and the label is bold. On the
178211
// landing screen ('none') this tracks the pre-focused pick; on the
179212
// queued screen it tracks the model the server has us on. Either
180213
// way, selectedModel reflects the intent of "what Enter commits to."
181214
const isSelected = model.id === selectedModel
182215
const isHovered = hoveredId === model.id
183216
const isFocused = focusedId === model.id && !isSelected
217+
const isAvailable = isFreebuffModelAvailable(model.id, new Date(now))
184218
const indicator = isSelected ? '●' : '○'
185219
const indicatorColor = isSelected ? theme.primary : theme.muted
186-
const labelColor = isSelected ? theme.foreground : theme.muted
220+
const labelColor = isSelected && isAvailable ? theme.foreground : theme.muted
187221
// Clickable whenever picking would actually do something — i.e.
188222
// anything except re-picking the queue we're already in.
189-
const interactable = !pending && model.id !== committedModelId
223+
const interactable = !pending && isAvailable && model.id !== committedModelId
190224
const ahead = aheadByModel?.[model.id]
191225
const hint =
192-
ahead === undefined ? '' : ahead === 0 ? 'No wait' : `${ahead} ahead`
226+
!isAvailable
227+
? 'Closed'
228+
: ahead === undefined
229+
? ''
230+
: ahead === 0
231+
? 'No wait'
232+
: `${ahead} ahead`
193233

194234
const borderColor = isSelected
195235
? theme.primary
@@ -202,7 +242,7 @@ export const FreebuffModelSelector: React.FC = () => {
202242
key={model.id}
203243
onClick={() => {
204244
setFocusedId(model.id)
205-
pick(model.id)
245+
if (isAvailable) pick(model.id)
206246
}}
207247
onMouseOver={() => interactable && setHoveredId(model.id)}
208248
onMouseOut={() => setHoveredId((curr) => (curr === model.id ? null : curr))}
@@ -223,6 +263,9 @@ export const FreebuffModelSelector: React.FC = () => {
223263
{model.displayName}
224264
</span>
225265
<span fg={theme.muted}> · {model.tagline}</span>
266+
{model.availability === 'deployment_hours' && (
267+
<span fg={theme.muted}> · {FREEBUFF_DEPLOYMENT_HOURS_LABEL}</span>
268+
)}
226269
<span fg={theme.muted}> {hint.padEnd(hintWidth)}</span>
227270
</text>
228271
</Button>

cli/src/components/waiting-room-screen.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
253253
⚠ Account unavailable
254254
</text>
255255
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
256-
This account can't use freebuff. If you think this is a
256+
This account has been suspended and can't use freebuff. If you think this is a
257257
mistake, contact support@codebuff.com. Press Ctrl+C to exit.
258258
</text>
259259
</>

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

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { env } from '@codebuff/common/env'
2+
import { DEFAULT_FREEBUFF_MODEL_ID } from '@codebuff/common/constants/freebuff-models'
23
import { useEffect } from 'react'
34

45
import {
@@ -75,14 +76,18 @@ async function callSession(
7576
return body
7677
}
7778
}
78-
// 409 from POST means the user picked a different model than their active
79-
// session is bound to. Surface as a non-throw `model_locked` so the UI can
80-
// show a confirmation prompt (DELETE then re-POST to switch).
79+
// 409 from POST means the selected model cannot be joined right now, either
80+
// because an active session is locked to another model or because a
81+
// Surface model-switch conflicts and temporary model availability closures
82+
// as non-throw states.
8183
if (resp.status === 409 && method === 'POST') {
8284
const body = (await resp.json().catch(() => null)) as
8385
| FreebuffSessionResponse
8486
| null
85-
if (body && body.status === 'model_locked') {
87+
if (
88+
body &&
89+
(body.status === 'model_locked' || body.status === 'model_unavailable')
90+
) {
8691
return body
8792
}
8893
}
@@ -119,6 +124,7 @@ function nextDelayMs(next: FreebuffSessionResponse): number | null {
119124
case 'country_blocked':
120125
case 'banned':
121126
case 'model_locked':
127+
case 'model_unavailable':
122128
return null
123129
}
124130
}
@@ -398,6 +404,12 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
398404
schedule(0)
399405
return
400406
}
407+
if (next.status === 'model_unavailable') {
408+
useFreebuffModelStore.getState().setSelectedModel(DEFAULT_FREEBUFF_MODEL_ID)
409+
nextMethod = 'GET'
410+
schedule(0)
411+
return
412+
}
401413

402414
// Startup takeover: the initial probe GET saw we already hold a seat
403415
// (from a prior CLI instance). POST now to rotate our instance id so

cli/src/state/freebuff-model-store.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
DEFAULT_FREEBUFF_MODEL_ID,
3-
resolveFreebuffModel,
3+
resolveAvailableFreebuffModel,
44
} from '@codebuff/common/constants/freebuff-models'
55
import { create } from 'zustand'
66

@@ -24,11 +24,11 @@ interface FreebuffModelStore {
2424
}
2525

2626
export const useFreebuffModelStore = create<FreebuffModelStore>((set) => ({
27-
selectedModel: resolveFreebuffModel(
27+
selectedModel: resolveAvailableFreebuffModel(
2828
loadFreebuffModelPreference() ?? DEFAULT_FREEBUFF_MODEL_ID,
2929
),
3030
setSelectedModel: (model) => {
31-
const resolved = resolveFreebuffModel(model)
31+
const resolved = resolveAvailableFreebuffModel(model)
3232
saveFreebuffModelPreference(resolved)
3333
set({ selectedModel: resolved })
3434
},

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ export const loadAgentDefinitions = (): AgentDefinition[] => {
370370
}
371371

372372
// Override the model of free-mode agents to match the user's pick from the
373-
// freebuff waiting room. Bundled definitions hardcode glm-5.1; we swap in
373+
// freebuff waiting room. Bundled definitions hardcode a free model; we swap in
374374
// whatever the user chose so the chat-completions request body carries the
375375
// matching model and the server-side session gate doesn't reject it as a
376376
// model mismatch.

common/src/constants/free-agents.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export const FREEBUFF_ROOT_AGENT_IDS = ['base2-free'] as const
2626
*/
2727
export const FREE_MODE_AGENT_MODELS: Record<string, Set<string>> = {
2828
// Root orchestrator
29-
'base2-free': new Set(['minimax/minimax-m2.7', 'z-ai/glm-5.1']),
29+
'base2-free': new Set([
30+
'minimax/minimax-m2.7',
31+
'z-ai/glm-5.1',
32+
]),
3033

3134
// File exploration agents
3235
'file-picker': new Set(['google/gemini-2.5-flash-lite']),
@@ -41,10 +44,16 @@ export const FREE_MODE_AGENT_MODELS: Record<string, Set<string>> = {
4144
'basher': new Set(['google/gemini-3.1-flash-lite-preview']),
4245

4346
// Editor for free mode
44-
'editor-lite': new Set(['minimax/minimax-m2.7', 'z-ai/glm-5.1']),
47+
'editor-lite': new Set([
48+
'minimax/minimax-m2.7',
49+
'z-ai/glm-5.1',
50+
]),
4551

4652
// Code reviewer for free mode
47-
'code-reviewer-lite': new Set(['minimax/minimax-m2.7', 'z-ai/glm-5.1']),
53+
'code-reviewer-lite': new Set([
54+
'minimax/minimax-m2.7',
55+
'z-ai/glm-5.1',
56+
]),
4857
}
4958

5059
/**

0 commit comments

Comments
 (0)