feat(dev): the picker lists Hanzo models, the name is a rule in brand.json, one default

This commit is contained in:
2026-10-07 16:24:18 -07:00
parent 63a0bb114d
commit 2dde1d95ad
11 changed files with 368 additions and 1167 deletions
+5
View File
@@ -30,6 +30,11 @@ checkout by ordinary path dependency.
shows them, and that is the honest picture — the checkout is a build input we
own, not a pristine mirror. `make reset` returns it.
The product's name is a rule, not edits. `patches/brand.json` says where `make prepare`
turns the word upstream calls itself by into ours, in the string literals of non-test code,
and what it leaves alone: a file name, a package, a wire value. A rewording upstream
cannot break it; an anchored edit can.
Every edit carries an exact match count. When upstream moves the ground under
one, preparation fails and names the file rather than branding the wrong line.
That is the signal to re-anchor, and it is why the delta is anchored strings
-1
View File
@@ -9,7 +9,6 @@ pub mod kai;
mod provider;
pub use provider::activate_provider;
pub use provider::clear_hanzo_api_key;
pub use provider::default_model_override;
pub use provider::hanzo_credential;
pub use provider::save_hanzo_api_key;
pub use provider::set_feature;
-11
View File
@@ -223,17 +223,6 @@ pub(super) fn upgrade_auth(home: &Path) {
});
}
/// The model a profile gets when it uses Hanzo and names none: Enso, whatever
/// the catalog lists first. A model the profile names, or another provider, is
/// the profile's own.
pub fn default_model_override(home: &Path) -> Option<String> {
let text = std::fs::read_to_string(home.join("config.toml")).ok()?;
let config: toml::Table = text.parse().ok()?;
(config.get("model_provider").and_then(toml::Value::as_str) == Some("hanzo")
&& !config.contains_key("model"))
.then(|| format!("model=\"{}\"", super::DEFAULT_MODEL))
}
#[cfg(test)]
#[path = "provider_tests.rs"]
mod tests;
-22
View File
@@ -99,28 +99,6 @@ fn a_provider_the_user_changed_is_left_alone() {
assert_eq!(std::fs::read_to_string(&path).unwrap(), theirs);
}
#[test]
fn a_hanzo_profile_without_a_model_gets_enso() {
let home = tempfile::tempdir().expect("tempdir");
std::fs::write(home.path().join("config.toml"), "model_provider = \"hanzo\"\n").expect("write");
assert_eq!(
default_model_override(home.path()).as_deref(),
Some("model=\"enso-auto\"")
);
}
#[test]
fn a_model_the_profile_names_or_another_provider_is_left_alone() {
let home = tempfile::tempdir().expect("tempdir");
let path = home.path().join("config.toml");
std::fs::write(&path, "model_provider = \"hanzo\"\nmodel = \"mine\"\n").expect("write");
assert_eq!(default_model_override(home.path()), None);
std::fs::write(&path, "model_provider = \"pool\"\n").expect("write");
assert_eq!(default_model_override(home.path()), None);
std::fs::remove_file(&path).expect("remove");
assert_eq!(default_model_override(home.path()), None);
}
#[test]
fn the_api_key_name_nothing_reads_is_upgraded_too() {
let home = tempfile::tempdir().expect("tempdir");
+1 -5
View File
@@ -203,13 +203,9 @@ fn main() -> Result<()> {
async fn run(paths: Arg0DispatchPaths) -> Result<()> {
let Dev {
mut interactive,
mut overrides,
overrides,
command,
} = Dev::parse();
// A flag the user passes comes later, so it wins.
if let Some(model) = hanzo_config::default_model_override(&hanzo_config::home()) {
overrides.raw_overrides.insert(0, model);
}
let sandbox = paths.codex_linux_sandbox_exe.clone();
match command {
+22
View File
@@ -0,0 +1,22 @@
//! The model picker is Hanzo's: Enso first, and the bundled OpenAI models known but unlisted.
use codex_protocol::openai_models::ModelVisibility;
#[test]
fn the_picker_leads_with_enso_and_does_not_list_the_bundled_openai_models() {
let models = codex_models_manager::bundled_models_response()
.expect("the bundled catalog parses")
.models;
let mut listed: Vec<_> = models
.iter()
.filter(|model| matches!(model.visibility, ModelVisibility::List))
.collect();
listed.sort_by_key(|model| model.priority);
assert_eq!(listed.first().map(|model| model.slug.as_str()), Some("enso-auto"));
assert!(listed.iter().any(|model| model.slug == "zen5"));
assert!(listed.iter().all(|model| !model.slug.starts_with("gpt-")));
assert!(
models.iter().any(|model| model.slug == "gpt-6-astra"),
"an OpenAI model stays known, so a session that names one still resolves"
);
}
+264
View File
@@ -0,0 +1,264 @@
//! The product's name, wherever upstream prints its own.
//!
//! Anchored edits in `patches/upstream.json` cover wording that has to change in
//! one particular way. The name is a rule instead: in a string literal of
//! non-test code, the word upstream calls itself by becomes ours. A rule survives
//! an upstream that rewords a message, where an anchor would stop preparation at
//! every bump. `patches/brand.json` says where it applies and what it leaves
//! alone: names a file, a package or a wire value really carries.
use anyhow::Context;
use anyhow::Result;
use serde::Deserialize;
use std::path::Path;
use std::path::PathBuf;
#[derive(Deserialize)]
struct Rule {
/// Directories under the checkout where the rule applies.
roots: Vec<String>,
/// A file whose path contains any of these is left alone.
skip_files: Vec<String>,
/// A literal containing any of these is left alone.
skip_literals: Vec<String>,
/// Whole phrases, replaced before the word itself.
phrases: Vec<[String; 2]>,
word: String,
to: String,
}
/// Rebrand the checkout and say how many literals changed.
pub fn run(root: &Path, source: &Path) -> Result<usize> {
let rule: Rule = serde_json::from_slice(&std::fs::read(root.join("patches/brand.json"))?)
.context("patches/brand.json")?;
let mut changed = 0;
for directory in &rule.roots {
for path in rust_files(&source.join(directory))? {
let relative = path.strip_prefix(source).unwrap_or(&path).to_string_lossy().into_owned();
if is_test_path(&relative) || rule.skip_files.iter().any(|skip| relative.contains(skip)) {
continue;
}
let text = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
if let Some((edited, count)) = rebrand(&rule, &text) {
std::fs::write(&path, edited)?;
changed += count;
}
}
}
Ok(changed)
}
fn rust_files(directory: &Path) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut pending = vec![directory.to_path_buf()];
while let Some(next) = pending.pop() {
let Ok(entries) = std::fs::read_dir(&next) else {
continue;
};
for entry in entries {
let path = entry?.path();
if path.is_dir() {
pending.push(path);
} else if path.extension().is_some_and(|extension| extension == "rs") {
files.push(path);
}
}
}
files.sort();
Ok(files)
}
fn is_test_path(path: &str) -> bool {
path.ends_with("_test.rs")
|| path.ends_with("_tests.rs")
|| path.ends_with("/tests.rs")
|| path.contains("/test/")
|| path.contains("/tests/")
|| path.contains("snapshots")
|| path.contains("test_support")
|| path.contains("test_fixtures")
}
/// Where a file's test code starts: everything from here on is left alone.
fn starts_tests(line: &str) -> bool {
let line = line.trim_start();
line.starts_with("#[cfg(test)]")
|| line
.strip_prefix("mod tests")
.is_some_and(|rest| rest.chars().next().is_none_or(|next| !is_word(next)))
}
fn is_word(character: char) -> bool {
character.is_ascii_alphanumeric() || character == '_'
}
/// The text with its literals rebranded, and how many changed.
fn rebrand(rule: &Rule, text: &str) -> Option<(String, usize)> {
let mut out = String::with_capacity(text.len());
let mut count = 0;
let mut in_tests = false;
for line in text.split_inclusive('\n') {
in_tests = in_tests || starts_tests(line);
let mentions = line.contains(&rule.word) || rule.phrases.iter().any(|[from, _]| line.contains(from));
if in_tests || line.trim_start().starts_with("//") || !mentions {
out.push_str(line);
continue;
}
let mut rest = 0;
for (start, end) in literals(line) {
let literal = &line[start..end];
if rule.skip_literals.iter().any(|skip| literal.contains(skip)) {
continue;
}
let new = name(rule, literal);
if new != literal {
out.push_str(&line[rest..start]);
out.push_str(&new);
rest = end;
count += 1;
}
}
out.push_str(&line[rest..]);
}
(count > 0).then_some((out, count))
}
/// The byte ranges of the string literals on one line: a quote, then anything
/// but a quote or a backslash, or a backslash and the character it escapes, up
/// to the closing quote. An opening quote with no closing one on the line is not
/// a literal.
fn literals(line: &str) -> Vec<(usize, usize)> {
let bytes = line.as_bytes();
let mut found = Vec::new();
let mut at = 0;
while at < bytes.len() {
// The character `'"'` is a quote that opens nothing.
if bytes[at..].starts_with(b"'\"'") {
at += 3;
continue;
}
if bytes[at] != b'"' {
at += 1;
continue;
}
let mut end = at + 1;
let mut closed = false;
while end < bytes.len() {
match bytes[end] {
b'"' => {
closed = true;
break;
}
b'\\' if end + 1 < bytes.len() && bytes[end + 1] != b'\n' => end += 2,
b'\\' | b'\n' => break,
_ => end += 1,
}
}
if closed {
found.push((at, end + 1));
at = end + 1;
} else {
at += 1;
}
}
found
}
fn name(rule: &Rule, literal: &str) -> String {
let mut text = literal.to_string();
for [from, to] in &rule.phrases {
text = text.replace(from, to);
}
let mut out = String::with_capacity(text.len());
let mut last = 0;
for (at, _) in text.match_indices(&rule.word) {
let before = text[..at].chars().next_back();
let after = text[at + rule.word.len()..].chars().next();
let starts_word = before.is_some_and(is_word) && !text[..at].ends_with("\\n");
if starts_word || after.is_some_and(is_word) {
continue;
}
out.push_str(&text[last..at]);
out.push_str(&rule.to);
last = at + rule.word.len();
}
out.push_str(&text[last..]);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn rule() -> Rule {
Rule {
roots: vec![],
skip_files: vec![],
skip_literals: vec!["Codex Apps".into(), "## ".into()],
phrases: vec![["OpenAI Codex".into(), "Hanzo Dev".into()], ["`codex ".into(), "`dev ".into()]],
word: "Codex".into(),
to: "Dev".into(),
}
}
fn brand(text: &str) -> String {
rebrand(&rule(), text).map_or_else(|| text.to_string(), |(edited, _)| edited)
}
#[test]
fn the_word_in_a_literal_becomes_ours() {
assert_eq!(brand("let a = \"Codex is ready\";\n"), "let a = \"Dev is ready\";\n");
assert_eq!(brand("x(\"OpenAI Codex v1\")\n"), "x(\"Hanzo Dev v1\")\n");
assert_eq!(brand("x(\"run `codex login`\")\n"), "x(\"run `dev login`\")\n");
}
#[test]
fn an_identifier_is_not_a_name() {
let code = "let a = CodexAuth::new(\"CodexOp\", \"my_Codex\", \"Codex2\");\n";
assert_eq!(brand(code), code);
}
#[test]
fn a_word_after_a_line_break_in_a_literal_is_a_name() {
assert_eq!(brand("x(\"one\\nCodex exits\")\n"), "x(\"one\\nDev exits\")\n");
}
#[test]
fn a_literal_that_names_a_real_thing_is_left_alone() {
let code = "x(\"Codex Apps tools\", \"y\");\nx(\"## My request for Codex:\");\n";
assert_eq!(brand(code), code);
}
#[test]
fn only_the_literal_changes_not_the_code_around_it() {
assert_eq!(
brand("fn f(codex: Codex) { g(\"Codex\", Codex::new()); }\n"),
"fn f(codex: Codex) { g(\"Dev\", Codex::new()); }\n"
);
}
#[test]
fn comments_and_tests_are_left_alone() {
let code = "// say \"Codex\"\nlet a = 1;\n#[cfg(test)]\nmod tests {\n let b = \"Codex\";\n}\n";
assert_eq!(brand(code), code);
let code = "mod tests_support_free {}\nlet a = \"Codex\";\n";
assert_eq!(brand(code), "mod tests_support_free {}\nlet a = \"Dev\";\n");
}
#[test]
fn a_quote_that_never_closes_is_not_a_literal() {
let code = "let c = '\"'; let s = \"Codex\";\n";
let found = literals(code);
assert_eq!(found.len(), 1);
assert_eq!(&code[found[0].0..found[0].1], "\"Codex\"");
let continued = "push_str(\"first\\nCodex exits\\\n";
assert!(literals(continued).is_empty());
}
#[test]
fn a_second_pass_changes_nothing() {
let once = brand("a(\"Codex\"); b(\"`codex x`\");\n");
assert!(rebrand(&rule(), &once).is_none());
}
}
+1
View File
@@ -3,6 +3,7 @@
//! The only Hanzo edits to upstream are the anchored substitutions in
//! `patches/upstream.json`, applied to the submodule checkout in place.
mod brand;
mod bump;
mod prepare;
mod v8;
+2 -1
View File
@@ -75,8 +75,9 @@ pub fn run(arguments: &[String]) -> Result<()> {
}
}
let names = crate::brand::run(root, &source)?;
let revision = crate::git(&source, &["rev-parse", "HEAD"])?;
println!("upstream {}: {applied} edits applied", &revision[..12]);
println!("upstream {}: {applied} edits applied, {names} names", &revision[..12]);
Ok(())
}
+62
View File
@@ -0,0 +1,62 @@
{
"roots": [
"tui/src",
"cli/src",
"exec/src",
"utils/cli/src",
"app-server-daemon/src",
"app-server/src",
"core/src",
"model-provider/src",
"login/src",
"config/src",
"state/src",
"sandboxing/src",
"codex-mcp/src",
"core-plugins/src"
],
"skip_files": [
"cli/src/doctor/security.rs",
"cli/src/doctor/desktop",
"cli/src/desktop_app",
"cli/src/doctor/updates.rs",
"cli/src/bin/",
"tui/src/task_mentions.rs",
"tui/src/ide_context/prompt.rs",
"tui/src/async_question_reply.rs",
"tui/src/goal_files.rs",
"tui/src/thread_transcript.rs",
"tui/src/chatwidget/replay.rs",
"tui/src/analytics/",
"tui/src/config_update.rs",
"login/src/auth/",
"login/src/auth_env",
"login/src/gateway",
"core/src/tools/handlers/test_sync_spec.rs",
"core-plugins/src/command_migration",
"app-server/src/request_processors/initialize_processor.rs"
],
"skip_literals": [
"Codex Apps",
"Codex Desktop",
"Codex Memo",
"Codex-managed Bedrock",
"Codex.app",
"Codex.dmg",
"Codex.exe",
"OpenAI.Codex",
"Codex Auth",
"Codex/Logs",
"dotCodex",
"persistent.oaistatic",
"CODEX_",
"## "
],
"phrases": [
["OpenAI Codex", "Hanzo Dev"],
["`codex ", "`dev "],
["`codex`", "`dev`"]
],
"word": "Codex",
"to": "Dev"
}
+11 -1127
View File
File diff suppressed because it is too large Load Diff