ask-llm-providers 0.10.2 → 0.12.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/ask/llm/models/github_copilot.json +86 -0
- data/lib/ask/llm/models/openai_codex.json +86 -0
- data/lib/ask/llm/openai_compatible.rb +4 -0
- data/lib/ask/llm/version.rb +1 -1
- data/lib/ask/provider/anthropic.rb +21 -3
- data/lib/ask/provider/google.rb +58 -1
- data/lib/ask/provider/openai.rb +53 -2
- data/lib/ask/provider/openai_codex.rb +114 -0
- data/lib/ask-llm-providers.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 912f0553986f01615036939eb502aa1f42a35023dc0f04181b970bec4ca3fbbe
|
|
4
|
+
data.tar.gz: 71868d9efd700086b9ad8a3d4ae9f0191239bc4333eed2c889212793abeb1c71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7354ea67a347577694c5cdd8ea3462719a308d8b268f90eb75022618b822f348395ec66cebb2bdb777168fc9f2359fa8d000fb6e647b947f58de84b6cc8dc495
|
|
7
|
+
data.tar.gz: e3c41f929d7118ede14d207e4c6aa480ca8bab95ab8d28a32085e0ba11ffa7ce3b87a24244d54ecaaee14323614840b57af8a312ba13ac62ab843e5fa53431aa
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
## [0.11.1] — 2026-08-07
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
|
|
5
|
+
- **GitHub Copilot provider entry + models.** `github_copilot` in the OpenAI-compatible registry (`api.githubcopilot.com` with the `X-GitHub-Api-Version` header) plus catalog models (gpt-5.4, gpt-5.4-nano, gpt-4.1, gpt-4o — flat-rate, riding the user's Copilot subscription via ask-auth's GithubCopilot device OAuth).
|
|
6
|
+
|
|
7
|
+
## [0.11.0] — 2026-08-07
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **OpenAI Codex provider — ChatGPT subscription access.** `Ask::Providers::OpenaiCodex` always speaks the Responses API at `chatgpt.com/backend-api/codex/responses` with a `ChatGPT-Account-Id` header; supports real **Responses SSE streaming** (`response.output_text.delta` text, `function_call` item/argument events, `response.completed` usage). Pair with ask-auth's `OpenaiCodex` OAuth provider (access token via `api_key:`, account id via `account_id:`). Registered as `:openai_codex`.
|
|
12
|
+
- **Codex models in the catalog** (`openai_codex.json`): gpt-5.5, gpt-5.4, gpt-5.4-mini, gpt-5.3-codex-spark — flat-rate (pricing 0) since they ride the user's subscription.
|
|
13
|
+
|
|
1
14
|
## [0.10.2] - 2026-08-06
|
|
2
15
|
|
|
3
16
|
### Fixed
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "gpt-5.4",
|
|
4
|
+
"name": "GPT-5.4",
|
|
5
|
+
"provider": "github_copilot",
|
|
6
|
+
"family": "gpt",
|
|
7
|
+
"context_window": 400000,
|
|
8
|
+
"max_output_tokens": 128000,
|
|
9
|
+
"capabilities": ["chat", "streaming", "tool_calls"],
|
|
10
|
+
"modalities": { "input": ["text"], "output": ["text"] },
|
|
11
|
+
"pricing": {
|
|
12
|
+
"text_tokens": {
|
|
13
|
+
"standard": {
|
|
14
|
+
"input_per_million": 0.0,
|
|
15
|
+
"output_per_million": 0.0,
|
|
16
|
+
"cache_read_input_per_million": 0.0
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"knowledge_cutoff": "2025-06-01",
|
|
21
|
+
"created_at": "2026-01-01"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "gpt-5.4-nano",
|
|
25
|
+
"name": "GPT-5.4 nano",
|
|
26
|
+
"provider": "github_copilot",
|
|
27
|
+
"family": "gpt",
|
|
28
|
+
"context_window": 400000,
|
|
29
|
+
"max_output_tokens": 64000,
|
|
30
|
+
"capabilities": ["chat", "streaming", "tool_calls"],
|
|
31
|
+
"modalities": { "input": ["text"], "output": ["text"] },
|
|
32
|
+
"pricing": {
|
|
33
|
+
"text_tokens": {
|
|
34
|
+
"standard": {
|
|
35
|
+
"input_per_million": 0.0,
|
|
36
|
+
"output_per_million": 0.0,
|
|
37
|
+
"cache_read_input_per_million": 0.0
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"knowledge_cutoff": "2025-06-01",
|
|
42
|
+
"created_at": "2026-01-01"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "gpt-4.1",
|
|
46
|
+
"name": "GPT-4.1",
|
|
47
|
+
"provider": "github_copilot",
|
|
48
|
+
"family": "gpt",
|
|
49
|
+
"context_window": 1000000,
|
|
50
|
+
"max_output_tokens": 32000,
|
|
51
|
+
"capabilities": ["chat", "streaming", "tool_calls"],
|
|
52
|
+
"modalities": { "input": ["text"], "output": ["text"] },
|
|
53
|
+
"pricing": {
|
|
54
|
+
"text_tokens": {
|
|
55
|
+
"standard": {
|
|
56
|
+
"input_per_million": 0.0,
|
|
57
|
+
"output_per_million": 0.0,
|
|
58
|
+
"cache_read_input_per_million": 0.0
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"knowledge_cutoff": "2025-06-01",
|
|
63
|
+
"created_at": "2025-05-01"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "gpt-4o",
|
|
67
|
+
"name": "GPT-4o",
|
|
68
|
+
"provider": "github_copilot",
|
|
69
|
+
"family": "gpt",
|
|
70
|
+
"context_window": 128000,
|
|
71
|
+
"max_output_tokens": 16000,
|
|
72
|
+
"capabilities": ["chat", "streaming", "tool_calls"],
|
|
73
|
+
"modalities": { "input": ["text"], "output": ["text"] },
|
|
74
|
+
"pricing": {
|
|
75
|
+
"text_tokens": {
|
|
76
|
+
"standard": {
|
|
77
|
+
"input_per_million": 0.0,
|
|
78
|
+
"output_per_million": 0.0,
|
|
79
|
+
"cache_read_input_per_million": 0.0
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"knowledge_cutoff": "2024-10-01",
|
|
84
|
+
"created_at": "2024-05-01"
|
|
85
|
+
}
|
|
86
|
+
]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "gpt-5.5",
|
|
4
|
+
"name": "GPT-5.5",
|
|
5
|
+
"provider": "openai_codex",
|
|
6
|
+
"family": "gpt-codex",
|
|
7
|
+
"context_window": 400000,
|
|
8
|
+
"max_output_tokens": 128000,
|
|
9
|
+
"capabilities": ["chat", "streaming", "tool_calls", "vision"],
|
|
10
|
+
"modalities": { "input": ["text"], "output": ["text"] },
|
|
11
|
+
"pricing": {
|
|
12
|
+
"text_tokens": {
|
|
13
|
+
"standard": {
|
|
14
|
+
"input_per_million": 0.0,
|
|
15
|
+
"output_per_million": 0.0,
|
|
16
|
+
"cache_read_input_per_million": 0.0
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"knowledge_cutoff": "2025-06-01",
|
|
21
|
+
"created_at": "2026-01-01"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "gpt-5.4",
|
|
25
|
+
"name": "GPT-5.4",
|
|
26
|
+
"provider": "openai_codex",
|
|
27
|
+
"family": "gpt-codex",
|
|
28
|
+
"context_window": 400000,
|
|
29
|
+
"max_output_tokens": 64000,
|
|
30
|
+
"capabilities": ["chat", "streaming", "tool_calls", "vision"],
|
|
31
|
+
"modalities": { "input": ["text"], "output": ["text"] },
|
|
32
|
+
"pricing": {
|
|
33
|
+
"text_tokens": {
|
|
34
|
+
"standard": {
|
|
35
|
+
"input_per_million": 0.0,
|
|
36
|
+
"output_per_million": 0.0,
|
|
37
|
+
"cache_read_input_per_million": 0.0
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"knowledge_cutoff": "2025-06-01",
|
|
42
|
+
"created_at": "2025-11-01"
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
"id": "gpt-5.4-mini",
|
|
46
|
+
"name": "GPT-5.4 mini",
|
|
47
|
+
"provider": "openai_codex",
|
|
48
|
+
"family": "gpt-codex",
|
|
49
|
+
"context_window": 400000,
|
|
50
|
+
"max_output_tokens": 64000,
|
|
51
|
+
"capabilities": ["chat", "streaming", "tool_calls", "vision"],
|
|
52
|
+
"modalities": { "input": ["text"], "output": ["text"] },
|
|
53
|
+
"pricing": {
|
|
54
|
+
"text_tokens": {
|
|
55
|
+
"standard": {
|
|
56
|
+
"input_per_million": 0.0,
|
|
57
|
+
"output_per_million": 0.0,
|
|
58
|
+
"cache_read_input_per_million": 0.0
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"knowledge_cutoff": "2025-06-01",
|
|
63
|
+
"created_at": "2025-11-01"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"id": "gpt-5.3-codex-spark",
|
|
67
|
+
"name": "GPT-5.3 Codex Spark",
|
|
68
|
+
"provider": "openai_codex",
|
|
69
|
+
"family": "gpt-codex",
|
|
70
|
+
"context_window": 400000,
|
|
71
|
+
"max_output_tokens": 64000,
|
|
72
|
+
"capabilities": ["chat", "streaming", "tool_calls", "vision"],
|
|
73
|
+
"modalities": { "input": ["text"], "output": ["text"] },
|
|
74
|
+
"pricing": {
|
|
75
|
+
"text_tokens": {
|
|
76
|
+
"standard": {
|
|
77
|
+
"input_per_million": 0.0,
|
|
78
|
+
"output_per_million": 0.0,
|
|
79
|
+
"cache_read_input_per_million": 0.0
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
"knowledge_cutoff": "2025-06-01",
|
|
84
|
+
"created_at": "2025-10-01"
|
|
85
|
+
}
|
|
86
|
+
]
|
|
@@ -75,6 +75,10 @@ module Ask
|
|
|
75
75
|
opencode_go: { api_base: "https://opencode.ai/zen/go/v1", api_key_env: "OPENCODE_GO_API_KEY",
|
|
76
76
|
capabilities: { chat: true, streaming: true, tool_calls: true } },
|
|
77
77
|
|
|
78
|
+
github_copilot: { api_base: "https://api.githubcopilot.com", api_key_env: "GITHUB_COPILOT_TOKEN",
|
|
79
|
+
extra_headers: { "X-GitHub-Api-Version" => "2026-06-01" },
|
|
80
|
+
capabilities: { chat: true, streaming: true, tool_calls: true } },
|
|
81
|
+
|
|
78
82
|
openrouter: { api_base: "https://openrouter.ai/api/v1", api_key_env: "OPENROUTER_API_KEY",
|
|
79
83
|
extra_headers: { "HTTP-Referer" => "https://github.com/ask-rb",
|
|
80
84
|
"X-Title" => "ask-rb" },
|
data/lib/ask/llm/version.rb
CHANGED
|
@@ -234,9 +234,27 @@ module Ask
|
|
|
234
234
|
# Fall back to text description
|
|
235
235
|
{ type: "text", text: "[#{type} content not supported by Anthropic]" }
|
|
236
236
|
when "file"
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
237
|
+
# Files become Anthropic document blocks: inline data (base64 or
|
|
238
|
+
# text source), a URL, or a provider file reference.
|
|
239
|
+
file_id = block[:file_id] || block["file_id"]
|
|
240
|
+
url = block[:url] || block["url"]
|
|
241
|
+
data = block[:data] || block["data"]
|
|
242
|
+
mime = block[:mime_type] || block["mime_type"]
|
|
243
|
+
filename = block[:filename] || "file"
|
|
244
|
+
|
|
245
|
+
if file_id
|
|
246
|
+
{ type: "document", source: { type: "file", file_id: file_id }, title: filename }
|
|
247
|
+
elsif url
|
|
248
|
+
{ type: "document", source: { type: "url", url: url }, title: filename }
|
|
249
|
+
elsif data
|
|
250
|
+
if mime.to_s.start_with?("text/")
|
|
251
|
+
{ type: "document", source: { type: "text", media_type: (mime || "text/plain"), data: data }, title: filename }
|
|
252
|
+
else
|
|
253
|
+
{ type: "document", source: { type: "base64", media_type: (mime || "application/octet-stream"), data: Base64.strict_encode64(data) }, title: filename }
|
|
254
|
+
end
|
|
255
|
+
else
|
|
256
|
+
block
|
|
257
|
+
end
|
|
240
258
|
else
|
|
241
259
|
block
|
|
242
260
|
end
|
data/lib/ask/provider/google.rb
CHANGED
|
@@ -143,7 +143,13 @@ module Ask
|
|
|
143
143
|
google_role = role == "assistant" ? "model" : role
|
|
144
144
|
|
|
145
145
|
parts = []
|
|
146
|
-
|
|
146
|
+
if content.is_a?(Array)
|
|
147
|
+
# Content blocks → Gemini parts (flat parts array). Previously
|
|
148
|
+
# arrays were passed through as a broken { text: [hash, …] } part.
|
|
149
|
+
content.each { |block| parts.concat(format_google_content_block(block)) }
|
|
150
|
+
elsif content
|
|
151
|
+
parts << { text: content }
|
|
152
|
+
end
|
|
147
153
|
|
|
148
154
|
if msg[:tool_calls] || msg["tool_calls"]
|
|
149
155
|
(msg[:tool_calls] || msg["tool_calls"]).each do |tc|
|
|
@@ -168,6 +174,57 @@ module Ask
|
|
|
168
174
|
{ role: google_role, parts: }
|
|
169
175
|
end
|
|
170
176
|
|
|
177
|
+
# Gemini content block → flat parts array. Media/file blocks become
|
|
178
|
+
# inlineData (base64) or fileData (uri/file_id); text-like files are
|
|
179
|
+
# inlined as text with a filename marker; unsupported blocks are
|
|
180
|
+
# skipped rather than mangled.
|
|
181
|
+
def format_google_content_block(block)
|
|
182
|
+
block = block.transform_keys(&:to_sym) if block.respond_to?(:transform_keys)
|
|
183
|
+
type = block[:type] || block["type"]
|
|
184
|
+
|
|
185
|
+
case type
|
|
186
|
+
when "text"
|
|
187
|
+
[{ text: block[:text] || block["text"] }]
|
|
188
|
+
when "image", "audio", "video"
|
|
189
|
+
mime = block[:mime_type] || block["mime_type"]
|
|
190
|
+
file_id = block[:file_id] || block["file_id"]
|
|
191
|
+
url = block[:url] || block["url"]
|
|
192
|
+
base64 = block[:base64] || block["base64"]
|
|
193
|
+
|
|
194
|
+
if file_id
|
|
195
|
+
[{ fileData: { mimeType: mime, fileUri: file_id } }]
|
|
196
|
+
elsif url
|
|
197
|
+
[{ fileData: { mimeType: mime, fileUri: url } }]
|
|
198
|
+
elsif base64
|
|
199
|
+
[{ inlineData: { mimeType: (mime || "application/octet-stream"), data: base64 } }]
|
|
200
|
+
else
|
|
201
|
+
[]
|
|
202
|
+
end
|
|
203
|
+
when "file"
|
|
204
|
+
mime = block[:mime_type] || block["mime_type"]
|
|
205
|
+
file_id = block[:file_id] || block["file_id"]
|
|
206
|
+
url = block[:url] || block["url"]
|
|
207
|
+
data = block[:data] || block["data"]
|
|
208
|
+
|
|
209
|
+
if file_id
|
|
210
|
+
[{ fileData: { mimeType: mime, fileUri: file_id } }]
|
|
211
|
+
elsif url
|
|
212
|
+
[{ fileData: { mimeType: mime, fileUri: url } }]
|
|
213
|
+
elsif data
|
|
214
|
+
if mime.to_s.start_with?("text/")
|
|
215
|
+
filename = block[:filename] ? "[#{block[:filename]}] " : ""
|
|
216
|
+
[{ text: "#{filename}#{data}" }]
|
|
217
|
+
else
|
|
218
|
+
[{ inlineData: { mimeType: (mime || "application/octet-stream"), data: Base64.strict_encode64(data) } }]
|
|
219
|
+
end
|
|
220
|
+
else
|
|
221
|
+
[]
|
|
222
|
+
end
|
|
223
|
+
else
|
|
224
|
+
[]
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
171
228
|
def format_tools(tools)
|
|
172
229
|
tools.map { |t|
|
|
173
230
|
{
|
data/lib/ask/provider/openai.rb
CHANGED
|
@@ -279,7 +279,11 @@ module Ask
|
|
|
279
279
|
content = msg[:content] || msg["content"] || ""
|
|
280
280
|
|
|
281
281
|
entry = { role: role.to_s }
|
|
282
|
-
entry[:content] =
|
|
282
|
+
entry[:content] = if content.is_a?(Array)
|
|
283
|
+
content.map { |block| format_responses_content_block(block) }
|
|
284
|
+
else
|
|
285
|
+
[{ type: "input_text", text: content.to_s }]
|
|
286
|
+
end
|
|
283
287
|
|
|
284
288
|
# Handle tool calls in assistant messages
|
|
285
289
|
if (tc = msg[:tool_calls] || msg["tool_calls"]) && tc.respond_to?(:any?) && tc.any?
|
|
@@ -302,6 +306,53 @@ module Ask
|
|
|
302
306
|
end
|
|
303
307
|
end
|
|
304
308
|
|
|
309
|
+
# Responses API content block → input_* part. File blocks become
|
|
310
|
+
# input_file (inline data URI, URL, or provider file id); images
|
|
311
|
+
# become input_image. Audio/video have no Responses input carrier
|
|
312
|
+
# and degrade to a text note (matching chat completions behavior).
|
|
313
|
+
def format_responses_content_block(block)
|
|
314
|
+
block = block.transform_keys(&:to_sym) if block.respond_to?(:transform_keys)
|
|
315
|
+
type = block[:type] || block["type"]
|
|
316
|
+
|
|
317
|
+
case type
|
|
318
|
+
when "text"
|
|
319
|
+
{ type: "input_text", text: block[:text] || block["text"] }
|
|
320
|
+
when "image"
|
|
321
|
+
file_id = block[:file_id] || block["file_id"]
|
|
322
|
+
url = block[:url] || block["url"]
|
|
323
|
+
base64 = block[:base64] || block["base64"]
|
|
324
|
+
if url || base64
|
|
325
|
+
mime = block[:mime_type] || block["mime_type"] || "image/png"
|
|
326
|
+
url ||= Ask::DataURI.from_base64(base64, mime_type: mime)
|
|
327
|
+
{ type: "input_image", image_url: url, detail: "auto" }
|
|
328
|
+
else
|
|
329
|
+
{ type: "input_image", image_url: file_id, detail: "auto" }
|
|
330
|
+
end
|
|
331
|
+
when "file"
|
|
332
|
+
file_id = block[:file_id] || block["file_id"]
|
|
333
|
+
url = block[:url] || block["url"]
|
|
334
|
+
data = block[:data] || block["data"]
|
|
335
|
+
if file_id
|
|
336
|
+
{ type: "input_file", file_id: file_id }
|
|
337
|
+
elsif url
|
|
338
|
+
{ type: "input_file", file_url: url }
|
|
339
|
+
elsif data
|
|
340
|
+
mime = block[:mime_type] || block["mime_type"] || "application/octet-stream"
|
|
341
|
+
{
|
|
342
|
+
type: "input_file",
|
|
343
|
+
filename: block[:filename] || "file",
|
|
344
|
+
file_data: Ask::DataURI.from_base64(Base64.strict_encode64(data), mime_type: mime)
|
|
345
|
+
}
|
|
346
|
+
else
|
|
347
|
+
block
|
|
348
|
+
end
|
|
349
|
+
when "audio", "video"
|
|
350
|
+
{ type: "input_text", text: "[#{type} content not supported by the Responses API]" }
|
|
351
|
+
else
|
|
352
|
+
block
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
305
356
|
def extract_responses_provider_results(output, provider_tools)
|
|
306
357
|
results = {}
|
|
307
358
|
provider_tool_names = provider_tools.map(&:name)
|
|
@@ -393,7 +444,7 @@ module Ask
|
|
|
393
444
|
elsif block[:base64] || block["base64"]
|
|
394
445
|
mime = block[:mime_type] || block["mime_type"] || "image/png"
|
|
395
446
|
data = block[:base64] || block["base64"]
|
|
396
|
-
{ type: "image_url", image_url: { url:
|
|
447
|
+
{ type: "image_url", image_url: { url: Ask::DataURI.from_base64(data, mime_type: mime) } }
|
|
397
448
|
elsif block[:file_id] || block["file_id"]
|
|
398
449
|
{ type: "image_url", image_url: { url: block[:file_id] || block["file_id"] } }
|
|
399
450
|
else
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "openai"
|
|
4
|
+
require_relative "../llm/sse_buffer"
|
|
5
|
+
|
|
6
|
+
module Ask
|
|
7
|
+
module Providers
|
|
8
|
+
# OpenAI Codex — requests routed through a ChatGPT subscription via OAuth
|
|
9
|
+
# (the access token + account id come from ask-auth's OpenaiCodex
|
|
10
|
+
# provider, passed explicitly by the host app: api_key / account_id).
|
|
11
|
+
#
|
|
12
|
+
# Always speaks the Responses API at
|
|
13
|
+
# chatgpt.com/backend-api/codex/responses, and streams the Responses SSE
|
|
14
|
+
# event shape (response.output_text.delta, function_call item events,
|
|
15
|
+
# response.completed) — the chat/completions parsing in the base class
|
|
16
|
+
# doesn't apply here.
|
|
17
|
+
class OpenaiCodex < OpenAI
|
|
18
|
+
API_BASE = "https://chatgpt.com/backend-api/codex"
|
|
19
|
+
SLUG = "openai_codex"
|
|
20
|
+
|
|
21
|
+
def self.slug
|
|
22
|
+
SLUG
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def api_base
|
|
26
|
+
@config.base_url || API_BASE
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def headers
|
|
30
|
+
super.tap do |h|
|
|
31
|
+
h["ChatGPT-Account-Id"] = @config.account_id if @config.account_id
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def chat(messages, model:, tools: nil, temperature: nil, stream: nil, schema: nil, **params, &block)
|
|
36
|
+
msgs = messages.is_a?(Ask::Conversation) ? messages.to_a : messages
|
|
37
|
+
regular_tools, = split_tools(tools)
|
|
38
|
+
|
|
39
|
+
payload = {
|
|
40
|
+
model: model,
|
|
41
|
+
input: format_responses_input(msgs)
|
|
42
|
+
}
|
|
43
|
+
payload[:tools] = format_tools(regular_tools) if regular_tools&.any?
|
|
44
|
+
payload[:temperature] = temperature if temperature
|
|
45
|
+
payload.merge!(params)
|
|
46
|
+
|
|
47
|
+
if stream
|
|
48
|
+
codex_stream(payload, model, &block)
|
|
49
|
+
else
|
|
50
|
+
responses_chat_nonstream(payload, model, [])
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def codex_stream(payload, model, &block)
|
|
57
|
+
stream = Ask::Stream.new
|
|
58
|
+
init_sse_buffer
|
|
59
|
+
@http.post("responses") do |req|
|
|
60
|
+
req.body = payload.merge(stream: true)
|
|
61
|
+
req.options.on_data = proc { |data, _bytes, _env| parse_codex_stream(data, stream, model, &block) }
|
|
62
|
+
end.tap do |resp|
|
|
63
|
+
unless resp.success?
|
|
64
|
+
err_body = case resp.body
|
|
65
|
+
when Hash then resp.body
|
|
66
|
+
when String then (JSON.parse(resp.body) rescue { "error" => { "message" => "HTTP #{resp.status}: #{resp.body[0..200]}" } })
|
|
67
|
+
else { "error" => { "message" => "HTTP #{resp.status}: empty response body" } }
|
|
68
|
+
end
|
|
69
|
+
err_body["error"] ||= {}
|
|
70
|
+
err_body["error"]["_status"] = resp.status
|
|
71
|
+
raise LLM::HTTP.map_error(resp.status, err_body, provider: "OpenAI")
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
stream.finish!
|
|
75
|
+
stream
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Responses-API SSE events:
|
|
79
|
+
# response.output_text.delta -> text content
|
|
80
|
+
# response.output_item.added -> function_call id/name
|
|
81
|
+
# response.function_call_arguments.delta -> tool-call argument fragment
|
|
82
|
+
# response.completed -> usage + finish status
|
|
83
|
+
def parse_codex_stream(raw, stream, model, &block)
|
|
84
|
+
each_sse_event(raw) do |data|
|
|
85
|
+
parsed = JSON.parse(data) rescue next
|
|
86
|
+
chunk =
|
|
87
|
+
case parsed["type"]
|
|
88
|
+
when "response.output_text.delta"
|
|
89
|
+
Ask::Chunk.new(content: parsed["delta"])
|
|
90
|
+
when "response.output_item.added"
|
|
91
|
+
item = parsed["item"] || {}
|
|
92
|
+
next unless item["type"] == "function_call"
|
|
93
|
+
|
|
94
|
+
Ask::Chunk.new(tool_calls: [{index: 0, id: item["id"], name: item["name"]}])
|
|
95
|
+
when "response.function_call_arguments.delta"
|
|
96
|
+
Ask::Chunk.new(tool_calls: [{index: 0, arguments: parsed["arguments"]}])
|
|
97
|
+
when "response.completed"
|
|
98
|
+
response = parsed["response"] || {}
|
|
99
|
+
usage = response["usage"] || {}
|
|
100
|
+
Ask::Chunk.new(
|
|
101
|
+
content: nil,
|
|
102
|
+
finish_reason: response["status"],
|
|
103
|
+
usage: {input_tokens: usage["input_tokens"], output_tokens: usage["output_tokens"]}
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
next unless chunk
|
|
107
|
+
|
|
108
|
+
stream.add(chunk)
|
|
109
|
+
yield chunk if block_given?
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
data/lib/ask-llm-providers.rb
CHANGED
|
@@ -32,6 +32,7 @@ require_relative "ask/provider/bedrock"
|
|
|
32
32
|
require_relative "ask/provider/ollama"
|
|
33
33
|
require_relative "ask/provider/mistral"
|
|
34
34
|
require_relative "ask/provider/cloudflare"
|
|
35
|
+
require_relative "ask/provider/openai_codex"
|
|
35
36
|
|
|
36
37
|
# Register canonical providers
|
|
37
38
|
Ask::Provider.register(:openai, Ask::Providers::OpenAI)
|
|
@@ -41,6 +42,7 @@ Ask::Provider.register(:bedrock, Ask::Providers::Bedrock)
|
|
|
41
42
|
Ask::Provider.register(:ollama, Ask::Providers::Ollama)
|
|
42
43
|
Ask::Provider.register(:mistral, Ask::Providers::Mistral)
|
|
43
44
|
Ask::Provider.register(:cloudflare, Ask::Providers::Cloudflare)
|
|
45
|
+
Ask::Provider.register(:openai_codex, Ask::Providers::OpenaiCodex)
|
|
44
46
|
|
|
45
47
|
# Register OpenAI-compatible providers from the registry (data → classes)
|
|
46
48
|
Ask::LLM::OPENAI_COMPATIBLE.each do |name, cfg|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ask-llm-providers
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.12.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kaka Ruto
|
|
@@ -186,11 +186,13 @@ files:
|
|
|
186
186
|
- lib/ask/llm/models/bedrock.json
|
|
187
187
|
- lib/ask/llm/models/deepseek.json
|
|
188
188
|
- lib/ask/llm/models/gemini.json
|
|
189
|
+
- lib/ask/llm/models/github_copilot.json
|
|
189
190
|
- lib/ask/llm/models/meta.json
|
|
190
191
|
- lib/ask/llm/models/mistral.json
|
|
191
192
|
- lib/ask/llm/models/moonshot.json
|
|
192
193
|
- lib/ask/llm/models/nvidia_nim.json
|
|
193
194
|
- lib/ask/llm/models/openai.json
|
|
195
|
+
- lib/ask/llm/models/openai_codex.json
|
|
194
196
|
- lib/ask/llm/models/perplexity.json
|
|
195
197
|
- lib/ask/llm/models/vertex_ai.json
|
|
196
198
|
- lib/ask/llm/models/xai.json
|
|
@@ -208,6 +210,7 @@ files:
|
|
|
208
210
|
- lib/ask/provider/mistral.rb
|
|
209
211
|
- lib/ask/provider/ollama.rb
|
|
210
212
|
- lib/ask/provider/openai.rb
|
|
213
|
+
- lib/ask/provider/openai_codex.rb
|
|
211
214
|
- lib/ask/provider/openai_compatible.rb
|
|
212
215
|
- lib/ask/skills/providers.model_select/SKILL.md
|
|
213
216
|
homepage: https://github.com/ask-rb/ask-llm-providers
|