Skip to content

Commit 96159c0

Browse files
author
Sisyphus-AI
committed
fix(provider): add 'active' to model status schema validation
Add "active" to the Model.status schema literals in: - packages/opencode/src/config/provider.ts Omniroute provider returns models with status: "active", but the schema only allowed ["alpha", "beta", "deprecated"]. This caused schema validation to fail with 400 Bad Request on /config endpoint. Fixes TUI crash: "opencode server GET http://opencode.internal/config → 400: (empty response body)" Closes anomalyco#26589
1 parent 66587dd commit 96159c0

3 files changed

Lines changed: 178 additions & 19 deletions

File tree

packages/opencode/src/config/provider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const Model = Schema.Struct({
4949
}),
5050
),
5151
experimental: Schema.optional(Schema.Boolean),
52-
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated"])),
52+
status: Schema.optional(Schema.Literals(["alpha", "beta", "deprecated", "active"])),
5353
provider: Schema.optional(
5454
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
5555
),

packages/opencode/src/mcp/index.ts

Lines changed: 143 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { BusEvent } from "../bus/bus-event"
2525
import { Bus } from "@/bus"
2626
import { TuiEvent } from "@/cli/cmd/tui/event"
2727
import open from "open"
28-
import { Effect, Exit, Layer, Option, Context, Schema, Stream } from "effect"
28+
import { Effect, Exit, Layer, Option, Context, Schema, Stream, Schedule, Duration } from "effect"
2929
import { EffectBridge } from "@/effect/bridge"
3030
import { InstanceState } from "@/effect/instance-state"
3131
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
@@ -119,11 +119,49 @@ function remoteURL(key: string, value: string) {
119119
log.warn("invalid remote mcp url", { key })
120120
}
121121

122-
// Convert MCP tool definition to AI SDK Tool type
123-
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
122+
// --- Transport error detection (inline to avoid SDK import issues) ---
123+
const TRANSPORT_ERROR_CODES = new Set([
124+
"ECONNRESET", "ECONNREFUSED", "ETIMEDOUT", "EHOSTUNREACH", "ENOTFOUND",
125+
"EPIPE", "ECONNABORTED", "UND_ERR_SOCKET", "UND_ERR_CLOSED",
126+
"ConnectionRefused", "ConnectionReset", "ConnectionAborted",
127+
"ConnectionClosed", "Timeout", "SocketClosed", "NotConnected",
128+
"FailedToOpenSocket",
129+
])
130+
131+
function isStreamableHTTPError(e: unknown): e is Error & { code: number | undefined } {
132+
return e instanceof Error && "code" in e && (typeof (e as any).code === "number" || typeof (e as any).code === "undefined")
133+
}
134+
135+
function isTransportError(e: unknown): boolean {
136+
if (isStreamableHTTPError(e)) {
137+
if (e.code === -1) return true
138+
if (typeof e.code !== "number") return false
139+
if (e.code === 401 || e.code === 403) return false
140+
return e.code >= 400
141+
}
142+
if (!(e instanceof Error)) return false
143+
const err = e as Error & { code?: string; cause?: { code?: string } }
144+
if (err.cause?.code && TRANSPORT_ERROR_CODES.has(err.cause.code)) return true
145+
if (err.code && TRANSPORT_ERROR_CODES.has(err.code)) return true
146+
if (err.message.includes("fetch failed")) return true
147+
if (err.message.includes("Unable to connect")) return true
148+
return false
149+
}
150+
151+
// --- State reference - declared early so it's available to async callbacks ---
152+
let mcpStateRef: InstanceState.InstanceState<State> | undefined
153+
154+
// Convert MCP tool definition to AI SDK Tool type with auto-reconnect on transport errors
155+
function makeTool(
156+
clientName: string,
157+
mcpTool: MCPToolDef,
158+
client: MCPClient,
159+
bridge: EffectBridge.Shape,
160+
reconnectClient: (name: string) => Promise<boolean>,
161+
timeout?: number,
162+
): Tool {
124163
const inputSchema = mcpTool.inputSchema
125164

126-
// Spread first, then override type to ensure it's always "object"
127165
const schema: JSONSchema7 = {
128166
...(inputSchema as JSONSchema7),
129167
type: "object",
@@ -134,18 +172,28 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number
134172
return dynamicTool({
135173
description: mcpTool.description ?? "",
136174
inputSchema: jsonSchema(schema),
137-
execute: async (args: unknown) => {
138-
return client.callTool(
139-
{
140-
name: mcpTool.name,
141-
arguments: (args || {}) as Record<string, unknown>,
142-
},
143-
CallToolResultSchema,
144-
{
145-
resetTimeoutOnProgress: true,
146-
timeout,
147-
},
148-
)
175+
execute: (args: unknown) => {
176+
const payload = {
177+
name: mcpTool.name,
178+
arguments: (args || {}) as Record<string, unknown>,
179+
}
180+
const opts = { resetTimeoutOnProgress: true, timeout }
181+
return client.callTool(payload, CallToolResultSchema, opts).catch(async (e) => {
182+
if (!isTransportError(e)) throw e
183+
log.warn("mcp transport error, attempting reconnect", {
184+
clientName,
185+
tool: mcpTool.name,
186+
error: e instanceof Error ? e.message : String(e),
187+
})
188+
const ok = await reconnectClient(clientName)
189+
if (!ok) throw e
190+
const state = mcpStateRef
191+
if (!state) throw e
192+
const next = await bridge.promise(InstanceState.get(state))
193+
const fresh = next.clients[clientName]
194+
if (!fresh || next.status[clientName]?.status !== "connected") throw e
195+
return fresh.callTool(payload, CallToolResultSchema, opts)
196+
})
149197
},
150198
})
151199
}
@@ -444,6 +492,61 @@ export const layer = Layer.effect(
444492
return { mcpClient, status, defs: listed } satisfies CreateResult
445493
})
446494
const cfgSvc = yield* Config.Service
495+
const layerBridge = yield* EffectBridge.make()
496+
const reconnecting = new Map<string, Promise<boolean>>()
497+
498+
// Single-flight reconnect: concurrent tool calls for the same MCP name
499+
// share one in-flight Promise instead of each triggering a new connect.
500+
// The entry is removed on both success and failure.
501+
const reconnectClient = (name: string): Promise<boolean> => {
502+
const existing = reconnecting.get(name)
503+
if (existing) return existing
504+
const p = layerBridge
505+
.promise(getMcpConfig(name))
506+
.then((mcp) => {
507+
if (!mcp) return false
508+
return layerBridge
509+
.promise(createAndStore(name, { ...mcp, enabled: true }))
510+
.then((status) => status.status === "connected")
511+
})
512+
.catch((err) => {
513+
log.error("mcp reconnect failed", { name, error: err instanceof Error ? err.message : String(err) })
514+
return false
515+
})
516+
.finally(() => {
517+
reconnecting.delete(name)
518+
})
519+
reconnecting.set(name, p)
520+
return p
521+
}
522+
523+
// Periodic health-check: attempt to reconnect failed servers every 30 seconds.
524+
// Uses a scoped fiber so it is cleaned up when the instance is disposed.
525+
const startHealthCheck = Effect.fn("MCP.healthCheck")(function* () {
526+
const s = yield* InstanceState.get(mcpStateRef!)
527+
const cfg = yield* cfgSvc.get()
528+
const config = cfg.mcp ?? {}
529+
const failedServers = Object.entries(s.status).filter(
530+
([name, st]) => st.status === "failed" && config[name] && isMcpConfigured(config[name]),
531+
)
532+
if (failedServers.length > 0) {
533+
log.info("mcp health-check: attempting reconnect for failed servers", {
534+
servers: failedServers.map(([name]) => name),
535+
})
536+
}
537+
for (const [name] of failedServers) {
538+
// Skip if already reconnecting
539+
if (reconnecting.has(name)) continue
540+
const mcp = config[name]
541+
if (!mcp) continue
542+
const ok = yield* Effect.promise(() => reconnectClient(name))
543+
if (ok) {
544+
log.info("mcp health-check: reconnected", { server: name })
545+
} else {
546+
log.debug("mcp health-check: reconnect still failed", { server: name })
547+
}
548+
}
549+
})
447550

448551
const descendants = Effect.fnUntraced(
449552
function* (pid: number) {
@@ -521,7 +624,14 @@ export const layer = Layer.effect(
521624
return
522625
}
523626

524-
const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.void))
627+
const result = yield* create(key, mcp).pipe(
628+
Effect.catch((err: unknown) => {
629+
const msg = err instanceof Error ? err.message : String(err)
630+
log.error("mcp server initialization failed, marking as failed", { key, error: msg })
631+
s.status[key] = { status: "failed", error: msg }
632+
return Effect.void
633+
}),
634+
)
525635
if (!result) return
526636

527637
s.status[key] = result.status
@@ -557,9 +667,17 @@ export const layer = Layer.effect(
557667
}),
558668
)
559669

