Skip to content

feat(provider-openai): standalone OpenAI Chat Completions provider worker - #252

Merged
ytallo merged 19 commits into
mainfrom
feat/provider-openai
Jun 13, 2026
Merged

feat(provider-openai): standalone OpenAI Chat Completions provider worker#252
ytallo merged 19 commits into
mainfrom
feat/provider-openai

Conversation

@ytallo

@ytallo ytallo commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Summary

Standalone llm-router plugin worker for the OpenAI Chat Completions API, structurally mirroring provider-anthropic/ (#247): token-gated registration with router::ready rebind, live model discovery reconciled through the router's single write path, SSE → AssistantMessageEvent streaming with ping watchdog and abort-on-channel-close, and typed upstream error classification.

OpenAI-specific surfaces

  • Reasoning effort (reasoning.rs): thinking_level maps to reasoning_effort per model family — each ladder branch traces to a documented 400 (o1 rejects the param entirely, chat-tuned variants take only the fixed default, pro tiers are high-only, xhigh is gpt-5.2+). Non-reasoning families omit the param and degrade with a warning.
  • Native structured output: response_format passes through as json_schema (the Anthropic provider reports-and-ignores it).
  • Live-only catalog with generation filter: the declaration carries no static models; a refresh fires right after registration. OpenAI's GET /v1/models returns bare ids (no capabilities, names, or limits — unlike Anthropic's API), so a local family-metadata table enriches known families and a name denylist drops known legacy generations (gpt-3*/gpt-4*/chatgpt*/o1/o3/o4; unrecognized future families pass through). Dated snapshots fold into their undated alias when both are live. Against the real API: 70 chat models → 23 current-generation rows.

Behavior notes

  • No key configured → catalog reconciles to empty and router::provider::list shows configured: false; chats fail loudly with a classified error.
  • Env-var credential fallback (OPENAI_API_KEY) resolves in the llm-router process, same as every provider.

Test plan

  • cargo test — 56 unit + 5 engine-backed integration (registration/token persistence, live-only catalog, filtered reconcile, chat with cost fill, upstream 401 classification, router-restart redeclare)
  • Live e2e against the local stack (engine + llm-router + harness + console): paste-a-key → auto-discovery (70 live models), routed chat on gpt-5.1, function calling (engine::workers::list round-trip), multi-turn recall, reasoning effort (thinking high on gpt-5.1 → correct bat-and-ball answer; degrade on gpt-4o-mini pre-filter), mid-stream abort, and the filtered catalog (23 rows, picker clean, chat on gpt-5 mini)

Summary by CodeRabbit

  • New Features

    • Added an OpenAI Chat Completions provider with streaming, structured-output, reasoning_effort, and prompt-caching support
    • Automatic live model discovery and catalog synchronization; persistent provider registration with automatic refresh on reconnect
    • Enhanced error classification with auth/rate-limit/transient handling
  • Documentation

    • Provider README and top-level docs updated with usage, flags, and examples
  • Tests

    • New integration and unit tests covering streaming, discovery, and wire-format behavior
  • Chores

    • CI/workflow and config entries added for provider-openai

@vercel

vercel Bot commented Jun 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
workers Ready Ready Preview, Comment Jun 13, 2026 1:08pm

Request Review

@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@ytallo, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 24 minutes and 53 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4685297e-7ab7-46af-aae7-725c3a00009e

📥 Commits

Reviewing files that changed from the base of the PR and between bea21b7 and df52d2f.

⛔ Files ignored due to path filters (1)
  • provider-openai/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (31)
  • .github/workflows/create-tag.yml
  • .github/workflows/release.yml
  • README.md
  • llm-router/README.md
  • provider-openai/.gitignore
  • provider-openai/Cargo.toml
  • provider-openai/README.md
  • provider-openai/build.rs
  • provider-openai/config.yaml
  • provider-openai/iii-permissions.yaml
  • provider-openai/iii.worker.yaml
  • provider-openai/src/config.rs
  • provider-openai/src/curated.rs
  • provider-openai/src/discovery.rs
  • provider-openai/src/errors.rs
  • provider-openai/src/lib.rs
  • provider-openai/src/main.rs
  • provider-openai/src/manifest.rs
  • provider-openai/src/reasoning.rs
  • provider-openai/src/register.rs
  • provider-openai/src/request.rs
  • provider-openai/src/router_client.rs
  • provider-openai/src/sse.rs
  • provider-openai/src/state.rs
  • provider-openai/src/stream_fn.rs
  • provider-openai/src/upstream.rs
  • provider-openai/src/wire/messages.rs
  • provider-openai/src/wire/mod.rs
  • provider-openai/src/wire/names.rs
  • provider-openai/src/wire/tools.rs
  • provider-openai/tests/integration.rs
📝 Walkthrough

Walkthrough

Adds a new Rust worker "provider-openai" implementing llm-router provider protocol: provider registration, per-request credential/config resolution, OpenAI chat completions streaming (SSE → router frames), model discovery/curation and reconciliation, error classification, request/wire adapters, and an integration test suite.

Changes

OpenAI Provider Implementation

