mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
feat(dev): the picker lists Hanzo models, the name is a rule in brand.json, one default
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user