laiya 0.0.2 → 0.0.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 665e132b315d14e0d867411d06e49c015ed8bfe18f50e3ee15623eee73a90e6e
4
- data.tar.gz: 693d030853123c19d1dda07fda06fac7d6c6b8a8d4b9db7a488cbda3e8ef2add
3
+ metadata.gz: f1d79e801894376e7f3a398c48d22f0c5b0100760751dc8fecb63c044f665de4
4
+ data.tar.gz: 9a72dbf43fc8eba78543c3d8de3acdbd25072b0db940688f21ffa0fff5dc9b93
5
5
  SHA512:
6
- metadata.gz: 6bb1a3cc19e46eaf6908343327edeb7c4af1867b07016962b4a6a70c22d7f31e44aa3bdf644f6a26ad2ed4d152dfe041f3d416a912aea00005076f988f60c198
7
- data.tar.gz: '015801fda685fe3fc0e3fe1d792784fdd4aaa6255e04d6e1d0ea29dc72b5c4360d513834e5681d4ca3b4092067231548e43bf87773b0066930ab4677bdaeac34'
6
+ metadata.gz: cc9253bf7a64e2ca4cf8b6fbdf316db4a625af93c9232943c5faa25f6d726002ece63158f38d1acc967b5b5db5fd849a7cc9801d5f207c9f0cca529da5ef968d
7
+ data.tar.gz: be8830a0dee2d6f0fc1219035ccce4de588704706aae3c2f4b3c459d3556e234f629dec67283c84be52cdbf3e429055d6019d4e965fa1448f01e50504ea16ab3
checksums.yaml.gz.sig CHANGED
Binary file
@@ -73,6 +73,42 @@ token counts. OpenCode does not currently infer these custom fields from
73
73
  `limit.output` values on each client as well. The `context` limit should match
74
74
  the effective upstream model configuration (for Ollama, including `num_ctx`).
75
75
 
76
+ ## Expose Ollama Thinking Controls
77
+
78
+ When Ollama model discovery is enabled, Laiya also queries `/api/show` for each
79
+ model and publishes available thinking controls under `laiya.reasoning`. Named
80
+ levels appear as `supported_efforts` with a `default_effort`; boolean-only
81
+ controls appear as `thinking_values` with a `default_thinking`. If a model has
82
+ no thinking metadata, advertises only `[false]`, or `/api/show` is unavailable,
83
+ Laiya still lists it without reasoning metadata.
84
+
85
+ The OpenAI-compatible proxy already forwards `reasoning_effort` requests to
86
+ Ollama. OpenCode custom providers do not automatically turn Laiya's extension
87
+ metadata into variants, so configure variants from the discovered values in
88
+ each OpenCode model entry:
89
+
90
+ ```jsonc
91
+ {
92
+ "providers": {
93
+ "laiya": {
94
+ "models": {
95
+ "qwen3-coder": {
96
+ "modelID": "qwen3-coder:latest",
97
+ "variants": [
98
+ {"id": "low", "body": {"reasoning_effort": "low"}},
99
+ {"id": "medium", "body": {"reasoning_effort": "medium"}},
100
+ {"id": "high", "body": {"reasoning_effort": "high"}},
101
+ ],
102
+ },
103
+ },
104
+ },
105
+ },
106
+ }
107
+ ```
108
+
109
+ Only configure variants advertised by the selected model. Thinking metadata and
110
+ supported levels are model-specific.
111
+
76
112
  ## Customize Discovery
77
113
 
78
114
  Subclass `Laiya::Models::Discover` to filter models or attach provider-specific
@@ -43,11 +43,13 @@ behind TLS and an access-controlled gateway. The service requires the caller to
43
43
  send the `LAIYA_API_KEY` as a Bearer token; this is separate from the Codex
44
44
  credentials used upstream.
45
45
 
46
- `LAIYA_CHATGPT_MODEL` selects the model exposed by `GET /v1/models` and sent to
47
- the Codex backend. It defaults to `gpt-6-luna`; set it to a model available to
48
- your account. Set `CODEX_HOME` if the credential file is in a non-default
49
- directory. `LAIYA_URL` can change the bind endpoint; do not bind publicly
50
- without TLS and network access controls.
46
+ The Codex provider discovers account-visible, API-supported models from the
47
+ authenticated Codex model catalog and refreshes the discovered list periodically.
48
+ The provider detects the installed Codex CLI version for the catalog request;
49
+ set `CODEX_CLIENT_VERSION` to override it if the CLI is unavailable on the
50
+ service's `PATH` or you need to use a different version. Set `CODEX_HOME` if the
51
+ credential file is in a non-default directory. `LAIYA_URL` can change the bind
52
+ endpoint; do not bind publicly without TLS and network access controls.
51
53
 
