diff --git a/.opencode/agent/code-reviewer.md b/.opencode/agent/code-reviewer.md index 85b4f67..66717bf 100644 --- a/.opencode/agent/code-reviewer.md +++ b/.opencode/agent/code-reviewer.md @@ -1,6 +1,5 @@ --- description: Elite code review specialist — security vulnerabilities, performance, correctness, production reliability. Use PROACTIVELY for code quality assurance within the Samourai pipeline. -model: inherit temperature: 0.2 reasoningEffort: high tools: diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index fdd86b5..a210000 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -9,11 +9,11 @@ // Fallback model used by any agent without an explicit "model" in the "agent" section. // Format: "provider/model-id" - "model": "github-copilot/gpt-5.2", + "model": "openrouter/deepseek/deepseek-chat-v3-0324", // Lightweight model for non-critical utility tasks: // session title generation, short summaries, metadata. - "small_model": "github-copilot/gpt-5-mini", + "small_model": "openrouter/openai/gpt-5-nano", // ─── Global Instructions ─────────────────────────────────────────────────── @@ -251,7 +251,7 @@ // Full access required to read/write artifacts, call sub-agents, // and interact with trackers (GitHub MCP when enabled). "pm": { - "model": "github-copilot/claude-opus-4.6", + "model": "openrouter/deepseek/deepseek-v4-pro", "steps": 200, // long orchestration: many possible steps "permission": { "bash": "allow", @@ -271,7 +271,7 @@ // @coder: implements plan phases. Reads, writes, and runs commands. // No web access — works exclusively within the repository. "coder": { - "model": "github-copilot/gpt-5.3-codex", + "model": "openrouter/deepseek/deepseek-chat-v3-0324", "steps": 220, // multi-phase implementation: many file operations "permission": { "bash": "allow", // run commands (tests, build, typecheck) @@ -288,7 +288,7 @@ // @fixer: fixes bugs and test failures using root-cause analysis. // Similar access to @coder but fewer steps (targeted investigation). "fixer": { - "model": "github-copilot/gpt-5.3-codex", + "model": "openrouter/deepseek/deepseek-chat-v3-0324", "steps": 120, // targeted debugging — should not wander too long "permission": { "bash": "allow", @@ -303,7 +303,7 @@ // @refactor: performs safe, incremental code refactoring. // Preserves behavior and validates with tests. "refactor": { - "model": "github-copilot/gpt-5.3-codex", // same model as @coder + "model": "openrouter/deepseek/deepseek-chat-v3-0324", // same model as @coder "steps": 60, "permission": { "bash": "allow", @@ -322,7 +322,7 @@ // @tdd-orchestrator: enforces the red-green-refactor cycle for a task. // Delegated by @coder in /run-plan when the tdd-orchestrator skill is active. "tdd-orchestrator": { - "model": "github-copilot/gpt-5.3-codex", + "model": "openrouter/deepseek/deepseek-chat-v3-0324", "steps": 60, // RED→GREEN→REFACTOR cycle × N behaviors "permission": { "bash": "allow", // run tests at each step of the cycle @@ -340,7 +340,7 @@ // @reviewer: validates code against spec + plan + quality heuristics. // May modify the plan (adding a remediation phase) but not the source code. "reviewer": { - "model": "github-copilot/gpt-5.3-codex", + "model": "openrouter/deepseek/deepseek-chat-v3-0324", "temperature": 0.2, // deterministic review: reduces variability in findings "steps": 30, // diff reading + report = bounded task "permission": { @@ -359,7 +359,7 @@ // @code-reviewer: targeted diff analysis — security, correctness, performance. // Delegated by @reviewer and /git-workflow. Strict read-only: modifies NOTHING. "code-reviewer": { - "model": "github-copilot/gpt-5.3-codex", + "model": "openrouter/deepseek/deepseek-chat-v3-0324", "temperature": 0.2, // consistent and reproducible findings "steps": 20, // bounded analysis — no action, report only "permission": { @@ -377,7 +377,7 @@ // @gap-analysis: artifact traceability analysis (spec/plan/test/code gaps). // Similar to @code-reviewer but focused on lifecycle coverage. "gap-analysis": { - "model": "github-copilot/gpt-5.3-codex", // same model as @reviewer + "model": "openrouter/deepseek/deepseek-chat-v3-0324", // same model as @reviewer "temperature": 0.2, // deterministic analysis for reproducible reports "steps": 25, "permission": { @@ -395,7 +395,7 @@ // @verdict: final GO/NO-GO evaluator based on gates and evidence. "verdict": { - "model": "github-copilot/gpt-5.3-codex", // same model as @reviewer + "model": "openrouter/deepseek/deepseek-chat-v3-0324", // same model as @reviewer "temperature": 0.1, // highly deterministic for consistent verdicts "steps": 20, "permission": { @@ -412,7 +412,7 @@ // @jira-analysis: analyzes ticket quality and extracts requirements. "jira-analysis": { - "model": "github-copilot/gpt-5.3-codex", // same model as @reviewer + "model": "openrouter/deepseek/deepseek-chat-v3-0324", // same model as @reviewer "temperature": 0.2, "steps": 20, "permission": { @@ -429,7 +429,7 @@ // @review-feedback-applier: applies review feedback after human validation. "review-feedback-applier": { - "model": "github-copilot/gpt-5.2-codex", + "model": "openrouter/deepseek/deepseek-chat-v3-0324", "steps": 30 // To enable GitHub MCP (read PR comments): // "tools": { "github*": true } @@ -439,7 +439,7 @@ // @committer: creates exactly one Conventional Commit. Simple and fast task. "committer": { - "model": "github-copilot/gpt-5-mini", // lightweight model sufficient for git operations + "model": "openrouter/openai/gpt-5-nano", // lightweight model sufficient for git operations "steps": 15, // git add + diff analysis + commit = few steps "permission": { "bash": "allow", // git status, git add, git commit @@ -451,7 +451,7 @@ // @pr-manager: creates/updates the PR and stops before merge. "pr-manager": { - "model": "github-copilot/claude-sonnet-4.5", + "model": "openrouter/deepseek/deepseek-chat", "steps": 20 // To enable GitHub MCP (create PR via API): // "tools": { "github*": true } @@ -461,21 +461,21 @@ // @spec-writer: writes the change specification (chg-xxx-spec.md). "spec-writer": { - "model": "github-copilot/claude-sonnet-4.6", // Claude: best for structured writing + "model": "openrouter/deepseek/deepseek-chat", // Claude: best for structured writing "steps": 30, "tools": { "socraticode*": true } // explore existing code before writing spec }, // @test-plan-writer: writes the test plan (chg-xxx-test-plan.md). "test-plan-writer": { - "model": "github-copilot/gpt-5.2", + "model": "openrouter/qwen/qwen3-plus", "steps": 25, "tools": { "socraticode*": true } // find existing test patterns and coverage }, // @plan-writer: writes the implementation plan (chg-xxx-plan.md). "plan-writer": { - "model": "github-copilot/gpt-5.2", + "model": "openrouter/qwen/qwen3-plus", "steps": 50, "tools": { "socraticode*": true } // understand codebase structure before planning }, @@ -483,7 +483,7 @@ // @doc-syncer: synchronizes system documentation with delivered code. // Reads the diff and updates doc/spec/. No web access. "doc-syncer": { - "model": "github-copilot/claude-sonnet-4.5", + "model": "openrouter/deepseek/deepseek-chat", "steps": 20, "permission": { "bash": "allow", // git diff to see what changed @@ -499,7 +499,7 @@ // @runner: executes commands (build, tests, lint) and logs the results. // Does not modify source files — read and execute only. "runner": { - "model": "github-copilot/gpt-5-mini", // execution task, no complex reasoning required + "model": "openrouter/openai/gpt-5-nano", // execution task, no complex reasoning required "steps": 25, "permission": { "bash": "allow", // run build, tests, lint, quality gates @@ -514,7 +514,7 @@ // @architect: technical and architectural decisions. Produces ADRs. // Most powerful model for complex reasoning. "architect": { - "model": "github-copilot/gpt-5.4", // best model for architectural thinking + "model": "openrouter/deepseek/deepseek-v4-pro", // best model for architectural thinking "steps": 40, "color": "secondary", "tools": { "socraticode*": true } // graph/impact for architectural analysis @@ -522,13 +522,13 @@ // @bootstrapper: onboards a new project into the Samourai ecosystem. "bootstrapper": { - "model": "github-copilot/claude-sonnet-4.6", + "model": "openrouter/deepseek/deepseek-chat", "steps": 30 }, // @toolsmith: creates and improves kit tools, agents, and commands. "toolsmith": { - "model": "github-copilot/gpt-5.3-codex", + "model": "openrouter/deepseek/deepseek-chat-v3-0324", "steps": 40 }, @@ -536,26 +536,26 @@ // @editor: writes and translates user-facing content per copywriting.md. "editor": { - "model": "github-copilot/claude-sonnet-4.6", // Claude: best for natural language + "model": "openrouter/deepseek/deepseek-chat", // Claude: best for natural language "steps": 20 }, // @designer: UI/UX design, aligned with the design system. "designer": { - "model": "github-copilot/gpt-4o", // GPT-4o: vision + design + "model": "openrouter/openai/gpt-4.1-nano", // GPT-4o: vision + design "steps": 20, "tools": { "puppeteer*": true } // enables Puppeteer MCP for UI screenshots }, // @image-generator: image generation via text-to-image. "image-generator": { - "model": "github-copilot/gpt-4o", + "model": "openrouter/openai/gpt-4.1-nano", "steps": 10 // single generation = few steps }, // @image-reviewer: analyzes screenshots and visual artifacts. Read-only. "image-reviewer": { - "model": "github-copilot/gpt-4o", // GPT-4o: multimodal (vision) + "model": "openrouter/openai/gpt-4.1-nano", // GPT-4o: multimodal (vision) "steps": 10, "permission": { "edit": "deny", // analysis only, modifies nothing @@ -568,7 +568,7 @@ // @external-researcher: research via web and MCP (context7, deepwiki, etc.). // Only agent authorized to leave the repository — webfetch and websearch enabled. "external-researcher": { - "model": "github-copilot/gemini-3.1-pro-preview", // Gemini: large context window for research + "model": "openrouter/google/gemini-2.5-flash", // Gemini: large context window for research "steps": 30, "permission": { "webfetch": "allow", // fetch web pages and documentation @@ -584,7 +584,7 @@ // Agent Samourai distinct des agents internes du pipeline Kairos Mesh. // Accède à l'API backend locale pour les données de marché si disponible. "trading-analyst": { - "model": "github-copilot/claude-sonnet-4.6", // Claude: raisonnement nuancé pour l'analyse financière + "model": "openrouter/deepseek/deepseek-chat", // Claude: raisonnement nuancé pour l'analyse financière "steps": 40, "temperature": 0.2, // déterministe: analyse prudente, évite la sur-créativité "permission": { @@ -605,7 +605,7 @@ // Lit/modifie les skills (SKILL.md), les prompts DB via API, et la config LLM. // ⚠️ Agents Binding (risk-manager, execution-manager): demander confirmation avant activation. "kairos-agent-manager": { - "model": "github-copilot/claude-sonnet-4.6", // Claude: analyse structurée des prompts + skills + "model": "openrouter/deepseek/deepseek-chat", // Claude: analyse structurée des prompts + skills "steps": 50, "temperature": 0.2, // prudent: modifications ciblées, pas de sur-créativité "permission": { diff --git a/.samourai/docai/changes/2026-07/2026-07-04--GH-34--openrouter-provider-llm/chg-GH-34-plan.md b/.samourai/docai/changes/2026-07/2026-07-04--GH-34--openrouter-provider-llm/chg-GH-34-plan.md new file mode 100644 index 0000000..eae6bfd --- /dev/null +++ b/.samourai/docai/changes/2026-07/2026-07-04--GH-34--openrouter-provider-llm/chg-GH-34-plan.md @@ -0,0 +1,105 @@ +# Plan d'implémentation — GH-34 : Provider LLM OpenRouter + +## Vue d'ensemble + +Ajout d'OpenRouter comme 4ème provider LLM. Extension purement additive : aucun changement de comportement pour Ollama/OpenAI/Mistral. + +--- + +## Phase 1 : Settings + +### 1.1 `backend/app/core/config.py` +- [ ] Ajouter 6 champs à la classe `Settings` : +```python +openrouter_base_url: str = Field(default='https://openrouter.ai/api/v1', alias='OPENROUTER_BASE_URL') +openrouter_api_key: str = Field(default='', alias='OPENROUTER_API_KEY') +openrouter_model: str = Field(default='openrouter/auto', alias='OPENROUTER_MODEL') +openrouter_timeout_seconds: int = Field(default=30, alias='OPENROUTER_TIMEOUT_SECONDS') +openrouter_input_cost_per_1m_tokens: float = Field(default=0.0, alias='OPENROUTER_INPUT_COST_PER_1M_TOKENS') +openrouter_output_cost_per_1m_tokens: float = Field(default=0.0, alias='OPENROUTER_OUTPUT_COST_PER_1M_TOKENS') +``` + +--- + +## Phase 2 : Model Selector + +### 2.1 `backend/app/services/llm/model_selector.py` +- [ ] Ligne 28 : `SUPPORTED_LLM_PROVIDERS = {'ollama', 'openai', 'mistral', 'openrouter'}` + +--- + +## Phase 3 : Provider Client + +### 3.1 `backend/app/services/llm/provider_client.py` +- [ ] `__init__` : `self.openrouter = OpenAICompatibleClient('openrouter')` +- [ ] `_provider_client()` : `if provider == 'openrouter': return self.openrouter` + +### 3.2 `backend/app/services/llm/openai_compatible_client.py` +- [ ] `_normalized_api_key()` : branche `self.provider == 'openrouter'` +- [ ] `_normalized_base_url()` : branche `self.provider == 'openrouter'` +- [ ] `_default_model()` : branche `self.provider == 'openrouter'` +- [ ] `_timeout_seconds()` : branche `self.provider == 'openrouter'` +- [ ] `_estimate_cost_usd()` : branche `self.provider == 'openrouter'` + +--- + +## Phase 4 : AgentScope + +### 4.1 `backend/app/services/agentscope/model_factory.py` +- [ ] `build_model()` : `if provider in ("openai", "mistral", "openrouter"):` + +### 4.2 `backend/app/services/agentscope/formatter_factory.py` +- [ ] `build_formatter()` : ajouter `openrouter` au dispatch (utilise `OpenAI*Formatter` comme OpenAI/Mistral) + +--- + +## Phase 5 : Points d'intégration + +### 5.1 `backend/app/services/benchmark/engine.py` +- [ ] `_resolve_provider_config()` : `if provider == 'openrouter': return provider, model_name, settings.openrouter_base_url, settings.openrouter_api_key` + +### 5.2 `backend/app/services/market/news_provider.py` +- [ ] Résolution provider : ajouter cas `openrouter` pour base_url et api_key + +### 5.3 `backend/app/api/routes/health.py` +- [ ] `health()` : `elif llm_provider == 'openrouter': llm_configured = bool((settings.openrouter_api_key or '').strip())` + +### 5.4 `backend/app/services/strategy/optimizer_service.py` +- [ ] `_resolve_llm_config()` : `elif provider == 'openrouter': base_url = settings.openrouter_base_url; api_key = settings.openrouter_api_key` + +--- + +## Phase 6 : Documentation + +### 6.1 `docs/configuration.md` +- [ ] Ajouter section `### OpenRouter` après Mistral (ligne ~141) + +### 6.2 `.env.example` +- [ ] Ajouter les 6 variables OpenRouter avec commentaires + +--- + +## Phase 7 : Tests + +### 7.1 `backend/tests/unit/test_openai_compatible_client.py` +- [ ] `test_openrouter_client_base_url()` +- [ ] `test_openrouter_client_default_model()` +- [ ] `test_openrouter_client_is_configured()` +- [ ] `test_openrouter_client_not_configured_without_key()` + +### 7.2 `backend/tests/unit/test_llm_provider_client.py` +- [ ] `test_llm_client_uses_openrouter_provider()` + +### 7.3 `backend/tests/unit/test_agent_model_selector.py` +- [ ] `test_agent_model_selector_supports_openrouter_provider()` +- [ ] `test_normalize_llm_provider_accepts_openrouter()` + +### 7.4 `backend/tests/unit/test_agentscope_model_factory.py` +- [ ] `test_build_openrouter_model()` + +--- + +## Phase 8 : Validation + +- [ ] `cd backend && pytest -q` — pas de régression +- [ ] `cd frontend && npm run build` — pas d'impact frontend \ No newline at end of file diff --git a/.samourai/docai/changes/2026-07/2026-07-04--GH-34--openrouter-provider-llm/chg-GH-34-pm-notes.yaml b/.samourai/docai/changes/2026-07/2026-07-04--GH-34--openrouter-provider-llm/chg-GH-34-pm-notes.yaml new file mode 100644 index 0000000..152f729 --- /dev/null +++ b/.samourai/docai/changes/2026-07/2026-07-04--GH-34--openrouter-provider-llm/chg-GH-34-pm-notes.yaml @@ -0,0 +1,20 @@ +change_id: GH-34 +title: "Ajouter OpenRouter comme provider LLM (API OpenAI-compatible)" +phases: + clarify_scope: { started: "2026-07-04T15:45:00Z", completed: "2026-07-04T15:50:00Z" } + specification: { started: "2026-07-04T15:50:00Z", completed: "2026-07-04T15:55:00Z" } + test_planning: { started: "2026-07-04T15:55:00Z", completed: "2026-07-04T16:00:00Z" } + delivery_planning: { started: "2026-07-04T15:55:00Z", completed: "2026-07-04T16:00:00Z" } + delivery: { started: "2026-07-04T16:00:00Z", completed: null } + system_spec_update: { started: null, completed: null } + review_fix: { started: null, completed: null } + quality_gates: { started: null, completed: null } + dod_check: { started: null, completed: null } + pr_creation: { started: null, completed: null, url: null } +decisions: [] +open_questions: [] +blockers: [] +notes: + - text: "Ticket créé via gh CLI — GitHub MCP auth KO" + type: info + date: "2026-07-04" \ No newline at end of file diff --git a/.samourai/docai/changes/2026-07/2026-07-04--GH-34--openrouter-provider-llm/chg-GH-34-spec.md b/.samourai/docai/changes/2026-07/2026-07-04--GH-34--openrouter-provider-llm/chg-GH-34-spec.md new file mode 100644 index 0000000..17e43f2 --- /dev/null +++ b/.samourai/docai/changes/2026-07/2026-07-04--GH-34--openrouter-provider-llm/chg-GH-34-spec.md @@ -0,0 +1,95 @@ +--- +change: + ref: GH-34 + type: feat + slug: openrouter-provider-llm + title: "Ajouter OpenRouter comme provider LLM (API OpenAI-compatible)" + service: llm-providers + labels: ["type:feature", "llm"] + risk_level: low + dependencies: + internal: ["llm-client", "model-selector", "agentscope"] + external: ["openrouter.ai"] +--- + +# Spécification — GH-34 : Provider LLM OpenRouter + +## 1. Problème + +Kairos Mesh supporte 3 providers LLM (Ollama, OpenAI, Mistral). OpenRouter permet d'accéder à 300+ modèles (Claude, Gemini, DeepSeek R1, etc.) via une API OpenAI-compatible unifiée — mais n'est pas intégré. + +## 2. Objectif + +Ajouter OpenRouter comme 4ème provider, en réutilisant le pattern existant `OpenAICompatibleClient` (OpenRouter est 100% compatible API OpenAI). + +## 3. Scope + +### In +- 6 variables d'env : `OPENROUTER_BASE_URL`, `OPENROUTER_API_KEY`, `OPENROUTER_MODEL`, `OPENROUTER_TIMEOUT_SECONDS`, `OPENROUTER_INPUT_COST_PER_1M_TOKENS`, `OPENROUTER_OUTPUT_COST_PER_1M_TOKENS` +- `'openrouter'` dans `SUPPORTED_LLM_PROVIDERS` +- `OpenAICompatibleClient('openrouter')` instancié dans `LlmClient` +- Dispatch `openrouter` dans les 8 points de résolution provider +- AgentScope `model_factory` et `formatter_factory` (comme OpenAI/Mistral) +- Tests unitaires pour chaque couche modifiée +- Mise à jour `docs/configuration.md` et `.env.example` + +### Out +- Pas de SDK dédié OpenRouter +- Pas de modification UI + +## 4. Architecture cible + +Même pattern que Mistral : 6 champs Settings + dispatch dans les 8 fichiers listés. + +Modèle par défaut : `openrouter/auto` (auto-routing OpenRouter). + +## 5. Critères d'acceptation + +- [ ] **AC1** : `OPENROUTER_API_KEY` + `LLM_PROVIDER=openrouter` → health endpoint `llm: configured` +- [ ] **AC2** : `LLM_PROVIDER=openrouter` → requêtes routées vers `https://openrouter.ai/api/v1` +- [ ] **AC3** : `ConnectorConfig(provider='openrouter')` en DB → sélection dynamique fonctionnelle +- [ ] **AC4** : `build_model('openrouter', ...)` → `OpenAIChatModel` configuré pour OpenRouter +- [ ] **AC5** : Tous les tests existants passent sans régression +- [ ] **AC6** : Nouveaux tests unitaires couvrent `OpenAICompatibleClient('openrouter')`, `LlmClient`, `model_selector`, `model_factory` + +## 6. Definition of Done + +- [ ] Code implémenté dans les 14 fichiers listés +- [ ] Tests unitaires ajoutés et verts +- [ ] Tests existants sans régression +- [ ] `docs/configuration.md` mis à jour (section OpenRouter) +- [ ] `.env.example` mis à jour (6 variables) +- [ ] `SUPPORTED_LLM_PROVIDERS` inclut `'openrouter'` + +## 7. Risques + +| Risque | Probabilité | Impact | Mitigation | +|--------|------------|--------|------------| +| Changement API OpenRouter | Faible | Moyen | API OpenAI-compatible standard, rétrocompatibilité garantie par OpenRouter | +| Régression providers existants | Très faible | Élevé | Tests exhaustifs existants préservés | + +## 8. Dépendances + +- Aucune dépendance interne sur d'autres tickets +- Dépendance externe : API OpenRouter stable (`https://openrouter.ai/api/v1`) + +## 9. Fichiers impactés + +| Fichier | Changement | +|---------|-----------| +| `backend/app/core/config.py` | 6 champs `openrouter_*` | +| `backend/app/services/llm/model_selector.py` | `SUPPORTED_LLM_PROVIDERS` + `'openrouter'` | +| `backend/app/services/llm/provider_client.py` | `self.openrouter = OpenAICompatibleClient('openrouter')` + dispatch | +| `backend/app/services/llm/openai_compatible_client.py` | Branches `provider == 'openrouter'` (5 méthodes) | +| `backend/app/services/agentscope/model_factory.py` | `'openrouter'` dans la condition `if provider in (...)` | +| `backend/app/services/agentscope/formatter_factory.py` | `'openrouter'` dans la condition | +| `backend/app/services/benchmark/engine.py` | `_resolve_provider_config()` cas openrouter | +| `backend/app/services/market/news_provider.py` | Résolution URL/clé openrouter | +| `backend/app/api/routes/health.py` | `elif llm_provider == 'openrouter'` | +| `backend/app/services/strategy/optimizer_service.py` | `_resolve_llm_config()` cas openrouter | +| `docs/configuration.md` | Section OpenRouter | +| `.env.example` | 6 variables OpenRouter | +| `backend/tests/unit/test_openai_compatible_client.py` | Test `OpenAICompatibleClient('openrouter')` | +| `backend/tests/unit/test_llm_provider_client.py` | Test dispatch openrouter | +| `backend/tests/unit/test_agent_model_selector.py` | Test `resolve_provider` openrouter | +| `backend/tests/unit/test_agentscope_model_factory.py` | Test `build_model('openrouter', ...)` | \ No newline at end of file diff --git a/.samourai/docai/changes/2026-07/2026-07-04--GH-34--openrouter-provider-llm/chg-GH-34-test-plan.md b/.samourai/docai/changes/2026-07/2026-07-04--GH-34--openrouter-provider-llm/chg-GH-34-test-plan.md new file mode 100644 index 0000000..6f7f4cf --- /dev/null +++ b/.samourai/docai/changes/2026-07/2026-07-04--GH-34--openrouter-provider-llm/chg-GH-34-test-plan.md @@ -0,0 +1,92 @@ +# Test Plan — GH-34 : Provider LLM OpenRouter + +## Stratégie de test + +Tests unitaires ciblés sur chaque couche modifiée + test de non-régression global. Aucun test d'intégration/E2E nécessaire (pas de nouveau comportement, pas d'impact frontend). + +## Mapping AC → Tests + +| AC | Tests | +|----|-------| +| AC1 : Health `llm: configured` | `test_openrouter_health_configured` | +| AC2 : Routage `openrouter.ai` | `test_openrouter_client_base_url`, `test_openrouter_client_default_model` | +| AC3 : `ConnectorConfig(provider='openrouter')` | `test_agent_model_selector_openrouter_provider` | +| AC4 : `build_model('openrouter', ...)` | `test_build_openrouter_model` | +| AC5 : Non-régression | `cd backend && pytest -q` (tous les tests) | +| AC6 : Couverture par couche | Voir tableau ci-dessous | + +--- + +## Cas de test + +### 1. Settings (`test_openrouter_settings.py` — nouveau) + +| ID | Test | Vérification | +|----|------|-------------| +| T1.1 | `Settings` charge `OPENROUTER_BASE_URL` | `settings.openrouter_base_url == "https://openrouter.ai/api/v1"` | +| T1.2 | `Settings` charge `OPENROUTER_MODEL` | `settings.openrouter_model == "openrouter/auto"` | +| T1.3 | `Settings` valeurs par défaut | Tous les champs ont leur défaut (api_key='', timeout=30, coûts=0.0) | + +### 2. Model Selector (`test_agent_model_selector.py` — ajout) + +| ID | Test | Vérification | +|----|------|-------------| +| T2.1 | `normalize_llm_provider('openrouter')` | Retourne `'openrouter'` | +| T2.2 | `normalize_llm_provider('OPENROUTER')` | Normalisé → `'openrouter'` | +| T2.3 | `resolve_provider(db)` avec `provider='openrouter'` en DB | Retourne `'openrouter'` | + +### 3. LlmClient (`test_llm_provider_client.py` — ajout) + +| ID | Test | Vérification | +|----|------|-------------| +| T3.1 | `_provider_client('openrouter')` | Retourne instance `OpenAICompatibleClient` avec `provider='openrouter'` | +| T3.2 | `chat()` avec provider openrouter en DB | `result['provider'] == 'openrouter'` | + +### 4. OpenAICompatibleClient (`test_openai_compatible_client.py` — ajout) + +| ID | Test | Vérification | +|----|------|-------------| +| T4.1 | `_normalized_base_url()` | `"https://openrouter.ai/api/v1"` | +| T4.2 | `_normalized_api_key()` | Lit `OPENROUTER_API_KEY` | +| T4.3 | `_default_model()` | `"openrouter/auto"` | +| T4.4 | `_timeout_seconds()` | `settings.openrouter_timeout_seconds` | +| T4.5 | `is_configured()` avec clé valide | `True` | +| T4.6 | `is_configured()` sans clé | `False` | + +### 5. AgentScope model_factory (`test_agentscope_model_factory.py` — ajout) + +| ID | Test | Vérification | +|----|------|-------------| +| T5.1 | `build_model('openrouter', 'openrouter/auto', 'https://openrouter.ai/api/v1', 'key')` | Retourne `OpenAIChatModel` | +| T5.2 | `build_model('openrouter', ...)` a les bons kwargs | `api_key='key'`, `client_kwargs['base_url']` correct | + +### 6. AgentScope formatter_factory (`test_agentscope_model_factory.py` — ajout) + +| ID | Test | Vérification | +|----|------|-------------| +| T6.1 | `build_formatter('openrouter')` | Retourne `OpenAIChatFormatter` | +| T6.2 | `build_formatter('openrouter', multi_agent=True)` | Retourne `OpenAIMultiAgentFormatter` | + +--- + +## Données de test + +- **API key factice** : `"sk-or-v1-test-key"` (format OpenRouter standard : `sk-or-v1-*`) +- **Base URL** : `"https://openrouter.ai/api/v1"` +- **Modèle par défaut** : `"openrouter/auto"` +- **DB ConnectorConfig** : `ConnectorConfig(connector_name='ollama', enabled=True, settings={'provider': 'openrouter'})` + +--- + +## Exécution + +```bash +# Tests unitaires ciblés +cd backend && python -m pytest tests/unit/test_openai_compatible_client.py \ + tests/unit/test_llm_provider_client.py \ + tests/unit/test_agent_model_selector.py \ + tests/unit/test_agentscope_model_factory.py -v + +# Non-régression +cd backend && pytest -q +``` \ No newline at end of file diff --git a/.samourai/docai/changes/2026-07/2026-07-05--GH-36--ajouter-openrouter-ui-connectors/chg-GH-36-plan.md b/.samourai/docai/changes/2026-07/2026-07-05--GH-36--ajouter-openrouter-ui-connectors/chg-GH-36-plan.md new file mode 100644 index 0000000..b30016f --- /dev/null +++ b/.samourai/docai/changes/2026-07/2026-07-05--GH-36--ajouter-openrouter-ui-connectors/chg-GH-36-plan.md @@ -0,0 +1,35 @@ +# Plan d'implémentation pour GH-36 - Ajouter OpenRouter dans l'UI Connectors + +## Phases +1. **Phase 1: Préparation** + - Création des fichiers artifacts (spec, test-plan, pm-notes) + - Validation des chemins et branches + +2. **Phase 2: Modifications frontend** + - Mise à jour du type LlmProvider + - Ajout d'OpenRouter dans la liste des fournisseurs + - Ajout du champ API_KEY dans l'onglet Security + - Mise à jour des fonctions utilitaires + +3. **Phase 3: Validation** + - Vérification visuelle des modifications + - Exécution du build frontend + +## Étapes détaillées +### Phase 2 +1. Mettre à jour le type LlmProvider pour inclure 'openrouter' +2. Ajouter 'openrouter' dans la liste LLM_PROVIDERS +3. Ajouter la condition pour 'openrouter' dans normalizeLlmProvider +4. Ajouter la valeur par défaut 'openrouter/auto' dans defaultModelForProvider +5. Mettre à jour SecretFieldKey et EMPTY_SECRET_FIELDS +6. Ajouter OPENROUTER_API_KEY dans hydrateSecretFields +7. Mettre à jour le tableau dans saveSecrets +8. Ajouter le bloc UI pour OPENROUTER_API_KEY + +### Phase 3 +1. Vérifier visuellement chaque modification +2. Exécuter `npm run build` dans le dossier frontend + +## Livrables +- Fichiers modifiés : ConnectorsPage.tsx +- Build frontend réussi \ No newline at end of file diff --git a/.samourai/docai/changes/2026-07/2026-07-05--GH-36--ajouter-openrouter-ui-connectors/chg-GH-36-pm-notes.yaml b/.samourai/docai/changes/2026-07/2026-07-05--GH-36--ajouter-openrouter-ui-connectors/chg-GH-36-pm-notes.yaml new file mode 100644 index 0000000..7f3b40b --- /dev/null +++ b/.samourai/docai/changes/2026-07/2026-07-05--GH-36--ajouter-openrouter-ui-connectors/chg-GH-36-pm-notes.yaml @@ -0,0 +1,21 @@ +change_id: GH-36 +title: "Ajouter OpenRouter dans l'UI Connectors" +phases: + clarify_scope: { started: "2026-07-05T12:00:00Z", completed: "2026-07-05T12:05:00Z" } + specification: { started: "2026-07-05T12:05:00Z", completed: "2026-07-05T12:15:00Z" } + test_planning: { started: "2026-07-05T12:15:00Z", completed: "2026-07-05T12:25:00Z" } + delivery_planning: { started: "2026-07-05T12:25:00Z", completed: "2026-07-05T12:35:00Z" } + delivery: { started: "2026-07-05T12:35:00Z", completed: "2026-07-05T12:50:00Z" } + system_spec_update: { started: "2026-07-05T12:50:00Z", completed: "2026-07-05T13:00:00Z" } + review_fix: { started: "2026-07-05T13:00:00Z", completed: "2026-07-05T13:10:00Z" } + quality_gates: { started: "2026-07-05T13:10:00Z", completed: "2026-07-05T13:20:00Z" } + dod_check: { started: "2026-07-05T13:20:00Z", completed: "2026-07-05T13:25:00Z" } + pr_creation: { started: "2026-07-05T13:25:00Z", completed: "2026-07-05T13:30:00Z", url: "https://github.com/simodev25/KairosMesh/pull/37" } +decisions: + - { text: "Scope limité à l'UI frontend uniquement — le backend est déjà prêt (GH-34)", date: "2026-07-05" } + - { text: "Modèle par défaut: openrouter/auto", date: "2026-07-05" } +blockers: + - { text: "Build frontend échoue sur erreurs préexistantes (GovernanceMonitorPanel, TradingViewChart, etc.) — non causées par GH-36", date: "2026-07-05" } +notes: + - { text: "Reviewer: PASS, Doc-syncer: aucune mise à jour nécessaire", type: info, date: "2026-07-05" } + - { text: "ConnectorsPage.tsx 0 erreur TypeScript — build global cassé par dette préexistante", type: risk, date: "2026-07-05" } \ No newline at end of file diff --git a/.samourai/docai/changes/2026-07/2026-07-05--GH-36--ajouter-openrouter-ui-connectors/chg-GH-36-spec.md b/.samourai/docai/changes/2026-07/2026-07-05--GH-36--ajouter-openrouter-ui-connectors/chg-GH-36-spec.md new file mode 100644 index 0000000..30b9d55 --- /dev/null +++ b/.samourai/docai/changes/2026-07/2026-07-05--GH-36--ajouter-openrouter-ui-connectors/chg-GH-36-spec.md @@ -0,0 +1,22 @@ +# Spécification pour GH-36 - Ajouter OpenRouter dans l'UI Connectors + +## Problème/But +Ajouter le support d'OpenRouter comme fournisseur LLM dans l'interface de configuration des connecteurs. + +## Scope/Non-goals +- Scope : Ajout de l'option OpenRouter dans l'UI existante +- Non-goals : Implémentation du backend pour OpenRouter (déjà fait dans GH-34) + +## Critères d'acceptation +1. OpenRouter apparaît dans la liste des fournisseurs LLM +2. Le champ API_KEY pour OpenRouter est disponible dans l'onglet Security +3. Les valeurs par défaut sont correctement configurées +4. Le build frontend passe sans erreur + +## Définition de Done +- Tous les critères d'acceptation sont validés +- Les modifications sont committées sur la branche feat/GH-36 +- Le build frontend passe sans erreur + +## Risques/Cas limites +- Aucun risque identifié - modification frontend uniquement \ No newline at end of file diff --git a/.samourai/docai/changes/2026-07/2026-07-05--GH-36--ajouter-openrouter-ui-connectors/chg-GH-36-test-plan.md b/.samourai/docai/changes/2026-07/2026-07-05--GH-36--ajouter-openrouter-ui-connectors/chg-GH-36-test-plan.md new file mode 100644 index 0000000..b3af7d0 --- /dev/null +++ b/.samourai/docai/changes/2026-07/2026-07-05--GH-36--ajouter-openrouter-ui-connectors/chg-GH-36-test-plan.md @@ -0,0 +1,21 @@ +# Plan de test pour GH-36 - Ajouter OpenRouter dans l'UI Connectors + +## Stratégie de test +Tests manuels de l'interface utilisateur pour vérifier l'intégration d'OpenRouter + +## Matrice de tests +| ID | Description | Critère lié | Statut | +|----|------------|-------------|--------| +| T1 | Vérifier que OpenRouter apparaît dans la liste des fournisseurs | AC1 | TODO | +| T2 | Vérifier que le champ API_KEY est présent dans l'onglet Security | AC2 | TODO | +| T3 | Vérifier les valeurs par défaut | AC3 | TODO | +| T4 | Vérifier que le build passe | AC4 | TODO | + +## Notes de configuration +Aucune configuration spéciale nécessaire + +## Checklist de vérification manuelle +- [ ] OpenRouter visible dans la liste déroulante +- [ ] Champ API_KEY présent et fonctionnel +- [ ] Valeur par défaut 'openrouter/auto' configurée +- [ ] Build frontend réussi \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example index 3901004..a2c7e35 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -84,6 +84,14 @@ MISTRAL_TIMEOUT_SECONDS=30 MISTRAL_INPUT_COST_PER_1M_TOKENS=0 MISTRAL_OUTPUT_COST_PER_1M_TOKENS=0 +# --- OpenRouter — 300+ models via OpenAI-compatible API --- +OPENROUTER_BASE_URL=https://openrouter.ai/api/v1 +OPENROUTER_API_KEY= +OPENROUTER_MODEL=openrouter/auto +OPENROUTER_TIMEOUT_SECONDS=30 +OPENROUTER_INPUT_COST_PER_1M_TOKENS=0 +OPENROUTER_OUTPUT_COST_PER_1M_TOKENS=0 + # --- Broker — MetaApi (MT4/MT5) --- METAAPI_TOKEN= METAAPI_ACCOUNT_ID= diff --git a/backend/app/api/routes/health.py b/backend/app/api/routes/health.py index ed8cd6d..a92b566 100644 --- a/backend/app/api/routes/health.py +++ b/backend/app/api/routes/health.py @@ -27,6 +27,8 @@ def health(db: Session = Depends(get_db)) -> HealthResponse: llm_configured = bool((settings.openai_api_key or '').strip()) elif llm_provider == 'mistral': llm_configured = bool((settings.mistral_api_key or '').strip()) + elif llm_provider == 'openrouter': + llm_configured = bool((settings.openrouter_api_key or '').strip()) else: llm_configured = bool((settings.ollama_api_key or '').strip()) services['llm'] = 'configured' if llm_configured else 'degraded' diff --git a/backend/app/core/config.py b/backend/app/core/config.py index ab73a60..8481b70 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -77,6 +77,12 @@ class Settings(BaseSettings): mistral_timeout_seconds: int = Field(default=30, alias='MISTRAL_TIMEOUT_SECONDS') mistral_input_cost_per_1m_tokens: float = Field(default=0.0, alias='MISTRAL_INPUT_COST_PER_1M_TOKENS') mistral_output_cost_per_1m_tokens: float = Field(default=0.0, alias='MISTRAL_OUTPUT_COST_PER_1M_TOKENS') + openrouter_base_url: str = Field(default='https://openrouter.ai/api/v1', alias='OPENROUTER_BASE_URL') + openrouter_api_key: str = Field(default='', alias='OPENROUTER_API_KEY') + openrouter_model: str = Field(default='openrouter/auto', alias='OPENROUTER_MODEL') + openrouter_timeout_seconds: int = Field(default=30, alias='OPENROUTER_TIMEOUT_SECONDS') + openrouter_input_cost_per_1m_tokens: float = Field(default=0.0, alias='OPENROUTER_INPUT_COST_PER_1M_TOKENS') + openrouter_output_cost_per_1m_tokens: float = Field(default=0.0, alias='OPENROUTER_OUTPUT_COST_PER_1M_TOKENS') decision_mode: str = Field(default='balanced', alias='DECISION_MODE') metaapi_token: str = Field(default='', alias='METAAPI_TOKEN') metaapi_account_id: str = Field(default='', alias='METAAPI_ACCOUNT_ID') diff --git a/backend/app/services/agentscope/formatter_factory.py b/backend/app/services/agentscope/formatter_factory.py index 1a65bf4..02f92bb 100644 --- a/backend/app/services/agentscope/formatter_factory.py +++ b/backend/app/services/agentscope/formatter_factory.py @@ -25,5 +25,5 @@ def build_formatter( if use_ollama_native: return OllamaMultiAgentFormatter() if multi_agent else OllamaChatFormatter() - # OpenAI, Mistral, and remote Ollama all use OpenAI-compatible API + # OpenAI, Mistral, OpenRouter, and remote Ollama all use OpenAI-compatible API return OpenAIMultiAgentFormatter() if multi_agent else OpenAIChatFormatter() diff --git a/backend/app/services/agentscope/model_factory.py b/backend/app/services/agentscope/model_factory.py index 3dc6031..6e9295b 100644 --- a/backend/app/services/agentscope/model_factory.py +++ b/backend/app/services/agentscope/model_factory.py @@ -50,7 +50,7 @@ def build_model( stream=stream, generate_kwargs={"temperature": temperature}, ) - if provider in ("openai", "mistral"): + if provider in ("openai", "mistral", "openrouter"): return OpenAIChatModel( model_name=model_name, api_key=api_key, diff --git a/backend/app/services/benchmark/engine.py b/backend/app/services/benchmark/engine.py index 1a6336b..1ccd152 100644 --- a/backend/app/services/benchmark/engine.py +++ b/backend/app/services/benchmark/engine.py @@ -42,6 +42,8 @@ def _resolve_provider_config(model_spec: dict[str, Any]) -> tuple[str, str, str, return provider, model_name, settings.openai_base_url, settings.openai_api_key if provider == 'mistral': return provider, model_name, settings.mistral_base_url, settings.mistral_api_key + if provider == 'openrouter': + return provider, model_name, settings.openrouter_base_url, settings.openrouter_api_key return 'ollama', model_name, settings.ollama_base_url, settings.ollama_api_key async def _build_agent( diff --git a/backend/app/services/llm/model_selector.py b/backend/app/services/llm/model_selector.py index bd5bc6b..3555dd7 100644 --- a/backend/app/services/llm/model_selector.py +++ b/backend/app/services/llm/model_selector.py @@ -25,7 +25,7 @@ 'execution-manager': False, } -SUPPORTED_LLM_PROVIDERS = {'ollama', 'openai', 'mistral'} +SUPPORTED_LLM_PROVIDERS = {'ollama', 'openai', 'mistral', 'openrouter'} DETERMINISTIC_ONLY_AGENTS: set[str] = set() MAX_AGENT_SKILLS_PER_AGENT = 12 MAX_AGENT_SKILL_LENGTH = 500 @@ -595,6 +595,8 @@ def _provider_default_model(self, provider: str) -> str: return str(self.settings.openai_model or '').strip() or 'gpt-4o-mini' if normalized_provider == 'mistral': return str(self.settings.mistral_model or '').strip() or 'mistral-small-latest' + if normalized_provider == 'openrouter': + return str(self.settings.openrouter_model or '').strip() or 'openrouter/auto' return str(self.settings.ollama_model or '').strip() or 'deepseek-v3.2' def is_enabled(self, db: Session | None, agent_name: str) -> bool: diff --git a/backend/app/services/llm/openai_compatible_client.py b/backend/app/services/llm/openai_compatible_client.py index 0bc991a..8ecac81 100644 --- a/backend/app/services/llm/openai_compatible_client.py +++ b/backend/app/services/llm/openai_compatible_client.py @@ -74,6 +74,8 @@ def _get_http_client(cls, timeout_seconds: float) -> httpx.Client: def _display_name(self) -> str: if self.provider == 'mistral': return 'Mistral' + if self.provider == 'openrouter': + return 'OpenRouter' return 'OpenAI' def _normalized_api_key(self, db: Session | None = None) -> str: @@ -85,6 +87,12 @@ def _normalized_api_key(self, db: Session | None = None) -> str: or RuntimeConnectorSettings.get_string('ollama', ('MISTRAL_API_KEY', 'mistral_api_key')) ) key = (runtime_key or self.settings.mistral_api_key or '').strip() + elif self.provider == 'openrouter': + runtime_key = ( + RuntimeConnectorSettings.get_string('openrouter', ('OPENROUTER_API_KEY', 'openrouter_api_key')) + or RuntimeConnectorSettings.get_string('ollama', ('OPENROUTER_API_KEY', 'openrouter_api_key')) + ) + key = (runtime_key or self.settings.openrouter_api_key or '').strip() else: runtime_key = ( RuntimeConnectorSettings.get_string('openai', ('OPENAI_API_KEY', 'openai_api_key')) @@ -96,7 +104,12 @@ def _normalized_api_key(self, db: Session | None = None) -> str: return key def _normalized_base_url(self) -> str: - raw_base_url = self.settings.mistral_base_url if self.provider == 'mistral' else self.settings.openai_base_url + if self.provider == 'mistral': + raw_base_url = self.settings.mistral_base_url + elif self.provider == 'openrouter': + raw_base_url = self.settings.openrouter_base_url + else: + raw_base_url = self.settings.openai_base_url base_url = str(raw_base_url or '').strip().rstrip('/') if not base_url: return base_url @@ -107,11 +120,15 @@ def _normalized_base_url(self) -> str: def _default_model(self) -> str: if self.provider == 'mistral': return str(self.settings.mistral_model or '').strip() or 'mistral-small-latest' + if self.provider == 'openrouter': + return str(self.settings.openrouter_model or '').strip() or 'openrouter/auto' return str(self.settings.openai_model or '').strip() or 'gpt-4o-mini' def _timeout_seconds(self) -> float: if self.provider == 'mistral': return float(self.settings.mistral_timeout_seconds) + if self.provider == 'openrouter': + return float(self.settings.openrouter_timeout_seconds) return float(self.settings.openai_timeout_seconds) def is_configured(self, base_url: str | None = None, *, db: Session | None = None) -> bool: @@ -126,6 +143,9 @@ def _estimate_cost_usd(self, prompt_tokens: int, completion_tokens: int) -> floa if self.provider == 'mistral': input_rate = float(self.settings.mistral_input_cost_per_1m_tokens) output_rate = float(self.settings.mistral_output_cost_per_1m_tokens) + elif self.provider == 'openrouter': + input_rate = float(self.settings.openrouter_input_cost_per_1m_tokens) + output_rate = float(self.settings.openrouter_output_cost_per_1m_tokens) else: input_rate = float(self.settings.openai_input_cost_per_1m_tokens) output_rate = float(self.settings.openai_output_cost_per_1m_tokens) diff --git a/backend/app/services/llm/provider_client.py b/backend/app/services/llm/provider_client.py index a9a1199..4e99663 100644 --- a/backend/app/services/llm/provider_client.py +++ b/backend/app/services/llm/provider_client.py @@ -18,6 +18,7 @@ def __init__(self) -> None: self.ollama = OllamaCloudClient() self.openai = OpenAICompatibleClient('openai') self.mistral = OpenAICompatibleClient('mistral') + self.openrouter = OpenAICompatibleClient('openrouter') def _resolve_provider(self, db: Session | None) -> str: return normalize_llm_provider(self.model_selector.resolve_provider(db), fallback='ollama') @@ -27,6 +28,8 @@ def _provider_client(self, provider: str) -> Any: return self.openai if provider == 'mistral': return self.mistral + if provider == 'openrouter': + return self.openrouter return self.ollama @staticmethod diff --git a/backend/app/services/market/news_provider.py b/backend/app/services/market/news_provider.py index ca93303..71c5969 100644 --- a/backend/app/services/market/news_provider.py +++ b/backend/app/services/market/news_provider.py @@ -109,6 +109,14 @@ class MarketProvider: 'timeout_ms': 15000, 'max_queries': 2, }, + 'openrouter': { + 'enabled': True, + 'priority': 95, + 'timeout_ms': 5000, + 'api_key_env': 'OPENROUTER_API_KEY', + 'endpoint_env': 'OPENROUTER_ENDPOINT', + 'lookback_hours': 48, + }, } news_analysis_defaults: dict[str, Any] = { 'max_items_total': 25, @@ -1149,6 +1157,12 @@ def _provider_api_key(self, provider_name: str, provider_cfg: dict[str, Any]) -> ('ALPHAVANTAGE_API_KEY', 'alphavantage_api_key'), default=str(self.settings.alphavantage_api_key or '').strip(), ) + if provider_name == 'openrouter': + return RuntimeConnectorSettings.get_string( + 'news', + ('OPENROUTER_API_KEY', 'openrouter_api_key'), + default=str(self.settings.openrouter_api_key or '').strip(), + ) return '' def _provider_timeout_seconds(self, provider_cfg: dict[str, Any], default_ms: int = 4000) -> float: @@ -1658,6 +1672,77 @@ def _fetch_alphavantage_items( 'lookback_hours': lookback_hours, } + def _fetch_openrouter_items( + self, + pair: str, + *, + max_items: int, + timeout_seconds: float, + provider_cfg: dict[str, Any], + api_key: str, + ) -> tuple[list[dict[str, Any]], dict[str, Any]]: + """Fetch news items from OpenRouter integration. + + This uses a configurable HTTP endpoint provided via provider config. + If endpoint is not configured, returns empty results. + """ + lookback_hours = int(max(self._safe_float(provider_cfg.get('lookback_hours'), 48), 1.0)) + endpoint = provider_cfg.get('endpoint') if isinstance(provider_cfg.get('endpoint'), str) else '' + if not endpoint: + return [], {'endpoint': endpoint} + query = ' OR '.join(self._keywords_for_pair(pair)[:8]) + from_dt = (datetime.now(timezone.utc) - timedelta(hours=lookback_hours)).isoformat() + headers = {} + if api_key: + headers['Authorization'] = f'Bearer {api_key}' + params = { + 'q': query, + 'from': from_dt, + 'limit': max_items, + } + items: list[dict[str, Any]] = [] + used_endpoint = endpoint + with httpx.Client(timeout=timeout_seconds) as client: + try: + resp = client.get(endpoint, params=params, headers=headers) + resp.raise_for_status() + payload = resp.json() if resp.content else {} + except Exception: + try: + resp = client.get(endpoint, params=params) + resp.raise_for_status() + payload = resp.json() if resp.content else {} + except Exception: + payload = {} + used_endpoint = endpoint + + current_items = ( + payload.get('items') or payload.get('articles') or payload.get('results') or [] + ) + if not isinstance(current_items, list): + current_items = [] + + for item in current_items: + if not isinstance(item, dict): + continue + normalized = self._normalize_article_item( + provider='openrouter', + pair=pair, + title=str(item.get('title') or ''), + summary=str(item.get('summary') or item.get('description') or ''), + url=item.get('url'), + published_at=item.get('published_at') or item.get('published'), + source_name=str(item.get('publisher') or ''), + language=str(item.get('language') or 'en'), + ) + if normalized is None: + continue + items.append(normalized) + if len(items) >= max_items: + break + + return items, {'endpoint': used_endpoint, 'query': query, 'lookback_hours': lookback_hours} + # ------------------------------------------------------------------ # LLM-powered web search provider # ------------------------------------------------------------------ @@ -1732,6 +1817,16 @@ def _resolve_llm_search_provider(self) -> tuple[str, str, str]: default=str(self.settings.mistral_api_key or '').strip(), ) return provider, base_url.rstrip('/'), api_key + if provider == 'openrouter': + base_url = RuntimeConnectorSettings.get_string( + 'openrouter', ('OPENROUTER_BASE_URL', 'openrouter_base_url'), + default=str(self.settings.openrouter_base_url or '').strip(), + ) or 'https://openrouter.ai/api/v1' + api_key = RuntimeConnectorSettings.get_string( + 'openrouter', ('OPENROUTER_API_KEY', 'openrouter_api_key'), + default=str(self.settings.openrouter_api_key or '').strip(), + ) + return provider, base_url.rstrip('/'), api_key # Default: ollama base_url = str(self.settings.ollama_base_url or '').strip().rstrip('/') api_key = RuntimeConnectorSettings.get_string( diff --git a/backend/app/services/strategy/optimizer_service.py b/backend/app/services/strategy/optimizer_service.py index 6e424e2..5643888 100644 --- a/backend/app/services/strategy/optimizer_service.py +++ b/backend/app/services/strategy/optimizer_service.py @@ -290,6 +290,9 @@ def _resolve_llm_config(db: Session) -> tuple[str, str, str, str]: elif provider == 'mistral': base_url = settings.mistral_base_url api_key = settings.mistral_api_key + elif provider == 'openrouter': + base_url = settings.openrouter_base_url + api_key = settings.openrouter_api_key else: # ollama base_url = settings.ollama_base_url api_key = settings.ollama_api_key diff --git a/backend/tests/unit/test_agent_model_selector.py b/backend/tests/unit/test_agent_model_selector.py index fa41af0..0f6f26e 100644 --- a/backend/tests/unit/test_agent_model_selector.py +++ b/backend/tests/unit/test_agent_model_selector.py @@ -8,6 +8,7 @@ AgentModelSelector, get_external_tools_for_agent, normalize_external_mcps, + normalize_llm_provider, validate_agent_tools_payload, ) @@ -380,3 +381,8 @@ def test_normalize_agent_tools_preserves_ext_tool_ids(): assert "ext__test-finance-mcp__get_earnings" in ta, "ext__ tool must be preserved" assert ta["ext__test-finance-mcp__get_earnings"] is False assert ta["ext__test-finance-mcp__get_analyst_rating"] is True + + +def test_normalize_llm_provider_accepts_openrouter() -> None: + assert normalize_llm_provider('openrouter') == 'openrouter' + assert normalize_llm_provider('OPENROUTER') == 'openrouter' diff --git a/backend/tests/unit/test_agentscope_model_factory.py b/backend/tests/unit/test_agentscope_model_factory.py index 28b19ba..4cf17ed 100644 --- a/backend/tests/unit/test_agentscope_model_factory.py +++ b/backend/tests/unit/test_agentscope_model_factory.py @@ -90,3 +90,18 @@ def test_mistral_uses_openai_formatter(): def test_formatter_unknown_provider_falls_back_to_openai(): f = build_formatter("unknown") assert f.__class__.__name__ == "OpenAIChatFormatter" + + +@patch("app.services.agentscope.model_factory.OpenAIChatModel") +def test_build_openrouter_model(mock_cls): + mock_cls.return_value = MagicMock() + build_model(provider="openrouter", model_name="openrouter/auto", base_url="https://openrouter.ai/api/v1", api_key="sk-or-v1-test") + mock_cls.assert_called_once() + call_kwargs = mock_cls.call_args[1] + assert call_kwargs["model_name"] == "openrouter/auto" + assert call_kwargs["api_key"] == "sk-or-v1-test" + + +def test_openrouter_uses_openai_formatter(): + f = build_formatter("openrouter", multi_agent=True) + assert f.__class__.__name__ == "OpenAIMultiAgentFormatter" diff --git a/backend/tests/unit/test_llm_provider_client.py b/backend/tests/unit/test_llm_provider_client.py index c13ab55..eaf4a4f 100644 --- a/backend/tests/unit/test_llm_provider_client.py +++ b/backend/tests/unit/test_llm_provider_client.py @@ -74,3 +74,9 @@ def test_llm_client_lists_models_for_explicit_provider_override() -> None: result = client.list_models(db, provider='mistral') assert result['provider'] == 'mistral' assert result['models'] == ['mistral-small-latest'] + + +def test_llm_client_dispatches_openrouter() -> None: + client = LlmClient() + provider_client = client._provider_client('openrouter') + assert provider_client.provider == 'openrouter' diff --git a/backend/tests/unit/test_openai_compatible_client.py b/backend/tests/unit/test_openai_compatible_client.py index 9d27086..4b0061a 100644 --- a/backend/tests/unit/test_openai_compatible_client.py +++ b/backend/tests/unit/test_openai_compatible_client.py @@ -23,3 +23,20 @@ def test_mistral_client_prefers_runtime_connector_api_key(monkeypatch) -> None: ) assert client._normalized_api_key() == 'runtime-mistral' + + +def test_openrouter_client_base_url() -> None: + client = OpenAICompatibleClient('openrouter') + client.settings.openrouter_base_url = 'https://openrouter.ai/api/v1' + assert client._normalized_base_url() == 'https://openrouter.ai/api/v1' + + +def test_openrouter_client_default_model() -> None: + client = OpenAICompatibleClient('openrouter') + assert client._default_model() == 'openrouter/auto' + + +def test_openrouter_client_not_configured_without_key() -> None: + client = OpenAICompatibleClient('openrouter') + client.settings.openrouter_api_key = '' + assert client.is_configured() is False diff --git a/docs/configuration.md b/docs/configuration.md index 09b30cc..1f5268a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -140,6 +140,17 @@ Variables are loaded at startup through `pydantic-settings`. Names are case-inse | `MISTRAL_INPUT_COST_PER_1M_TOKENS` | `float` | `0.0` | Cost per 1 M input tokens in USD, for cost tracking. | | `MISTRAL_OUTPUT_COST_PER_1M_TOKENS` | `float` | `0.0` | Cost per 1 M output tokens in USD, for cost tracking. | +### OpenRouter + +| Variable | Type | Default | Description | +|---|---|---|---| +| `OPENROUTER_BASE_URL` | `str` | `https://openrouter.ai/api/v1` | Base URL for the OpenRouter API. | +| `OPENROUTER_API_KEY` | `str` | `` (empty) | OpenRouter API key. Required when `LLM_PROVIDER=openrouter`. | +| `OPENROUTER_MODEL` | `str` | `openrouter/auto` | Model name. Use `openrouter/auto` for automatic model selection, or specify any supported model (e.g. `anthropic/claude-3-opus`). | +| `OPENROUTER_TIMEOUT_SECONDS` | `int` | `30` | HTTP request timeout. | +| `OPENROUTER_INPUT_COST_PER_1M_TOKENS` | `float` | `0.0` | Cost per 1 M input tokens in USD, for cost tracking. | +| `OPENROUTER_OUTPUT_COST_PER_1M_TOKENS` | `float` | `0.0` | Cost per 1 M output tokens in USD, for cost tracking. | + --- ## 5. Broker — MetaAPI diff --git a/frontend/src/pages/ConnectorsPage.tsx b/frontend/src/pages/ConnectorsPage.tsx index c508620..fcbb624 100644 --- a/frontend/src/pages/ConnectorsPage.tsx +++ b/frontend/src/pages/ConnectorsPage.tsx @@ -118,11 +118,11 @@ const AGENT_PROMPT_FALLBACKS: Record = }, }; -type LlmProvider = 'ollama' | 'openai' | 'mistral'; +type LlmProvider = 'ollama' | 'openai' | 'mistral' | 'openrouter'; type DecisionMode = 'conservative' | 'balanced' | 'permissive'; const EXECUTION_MODE_OPTIONS: ExecutionMode[] = ['simulation', 'paper', 'live']; -const LLM_PROVIDERS: LlmProvider[] = ['ollama', 'openai', 'mistral']; +const LLM_PROVIDERS: LlmProvider[] = ['ollama', 'openai', 'mistral', 'openrouter']; const DECISION_MODE_OPTIONS: Array<{ value: DecisionMode; label: string; description: string }> = [ { value: 'conservative', @@ -143,6 +143,7 @@ const DECISION_MODE_OPTIONS: Array<{ value: DecisionMode; label: string; descrip function normalizeLlmProvider(value: unknown): LlmProvider { const text = typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (text === 'openrouter') return 'openrouter'; if (text === 'openai') return 'openai'; if (text === 'mistral') return 'mistral'; return 'ollama'; @@ -177,6 +178,7 @@ function normalizeBooleanSetting(value: unknown, fallback = false): boolean { } function defaultModelForProvider(provider: LlmProvider): string { + if (provider === 'openrouter') return 'openrouter/auto'; if (provider === 'openai') return 'gpt-4o-mini'; if (provider === 'mistral') return 'mistral-small-latest'; return 'deepseek-v3.2'; @@ -288,6 +290,7 @@ type SecretFieldKey = | 'ALPHAVANTAGE_API_KEY' | 'OLLAMA_API_KEY' | 'MISTRAL_API_KEY' + | 'OPENROUTER_API_KEY' | 'OPENAI_API_KEY' | 'METAAPI_TOKEN' | 'METAAPI_ACCOUNT_ID'; @@ -307,6 +310,7 @@ const EMPTY_SECRET_FIELDS: Record = { ALPHAVANTAGE_API_KEY: '', OLLAMA_API_KEY: '', MISTRAL_API_KEY: '', + OPENROUTER_API_KEY: '', OPENAI_API_KEY: '', METAAPI_TOKEN: '', METAAPI_ACCOUNT_ID: '', @@ -650,6 +654,7 @@ export function ConnectorsPage() { setSecretFields({ OLLAMA_API_KEY: readConnectorSecret(ollamaSettings, 'OLLAMA_API_KEY'), + OPENROUTER_API_KEY: readConnectorSecret(ollamaSettings, 'OPENROUTER_API_KEY'), OPENAI_API_KEY: readConnectorSecret(ollamaSettings, 'OPENAI_API_KEY'), MISTRAL_API_KEY: readConnectorSecret(ollamaSettings, 'MISTRAL_API_KEY'), NEWSAPI_API_KEY: readConnectorSecret(newsSettings, 'NEWSAPI_API_KEY'), @@ -1191,7 +1196,7 @@ export function ConnectorsPage() { setError(null); try { await Promise.all([ - api.updateConnector(token, 'ollama', buildSettings('ollama', ['OLLAMA_API_KEY', 'OPENAI_API_KEY', 'MISTRAL_API_KEY'])), + api.updateConnector(token, 'ollama', buildSettings('ollama', ['OLLAMA_API_KEY', 'OPENAI_API_KEY', 'MISTRAL_API_KEY', 'OPENROUTER_API_KEY'])), api.updateConnector( token, 'news', @@ -2069,6 +2074,16 @@ export function ConnectorsPage() { />

Current:{maskSecretPreview(secretFields.MISTRAL_API_KEY)}

+ NEWS_PROVIDER_KEYS