mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(inference): flip openai/codex + custom cloud slugs to crate-native OpenAiModel (#4782)
This commit is contained in:
@@ -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`. |
|
||||
|
||||
|
||||
@@ -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<serde_json::Value>,
|
||||
/// 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<dyn
|
||||
if let Some(options) = config.default_provider_options {
|
||||
model = model.with_default_provider_options(options);
|
||||
}
|
||||
for (name, value) in config.extra_query_params {
|
||||
model = model.with_extra_query_param(name.clone(), value.clone());
|
||||
}
|
||||
if let Some(user_agent) = config.user_agent {
|
||||
model = model.with_user_agent(user_agent);
|
||||
}
|
||||
if config.responses_api_primary {
|
||||
model = model.with_responses_api_primary();
|
||||
}
|
||||
if config.responses_omit_max_output_tokens {
|
||||
model = model.with_responses_omit_max_output_tokens();
|
||||
}
|
||||
|
||||
Arc::new(model)
|
||||
}
|
||||
@@ -157,6 +182,10 @@ pub(crate) fn make_crate_openai_chat_model(
|
||||
native_tool_calling: None,
|
||||
vision: None,
|
||||
default_provider_options: None,
|
||||
responses_api_primary: false,
|
||||
responses_omit_max_output_tokens: false,
|
||||
extra_query_params: &[],
|
||||
user_agent: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -197,6 +226,10 @@ pub(crate) fn make_crate_local_runtime_chat_model(
|
||||
native_tool_calling: Some(false),
|
||||
vision: Some(false),
|
||||
default_provider_options,
|
||||
responses_api_primary: false,
|
||||
responses_omit_max_output_tokens: false,
|
||||
extra_query_params: &[],
|
||||
user_agent: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -240,6 +273,10 @@ mod tests {
|
||||
native_tool_calling: None,
|
||||
vision: None,
|
||||
default_provider_options: None,
|
||||
responses_api_primary: false,
|
||||
responses_omit_max_output_tokens: false,
|
||||
extra_query_params: &[],
|
||||
user_agent: None,
|
||||
});
|
||||
// The built model carries the configured provider + model on its profile.
|
||||
let profile = model.profile().expect("openai models expose a profile");
|
||||
@@ -281,6 +318,10 @@ mod tests {
|
||||
native_tool_calling: Some(false),
|
||||
vision: Some(false),
|
||||
default_provider_options: None,
|
||||
responses_api_primary: false,
|
||||
responses_omit_max_output_tokens: false,
|
||||
extra_query_params: &[],
|
||||
user_agent: None,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2236,22 +2236,23 @@ fn make_cloud_provider_by_slug(
|
||||
}
|
||||
|
||||
/// A `<slug>:<model>` 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<String> = 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)))
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/tinyagents updated: 7c6e81a3da...8e57665000
Reference in New Issue
Block a user