@gotcos/glasses-server 6.3.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,27 @@
1
+ // Internal model-input shape for image attachments (Release A).
2
+ // The bridges receive resolved server-owned FILE PATHS — never base64 —
3
+ // plus the public ref and an explicit deletion contract:
4
+ // deleteAfterRun: false → durable media-store asset; the store's lifecycle
5
+ // (GC/retention) owns the file. Bridges must not
6
+ // delete it, even on failed/cancelled runs.
7
+ // deleteAfterRun: true → truly ephemeral request-temp file; the bridge
8
+ // deletes it exactly once when the run settles.
9
+
10
+ import { unlinkSync } from 'node:fs'
11
+ import type { MediaAttachmentRef } from '../../shared/media-attachment.js'
12
+
13
+ export interface ModelImageInput {
14
+ /** Absolute path to a server-owned normalized image file. */
15
+ path: string
16
+ attachment: MediaAttachmentRef
17
+ deleteAfterRun: boolean
18
+ }
19
+
20
+ /** Terminal cleanup used by both bridges: delete ONLY inputs explicitly
21
+ * marked ephemeral. Idempotent — safe if a bridge settles twice. */
22
+ export function cleanupModelImageInputs(inputs: ModelImageInput[]): void {
23
+ for (const input of inputs) {
24
+ if (!input.deleteAfterRun) continue
25
+ try { unlinkSync(input.path) } catch { /* already gone — fine */ }
26
+ }
27
+ }
@@ -8,21 +8,51 @@ import {
8
8
  type PromptReference,
9
9
  } from './conversation.js'
10
10
  import { DEFAULT_MODEL, isCodexModel, isClaudeModel, normalizeModelPreference } from '../../shared/model-preference.js'
11
+ import type { ModelImageInput } from './model-image-input.js'
11
12
 
12
- // Chat routes to the user's local Claude Code CLI (opus/sonnet/haiku) or the
13
- // Codex CLI (codex-high). Any unknown preference falls back to the Claude default
13
+ // Bridges return as soon as their subprocess is spawned, while completion is
14
+ // delivered later through callbacks. This keyed tail queue therefore releases
15
+ // only on a terminal callback (or an early throw), preventing two turns from
16
+ // mutating the same conversation/CLI session concurrently.
17
+ const sessionRunTails = new Map<string, Promise<void>>()
18
+
19
+ async function acquireSessionRunLock(sessionId: string): Promise<() => void> {
20
+ const previous = sessionRunTails.get(sessionId) ?? Promise.resolve()
21
+ let openGate!: () => void
22
+ const gate = new Promise<void>(resolve => { openGate = resolve })
23
+ const tail = previous.catch(() => {}).then(() => gate)
24
+ sessionRunTails.set(sessionId, tail)
25
+ await previous.catch(() => {})
26
+
27
+ let released = false
28
+ return () => {
29
+ if (released) return
30
+ released = true
31
+ openGate()
32
+ if (sessionRunTails.get(sessionId) === tail) sessionRunTails.delete(sessionId)
33
+ }
34
+ }
35
+
36
+ // Chat routes to the user's local Claude Code CLI or stable Codex live-catalog
37
+ // slots. Any unknown preference falls back to the Claude default
14
38
  // so chat always works on a stock install.
