Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rotom"
version = "1.5.6"
version = "1.5.7"
edition = "2024"
rust-version = "1.86"
description = "OpenAI- and Anthropic-compatible local API gateway backed by OAuth providers."
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Claude Code running through Grok and GPT:
<img src="demos/claude-grok-4.3.gif" alt="Claude Code using grok-4.3 through rotom" width="720">
</p>
<p align="center">
<img src="demos/claude-gpt-5.5.gif" alt="Claude Code using gpt-5.5 through rotom" width="720">
<img src="demos/claude-gpt-5.5.gif" alt="Claude Code using GPT through rotom" width="720">
</p>

## Quick Start
Expand All @@ -38,7 +38,7 @@ Point Claude Code (or any Anthropic-compatible client) at the gateway:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:14550
export ANTHROPIC_AUTH_TOKEN=local-secret
export ANTHROPIC_MODEL="gpt-5.5"
export ANTHROPIC_MODEL="gpt-5.6-sol"

claude
```
Expand All @@ -51,7 +51,7 @@ claude -p "Reply with the single word OK"

> Point `ANTHROPIC_BASE_URL` at the server root, **not** `/v1` — clients append
> `/v1/messages` themselves. `ANTHROPIC_AUTH_TOKEN` is your local `--api-key`,
> not an upstream token. Use a model that `/v1/models` lists (e.g. `gpt-5.5`).
> not an upstream token. Use a model that `/v1/models` lists (e.g. `gpt-5.6-sol`).

## Logging In

Expand Down Expand Up @@ -86,9 +86,10 @@ rotom models # everything rotom exposes
rotom models --provider grok # one provider
```

Common ids include `gpt-5.5`, `grok-4.3`, Kiro's `claude-*` family, and
`cursor/auto`. rotom fetches the live registry where the provider supports it
and falls back to built-in aliases otherwise.
Common ids include `gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna`,
`grok-4.3`, Kiro's `claude-*` family, and `cursor/auto`. rotom fetches the
live registry where the provider supports it and falls back to built-in aliases
otherwise.

Unknown Anthropic ids like `claude-sonnet-*` are rewritten to a fallback
(default `gpt-5.5`). Override with `--model-fallback` or `ROTOM_MODEL_FALLBACK`.
Expand All @@ -101,7 +102,7 @@ OpenAI-compatible:
curl http://127.0.0.1:14550/v1/chat/completions \
-H 'content-type: application/json' \
-H 'authorization: Bearer local-secret' \
-d '{"model": "gpt-5.5", "messages": [{"role": "user", "content": "hello"}]}'
-d '{"model": "gpt-5.6-sol", "messages": [{"role": "user", "content": "hello"}]}'
```

Anthropic-compatible:
Expand All @@ -111,7 +112,7 @@ curl http://127.0.0.1:14550/v1/messages \
-H 'content-type: application/json' \
-H 'x-api-key: local-secret' \
-H 'anthropic-version: 2023-06-01' \
-d '{"model": "gpt-5.5", "max_tokens": 1024, "messages": [{"role": "user", "content": "hello"}]}'
-d '{"model": "gpt-5.6-sol", "max_tokens": 1024, "messages": [{"role": "user", "content": "hello"}]}'
```