Layer / File(s) Summary
Project scaffolding and configuration
provider-openai/Cargo.toml, provider-openai/.gitignore, provider-openai/build.rs, provider-openai/iii.worker.yaml, provider-openai/iii-permissions.yaml, .github/workflows/*
Cargo workspace metadata, build-time TARGET emission, gitignore, worker deployment manifest, permissions restricting agent calls, and CI release/tag workflow globs/inputs updated to include provider-openai.
Documentation and runtime config placeholder
provider-openai/README.md, llm-router/README.md, provider-openai/config.yaml
Provider README documents protocol, registration, credentials, error semantics, reasoning, discovery, and tests; llm-router README adds a provider-openai reference; config.yaml notes file-config is ignored in favor of router-provided settings.
Manifest, lib and main entrypoints
provider-openai/src/lib.rs, provider-openai/src/manifest.rs, provider-openai/src/main.rs
Crate exports/submodules and PROVIDER_ID, manifest generator for --manifest, and binary main implementing CLI, manifest output, config-key warnings, worker registration, and graceful shutdown.
Configuration and state management
provider-openai/src/config.rs, provider-openai/src/state.rs
OpenaiConfig assembly from router resolve with credential extraction, max_tokens/api_url precedence defaults, NotConfigured error, and persistent registration-token load/store via iii-state.
Model curation and discovery
provider-openai/src/curated.rs, provider-openai/src/discovery.rs
Local family metadata/pricing, base-id ISO-date stripping, legacy-generation detection, models_url derivation, live models parsing/filtering/deduplication, and refresh_models reconciliation with auth/transient outcome handling.
Error classification and factories
provider-openai/src/errors.rs
OpenAI envelope parsing and HTTP/message-based mapping into llm_router ErrorKind, bus error permanence rule, and IIIError factory helpers for invalid_request and upstream_unavailable.
Wire format and request building
provider-openai/src/wire/messages.rs, provider-openai/src/wire/tools.rs, provider-openai/src/wire/names.rs, provider-openai/src/request.rs
Conversion of AgentMessage/AgentFunction into OpenAI chat rows and function tool descriptors, name encode/decode, build_body with streaming/max_completion_tokens/reasoning_effort/response_format, and build_headers.
Reasoning effort mapping
provider-openai/src/reasoning.rs
Detection of reasoning-capable models (catalog flag or id pattern) and mapping ThinkingLevel to OpenAI reasoning_effort with family-specific support, degradation, and omission rules.
SSE state machine and streaming events
provider-openai/src/sse.rs
PartialState accumulator, handle_chunk producing AssistantMessageEvent sequences (start/delta/end/usage/error), usage merge semantics, finish reason mapping, and synthetic mid-stream error emission.
Upstream HTTP streaming adapter
provider-openai/src/upstream.rs
spawn_upstream that POSTs to /v1/chat/completions, reads SSE data blocks, extracts last data: payload, emits Start/Stop/Done or Error frames, and aborts on receiver drop; tests for auth/transient/terminal behaviors.
Router protocol client
provider-openai/src/router_client.rs
Async wrappers around iii.trigger calls for resolve, reconcile, models_get, and register with fixed timeout and JSON deserialization/error mapping.
Stream orchestration and pump
provider-openai/src/stream_fn.rs
make_stream factory, run_stream_call orchestration (token load, resolve, config selection, reasoning handling), send_event helper, and pump loop forwarding upstream events to router FrameSink with Ping keepalives.
Provider registration and lifecycle
provider-openai/src/register.rs
Provider declaration payload, declare_once with token persistence, exponential backoff declare_with_backoff loop, declare_and_refresh bootstrap, router::ready re-declare subscription, and register_provider wiring.
End-to-end integration tests
provider-openai/tests/integration.rs
Tokio integration suite bootstrapping an engine, stub upstream for SSE/401/models, capturing router frames, asserting registration token persistence, chat streaming/usage/cost, auth failure mapping, model refresh reconciliation, and re-declare on router restart.

Sequence Diagram(s)

sequenceDiagram
  participant Router
  participant ProviderWorker
  participant OpenAI_Upstream
  participant IIIState
  Router->>ProviderWorker: trigger provider::openai::stream (ProviderStreamInput)
  ProviderWorker->>IIIState: load_token / store_token (registration token)
  ProviderWorker->>Router: iii.trigger resolve (resolve credentials/config)
  ProviderWorker->>OpenAI_Upstream: POST /v1/chat/completions (body, headers)
  OpenAI_Upstream->>ProviderWorker: SSE data chunks -> parse -> AssistantMessageEvent
  ProviderWorker->>Router: write frame to FrameSink (AssistantMessageEvent)
  Router->>ProviderWorker: router ready (subscribe) -> provider re-declare/refresh
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

  • iii-hq/workers#40: Related CI/workflow changes touching release/tag dispatcher and worker selection inputs.

Suggested reviewers

  • sergiofilhowz

"I nibble code and hop along,
Streams of SSE, a jaunty song,
Models found and tokens kept,
Provider booted, tests well slept,
🐇✨"

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: a new standalone OpenAI Chat Completions provider worker for llm-router, which is the primary focus of this substantial PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/provider-openai

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ytallo
ytallo changed the base branch from feat/provider-anthropic to feat/llm-router-rs June 12, 2026 14:41
@ytallo
ytallo changed the base branch from feat/llm-router-rs to feat/provider-anthropic June 12, 2026 14:41
@ytallo
ytallo force-pushed the feat/provider-openai branch from 08b852d to 55c8add Compare June 12, 2026 14:44
@ytallo
ytallo changed the base branch from feat/provider-anthropic to feat/llm-router-rs June 12, 2026 14:44
@github-actions

github-actions Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

skill-check — worker

0 verified, 18 skipped (no docs/).

Layer Result
structure
vale
ai
render

Four for four. Nicely done.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🧹 Nitpick comments (1)
provider-openai/tests/integration.rs (1)

196-223: ⚡ Quick win

Validate the outbound chat request in the stub.

Right now every non-GET /v1/models request gets a success/error fixture regardless of method, path, headers, or body. That means regressions in the OpenAI wire contract can slip through this suite while chat_streams_end_to_end_with_cost_fill() still passes. Capture the raw request and assert the minimal contract here before sending the fixture.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@provider-openai/tests/integration.rs` around lines 196 - 223, The
stub_upstream currently returns fixture responses for any non-GET request
without validating the actual outbound request; update stub_upstream to read and
preserve the full raw request (request line, headers, and body) for non-GET
traffic, parse and assert the minimal OpenAI chat contract — e.g., method is
POST, path is "/v1/chat/completions", Content-Type contains "application/json",
and the JSON body contains a "messages" field — before choosing to return
messages_response (leave GET /v1/models returning STUB_MODELS). Ensure these
validations occur inside the spawned connection handler (the closure that reads
from sock) and only send the fixture after assertions pass so failing contract
expectations will surface test failures.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@provider-openai/src/config.rs`:
- Around line 37-40: The match on resolved.credential currently treats any
Some(credential) as configured even when credential_parts(credential) yields an
empty string; change the logic in the block handling resolved.credential so that
you call credential_parts(credential), trim/check the resulting string and if
it's empty return Err(NotConfigured) instead of assigning credential_value.
Update the branch that currently sets credential_value to perform this
empty-check (use the existing credential_parts helper and the NotConfigured
error) so empty bearer tokens are treated as not configured.

In `@provider-openai/src/curated.rs`:
- Around line 37-43: family_meta currently has no entry for the bare "gpt-5"
alias, so models retained by is_legacy_generation() and
discovery::parse_live_models() fall through to defaults; add an explicit "gpt-5"
match arm in family_meta (e.g., map to ("GPT-5", same token limits/flags/pricing
as the intended current GPT‑5 family member) so it gets proper
max_output_tokens, structured-output/thinking flags, and pricing), or
alternatively normalize the alias to a known family (e.g., rewrite "gpt-5" to
"gpt-5.1" or "gpt-5.2") before calling enrich(); update family_meta (and/or the
normalization location) to reference the chosen family consistently.

In `@provider-openai/src/register.rs`:
- Around line 100-106: register_provider currently builds a reqwest::Client with
only connect_timeout and reuses it for streaming and discovery, which lets
provider::openai::refresh_models / provider-openai::discovery::fetch_live_models
hang if the upstream stalls; fix by creating a separate reqwest client for model
refresh (e.g., new client in register_provider or inside
refresh_models/fetch_live_models) that sets a full request timeout via
.timeout(...) or .read_timeout(...), or alternatively wrap the GET/json calls in
tokio::time::timeout, and ensure register_provider continues to use the original
client for streaming while the discovery path uses the timeout-enabled client.

In `@provider-openai/src/router_client.rs`:
- Around line 47-55: The function models_get currently swallows call errors via
`.await.ok()?`, making timeouts/auth/bus failures look like "no model"; change
models_get to return a Result<Option<Model>, IIIError> (instead of
Option<Model>) and propagate the call error instead of discarding it: await the
`call(...)` and `?` the Result to return the underlying IIIError, then handle
the case where `raw.get("model")` is None by returning Ok(None) and convert
serde deserialization failures into an IIIError (or map them to an appropriate
error variant) rather than silencing them; use the existing symbols models_get,
call, IIIError, and PROVIDER_ID to locate and update the logic.

In `@provider-openai/src/sse.rs`:
- Around line 247-250: The code reads an upstream-controlled index via
tc.get("index") and grows state.function_calls to that size, which allows
unbounded allocation; change this by validating and capping the index before
resizing: parse the index from tc.get("index"), check it against a safe maximum
(e.g., a MAX_FUNCTION_CALLS constant) and if it’s >= MAX_FUNCTION_CALLS or
otherwise invalid, either ignore the request or return/log an error instead of
growing the vector; only perform the while loop/resizing when the
validated_index is within the allowed bound so
state.function_calls.push(PartialFunctionCall::default()) cannot be driven by an
attacker to trigger OOM.

In `@provider-openai/src/state.rs`:
- Around line 10-20: The function load_token currently swallows all errors from
iii.trigger by using .await.ok()?; change load_token to return a
Result<Option<String>, E> (e.g., anyhow::Error or the III trigger error type)
instead of Option<String>, propagate the trigger error with ? (or map_err to add
context) when calling iii.trigger(TriggerRequest { function_id: "state::get",
payload: json!({ "scope": STATE_SCOPE, "key": TOKEN_KEY }), ... }), and only
convert a successful value into Option<String>
(value.as_str().map(String::from)); this preserves transient/state errors while
still returning None when the token is genuinely absent.

In `@provider-openai/src/stream_fn.rs`:
- Around line 92-97: The reasoning_effort and related checks use the original
input model variable (e.g., calls to is_reasoning_model and reasoning_effort_for
with &model and input.thinking_level) but the request actually sends cfg.model
(the resolved model/alias) upstream; update the logic to compute
reasoning_effort and the warning branch using the resolved model in cfg (use
cfg.model or the resolved model variable) so the classification/mapping and
warning behavior reflect the actual target model sent in the API call, and
ensure any other nearby checks (the ones around lines where reasoning_effort is
used and where cfg.model is sent) are changed consistently.

In `@provider-openai/src/wire/messages.rs`:
- Around line 145-153: The function to_wire_messages currently always creates
and pushes an assistant entry (entry = json!({ "role": "assistant" })) even when
both text and tool_calls are empty, which can cause OpenAI validation errors;
change the logic in to_wire_messages so you only construct/push the assistant
entry (the entry variable and the out.push(entry) call) when either text is
non-empty or tool_calls is non-empty, and ensure the existing comment/behavior
about placing placeholders for orphans "directly after the assistant" is
preserved by only inserting those placeholders after an actual assistant entry
has been emitted.

In `@provider-openai/src/wire/names.rs`:
- Around line 4-10: The current encode_tool_name/decode_tool_name is lossy for
names containing "__"; replace the ad-hoc replace scheme with a reversible
encoding (e.g., base64-url or percent-encoding) so encode_tool_name(name: &str)
returns a collision-free encoded string and decode_tool_name reverses it back;
update both functions (encode_tool_name and decode_tool_name) to use the chosen
reversible encoder/decoder so decode(encode(x)) == x for any input, and add a
short unit test exercising names with "__" and "::" to verify round-trip
correctness.

In `@provider-openai/tests/integration.rs`:
- Line 377: The test currently ignores the result of
tokio::time::timeout(Duration::from_secs(5), pump).await, which can hide a
timeout or task panic; update both chat_streams_end_to_end_with_cost_fill() and
upstream_401_surfaces_as_auth_expired_error_frame() to assert the timeout
succeeded (fail the test on Err from timeout) and then separately assert the
JoinHandle completed successfully (surface JoinError from awaiting the
JoinHandle) before proceeding to read frames. In practice replace the ignored
let _ = ... with checking the timeout Result (expect or assert on Err) to fail
on elapsed, then await the returned JoinHandle and fail/assert on its JoinError,
and only after that continue to read frames.
- Around line 42-48: The helper free_port() is vulnerable to a TOCTOU race
because it relinquishes the ephemeral port before spawn_engine() starts iii;
change the test startup to reserve the port (keep the TcpListener open) until
the engine is confirmed listening or implement a retry loop that picks a fresh
port and retries spawn_engine() on failure; specifically modify free_port()
usage or replace it with a reserve_port() that returns a TcpListener (or a
(port, listener) pair) and ensure spawn_engine() is invoked while the listener
is held, or wrap spawn_engine() in a small retry/backoff that retries on
bind/address-in-use errors to eliminate the intermittent race.

---

Nitpick comments:
In `@provider-openai/tests/integration.rs`:
- Around line 196-223: The stub_upstream currently returns fixture responses for
any non-GET request without validating the actual outbound request; update
stub_upstream to read and preserve the full raw request (request line, headers,
and body) for non-GET traffic, parse and assert the minimal OpenAI chat contract
— e.g., method is POST, path is "/v1/chat/completions", Content-Type contains
"application/json", and the JSON body contains a "messages" field — before
choosing to return messages_response (leave GET /v1/models returning
STUB_MODELS). Ensure these validations occur inside the spawned connection
handler (the closure that reads from sock) and only send the fixture after
assertions pass so failing contract expectations will surface test failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 990ef76b-3c78-4b17-ba56-4f85770e70ef

📥 Commits

Reviewing files that changed from the base of the PR and between cccef22 and 952738c.

⛔ Files ignored due to path filters (1)
  • provider-openai/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (27)
  • llm-router/README.md
  • provider-openai/.gitignore
  • provider-openai/Cargo.toml
  • provider-openai/README.md
  • provider-openai/build.rs
  • provider-openai/iii-permissions.yaml
  • provider-openai/iii.worker.yaml
  • provider-openai/src/config.rs
  • provider-openai/src/curated.rs
  • provider-openai/src/discovery.rs
  • provider-openai/src/errors.rs
  • provider-openai/src/lib.rs
  • provider-openai/src/main.rs
  • provider-openai/src/manifest.rs
  • provider-openai/src/reasoning.rs
  • provider-openai/src/register.rs
  • provider-openai/src/request.rs
  • provider-openai/src/router_client.rs
  • provider-openai/src/sse.rs
  • provider-openai/src/state.rs
  • provider-openai/src/stream_fn.rs
  • provider-openai/src/upstream.rs
  • provider-openai/src/wire/messages.rs
  • provider-openai/src/wire/mod.rs
  • provider-openai/src/wire/names.rs
  • provider-openai/src/wire/tools.rs
  • provider-openai/tests/integration.rs

Comment on lines +37 to +40
let credential_value = match &resolved.credential {
Some(credential) => credential_parts(credential).to_string(),
None => return Err(NotConfigured),
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Treat empty credential secrets as NotConfigured too.

Line 37–40 currently accepts Some(Credential) even when the extracted bearer token is empty, which defers a deterministic config error into an upstream 401 path.

Suggested patch
-    let credential_value = match &resolved.credential {
-        Some(credential) => credential_parts(credential).to_string(),
-        None => return Err(NotConfigured),
-    };
+    let credential_value = match &resolved.credential {
+        Some(credential) => {
+            let v = credential_parts(credential).trim();
+            if v.is_empty() {
+                return Err(NotConfigured);
+            }
+            v.to_string()
+        }
+        None => return Err(NotConfigured),
+    };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let credential_value = match &resolved.credential {
Some(credential) => credential_parts(credential).to_string(),
None => return Err(NotConfigured),
};
let credential_value = match &resolved.credential {
Some(credential) => {
let v = credential_parts(credential).trim();
if v.is_empty() {
return Err(NotConfigured);
}
v.to_string()
}
None => return Err(NotConfigured),
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@provider-openai/src/config.rs` around lines 37 - 40, The match on
resolved.credential currently treats any Some(credential) as configured even
when credential_parts(credential) yields an empty string; change the logic in
the block handling resolved.credential so that you call
credential_parts(credential), trim/check the resulting string and if it's empty
return Err(NotConfigured) instead of assigning credential_value. Update the
branch that currently sets credential_value to perform this empty-check (use the
existing credential_parts helper and the NotConfigured error) so empty bearer
tokens are treated as not configured.

Comment on lines +37 to +43
fn family_meta(base: &str) -> Option<(&'static str, u64, u64, bool, Pricing)> {
match base {
"gpt-5.2" => Some(("GPT-5.2", 400_000, 128_000, true, price(1.75, 14.0))),
"gpt-5.1" => Some(("GPT-5.1", 400_000, 128_000, false, price(1.25, 10.0))),
"gpt-5-mini" => Some(("GPT-5 Mini", 400_000, 128_000, false, price(0.25, 2.0))),
"gpt-5-nano" => Some(("GPT-5 Nano", 400_000, 128_000, false, price(0.05, 0.40))),
_ => None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add metadata for the bare gpt-5 alias.

is_legacy_generation() explicitly keeps gpt-5, and discovery::parse_live_models() will publish it if /v1/models returns that alias, but family_meta() falls through to the unknown-family defaults here. The router would then advertise gpt-5 with max_output_tokens: 16_384, no structured-output/thinking flags, and no pricing even though this code treats it as a current chat model. Add an explicit gpt-5 row or normalize that alias before calling enrich().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@provider-openai/src/curated.rs` around lines 37 - 43, family_meta currently
has no entry for the bare "gpt-5" alias, so models retained by
is_legacy_generation() and discovery::parse_live_models() fall through to
defaults; add an explicit "gpt-5" match arm in family_meta (e.g., map to
("GPT-5", same token limits/flags/pricing as the intended current GPT‑5 family
member) so it gets proper max_output_tokens, structured-output/thinking flags,
and pricing), or alternatively normalize the alias to a known family (e.g.,
rewrite "gpt-5" to "gpt-5.1" or "gpt-5.2") before calling enrich(); update
family_meta (and/or the normalization location) to reference the chosen family
consistently.

Comment on lines +18 to +22
pub fn models_url(api_url: &str) -> String {
match api_url.strip_suffix("/chat/completions") {
Some(base) => format!("{base}/models"),
None => "https://api.openai.com/v1/models".to_string(),
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Keep /v1/models on the configured host.

If resolved.api_url points at a proxy or custom base and does not literally end with /chat/completions, this helper silently falls back to https://api.openai.com/v1/models. refresh_models() then sends the resolved Bearer credential there, which both breaks catalog refresh for custom deployments and leaks the secret to the wrong upstream. Return an error/skip refresh instead of changing hosts, or derive /models from the same origin/path prefix.

Comment on lines +100 to +106
pub async fn register_provider(iii: III) -> Result<(), IIIError> {
// Streaming uses no total timeout (the router owns stream budgets);
// connect failures surface fast.
let http = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.build()
.expect("reqwest client");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In reqwest's ClientBuilder, if only connect_timeout is set and no timeout/read_timeout is configured, do requests and response body reads have an infinite default timeout?

💡 Result:

Yes, if you only set the connect_timeout in reqwest's ClientBuilder, there is no default timeout for the rest of the request (sending the request and reading the response body) [1][2]. In the async implementation, the default timeout for the overall request and for individual read operations is None [2], meaning these operations will not time out automatically unless specifically configured [1][2]. It is important to distinguish between the different types of timeouts available in reqwest: 1. connect_timeout: Specifies a limit only for the TCP connection establishment phase [3][1]. 2. timeout: Specifies a deadline for the entire request lifecycle (from connection start until the response body has finished) [3][1]. 3. read_timeout: Specifies a timeout for each individual read operation, which is useful for detecting stalled connections during body streaming [1][4][2]. If you do not set the global timeout or a read_timeout, requests will wait indefinitely for the server to respond and send the full response body [1][2]. Note that the blocking client in reqwest differs from the async client in its default behavior, often including a 30-second default for its general timeout [5][6], but for the standard async ClientBuilder, no such automatic limit exists when only connect_timeout is set [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and display the relevant code in register.rs around the provided line range
rg -n "pub async fn register_provider" -S provider-openai/src/register.rs
sed -n '80,140p' provider-openai/src/register.rs

# Find where fetch_live_models / refresh_models are implemented and inspect timeout usage
rg -n "fetch_live_models|refresh_models" -S provider-openai/src
# Show discovery.rs relevant sections
DISCOVERY_FILE=$(fd -t f "discovery.rs" provider-openai/src | head -n 1 || true)
echo "Discovery file: $DISCOVERY_FILE"
if [ -n "${DISCOVERY_FILE:-}" ]; then
  rg -n "fetch_live_models" "$DISCOVERY_FILE"
  rg -n "v1/models|/v1/models" "$DISCOVERY_FILE"
  # print a reasonable window around the functions
  # (use sed windows around matches if possible)
  # First, show entire function blocks by grabbing lines around occurrences
  sed -n '1,220p' "$DISCOVERY_FILE" | sed -n '1,220p'
fi

# Also inspect any place where the reqwest client is constructed or passed into discovery
rg -n "reqwest::Client::builder|connect_timeout|timeout\(|read_timeout\(" -S provider-openai/src

Repository: iii-hq/workers

Length of output: 11989


Add a request/response timeout for model refresh (/v1/models) to prevent indefinite hangs

register_provider builds a reqwest::Client with only connect_timeout and reuses it for both streaming and provider::openai::refresh_models. provider-openai/src/discovery.rs::fetch_live_models performs a normal GET /v1/models and then resp.json::<Value>().await without any timeout/read_timeout, so if the upstream stalls after the TCP connection succeeds, the refresh task can wait indefinitely (reqwest async timeout defaults to None unless configured). Create a separate client for discovery/refresh with .timeout(...)/.read_timeout(...) (or wrap send/json in tokio::time::timeout) instead of sharing the stream client.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@provider-openai/src/register.rs` around lines 100 - 106, register_provider
currently builds a reqwest::Client with only connect_timeout and reuses it for
streaming and discovery, which lets provider::openai::refresh_models /
provider-openai::discovery::fetch_live_models hang if the upstream stalls; fix
by creating a separate reqwest client for model refresh (e.g., new client in
register_provider or inside refresh_models/fetch_live_models) that sets a full
request timeout via .timeout(...) or .read_timeout(...), or alternatively wrap
the GET/json calls in tokio::time::timeout, and ensure register_provider
continues to use the original client for streaming while the discovery path uses
the timeout-enabled client.

Comment on lines +47 to +55
pub async fn models_get(iii: &III, model_id: &str) -> Option<Model> {
let raw = call(
iii,
"router::models::get",
json!({ "provider": PROVIDER_ID, "id": model_id }),
)
.await
.ok()?;
serde_json::from_value(raw.get("model")?.clone()).ok()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

models_get currently masks router call failures as “model absent”.

Line 54 (.await.ok()?) drops all IIIError variants, so timeout/auth/bus failures become indistinguishable from a legitimate miss. Downstream callers then take fallback paths silently.

Suggested fix
-pub async fn models_get(iii: &III, model_id: &str) -> Option<Model> {
-    let raw = call(
+pub async fn models_get(iii: &III, model_id: &str) -> Result<Option<Model>, IIIError> {
+    let raw = call(
         iii,
         "router::models::get",
         json!({ "provider": PROVIDER_ID, "id": model_id }),
     )
-    .await
-    .ok()?;
-    serde_json::from_value(raw.get("model")?.clone()).ok()
+    .await?;
+    match raw.get("model") {
+        Some(v) if !v.is_null() => serde_json::from_value(v.clone())
+            .map(Some)
+            .map_err(|e| IIIError::Remote {
+                code: "provider/bad_models_get_response".into(),
+                message: e.to_string(),
+                stacktrace: None,
+            }),
+        _ => Ok(None),
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub async fn models_get(iii: &III, model_id: &str) -> Option<Model> {
let raw = call(
iii,
"router::models::get",
json!({ "provider": PROVIDER_ID, "id": model_id }),
)
.await
.ok()?;
serde_json::from_value(raw.get("model")?.clone()).ok()
pub async fn models_get(iii: &III, model_id: &str) -> Result<Option<Model>, IIIError> {
let raw = call(
iii,
"router::models::get",
json!({ "provider": PROVIDER_ID, "id": model_id }),
)
.await?;
match raw.get("model") {
Some(v) if !v.is_null() => serde_json::from_value(v.clone())
.map(Some)
.map_err(|e| IIIError::Remote {
code: "provider/bad_models_get_response".into(),
message: e.to_string(),
stacktrace: None,
}),
_ => Ok(None),
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@provider-openai/src/router_client.rs` around lines 47 - 55, The function
models_get currently swallows call errors via `.await.ok()?`, making
timeouts/auth/bus failures look like "no model"; change models_get to return a
Result<Option<Model>, IIIError> (instead of Option<Model>) and propagate the
call error instead of discarding it: await the `call(...)` and `?` the Result to
return the underlying IIIError, then handle the case where `raw.get("model")` is
None by returning Ok(None) and convert serde deserialization failures into an
IIIError (or map them to an appropriate error variant) rather than silencing
them; use the existing symbols models_get, call, IIIError, and PROVIDER_ID to
locate and update the logic.

Comment on lines +92 to +97
let reasoning_effort = if is_reasoning_model(
&model,
model_meta.as_ref().and_then(|m| m.supports_thinking),
) {
let effort = reasoning_effort_for(input.thinking_level, &model);
if input.thinking_level.is_some() && effort.is_none() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reasoning effort is computed from input.model, but the API call uses cfg.model.

Line 115 sends cfg.model upstream, while Lines 92 and 96 classify/map effort using model from input. If resolve remaps aliases, reasoning_effort and warning behavior can be wrong for the actual target model.

Suggested fix
-    let model = input.model.clone();
+    let model = input.model.clone();
+    let target_model = cfg.model.clone();
@@
-    let model_meta = match input.model_meta {
+    let model_meta = match input.model_meta {
         Some(m) => Some(m),
-        None => router_client::models_get(iii, &model).await,
+        None => router_client::models_get(iii, &target_model).await,
     };
-    let reasoning_effort = if is_reasoning_model(
-        &model,
+    let reasoning_effort = if is_reasoning_model(
+        &target_model,
         model_meta.as_ref().and_then(|m| m.supports_thinking),
     ) {
-        let effort = reasoning_effort_for(input.thinking_level, &model);
+        let effort = reasoning_effort_for(input.thinking_level, &target_model);

Also applies to: 114-116

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@provider-openai/src/stream_fn.rs` around lines 92 - 97, The reasoning_effort
and related checks use the original input model variable (e.g., calls to
is_reasoning_model and reasoning_effort_for with &model and
input.thinking_level) but the request actually sends cfg.model (the resolved
model/alias) upstream; update the logic to compute reasoning_effort and the
warning branch using the resolved model in cfg (use cfg.model or the resolved
model variable) so the classification/mapping and warning behavior reflect the
actual target model sent in the API call, and ensure any other nearby checks
(the ones around lines where reasoning_effort is used and where cfg.model is
sent) are changed consistently.

Comment on lines +145 to +153
let mut entry = json!({ "role": "assistant" });
if !text.is_empty() {
entry["content"] = Value::String(text);
}
if !tool_calls.is_empty() {
entry["tool_calls"] = Value::Array(tool_calls);
}
out.push(entry);
// Placeholders for orphans go directly after the assistant

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show file around the referenced lines
FILE="provider-openai/src/wire/messages.rs"
echo "== $FILE =="
python3 - <<PY
import itertools
path="$FILE"
start=100
end=200
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f,1):
        if start<=i<=end:
            print(f"{i:4d}: {line.rstrip()}")
PY

# Find all assistant entry constructions and any related skip/filter logic
rg -n "thinking|redacted|Thinking|redacted-thinking|role\"\\s*:\\s*\"assistant\"|tool_calls|out\\.push\\(" "$FILE"

# Check if entry.get("content") pattern already exists elsewhere in the repo (serde_json Value usage)
rg -n "entry\\.get\\(\"content\"\\)|\\.get\\(\"tool_calls\"\\)" provider-openai/src || true

# Look for validation/schema for the "wire" rows / outgoing messages
# (search in provider-openai and maybe common request builders)
rg -n "content.*tool_calls|tool_calls.*content|Messages|wire.*row|invalid.*assistant|assistant row" provider-openai/src || true

Repository: iii-hq/workers

Length of output: 6223


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="provider-openai/src/wire/messages.rs"

echo "== Test section around thinking_blocks_and_result_images_are_dropped() =="
python3 - <<PY
path="$FILE"
start=320
end=410
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f,1):
        if start<=i<=end:
            print(f"{i:4d}: {line.rstrip()}")
PY

echo "== Look for any filtering of wire messages before sending (provider-openai) =="
rg -n "get\\(\"content\"\\)|get\\(\"tool_calls\"\\)|empty assistant|skip.*assistant|filter.*tool_calls|filter.*assistant|drop.*assistant" provider-openai/src || true

echo "== Where the wire array is used to build OpenAI requests =="
rg -n "wire\\b|messages\\b.*wire|to_wire|from_wire|chat\\b.*messages" provider-openai/src || true

Repository: iii-hq/workers

Length of output: 10457


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="provider-openai/src/request.rs"
echo "== $FILE =="
python3 - <<PY
path="$FILE"
start=1
end=200
with open(path,'r',encoding='utf-8') as f:
    for i,line in enumerate(f,1):
        if start<=i<=end:
            print(f"{i:4d}: {line.rstrip()}")
PY

echo "== Search for any filtering/validation of assistant rows with missing content/tool_calls =="
rg -n "role\"\\s*:\\s*\"assistant\"|\\[\"role\"\\]\\s*==\\s*\"assistant\"|assistant.*(content|tool_calls)|tool_calls.*assistant" provider-openai/src || true
rg -n "get\\(\"content\"\\)|get\\(\"tool_calls\"\\)|content\\s*\\.\\s*is_none|tool_calls\\s*\\.\\s*is_none|content\\s*==\\s*Null" provider-openai/src || true

echo "== Search tests for assistant with only Thinking/RedactedThinking (no Text) =="
rg -n "ContentBlock::Thinking|ContentBlock::RedactedThinking" provider-openai/src/wire/messages.rs

Repository: iii-hq/workers

Length of output: 8858


🌐 Web query:

OpenAI Chat Completions API assistant message role requires content or tool_calls validation

💡 Result:

In the Chat Completions API, an assistant message (role = "assistant") must include content unless tool_calls (or the deprecated function_call) is specified. - The assistant message schema documents content as: “Required unless tool_calls or function_call is specified.” [1][2] - The same schema also defines tool_calls as the tool calls generated by the model. [1][2] - This is consistent with the typical function/tool-calling flow: when the model responds with tool_calls, you execute the tools and append tool result messages, then call the API again; the example explicitly notes that tool-role messages must be responses to a preceding message with tool_calls. [3] Practical validation guidance (what to send): - If message.tool_calls is present (non-empty), you should omit content (or allow it to be null/absent per the client type), and rely on tool_calls to carry the structured tool request. [1][2] - If message.tool_calls is absent, then message.content must be provided (text or content parts, depending on your SDK/model). [1][2] Why you may see “requires content or tool_calls”: - Your payload is likely validating against the ChatCompletionAssistantMessageParam type where content is conditionally required. If neither content nor tool_calls is provided on an assistant message, the validator rejects it. [1][2] Sources: [1] https://github.com/openai/openai-python/blob/5e8f09c2/src/openai/types/chat/chat_completion_assistant_message_param.py [2] https://openai-hd4n6.mintlify.app/api-reference/chat/create-chat-completion [3] https://developers.openai.com/cookbook/examples/how_to_call_functions_with_chat_models

Citations:


Skip emitting empty assistant turns (no content and no tool_calls)

to_wire_messages unconditionally pushes json!({ "role": "assistant" }) even when text and tool_calls are both empty (e.g., an assistant turn containing only dropped Thinking/RedactedThinking blocks). OpenAI Chat Completions requires content unless tool_calls (or deprecated function_call) is provided, so this can produce a 400 validation error.

Proposed fix
                 if !tool_calls.is_empty() {
                     entry["tool_calls"] = Value::Array(tool_calls);
                 }
-                out.push(entry);
+                if entry.get("content").is_some() || entry.get("tool_calls").is_some() {
+                    out.push(entry);
+                }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@provider-openai/src/wire/messages.rs` around lines 145 - 153, The function
to_wire_messages currently always creates and pushes an assistant entry (entry =
json!({ "role": "assistant" })) even when both text and tool_calls are empty,
which can cause OpenAI validation errors; change the logic in to_wire_messages
so you only construct/push the assistant entry (the entry variable and the
out.push(entry) call) when either text is non-empty or tool_calls is non-empty,
and ensure the existing comment/behavior about placing placeholders for orphans
"directly after the assistant" is preserved by only inserting those placeholders
after an actual assistant entry has been emitted.

Comment on lines +4 to +10
pub fn encode_tool_name(name: &str) -> String {
name.replace("::", "__")
}

pub fn decode_tool_name(name: &str) -> String {
name.replace("__", "::")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Tool-name codec is lossy for ids containing __.

At Lines 8-9, decoding blindly rewrites every __ to ::. That makes the codec non-reversible (decode(encode("a__b")) != "a__b"), which can rewrite function ids and break tool execution routing.

A collision-free encoding/decoding scheme (or a per-request encoded-name map) is needed here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@provider-openai/src/wire/names.rs` around lines 4 - 10, The current
encode_tool_name/decode_tool_name is lossy for names containing "__"; replace
the ad-hoc replace scheme with a reversible encoding (e.g., base64-url or
percent-encoding) so encode_tool_name(name: &str) returns a collision-free
encoded string and decode_tool_name reverses it back; update both functions
(encode_tool_name and decode_tool_name) to use the chosen reversible
encoder/decoder so decode(encode(x)) == x for any input, and add a short unit
test exercising names with "__" and "::" to verify round-trip correctness.

Comment on lines +42 to +48
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.expect("bind ephemeral port")
.local_addr()
.expect("local addr")
.port()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid the free-port TOCTOU during engine startup.

free_port() releases the ephemeral listener before spawn_engine() launches iii, so another process can claim that port and make these tests fail intermittently. Retrying startup with a fresh port (or keeping the reservation until the engine is listening, if the engine supports that flow) would remove a real flake source.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@provider-openai/tests/integration.rs` around lines 42 - 48, The helper
free_port() is vulnerable to a TOCTOU race because it relinquishes the ephemeral
port before spawn_engine() starts iii; change the test startup to reserve the
port (keep the TcpListener open) until the engine is confirmed listening or
implement a retry loop that picks a fresh port and retries spawn_engine() on
failure; specifically modify free_port() usage or replace it with a
reserve_port() that returns a TcpListener (or a (port, listener) pair) and
ensure spawn_engine() is invoked while the listener is held, or wrap
spawn_engine() in a small retry/backoff that retries on bind/address-in-use
errors to eliminate the intermittent race.

"cost filled: {res}"
);

let _ = tokio::time::timeout(Duration::from_secs(5), pump).await;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't ignore timeout(..., pump) failures in chat_streams_end_to_end_with_cost_fill() and upstream_401_surfaces_as_auth_expired_error_frame().

If the 5s timeout fires in either test, dropping the JoinHandle detaches read_all() and the test keeps going. That hides broken stream shutdown and can leave a background task running until later worker teardown. Fail on Err(_), and surface JoinError separately, before reading frames.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@provider-openai/tests/integration.rs` at line 377, The test currently ignores
the result of tokio::time::timeout(Duration::from_secs(5), pump).await, which
can hide a timeout or task panic; update both
chat_streams_end_to_end_with_cost_fill() and
upstream_401_surfaces_as_auth_expired_error_frame() to assert the timeout
succeeded (fail the test on Err from timeout) and then separately assert the
JoinHandle completed successfully (surface JoinError from awaiting the
JoinHandle) before proceeding to read frames. In practice replace the ignored
let _ = ... with checking the timeout Result (expect or assert on Err) to fail
on elapsed, then await the returned JoinHandle and fail/assert on its JoinError,
and only after that continue to read frames.

ytallo added 6 commits June 13, 2026 10:06
GET /v1/models is now the source of truth for the catalog's id list: the
registration declaration carries no static models and a refresh fires right
after registering. OpenAI's models API returns bare ids — no capability
tree, display names, or limits — so a local metadata table enriches known
families and conservative defaults cover the rest; pricing stays local for
the same reason.

Two filters shape the slice: known legacy generations (gpt-3*/gpt-4*/
chatgpt*/o1/o3/o4 — a name denylist, since there is no capability data to
key off; unrecognized future families pass through) are dropped, and dated
snapshots fold into their undated alias when both are live. Against the
real API this takes the picker from 70 rows to 23, all current-generation.
- main.rs: clap CLI (--config/--url/--manifest), tracing init,
  WorkerMetadata identity, and shutdown_async on exit; --url honours
  III_WS_URL as a fallback. A --config file carrying real keys now warns
  instead of being silently ignored (provider config comes from the
  llm-router configuration entry).
- config.yaml: committed stub documenting where configuration lives.
- Release wiring: added to create-tag.yml options and release.yml tag
  patterns. Version pre-bumped to 0.3.0 above the retired bundled Node
  provider's tags (provider-openai/v0.1.0–v0.2.1) so the first Create Tag
  run cannot collide.
- README: Modules table row; worker README documents the CLI flags.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants