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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +62 -88
- data/lib/ask/llm/http.rb +1 -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 +61 -3
- 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,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
|
|
4
|
-
from `ask-core` with a capabilities-based interface
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
##
|
|
15
|
+
## Quick Start
|
|
26
16
|
|
|
27
17
|
```ruby
|
|
28
18
|
require "ask-llm-providers"
|
|
29
19
|
|
|
30
|
-
#
|
|
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(
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
##
|
|
70
|
+
## Entry Points
|
|
80
71
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
+
## Full documentation
|
|
104
85
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
@@ -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)
|
|
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] =
|
|
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:
|
|
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
|
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
|