## Running As a Service
Expand Down
5 changes: 3 additions & 2 deletions src/codex/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,8 @@ fn clamp_reasoning_effort(model: &str, effort: &str) -> String {
if (id.starts_with("gpt-5.2")
|| id.starts_with("gpt-5.3")
|| id.starts_with("gpt-5.4")
|| id.starts_with("gpt-5.5"))
|| id.starts_with("gpt-5.5")
|| id.starts_with("gpt-5.6"))
&& effort == "minimal"
{
"low".to_owned()
Expand Down Expand Up @@ -486,7 +487,7 @@ mod tests {
#[test]
fn does_not_forward_unsupported_sampling_controls() {
let body = to_codex_request(&request(json!({
"model": "gpt-5.5",
"model": "gpt-5.6-terra",
"messages": [{"role": "user", "content": "hello"}],
"temperature": 0.2,
"top_p": 0.7,
Expand Down
5 changes: 2 additions & 3 deletions src/codex/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1098,15 +1098,14 @@ fn cursor_builtin_exec_decline(exec: &ExecServerMessage) -> Option<AgentClientMe
error: reason,
}),
});
} else if let Some(fetch) = exec.fetch_args.as_ref() {
} else {
let fetch = exec.fetch_args.as_ref()?;
message.fetch_result = Some(FetchResult {
error: Some(FetchError {
url: fetch.url.clone(),
error: reason,
}),
});
} else {
return None;
}

Some(AgentClientMessage {
Expand Down
4 changes: 4 additions & 0 deletions src/main_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ fn resolve_served_providers(
return Ok(vec![value.parse()?]);
}

if let Some(provider) = config.and_then(|item| item.provider) {
return Ok(vec![provider]);
}

let mut providers = store
.load_all()?
.into_iter()
Expand Down
94 changes: 91 additions & 3 deletions src/main_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,44 @@ use super::{
build_update_command, credential_subject, daemon_endpoint_lines, format_bind_addresses,
format_login_provider_choice, format_models, new_provider_daemon_restart_hint,
parse_bind_hosts, parse_login_provider_choice, resolve_model_fallback,
resolve_status_providers, rotom_version_line, token_expiry_message,
resolve_served_providers, resolve_status_providers, rotom_version_line, token_expiry_message,
};
use clap::Parser;
use rotom::config::{Credentials, Provider, now_unix};
use rotom::config::{AuthStore, Credentials, Provider, now_unix};
use rotom::timefmt::format_duration;
use std::{
fs,
path::PathBuf,
time::{SystemTime, UNIX_EPOCH},
};

struct TestAuthStore {
store: AuthStore,
path: PathBuf,
}

impl TestAuthStore {
fn new() -> Self {
let path = std::env::temp_dir().join(format!(
"rotom-main-test-{}-{}.json",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
Self {
store: AuthStore::new(path.clone()),
path,
}
}
}

impl Drop for TestAuthStore {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}

#[test]
fn reuses_shared_duration_formatting() {
Expand Down Expand Up @@ -161,11 +194,66 @@ fn prefers_explicit_model_fallback_over_default() {
);
}

#[test]
fn served_provider_prefers_cli_provider() {
let auth = TestAuthStore::new();
save_provider_credentials(&auth.store, Provider::Codex);
save_provider_credentials(&auth.store, Provider::Cursor);
let config = AppConfig {
provider: Some(Provider::Cursor),
..AppConfig::default()
};

let providers =
resolve_served_providers(&auth.store, Some("grok".into()), Some(&config)).unwrap();

assert_eq!(providers, [Provider::Grok]);
}

#[test]
fn served_provider_prefers_config_provider_over_saved_providers() {
let auth = TestAuthStore::new();
save_provider_credentials(&auth.store, Provider::Codex);
save_provider_credentials(&auth.store, Provider::Cursor);
let config = AppConfig {
provider: Some(Provider::Codex),
..AppConfig::default()
};

let providers = resolve_served_providers(&auth.store, None, Some(&config)).unwrap();

assert_eq!(providers, [Provider::Codex]);
}

#[test]
fn served_provider_uses_saved_providers_without_selection() {
let auth = TestAuthStore::new();
save_provider_credentials(&auth.store, Provider::Cursor);
save_provider_credentials(&auth.store, Provider::Codex);

let providers = resolve_served_providers(&auth.store, None, None).unwrap();

assert_eq!(providers, [Provider::Codex, Provider::Cursor]);
}

fn save_provider_credentials(store: &AuthStore, provider: Provider) {
store
.save(&Credentials {
provider,
access_token: "access".into(),
refresh_token: "refresh".into(),
expires_at: now_unix() + 90,
account_id: String::new(),
})
.unwrap();
}

#[test]
fn formats_models_grouped_by_provider() {
let output = format_models(&[Provider::Codex, Provider::Grok]);

assert!(output.contains("OpenAI (codex)\n gpt-5.1"));
assert!(output.contains(" gpt-5.6-sol\n"));
assert!(output.contains("Grok (grok)\n grok-4.3"));
assert!(!output.contains("Kiro (kiro)"));
assert!(output.contains("\n\nGrok (grok)"));
Expand All @@ -177,7 +265,7 @@ fn formats_single_provider_models() {

assert!(output.starts_with("Grok (grok)\n"));
assert!(output.contains(" grok-4\n"));
assert!(!output.contains("gpt-5.5"));
assert!(!output.contains("gpt-5.6-sol"));
}

#[test]
Expand Down
51 changes: 35 additions & 16 deletions src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ pub const OPENCLAW_CODEX_MODELS: &[&str] = &[
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.5",
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
];

/// Default Grok model identifiers exposed by the project.
Expand Down Expand Up @@ -210,13 +213,7 @@ fn highlight_model_rank(provider: Provider, id: &str) -> Option<ModelRank> {

let (major, minor) = strongest_version(normalized)?;
let family = match provider {
Provider::Codex => {
if normalized.starts_with("gpt-") {
80
} else {
return None;
}
}
Provider::Codex => codex_family_rank(normalized)?,
Provider::Grok => {
if normalized.starts_with("grok-") {
80
Expand Down Expand Up @@ -286,6 +283,22 @@ fn is_lightweight_highlight_variant(id: &str) -> bool {
|| id.contains("-spark")
}

fn codex_family_rank(id: &str) -> Option<u16> {
if !id.starts_with("gpt-") {
return None;
}

if id.starts_with("gpt-5.6-sol") {
Some(100)
} else if id.starts_with("gpt-5.6-terra") {
Some(95)
} else if id.starts_with("gpt-5.6-luna") {
Some(90)
} else {
Some(80)
}
}

fn kiro_family_rank(id: &str) -> Option<u16> {
if id.starts_with("claude-fable-") {
Some(105)
Expand Down Expand Up @@ -413,19 +426,22 @@ mod tests {
}

#[test]
fn defaults_include_gpt_55_models() {
fn defaults_include_gpt_56_models() {
let ids = resolve_model_ids();

assert!(ids.iter().any(|id| id == "gpt-5.5"));
assert!(!ids.iter().any(|id| id == "gpt-5.5-mini"));
assert!(ids.iter().any(|id| id == "gpt-5.6-sol"));
assert!(ids.iter().any(|id| id == "gpt-5.6-terra"));
assert!(ids.iter().any(|id| id == "gpt-5.6-luna"));
assert!(!ids.iter().any(|id| id == "gpt-5.6"));
assert!(!ids.iter().any(|id| id == "gpt-5.6-mini"));
}

#[test]
fn grok_defaults_include_grok_models() {
let ids = resolve_model_ids_for_provider(Provider::Grok);

assert!(ids.iter().any(|id| id == "grok-4.3"));
assert!(!ids.iter().any(|id| id == "gpt-5.5"));
assert!(!ids.iter().any(|id| id == "gpt-5.6-sol"));
}

#[test]
Expand Down Expand Up @@ -454,10 +470,10 @@ mod tests {
assert_eq!(
highlight_model_ids_for_provider(Provider::Codex, &ids),
vec![
"gpt-5.6-sol".to_owned(),
"gpt-5.6-terra".to_owned(),
"gpt-5.6-luna".to_owned(),
"gpt-5.5".to_owned(),
"gpt-5.4".to_owned(),
"gpt-5.3-codex".to_owned(),
"gpt-5.2-codex".to_owned(),
]
);
}
Expand Down Expand Up @@ -549,7 +565,10 @@ mod tests {
assert_eq!(provider_for_model("xai/grok-4.3"), Provider::Grok);
assert_eq!(provider_for_model("grok/grok-4.3"), Provider::Grok);
assert_eq!(provider_for_model("openai-codex/grok-4.3"), Provider::Grok);
assert_eq!(provider_for_model("openai-codex/gpt-5.5"), Provider::Codex);
assert_eq!(
provider_for_model("openai-codex/gpt-5.6-luna"),
Provider::Codex
);
assert_eq!(provider_for_model("kiro/auto"), Provider::Kiro);
assert_eq!(provider_for_model("cursor/gpt-5"), Provider::Cursor);
assert_eq!(provider_for_model("sonnet-4"), Provider::Cursor);
Expand All @@ -574,7 +593,7 @@ mod tests {
.map(|model| model.owned_by)
};

assert_eq!(owner_for("gpt-5.5"), Some("openai-codex"));
assert_eq!(owner_for("gpt-5.6-sol"), Some("openai-codex"));
assert_eq!(owner_for("grok-4.3"), Some("xai"));
assert_eq!(owner_for("auto"), Some("kiro"));
assert_eq!(owner_for("cursor/gpt-5"), Some("cursor"));
Expand Down
Loading