@gotcos/glasses-server 6.2.1 → 6.5.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.
@@ -0,0 +1,285 @@
1
+ // Media attachment API (Release A) — authenticated upload, lifecycle, and
2
+ // content endpoints backed by server/lib/media-store.ts.
3
+ //
4
+ // POST /api/media — upload images (base64 JSON batch)
5
+ // POST /api/media/reserve — bind staged media to a queue item
6
+ // POST /api/media/associate — bind media to a run/message (replay-safe)
7
+ // POST /api/media/release — drop staged/reserved media (cancel path)
8
+ // GET /api/media/:id — metadata (public ref + availability)
9
+ // GET /api/media/:id/content — bytes (?variant=phone|thumb)
10
+ // DELETE /api/media/:id — delete UNASSOCIATED media only
11
+ //
12
+ // Uploads accept uploaded bytes only — no remote URLs, no filesystem paths,
13
+ // no data-URI passthrough. The dedicated body parser (mediaBodyParser) is
14
+ // mounted BEFORE the global express.json() so a maximum valid batch
15
+ // (8 MiB decoded ≈ 10.7 MiB base64) doesn't trip the global 10 MB limit;
16
+ // the allowance stays scoped to /api/media.
17
+
18
+ import { Router, json, type Request, type Response } from 'express'
19
+ import { readFileSync } from 'node:fs'
20
+ import {
21
+ MAX_ATTACHMENTS_PER_PROMPT,
22
+ isValidMediaId,
23
+ parseMediaIdList,
24
+ type MediaAttachmentRef,
25
+ } from '../../shared/media-attachment.js'
26
+ import {
27
+ G2_LENS_VARIANT_CAPABILITY,
28
+ getMediaStore,
29
+ MediaStoreError,
30
+ } from '../lib/media-store.js'
31
+ import {
32
+ ImageSafetyError,
33
+ MAX_BATCH_BYTES,
34
+ isMediaProcessingReady,
35
+ strictBase64Decode,
36
+ } from '../lib/image-safety.js'
37
+
38
+ // Route-scoped parser: 16 MB covers the max valid batch after base64 + JSON
39
+ // overhead. Mounted only for /api/media in server/index.ts — the global
40
+ // server limit is unchanged.
41
+ export const mediaBodyParser = json({ limit: '16mb' })
42
+
43
+ export const mediaRouter = Router()
44
+
45
+ const MEDIA_ERROR_STATUS: Record<string, number> = {
46
+ media_not_found: 404,
47
+ media_expired: 410,
48
+ media_unavailable: 503,
49
+ media_conflict: 409,
50
+ }
51
+
52
+ const SAFETY_ERROR_STATUS: Record<string, number> = {
53
+ invalid_base64: 400,
54
+ unsupported_format: 400,
55
+ image_too_large: 400,
56
+ dimensions_too_large: 400,
57
+ corrupt_image: 400,
58
+ media_processing_unavailable: 503,
59
+ normalization_failed: 500,
60
+ }
61
+
62
+ function sendMediaError(res: Response, err: unknown): void {
63
+ if (err instanceof MediaStoreError) {
64
+ res.status(MEDIA_ERROR_STATUS[err.code] ?? 500).json({ error: err.code })
65
+ return
66
+ }
67
+ if (err instanceof ImageSafetyError) {
68
+ const status = SAFETY_ERROR_STATUS[err.code] ?? 500
69
+ res.status(status).json({
70
+ error: err.code,
71
+ ...(err.code === 'media_processing_unavailable' ? { mediaProcessingReady: false } : {}),
72
+ })
73
+ return
74
+ }
75
+ console.error('[media] unexpected error:', err)
76
+ res.status(500).json({ error: 'media_internal_error' })
77
+ }
78
+
79
+ function safeString(v: unknown, max: number): string | undefined {
80
+ return typeof v === 'string' && v.trim().length > 0 ? v.trim().slice(0, max) : undefined
81
+ }
82
+
83
+ // ── Upload ───────────────────────────────────────────────────────────────────
84
+
85
+ mediaRouter.post('/media', async (req: Request, res: Response) => {
86
+ try {
87
+ const body = req.body ?? {}
88
+ const rawImages = Array.isArray(body.images) ? body.images : []
89
+ if (rawImages.length === 0) {
90
+ res.status(400).json({ error: 'no_images' })
91
+ return
92
+ }
93
+ if (rawImages.length > MAX_ATTACHMENTS_PER_PROMPT) {
94
+ res.status(400).json({ error: 'too_many_images', max: MAX_ATTACHMENTS_PER_PROMPT })
95
+ return
96
+ }
97
+ // Release A: only phone photos enter through this route. Traffic frames
98
+ // and generated visuals arrive via the Release C run-scoped publisher.
99
+ const kind = body.kind === undefined || body.kind === 'user_photo' ? 'user_photo' : null
100
+ if (!kind) {
101
+ res.status(400).json({ error: 'unsupported_kind' })
102
+ return
103
+ }
104
+ if (!(await isMediaProcessingReady())) {
105
+ res.status(503).json({ error: 'media_processing_unavailable', mediaProcessingReady: false })
106
+ return
107
+ }
108
+
109
+ // Decode + total-batch gate before any normalization work.
110
+ const decoded: Array<{ bytes: Buffer; label?: string; capturedAt?: string }> = []
111
+ let totalBytes = 0
112
+ for (const raw of rawImages) {
113
+ const item = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {}
114
+ const bytes = strictBase64Decode(item.data)
115
+ totalBytes += bytes.length
116
+ if (totalBytes > MAX_BATCH_BYTES) {
117
+ res.status(400).json({ error: 'batch_too_large', maxBytes: MAX_BATCH_BYTES })
118
+ return
119
+ }
120
+ decoded.push({
121
+ bytes,
122
+ label: safeString(item.label, 120),
123
+ capturedAt: safeString(item.capturedAt, 40),
124
+ })
125
+ }
126
+
127
+ const sessionId = safeString(body.sessionId, 64)
128
+ const store = getMediaStore()
129
+ const attachments: MediaAttachmentRef[] = []
130
+ for (const img of decoded) {
131
+ attachments.push(await store.ingestImage({
132
+ bytes: img.bytes,
133
+ kind,
134
+ label: img.label,
135
+ capturedAt: img.capturedAt,
136
+ sessionId,
137
+ }))
138
+ }
139
+ res.json({ attachments })
140
+ } catch (err) {
141
+ sendMediaError(res, err)
142
+ }
143
+ })
144
+
145
+ // ── Lifecycle ────────────────────────────────────────────────────────────────
146
+
147
+ mediaRouter.post('/media/reserve', async (req: Request, res: Response) => {
148
+ try {
149
+ const ids = parseMediaIdList(req.body?.ids)
150
+ const clientQueueItemId = safeString(req.body?.clientQueueItemId, 120)
151
+ if (ids.length === 0 || !clientQueueItemId) {
152
+ res.status(400).json({ error: 'ids_and_client_queue_item_id_required' })
153
+ return
154
+ }
155
+ const attachments = await getMediaStore().reserve(ids, {
156
+ clientQueueItemId,
157
+ sessionId: safeString(req.body?.sessionId, 64),
158
+ })
159
+ res.json({ ok: true, attachments })
160
+ } catch (err) {
161
+ sendMediaError(res, err)
162
+ }
163
+ })
164
+
165
+ mediaRouter.post('/media/associate', async (req: Request, res: Response) => {
166
+ try {
167
+ const ids = parseMediaIdList(req.body?.ids)
168
+ if (ids.length === 0) {
169
+ res.status(400).json({ error: 'ids_required' })
170
+ return
171
+ }
172
+ const globalMsgNum = typeof req.body?.globalMsgNum === 'number' && req.body.globalMsgNum > 0
173
+ ? Math.floor(req.body.globalMsgNum) : undefined
174
+ await getMediaStore().associate(ids, {
175
+ sessionId: safeString(req.body?.sessionId, 64),
176
+ runId: safeString(req.body?.runId, 120),
177
+ globalMsgNum,
178
+ })
179
+ res.json({ ok: true })
180
+ } catch (err) {
181
+ sendMediaError(res, err)
182
+ }
183
+ })
184
+
185
+ mediaRouter.post('/media/release', async (req: Request, res: Response) => {
186
+ try {
187
+ const ids = parseMediaIdList(req.body?.ids)
188
+ if (ids.length === 0) {
189
+ res.status(400).json({ error: 'ids_required' })
190
+ return
191
+ }
192
+ await getMediaStore().release(ids, {
193
+ sessionId: safeString(req.body?.sessionId, 64),
194
+ clientQueueItemId: safeString(req.body?.clientQueueItemId, 120),
195
+ })
196
+ res.json({ ok: true })
197
+ } catch (err) {
198
+ sendMediaError(res, err)
199
+ }
200
+ })
201
+
202
+ // ── Reads ────────────────────────────────────────────────────────────────────
203
+
204
+ mediaRouter.get('/media/:id', (req: Request, res: Response) => {
205
+ const id = req.params.id
206
+ if (!isValidMediaId(id)) {
207
+ res.status(400).json({ error: 'invalid_media_id' })
208
+ return
209
+ }
210
+ const store = getMediaStore()
211
+ const rec = store.getRecord(id)
212
+ if (!rec || rec.lifecycle === 'deleted') {
213
+ res.status(404).json({ error: 'media_not_found' })
214
+ return
215
+ }
216
+ const content = store.getContent(id, 'phone')
217
+ res.json({
218
+ attachment: rec.ref,
219
+ contentAvailable: content.status === 'ok',
220
+ ...(content.status === 'expired' ? { expired: true } : {}),
221
+ })
222
+ })
223
+
224
+ mediaRouter.get('/media/:id/content', async (req: Request, res: Response) => {
225
+ const id = req.params.id
226
+ if (!isValidMediaId(id)) {
227
+ res.status(400).json({ error: 'invalid_media_id' })
228
+ return
229
+ }
230
+ // 'g2' (Release B) — exact 288x144 grayscale PNG for the lens, generated
231
+ // lazily and cached beside the asset.
232
+ const variant = req.query.variant === 'thumb' ? 'thumb' : req.query.variant === 'g2' ? 'g2' : 'phone'
233
+ const content = variant === 'g2'
234
+ ? await getMediaStore().getG2Content(id)
235
+ : getMediaStore().getContent(id, variant)
236
+ if (content.status === 'not_found') {
237
+ res.status(404).json({ error: 'media_not_found' })
238
+ return
239
+ }
240
+ if (content.status === 'expired') {
241
+ res.status(410).json({ error: 'media_expired' })
242
+ return
243
+ }
244
+ if (content.status === 'unavailable') {
245
+ res.status(503).json({ error: 'media_unavailable' })
246
+ return
247
+ }
248
+ try {
249
+ // Read + send the buffer directly: content-length is exact and no
250
+ // filesystem path semantics leak into the response.
251
+ const bytes = readFileSync(content.path)
252
+ res.status(200)
253
+ res.setHeader('Content-Type', content.mime)
254
+ res.setHeader('Cache-Control', 'private, no-store')
255
+ res.setHeader('X-Content-Type-Options', 'nosniff')
256
+ if (variant === 'g2') {
257
+ res.setHeader('X-COS-G2-Variant', G2_LENS_VARIANT_CAPABILITY)
258
+ // Even Hub loads the app from a different origin. Without an expose
259
+ // header, browser fetch can receive this value but cannot read it.
260
+ res.setHeader('Access-Control-Expose-Headers', 'X-COS-G2-Variant')
261
+ }
262
+ res.setHeader('Content-Length', String(bytes.length))
263
+ res.end(bytes)
264
+ } catch (err) {
265
+ sendMediaError(res, err)
266
+ }
267
+ })
268
+
269
+ mediaRouter.delete('/media/:id', async (req: Request, res: Response) => {
270
+ const id = req.params.id
271
+ if (!isValidMediaId(id)) {
272
+ res.status(400).json({ error: 'invalid_media_id' })
273
+ return
274
+ }
275
+ try {
276
+ const deleted = await getMediaStore().deleteUnassociated(id)
277
+ if (!deleted) {
278
+ res.status(404).json({ error: 'media_not_found' })
279
+ return
280
+ }
281
+ res.json({ deleted: true })
282
+ } catch (err) {
283
+ sendMediaError(res, err)
284
+ }
285
+ })
@@ -0,0 +1,202 @@
1
+ // Global message reference resolution (v5.15.0) — the server half of
2
+ // "reference message N" across days. Numbers are stamped at exchange time
3
+ // (client-sent, stored via conversation.addExchange) and persist durably in
4
+ // the day archives; this router resolves a number the client no longer holds
5
+ // in its local list, and publishes the numbering ceiling so a cleared or
6
+ // fresh client continues the sequence instead of reusing numbers.
7
+ //
8
+ // GET /api/message/:num → { globalMsgNum, date, query, response } (404 when unknown)
9
+ // GET /api/message-counter → { max }
10
+ //
11
+ // Resolution order (per the prompt-queue/archive plan): live in-memory
12
+ // sessions first (covers the mirror's 15-minute lag), then day archives
13
+ // newest-first. Day files are read as plain data — their write path belongs
14
+ // to the archive workstream and is not touched here.
15
+ import { Router } from 'express'
16
+ import { readdirSync, readFileSync } from 'fs'
17
+ import { resolve } from 'path'
18
+ import { getActiveSessions } from '../lib/conversation.js'
19
+ import { dataPath } from '../lib/data-dir.js'
20
+ import { localDay } from '../lib/local-day.js'
21
+ import { mergeMediaAttachmentRefs, type MediaAttachmentRef } from '../../shared/media-attachment.js'
22
+
23
+ // v6.3.0 — read archives from the SAME persistent location the archive-mirror
24
+ // writes to (~/.cos-glasses/data/archive via dataPath), not a package-relative
25
+ // dir. The app repo uses server/data/archive; the public package uses dataPath,
26
+ // so this file must match the package's lib/archive.ts, or npx users' cross-day
27
+ // references + archive-chat detail read an empty/nonexistent directory.
28
+ const ARCHIVE_DIR = dataPath('archive')
29
+
30
+ export interface ResolvedGlobalMessage {
31
+ globalMsgNum: number
32
+ date: string
33
+ query: string
34
+ response: string
35
+ attachments?: MediaAttachmentRef[]
36
+ }
37
+
38
+ interface ExchangeLike {
39
+ role?: string
40
+ content?: string
41
+ timestamp?: number
42
+ globalMsgNum?: number
43
+ attachments?: unknown
44
+ }
45
+
46
+ /** Pair the stamped exchange with its other half: a user turn pairs forward
47
+ * to the next assistant turn; an assistant turn pairs backward. */
48
+ function pairExchange(exchanges: ExchangeLike[], i: number): { query: string; response: string; attachments: MediaAttachmentRef[] } {
49
+ const hit = exchanges[i]
50
+ const user = hit.role === 'user'
51
+ ? hit
52
+ : [...exchanges.slice(0, i)].reverse().find((e) => e?.role === 'user')
53
+ const assistant = hit.role === 'assistant'
54
+ ? hit
55
+ : exchanges.slice(i + 1).find((e) => e?.role === 'assistant')
56
+ return {
57
+ query: user?.content ?? '',
58
+ response: assistant?.content ?? '',
59
+ attachments: mergeMediaAttachmentRefs(user?.attachments, assistant?.attachments),
60
+ }
61
+ }
62
+
63
+ function scanExchanges(exchanges: ExchangeLike[], num: number, date: string): ResolvedGlobalMessage | null {
64
+ for (let i = 0; i < exchanges.length; i++) {
65
+ if (exchanges[i]?.globalMsgNum !== num) continue
66
+ const { query, response, attachments } = pairExchange(exchanges, i)
67
+ return {
68
+ globalMsgNum: num, date, query, response,
69
+ ...(attachments.length > 0 ? { attachments } : {}),
70
+ }
71
+ }
72
+ return null
73
+ }
74
+
75
+ /** Resolve a global message number from the day archives, newest-first.
76
+ * Exported with an explicit dir for tests. */
77
+ export function resolveFromArchiveDir(dir: string, num: number): ResolvedGlobalMessage | null {
78
+ let files: string[] = []
79
+ try {
80
+ files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f)).sort().reverse()
81
+ } catch {
82
+ return null
83
+ }
84
+ for (const f of files) {
85
+ try {
86
+ const day = JSON.parse(readFileSync(resolve(dir, f), 'utf8'))
87
+ const chats = Array.isArray(day?.chats) ? day.chats : []
88
+ for (const chat of chats) {
89
+ const exchanges = Array.isArray(chat?.exchanges) ? chat.exchanges : []
90
+ const hit = scanExchanges(exchanges, num, typeof day?.date === 'string' ? day.date : f.slice(0, 10))
91
+ if (hit) return hit
92
+ }
93
+ } catch {
94
+ // Unreadable/corrupt day file — skip; the archive workstream owns repair.
95
+ }
96
+ }
97
+ return null
98
+ }
99
+
100
+ /** Read a specific archived chat's paired Q&A messages WITH their durable
101
+ * global numbers (the archive-lib read path strips globalMsgNum; the browser
102
+ * needs it so "reference message N" is self-evident from the screen). Same
103
+ * user->next-assistant pairing as the lib; the pair's number is the user
104
+ * turn's stamp (falling back to the assistant's). Dir-param form for tests. */
105
+ export function readArchiveChatNumbered(
106
+ dir: string,
107
+ date: string,
108
+ chatIndex: number,
109
+ ): Array<{ query: string; text: string; timestamp: number; no?: number; attachments?: MediaAttachmentRef[] }> {
110
+ // Defense-in-depth against path traversal — `date` builds a `${date}.json`
111
+ // path. The archive route also validates, but this is exported/reused.
112
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return []
113
+ let day: { chats?: Array<{ id?: number; exchanges?: ExchangeLike[] }> }
114
+ try {
115
+ day = JSON.parse(readFileSync(resolve(dir, `${date}.json`), 'utf8'))
116
+ } catch {
117
+ return []
118
+ }
119
+ const chat = (Array.isArray(day?.chats) ? day.chats : []).find((c) => c?.id === chatIndex)
120
+ if (!chat) return []
121
+ const exchanges: ExchangeLike[] = Array.isArray(chat.exchanges) ? chat.exchanges : []
122
+ const out: Array<{ query: string; text: string; timestamp: number; no?: number; attachments?: MediaAttachmentRef[] }> = []
123
+ for (let i = 0; i < exchanges.length; i++) {
124
+ const ex = exchanges[i]
125
+ if (ex?.role !== 'user') continue
126
+ const next = exchanges[i + 1]
127
+ if (next?.role !== 'assistant') continue
128
+ const attachments = mergeMediaAttachmentRefs(ex.attachments, next.attachments)
129
+ out.push({
130
+ query: ex.content ?? '',
131
+ text: next.content ?? '',
132
+ timestamp: next.timestamp ?? ex.timestamp ?? 0,
133
+ no: ex.globalMsgNum ?? next.globalMsgNum,
134
+ ...(attachments.length > 0 ? { attachments } : {}),
135
+ })
136
+ i++
137
+ }
138
+ return out
139
+ }
140
+
141
+ /** ARCHIVE_DIR-bound form for the route. */
142
+ export function getArchiveChatMessagesNumbered(date: string, chatIndex: number) {
143
+ return readArchiveChatNumbered(ARCHIVE_DIR, date, chatIndex)
144
+ }
145
+
146
+ /** Highest stamped number across the day archives (0 when none). */
147
+ export function maxGlobalMsgNumInDir(dir: string): number {
148
+ let max = 0
149
+ let files: string[] = []
150
+ try {
151
+ files = readdirSync(dir).filter((f) => /^\d{4}-\d{2}-\d{2}\.json$/.test(f))
152
+ } catch {
153
+ return 0
154
+ }
155
+ for (const f of files) {
156
+ try {
157
+ const day = JSON.parse(readFileSync(resolve(dir, f), 'utf8'))
158
+ for (const chat of Array.isArray(day?.chats) ? day.chats : []) {
159
+ for (const ex of Array.isArray(chat?.exchanges) ? chat.exchanges : []) {
160
+ if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > max) max = ex.globalMsgNum
161
+ }
162
+ }
163
+ } catch { /* skip */ }
164
+ }
165
+ return max
166
+ }
167
+
168
+ function resolveFromLiveSessions(num: number): ResolvedGlobalMessage | null {
169
+ const today = localDay() // local calendar day, not UTC — a live ref in the user's evening must not label tomorrow
170
+ for (const session of getActiveSessions()) {
171
+ const exchanges = (session as { exchanges?: ExchangeLike[] }).exchanges ?? []
172
+ const hit = scanExchanges(exchanges, num, today)
173
+ if (hit) return hit
174
+ }
175
+ return null
176
+ }
177
+
178
+ export const messageRefRouter = Router()
179
+
180
+ messageRefRouter.get('/message/:num', (req, res) => {
181
+ const num = Number.parseInt(req.params.num, 10)
182
+ if (!Number.isFinite(num) || num < 1) {
183
+ res.status(400).json({ error: 'invalid message number' })
184
+ return
185
+ }
186
+ const hit = resolveFromLiveSessions(num) ?? resolveFromArchiveDir(ARCHIVE_DIR, num)
187
+ if (!hit) {
188
+ res.status(404).json({ error: `message ${num} not found` })
189
+ return
190
+ }
191
+ res.json(hit)
192
+ })
193
+
194
+ messageRefRouter.get('/message-counter', (_req, res) => {
195
+ let liveMax = 0
196
+ for (const session of getActiveSessions()) {
197
+ for (const ex of ((session as { exchanges?: ExchangeLike[] }).exchanges ?? [])) {
198
+ if (typeof ex?.globalMsgNum === 'number' && ex.globalMsgNum > liveMax) liveMax = ex.globalMsgNum
199
+ }
200
+ }
201
+ res.json({ max: Math.max(liveMax, maxGlobalMsgNumInDir(ARCHIVE_DIR)) })
202
+ })
@@ -7,6 +7,10 @@ import { Router } from 'express'
7
7
  import { preWarmCLI, logLatency } from '../lib/claude-bridge.js'
