diff --git a/cron/jobs.py b/cron/jobs.py index ca00e08db..9c411f0c7 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -292,6 +292,9 @@ def create_job( origin: Optional[Dict[str, Any]] = None, skill: Optional[str] = None, skills: Optional[List[str]] = None, + model: Optional[str] = None, + provider: Optional[str] = None, + base_url: Optional[str] = None, ) -> Dict[str, Any]: """ Create a new cron job. @@ -305,6 +308,9 @@ def create_job( origin: Source info where job was created (for "origin" delivery) skill: Optional legacy single skill name to load before running the prompt skills: Optional ordered list of skills to load before running the prompt + model: Optional per-job model override + provider: Optional per-job provider override + base_url: Optional per-job base URL override Returns: The created job dict @@ -323,6 +329,13 @@ def create_job( now = _hermes_now().isoformat() normalized_skills = _normalize_skill_list(skill, skills) + normalized_model = str(model).strip() if isinstance(model, str) else None + normalized_provider = str(provider).strip() if isinstance(provider, str) else None + normalized_base_url = str(base_url).strip().rstrip("/") if isinstance(base_url, str) else None + normalized_model = normalized_model or None + normalized_provider = normalized_provider or None + normalized_base_url = normalized_base_url or None + label_source = (prompt or (normalized_skills[0] if normalized_skills else None)) or "cron job" job = { "id": job_id, @@ -330,6 +343,9 @@ def create_job( "prompt": prompt, "skills": normalized_skills, "skill": normalized_skills[0] if normalized_skills else None, + "model": normalized_model, + "provider": normalized_provider, + "base_url": normalized_base_url, "schedule": parsed_schedule, "schedule_display": parsed_schedule.get("display", schedule), "repeat": { diff --git a/cron/scheduler.py b/cron/scheduler.py index 193af7c00..8d75e1a95 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -261,7 +261,7 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: if delivery_target.get("thread_id") is not None: os.environ["HERMES_CRON_AUTO_DELIVER_THREAD_ID"] = str(delivery_target["thread_id"]) - model = os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6" + model = job.get("model") or os.getenv("HERMES_MODEL") or "anthropic/claude-opus-4.6" # Load config.yaml for model, reasoning, prefill, toolsets, provider routing _cfg = {} @@ -272,10 +272,11 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: with open(_cfg_path) as _f: _cfg = yaml.safe_load(_f) or {} _model_cfg = _cfg.get("model", {}) - if isinstance(_model_cfg, str): - model = _model_cfg - elif isinstance(_model_cfg, dict): - model = _model_cfg.get("default", model) + if not job.get("model"): + if isinstance(_model_cfg, str): + model = _model_cfg + elif isinstance(_model_cfg, dict): + model = _model_cfg.get("default", model) except Exception as e: logger.warning("Job '%s': failed to load config.yaml, using defaults: %s", job_id, e) @@ -320,9 +321,12 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]: format_runtime_provider_error, ) try: - runtime = resolve_runtime_provider( - requested=os.getenv("HERMES_INFERENCE_PROVIDER"), - ) + runtime_kwargs = { + "requested": job.get("provider") or os.getenv("HERMES_INFERENCE_PROVIDER"), + } + if job.get("base_url"): + runtime_kwargs["explicit_base_url"] = job.get("base_url") + runtime = resolve_runtime_provider(**runtime_kwargs) except Exception as exc: message = format_runtime_provider_error(exc) raise RuntimeError(message) from exc diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index 1b3e5d547..ad256714a 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -309,6 +309,57 @@ class TestRunJobConfigLogging: f"Expected 'failed to parse prefill messages' warning in logs, got: {[r.message for r in caplog.records]}" +class TestRunJobPerJobOverrides: + def test_job_level_model_provider_and_base_url_overrides_are_used(self, tmp_path): + config_yaml = tmp_path / "config.yaml" + config_yaml.write_text( + "model:\n" + " default: gpt-5.4\n" + " provider: openai-codex\n" + " base_url: https://chatgpt.com/backend-api/codex\n" + ) + + job = { + "id": "briefing-job", + "name": "briefing", + "prompt": "hello", + "model": "perplexity/sonar-pro", + "provider": "custom", + "base_url": "http://127.0.0.1:4000/v1", + } + + fake_db = MagicMock() + fake_runtime = { + "provider": "openrouter", + "api_mode": "chat_completions", + "base_url": "http://127.0.0.1:4000/v1", + "api_key": "***", + } + + with patch("cron.scheduler._hermes_home", tmp_path), \ + patch("cron.scheduler._resolve_origin", return_value=None), \ + patch("dotenv.load_dotenv"), \ + patch("hermes_state.SessionDB", return_value=fake_db), \ + patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_runtime) as runtime_mock, \ + patch("run_agent.AIAgent") as mock_agent_cls: + mock_agent = MagicMock() + mock_agent.run_conversation.return_value = {"final_response": "ok"} + mock_agent_cls.return_value = mock_agent + + success, output, final_response, error = run_job(job) + + assert success is True + assert error is None + assert final_response == "ok" + assert "ok" in output + runtime_mock.assert_called_once_with( + requested="custom", + explicit_base_url="http://127.0.0.1:4000/v1", + ) + assert mock_agent_cls.call_args.kwargs["model"] == "perplexity/sonar-pro" + fake_db.close.assert_called_once() + + class TestRunJobSkillBacked: def test_run_job_loads_skill_and_disables_recursive_cron_tools(self, tmp_path): job = { diff --git a/tests/hermes_cli/test_setup.py b/tests/hermes_cli/test_setup.py index 4d0ab8873..e0abc639c 100644 --- a/tests/hermes_cli/test_setup.py +++ b/tests/hermes_cli/test_setup.py @@ -25,7 +25,11 @@ def test_nous_oauth_setup_keeps_current_model_when_syncing_disk_provider( config = load_config() - prompt_choices = iter([0, 2]) + # Provider selection always comes first. Depending on available vision + # backends, setup may either skip the optional vision step or prompt for + # it before the default-model choice. Provide enough selections for both + # paths while still ending on "keep current model". + prompt_choices = iter([0, 2, 2]) monkeypatch.setattr( "hermes_cli.setup.prompt_choice", lambda *args, **kwargs: next(prompt_choices), diff --git a/tests/tools/test_cronjob_tools.py b/tests/tools/test_cronjob_tools.py index 0e5f90378..293622070 100644 --- a/tests/tools/test_cronjob_tools.py +++ b/tests/tools/test_cronjob_tools.py @@ -137,6 +137,22 @@ class TestScheduleCronjob: )) assert result["repeat"] == "5 times" + def test_schedule_persists_runtime_overrides(self): + result = json.loads(schedule_cronjob( + prompt="Pinned job", + schedule="every 1h", + model="anthropic/claude-sonnet-4", + provider="custom", + base_url="http://127.0.0.1:4000/v1/", + )) + assert result["success"] is True + + listing = json.loads(list_cronjobs()) + job = listing["jobs"][0] + assert job["model"] == "anthropic/claude-sonnet-4" + assert job["provider"] == "custom" + assert job["base_url"] == "http://127.0.0.1:4000/v1" + # ========================================================================= # list_cronjobs @@ -249,6 +265,33 @@ class TestUnifiedCronjobTool: assert updated["job"]["name"] == "New Name" assert updated["job"]["schedule"] == "every 120m" + def test_update_runtime_overrides_can_set_and_clear(self): + created = json.loads( + cronjob( + action="create", + prompt="Check", + schedule="every 1h", + model="anthropic/claude-sonnet-4", + provider="custom", + base_url="http://127.0.0.1:4000/v1", + ) + ) + job_id = created["job_id"] + + updated = json.loads( + cronjob( + action="update", + job_id=job_id, + model="openai/gpt-4.1", + provider="openrouter", + base_url="", + ) + ) + assert updated["success"] is True + assert updated["job"]["model"] == "openai/gpt-4.1" + assert updated["job"]["provider"] == "openrouter" + assert updated["job"]["base_url"] is None + def test_create_skill_backed_job(self): result = json.loads( cronjob( diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 2a40c1636..9ff7127bb 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -103,6 +103,16 @@ def _canonical_skills(skill: Optional[str] = None, skills: Optional[Any] = None) +def _normalize_optional_job_value(value: Optional[Any], *, strip_trailing_slash: bool = False) -> Optional[str]: + if value is None: + return None + text = str(value).strip() + if strip_trailing_slash: + text = text.rstrip("/") + return text or None + + + def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: prompt = job.get("prompt", "") skills = _canonical_skills(job.get("skill"), job.get("skills")) @@ -112,6 +122,9 @@ def _format_job(job: Dict[str, Any]) -> Dict[str, Any]: "skill": skills[0] if skills else None, "skills": skills, "prompt_preview": prompt[:100] + "..." if len(prompt) > 100 else prompt, + "model": job.get("model"), + "provider": job.get("provider"), + "base_url": job.get("base_url"), "schedule": job.get("schedule_display"), "repeat": _repeat_display(job), "deliver": job.get("deliver", "local"), @@ -136,6 +149,9 @@ def cronjob( include_disabled: bool = False, skill: Optional[str] = None, skills: Optional[List[str]] = None, + model: Optional[str] = None, + provider: Optional[str] = None, + base_url: Optional[str] = None, reason: Optional[str] = None, task_id: str = None, ) -> str: @@ -164,6 +180,9 @@ def cronjob( deliver=deliver, origin=_origin_from_env(), skills=canonical_skills, + model=_normalize_optional_job_value(model), + provider=_normalize_optional_job_value(provider), + base_url=_normalize_optional_job_value(base_url, strip_trailing_slash=True), ) return json.dumps( { @@ -240,6 +259,12 @@ def cronjob( canonical_skills = _canonical_skills(skill, skills) updates["skills"] = canonical_skills updates["skill"] = canonical_skills[0] if canonical_skills else None + if model is not None: + updates["model"] = _normalize_optional_job_value(model) + if provider is not None: + updates["provider"] = _normalize_optional_job_value(provider) + if base_url is not None: + updates["base_url"] = _normalize_optional_job_value(base_url, strip_trailing_slash=True) if repeat is not None: repeat_state = dict(job.get("repeat") or {}) repeat_state["times"] = repeat @@ -272,6 +297,9 @@ def schedule_cronjob( name: Optional[str] = None, repeat: Optional[int] = None, deliver: Optional[str] = None, + model: Optional[str] = None, + provider: Optional[str] = None, + base_url: Optional[str] = None, task_id: str = None, ) -> str: return cronjob( @@ -281,6 +309,9 @@ def schedule_cronjob( name=name, repeat=repeat, deliver=deliver, + model=model, + provider=provider, + base_url=base_url, task_id=task_id, ) @@ -343,6 +374,18 @@ Important safety rule: cron-run sessions should not recursively schedule more cr "type": "string", "description": "Delivery target: origin, local, telegram, discord, signal, or platform:chat_id" }, + "model": { + "type": "string", + "description": "Optional per-job model override used when the cron job runs" + }, + "provider": { + "type": "string", + "description": "Optional per-job provider override used when resolving runtime credentials" + }, + "base_url": { + "type": "string", + "description": "Optional per-job base URL override paired with provider/model routing" + }, "include_disabled": { "type": "boolean", "description": "For list: include paused/completed jobs" @@ -407,6 +450,9 @@ registry.register( include_disabled=args.get("include_disabled", False), skill=args.get("skill"), skills=args.get("skills"), + model=args.get("model"), + provider=args.get("provider"), + base_url=args.get("base_url"), reason=args.get("reason"), task_id=kw.get("task_id"), ),