Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
node_modules/

# armada:start
/armada/
/.opencode/
/opencode.json
# armada:end
node_modules/
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,5 @@ parallel as background subagents.





2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
10 changes: 9 additions & 1 deletion src/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 {
Expand Down
8 changes: 6 additions & 2 deletions src/generator.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
}
}
}
Expand Down Expand Up @@ -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/<name>.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/<name>.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)
Expand Down
6 changes: 6 additions & 0 deletions src/init-summary.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,11 @@ export function renderInitSummary(manifest) {
)
}

if (project.openrouterProviders?.length) {
lines.push(
`OpenRouter Provider Order: ${project.openrouterProviders.join(", ")}`,
)
}

return lines.join("\n")
}
17 changes: 17 additions & 0 deletions src/manifest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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",
Expand All @@ -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,
Expand Down
50 changes: 50 additions & 0 deletions src/openrouter-api.js
Original file line number Diff line number Diff line change
@@ -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
}
}
77 changes: 77 additions & 0 deletions tests/openrouter-provider.test.js
Original file line number Diff line number Diff line change
@@ -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")
})