diff --git a/crates/tinymemory-api/src/host/cloud_providers.rs b/crates/tinymemory-api/src/host/cloud_providers.rs index 0d77e9da..4112a719 100644 --- a/crates/tinymemory-api/src/host/cloud_providers.rs +++ b/crates/tinymemory-api/src/host/cloud_providers.rs @@ -45,6 +45,12 @@ pub const BUILTIN_CLOUD_PROVIDERS: &[BuiltinCloudProvider] = &[ endpoint: "https://openrouter.ai/api/v1", auth_style: AuthStyle::Bearer, }, + BuiltinCloudProvider { + slug: "inworld", + label: "Inworld", + endpoint: "https://api.inworld.ai/v1", + auth_style: AuthStyle::Basic, + }, BuiltinCloudProvider { slug: "orcarouter", label: "OrcaRouter", @@ -326,6 +332,8 @@ pub enum AuthStyle { /// OpenAI-compatible: `Authorization: Bearer ` #[default] Bearer, + /// HTTP Basic: `Authorization: Basic ` + Basic, /// Anthropic: `x-api-key: ` + `anthropic-version: 2023-06-01` Anthropic, /// OpenHuman session JWT (injected by the backend provider, not stored here). @@ -338,6 +346,7 @@ impl AuthStyle { pub fn as_str(&self) -> &'static str { match self { Self::Bearer => "bearer", + Self::Basic => "basic", Self::Anthropic => "anthropic", Self::OpenhumanJwt => "openhuman_jwt", Self::None => "none", diff --git a/crates/tinymemory-api/src/host/cloud_providers_tests.rs b/crates/tinymemory-api/src/host/cloud_providers_tests.rs index b3ad190f..26985290 100644 --- a/crates/tinymemory-api/src/host/cloud_providers_tests.rs +++ b/crates/tinymemory-api/src/host/cloud_providers_tests.rs @@ -64,6 +64,12 @@ fn builtin_cloud_provider_defaults_cover_phase_one_presets() { "https://api-inference.modelscope.cn/v1", AuthStyle::Bearer, ), + ( + "inworld", + "Inworld", + "https://api.inworld.ai/v1", + AuthStyle::Basic, + ), ] { let mut entry = CloudProviderCreds { id: format!("p_{slug}"), @@ -93,7 +99,7 @@ fn builtin_cloud_provider_slugs_are_unique() { #[test] fn is_builtin_cloud_slug_matches_presets_only() { - for slug in ["openai", "deepseek", "groq", "mistral"] { + for slug in ["openai", "deepseek", "groq", "mistral", "inworld"] { assert!(is_builtin_cloud_slug(slug), "{slug} is a built-in preset"); } for slug in ["my-proxy", "custom-openai", "totally-unknown", ""] { @@ -167,6 +173,7 @@ fn host_is_builtin_cloud_provider_recognises_inference_hosts() { "api.openai.com", "api.anthropic.com", "api.groq.com", + "api.inworld.ai", "generativelanguage.googleapis.com", "API.OPENAI.COM", // case-insensitive ] { @@ -211,6 +218,7 @@ fn nvidia_host_is_chat_completions_only_regardless_of_slug() { "https://api.deepseek.com/v1", "https://api.groq.com/openai/v1", "https://api.mistral.ai/v1", + "https://api.inworld.ai/v1", ] { assert!( endpoint_host_is_chat_completions_only(endpoint), @@ -248,8 +256,8 @@ fn host_gate_agrees_with_slug_capability_for_every_builtin() { for provider in BUILTIN_CLOUD_PROVIDERS { // OpenhumanJwt / Anthropic presets never route through the // OpenAI-compatible Responses fallback; the gate only matters for - // the Bearer OpenAI-compatible hosts. - if provider.auth_style != AuthStyle::Bearer { + // Bearer and Basic OpenAI-compatible hosts. + if !matches!(provider.auth_style, AuthStyle::Bearer | AuthStyle::Basic) { continue; } let host_chat_only = endpoint_host_is_chat_completions_only(provider.endpoint); @@ -265,13 +273,22 @@ fn host_gate_agrees_with_slug_capability_for_every_builtin() { #[test] fn auth_styles_and_legacy_provider_types_expose_stable_wire_properties() { let styles = [ - (AuthStyle::Bearer, "bearer"), - (AuthStyle::Anthropic, "anthropic"), - (AuthStyle::OpenhumanJwt, "openhuman_jwt"), - (AuthStyle::None, "none"), + (AuthStyle::Bearer, "bearer", "bearer"), + (AuthStyle::Basic, "basic", "basic"), + (AuthStyle::Anthropic, "anthropic", "anthropic"), + // Preserve the historical serde value while `as_str` retains the + // underscore used by host configuration code. + (AuthStyle::OpenhumanJwt, "openhuman_jwt", "openhumanjwt"), + (AuthStyle::None, "none", "none"), ]; - for (style, expected) in styles { + for (style, expected, wire_value) in styles { assert_eq!(style.as_str(), expected); + let json = serde_json::to_string(&style).expect("auth style serializes"); + assert_eq!(json, format!("\"{wire_value}\"")); + assert_eq!( + serde_json::from_str::(&json).expect("auth style deserializes"), + style + ); } let types = [ diff --git a/docs/plans/inworld-cloud-provider.md b/docs/plans/inworld-cloud-provider.md new file mode 100644 index 00000000..eda2df39 --- /dev/null +++ b/docs/plans/inworld-cloud-provider.md @@ -0,0 +1,24 @@ +# Inworld cloud provider metadata plan + +Specification: [Inworld cloud provider metadata](../specs/inworld-cloud-provider.md) + +## Goal + +Add the smallest host contract required for an Inworld LLM provider without +adding request transport or credential storage to TinyMemory. + +## Tasks + +1. Extend cloud-provider tests for the `basic` wire value, Inworld preset, + legacy migration, host recognition, and chat-only classification. +2. Add `AuthStyle::Basic` and the Inworld built-in catalog entry in + `crates/tinymemory-api/src/host/cloud_providers.rs`. +3. Run formatting, Clippy, build, and test contract commands. + +## Completion checklist + +- [x] Focused cloud-provider tests pass. +- [x] `cargo fmt --all -- --check` passes. +- [x] `cargo clippy --all-targets --all-features -- -D warnings` passes. +- [x] `cargo build --all-targets --all-features` passes. +- [x] `cargo test --all-features` passes. diff --git a/docs/specs/inworld-cloud-provider.md b/docs/specs/inworld-cloud-provider.md new file mode 100644 index 00000000..dadeafe7 --- /dev/null +++ b/docs/specs/inworld-cloud-provider.md @@ -0,0 +1,61 @@ +# Inworld cloud provider metadata + +**Status:** Accepted + +**Owner:** TinyMemory maintainers + +## Problem + +Hosts that consume TinyMemory's cloud-provider contract cannot represent an +OpenAI-compatible provider that authenticates with HTTP Basic credentials. +They also need one canonical Inworld preset so configuration, migration, and +backend-host safety logic agree on its endpoint and authentication style. + +## Goals + +- Represent HTTP Basic authentication as a stable `basic` wire value. +- Publish Inworld as a built-in OpenAI-compatible cloud provider. +- Classify Inworld's API host as an inference host and its LLM endpoint as + chat-completions-only. +- Preserve existing provider metadata and legacy migrations. + +## Non-goals + +- Store or transmit credentials. +- Implement Inworld model discovery or inference requests. +- Add Inworld voice-provider metadata. + +## Proposed behavior + +`AuthStyle::Basic` serializes as `"basic"` and reports `"basic"` from +`AuthStyle::as_str()`. The built-in catalog contains: + +```text +slug: inworld +label: Inworld +endpoint: https://api.inworld.ai/v1 +auth_style: basic +``` + +Legacy entries with `type: "inworld"` inherit those values when their modern +fields are absent. `api.inworld.ai` is treated as a built-in inference host and +the endpoint remains chat-completions-only. + +## Invariants and constraints + +- The catalog is the single source for built-in host classification. +- Only OpenAI's first-party preset advertises the Responses API. +- Existing auth-style wire values remain unchanged. +- No credential value is stored in this crate's provider metadata. + +## Acceptance criteria + +- All auth styles round-trip through JSON with stable lowercase values. +- Catalog and legacy-migration tests produce the documented Inworld preset. +- Host classification recognizes `api.inworld.ai` and disables the Responses + API fallback for its endpoint. +- The workspace's four contract commands pass. + +## Open questions + +None.