52
54
  ## Try it
53
55
 
@@ -79,29 +81,43 @@ available because the Codex backend is used statelessly.
79
81
  ## Connect OpenCode from another computer
80
82
 
81
83
  Put the service behind TLS and an access-controlled network gateway, then add a
82
- custom Responses-capable OpenAI provider to OpenCode. Keep the Laiya API key in
83
- the client machine's secret configuration rather than committing it:
84
+ custom Responses-compatible provider to OpenCode. Codex tool turns require
85
+ `/v1/responses`; the Chat Completions adapter intentionally rejects them. Keep
86
+ the Laiya API key in the client machine's secret configuration rather than
87
+ committing it:
84
88
 
85
- ```json
89
+ ```jsonc
86
90
  {
87
- "$schema": "https://opencode.ai/config.json",
88
- "provider": {
89
- "laiya": {
90
- "npm": "@ai-sdk/openai",
91
- "name": "Laiya Codex",
92
- "options": {
93
- "baseURL": "https://laiya.example.com/v1",
94
- "apiKey": "<LAIYA_API_KEY>"
95
- },
96
- "models": {
97
- "gpt-6-luna": {"name": "GPT-6 Luna"}
98
- }
99
- }
100
- },
101
- "model": "laiya/gpt-6-luna"
91
+ "$schema": "https://opencode.ai/config.json",
92
+ "providers": {
93
+ "laiya-codex": {
94
+ "name": "Laiya Codex",
95
+ "env": ["LAIYA_API_KEY"],
96
+ "package": "@opencode/ai/providers/openai-compatible/responses",
97
+ "settings": {
98
+ "baseURL": "https://laiya.example.com/v1",
99
+ },
100
+ "models": {
101
+ "gpt-6-luna": {
102
+ "name": "GPT-6 Luna",
103
+ "variants": [
104
+ {"id": "low", "settings": {"reasoningEffort": "low"}},
105
+ {"id": "medium", "settings": {"reasoningEffort": "medium"}},
106
+ {"id": "high", "settings": {"reasoningEffort": "high"}},
107
+ {"id": "xhigh", "settings": {"reasoningEffort": "xhigh"}},
108
+ {"id": "max", "settings": {"reasoningEffort": "max"}},
109
+ ],
110
+ },
111
+ },
112
+ },
113
+ },
114
+ "model": "laiya-codex/gpt-6-luna",
102
115
  }
