Skip to content

Commit bbf04a6

Browse files
author
Fil Sviatoslav
committed
Improve Xcode local provider compatibility
1 parent 8afc443 commit bbf04a6

10 files changed

Lines changed: 181 additions & 44 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ The format is based on Keep a Changelog, and Esh follows Semantic Versioning.
66

77
## [Unreleased]
88

9+
## [0.1.31] - 2026-04-25
10+
11+
### Fixed
12+
- Xcode local model provider compatibility by keeping `/v1/models` text-only and adding local-provider probes.
13+
14+
### Added
15+
- OpenAI-compatible server now exposes `/v1/tools`, `/api/tags`, root health, query-safe routing, CORS headers, and port `11435` defaults for Xcode.
16+
917
## [0.1.30] - 2026-04-25
1018

1119
### Fixed

README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,10 @@ JSON
123123
Use `esh serve` to expose a local OpenAI-compatible HTTP surface for editors, scripts, and desktop apps.
124124

125125
```bash
126-
./esh serve --host 127.0.0.1 --port 11434
127-
curl http://127.0.0.1:11434/v1/models
128-
curl http://127.0.0.1:11434/v1/audio/models
129-
curl http://127.0.0.1:11434/v1/chat/completions \
126+
./esh serve --host 127.0.0.1 --port 11435
127+
curl http://127.0.0.1:11435/v1/models
128+
curl http://127.0.0.1:11435/v1/audio/models
129+
curl http://127.0.0.1:11435/v1/chat/completions \
130130
-H 'Content-Type: application/json' \
131131
-d '{
132132
"model": "mlx-community--qwen2.5-0.5b-instruct-4bit",
@@ -139,16 +139,19 @@ curl http://127.0.0.1:11434/v1/chat/completions \
139139
Supported routes in v1:
140140
- `GET /health`
141141
- `GET /v1/models`
142+
- `GET /v1/tools`
142143
- `GET /v1/audio/models`
144+
- `GET /api/tags`
143145
- `POST /v1/chat/completions`
144146
- `POST /v1/responses`
145147

146148
Notes:
147149
- unsupported request fields are ignored when safe
148150
- `stream` is not supported yet
149151
- text inputs are supported for chat/responses in v1
150-
- `/v1/models` includes installed text models and MLX TTS models; audio entries include `modality: "audio"` and `tts` capability metadata
152+
- `/v1/models` includes installed text models only for strict OpenAI-compatible clients such as Xcode
151153
- `/v1/audio/models` returns the reusable MLX TTS model catalog with voices, languages, output formats, and capabilities so external agents can present and reuse voice choices
154+
- `/v1/tools` advertises request-side tool support and `/api/tags` provides an Ollama-compatible model list for local-provider probes
152155
- set `ESH_API_KEY` or pass `--api-key <token>` to require `Authorization: Bearer <token>`
153156

154157
In the interactive TUI (`./esh`), select **OpenAI server** to toggle the same local API while the TUI process stays open. In chat, use `/serve toggle`, `/serve start`, `/serve stop`, or `/serve status`; the header shows whether the local API is on.

Sources/EshCore/Services/OpenAICompatibleHTTPHandler.swift

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,26 @@ public struct OpenAICompatibleHTTPHandler: Sendable {
3939
do {
4040
try validateAuthorization(headers: request.headers)
4141

42-
switch (request.method.uppercased(), request.path) {
43-
case ("GET", "/health"):
44-
return try jsonResponse(statusCode: 200, payload: ["status": "ok"])
42+
let path = normalizedPath(request.path)
43+
switch (request.method.uppercased(), path) {
44+
case ("OPTIONS", _):
45+
return emptyResponse(statusCode: 204)
46+
case ("GET", "/"), ("GET", "/health"), ("GET", "/v1"):
47+
return try jsonResponse(
48+
statusCode: 200,
49+
payload: [
50+
"status": "ok",
51+
"routes": "/v1/models,/v1/chat/completions,/v1/responses,/v1/tools,/v1/audio/models,/api/tags"
52+
]
53+
)
4554
case ("GET", "/v1/models"):
4655
return try jsonResponse(statusCode: 200, payload: service.models())
4756
case ("GET", "/v1/audio/models"):
4857
return try jsonResponse(statusCode: 200, payload: service.audioModels())
58+
case ("GET", "/v1/tools"):
59+
return try jsonResponse(statusCode: 200, payload: service.tools())
60+
case ("GET", "/api/tags"):
61+
return try jsonResponse(statusCode: 200, payload: service.ollamaTags())
4962
case ("POST", "/v1/chat/completions"):
5063
let decoded = try JSONCoding.decoder.decode(OpenAIChatCompletionsRequest.self, from: request.body)
5164
let response = try await service.chatCompletions(decoded)
@@ -68,6 +81,13 @@ public struct OpenAICompatibleHTTPHandler: Sendable {
6881
}
6982
}
7083

84+
private func normalizedPath(_ path: String) -> String {
85+
guard let queryStart = path.firstIndex(of: "?") else {
86+
return path
87+
}
88+
return String(path[..<queryStart])
89+
}
90+
7191
private func validateAuthorization(headers: [String: String]) throws {
7292
guard let bearerToken, bearerToken.isEmpty == false else { return }
7393
let authorization = headers.first { $0.key.lowercased() == "authorization" }?.value
@@ -79,15 +99,35 @@ public struct OpenAICompatibleHTTPHandler: Sendable {
7999
private func jsonResponse<T: Encodable>(statusCode: Int, payload: T) throws -> OpenAICompatibleHTTPResponse {
80100
let body = try JSONCoding.encoder.encode(payload)
81101
return OpenAICompatibleHTTPResponse(
102+
statusCode: statusCode,
103+
headers: jsonHeaders(contentLength: body.count),
104+
body: body
105+
)
106+
}
107+
108+
private func emptyResponse(statusCode: Int) -> OpenAICompatibleHTTPResponse {
109+
OpenAICompatibleHTTPResponse(
82110
statusCode: statusCode,
83111
headers: [
84-
"content-type": "application/json; charset=utf-8",
85-
"content-length": String(body.count)
112+
"access-control-allow-origin": "*",
113+
"access-control-allow-methods": "GET,POST,OPTIONS",
114+
"access-control-allow-headers": "authorization,content-type",
115+
"content-length": "0"
86116
],
87-
body: body
117+
body: Data()
88118
)
89119
}
90120

121+
private func jsonHeaders(contentLength: Int) -> [String: String] {
122+
[
123+
"access-control-allow-origin": "*",
124+
"access-control-allow-methods": "GET,POST,OPTIONS",
125+
"access-control-allow-headers": "authorization,content-type",
126+
"content-type": "application/json; charset=utf-8",
127+
"content-length": String(contentLength)
128+
]
129+
}
130+
91131
private func errorResponse(for error: OpenAICompatibleError) -> OpenAICompatibleHTTPResponse {
92132
let statusCode: Int
93133
let type: String
@@ -112,10 +152,7 @@ public struct OpenAICompatibleHTTPHandler: Sendable {
112152
let body = (try? JSONCoding.encoder.encode(payload)) ?? Data(#"{"error":{"message":"Unknown error","type":"server_error"}}"#.utf8)
113153
return OpenAICompatibleHTTPResponse(
114154
statusCode: statusCode,
115-
headers: [
116-
"content-type": "application/json; charset=utf-8",
117-
"content-length": String(body.count)
118-
],
155+
headers: jsonHeaders(contentLength: body.count),
119156
body: body
120157
)
121158
}

Sources/EshCore/Services/OpenAICompatibleLocalServer.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,12 +142,22 @@ public final class OpenAICompatibleLocalServer: @unchecked Sendable {
142142
let body = Data(data[bodyStart..<(bodyStart + contentLength)])
143143
return OpenAICompatibleHTTPRequest(
144144
method: String(requestLineParts[0]),
145-
path: String(requestLineParts[1]),
145+
path: normalizedRequestPath(String(requestLineParts[1])),
146146
headers: headers,
147147
body: body
148148
)
149149
}
150150

151+
private func normalizedRequestPath(_ path: String) -> String {
152+
guard let url = URL(string: path), url.path.isEmpty == false else {
153+
return path
154+
}
155+
if let query = url.query, query.isEmpty == false {
156+
return "\(url.path)?\(query)"
157+
}
158+
return url.path
159+
}
160+
151161
private func serialize(response: OpenAICompatibleHTTPResponse) -> Data {
152162
let reasonPhrase = switch response.statusCode {
153163
case 200: "OK"

Sources/EshCore/Services/OpenAICompatibleService.swift

Lines changed: 86 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -227,20 +227,75 @@ public struct OpenAIModelsResponse: Codable, Hashable, Sendable {
227227
public var object: String
228228
public var created: Int
229229
public var ownedBy: String
230-
public var modality: String?
231-
public var capabilities: [String]?
232230

233231
enum CodingKeys: String, CodingKey {
234232
case id
235233
case object
236234
case created
237235
case ownedBy = "owned_by"
238-
case modality
239-
case capabilities
240236
}
241237
}
242238
}
243239

240+
public struct OllamaTagsResponse: Codable, Hashable, Sendable {
241+
public var models: [Model]
242+
243+
public struct Model: Codable, Hashable, Sendable {
244+
public var name: String
245+
public var model: String
246+
public var modifiedAt: String
247+
public var size: Int
248+
public var digest: String
249+
public var details: Details
250+
251+
enum CodingKeys: String, CodingKey {
252+
case name
253+
case model
254+
case modifiedAt = "modified_at"
255+
case size
256+
case digest
257+
case details
258+
}
259+
}
260+
261+
public struct Details: Codable, Hashable, Sendable {
262+
public var format: String
263+
public var family: String
264+
public var parameterSize: String
265+
public var quantizationLevel: String
266+
267+
enum CodingKeys: String, CodingKey {
268+
case format
269+
case family
270+
case parameterSize = "parameter_size"
271+
case quantizationLevel = "quantization_level"
272+
}
273+
}
274+
}
275+
276+
public struct OpenAIToolsResponse: Codable, Hashable, Sendable {
277+
public var object: String
278+
public var data: [Tool]
279+
public var supportsRequestTools: Bool
280+
281+
enum CodingKeys: String, CodingKey {
282+
case object
283+
case data
284+
case supportsRequestTools = "supports_request_tools"
285+
}
286+
287+
public struct Tool: Codable, Hashable, Sendable {
288+
public var type: String
289+
public var function: Function
290+
}
291+
292+
public struct Function: Codable, Hashable, Sendable {
293+
public var name: String
294+
public var description: String
295+
public var parameters: [String: String]
296+
}
297+
}
298+
244299
public struct OpenAIAudioModelsResponse: Codable, Hashable, Sendable {
245300
public var object: String
246301
public var data: [OpenAIAudioModel]
@@ -456,24 +511,10 @@ public struct OpenAICompatibleService: Sendable {
456511
id: $0.id,
457512
object: "model",
458513
created: 0,
459-
ownedBy: "esh",
460-
modality: "text",
461-
capabilities: ["chat", "responses"]
514+
ownedBy: "esh"
462515
)
463516
}
464-
let audioModels = try audioModelsClosure()
465-
.sorted { $0.id.localizedCaseInsensitiveCompare($1.id) == .orderedAscending }
466-
.map {
467-
OpenAIModelsResponse.Model(
468-
id: $0.id,
469-
object: $0.object,
470-
created: $0.created,
471-
ownedBy: $0.ownedBy,
472-
modality: "audio",
473-
capabilities: $0.capabilities
474-
)
475-
}
476-
return OpenAIModelsResponse(object: "list", data: textModels + audioModels)
517+
return OpenAIModelsResponse(object: "list", data: textModels)
477518
}
478519

479520
public func audioModels() throws -> OpenAIAudioModelsResponse {
@@ -482,6 +523,31 @@ public struct OpenAICompatibleService: Sendable {
482523
return OpenAIAudioModelsResponse(object: "list", data: models)
483524
}
484525

526+
public func ollamaTags() throws -> OllamaTagsResponse {
527+
let models = try installedModelsClosure()
528+
.sorted { $0.id.localizedCaseInsensitiveCompare($1.id) == .orderedAscending }
529+
.map { model in
530+
OllamaTagsResponse.Model(
531+
name: model.id,
532+
model: model.id,
533+
modifiedAt: "1970-01-01T00:00:00Z",
534+
size: 0,
535+
digest: model.id,
536+
details: .init(
537+
format: model.backend.rawValue,
538+
family: "esh",
539+
parameterSize: "unknown",
540+
quantizationLevel: model.variant ?? "unknown"
541+
)
542+
)
543+
}
544+
return OllamaTagsResponse(models: models)
545+
}
546+
547+
public func tools() -> OpenAIToolsResponse {
548+
OpenAIToolsResponse(object: "list", data: [], supportsRequestTools: true)
549+
}
550+
485551
private func externalMessage(from message: OpenAIInputMessage) throws -> ExternalInferenceMessage {
486552
guard let role = Message.Role(rawValue: message.role) else {
487553
throw OpenAICompatibleError.invalidRequest("Unsupported message role: \(message.role)")

Sources/esh/App/OpenAICompatibleServerController.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ final class OpenAICompatibleServerController: @unchecked Sendable {
77
private let lock = NSLock()
88
private var server: OpenAICompatibleLocalServer?
99
private var activeHost = "127.0.0.1"
10-
private var activePort: UInt16 = 11434
10+
private var activePort: UInt16 = ServeCommand.defaultPort
1111

1212
var isRunning: Bool {
1313
lock.lock()
@@ -26,7 +26,7 @@ final class OpenAICompatibleServerController: @unchecked Sendable {
2626
root: PersistenceRoot,
2727
toolVersion: String?,
2828
host: String = "127.0.0.1",
29-
port: UInt16 = 11434,
29+
port: UInt16 = ServeCommand.defaultPort,
3030
apiKey: String? = nil
3131
) throws {
3232
lock.lock()

Sources/esh/App/TUIApplication.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -963,7 +963,7 @@ struct TUIApplication {
963963
lines: [
964964
"status: off",
965965
"toggle: /serve toggle",
966-
"cli: esh serve --host 127.0.0.1 --port 11434"
966+
"cli: esh serve --host 127.0.0.1 --port \(ServeCommand.defaultPort)"
967967
]
968968
)
969969
}

Sources/esh/Commands/ServeCommand.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Darwin
33
import EshCore
44

55
enum ServeCommand {
6+
static let defaultPort: UInt16 = 11435
67
private static let usage = "Usage: esh serve [--host 127.0.0.1|localhost|::1|0.0.0.0|::] [--port <1-65535>] [--api-key <token>]"
78

89
static func run(arguments: [String], root: PersistenceRoot, toolVersion: String?) async throws {
@@ -30,7 +31,7 @@ enum ServeCommand {
3031
let redactedAuth = apiKey == nil ? "disabled" : "enabled"
3132
print("esh OpenAI-compatible server listening on http://\(host):\(port)")
3233
print("auth: \(redactedAuth)")
33-
print("routes: GET /health, GET /v1/models, GET /v1/audio/models, POST /v1/chat/completions, POST /v1/responses")
34+
print("routes: GET /health, GET /v1/models, GET /v1/tools, GET /v1/audio/models, GET /api/tags, POST /v1/chat/completions, POST /v1/responses")
3435
print("press Ctrl+C to stop")
3536

3637
let signalHandler = SignalHandler()
@@ -40,7 +41,7 @@ enum ServeCommand {
4041

4142
private static func resolvePort(arguments: [String]) throws -> UInt16 {
4243
guard let rawPort = CommandSupport.optionalValue(flag: "--port", in: arguments) else {
43-
return 11434
44+
return defaultPort
4445
}
4546
guard let parsed = UInt16(rawPort), parsed > 0 else {
4647
throw StoreError.invalidManifest("Invalid port `\(rawPort)`. " + usage)

Tests/EshCoreTests/OpenAICompatibleServiceTests.swift

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,15 +187,27 @@ struct OpenAICompatibleServiceTests {
187187
let models = try await handler.handle(.init(method: "GET", path: "/v1/models", headers: [:], body: Data()))
188188
let modelsPayload = try JSONCoding.decoder.decode(OpenAIModelsResponse.self, from: models.body)
189189
#expect(models.statusCode == 200)
190-
#expect(modelsPayload.data.map(\.id) == ["a-model", "b-model", "voice-model"])
191-
#expect(modelsPayload.data.last?.modality == "audio")
190+
#expect(modelsPayload.data.map(\.id) == ["a-model", "b-model"])
191+
192+
let queryModels = try await handler.handle(.init(method: "GET", path: "/v1/models?source=xcode", headers: [:], body: Data()))
193+
#expect(queryModels.statusCode == 200)
192194

193195
let audioModels = try await handler.handle(.init(method: "GET", path: "/v1/audio/models", headers: [:], body: Data()))
194196
let audioPayload = try JSONCoding.decoder.decode(OpenAIAudioModelsResponse.self, from: audioModels.body)
195197
#expect(audioModels.statusCode == 200)
196198
#expect(audioPayload.data.first?.voices.first?.id == "alba")
197199
#expect(audioPayload.data.first?.languages.first?.id == "en")
198200

201+
let tools = try await handler.handle(.init(method: "GET", path: "/v1/tools", headers: [:], body: Data()))
202+
let toolsPayload = try JSONCoding.decoder.decode(OpenAIToolsResponse.self, from: tools.body)
203+
#expect(tools.statusCode == 200)
204+
#expect(toolsPayload.supportsRequestTools)
205+
206+
let tags = try await handler.handle(.init(method: "GET", path: "/api/tags", headers: [:], body: Data()))
207+
let tagsPayload = try JSONCoding.decoder.decode(OllamaTagsResponse.self, from: tags.body)
208+
#expect(tags.statusCode == 200)
209+
#expect(tagsPayload.models.map(\.name) == ["a-model", "b-model"])
210+
199211
let chatRequestBody = Data(
200212
"""
201213
{

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.30
1+
0.1.31

0 commit comments

Comments
 (0)