Skip to content

Commit f009f28

Browse files
fix(health): report live modality readiness
1 parent 3857b68 commit f009f28

5 files changed

Lines changed: 153 additions & 22 deletions

File tree

docs/P0_P2_INTEGRATION_COVERAGE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ manual claim does not count as complete integration coverage.
88
## Current status - 2026-07-17
99

1010
- Status snapshot:
11-
- P0: 74 total, 23 covered, 51 left.
11+
- P0: 74 total, 24 covered, 50 left.
1212
- P1: 71 total, 6 covered, 65 left.
1313
- P2: 10 total, 1 covered, 9 left.
14-
- Overall: 155 total, 30 covered, 125 left.
14+
- Overall: 155 total, 31 covered, 124 left.
1515
- Green gates today:
1616
- `npm test`: 202 files passed, 1 skipped; 2,190 tests passed, 1 skipped.
1717
- `npm run test:coverage`: 96.75% statements, 91.54% branches, 96.14% functions,
@@ -29,6 +29,9 @@ manual claim does not count as complete integration coverage.
2929
new temp `OFFGRID_USER_DATA` directory and verifies first-run onboarding and the preload bridge.
3030
- #9 - Fresh onboarding completes. `e2e/smoke.spec.ts` drives the real onboarding flow and lands on
3131
Models.
32+
- #13 - System Health is truthful. `e2e/smoke.spec.ts` launches a fresh profile, compares the real
33+
gateway `/health` payload with the System Health IPC components, verifies absent runtimes are
34+
reported as `not_installed`, and confirms the unavailable chat engine port is actually down.
3235
- #43 - Chat survives relaunch. `e2e/chat-memory.spec.ts` creates multiple scoped and unscoped
3336
conversations with messages and context through production IPC, fully closes Electron, reopens
3437
the same profile, and verifies every association and payload through the reloaded preload path.
@@ -117,7 +120,6 @@ manual claim does not count as complete integration coverage.
117120
- #10 - Configure for me completes.
118121
- #11 - Manual setup path works.
119122
- #12 - Onboarding resumes after relaunch.
120-
- #13 - System Health is truthful.
121123
- #14 - Chat engine stderr is surfaced.
122124
- #15 - Required permissions granted.
123125
- #16 - Denied permission is recoverable.

e2e/smoke.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,45 @@ test('system:health IPC returns the component list', async () => {
108108
const ids = health.components.map((c: { id: string }) => c.id)
109109
expect(ids).toContain('chat')
110110
expect(ids).toContain('gateway')
111+
112+
let gateway: { modalities?: Record<string, string> } = {}
113+
await expect
114+
.poll(
115+
async () => {
116+
try {
117+
const response = await fetch('http://127.0.0.1:7878/health')
118+
if (!response.ok) return false
119+
gateway = await response.json()
120+
return true
121+
} catch {
122+
return false
123+
}
124+
},
125+
{ timeout: 10000 }
126+
)
127+
.toBe(true)
128+
129+
const byId = new Map(
130+
health.components.map((component: { id: string; status: string }) => [component.id, component])
131+
)
132+
expect(health.activeModel).toBeNull()
133+
expect(byId.get('gateway')?.status).toBe('ready')
134+
expect(byId.get('chat')?.status).toBe('not_installed')
135+
const llamaReachable = await fetch('http://127.0.0.1:8439/health')
136+
.then((response) => response.ok)
137+
.catch(() => false)
138+
expect(llamaReachable).toBe(false)
139+
140+
const gatewayBackedComponents = {
141+
vision: 'vision_understanding',
142+
embeddings: 'embeddings',
143+
transcription: 'transcription',
144+
speech: 'speech'
145+
}
146+
for (const [componentId, modalityId] of Object.entries(gatewayBackedComponents)) {
147+
expect(byId.get(componentId)?.status).toBe(gateway.modalities?.[modalityId])
148+
}
149+
expect(byId.get('image')?.status).toBe(gateway.modalities?.image_generation)
111150
})
112151