8
8
  import { callModelStreaming } from '../lib/model-router.js'
9
9
  import { normalizeModelPreference, DEFAULT_MODEL, type ModelPreference } from '../../shared/model-preference.js'
10
+ import {
11
+ getCodexModelCatalog,
12
+ resolveCodexPreferenceForModelId,
13
+ } from '../lib/codex-model-catalog.js'
10
14
  import { tryInstantResponse } from '../lib/response-cache.js'
11
15
  import crypto from 'node:crypto'
12
16
 
@@ -70,23 +74,31 @@ function validateAuth(req: any, res: any): boolean {
70
74
  return true
71
75
  }
72
76
 
73
- // Resolve model from OpenAI-compatible model ids. Defaults to Haiku for
74
- // "Hey Even" speed; Opus/Sonnet/Haiku/Codex High can be explicitly selected.
77
+ // Resolve model from OpenAI-compatible ids, stable app slots, or concrete ids
78
+ // currently advertised by the live Codex catalog.
75
79
  function resolveModel(model?: string, _query?: string): ModelPreference {
76
80
  const normalized = normalizeModelPreference(model)
77
81
  if (normalized) return normalized
82
+ if (model) {
83
+ const catalogPreference = resolveCodexPreferenceForModelId(model)
84
+ if (catalogPreference) return catalogPreference
85
+ }
78
86
  if (model === 'cos-opus') return 'opus'
87
+ if (model === 'cos-fable') return 'fable'
79
88
  if (model === 'cos-sonnet') return 'sonnet'
80
89
  if (model === 'cos-haiku') return 'haiku'
81
- if (model === 'cos-codex-high' || model === 'cos-codex') return 'codex-high'
90
+ if (model === 'cos-gpt-frontier' || model === 'cos-codex-high' || model === 'cos-codex') return 'codex-frontier'
91
+ if (model === 'cos-gpt-balanced') return 'codex-balanced'
82
92
  return normalizeModelPreference(process.env.COS_G2_DEFAULT_MODEL) ?? DEFAULT_MODEL
83
93
  }