15
39
  export async function callModelStreaming(
16
40
  query: string,
17
41
  sessionId: string | undefined,
18
42
  callbacks: StreamCallbacks,
19
43
  model?: ModelPreference,
20
- images?: string[],
44
+ images?: ModelImageInput[],
21
45
  reference?: PromptReference,
22
46
  globalMsgNum?: number,
23
47
  options?: CallOptions,
24
48
  ): Promise<string> {
25
49
  const sid = getOrCreateSession(sessionId)
50
+ const release = await acquireSessionRunLock(sid)
51
+ if (options?.abortSignal?.aborted) {
52
+ release()
53
+ throw new Error('model-router: request aborted before the model run started.')
54
+ }
55
+
26
56
  const sessionModel = getSessionModel(sid)
27
57
  // COS_G2_DEFAULT_MODEL is the documented default-model switch (CHANGELOG 6.1.0);
28
58
  // it must win over the hardcoded DEFAULT_MODEL on this primary query path, not
@@ -32,11 +62,40 @@ export async function callModelStreaming(
32
62
 
33
63
  setSessionModel(sid, resolvedModel)
34
64
 
35
- if (isCodexModel(resolvedModel)) {
36
- return callCodexStreaming(query, sid, callbacks, resolvedModel, images, reference, globalMsgNum, options)
65
+ let terminal = false
66
+ const releaseTerminal = () => {
67
+ if (terminal) return
68
+ terminal = true
69
+ release()
37
70
  }
38
- if (isClaudeModel(resolvedModel)) {
39
- return callClaudeStreaming(query, sid, callbacks, resolvedModel, images, reference, globalMsgNum, options)
71
+ const lockedCallbacks: StreamCallbacks = {
72
+ ...callbacks,
73
+ onDone: (fullText, completedModel, cliSessionId, metadata) => {
74
+ try {
75
+ callbacks.onDone(fullText, completedModel, cliSessionId, metadata)
76
+ } finally {
77
+ releaseTerminal()
78
+ }
79
+ },
80
+ onError: (error) => {
81
+ try {
82
+ callbacks.onError(error)
83
+ } finally {
84
+ releaseTerminal()
85
+ }
86
+ },
87
+ }
88
+
89
+ try {
90
+ if (isCodexModel(resolvedModel)) {
91
+ return await callCodexStreaming(query, sid, lockedCallbacks, resolvedModel, images, reference, globalMsgNum, options)
92
+ }
93
+ if (isClaudeModel(resolvedModel)) {
94
+ return await callClaudeStreaming(query, sid, lockedCallbacks, resolvedModel, images, reference, globalMsgNum, options)
95
+ }
96
+ return await callClaudeStreaming(query, sid, lockedCallbacks, DEFAULT_MODEL, images, reference, globalMsgNum, options)
97
+ } catch (err) {
98
+ releaseTerminal()
99
+ throw err
40
100
  }
41
- return callClaudeStreaming(query, sid, callbacks, DEFAULT_MODEL, images, reference, globalMsgNum, options)
42
101
  }
@@ -0,0 +1,132 @@
1
+ // Query-side attachment resolution (Release A). Converts an /api/query
2
+ // request body's image inputs into ModelImageInput[]:
3
+ //
4
+ // attachmentIds[] — durable media-store assets uploaded via /api/media.
5
+ // Validated against lifecycle + the reservation's
6
+ // clientQueueItemId (an id alone is never bearer auth).
7
+ // images[]/image — legacy base64. NOT a bypass: bytes are ingested
8
+ // through the same media store (validation,
9
+ // normalization, lifecycle tracking, limits) and then
10
+ // resolved exactly like uploaded attachments.
11
+ //
12
+ // Every resolved input is durable (deleteAfterRun: false) — the store's
13
+ // retention owns the files; model runs never delete them.
14
+
15
+ import {
16
+ MAX_ATTACHMENTS_PER_PROMPT,
17
+ isValidMediaId,
18
+ parseMediaIdList,
19
+ type MediaAttachmentRef,
20
+ } from '../../shared/media-attachment.js'
21
+ import { getMediaStore, MediaStoreError } from './media-store.js'
22
+ import { strictBase64Decode, ImageSafetyError } from './image-safety.js'
23
+ import type { ModelImageInput } from './model-image-input.js'
24
+
25
+ export interface ResolvedQueryAttachments {
26
+ inputs: ModelImageInput[]
27
+ refs: MediaAttachmentRef[]
28
+ /** Ids to associate with the final message when the run completes. */
29
+ ids: string[]
30
+ }
31
+
32
+ export class QueryAttachmentError extends Error {
33
+ readonly status: number
34
+ readonly code: string
35
+ constructor(status: number, code: string, message: string) {
36
+ super(message)
37
+ this.status = status
38
+ this.code = code
39
+ this.name = 'QueryAttachmentError'
40
+ }
41
+ }
42
+
43
+ const MEDIA_ERROR_STATUS: Record<string, number> = {
44
+ media_not_found: 404,
45
+ media_expired: 410,
46
+ media_unavailable: 503,
47
+ media_conflict: 409,
48
+ }
49
+
50
+ const SAFETY_ERROR_STATUS: Record<string, number> = {
51
+ invalid_base64: 400,
52
+ unsupported_format: 400,
53
+ image_too_large: 400,
54
+ dimensions_too_large: 400,
55
+ corrupt_image: 400,
56
+ media_processing_unavailable: 503,
57
+ normalization_failed: 500,
58
+ }
59
+
60
+ function asQueryAttachmentError(err: unknown): QueryAttachmentError {
61
+ if (err instanceof MediaStoreError) {
62
+ return new QueryAttachmentError(MEDIA_ERROR_STATUS[err.code] ?? 500, err.code, err.message)
63
+ }
64
+ if (err instanceof ImageSafetyError) {
65
+ return new QueryAttachmentError(SAFETY_ERROR_STATUS[err.code] ?? 500, err.code, err.message)
66
+ }
67
+ return new QueryAttachmentError(500, 'attachment_resolution_failed', err instanceof Error ? err.message : String(err))
68
+ }
69
+
70
+ export interface QueryImageBody {
71
+ attachmentIds?: unknown
72
+ clientQueueItemId?: unknown
73
+ images?: unknown
74
+ image?: unknown
75
+ sessionId?: unknown
76
+ }
77
+
78
+ /** Resolve all image inputs for one query. Throws QueryAttachmentError with
79
+ * an HTTP status + typed code — callers respond BEFORE opening the SSE
80
+ * stream. Returns empty inputs when the request carries no images. */
81
+ export async function resolveQueryAttachments(body: QueryImageBody): Promise<ResolvedQueryAttachments> {
82
+ try {
83
+ const store = getMediaStore()
84
+ const clientQueueItemId = typeof body.clientQueueItemId === 'string' && body.clientQueueItemId.trim()
85
+ ? body.clientQueueItemId.trim().slice(0, 120)
86
+ : undefined
87
+ const sessionId = typeof body.sessionId === 'string' && body.sessionId.trim()
88
+ ? body.sessionId.trim().slice(0, 64)
89
+ : undefined
90
+
91
+ const inputs: ModelImageInput[] = []
92
+
93
+ // Reject over-limit requests BEFORE the shared parser caps them. Silent
94
+ // truncation would run a successful vision query while dropping a user's
95
+ // sixth image, which is materially worse than a typed 400.
96
+ const validUniqueIds = Array.isArray(body.attachmentIds)
97
+ ? [...new Set(body.attachmentIds.filter(isValidMediaId))]
98
+ : []
99
+ const legacyRaw: unknown[] = Array.isArray(body.images)
100
+ ? body.images
101
+ : typeof body.image === 'string' && body.image.length > 0 ? [body.image] : []
102
+ const validLegacyCount = legacyRaw.filter((raw) => typeof raw === 'string' && raw.length > 0).length
103
+ if (validUniqueIds.length + validLegacyCount > MAX_ATTACHMENTS_PER_PROMPT) {
104
+ throw new QueryAttachmentError(400, 'too_many_images', `max ${MAX_ATTACHMENTS_PER_PROMPT} images per prompt`)
105
+ }
106
+
107
+ // 1. Durable attachment ids.
108
+ const ids = parseMediaIdList(body.attachmentIds)
109
+ for (const id of ids) {
110
+ const { record, path } = store.resolveUsable(id, clientQueueItemId)
111
+ inputs.push({ path, attachment: record.ref, deleteAfterRun: false })
112
+ }
113
+
114
+ // 2. Legacy base64 — ingested through the SAME store (no bypass).
115
+ for (const raw of legacyRaw) {
116
+ if (typeof raw !== 'string' || raw.length === 0) continue
117
+ const bytes = strictBase64Decode(raw)
118
+ const ref = await store.ingestImage({ bytes, kind: 'user_photo', sessionId })
119
+ const { path } = store.resolveUsable(ref.id)
120
+ inputs.push({ path, attachment: ref, deleteAfterRun: false })
121
+ }
122
+
123
+ return {
124
+ inputs,
125
+ refs: inputs.map((i) => i.attachment),
126
+ ids: inputs.map((i) => i.attachment.id),
127
+ }
128
+ } catch (err) {
129
+ if (err instanceof QueryAttachmentError) throw err
130
+ throw asQueryAttachmentError(err)
131
+ }
132
+ }