103
116
  ```
104
117
 
105
- OpenCode uses `/v1/responses` for this provider. The model can return tool calls,
106
- which OpenCode executes on the client computer and reports back on subsequent
107
- requests; Laiya never executes client tools.
118
+ Codex entries returned by `GET /v1/models` include the supported reasoning
119
+ efforts in `laiya.reasoning.supported_efforts` and the default in
120
+ `laiya.reasoning.default_effort`. Use those values to configure OpenCode's
121
+ model variants; its custom-provider model list remains explicit. The model can
122
+ return tool calls, which OpenCode executes on the client computer and reports
123
+ back on subsequent requests; Laiya never executes client tools.
@@ -17,8 +17,8 @@ configuration = Async::Service::Configuration.build do
17
17
 
18
18
  configuration do
19
19
  Laiya::Configuration.build do |builder|
20
- builder.provider :codex, Laiya::Provider::Codex.new
21
- builder.model ENV.fetch("LAIYA_CHATGPT_MODEL", "gpt-6-luna"), provider: :codex
20
+ codex = Laiya::Provider::Codex.new
21
+ builder.provider :codex, codex, models: :discover
22
22
  builder.default_provider :codex
23
23
  end
24
24
  end
@@ -73,6 +73,42 @@ token counts. OpenCode does not currently infer these custom fields from
73
73
  `limit.output` values on each client as well. The `context` limit should match
74
74
  the effective upstream model configuration (for Ollama, including `num_ctx`).
75
75
 
76
+ ## Expose Ollama Thinking Controls
77
+
78
+ When Ollama model discovery is enabled, Laiya also queries `/api/show` for each
79
+ model and publishes available thinking controls under `laiya.reasoning`. Named
80
+ levels appear as `supported_efforts` with a `default_effort`; boolean-only
81
+ controls appear as `thinking_values` with a `default_thinking`. If a model has
82
+ no thinking metadata, advertises only `[false]`, or `/api/show` is unavailable,
83
+ Laiya still lists it without reasoning metadata.
84
+
85
+ The OpenAI-compatible proxy already forwards `reasoning_effort` requests to
86
+ Ollama. OpenCode custom providers do not automatically turn Laiya's extension
87
+ metadata into variants, so configure variants from the discovered values in
88
+ each OpenCode model entry:
89
+
90
+ ```jsonc
91
+ {
92
+ "providers": {
93
+ "laiya": {
94
+ "models": {
95
+ "qwen3-coder": {
96
+ "modelID": "qwen3-coder:latest",
97
+ "variants": [
98
+ {"id": "low", "body": {"reasoning_effort": "low"}},
99
+ {"id": "medium", "body": {"reasoning_effort": "medium"}},
100
+ {"id": "high", "body": {"reasoning_effort": "high"}},
101
+ ],
102
+ },
103
+ },
104
+ },
105
+ },
106
+ }
107
+ ```
108
+
109
+ Only configure variants advertised by the selected model. Thinking metadata and
110
+ supported levels are model-specific.
111
+
76
112
  ## Customize Discovery
77
113
 
78
114
  Subclass `Laiya::Models::Discover` to filter models or attach provider-specific
@@ -5,8 +5,10 @@
5
5
 
6
6
  require "async/http"
7
7
  require "json"
8
+ require "open3"
8
9
  require "protocol/http/body/streamable"
9
10
  require "securerandom"
11
+ require "uri"
10
12
 
11
13
  require_relative "interface"
12
14
  require_relative "codex/authentication"
@@ -26,20 +28,66 @@ module Laiya
26
28
  DEFAULT_ENDPOINT = "https://chatgpt.com/backend-api/codex"
27
29
  USER_AGENT = "laiya-codex/0.0.0"
28
30
 
31
+ # Return the configured or installed Codex CLI version.
32
+ # @returns [String | Nil] The Codex CLI version, or `nil` when it cannot be detected.
33
+ def self.client_version
34
+ if version = ENV["CODEX_CLIENT_VERSION"]
35
+ return version unless version.empty?
36
+ end
37
+
38
+ output, status = Open3.capture2("codex", "--version")
39
+ return unless status.success?
40
+
41
+ return output[/\bcodex(?:-cli)?\s+(\S+)/, 1]
42
+ rescue Errno::ENOENT
43
+ nil
44
+ end
45
+
29
46
  # Initialize the experimental ChatGPT Codex API adapter.
30
47
  # @option :authentication [Interface(:credentials) | Nil] A credential source.
31
48
  # @option :codex_home [String] The Codex home directory containing `auth.json`.
49
+ # @option :client_version [String | Nil] The Codex CLI version used for model discovery; defaults to `CODEX_CLIENT_VERSION` or `codex --version`.
32
50
  # @option :endpoint [String | Async::HTTP::Endpoint] The Codex backend endpoint.
33
51
  # @option :client [Interface(:call) | Nil] An optional HTTP client.
34
- def initialize(authentication: nil, codex_home: ENV.fetch("CODEX_HOME", Authentication::DEFAULT_CODEX_HOME), endpoint: DEFAULT_ENDPOINT, client: nil, **client_options)
52
+ def initialize(authentication: nil, codex_home: ENV.fetch("CODEX_HOME", Authentication::DEFAULT_CODEX_HOME), client_version: ENV["CODEX_CLIENT_VERSION"], endpoint: DEFAULT_ENDPOINT, client: nil, **client_options)
35
53
  @endpoint = Async::HTTP::Endpoint[endpoint]
36
54
  @authentication = authentication || Authentication.new(codex_home: codex_home)
55
+ @client_version = client_version
56
+ @client_version_detected = client_version && !client_version.empty?
37
57
  @client = client || Async::HTTP::Client.new(@endpoint, **client_options)
38
58
  @owns_client = client.nil?
39
59
  end
40
60
 
41
61
  attr :endpoint
42
62
 
63
+ # Fetch and normalize the authenticated Codex model catalog.
64
+ # @returns [Protocol::HTTP::Response] The OpenAI-compatible model list.
65
+ def models
66
+ client_version = self.client_version
67
+ unless client_version
68
+ return error_response(500, "Set CODEX_CLIENT_VERSION or install the Codex CLI to discover models", "server_error")
69
+ end
70
+
71
+ credentials = @authentication.credentials
72
+ upstream = request_models(credentials, client_version)
73
+
74
+ if upstream.status == 401
75
+ upstream.close
76
+ credentials = @authentication.credentials(refresh: true)
77
+ upstream = request_models(credentials, client_version)
78
+ end
79
+
80
+ unless upstream.status >= 200 && upstream.status < 300
81
+ return upstream
82
+ end
83
+
84
+ model_catalog_response(upstream)
85
+ rescue Authentication::Error
86
+ error_response(502, "Codex authentication failed", "server_error")
87
+ rescue StandardError
88
+ error_response(502, "Codex model discovery failed", "server_error")
89
+ end
90
+
43
91
  # Convert supported Chat Completions requests or proxy Responses requests.
44
92
  # @parameter request [Protocol::HTTP::Request] The incoming OpenAI-compatible request.
45
93
  # @returns [Protocol::HTTP::Response] The translated or proxied response.
@@ -107,6 +155,85 @@ module Laiya
107
155
 
108
156
  private
109
157
 
158
+ def client_version
159
+ return @client_version if @client_version_detected
160
+
161
+ @client_version_detected = true
162
+ @client_version = self.class.client_version
163
+ end
164
+
165
+ def request_models(credentials, client_version)
166
+ path = "#{@endpoint.path.split("?", 2).first.sub(/\/+\z/, "")}/models?#{URI.encode_www_form(client_version: client_version)}"
167
+
168
+ headers = Protocol::HTTP::Headers[
169
+ "accept" => "application/json",
170
+ "authorization" => "Bearer #{credentials.fetch(:access_token)}",
171
+ "originator" => "laiya",
172
+ "user-agent" => USER_AGENT,
173
+ ]
174
+
175
+ if account_id = credentials[:account_id]
176
+ headers["chatgpt-account-id"] = account_id
177
+ end
178
+
179
+ if residency = credentials[:residency]
180
+ headers["x-openai-internal-codex-residency"] = residency
181
+ end
182
+
183
+ return @client.call(Protocol::HTTP::Request["GET", path, headers])
184
+ end
185
+
186
+ def model_catalog_response(upstream)
187
+ payload = JSON.parse(upstream.read)
188
+ models = payload.fetch("models")
189
+ unless models.is_a?(Array)
190
+ return error_response(502, "Codex model catalog was invalid", "server_error")
191
+ end
192
+
193
+ data = models.filter_map{|model| normalize_model(model)}
194
+ return Protocol::HTTP::Response[
195
+ 200,
196
+ {"content-type" => "application/json"},
197
+ [JSON.dump(object: "list", data: data)],
198
+ ]
199
+ rescue JSON::ParserError, KeyError, TypeError
200
+ error_response(502, "Codex model catalog was invalid", "server_error")
201
+ ensure
202
+ upstream.close
203
+ end
204
+
205
+ def normalize_model(model)
206
+ return unless model.is_a?(Hash)
207
+ return unless model["supported_in_api"] == true && model["visibility"] == "list"
208
+
209
+ id = model["slug"]
210
+ return unless id.is_a?(String) && !id.empty?
211
+
212
+ metadata = {}
213
+ metadata["name"] = model["display_name"] if model["display_name"].is_a?(String)
214
+ if (context = model["context_window"]).is_a?(Integer) && context.positive?
215
+ metadata["limits"] = {"context" => context}
216
+ end
217
+ efforts = Array(model["supported_reasoning_levels"]).filter_map do |level|
218
+ level["effort"] if level.is_a?(Hash) && level["effort"].is_a?(String) && !level["effort"].empty?
219
+ end.uniq
220
+ if efforts.any?
221
+ reasoning = {"supported_efforts" => efforts}
222
+ if efforts.include?(default_effort = model["default_reasoning_level"])
223
+ reasoning["default_effort"] = default_effort
224
+ end
225
+ metadata["reasoning"] = reasoning
226
+ end
227
+
228
+ {
229
+ "id" => id,
230
+ "object" => "model",
231
+ "created" => 0,
232
+ "owned_by" => "codex",
233
+ "laiya" => metadata,
234
+ }
235
+ end
236
+
110
237
  def request_codex(payload, request, credentials)
111
238
  headers = Protocol::HTTP::Headers[
112
239
  "accept" => "text/event-stream",
@@ -3,6 +3,8 @@
3
3
  # Released under the MIT License.
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
+ require "json"
7
+
6
8
  require_relative "openai"
7
9
 
8
10
  module Laiya
@@ -17,6 +19,90 @@ module Laiya
17
19
  def initialize(endpoint: DEFAULT_ENDPOINT, api_key: "ollama", **options)
18
20
  super(endpoint: endpoint, api_key: api_key, **options)
19
21
  end
22
+
23
+ # Fetch Ollama's model list and add supported thinking metadata.
24
+ # @returns [Protocol::HTTP::Response] The enriched OpenAI-compatible model list.
25
+ def models
26
+ response = super
27
+ return response unless response.status >= 200 && response.status < 300
28
+
29
+ enrich_models(response)
30
+ end
31
+
32
+ private
33
+
34
+ def enrich_models(response)
35
+ payload = JSON.parse(response.read)
36
+ models = payload.fetch("data")
37
+ unless models.is_a?(Array)
38
+ return model_catalog_error
39
+ end
40
+
41
+ payload["data"] = models.map{|model| enrich_model(model)}
42
+ return Protocol::HTTP::Response[
43
+ 200,
44
+ {"content-type" => "application/json"},
45
+ [JSON.dump(payload)],
46
+ ]
47
+ rescue JSON::ParserError, KeyError, TypeError
48
+ model_catalog_error
49
+ ensure
50
+ response.close
51
+ end
52
+
53
+ def enrich_model(model)
54
+ return model unless model.is_a?(Hash)
55
+ return model unless model["id"].is_a?(String) && !model["id"].empty?
56
+
57
+ response = self.call(Protocol::HTTP::Request[
58
+ "POST",
59
+ "/api/show",
60
+ {"content-type" => "application/json"},
61
+ [JSON.dump(model: model["id"])],
62
+ ])
63
+ return model unless response.status >= 200 && response.status < 300
64
+
65
+ thinking = JSON.parse(response.read)["thinking"]
66
+ reasoning = normalize_reasoning(thinking)
67
+ return model unless reasoning
68
+
69
+ metadata = model["laiya"].is_a?(Hash) ? model["laiya"].dup : {}
70
+ metadata["reasoning"] = reasoning
71
+ return model.merge("laiya" => metadata)
72
+ rescue StandardError
73
+ return model
74
+ ensure
75
+ response&.close
76
+ end
77
+
78
+ def normalize_reasoning(thinking)
79
+ return unless thinking.is_a?(Hash)
80
+
81
+ values = Array(thinking["values"]).select do |value|
82
+ (value.is_a?(String) && !value.empty?) || value == true || value == false
83
+ end.uniq
84
+ return if values.empty? || values == [false]
85
+
86
+ result = {"thinking_values" => values}
87
+ default = thinking["default"]
88
+ result["default_thinking"] = default if values.include?(default)
89
+
90
+ efforts = values.grep(String)
91
+ unless efforts.empty?
92
+ result["supported_efforts"] = efforts
93
+ result["default_effort"] = default if efforts.include?(default)
94
+ end
95
+
96
+ return result
97
+ end
98
+
99
+ def model_catalog_error
100
+ Protocol::HTTP::Response[
101
+ 502,
102
+ {"content-type" => "application/json"},
103
+ [JSON.dump(error: {message: "Ollama model catalog was invalid", type: "server_error"})],
104
+ ]
105
+ end
20
106
  end
21
107
  end
22
108
  end
data/lib/laiya/version.rb CHANGED
@@ -4,5 +4,5 @@
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
6
  module Laiya
7
- VERSION = "0.0.2"
7
+ VERSION = "0.0.3"
8
8
  end
data/readme.md CHANGED
@@ -30,6 +30,12 @@ Please see the [project documentation](https://socketry.github.io/laiya/) for mo
30
30
 
31
31
  Please see the [project releases](https://socketry.github.io/laiya/releases/index) for all releases.
32
32
 
33
+ ### v0.0.3
34
+
35
+ - Enrich discovered Ollama models with thinking controls from `/api/show`.
36
+ - Discover account-visible Codex models and reasoning capabilities from the authenticated model catalog.
37
+ - Document a Responses-compatible OpenCode provider for Codex tool calling.
38
+
33
39
  ### v0.0.2
34
40
 
35
41
  - Add Laiya's OpenAI-compatible HTTP API and Async::Service launcher.
data/releases.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Releases
2
2
 
3
+ ## v0.0.3
4
+
5
+ - Enrich discovered Ollama models with thinking controls from `/api/show`.
6
+ - Discover account-visible Codex models and reasoning capabilities from the authenticated model catalog.
7
+ - Document a Responses-compatible OpenCode provider for Codex tool calling.
8
+
3
9
  ## v0.0.2
4
10
 
5
11
  - Add Laiya's OpenAI-compatible HTTP API and Async::Service launcher.
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: laiya
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.2
4
+ version: 0.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Samuel Williams
metadata.gz.sig CHANGED
Binary file