84
94
 
85
95
  const MODEL_NAMES: Record<ModelPreference, string> = {
86
96
  opus: 'cos-opus',
97
+ fable: 'cos-fable',
87
98
  sonnet: 'cos-sonnet',
88
99
  haiku: 'cos-haiku',
89
- 'codex-high': 'cos-codex-high',
100
+ 'codex-frontier': 'cos-gpt-frontier',
101
+ 'codex-balanced': 'cos-gpt-balanced',
90
102
  }
91
103
 
92
104
  // Extract the user's latest message from the OpenAI messages array
@@ -247,6 +259,7 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
247
259
  let resolveInflight: (text: string) => void
248
260
  let rejectInflight: (err: any) => void
249
261
  const inflightPromise = new Promise<string>((res, rej) => { resolveInflight = res; rejectInflight = rej })
262
+ void inflightPromise.catch(() => { /* duplicate waiters observe the original rejection */ })
250
263
  inflightQueries.set(dedupKey, { promise: inflightPromise, timestamp: Date.now() })
251
264
 
252
265
  // ── Streaming response (SSE) ──
@@ -381,12 +394,23 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
381
394
  const fullText = await new Promise<string>((resolve, reject) => {
382
395
  let result = ''
383
396
  let nsFirstChunkMs = -1
397
+ let settled = false
398
+ const fail = (error: unknown) => {
399
+ if (settled) return
400
+ settled = true
401
+ const err = error instanceof Error ? error : new Error(String(error))
402
+ rejectInflight!(err)
403
+ inflightQueries.delete(dedupKey)
404
+ reject(err)
405
+ }
384
406
  callModelStreaming(query, currentSessionIdNS, {
385
407
  onChunk: (text) => {
386
408
  if (nsFirstChunkMs < 0) nsFirstChunkMs = Date.now() - requestReceivedAt
387
409
  result += text
388
410
  },
389
411
  onDone: (fullText) => {
412
+ if (settled) return
413
+ settled = true
390
414
  const text = fullText || result
391
415
  resolveInflight!(text)
392
416
  inflightQueries.delete(dedupKey)
@@ -404,13 +428,13 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
404
428
  resolve(text)
405
429
  },
406
430
  onError: (error) => {
407
- rejectInflight!(new Error(error))
408
- inflightQueries.delete(dedupKey)
409
- reject(new Error(error))
431
+ fail(new Error(error))
410
432
  },
411
433
  onToolStatus: () => {},
412
434
  onStart: () => {},
413
- }, resolvedModel, undefined, undefined, undefined, { lightweight: true }).then(sid => { g2SessionId = sid })
435
+ }, resolvedModel, undefined, undefined, undefined, { lightweight: true })
436
+ .then(sid => { g2SessionId = sid })
437
+ .catch(fail)
414
438
  })
415
439
 
416
440
  res.json({
@@ -433,14 +457,23 @@ openaiCompatRouter.post('/v1/chat/completions', async (req, res) => {
433
457
  })
434
458
 
435
459
  // GET /v1/models — required by some clients for model discovery
436
- openaiCompatRouter.get('/v1/models', (_req, res) => {
460
+ openaiCompatRouter.get('/v1/models', async (_req, res) => {
461
+ const catalog = await getCodexModelCatalog()
437
462
  res.json({
438
463
  object: 'list',
439
- data: [
464
+ data: [
440
465
  { id: 'cos-opus', object: 'model', created: 1709251200, owned_by: 'cos' },
466
+ { id: 'cos-fable', object: 'model', created: 1709251200, owned_by: 'cos' },
441
467
  { id: 'cos-sonnet', object: 'model', created: 1709251200, owned_by: 'cos' },
442
468
  { id: 'cos-haiku', object: 'model', created: 1709251200, owned_by: 'cos' },
443
- { id: 'cos-codex-high', object: 'model', created: 1709251200, owned_by: 'cos' },
469
+ { id: 'cos-gpt-frontier', object: 'model', created: 1709251200, owned_by: 'cos' },
470
+ { id: 'cos-gpt-balanced', object: 'model', created: 1709251200, owned_by: 'cos' },
471
+ ...catalog.options.filter(option => option.id).map(option => ({
472
+ id: option.id,
473
+ object: 'model',
474
+ created: 1709251200,
475
+ owned_by: 'openai',
476
+ })),
444
477
  ],
445
478
  })
446
479
  })