Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5972cd2fbc | ||
|
|
0661c2cb83 | ||
|
|
c97f52de82 | ||
|
|
6de15ad5f5 |
@@ -28,11 +28,16 @@ const GATEWAY = process.env.LH_GATEWAY_URL ?? "http://localhost:3100";
|
|||||||
// retries — verified with trivial 8-token probes). Swapped default to
|
// retries — verified with trivial 8-token probes). Swapped default to
|
||||||
// deepseek-v3.1:671b which is proven working end-to-end through the
|
// deepseek-v3.1:671b which is proven working end-to-end through the
|
||||||
// pr_audit mode runner during Phase 5 distillation acceptance testing.
|
// pr_audit mode runner during Phase 5 distillation acceptance testing.
|
||||||
// kimi-k2:1t can be re-selected via LH_AUDITOR_REVIEW_MODEL env when
|
//
|
||||||
// the upstream returns. Tie-breaker stays grok-4.1-fast (different
|
// 2026-09-02 update: Ollama Cloud subscription lapsed — every
|
||||||
// vendor lineage so consensus + tie-break won't fail-correlate).
|
// ollama_cloud model 402s ("requires a subscription"). J's call: sweep
|
||||||
const MODEL = process.env.LH_AUDITOR_REVIEW_MODEL ?? "deepseek-v3.1:671b";
|
// the auditor to OpenRouter. Same deepseek lineage via OpenRouter;
|
||||||
const TIEBREAKER_MODEL = process.env.LH_AUDITOR_TIEBREAKER_MODEL ?? "x-ai/grok-4.1-fast";
|
// tie-breaker moves to grok-4.3 because OpenRouter 404s grok-4.1-fast
|
||||||
|
// as deprecated. Both probed live through the gateway before landing.
|
||||||
|
// Vendor lineages stay distinct so consensus + tie-break won't
|
||||||
|
// fail-correlate.
|
||||||
|
const MODEL = process.env.LH_AUDITOR_REVIEW_MODEL ?? "openrouter/deepseek/deepseek-chat-v3.1";
|
||||||
|
const TIEBREAKER_MODEL = process.env.LH_AUDITOR_TIEBREAKER_MODEL ?? "openrouter/x-ai/grok-4.3";
|
||||||
const N_CONSENSUS = Number(process.env.LH_AUDITOR_CONSENSUS_N ?? 3);
|
const N_CONSENSUS = Number(process.env.LH_AUDITOR_CONSENSUS_N ?? 3);
|
||||||
const AUDIT_DISCREPANCIES_JSONL = "/home/profit/lakehouse/data/_kb/audit_discrepancies.jsonl";
|
const AUDIT_DISCREPANCIES_JSONL = "/home/profit/lakehouse/data/_kb/audit_discrepancies.jsonl";
|
||||||
// 40KB comfortably fits the consensus models' context windows
|
// 40KB comfortably fits the consensus models' context windows
|
||||||
|
|||||||
@@ -51,13 +51,15 @@ const CALL_TIMEOUT_MS = 900_000;
|
|||||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
const MAX_DIFF_CHARS = 180_000;
|
const MAX_DIFF_CHARS = 180_000;
|
||||||
const MAX_PRIOR_FINDINGS = 50;
|
const MAX_PRIOR_FINDINGS = 50;
|
||||||
// Default provider/model = ollama_cloud/kimi-k2.6. Pre-2026-04-27 we
|
// Default provider/model = openrouter/moonshotai/kimi-k2.6.
|
||||||
// went direct to api.kimi.com, but Ollama Cloud Pro now exposes the
|
// History: pre-2026-04-27 direct api.kimi.com (User-Agent gating),
|
||||||
// same model legitimately, so we route there to avoid User-Agent
|
// then ollama_cloud/kimi-k2.6 while the sub was active. 2026-09-02:
|
||||||
// gating. The api.kimi.com path (provider=kimi) remains wired in the
|
// Ollama Cloud subscription lapsed (402 on every cloud model), J's
|
||||||
// gateway as a fallback for when Ollama Cloud is upstream-broken.
|
// call was to sweep the auditor to OpenRouter — same Moonshot model,
|
||||||
const KIMI_PROVIDER = process.env.LH_AUDITOR_KIMI_PROVIDER ?? "ollama_cloud";
|
// probed live through the gateway. The api.kimi.com path
|
||||||
const KIMI_MODEL = process.env.LH_AUDITOR_KIMI_MODEL ?? "kimi-k2.6";
|
// (provider=kimi) remains wired in the gateway as a fallback.
|
||||||
|
const KIMI_PROVIDER = process.env.LH_AUDITOR_KIMI_PROVIDER ?? "openrouter";
|
||||||
|
const KIMI_MODEL = process.env.LH_AUDITOR_KIMI_MODEL ?? "moonshotai/kimi-k2.6";
|
||||||
// Cross-lineage alternation. 2026-04-27 J's call: Opus is too
|
// Cross-lineage alternation. 2026-04-27 J's call: Opus is too
|
||||||
// expensive to auto-fire (~$0.30/audit). Kimi K2.6 via Go-sub is
|
// expensive to auto-fire (~$0.30/audit). Kimi K2.6 via Go-sub is
|
||||||
// effectively free; Haiku 4.5 via Zen is ~$0.04. Alternate between
|
// effectively free; Haiku 4.5 via Zen is ~$0.04. Alternate between
|
||||||
@@ -111,7 +113,7 @@ function maxTokensFor(model: string): number {
|
|||||||
if (MAX_TOKENS_OVERRIDE > 0) return MAX_TOKENS_OVERRIDE;
|
if (MAX_TOKENS_OVERRIDE > 0) return MAX_TOKENS_OVERRIDE;
|
||||||
if (model.startsWith("claude-opus")) return 32_000;
|
if (model.startsWith("claude-opus")) return 32_000;
|
||||||
if (model.startsWith("claude-haiku") || model.startsWith("claude-sonnet")) return 8_192;
|
if (model.startsWith("claude-haiku") || model.startsWith("claude-sonnet")) return 8_192;
|
||||||
if (model.startsWith("kimi-")) return 128_000;
|
if (model.startsWith("kimi-") || model.includes("/kimi-")) return 128_000;
|
||||||
if (model.startsWith("gpt-5") || model.startsWith("o1") || model.startsWith("o3") || model.startsWith("o4")) return 32_000;
|
if (model.startsWith("gpt-5") || model.startsWith("o1") || model.startsWith("o3") || model.startsWith("o4")) return 32_000;
|
||||||
return 16_000;
|
return 16_000;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-10
@@ -9,28 +9,42 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import type { PrSnapshot } from "./types.ts";
|
import type { PrSnapshot } from "./types.ts";
|
||||||
|
|
||||||
const HOST = process.env.GITEA_HOST ?? "https://git.agentview.dev";
|
// 2026-09-02: repo home moved to git.warren.help in the VPS
|
||||||
|
// consolidation; git.agentview.dev now dead-ends at a Traefik default
|
||||||
|
// cert + 404 (auditor had been failing listOpenPrs every cycle since
|
||||||
|
// Aug 25). Credentials come from ~/.git-credentials for HOST's domain,
|
||||||
|
// falling back to the user:pass embedded in this repo's origin remote.
|
||||||
|
// Basic auth works for both a PAT and a password on Gitea.
|
||||||
|
const HOST = process.env.GITEA_HOST ?? "https://git.warren.help";
|
||||||
const OWNER = "profit";
|
const OWNER = "profit";
|
||||||
const REPO = "lakehouse";
|
const REPO = "lakehouse";
|
||||||
const CRED_FILE = "/home/profit/.git-credentials";
|
const CRED_FILE = "/home/profit/.git-credentials";
|
||||||
|
const GIT_CONFIG = "/home/profit/lakehouse/.git/config";
|
||||||
|
|
||||||
let cachedPat: string | null = null;
|
let cachedAuth: string | null = null;
|
||||||
|
|
||||||
async function getPat(): Promise<string> {
|
async function getAuthHeader(): Promise<string> {
|
||||||
if (cachedPat) return cachedPat;
|
if (cachedAuth) return cachedAuth;
|
||||||
const raw = await readFile(CRED_FILE, "utf8");
|
const hostDomain = new URL(HOST).host.replace(/\./g, "\\.");
|
||||||
|
const credRe = new RegExp(`^https://([^:]+):([^@]+)@${hostDomain}`);
|
||||||
|
for (const file of [CRED_FILE, GIT_CONFIG]) {
|
||||||
|
let raw: string;
|
||||||
|
try { raw = await readFile(file, "utf8"); } catch { continue; }
|
||||||
for (const line of raw.split("\n")) {
|
for (const line of raw.split("\n")) {
|
||||||
const m = line.match(/^https:\/\/[^:]+:([^@]+)@git\.agentview\.dev/);
|
const m = line.trim().replace(/^url = /, "").match(credRe);
|
||||||
if (m) { cachedPat = m[1]; return m[1]; }
|
if (m) {
|
||||||
|
cachedAuth = `Basic ${Buffer.from(`${decodeURIComponent(m[1])}:${decodeURIComponent(m[2])}`).toString("base64")}`;
|
||||||
|
return cachedAuth;
|
||||||
}
|
}
|
||||||
throw new Error(`no Gitea PAT in ${CRED_FILE}`);
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`no Gitea credentials for ${HOST} in ${CRED_FILE} or ${GIT_CONFIG}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function giteaFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
async function giteaFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||||
const pat = await getPat();
|
|
||||||
const url = `${HOST}/api/v1${path}`;
|
const url = `${HOST}/api/v1${path}`;
|
||||||
const headers = new Headers(init.headers);
|
const headers = new Headers(init.headers);
|
||||||
headers.set("Authorization", `token ${pat}`);
|
headers.set("Authorization", await getAuthHeader());
|
||||||
if (init.body && !headers.has("content-type")) {
|
if (init.body && !headers.has("content-type")) {
|
||||||
headers.set("content-type", "application/json");
|
headers.set("content-type", "application/json");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ pub async fn chat(
|
|||||||
// form so OpenRouter sees the raw model id (e.g. "openai/gpt-oss-120b:free").
|
// form so OpenRouter sees the raw model id (e.g. "openai/gpt-oss-120b:free").
|
||||||
let model = req.model.strip_prefix("openrouter/").unwrap_or(&req.model).to_string();
|
let model = req.model.strip_prefix("openrouter/").unwrap_or(&req.model).to_string();
|
||||||
|
|
||||||
let body = ORChatBody {
|
let mut body = ORChatBody {
|
||||||
model: model.clone(),
|
model: model.clone(),
|
||||||
// Pass content through verbatim — preserves OpenAI's multimodal
|
// Pass content through verbatim — preserves OpenAI's multimodal
|
||||||
// content-parts shape (`[{type:"text",text:"..."}, ...]`) so the
|
// content-parts shape (`[{type:"text",text:"..."}, ...]`) so the
|
||||||
@@ -66,7 +66,7 @@ pub async fn chat(
|
|||||||
role: m.role.clone(),
|
role: m.role.clone(),
|
||||||
content: m.content.clone(),
|
content: m.content.clone(),
|
||||||
}).collect(),
|
}).collect(),
|
||||||
max_tokens: req.max_tokens.unwrap_or(800),
|
max_tokens: Some(req.max_tokens.unwrap_or(800)),
|
||||||
temperature: req.temperature.unwrap_or(0.3),
|
temperature: req.temperature.unwrap_or(0.3),
|
||||||
stream: false,
|
stream: false,
|
||||||
};
|
};
|
||||||
@@ -77,7 +77,8 @@ pub async fn chat(
|
|||||||
.map_err(|e| format!("build client: {e}"))?;
|
.map_err(|e| format!("build client: {e}"))?;
|
||||||
|
|
||||||
let t0 = std::time::Instant::now();
|
let t0 = std::time::Instant::now();
|
||||||
let resp = client
|
let send = |client: reqwest::Client, key: String, body_json: serde_json::Value| async move {
|
||||||
|
client
|
||||||
.post(format!("{}/chat/completions", OR_BASE_URL))
|
.post(format!("{}/chat/completions", OR_BASE_URL))
|
||||||
.bearer_auth(key)
|
.bearer_auth(key)
|
||||||
// OpenRouter recommends Referer + Title for attribution; absent
|
// OpenRouter recommends Referer + Title for attribution; absent
|
||||||
@@ -85,10 +86,52 @@ pub async fn chat(
|
|||||||
// their dashboard.
|
// their dashboard.
|
||||||
.header("HTTP-Referer", "https://vcp.devop.live")
|
.header("HTTP-Referer", "https://vcp.devop.live")
|
||||||
.header("X-Title", "Lakehouse Scrum")
|
.header("X-Title", "Lakehouse Scrum")
|
||||||
.json(&body)
|
.json(&body_json)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("openrouter.ai unreachable: {e}"))?;
|
.map_err(|e| format!("openrouter.ai unreachable: {e}"))
|
||||||
|
};
|
||||||
|
let mut resp = send(
|
||||||
|
client.clone(),
|
||||||
|
key.to_string(),
|
||||||
|
serde_json::to_value(&body).map_err(|e| format!("serialize body: {e}"))?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// OpenRouter's affordability precheck reserves max_tokens × price against
|
||||||
|
// the account balance. Measured 2026-08-28: stripping the cap makes it
|
||||||
|
// WORSE (the precheck then prices the model's own maximum, e.g. 65536) —
|
||||||
|
// so a low-balance 402 surfaces verbatim and the fix is funding the
|
||||||
|
// account the key belongs to, not request-shape games. One clamp retry:
|
||||||
|
// when the error names an affordable budget ≥256 tokens, retry once at
|
||||||
|
// that budget so small answers still get through on a thin balance.
|
||||||
|
if resp.status() == reqwest::StatusCode::PAYMENT_REQUIRED && body.max_tokens.is_some() {
|
||||||
|
let err_body = resp.text().await.unwrap_or_else(|_| "?".into());
|
||||||
|
let afford = err_body
|
||||||
|
.split("can only afford ")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
|
||||||
|
.and_then(|n| n.parse::<u32>().ok());
|
||||||
|
match afford {
|
||||||
|
Some(n) if n >= 256 => {
|
||||||
|
tracing::warn!(
|
||||||
|
target: "v1.chat",
|
||||||
|
provider = "openrouter",
|
||||||
|
model = %model,
|
||||||
|
afford = n,
|
||||||
|
"402 affordability precheck — clamping max_tokens to the stated budget",
|
||||||
|
);
|
||||||
|
body.max_tokens = Some(n);
|
||||||
|
resp = send(
|
||||||
|
client.clone(),
|
||||||
|
key.to_string(),
|
||||||
|
serde_json::to_value(&body).map_err(|e| format!("serialize body: {e}"))?,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
_ => return Err(format!("openrouter.ai 402 Payment Required: {}", err_body)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let status = resp.status();
|
let status = resp.status();
|
||||||
if !status.is_success() {
|
if !status.is_success() {
|
||||||
@@ -102,7 +145,21 @@ pub async fn chat(
|
|||||||
let latency_ms = t0.elapsed().as_millis();
|
let latency_ms = t0.elapsed().as_millis();
|
||||||
let choice = parsed.choices.into_iter().next()
|
let choice = parsed.choices.into_iter().next()
|
||||||
.ok_or_else(|| "openrouter returned no choices".to_string())?;
|
.ok_or_else(|| "openrouter returned no choices".to_string())?;
|
||||||
let text = choice.message.content;
|
let reasoning_only = choice.message.content.is_none() && choice.message.reasoning.is_some();
|
||||||
|
let text = choice.message.content.unwrap_or_default();
|
||||||
|
if reasoning_only {
|
||||||
|
// The model spent its whole budget thinking. Return well-formed empty
|
||||||
|
// content (callers' schema validation + retry budgets handle it
|
||||||
|
// deterministically) rather than a decode error that reads as a
|
||||||
|
// gateway fault — and never pass thinking-text off as an answer.
|
||||||
|
tracing::warn!(
|
||||||
|
target: "v1.chat",
|
||||||
|
provider = "openrouter",
|
||||||
|
model = %model,
|
||||||
|
finish = %choice.finish_reason.as_deref().unwrap_or("?"),
|
||||||
|
"reasoning-only response (content null) — raise max_tokens or use a non-reasoning model",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let prompt_tokens = parsed.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or_else(|| {
|
let prompt_tokens = parsed.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or_else(|| {
|
||||||
let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum();
|
let chars: usize = req.messages.iter().map(|m| m.text().chars().count()).sum();
|
||||||
@@ -146,7 +203,8 @@ pub async fn chat(
|
|||||||
struct ORChatBody {
|
struct ORChatBody {
|
||||||
model: String,
|
model: String,
|
||||||
messages: Vec<ORMessage>,
|
messages: Vec<ORMessage>,
|
||||||
max_tokens: u32,
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
max_tokens: Option<u32>,
|
||||||
temperature: f64,
|
temperature: f64,
|
||||||
stream: bool,
|
stream: bool,
|
||||||
}
|
}
|
||||||
@@ -169,7 +227,18 @@ struct ORChoice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ORMessageResp { content: String }
|
struct ORMessageResp {
|
||||||
|
// Reasoning models (GLM-5.x, deepseek-v4-pro) return `content: null` plus a
|
||||||
|
// `reasoning` field when they exhaust max_tokens mid-thought. A bare String
|
||||||
|
// here made serde reject the whole body ("invalid openrouter response",
|
||||||
|
// 2026-08-28) — which took down every reasoning model on the lane,
|
||||||
|
// including phase2's configured glm-5.2. Content stays the only text we
|
||||||
|
// ever return; reasoning is observability, never an answer.
|
||||||
|
#[serde(default)]
|
||||||
|
content: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
reasoning: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct ORUsage { prompt_tokens: u32, completion_tokens: u32 }
|
struct ORUsage { prompt_tokens: u32, completion_tokens: u32 }
|
||||||
@@ -178,6 +247,21 @@ struct ORUsage { prompt_tokens: u32, completion_tokens: u32 }
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reasoning_model_null_content_parses() {
|
||||||
|
// GLM-5.x / deepseek-v4-pro shape: content null + reasoning present
|
||||||
|
// (the 2026-08-28 decode failure). Must parse, not error.
|
||||||
|
let body = r#"{"choices":[{"message":{"role":"assistant","content":null,"reasoning":"thinking…"},"finish_reason":"length"}],"usage":{"prompt_tokens":5,"completion_tokens":10}}"#;
|
||||||
|
let parsed: ORChatResponse = serde_json::from_str(body).expect("null content must parse");
|
||||||
|
let choice = &parsed.choices[0];
|
||||||
|
assert!(choice.message.content.is_none());
|
||||||
|
assert_eq!(choice.message.reasoning.as_deref(), Some("thinking…"));
|
||||||
|
// And the normal shape still parses with reasoning absent.
|
||||||
|
let normal = r#"{"choices":[{"message":{"role":"assistant","content":"OK"},"finish_reason":"stop"}]}"#;
|
||||||
|
let parsed: ORChatResponse = serde_json::from_str(normal).expect("normal shape");
|
||||||
|
assert_eq!(parsed.choices[0].message.content.as_deref(), Some("OK"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn resolve_openrouter_key_does_not_panic() {
|
fn resolve_openrouter_key_does_not_panic() {
|
||||||
// Smoke test — all three sources may or may not be set depending
|
// Smoke test — all three sources may or may not be set depending
|
||||||
@@ -192,7 +276,7 @@ mod tests {
|
|||||||
messages: vec![
|
messages: vec![
|
||||||
ORMessage { role: "user".into(), content: "review this".into() },
|
ORMessage { role: "user".into(), content: "review this".into() },
|
||||||
],
|
],
|
||||||
max_tokens: 800,
|
max_tokens: Some(800),
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
stream: false,
|
stream: false,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user