ask-llm-providers 0.10.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15fc5c9c298a6f88b722fde13139a1c3fe2dfa795255e8d7226b9ca0b5187bd5
4
- data.tar.gz: f9f1c3d677c470c2fffeac5720024ecc9c40fb58cf3b058ac2a99e1e5769100e
3
+ metadata.gz: 912f0553986f01615036939eb502aa1f42a35023dc0f04181b970bec4ca3fbbe
4
+ data.tar.gz: 71868d9efd700086b9ad8a3d4ae9f0191239bc4333eed2c889212793abeb1c71
5
5
  SHA512:
6
- metadata.gz: 63e97ca4cf04aac0d2e9de99a2d4b6425bd1c95466be13a7172667729830c83b3025fb54094e4a5b20bd1b6b49374bdd1575ef8a60a9eedf3395438a559cc1cf
7
- data.tar.gz: 50dfb8a0ca28288df43be9f8126530fa6bddbb6da4d1f951023b4ad7e592298b8c5ed9abb04f53494b65a049421447ba1ef4c02ea594a3af9f7b63176c707598
6
+ metadata.gz: 7354ea67a347577694c5cdd8ea3462719a308d8b268f90eb75022618b822f348395ec66cebb2bdb777168fc9f2359fa8d000fb6e647b947f58de84b6cc8dc495
7
+ data.tar.gz: e3c41f929d7118ede14d207e4c6aa480ca8bab95ab8d28a32085e0ba11ffa7ce3b87a24244d54ecaaee14323614840b57af8a312ba13ac62ab843e5fa53431aa
data/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
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
+
14
+ ## [0.10.2] - 2026-08-06
15
+
16
+ ### Fixed
17
+
18
+ - **Streamed token usage is now captured.** OpenAI-style streams end with a
19
+ chunk whose `choices` is empty but `usage` populated; `dig("choices", 0)
20
+ or next` silently dropped it, so every streamed call reported ~1 output
21
+ token (input 0) — token billing, cost accounting, and usage metrics
22
+ under-counted by orders of magnitude. The usage-only chunk is now added
23
+ to the stream (without yielding — it carries no content).
24
+
1
25
  ## [0.10.1] - 2026-07-31
2
26
 
3
27
  ### Fixed
data/README.md CHANGED
@@ -1,20 +1,10 @@
1
1
  # ask-llm-providers
2
2
 
3
- All LLM providers for the ask-rb ecosystem in one gem. Implements `Ask::Provider`
4
- from `ask-core` with a capabilities-based interface.
5
-
6
- ## Supported Providers
7
-
8
- | Provider | Auth | Implementation |
9
- |---|---|---|
10
- | **OpenAI** + all OpenAI-compatible | `Ask::Auth.resolve(:openai_api_key)` | `Ask::Providers::OpenAI` |
11
- | **Anthropic** (Claude) | `Ask::Auth.resolve(:anthropic_api_key)` | `Ask::Providers::Anthropic` |
12
- | **Google Gemini** | `Ask::Auth.resolve(:gemini_api_key)` | `Ask::Providers::Google` |
13
- | **Vertex AI** | GCP service account | `Ask::Providers::Google` (via Vertex) |
14
- | **Amazon Bedrock** | AWS credentials chain | `Ask::Providers::Bedrock` |
15
- | **Ollama** (local) | None needed | `Ask::Providers::Ollama` |
16
- | **Mistral AI** | `Ask::Auth.resolve(:mistral_api_key)` | `Ask::Providers::Mistral` |
17
- | **Cloudflare Workers AI** | `Ask::Auth.resolve(:cloudflare_api_key)` | `Ask::Providers::Cloudflare` |
3
+ All LLM providers for the ask-rb ecosystem in one gem. Implements the
4
+ `Ask::Provider` interface from `ask-core` with a capabilities-based interface:
5
+ 7 canonical provider classes plus 26 OpenAI-compatible registry entries,
6
+ a bundled model catalog, and cost calculation. Providers auto-register and
7
+ the model catalog auto-loads when the gem is required.
18
8
 
19
9
  ## Installation
20
10
 
@@ -22,102 +12,86 @@ from `ask-core` with a capabilities-based interface.
22
12
  gem "ask-llm-providers"
23
13
  ```
24
14
 
25
- ## Usage
15
+ ## Quick Start
26
16
 
27
17
  ```ruby
28
18
  require "ask-llm-providers"
29
19
 
30
- # All providers are auto-registered with Ask::Models
31
- models = Ask::Models.find("gpt-4o")
32
- # => { provider: :openai, capabilities: [...] }
33
-
34
- # Use a provider directly
20
+ # Use a provider directly; streaming yields chunks to the block
35
21
  provider = Ask::Providers::OpenAI.new
36
- provider.chat(conversation, tools: [], model: "gpt-4o") do |chunk|
22
+ provider.chat([{ role: "user", content: "Tell me a story" }], model: "gpt-4o") do |chunk|
37
23
  print chunk.content
38
24
  end
