From e7a6b616b3360385fb4fa0194320e24fca40b775 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:04:01 -0700 Subject: [PATCH] feat(inference): flip openai/codex + custom cloud slugs to crate-native OpenAiModel (#4782) --- docs/tinyagents-drift-ledger.md | 2 +- .../inference/provider/crate_openai.rs | 41 +++++++ src/openhuman/inference/provider/factory.rs | 100 ++++++++++++------ .../inference/provider/factory_tests.rs | 19 +++- vendor/tinyagents | 2 +- 5 files changed, 124 insertions(+), 40 deletions(-) diff --git a/docs/tinyagents-drift-ledger.md b/docs/tinyagents-drift-ledger.md index 8b3c81fc9..6a1ff283f 100644 --- a/docs/tinyagents-drift-ledger.md +++ b/docs/tinyagents-drift-ledger.md @@ -79,7 +79,7 @@ site is crate-backed (the migration's scaffold → flip → delete pattern). | --- | --- | --- | | Managed OpenHuman backend (common path: chat turns, memory/learning/meeting summaries) | `factory::make_openhuman_backend_model` → `OpenHumanBackendModel` (dynamic JWT + `thread_id` + billing envelope bridged onto crate `OpenAiModel`) | **CUT OVER** — `create_chat_model_with_model_id` routes it (commit `7e98c1b39`); test-provider override still wins. | | Wire-equivalent BYOK cloud slug (Anthropic / None / plain-Bearer, no codex-oauth, no `/v1/responses`) | `factory::try_create_cloud_slug_chat_model` → `crate_openai::make_crate_openai_chat_model` | **CUT OVER (conservative subset)** — `create_chat_model` routes these crate-native after the managed + local short-circuits. Resolution is shared via `resolve_cloud_slug` (the legacy `make_cloud_provider_by_slug` was refactored onto it, so eligible slugs resolve **identically**; only the wire client differs). The same `enforce_local_only_inference` + `verify_session_active` gate runs first. Covers the common non-OpenAI BYOK providers (DeepSeek, Groq, Mistral, xAI, …) via the crate wire the managed backend already proves. | -| `openai` / codex + custom-proxy cloud slugs | stay `Provider` path | **PHASE 3** — `openai` keeps the `/v1/responses` fallback + codex-oauth (query-param auth, account/originator headers, user-agent, responses-API-primary); custom slugs may proxy `/v1/responses`. `try_create_cloud_slug_chat_model` returns `None` for these. Land with the crate `/responses` port. | +| `openai` / codex + custom-proxy cloud slugs | crate-native via `try_create_cloud_slug_chat_model` | **CUT OVER** — the flip now covers **every** configured cloud slug except the managed `OpenhumanJwt` entry. Codex OAuth → crate `OpenAiModel` on the Responses API (`with_responses_api_primary` + account/originator headers + user-agent + `client_version` query + `max_output_tokens` omitted), enabled by the crate `/v1/responses` port ([tinyagents#51](https://github.com/tinyhumansai/tinyagents/pull/51)). Non-codex `openai` + custom slugs → crate Chat Completions (the legacy 404 → `/v1/responses` **fallback** is not replicated — chat completions is their primary path). Host pin `8e57665` = #49 toggles + #51 Responses, with #50 reverted (#52) so loopback stays retryable. **Live per-provider validation deferred to a dedicated tinyagents run.** | | Local runtimes (Ollama/LM Studio/MLX/OMLX/local-openai) | `factory::try_create_local_runtime_chat_model` → `crate_openai::make_crate_local_runtime_chat_model` (native tools + vision forced off; `num_ctx` baked as `{"options":{"num_ctx":N}}`) | **CUT OVER** — `create_chat_model_with_model_id` routes local runtimes crate-native (after the managed short-circuit). The flip **re-runs the same gate** the `Provider` path applies (`enforce_local_only_inference` + `verify_session_active`), so it cannot bypass privacy mode or the session requirement. Temperature rides the per-call `ModelRequest` (parity with managed). **Loopback error handling defers to upstream:** an upstream merge (`b709a993…`/`04ffc029…`) replaced the earlier `..._offline_trips_halt_guard` test with `cron_agent_job_short_loopback_send_error_stays_retryable` — i.e. an offline local provider now **stays retryable** (it may be transiently starting up). So the transient cron `{e:#}` cause-chain surfacing + the `is_non_retryable` loopback fast-fail were reverted, and the host stays pinned at `7c6e81a` (before [tinyagents#50](https://github.com/tinyhumansai/tinyagents/pull/50) `error_source_chain`) so the crate-native local error does not surface the `connection refused` errno the classifier would trip on. #50 remains a good crate improvement but is deliberately **not consumed** here to keep loopback retryable. | | Bespoke (managed backend, `claude_code`, `claude_agent_sdk`, `openai_codex`) | stay host `ChatModel` impls | **HOST-OWNED** — subprocess / `/v1/responses` / query-param auth have no crate equivalent; never route through `crate_openai`. | diff --git a/src/openhuman/inference/provider/crate_openai.rs b/src/openhuman/inference/provider/crate_openai.rs index 503f0c6a5..4a1981cc8 100644 --- a/src/openhuman/inference/provider/crate_openai.rs +++ b/src/openhuman/inference/provider/crate_openai.rs @@ -79,6 +79,19 @@ pub(crate) struct CrateOpenAiConfig<'a> { /// `provider_options`. `None` bakes nothing. Maps to /// `OpenAiModel::with_default_provider_options`. pub default_provider_options: Option, + /// Route calls to the OpenAI **Responses API** (`/v1/responses`) instead of + /// Chat Completions — the OpenAI Codex OAuth backend. Maps to + /// `OpenAiModel::with_responses_api_primary`. + pub responses_api_primary: bool, + /// (Responses path) omit `max_output_tokens`, which the Codex backend + /// rejects. Maps to `OpenAiModel::with_responses_omit_max_output_tokens`. + pub responses_omit_max_output_tokens: bool, + /// Static query parameters appended to every request URL (e.g. the Codex + /// `client_version`). Maps to `OpenAiModel::with_extra_query_param`. + pub extra_query_params: &'a [(String, String)], + /// `User-Agent` header override (e.g. the Codex CLI UA). Maps to + /// `OpenAiModel::with_user_agent`. + pub user_agent: Option<&'a str>, } /// Build a crate-native `OpenAiModel` (`ChatModel`) for the given OpenAI-compatible @@ -118,6 +131,18 @@ pub(crate) fn build_crate_openai_model(config: CrateOpenAiConfig<'_>) -> Arc:` BYOK cloud provider as a crate-native [`ChatModel`] — the -/// Motion B cutover of the wire-equivalent [`make_cloud_provider_by_slug`] -/// branches (issue #4727 Phase 3, conservative subset). +/// Motion B cutover of every [`make_cloud_provider_by_slug`] branch except the +/// managed `OpenhumanJwt` entry (issue #4727 Phase 3). /// /// Returns `None` (fall through to the `Provider` path) unless the role resolves -/// to a **configured** cloud slug whose auth style the crate `OpenAiModel` serves -/// with byte-identical wire semantics: -/// - `Anthropic` / `None` auth → always eligible (their endpoints have no -/// `/v1/responses`, so the host's dormant responses-fallback is behavior-neutral); -/// - `Bearer` → eligible **only** when there is no `/v1/responses` fallback, no -/// `openai-codex` OAuth, and no codex account header — i.e. a plain -/// chat-completions Bearer provider (DeepSeek, Groq, Mistral, xAI, …). +/// to a **configured** cloud slug. When it does: +/// - `Anthropic` / `None` / plain `Bearer` → crate `OpenAiModel` Chat Completions; +/// - `Bearer` with OpenAI **Codex OAuth** → crate `OpenAiModel` on the Responses +/// API (`with_responses_api_primary`), with the codex account/originator +/// headers, user-agent, `client_version` query param, and `max_output_tokens` +/// omitted (the crate `/v1/responses` support, tinyagents#51); +/// - `OpenhumanJwt` → `None` (routed to the managed backend elsewhere). /// -/// Everything else — `openai` (codex-oauth + Responses API), custom slugs whose -/// endpoint may proxy `/v1/responses`, and the managed `OpenhumanJwt` entry — -/// stays on the `Provider` path (deferred to the crate `/responses` port). The -/// resolution is shared via [`resolve_cloud_slug`], so eligible slugs resolve +/// The legacy host's rare chat-completions-404 → `/v1/responses` **fallback** for +/// non-codex slugs is not replicated (the crate has responses-*primary*, not +/// fallback); chat completions is the primary path those slugs use in practice. +/// +/// The resolution is shared via [`resolve_cloud_slug`], so slugs resolve /// identically to the legacy path; only the wire client differs. The **same** /// access gate the `Provider` path applies (`enforce_local_only_inference` + /// `verify_session_active`) runs before building. Temperature rides the per-call @@ -2311,36 +2312,69 @@ fn try_create_cloud_slug_chat_model( Err(e) => return Some(Err(e)), }; - // Only flip the wire-equivalent auth styles; codex-oauth, the `/v1/responses` - // fallback, and the managed `OpenhumanJwt` entry stay on the `Provider` path. + // Every configured cloud slug except the managed `OpenhumanJwt` entry builds + // a crate-native client. Codex OAuth routes to the Responses API with its + // headers / UA / query; every other Bearer/Anthropic/None slug uses Chat + // Completions (its primary path — the legacy host's rare 404 → `/v1/responses` + // fallback for non-codex slugs is not replicated). + let mut endpoint = entry.endpoint.clone(); + let mut extra_headers: Vec<(String, String)> = Vec::new(); + let mut extra_query_params: Vec<(String, String)> = Vec::new(); + let mut user_agent: Option = None; + let mut responses_api_primary = false; + let mut responses_omit_max_output_tokens = false; + let auth = match entry.auth_style { AuthStyle::Anthropic => CompatAuthStyle::Anthropic, AuthStyle::None => CompatAuthStyle::None, AuthStyle::OpenhumanJwt => return None, AuthStyle::Bearer => { - let responses_fallback = (!is_builtin_cloud_slug(&slug) - || builtin_cloud_supports_responses_api(&slug)) - && !endpoint_host_is_chat_completions_only(&codex.endpoint); - if responses_fallback || codex.using_oauth || codex.account_id.is_some() { - return None; + // The codex routing may re-target the endpoint (OAuth backend). + endpoint = codex.endpoint.clone(); + if let Some(account_id) = codex.account_id.as_deref() { + extra_headers.push(( + OPENAI_CODEX_ACCOUNT_HEADER.to_string(), + account_id.to_string(), + )); + } + if codex.using_oauth { + // Codex OAuth → Responses API primary + the codex request shape. + extra_headers.push(( + OPENAI_CODEX_ORIGINATOR_HEADER.to_string(), + OPENAI_CODEX_ORIGINATOR.to_string(), + )); + user_agent = Some(openai_codex_user_agent()); + extra_query_params + .push(("client_version".to_string(), openai_codex_client_version())); + responses_api_primary = true; + responses_omit_max_output_tokens = true; } CompatAuthStyle::Bearer } }; - let unsupported = &config.temperature_unsupported_models; - let chat = super::crate_openai::make_crate_openai_chat_model( - &slug, - &entry.endpoint, - &key, - auth, - &effective_model, - unsupported, - temperature_override, - // Cloud OpenAI-compatible providers accept a `system` role — no merge - // (parity with `OpenAiCompatibleProvider::new`). - false, - ); + let unsupported = config.temperature_unsupported_models.clone(); + let chat = + super::crate_openai::build_crate_openai_model(super::crate_openai::CrateOpenAiConfig { + provider_name: slug.as_str(), + endpoint: endpoint.as_str(), + api_key: key.as_str(), + auth_style: auth, + model: effective_model.as_str(), + temperature_unsupported_models: unsupported.as_slice(), + temperature_override, + // Cloud OpenAI-compatible providers accept a `system` role — no merge + // (parity with `OpenAiCompatibleProvider::new`). + merge_system_into_user: false, + extra_headers: extra_headers.as_slice(), + native_tool_calling: None, + vision: None, + default_provider_options: None, + responses_api_primary, + responses_omit_max_output_tokens, + extra_query_params: extra_query_params.as_slice(), + user_agent: user_agent.as_deref(), + }); Some(Ok((chat, effective_model))) } diff --git a/src/openhuman/inference/provider/factory_tests.rs b/src/openhuman/inference/provider/factory_tests.rs index 84b6ddcff..acdf764b4 100644 --- a/src/openhuman/inference/provider/factory_tests.rs +++ b/src/openhuman/inference/provider/factory_tests.rs @@ -2956,17 +2956,26 @@ fn create_chat_model_routes_anthropic_auth_cloud_slug_to_crate_native() { } #[test] -fn try_create_cloud_slug_declines_openai_and_non_cloud() { +fn try_create_cloud_slug_flips_openai_but_declines_non_cloud() { + use tinyagents::harness::model::ChatModel; + let _guard = crate::openhuman::inference::inference_test_guard(); - // `openai` exposes the Responses API, so its Bearer branch keeps the - // `/v1/responses` fallback — it is NOT wire-equivalent and stays host-side. + // `openai` (API-key Bearer, no codex OAuth) now flips crate-native on Chat + // Completions — the legacy `/v1/responses` fallback is not replicated. let mut openai = Config::default(); openai.cloud_providers.push(openai_entry("p_oai", "openai")); openai.chat_provider = Some("openai:gpt-4o-mini".to_string()); - assert!(try_create_cloud_slug_chat_model("chat", &openai).is_none()); + let (model, model_id) = try_create_cloud_slug_chat_model("chat", &openai) + .expect("openai should flip crate-native") + .expect("build"); + assert_eq!(model_id, "gpt-4o-mini"); + assert_eq!( + model.profile().and_then(|p| p.provider.as_deref()), + Some("openai") + ); // Managed (default), local runtimes, and unconfigured slugs are not cloud - // slugs — they fall through to their own paths. + // slugs — they decline and fall through to their own paths. assert!(try_create_cloud_slug_chat_model("chat", &Config::default()).is_none()); let mut local = Config::default(); local.chat_provider = Some("ollama:qwen2.5".to_string()); diff --git a/vendor/tinyagents b/vendor/tinyagents index 7c6e81a3d..8e5766500 160000 --- a/vendor/tinyagents +++ b/vendor/tinyagents @@ -1 +1 @@ -Subproject commit 7c6e81a3dadf05232d51571c3745cfd2b6c05feb +Subproject commit 8e57665000a95ccc01dbf9591173879a93956109