113152
test('gateway /v1/models serves active local models with modality metadata', async () => {

src/main/model-server.ts

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -51,13 +51,35 @@ import { isAsync, matchPollRoute } from './model-server/async-request'
5151
import { sanitizeChatMessages } from './model-server/chat-messages'
5252
import { parseMultipart } from './model-server/multipart'
5353
import { tagLlmEntries, modelEntry, ollamaMirror } from './model-server/models-list'
54+
import { buildGatewayModalities, type GatewayModalities } from './model-server/health'
5455

5556
const UPSTREAM_HOST = '127.0.0.1'
5657
const UPSTREAM_PORT = LLAMA_SERVER_PORT // bundled llama-server (see llm.ts)
5758
const MAX_UPLOAD = 200 * 1024 * 1024 // 200MB upload cap (audio / init image)
5859

5960
let server: http.Server | null = null
6061

62+
async function liveGatewayModalities(imageAvailable: boolean): Promise<GatewayModalities> {
63+
let installed: string[] = []
64+
try {
65+
const models = await import('./models-manager')
66+
installed = await models.listInstalled()
67+
} catch {
68+
/* unavailable model registry means optional modalities are not ready */
69+
}
70+
const transcription = getActiveModal('transcription')
71+
const speech = getActiveModal('speech')
72+
const chat = llm.modelsExist()
73+
return buildGatewayModalities({
74+
chat,
75+
vision: chat && llm.hasVision(),
76+
embeddings: true,
77+
transcription: !!whisperModel() || (!!transcription && installed.includes(transcription)),
78+
speech: !!speech && installed.includes(speech),
79+
image: imageAvailable
80+
})
81+
}
82+
6183
function json(res: http.ServerResponse, status: number, body: unknown): void {
6284
res.writeHead(status, { 'Content-Type': 'application/json' })
6385
res.end(JSON.stringify(body))
@@ -893,7 +915,7 @@ async function handleImageEdit(
893915
export function startModelServer(port = GATEWAY_PORT): void {
894916
if (server) return
895917

896-
server = http.createServer((req, res) => {
918+
server = http.createServer(async (req, res) => {
897919
res.setHeader('Access-Control-Allow-Origin', '*')
898920
res.setHeader('Access-Control-Allow-Headers', '*')
899921
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
@@ -921,21 +943,14 @@ export function startModelServer(port = GATEWAY_PORT): void {
921943

922944
if (url === '/' || url === '/health') {
923945
const img = imageGenStatus()
946+
const modalities = await liveGatewayModalities(img.available)
924947
json(res, 200, {
925948
name: 'Off Grid AI — local model gateway',
926949
openai_compatible: true,
927950
base_url: `http://127.0.0.1:${port}/v1`,
928951
docs: `http://127.0.0.1:${port}/docs`,
929952
mcp: `http://127.0.0.1:${port}/mcp`,
930-
modalities: {
931-
text: 'ready',
932-
vision_understanding: 'ready',
933-
embeddings: 'ready',
934-
transcription: 'ready',
935-
speech: 'ready',
936-
image_generation: img.available ? 'ready' : 'not_installed',
937-
image_edit: img.available ? 'ready' : 'not_installed'
938-
},
953+
modalities,
939954
image_models: img.models,
940955
image_reason: img.available ? undefined : img.reason
941956
})
@@ -944,15 +959,8 @@ export function startModelServer(port = GATEWAY_PORT): void {
944959

945960
if (url === '/openapi.json') {
946961
const img = imageGenStatus()
947-
const mods = {
948-
text: 'ready',
949-
vision_understanding: 'ready',
950-
embeddings: 'ready',
951-
transcription: 'ready',
952-
speech: 'ready',
953-
image_generation: img.available ? 'ready' : 'not_installed'
954-
}
955-
json(res, 200, openApiSpec(port, mods, img.models))
962+
const modalities = await liveGatewayModalities(img.available)
963+
json(res, 200, openApiSpec(port, modalities, img.models))
956964
return
957965
}
958966

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { buildGatewayModalities } from '../health'
3+
4+
describe('buildGatewayModalities', () => {
5+
it('reports unavailable runtimes as not installed', () => {
6+
expect(
7+
buildGatewayModalities({
8+
chat: false,
9+
vision: false,
10+
embeddings: true,
11+
transcription: false,
12+
speech: false,
13+
image: false
14+
})
15+
).toEqual({
16+
text: 'not_installed',
17+
vision_understanding: 'not_installed',
18+
embeddings: 'ready',
19+
transcription: 'not_installed',
20+
speech: 'not_installed',
21+
image_generation: 'not_installed',
22+
image_edit: 'not_installed'
23+
})
24+
})
25+
26+
it('reports every runtime from its own capability fact', () => {
27+
expect(
28+
buildGatewayModalities({
29+
chat: true,
30+
vision: false,
31+
embeddings: false,
32+
transcription: true,
33+
speech: true,
34+
image: true
35+
})
36+
).toEqual({
37+
text: 'ready',
38+
vision_understanding: 'not_installed',
39+
embeddings: 'not_installed',
40+
transcription: 'ready',
41+
speech: 'ready',
42+
image_generation: 'ready',
43+
image_edit: 'ready'
44+
})
45+
})
46+
})

src/main/model-server/health.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export type GatewayModalityStatus = 'ready' | 'not_installed'
2+
3+
export interface GatewayCapabilityFacts {
4+
chat: boolean
5+
vision: boolean
6+
embeddings: boolean
7+
transcription: boolean
8+
speech: boolean
9+
image: boolean
10+
}
11+
12+
export type GatewayModalities = Record<
13+
| 'text'
14+
| 'vision_understanding'
15+
| 'embeddings'
16+
| 'transcription'
17+
| 'speech'
18+
| 'image_generation'
19+
| 'image_edit',
20+
GatewayModalityStatus
21+
>
22+
23+
/** Convert live runtime facts into the one modality contract shared by health and OpenAPI. */
24+
export function buildGatewayModalities(facts: GatewayCapabilityFacts): GatewayModalities {
25+
const status = (available: boolean): GatewayModalityStatus =>
26+
available ? 'ready' : 'not_installed'
27+
return {
28+
text: status(facts.chat),
29+
vision_understanding: status(facts.vision),
30+
embeddings: status(facts.embeddings),
31+
transcription: status(facts.transcription),
32+
speech: status(facts.speech),
33+
image_generation: status(facts.image),
34+
image_edit: status(facts.image)
35+
}
36+
}

0 commit comments

Comments
 (0)