39
- ```
40
25
 
41
- ## Capabilities
42
-
43
- Each provider and model exposes its capabilities:
44
-
45
- ```ruby
46
- provider = Ask::Providers::OpenAI.new
47
- provider.capabilities
48
- # => { chat: true, streaming: true, tool_calls: true, vision: true, thinking: true,
49
- # :structured_output, :embed, :transcribe, :paint, :moderate]
50
-
51
- model = Ask::Models.find("claude-sonnet-4-5")
52
- model[:capabilities]
53
- # => { chat: true, streaming: true, tool_calls: true, vision: true, thinking: true, :prompt_caching]
54
-
55
- # Unsupported capabilities raise a helpful error
56
- provider = Ask::Providers::Anthropic.new
57
- provider.embed(["text"], model: "claude-sonnet-4-5")
58
- # => Ask::CapabilityNotSupported: Anthropic (claude-sonnet-4-5) does not support embeddings.
26
+ # Look up model metadata (capabilities, pricing, context window)
27
+ model = Ask::ModelCatalog.find("gpt-4o")
28
+ model.capabilities # => ["chat", "streaming", "tool_calls", ...]
59
29
  ```
60
30
 
31
+ ## Supported Providers
61
32
 
62
-
63
- ## Streaming
33
+ | Provider | Auth |
34
+ |---|---|
35
+ | OpenAI | `Ask::Auth.resolve(:openai_api_key)` (env `OPENAI_API_KEY`) |
36
+ | Anthropic (Claude) | `Ask::Auth.resolve(:anthropic_api_key)` (env `ANTHROPIC_API_KEY`) |
37
+ | Google Gemini | `Ask::Auth.resolve(:gemini_api_key)` (env `GEMINI_API_KEY`); Vertex AI via GCP service account |
38
+ | Amazon Bedrock | AWS credentials chain (env, `~/.aws`, instance profile) |
39
+ | Ollama (local) | none needed |
40
+ | Mistral AI | `Ask::Auth.resolve(:mistral_api_key)` (env `MISTRAL_API_KEY`) |
41
+ | Cloudflare Workers AI | `Ask::Auth.resolve(:cloudflare_api_key)` (env `CLOUDFLARE_API_KEY`) |
42
+ | 26 OpenAI-compatible | per-provider `*_API_KEY` env var (e.g. `DEEPSEEK_API_KEY`, `GROQ_API_KEY`, `OPENROUTER_API_KEY`) |
43
+
44
+ Credentials resolve through `Ask::Auth` in order: environment variables,
45
+ `~/.ask/credentials.yml`, Rails credentials, then database and OAuth
46
+ providers. Keys are read via `Ask::Auth.resolve(:<name>_api_key)`.
47
+
48
+ The 26 OpenAI-compatible entries are registered from
49
+ `Ask::LLM::OPENAI_COMPATIBLE`: DeepSeek, Groq, Together, Fireworks, Cerebras,
50
+ xAI, Perplexity, DeepInfra, Anyscale, SambaNova, Nebius, Nvidia NIM, Friendli,
51
+ Hyperbolic, Novita, Nscale, Featherless, AI/ML API, AI21, Meta, GitHub Models,
52
+ OpenRouter, OpenCode, OpenCode Go, Mimo, and Moonshot. Each uses its own
53
+ `*_API_KEY` env var; note that `opencode_go` uses `OPENCODE_GO_API_KEY` (its
54
+ own key, not `OPENCODE_API_KEY`).
55
+
56
+ ## Model Catalog
57
+
58
+ The gem bundles model metadata (capabilities, pricing, context windows,
59
+ modalities) as JSON for 12 providers: openai, anthropic, gemini, vertex_ai,
60
+ bedrock, deepseek, mistral, perplexity, xai, meta, moonshot, and nvidia_nim,
61
+ with 400+ models in total.
64
62
 
65
63
  ```ruby
