Compare commits

..
2 Commits
Author SHA1 Message Date
bruno b7f29baea5 fix: assistant setup — liste des providers construite depuis le registre (QwenCloud absents de la liste codee en dur, v1.1.9)
release / release (push) Successful in 13m41s
release / macos (push) Canceled after 0s
2026-09-01 11:40:05 -04:00
bruno cf53c98d98 feat: providers QwenCloud v1.1.8 — token-plan (Credits) et pay-as-you-go (Model Studio/DashScope), qwen-code configure par defaut
release / release (push) Successful in 13m39s
release / macos (push) Canceled after 0s
2026-09-01 10:20:07 -04:00
6 changed files with 111 additions and 20 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]] [[package]]
name = "agent-manager" name = "agent-manager"
version = "1.1.7" version = "1.1.9"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "1.1.7" version = "1.1.9"
edition = "2021" edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT" license = "MIT"
+17
View File
@@ -138,6 +138,23 @@
> (`installable: false`, clé API payante seulement), alias `gemini`/`agy` > (`installable: false`, clé API payante seulement), alias `gemini`/`agy`
> redirigés, groupe `mainstream` et gabarit web mis à jour. Import des > redirigés, groupe `mainstream` et gabarit web mis à jour. Import des
> extensions/skills Gemini : `agy plugin import gemini`. > extensions/skills Gemini : `agy plugin import gemini`.
>
> 🔧 **Maintenance v1.1.8 (2026-09-01)** : providers **QwenCloud** ajoutés au
> registre par défaut — `qwen-cloud` (pay-as-you-go, Model Studio/DashScope,
> dashscope-intl…/compatible-mode/v1) et `qwen-cloud-token-plan` (abonnement
> Credits, token-plan.ap-southeast-1…/compatible-mode/v1, modèles restreints
> dont qwen3.8-max). `qwen-code` pointe sur qwen-cloud par défaut (env_map
> OpenAI-compatible OPENAI_API_KEY/BASE_URL/MODEL) avec hints pour basculer
> sur le Token Plan (`--provider qwen-cloud-token-plan`, clé dédiée via
> `am providers set-token`).
>
> 🐛 **Maintenance v1.1.9 (2026-09-01)** : l'assistant `am setup` listait ses
> providers dans une constante codée en dur — les QwenCloud ajoutés au
> registre n'y apparaissaient pas. La liste est maintenant construite depuis
> le registre fusionné (embarqué + config utilisateur), ollama et custom en
> plus, sans doublon. Test de non-régression ajouté. Bonus : le wizard ne
> boucle plus à l'infini sur stdin fermé (erreur claire + suggestion
> `--yes`).
--- ---
+23
View File
@@ -108,6 +108,20 @@ settings:
base_url: https://generativelanguage.googleapis.com/v1beta base_url: https://generativelanguage.googleapis.com/v1beta
default_model: gemini-2.5-pro default_model: gemini-2.5-pro
models: [gemini-2.5-pro, gemini-2.5-flash] models: [gemini-2.5-pro, gemini-2.5-flash]
# QwenCloud (Alibaba) — deux modes de facturation, deux providers :
# - qwen-cloud-token-plan : abonnement Credits (Personal/Team), clé
# dédiée Token Plan, modèles restreints (voir docs.qwencloud.com).
# Base URL internationale (ap-southeast-1) ; la variante Pékin
# token-plan.cn-beijing.maas.aliyuncs.com sert la Chine.
# - qwen-cloud : pay-as-you-go (clé sk- Model Studio / DashScope).
qwen-cloud-token-plan:
base_url: https://token-plan.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1
default_model: qwen3.8-max
models: [qwen3.8-max, qwen3.8-flash, qwen3.7-max, qwen3.7-plus, qwen3.6-flash, deepseek-v4-pro, glm-5.2]
qwen-cloud:
base_url: https://dashscope-intl.aliyuncs.com/compatible-mode/v1
default_model: qwen3.7-plus
models: [qwen3.8-max, qwen3.7-max, qwen3.7-plus, qwen3.6-flash, qwen3-coder-plus, qwen3-coder-next, deepseek-v4-pro]
openrouter: openrouter:
base_url: https://openrouter.ai/api/v1 base_url: https://openrouter.ai/api/v1
default_model: deepseek/deepseek-chat-v3-0324 default_model: deepseek/deepseek-chat-v3-0324
@@ -457,6 +471,15 @@ agents:
dependencies: dependencies:
- { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" } - { name: node, min_version: "18.0.0", install_hint: "https://nodejs.org" }
run: qwen-code run: qwen-code
provider: qwen-cloud
config:
env_map:
api_key: OPENAI_API_KEY
model: OPENAI_MODEL
base_url: OPENAI_BASE_URL
setup_hints:
- "am install qwen-code --provider qwen-cloud-token-plan (abonnement Credits Token Plan au lieu du pay-as-you-go)"
- "am providers set-token qwen-cloud-token-plan (clé dédiée Token Plan ; pay-as-you-go : clé sk- Model Studio)"
tags: [alibaba, qwen] tags: [alibaba, qwen]
- name: grok-build - name: grok-build
+2 -2
View File
@@ -1,6 +1,6 @@
.ie \n(.g .ds Aq \(aq .ie \n(.g .ds Aq \(aq
.el .ds Aq ' .el .ds Aq '
.TH am 1 "am 1.1.7" .TH am 1 "am 1.1.9"
.SH NAME .SH NAME
am \- agent\-manager (am) — manage local AI coding agents am \- agent\-manager (am) — manage local AI coding agents
.SH SYNOPSIS .SH SYNOPSIS
@@ -250,4 +250,4 @@ Export the configuration and installation state (backup)
am\-import(1) am\-import(1)
Import a previously exported configuration and state Import a previously exported configuration and state
.SH VERSION .SH VERSION
v1.1.7 v1.1.9
+67 -16
View File
@@ -14,9 +14,22 @@ use crate::secrets::SecretStore;
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use std::io::Write; use std::io::Write;
/// Known providers offered by the wizard (from the embedded registry plus /// Providers offered by the wizard: every provider defined in the merged
/// the local Ollama runtime and a custom endpoint). /// registry (embedded + user config, sorted), plus the local Ollama runtime
const CHOICES: &[&str] = &["anthropic", "openai", "deepseek", "google", "openrouter", "ollama", "custom"]; /// and a custom endpoint. Built dynamically so new registry providers
/// (e.g. the QwenCloud token-plan / pay-as-you-go pair) show up without a
/// code change — CHOICES used to be a hardcoded list that silently drifted.
fn choices(app: &App) -> Vec<String> {
let mut v: Vec<String> = crate::providers::defined(&app.config)
.map(|(k, _)| k.clone())
.collect();
v.sort();
if !v.iter().any(|p| p == "ollama") {
v.push("ollama".to_string());
}
v.push("custom".to_string());
v
}
/// True when the user has not configured a usable default provider yet: /// True when the user has not configured a usable default provider yet:
/// no `settings.default_provider`, or the default provider has no API /// no `settings.default_provider`, or the default provider has no API
@@ -36,10 +49,14 @@ pub fn needs_setup(app: &App) -> bool {
.is_none() .is_none()
} }
fn read_line() -> Result<String> { /// Read one trimmed line from stdin. `None` on EOF — callers must not spin
/// on empty input when nothing is connected (pipes, --yes in CI).
fn read_line() -> Result<Option<String>> {
let mut line = String::new(); let mut line = String::new();
std::io::stdin().read_line(&mut line)?; if std::io::stdin().read_line(&mut line)? == 0 {
Ok(line.trim().to_string()) return Ok(None);
}
Ok(Some(line.trim().to_string()))
} }
/// Ask a y/N question on stderr, defaulting to yes when `yes` is set. /// Ask a y/N question on stderr, defaulting to yes when `yes` is set.
@@ -50,7 +67,7 @@ fn ask(app: &App, question: &str, default_yes: bool) -> Result<bool> {
let suffix = if default_yes { "[Y/n]" } else { "[y/N]" }; let suffix = if default_yes { "[Y/n]" } else { "[y/N]" };
eprint!("{question} {suffix} "); eprint!("{question} {suffix} ");
std::io::stderr().flush()?; std::io::stderr().flush()?;
let answer = read_line()?.to_lowercase(); let answer = read_line()?.unwrap_or_default().to_lowercase();
Ok(match answer.as_str() { Ok(match answer.as_str() {
"" => default_yes, "" => default_yes,
"y" | "yes" | "o" | "oui" | "true" => true, "y" | "yes" | "o" | "oui" | "true" => true,
@@ -61,9 +78,10 @@ fn ask(app: &App, question: &str, default_yes: bool) -> Result<bool> {
/// Pick a provider from the list (or the current default when --yes). /// Pick a provider from the list (or the current default when --yes).
fn pick_provider(app: &App) -> Result<(String, Option<String>)> { fn pick_provider(app: &App) -> Result<(String, Option<String>)> {
let current = crate::providers::default_name(&app.config); let current = crate::providers::default_name(&app.config);
let choices = choices(app);
println!("Provider disponible :"); println!("Provider disponible :");
for (i, p) in CHOICES.iter().enumerate() { for (i, p) in choices.iter().enumerate() {
let star = if Some(*p) == current { " (actuel)" } else { "" }; let star = if Some(p.as_str()) == current { " (actuel)" } else { "" };
println!(" {}) {}{star}", i + 1, p); println!(" {}) {}{star}", i + 1, p);
} }
if app.cli.yes { if app.cli.yes {
@@ -71,16 +89,18 @@ fn pick_provider(app: &App) -> Result<(String, Option<String>)> {
return Ok((name, None)); return Ok((name, None));
} }
loop { loop {
print!("Choisissez (1-{}) : ", CHOICES.len()); print!("Choisissez (1-{}) : ", choices.len());
std::io::stdout().flush()?; std::io::stdout().flush()?;
let raw = read_line()?; let Some(raw) = read_line()? else {
bail!("aucune entrée sur stdin — relancez en terminal interactif ou utilisez 'am setup --yes'");
};
if let Ok(n) = raw.parse::<usize>() { if let Ok(n) = raw.parse::<usize>() {
if (1..=CHOICES.len()).contains(&n) { if (1..=choices.len()).contains(&n) {
let name = CHOICES[n - 1]; let name = &choices[n - 1];
if name == "custom" { if name == "custom" {
print!("Base URL de l'API (ex: https://api.openai.com/v1) : "); print!("Base URL de l'API (ex: https://api.openai.com/v1) : ");
std::io::stdout().flush()?; std::io::stdout().flush()?;
let url = read_line()?; let url = read_line()?.unwrap_or_default();
if url.is_empty() { if url.is_empty() {
continue; continue;
} }
@@ -126,7 +146,9 @@ fn pick_model(app: &App, name: &str) -> Result<String> {
loop { loop {
print!("Modèle par défaut : "); print!("Modèle par défaut : ");
std::io::stdout().flush()?; std::io::stdout().flush()?;
let raw = read_line()?; let Some(raw) = read_line()? else {
bail!("aucune entrée sur stdin — relancez en terminal interactif ou utilisez 'am setup --yes'");
};
if let Ok(n) = raw.parse::<usize>() { if let Ok(n) = raw.parse::<usize>() {
if n >= 1 && n <= models.len() { if n >= 1 && n <= models.len() {
return Ok(models[n - 1].clone()); return Ok(models[n - 1].clone());
@@ -134,7 +156,7 @@ fn pick_model(app: &App, name: &str) -> Result<String> {
if n == models.len() + 1 { if n == models.len() + 1 {
print!("Nom du modèle : "); print!("Nom du modèle : ");
std::io::stdout().flush()?; std::io::stdout().flush()?;
let custom = read_line()?; let custom = read_line()?.unwrap_or_default();
if !custom.is_empty() { if !custom.is_empty() {
return Ok(custom); return Ok(custom);
} }
@@ -347,6 +369,35 @@ mod tests {
assert!(needs_setup(&app)); assert!(needs_setup(&app));
} }
#[test]
fn wizard_offers_every_registry_provider() {
// Regression (v1.1.8): the wizard used a hardcoded CHOICES list and
// the QwenCloud providers added to the registry never appeared.
// The list must now track the merged registry + ollama + custom.
let app = app_with("");
let c = choices(&app);
for p in [
"anthropic",
"openai",
"deepseek",
"google",
"openrouter",
"qwen-cloud",
"qwen-cloud-token-plan",
"ollama",
"custom",
] {
assert!(c.iter().any(|x| x == p), "wizard must offer {p}: {c:?}");
}
// User-defined providers show up too, ollama is never duplicated.
let app2 = app_with(
" default_provider: anthropic\n providers:\n ollama:\n base_url: http://localhost:11434/v1\n my-llm:\n base_url: https://llm.internal/v1\n",
);
let c2 = choices(&app2);
assert_eq!(c2.iter().filter(|x| *x == "ollama").count(), 1, "{c2:?}");
assert!(c2.iter().any(|x| x == "my-llm"), "{c2:?}");
}
#[test] #[test]
fn needs_setup_false_for_local_ollama() { fn needs_setup_false_for_local_ollama() {
let app = app_with( let app = app_with(