@gotcos/glasses-server 6.3.1 → 6.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +23 -7
- package/CHANGELOG.md +108 -0
- package/README.md +28 -8
- package/bin/cli.cjs +22 -10
- package/package.json +18 -6
- package/server/bin/cos-output-image-publisher.mjs +324 -0
- package/server/bootstrap.ts +16 -0
- package/server/index.ts +61 -21
- package/server/lib/activity-preview.ts +168 -0
- package/server/lib/archive.ts +20 -6
- package/server/lib/claude-bridge.ts +215 -60
- package/server/lib/claude-run-ledger.ts +7 -2
- package/server/lib/codex-bridge.ts +186 -71
- package/server/lib/codex-engine-sessions.ts +24 -2
- package/server/lib/codex-model-catalog.ts +450 -0
- package/server/lib/codex-run-ledger.ts +20 -4
- package/server/lib/conversation.ts +64 -2
- package/server/lib/display-bus.ts +61 -3
- package/server/lib/image-safety.ts +458 -0
- package/server/lib/listener-startup.ts +29 -0
- package/server/lib/media-store.ts +833 -0
- package/server/lib/model-image-input.ts +27 -0
- package/server/lib/model-router.ts +67 -8
- package/server/lib/query-attachments.ts +132 -0
- package/server/lib/run-output-images.ts +442 -0
- package/server/lib/server-instance-id.ts +55 -0
- package/server/lib/server-instance-lock.ts +122 -0
- package/server/lib/server-metrics.ts +7 -0
- package/server/routes/display.ts +43 -22
- package/server/routes/health.ts +19 -2
- package/server/routes/media.ts +285 -0
- package/server/routes/message-ref.ts +18 -6
- package/server/routes/openai-compat.ts +44 -11
- package/server/routes/query.ts +51 -16
- package/server/routes/sessions.ts +33 -4
- package/shared/media-attachment.ts +126 -0
- package/shared/model-preference.ts +140 -17
package/server/routes/query.ts
CHANGED
|
@@ -5,7 +5,10 @@ import { Router } from 'express'
|
|
|
5
5
|
import { callModelStreaming } from '../lib/model-router.js'
|
|
6
6
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
7
7
|
import { errMsg } from '../lib/utils.js'
|
|
8
|
-
import { normalizeModelPreference } from '../../shared/model-preference.js'
|
|
8
|
+
import { normalizeEffortPreference, normalizeModelPreference } from '../../shared/model-preference.js'
|
|
9
|
+
import { QueryAttachmentError, resolveQueryAttachments } from '../lib/query-attachments.js'
|
|
10
|
+
import { getMediaStore } from '../lib/media-store.js'
|
|
11
|
+
import { mergeMediaAttachmentRefs } from '../../shared/media-attachment.js'
|
|
9
12
|
|
|
10
13
|
const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
11
14
|
WebSearch: 'Searching web...',
|
|
@@ -16,28 +19,35 @@ const TOOL_STATUS_MESSAGES: Record<string, string> = {
|
|
|
16
19
|
export const queryRouter = Router()
|
|
17
20
|
|
|
18
21
|
queryRouter.post('/query', async (req, res) => {
|
|
19
|
-
const { query, sessionId, model,
|
|
22
|
+
const { query, sessionId, model, effort, reference, globalMsgNum } = req.body
|
|
23
|
+
const activityToolMode = req.body.activityToolMode === 'off' || req.body.activityToolMode === 'preview'
|
|
24
|
+
? req.body.activityToolMode
|
|
25
|
+
: 'status'
|
|
20
26
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
// Resolve durable attachment ids and legacy base64 images through one
|
|
28
|
+
// validation/normalization path before opening SSE.
|
|
29
|
+
let resolvedAttachments
|
|
30
|
+
try {
|
|
31
|
+
resolvedAttachments = await resolveQueryAttachments(req.body)
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err instanceof QueryAttachmentError) {
|
|
34
|
+
return res.status(err.status).json({ error: err.code, detail: err.message })
|
|
35
|
+
}
|
|
36
|
+
return res.status(500).json({ error: errMsg(err) })
|
|
30
37
|
}
|
|
38
|
+
const imageInputs = resolvedAttachments.inputs.length > 0 ? resolvedAttachments.inputs : undefined
|
|
39
|
+
const attachmentRefs = resolvedAttachments.refs
|
|
31
40
|
|
|
32
41
|
const resolvedQuery = typeof query === 'string' ? query : ''
|
|
33
42
|
|
|
34
43
|
// Vision queries can have an empty query (default to "describe what you see")
|
|
35
|
-
if ((!resolvedQuery || typeof resolvedQuery !== 'string') && !
|
|
44
|
+
if ((!resolvedQuery || typeof resolvedQuery !== 'string') && !imageInputs) {
|
|
36
45
|
return res.status(400).json({ error: 'query string or image required' })
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
// Validate model if provided
|
|
40
49
|
const validModel = normalizeModelPreference(model)
|
|
50
|
+
const validEffort = normalizeEffortPreference(effort)
|
|
41
51
|
|
|
42
52
|
// Validate globalMsgNum if provided
|
|
43
53
|
const validGlobalMsgNum = typeof globalMsgNum === 'number' && globalMsgNum > 0
|
|
@@ -80,15 +90,40 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
80
90
|
},
|
|
81
91
|
onToolStatus: (toolName) => {
|
|
82
92
|
if (!done) {
|
|
83
|
-
const message =
|
|
93
|
+
const message = activityToolMode === 'off'
|
|
94
|
+
? 'Processing...'
|
|
95
|
+
: TOOL_STATUS_MESSAGES[toolName] ?? (/\s|\.{3}$/.test(toolName) ? toolName : `Using ${toolName}...`)
|
|
84
96
|
res.write(`event: tool_status\ndata: ${JSON.stringify({ message })}\n\n`)
|
|
85
97
|
emitDisplay({ type: 'tool_status', data: { message } })
|
|
86
98
|
}
|
|
87
99
|
},
|
|
100
|
+
// Activity lines stay on this authenticated request stream. The global
|
|
101
|
+
// display stream is intentionally unauthenticated for Even Hub recovery,
|
|
102
|
+
// so observable command/output text must never be broadcast there.
|
|
103
|
+
...(activityToolMode === 'preview' ? {
|
|
104
|
+
onActivityLine: (line: { kind: 'input' | 'output'; text: string }) => {
|
|
105
|
+
if (!done) {
|
|
106
|
+
res.write(`event: activity_line\ndata: ${JSON.stringify(line)}\n\n`)
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
} : {}),
|
|
88
110
|
onDone: (fullText, model, cliSessionId, metadata) => {
|
|
111
|
+
// Durable association is independent of the SSE socket. Backgrounding
|
|
112
|
+
// the phone cannot leave request media reserved until expiry.
|
|
113
|
+
if (resolvedAttachments.ids.length > 0) {
|
|
114
|
+
getMediaStore().associate(resolvedAttachments.ids, {
|
|
115
|
+
sessionId: sid,
|
|
116
|
+
...(validGlobalMsgNum ? { globalMsgNum: validGlobalMsgNum } : {}),
|
|
117
|
+
}).catch((err) => console.error('[query] attachment association failed:', err))
|
|
118
|
+
}
|
|
89
119
|
if (!done) {
|
|
90
120
|
done = true
|
|
91
|
-
const
|
|
121
|
+
const attachments = mergeMediaAttachmentRefs(attachmentRefs, metadata?.outputAttachments)
|
|
122
|
+
const { outputAttachments: _outputAttachments, ...runMetadata } = metadata ?? {}
|
|
123
|
+
const payload = {
|
|
124
|
+
text: fullText, sessionId: sid, model, cliSessionId, ...runMetadata,
|
|
125
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
126
|
+
}
|
|
92
127
|
res.write(`event: done\ndata: ${JSON.stringify(payload)}\n\n`)
|
|
93
128
|
emitDisplay({ type: 'done', data: payload })
|
|
94
129
|
res.end()
|
|
@@ -102,13 +137,13 @@ queryRouter.post('/query', async (req, res) => {
|
|
|
102
137
|
res.end()
|
|
103
138
|
}
|
|
104
139
|
},
|
|
105
|
-
}, validModel,
|
|
140
|
+
}, validModel, imageInputs,
|
|
106
141
|
// Pass reference if provided (for "recall message N" feature)
|
|
107
142
|
reference && typeof reference === 'object' && reference.query && reference.response
|
|
108
143
|
? { query: String(reference.query), response: String(reference.response) }
|
|
109
144
|
: undefined,
|
|
110
145
|
validGlobalMsgNum,
|
|
111
|
-
{ abortSignal: abortController.signal },
|
|
146
|
+
{ abortSignal: abortController.signal, effort: validEffort },
|
|
112
147
|
)
|
|
113
148
|
} catch (err: unknown) {
|
|
114
149
|
if (!done) {
|
|
@@ -7,6 +7,7 @@ import { buildSessionLogEntry, writeSessionLog } from '../lib/session-log.js'
|
|
|
7
7
|
import { getArchiveDayMessages } from '../lib/archive.js'
|
|
8
8
|
import { localDay } from '../lib/local-day.js'
|
|
9
9
|
import { clearCodexEngineSession } from '../lib/codex-engine-sessions.js'
|
|
10
|
+
import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
|
|
10
11
|
|
|
11
12
|
export const sessionsRouter = Router()
|
|
12
13
|
|
|
@@ -48,14 +49,30 @@ sessionsRouter.get('/sessions/:id/messages', (req, res) => {
|
|
|
48
49
|
return
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
// Pair user+assistant exchanges into client message format
|
|
52
|
-
|
|
52
|
+
// Pair user+assistant exchanges into client message format. Request refs on
|
|
53
|
+
// the user turn and output refs on the assistant turn become one safe list.
|
|
54
|
+
const messages: Array<{
|
|
55
|
+
query: string
|
|
56
|
+
text: string
|
|
57
|
+
timestamp: number
|
|
58
|
+
no?: number
|
|
59
|
+
sessionId: string
|
|
60
|
+
attachments?: MediaAttachmentRef[]
|
|
61
|
+
}> = []
|
|
53
62
|
for (let i = 0; i < exchanges.length; i++) {
|
|
54
63
|
const ex = exchanges[i]
|
|
55
64
|
if (ex.role === 'user') {
|
|
56
65
|
const next = exchanges[i + 1]
|
|
57
66
|
if (next && next.role === 'assistant') {
|
|
58
|
-
|
|
67
|
+
const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
|
|
68
|
+
messages.push({
|
|
69
|
+
query: ex.content,
|
|
70
|
+
text: next.content,
|
|
71
|
+
timestamp: next.timestamp,
|
|
72
|
+
sessionId: req.params.id,
|
|
73
|
+
...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
|
|
74
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
75
|
+
})
|
|
59
76
|
i++ // skip the assistant exchange
|
|
60
77
|
}
|
|
61
78
|
}
|
|
@@ -230,7 +247,16 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
230
247
|
source: 'archive' as const,
|
|
231
248
|
}))
|
|
232
249
|
|
|
233
|
-
const liveMessages: Array<{
|
|
250
|
+
const liveMessages: Array<{
|
|
251
|
+
query: string
|
|
252
|
+
text: string
|
|
253
|
+
timestamp: number
|
|
254
|
+
chatIndex: number
|
|
255
|
+
sessionId: string
|
|
256
|
+
source: 'live'
|
|
257
|
+
no?: number
|
|
258
|
+
attachments?: MediaAttachmentRef[]
|
|
259
|
+
}> = []
|
|
234
260
|
const liveSessions = getActiveSessions()
|
|
235
261
|
for (const session of liveSessions) {
|
|
236
262
|
const sessionDay = localDay(session.lastActivity)
|
|
@@ -240,6 +266,7 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
240
266
|
if (ex.role === 'user') {
|
|
241
267
|
const next = session.exchanges[i + 1]
|
|
242
268
|
if (next && next.role === 'assistant') {
|
|
269
|
+
const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
|
|
243
270
|
liveMessages.push({
|
|
244
271
|
query: ex.content,
|
|
245
272
|
text: next.content,
|
|
@@ -247,6 +274,8 @@ sessionsRouter.get('/sessions/today/all-messages', (_req, res) => {
|
|
|
247
274
|
chatIndex: -1,
|
|
248
275
|
sessionId: session.id,
|
|
249
276
|
source: 'live',
|
|
277
|
+
...((ex.globalMsgNum ?? next.globalMsgNum) != null ? { no: ex.globalMsgNum ?? next.globalMsgNum } : {}),
|
|
278
|
+
...(attachments.length > 0 ? { attachments } : {}),
|
|
250
279
|
})
|
|
251
280
|
i++
|
|
252
281
|
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Media attachment contract — the ONE shape both the browser client and the
|
|
2
|
+
// server exchange for image attachments (Release A of the image-attachments
|
|
3
|
+
// plan). The public ref deliberately carries NO storage path, URL, token,
|
|
4
|
+
// base64, checksum, or internal lifecycle state — those live only in the
|
|
5
|
+
// server media index. Anything that persists or transmits an attachment
|
|
6
|
+
// persists THIS shape (or just the id) and nothing else.
|
|
7
|
+
|
|
8
|
+
export type MediaKind = 'user_photo' | 'traffic_frame' | 'generated_visual'
|
|
9
|
+
|
|
10
|
+
export type MediaMime = 'image/jpeg' | 'image/png'
|
|
11
|
+
|
|
12
|
+
export interface MediaAttachmentRef {
|
|
13
|
+
id: string
|
|
14
|
+
kind: MediaKind
|
|
15
|
+
mime: MediaMime
|
|
16
|
+
width: number
|
|
17
|
+
height: number
|
|
18
|
+
createdAt: string
|
|
19
|
+
label?: string
|
|
20
|
+
capturedAt?: string
|
|
21
|
+
expiresAt?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Hard cap on attachments per prompt — mirrored by upload validation,
|
|
25
|
+
* query resolution, and the phone composer. */
|
|
26
|
+
export const MAX_ATTACHMENTS_PER_PROMPT = 5
|
|
27
|
+
|
|
28
|
+
// ── Media IDs ────────────────────────────────────────────────────────────────
|
|
29
|
+
// One strict generated format, one strict validator. The id builds filesystem
|
|
30
|
+
// paths on the server, so the validator rejects anything that isn't exactly
|
|
31
|
+
// `m_` + 24 lowercase hex chars — no path characters can ever pass.
|
|
32
|
+
|
|
33
|
+
export const MEDIA_ID_RE = /^m_[a-f0-9]{24}$/
|
|
34
|
+
|
|
35
|
+
export function isValidMediaId(id: unknown): id is string {
|
|
36
|
+
return typeof id === 'string' && MEDIA_ID_RE.test(id)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const VALID_KINDS: ReadonlySet<string> = new Set(['user_photo', 'traffic_frame', 'generated_visual'])
|
|
40
|
+
const VALID_MIMES: ReadonlySet<string> = new Set(['image/jpeg', 'image/png'])
|
|
41
|
+
const MAX_LABEL_LEN = 120
|
|
42
|
+
// ISO-8601 subset — what `new Date().toISOString()` emits.
|
|
43
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/
|
|
44
|
+
|
|
45
|
+
function isIsoTimestamp(v: unknown): v is string {
|
|
46
|
+
return typeof v === 'string' && ISO_RE.test(v) && Number.isFinite(new Date(v).getTime())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isDimension(v: unknown): v is number {
|
|
50
|
+
return typeof v === 'number' && Number.isInteger(v) && v > 0 && v <= 65_535
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Validate an UNTRUSTED value into a MediaAttachmentRef, or null.
|
|
54
|
+
* TypeScript types alone are not validation — every persistence and API
|
|
55
|
+
* boundary that accepts a ref from outside must run it through here.
|
|
56
|
+
* Returns a fresh object containing only the known fields (drops extras). */
|
|
57
|
+
export function parseMediaAttachmentRef(raw: unknown): MediaAttachmentRef | null {
|
|
58
|
+
if (!raw || typeof raw !== 'object') return null
|
|
59
|
+
const r = raw as Record<string, unknown>
|
|
60
|
+
if (!isValidMediaId(r.id)) return null
|
|
61
|
+
if (typeof r.kind !== 'string' || !VALID_KINDS.has(r.kind)) return null
|
|
62
|
+
if (typeof r.mime !== 'string' || !VALID_MIMES.has(r.mime)) return null
|
|
63
|
+
if (!isDimension(r.width) || !isDimension(r.height)) return null
|
|
64
|
+
if (!isIsoTimestamp(r.createdAt)) return null
|
|
65
|
+
const ref: MediaAttachmentRef = {
|
|
66
|
+
id: r.id,
|
|
67
|
+
kind: r.kind as MediaKind,
|
|
68
|
+
mime: r.mime as MediaMime,
|
|
69
|
+
width: r.width,
|
|
70
|
+
height: r.height,
|
|
71
|
+
createdAt: r.createdAt,
|
|
72
|
+
}
|
|
73
|
+
if (typeof r.label === 'string' && r.label.length > 0) {
|
|
74
|
+
ref.label = r.label.slice(0, MAX_LABEL_LEN)
|
|
75
|
+
}
|
|
76
|
+
if (isIsoTimestamp(r.capturedAt)) ref.capturedAt = r.capturedAt
|
|
77
|
+
if (isIsoTimestamp(r.expiresAt)) ref.expiresAt = r.expiresAt
|
|
78
|
+
return ref
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Validate an untrusted array of refs, dropping only the invalid entries
|
|
82
|
+
* (a bad ref must never take the whole conversation record with it). */
|
|
83
|
+
export function parseMediaAttachmentRefs(raw: unknown): MediaAttachmentRef[] {
|
|
84
|
+
if (!Array.isArray(raw)) return []
|
|
85
|
+
const out: MediaAttachmentRef[] = []
|
|
86
|
+
for (const item of raw) {
|
|
87
|
+
const ref = parseMediaAttachmentRef(item)
|
|
88
|
+
if (ref) out.push(ref)
|
|
89
|
+
if (out.length >= MAX_ATTACHMENTS_PER_PROMPT) break
|
|
90
|
+
}
|
|
91
|
+
return out
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Validate, merge, and de-duplicate attachment refs from multiple untrusted
|
|
95
|
+
* exchange surfaces. A completed Q&A pair can carry request refs on the user
|
|
96
|
+
* turn and generated/research refs on the assistant turn; readers should see
|
|
97
|
+
* one bounded list without trusting either persisted shape. First occurrence
|
|
98
|
+
* wins so the request-side ref remains stable when the server echoes it back
|
|
99
|
+
* in completion metadata. */
|
|
100
|
+
export function mergeMediaAttachmentRefs(...sources: unknown[]): MediaAttachmentRef[] {
|
|
101
|
+
const out: MediaAttachmentRef[] = []
|
|
102
|
+
const seen = new Set<string>()
|
|
103
|
+
for (const source of sources) {
|
|
104
|
+
if (!Array.isArray(source)) continue
|
|
105
|
+
for (const item of source) {
|
|
106
|
+
const ref = parseMediaAttachmentRef(item)
|
|
107
|
+
if (!ref || seen.has(ref.id)) continue
|
|
108
|
+
seen.add(ref.id)
|
|
109
|
+
out.push(ref)
|
|
110
|
+
if (out.length >= MAX_ATTACHMENTS_PER_PROMPT) return out
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Validate an untrusted list of media IDs (dedup, cap, strict format). */
|
|
117
|
+
export function parseMediaIdList(raw: unknown): string[] {
|
|
118
|
+
if (!Array.isArray(raw)) return []
|
|
119
|
+
const out: string[] = []
|
|
120
|
+
for (const item of raw) {
|
|
121
|
+
if (!isValidMediaId(item) || out.includes(item)) continue
|
|
122
|
+
out.push(item)
|
|
123
|
+
if (out.length >= MAX_ATTACHMENTS_PER_PROMPT) break
|
|
124
|
+
}
|
|
125
|
+
return out
|
|
126
|
+
}
|
|
@@ -1,42 +1,159 @@
|
|
|
1
|
-
export type ClaudeModelPreference = 'opus' | 'sonnet' | 'haiku'
|
|
2
|
-
|
|
1
|
+
export type ClaudeModelPreference = 'opus' | 'fable' | 'sonnet' | 'haiku'
|
|
2
|
+
|
|
3
|
+
// Stable app-level slots. Concrete GPT model ids resolve at runtime from the
|
|
4
|
+
// local Codex CLI catalog, so a shipped glasses app keeps tracking new releases.
|
|
5
|
+
export type CodexModelPreference = 'codex-frontier' | 'codex-balanced'
|
|
3
6
|
export type ModelPreference = ClaudeModelPreference | CodexModelPreference
|
|
4
7
|
|
|
8
|
+
// Preserve the public server's established fast, broadly available default.
|
|
5
9
|
export const DEFAULT_MODEL = 'sonnet' as const
|
|
6
|
-
export const
|
|
10
|
+
export const CODEX_FRONTIER_MODEL: CodexModelPreference = 'codex-frontier'
|
|
11
|
+
export const CODEX_BALANCED_MODEL: CodexModelPreference = 'codex-balanced'
|
|
12
|
+
// Backward-compatible export for callers that predate the two-slot catalog.
|
|
13
|
+
export const CODEX_HIGH_MODEL: CodexModelPreference = CODEX_FRONTIER_MODEL
|
|
14
|
+
// Existing 6.1–6.3 installs may pin the legacy codex-high slot. Frontier is its
|
|
15
|
+
// migration target; Balanced remains auto-catalog even when this override is set.
|
|
16
|
+
export const CODEX_MODEL_ID = process.env.COS_CODEX_MODEL?.trim() ?? ''
|
|
17
|
+
|
|
18
|
+
export type CodexReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'ultra'
|
|
19
|
+
const CODEX_REASONING_EFFORT_SET = new Set<CodexReasoningEffort>(['low', 'medium', 'high', 'xhigh', 'max', 'ultra'])
|
|
20
|
+
|
|
21
|
+
export function resolveConfiguredCodexReasoningEffort(): CodexReasoningEffort {
|
|
22
|
+
const raw = process.env.COS_CODEX_REASONING_EFFORT?.trim().toLowerCase()
|
|
23
|
+
return raw && CODEX_REASONING_EFFORT_SET.has(raw as CodexReasoningEffort)
|
|
24
|
+
? raw as CodexReasoningEffort
|
|
25
|
+
: 'high'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const CODEX_HIGH_REASONING_EFFORT: CodexReasoningEffort = resolveConfiguredCodexReasoningEffort()
|
|
29
|
+
|
|
30
|
+
export const MODEL_OPTIONS: ModelPreference[] = [
|
|
31
|
+
'opus',
|
|
32
|
+
'fable',
|
|
33
|
+
'sonnet',
|
|
34
|
+
CODEX_FRONTIER_MODEL,
|
|
35
|
+
CODEX_BALANCED_MODEL,
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const MODEL_SET = new Set<ModelPreference>([
|
|
39
|
+
'opus',
|
|
40
|
+
'fable',
|
|
41
|
+
'sonnet',
|
|
42
|
+
'haiku',
|
|
43
|
+
CODEX_FRONTIER_MODEL,
|
|
44
|
+
CODEX_BALANCED_MODEL,
|
|
45
|
+
])
|
|
46
|
+
|
|
47
|
+
// Bare Claude tier aliases resolve to the newest model in that tier at spawn.
|
|
48
|
+
// The [1m] suffix keeps the large-context contract without pinning a version.
|
|
49
|
+
export const CLAUDE_CLI_MODEL_ID: Record<ClaudeModelPreference, string> = {
|
|
50
|
+
opus: 'opus[1m]',
|
|
51
|
+
fable: 'fable[1m]',
|
|
52
|
+
sonnet: 'sonnet[1m]',
|
|
53
|
+
haiku: 'haiku',
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveClaudeCliModelId(model: ClaudeModelPreference): string {
|
|
57
|
+
return CLAUDE_CLI_MODEL_ID[model] ?? CLAUDE_CLI_MODEL_ID[DEFAULT_MODEL]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Format a concrete Claude CLI model id for diagnostics. */
|
|
61
|
+
export function formatResolvedModelDisplay(cliModelId: string | undefined): string {
|
|
62
|
+
if (!cliModelId) return ''
|
|
63
|
+
const oneM = cliModelId.includes('[1m]')
|
|
64
|
+
const bare = cliModelId.replace('[1m]', '')
|
|
65
|
+
const match = /^claude-([a-z]+)-(\d+(?:-\d+)*)$/.exec(bare)
|
|
66
|
+
if (!match) return cliModelId
|
|
67
|
+
const family = match[1].charAt(0).toUpperCase() + match[1].slice(1)
|
|
68
|
+
const version = match[2].split('-').join('.')
|
|
69
|
+
return `${family} ${version}${oneM ? ' (1M)' : ''}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type EffortPreference = 'high' | 'xhigh' | 'max' | 'ultracode'
|
|
73
|
+
export const DEFAULT_EFFORT: EffortPreference = 'high'
|
|
74
|
+
export const EFFORT_OPTIONS: EffortPreference[] = ['high', 'xhigh', 'max', 'ultracode']
|
|
75
|
+
const EFFORT_SET = new Set<EffortPreference>(EFFORT_OPTIONS)
|
|
76
|
+
export const ULTRACODE_KEYWORD = 'ultracode'
|
|
77
|
+
|
|
78
|
+
export function isEffortPreference(value: unknown): value is EffortPreference {
|
|
79
|
+
return typeof value === 'string' && EFFORT_SET.has(value as EffortPreference)
|
|
80
|
+
}
|
|
7
81
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export
|
|
82
|
+
export function normalizeEffortPreference(value: unknown): EffortPreference | undefined {
|
|
83
|
+
return isEffortPreference(value) ? value : undefined
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Map the UI-only ultracode choice to a Claude CLI effort flag. */
|
|
87
|
+
export function resolveCliEffortFlag(effort: EffortPreference): 'high' | 'xhigh' | 'max' {
|
|
88
|
+
return effort === 'ultracode' ? 'xhigh' : effort
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Codex Fast mode is emitted only when the selected live model advertises it.
|
|
92
|
+
export const CODEX_SERVICE_TIER = 'priority'
|
|
14
93
|
|
|
15
|
-
export
|
|
94
|
+
export function resolveCodexReasoningEffort(effort: EffortPreference | undefined): CodexReasoningEffort {
|
|
95
|
+
if (effort === 'ultracode') return 'ultra'
|
|
96
|
+
if (effort === 'max') return 'max'
|
|
97
|
+
if (effort === 'xhigh') return 'xhigh'
|
|
98
|
+
return 'high'
|
|
99
|
+
}
|
|
16
100
|
|
|
17
|
-
|
|
101
|
+
export function effortLabel(effort: EffortPreference): string {
|
|
102
|
+
switch (effort) {
|
|
103
|
+
case 'xhigh': return 'Extra High'
|
|
104
|
+
case 'max': return 'Max'
|
|
105
|
+
case 'ultracode': return 'Ultracode'
|
|
106
|
+
case 'high':
|
|
107
|
+
default:
|
|
108
|
+
return 'High'
|
|
109
|
+
}
|
|
110
|
+
}
|
|
18
111
|
|
|
19
112
|
export function isModelPreference(value: unknown): value is ModelPreference {
|
|
20
113
|
return typeof value === 'string' && MODEL_SET.has(value as ModelPreference)
|
|
21
114
|
}
|
|
22
115
|
|
|
23
116
|
export function normalizeModelPreference(value: unknown): ModelPreference | undefined {
|
|
117
|
+
// Existing installs persist codex-high. Migrate it to the live frontier slot.
|
|
118
|
+
if (value === 'codex-high') return CODEX_FRONTIER_MODEL
|
|
24
119
|
return isModelPreference(value) ? value : undefined
|
|
25
120
|
}
|
|
26
121
|
|
|
27
122
|
export function isClaudeModel(model: ModelPreference): model is ClaudeModelPreference {
|
|
28
|
-
return model === 'opus' || model === 'sonnet' || model === 'haiku'
|
|
123
|
+
return model === 'opus' || model === 'fable' || model === 'sonnet' || model === 'haiku'
|
|
29
124
|
}
|
|
30
125
|
|
|
31
126
|
export function isCodexModel(model: ModelPreference): model is CodexModelPreference {
|
|
32
|
-
return model ===
|
|
127
|
+
return model === CODEX_FRONTIER_MODEL || model === CODEX_BALANCED_MODEL
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export interface RuntimeCodexModelLabel {
|
|
131
|
+
preference: CodexModelPreference
|
|
132
|
+
displayName: string
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const runtimeCodexLabels: Partial<Record<CodexModelPreference, string>> = {}
|
|
136
|
+
|
|
137
|
+
export function setRuntimeCodexModelLabels(options: RuntimeCodexModelLabel[]): void {
|
|
138
|
+
for (const option of options) {
|
|
139
|
+
if (!isCodexModel(option.preference)) continue
|
|
140
|
+
const label = typeof option.displayName === 'string' ? option.displayName.trim() : ''
|
|
141
|
+
if (label) runtimeCodexLabels[option.preference] = label
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function resetRuntimeCodexModelLabels(): void {
|
|
146
|
+
delete runtimeCodexLabels[CODEX_FRONTIER_MODEL]
|
|
147
|
+
delete runtimeCodexLabels[CODEX_BALANCED_MODEL]
|
|
33
148
|
}
|
|
34
149
|
|
|
35
150
|
export function modelLabel(model: ModelPreference): string {
|
|
36
151
|
switch (model) {
|
|
152
|
+
case 'fable': return 'Fable'
|
|
37
153
|
case 'sonnet': return 'Sonnet'
|
|
38
154
|
case 'haiku': return 'Haiku'
|
|
39
|
-
case 'codex-
|
|
155
|
+
case 'codex-frontier': return runtimeCodexLabels[model] ?? 'GPT Frontier'
|
|
156
|
+
case 'codex-balanced': return runtimeCodexLabels[model] ?? 'GPT Balanced'
|
|
40
157
|
case 'opus':
|
|
41
158
|
default:
|
|
42
159
|
return 'Opus'
|
|
@@ -45,9 +162,11 @@ export function modelLabel(model: ModelPreference): string {
|
|
|
45
162
|
|
|
46
163
|
export function modelShortLabel(model: ModelPreference): string {
|
|
47
164
|
switch (model) {
|
|
165
|
+
case 'fable': return 'Fable'
|
|
48
166
|
case 'sonnet': return 'Sonnet'
|
|
49
167
|
case 'haiku': return 'Haiku'
|
|
50
|
-
case 'codex-
|
|
168
|
+
case 'codex-frontier': return 'GPT Max'
|
|
169
|
+
case 'codex-balanced': return 'GPT Bal'
|
|
51
170
|
case 'opus':
|
|
52
171
|
default:
|
|
53
172
|
return 'Opus'
|
|
@@ -56,9 +175,11 @@ export function modelShortLabel(model: ModelPreference): string {
|
|
|
56
175
|
|
|
57
176
|
export function modelButtonLabel(model: ModelPreference): string {
|
|
58
177
|
switch (model) {
|
|
178
|
+
case 'fable': return 'FABLE'
|
|
59
179
|
case 'sonnet': return 'SNNT'
|
|
60
180
|
case 'haiku': return 'HAIKU'
|
|
61
|
-
case 'codex-
|
|
181
|
+
case 'codex-frontier': return 'GPT MAX'
|
|
182
|
+
case 'codex-balanced': return 'GPT BAL'
|
|
62
183
|
case 'opus':
|
|
63
184
|
default:
|
|
64
185
|
return 'OPUS'
|
|
@@ -67,9 +188,11 @@ export function modelButtonLabel(model: ModelPreference): string {
|
|
|
67
188
|
|
|
68
189
|
export function modelTag(model: ModelPreference): string {
|
|
69
190
|
switch (model) {
|
|
191
|
+
case 'fable': return 'F'
|
|
70
192
|
case 'sonnet': return 'S'
|
|
71
193
|
case 'haiku': return 'H'
|
|
72
|
-
case 'codex-
|
|
194
|
+
case 'codex-frontier': return 'GF'
|
|
195
|
+
case 'codex-balanced': return 'GB'
|
|
73
196
|
case 'opus':
|
|
74
197
|
default:
|
|
75
198
|
return 'O'
|