66
- stream = provider.chat(
67
- [{ role: "user", content: "Tell me a story" }],
68
- model: "gpt-4o",
69
- stream: true
70
- ) do |chunk|
71
- print chunk.content
72
- end
73
-
74
- # After streaming completes, you can access the full response
75
- puts stream.accumulated_text
76
- puts stream.accumulated_usage
64
+ Ask::ModelCatalog.find("claude-sonnet-4-5") # => Ask::ModelInfo
65
+ Ask::ModelCatalog.chat_models # => filtered catalog
66
+ Ask::ModelCatalog.by_provider(:gemini)
67
+ Ask::ModelCatalog.refresh! # fetch latest from models.dev
77
68
  ```
78
69
 
79
- ## Tool Calls
70
+ ## Entry Points
80
71
 
81
- ```ruby
82
- tools = [{
83
- name: "get_weather",
84
- description: "Get weather for a location",
85
- parameters: {
86
- type: "object",
87
- properties: { location: { type: "string" } },
88
- required: ["location"]
89
- }
90
- }]
91
-
92
- response = provider.chat(
93
- [{ role: "user", content: "What's the weather in NYC?" }],
94
- model: "gpt-4o",
95
- tools: tools
96
- )
97
- # response.tool_call? => true
98
- # response.tool_calls => [{ id: "call_1", name: "get_weather", arguments: '{"location":"NYC"}' }]
99
- ```
72
+ | API | Purpose |
73
+ |---|---|
74
+ | `Ask::Providers::OpenAI/Anthropic/Google/Bedrock/Ollama/Mistral/Cloudflare` | Canonical provider classes |
75
+ | `Ask::LLM::OPENAI_COMPATIBLE` | Registry data for the 26 compatible providers |
76
+ | `Ask::LLM::Catalog.load!` / `refresh!` | Load bundled + user model data into `Ask::ModelCatalog` |
77
+ | `Ask::LLM::Aliases.resolve("claude-sonnet-4")` | Short-name to canonical model ID resolution |
78
+ | `Ask::LLM::CostCalculator.calculate(model, input_tokens:, output_tokens:)` | USD cost from model pricing |
79
+ | `Ask::Provider.resolve(:openai)` | Class lookup by registered slug |
100
80
 
101
- ## Error Handling
81
+ Providers accept a `base_url` override, so any OpenAI-compatible endpoint can
82
+ be used through `Ask::Providers::OpenAI` or `Ask::Providers::OpenAICompatible`.
102
83
 
103
- Provider errors map to structured `Ask::Error` types:
84
+ ## Full documentation
104
85
 
105
- ```ruby
106
- Ask::RateLimitError # 429 retry with backoff
107
- Ask::Unauthorized # 401/403 check your API key
108
- Ask::ServerError # 500 — provider issue
109
- Ask::ServiceUnavailable # 503 — temporary
110
- Ask::ContextLengthExceeded # context window exceeded
111
- Ask::ProviderError # other provider errors
112
- Ask::CapabilityNotSupported # feature not available on this model
113
- ```
86
+ The full ask-rb documentation lives at https://ask-rb.github.io/ask-docs.
87
+ https://ask-rb.github.io/ask-docs/core/providers covers ask-llm-providers in
88
+ depth, including capabilities, streaming, tool calls, and error handling.
89
+ API reference: https://ask-rb.github.io/ask-docs/reference/api.
114
90
 
115
91
  ## Development
116
92
 
117
- ```bash
118
- bin/setup
93
+ bundle install
119
94
  bundle exec rake test
120
- ```
121
95
 
122
96
  ## License
123
97
 
data/lib/ask/llm/http.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require "time"
2
3
 
3
4
  module Ask
4
5
  module LLM
@@ -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" },
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module LLM
5
- VERSION = "0.10.1"
5
+ VERSION = "0.12.0"
6
6
  end
7
7
  end
@@ -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
- data = block[:data] || block["data"] || ""
238
- filename = block[:filename] ? "[#{block[:filename]}] " : ""
239
- { type: "text", text: "#{filename}#{data}" }
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
@@ -143,7 +143,13 @@ module Ask
143
143
  google_role = role == "assistant" ? "model" : role
144
144
 
145
145
  parts = []
146
- parts << { text: content } if content
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
  {
@@ -124,7 +124,14 @@ module Ask
124
124
  def parse_stream(raw, stream, model, &block)
125
125
  each_sse_event(raw) do |data|
126
126
  parsed = JSON.parse(data) rescue next
127
- choice = parsed.dig("choices", 0) or next
127
+ choice = parsed.dig("choices", 0)
128
+ if choice.nil?
129
+ # Final chunk of an OpenAI-style stream: choices is empty but
130
+ # usage is populated. Added to the stream (for token
131
+ # accounting) without yielding — it carries no content.
132
+ stream.add(Ask::Chunk.new(content: nil, usage: parsed["usage"])) if parsed["usage"]
133
+ next
134
+ end
128
135
  delta = choice["delta"] || {}
129
136
  thinking = extract_thinking(parsed, delta)
130
137
  chunk = Ask::Chunk.new(
@@ -272,7 +279,11 @@ module Ask
272
279
  content = msg[:content] || msg["content"] || ""
273
280
 
274
281
  entry = { role: role.to_s }
275
- entry[:content] = [{ type: "input_text", text: content.to_s }]
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
276
287
 
277
288
  # Handle tool calls in assistant messages
278
289
  if (tc = msg[:tool_calls] || msg["tool_calls"]) && tc.respond_to?(:any?) && tc.any?
@@ -295,6 +306,53 @@ module Ask
295
306
  end
296
307
  end
297
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
+
298
356
  def extract_responses_provider_results(output, provider_tools)
299
357
  results = {}
300
358
  provider_tool_names = provider_tools.map(&:name)
@@ -386,7 +444,7 @@ module Ask
386
444
  elsif block[:base64] || block["base64"]
387
445
  mime = block[:mime_type] || block["mime_type"] || "image/png"
388
446
  data = block[:base64] || block["base64"]
389
- { type: "image_url", image_url: { url: "data:#{mime};base64,#{data}" } }
447
+ { type: "image_url", image_url: { url: Ask::DataURI.from_base64(data, mime_type: mime) } }
390
448
  elsif block[:file_id] || block["file_id"]
391
449
  { type: "image_url", image_url: { url: block[:file_id] || block["file_id"] } }
392
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
@@ -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.10.1
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