670+
// Start periodic health-check for failed MCP servers (auto-reconnect every 30s)
671+
yield* startHealthCheck().pipe(
672+
Effect.catchCause(() => Effect.void),
673+
Effect.repeat(Schedule.spaced(Duration.seconds(30))),
674+
Effect.forkScoped,
675+
)
676+
560677
return s
561678
}),
562679
)
680+
mcpStateRef = state
563681

564682
function closeClient(s: State, name: string) {
565683
const client = s.clients[name]
@@ -667,7 +785,14 @@ export const layer = Layer.effect(
667785

668786
const timeout = entry?.timeout ?? defaultTimeout
669787
for (const mcpTool of listed) {
670-
result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool(mcpTool, client, timeout)
788+
result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = makeTool(
789+
clientName,
790+
mcpTool,
791+
client,
792+
layerBridge,
793+
reconnectClient,
794+
timeout,
795+
)
671796
}
672797
}),
673798
{ concurrency: "unbounded" },

packages/opencode/test/provider/models.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,4 +256,38 @@ describe("ModelsDev Service", () => {
256256
expect(final.calls.length).toBeGreaterThanOrEqual(1)
257257
}),
258258
)
259+
260+
it.live("accepts model status 'active' in provider models", () =>
261+
Effect.gen(function* () {
262+
const fixtureWithActiveStatus: Record<string, ModelsDev.Provider> = {
263+
acme: {
264+
id: "acme",
265+
name: "Acme",
266+
env: ["ACME_API_KEY"],
267+
models: {
268+
"acme-1": {
269+
id: "acme-1",
270+
name: "Acme One",
271+
release_date: "2026-01-01",
272+
attachment: false,
273+
reasoning: false,
274+
temperature: true,
275+
tool_call: true,
276+
limit: { context: 128000, output: 8192 },
277+
status: "active" as const,
278+
},
279+
},
280+
},
281+
}
282+
yield* writeCache(fixtureWithActiveStatus)
283+
const state = yield* Ref.make({ body: JSON.stringify(fixtureWithActiveStatus), status: 200, calls: [] })
284+
const result = yield* provided(
285+
state,
286+
ModelsDev.Service.use((s) => s.get()),
287+
)
288+
expect(result["acme"]).toBeDefined()
289+
expect(result["acme"].models["acme-1"]).toBeDefined()
290+
expect(result["acme"].models["acme-1"].status).toBe("active")
291+
}),
292+
)
259293
})

0 commit comments

Comments
 (0)