diff --git a/.gitignore b/.gitignore index aca154b..830814d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ +node_modules/ + # armada:start /armada/ /.opencode/ /opencode.json # armada:end -node_modules/ diff --git a/AGENTS.md b/AGENTS.md index 91d16be..aab8323 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -149,3 +149,5 @@ parallel as background subagents. + + diff --git a/package.json b/package.json index 48bcc2d..6e7a1da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@rafamacalaba/armada", - "version": "1.2.7", + "version": "1.2.8", "description": "Loop engineering for software development. Turn any repo into a self-organizing AI team — 8 specialists, evidence-gated phases, parallel feature voyages.", "type": "module", "repository": { diff --git a/src/cli.js b/src/cli.js index 950e051..ec93e39 100755 --- a/src/cli.js +++ b/src/cli.js @@ -60,7 +60,7 @@ import { releaseStep1, releaseStep2, validateVersion, productionInjection } from // Track active heartbeat intervals so they can be cleaned up on exit. const activeHeartbeats = new Map() -export const VERSION = "1.2.7" +export const VERSION = "1.2.8" const HELP = `armada v${VERSION} Evidence-gated AI-engineer teams for opencode, natively (no plugin). @@ -457,6 +457,14 @@ async function init(args) { manifest.project.supervision = manifest.project.supervision ?? { plugin: false, fleet: true } manifest.project.supervision.shipnames = false } + const providerIdx = args.findIndex((a) => a === "--openrouter-provider" || a === "--openrouter-providers") + if (providerIdx !== -1 && args[providerIdx + 1] && !args[providerIdx + 1].startsWith("--")) { + const rawVal = args[providerIdx + 1] + const providersList = rawVal.split(",").map((p) => p.trim()).filter(Boolean) + if (providersList.length) { + manifest.project.openrouterProviders = providersList + } + } const reqIdx = args.indexOf("--requirements") if (reqIdx !== -1 && args[reqIdx + 1] && !args[reqIdx + 1].startsWith("--")) { try { diff --git a/src/generator.js b/src/generator.js index 0e30a9f..899d431 100644 --- a/src/generator.js +++ b/src/generator.js @@ -236,8 +236,12 @@ export function renderOpenCodeJson(manifest, team) { if (!id.startsWith("openrouter/")) continue const slug = id.slice("openrouter/".length) if (!slug) continue + const provOpt = { allow_fallbacks: true } + if (manifest.project?.openrouterProviders?.length) { + provOpt.order = manifest.project.openrouterProviders + } openrouterModels[slug] = { - options: { provider: { allow_fallbacks: true } }, + options: { provider: provOpt }, } } } @@ -1129,7 +1133,7 @@ project: yolo: ${manifest.project.yolo ?? false} # Path to the contract file (default: armada/REQUIREMENTS.md) requirementsFile: ${q(manifest.project.requirementsFile ?? "armada/REQUIREMENTS.md")} -${manifest.project.feature ? ` # (optional) Active feature name; sets armada/state/features/.json\n feature: ${q(manifest.project.feature)}\n` : ""}${manifest.project.skills !== undefined ? ` # (optional) Skills to load into the orchestrator prompt\n skills: [${(manifest.project.skills || []).map((s) => q(s)).join(", ")}]\n` : ""} supervision: +${manifest.project.feature ? ` # (optional) Active feature name; sets armada/state/features/.json\n feature: ${q(manifest.project.feature)}\n` : ""}${manifest.project.skills !== undefined ? ` # (optional) Skills to load into the orchestrator prompt\n skills: [${(manifest.project.skills || []).map((s) => q(s)).join(", ")}]\n` : ""}${manifest.project.openrouterProviders !== undefined ? ` # (optional) Preferred OpenRouter provider routing order (e.g. Novita, DeepInfra)\n openrouter_providers: [${(manifest.project.openrouterProviders || []).map((p) => q(p)).join(", ")}]\n` : ""} supervision: # Emit .opencode/plugins/armada-supervision.js plugin: ${manifest.project.supervision?.plugin ?? false} # Emit per-lane fleet dashboard (default: true) diff --git a/src/init-summary.js b/src/init-summary.js index 7081d4f..ce47626 100644 --- a/src/init-summary.js +++ b/src/init-summary.js @@ -44,5 +44,11 @@ export function renderInitSummary(manifest) { ) } + if (project.openrouterProviders?.length) { + lines.push( + `OpenRouter Provider Order: ${project.openrouterProviders.join(", ")}`, + ) + } + return lines.join("\n") } diff --git a/src/manifest.js b/src/manifest.js index 7118785..d2fb603 100644 --- a/src/manifest.js +++ b/src/manifest.js @@ -101,6 +101,20 @@ function validateSkills(skills) { return skills } +function validateOpenRouterProviders(providers) { + if (providers === undefined || providers === null) return undefined + const list = typeof providers === "string" ? [providers] : providers + if (!Array.isArray(list)) { + throw new Error("armada.yaml: schema violation: project.openrouter_providers must be a string or list of strings") + } + for (const p of list) { + if (typeof p !== "string" || p.trim() === "") { + throw new Error("armada.yaml: schema violation: project.openrouter_providers entries must be non-empty strings") + } + } + return list.map((p) => p.trim()) +} + export function parseManifestYaml(text, target) { let raw try { @@ -169,6 +183,7 @@ export function parseManifestYaml(text, target) { if (!team.length) throw new Error("armada.yaml: team is empty") validateRequirementsFile(p.requirementsFile ?? "armada/REQUIREMENTS.md") const skills = validateSkills(p.skills) + const openrouterProviders = validateOpenRouterProviders(p.openrouter_providers) return { project: { name: p.name ?? "project", @@ -181,12 +196,14 @@ export function parseManifestYaml(text, target) { requirementsFile: p.requirementsFile ?? "armada/REQUIREMENTS.md", feature: p.feature ?? null, skills, + openrouterProviders, supervision: { plugin: p.supervision?.plugin ?? false, fleet: p.supervision?.fleet ?? true, watchdog: p.supervision?.watchdog ?? false, shipnames: p.supervision?.shipnames ?? true, }, + stack: { frontend: stack.frontend ?? null, backend: stack.backend ?? null, diff --git a/src/openrouter-api.js b/src/openrouter-api.js new file mode 100644 index 0000000..f3e2862 --- /dev/null +++ b/src/openrouter-api.js @@ -0,0 +1,50 @@ +// OpenRouter API helper for querying provider endpoints, pricing, and discounts. + +export async function fetchModelEndpoints(modelSlug) { + try { + const slugWithoutPrefix = modelSlug.replace(/^openrouter\//, "").replace(/^opencode-go\//, "") + const modelsRes = await fetch("https://openrouter.ai/api/v1/models") + if (!modelsRes.ok) return null + const modelsData = await modelsRes.json() + const model = modelsData.data?.find( + (m) => m.id === slugWithoutPrefix || m.canonical_slug === slugWithoutPrefix + ) + if (!model) return null + + const detailsUrl = model.links?.details + ? `https://openrouter.ai${model.links.details}` + : `https://openrouter.ai/api/v1/models/${encodeURIComponent(model.canonical_slug || model.id)}/endpoints` + + const endpointsRes = await fetch(detailsUrl) + if (!endpointsRes.ok) return null + const endpointsData = await endpointsRes.json() + const endpoints = endpointsData.data?.endpoints ?? [] + if (!endpoints.length) return null + + const parsed = endpoints.map((e) => { + const promptCost = parseFloat(e.pricing?.prompt ?? "0") * 1_000_000 + const completionCost = parseFloat(e.pricing?.completion ?? "0") * 1_000_000 + return { + providerName: e.provider_name, + promptCostPerM: promptCost, + completionCostPerM: completionCost, + totalCostPerM: promptCost + completionCost, + discount: e.pricing?.discount ?? 0, + uptime: e.uptime_last_30m ?? 100, + } + }) + + // Deduplicate by providerName, keeping lowest cost + const map = new Map() + for (const p of parsed) { + if (!map.has(p.providerName) || p.totalCostPerM < map.get(p.providerName).totalCostPerM) { + map.set(p.providerName, p) + } + } + + const sorted = Array.from(map.values()).sort((a, b) => a.totalCostPerM - b.totalCostPerM) + return sorted + } catch (_err) { + return null + } +} diff --git a/tests/openrouter-provider.test.js b/tests/openrouter-provider.test.js new file mode 100644 index 0000000..fdb42ee --- /dev/null +++ b/tests/openrouter-provider.test.js @@ -0,0 +1,77 @@ +import { test } from "node:test" +import assert from "node:assert" +import { parseManifestYaml } from "../src/manifest.js" +import { renderOpenCodeJson, renderManifestYaml, mergeOpenCodeJson } from "../src/generator.js" +import { fetchModelEndpoints } from "../src/openrouter-api.js" + +test("parseManifestYaml parses openrouter_providers string and array", () => { + const yamlArr = ` +project: + name: test + budget: balanced + openrouter_providers: + - Novita + - DeepInfra +team: + - role: qa + model: opencode-go/minimax-m3 + enabled: true +` + const parsedArr = parseManifestYaml(yamlArr) + assert.deepStrictEqual(parsedArr.project.openrouterProviders, ["Novita", "DeepInfra"]) + + const yamlStr = ` +project: + name: test + budget: balanced + openrouter_providers: Novita +team: + - role: qa + model: opencode-go/minimax-m3 + enabled: true +` + const parsedStr = parseManifestYaml(yamlStr) + assert.deepStrictEqual(parsedStr.project.openrouterProviders, ["Novita"]) +}) + +test("renderOpenCodeJson includes order in options.provider when openrouterProviders is set", () => { + const manifest = { + project: { + name: "test", + budget: "balanced", + openrouterProviders: ["Novita"], + }, + } + const team = [ + { role: "orchestrator", model: "openrouter/z-ai/glm-5.2", fallback: null, enabled: true }, + ] + const opencodeJson = renderOpenCodeJson(manifest, team) + assert.deepStrictEqual(opencodeJson.provider.openrouter.models["z-ai/glm-5.2"].options.provider, { + allow_fallbacks: true, + order: ["Novita"], + }) +}) + +test("renderManifestYaml outputs openrouter_providers block", () => { + const manifest = { + project: { + name: "test", + budget: "balanced", + openrouterProviders: ["Novita"], + }, + } + const team = [ + { role: "qa", model: "opencode-go/minimax-m3", fallback: null, enabled: true }, + ] + const yaml = renderManifestYaml(manifest, team) + assert.match(yaml, /openrouter_providers: \["Novita"\]/) +}) + +test("fetchModelEndpoints retrieves provider endpoint discounts from OpenRouter API", async () => { + const endpoints = await fetchModelEndpoints("z-ai/glm-5.2") + assert.ok(Array.isArray(endpoints), "endpoints is an array") + assert.ok(endpoints.length > 0, "endpoints has entries") + const novita = endpoints.find((e) => e.providerName === "Novita") + assert.ok(novita, "Novita provider entry found") + assert.ok(novita.promptCostPerM > 0, "Novita prompt cost per M parsed") +})