Skip to content
Open
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: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -689,6 +689,9 @@ NEXT_PUBLIC_CLOUD_URL=
# NEXT_PUBLIC_BASE_URL. Only enable if direct client access to AgentProxy is blocked
# and your proxy strips/rebuilds incoming forwarded headers.
# Values: true/loopback (trust loopback proxy peers), private/lan (also trust LAN peers).
# When using private/lan for the API bridge, list each trusted private proxy peer explicitly.
# Literal IPv4/IPv6 addresses only; CIDR and DNS names are intentionally rejected.
# AGENTPROXY_TRUSTED_PROXY_PEERS=192.168.1.10,fd00::10

# Proxy addresses whose X-Forwarded-For / X-Real-IP the IP allow/deny list may believe, on top
# of loopback, private-network addresses and Cloudflare edges, which are always trusted. Needed
Expand Down
4 changes: 3 additions & 1 deletion docs/ops/VM_DEPLOYMENT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ for setting the explicit public URL when OAuth or generated browser links need o
`AGENTPROXY_TRUST_PROXY` if AgentProxy is not directly reachable by clients and your proxy
strips/rebuilds incoming forwarded headers.

For a private-LAN reverse proxy forwarding through the API bridge, also set `AGENTPROXY_TRUSTED_PROXY_PEERS` to the comma-separated literal IP addresses of your trusted proxy peers. The bridge ignores forwarding headers from all other LAN clients, even with `AGENTPROXY_TRUST_PROXY=private`.

### 3.3 Enable and Test

```bash
Expand Down Expand Up @@ -421,7 +423,7 @@ See also [TUNNELS_GUIDE.md](./TUNNELS_GUIDE.md) for the in-repo Cloudflare Tunne
| 22 | SSH | Public (with fail2ban) |
| 80 | nginx HTTP | Redirect → HTTPS |
| 443 | nginx HTTPS | Via Cloudflare Proxy |
| 20128 | AgentProxy | Localhost only (via nginx) |
| 20128 | AgentProxy | Localhost only (via nginx) |

## Low-Memory / Small VPS Optimization

Expand Down
1 change: 1 addition & 0 deletions docs/reference/ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ AgentProxy provides a two-layer defense: request-side injection scanning and res
| `AGENTPROXY_PROVIDER_MANIFEST_URL` | _(unset)_ | `open-sse/config/providerPluginManifestUrl.ts` | Absolute provider plugin manifest URL advertised to sidecar clients. When unset, AgentProxy derives `/api/v1/provider-plugin-manifest` from request origin or HOST/PORT. |
| `AGENTPROXY_PUBLIC_PROTOCOL` | `http` | `open-sse/config/providerPluginManifestUrl.ts` | Protocol used when deriving the provider plugin manifest URL from HOST/PORT without a request origin. Set to `https` behind a TLS-terminating public proxy when no explicit `AGENTPROXY_PROVIDER_MANIFEST_URL` is set. |
| `AGENTPROXY_TRUST_PROXY` | _(unset)_ | `src/server/origin/publicOrigin.ts` | Optional trust mode for forwarded public-origin headers. Unset = do not trust `Forwarded` / `X-Forwarded-*` for security decisions. `true` / `loopback` trusts forwarded host/proto only from a token-stamped loopback proxy. `private` / `lan` also trusts private-LAN proxy peers. Prefer explicit `NEXT_PUBLIC_BASE_URL` in production. |
| `AGENTPROXY_TRUSTED_PROXY_PEERS` | _(unset)_ | `src/lib/apiBridgeServer.ts` | Comma-separated literal private proxy IPs (IPv4/IPv6, no CIDR/DNS). Required in addition to `AGENTPROXY_TRUST_PROXY=private` or `lan` before the API bridge trusts forwarding headers from a LAN peer. Unlisted direct LAN clients are treated as remote and their supplied forwarding headers are dropped. |
| `KIE_CALLBACK_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public callback URL for asynchronous kie.ai jobs. Highest-priority override before `AGENTPROXY_KIE_CALLBACK_URL` and `AGENTPROXY_PUBLIC_URL`. |
| `AGENTPROXY_KIE_CALLBACK_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Alternate spelling of `KIE_CALLBACK_URL`. Falls back when the primary variable is unset. |
| `AGENTPROXY_PUBLIC_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public origin used to compose async callback URLs. Lowest-priority fallback for kie.ai callbacks; also used as a generic public URL for other relays. |
Expand Down
11 changes: 9 additions & 2 deletions src/app/api/models/test-all/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const testAllSchema = z.object({
});

export interface BatchTestResultEntry {
status: "ok" | "error" | "slow";
status: "ok" | "error" | "slow" | "skipped";
latencyMs: number;
responseText?: string;
error?: string;
Expand All @@ -56,7 +56,14 @@ function toBatchEntry(
result: Awaited<ReturnType<typeof runSingleModelTest>>
): BatchTestResultEntry {
const entry: BatchTestResultEntry = {
status: result.status === "ok" ? "ok" : result.status === "slow" ? "slow" : "error",
status:
result.status === "ok"
? "ok"
: result.status === "slow"
? "slow"
: result.status === "skipped"
? "skipped"
: "error",
Comment thread
khanhkit marked this conversation as resolved.
latencyMs: result.latencyMs,
};
if (result.responseText !== undefined) entry.responseText = result.responseText;
Expand Down
7 changes: 7 additions & 0 deletions src/app/api/models/test/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ export async function POST(request: Request) {
responseText: result.responseText,
});
}
if (result.status === "skipped") {
return NextResponse.json({
status: "skipped",
latencyMs: result.latencyMs,
reason: result.error,
});
}

const body: Record<string, unknown> = {
status: "error",
Expand Down
1 change: 1 addition & 0 deletions src/app/api/services/openwa/_lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export async function getOrInitSupervisor(): Promise<ServiceSupervisor> {
// on first pairing. See bootstrap.ts's SERVICES[] "openwa" entry comment
// for the full verified explanation (must stay in sync with this value).
healthIntervalMs: 60_000,
healthFailureDuringStartup: "tolerate",
stopTimeoutMs: 30_000,
logsBufferBytes: 5_242_880,
// #6205: mirrors bootstrap.ts's own supervisor construction — adopt a
Expand Down
14 changes: 12 additions & 2 deletions src/lib/agentSkills/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,14 +163,24 @@ function buildApiDocuments(skill: AgentSkill, sources: BuildSources): ApiSkillDo
return { body: inlineBody, references: [] };
}

// GitHub Markdown suffixes duplicate heading slugs; both indexes must match.
const usedAnchors = new Set<string>();
const endpointAnchors = endpointBlocks.map((block) => {
const base = markdownAnchor(block.title);
let anchor = base;
let suffix = 1;
while (usedAnchors.has(anchor)) anchor = `${base}-${suffix++}`;
usedAnchors.add(anchor);
return anchor;
});
const endpointIndex = endpointBlocks.map(
(block) => `- [\`${block.title}\`](references/endpoints.md#${markdownAnchor(block.title)})`
(block, index) => `- [\`${block.title}\`](references/endpoints.md#${endpointAnchors[index]})`
);
const indexedLines = [...lines, "## Endpoints\n", ...endpointIndex, ...payloadLines];

const referencePreamble = [GENERATED_COMMENT, "", "# Endpoint reference", ""];
const tableOfContents = endpointBlocks.map(
(block) => `- [\`${block.title}\`](#${markdownAnchor(block.title)})`
(block, index) => `- [\`${block.title}\`](#${endpointAnchors[index]})`
);
const needsTableOfContents =
referencePreamble.length + tableOfContents.length + 2 + endpointLines.length >
Expand Down
2 changes: 1 addition & 1 deletion src/lib/agentSkills/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export interface AgentSkill {
area: SkillArea;
endpoints?: string[]; // e.g. ["POST /api/providers", "GET /api/providers/:id"] (api only)
cliCommands?: string[]; // e.g. ["providers list", "providers test", "providers rotate"] (cli only)
cliBinary?: string; // generated CLI package/binary override; defaults to legacy "omniroute"
cliBinary?: string; // generated CLI package/binary override; defaults to "agentproxy"
icon?: string; // Material symbol name
isEntry?: boolean; // "start here" tag
isNew?: boolean; // "new" tag
Expand Down
6 changes: 3 additions & 3 deletions src/lib/api/modelTestRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ export interface RunSingleModelTestOptions {

export interface SingleModelTestResult {
modelId: string;
status: "ok" | "error" | "rate_limited" | "slow";
status: "ok" | "error" | "rate_limited" | "slow" | "skipped";
latencyMs: number;
responseText?: string;
statusCode?: number;
Expand Down Expand Up @@ -484,9 +484,9 @@ export async function runSingleModelTest(
if (isNonChatGeneration) {
return {
modelId: fullModelStr,
status: "error",
status: "skipped",
latencyMs: 0,
httpStatus: 422,
httpStatus: 200,
error:
"Skipped: non-chat generation model (images/music/video) — use the corresponding generation endpoint instead",
};
Expand Down
18 changes: 17 additions & 1 deletion src/lib/apiBridgeServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,27 @@ const FORWARDING_HEADERS = new Set([
"x-client-ip",
]);

function isExplicitTrustedPrivateProxy(peer: string): boolean {
const configured = process.env.AGENTPROXY_TRUSTED_PROXY_PEERS;
if (!configured) return false;
return configured.split(",").some((entry) => {
const candidate = entry.trim();
return (
net.isIP(candidate) !== 0 &&
Comment thread
khanhkit marked this conversation as resolved.
candidate.replace(/^::ffff:/, "").toLowerCase() === peer.toLowerCase()
Comment on lines +77 to +78

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
IPv6 peers are compared as raw strings after only stripping ::ffff:. Equivalent IPv6 spellings (for example fd00::1 versus fd00:0:0:0:0:0:0:1) will not match, so a configured trusted proxy can be classified as remote; forwarding headers are then stripped and the proxy address is recorded as the client. Normalize both addresses via parsed IP/binary representation before comparison, or require canonical configuration and validate it explicitly.

Suggestion:

Suggested change
net.isIP(candidate) !== 0 &&
candidate.replace(/^::ffff:/, "").toLowerCase() === peer.toLowerCase()
net.isIP(candidate) !== 0 &&
normalizeIp(candidate) === normalizeIp(peer)

);
});
}

function resolveClientPolicy(remoteAddress: string | undefined): ClientPolicy {
const scope = classifyIpScope(remoteAddress);
if (scope === "loopback") return { kind: "local" };
const peer = remoteAddress ? remoteAddress.replace(/^::ffff:/, "") : "unknown";
if (scope === "private" && getTrustProxyMode() === "private") {
if (
scope === "private" &&
getTrustProxyMode() === "private" &&
isExplicitTrustedPrivateProxy(peer)
) {
return { kind: "trusted-proxy", peer };
}
return { kind: "remote", peer };
Expand Down
47 changes: 39 additions & 8 deletions src/lib/arenaEloSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { backupDbFile } from "./db/backup";
import {
applyArenaEloRefresh,
deleteModelIntelligenceBySource,
getLatestSyncedAt,
type ModelIntelligenceEntry,
} from "./db/modelIntelligence";

Expand Down Expand Up @@ -200,6 +199,24 @@ function writeLastFailedAt(ts: number): void {
}
}

function readLastCompleteRefreshAt(): number | null {
try {
const db = getDbInstance();
const row = db.prepare(KV_READ_SQL).get("arena_elo", "lastCompleteRefreshAt") as
{ value: string } | undefined;
if (!row?.value) return null;
const ts = Date.parse(row.value);
return Number.isFinite(ts) ? ts : null;
} catch {
return null;
}
}

function writeLastCompleteRefreshAt(ts: number): void {
const db = getDbInstance();
db.prepare(KV_WRITE_SQL).run("arena_elo", "lastCompleteRefreshAt", new Date(ts).toISOString());
}
Comment thread
khanhkit marked this conversation as resolved.

function clearLastFailedAt(): void {
try {
const db = getDbInstance();
Expand Down Expand Up @@ -495,12 +512,12 @@ export async function syncArenaElo(dryRun = false): Promise<SyncResult> {
}

if (!dryRun) {
// Freshness guard: a non-empty dataset synced within the interval is
// still good — skip the fetch entirely (cheap fast path on boot).
const latest = getLatestSyncedAt("arena_elo");
if (latest) {
const age = Date.now() - Date.parse(latest);
if (Number.isFinite(age) && age >= 0 && age < SYNC_INTERVAL_MS) {
// Freshness is advanced only after a complete all-category refresh.
// Row timestamps alone can describe a partial historical dataset.
const latestCompleteRefresh = readLastCompleteRefreshAt();
if (latestCompleteRefresh !== null) {
const age = Date.now() - latestCompleteRefresh;
if (age >= 0 && age < SYNC_INTERVAL_MS) {
return {
Comment on lines +517 to 521

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · high
lastCompleteRefreshAt survives clearSyncedIntelligence(). After a manual DELETE/clear, this guard can return success within SYNC_INTERVAL_MS while the Arena rows are empty, preventing repopulation until the interval expires. Clear or invalidate this marker when clearing the dataset, or require stored rows before taking this fast path.

Suggestion:

Suggested change
const latestCompleteRefresh = readLastCompleteRefreshAt();
if (latestCompleteRefresh !== null) {
const age = Date.now() - latestCompleteRefresh;
if (age >= 0 && age < SYNC_INTERVAL_MS) {
return {
const latestCompleteRefresh = readLastCompleteRefreshAt();
if (latestCompleteRefresh !== null && hasArenaEloEntries()) {
const age = Date.now() - latestCompleteRefresh;
if (age >= 0 && age < SYNC_INTERVAL_MS) {
return {

success: true,
modelCount: lastSyncModelCount,
Expand All @@ -527,11 +544,25 @@ export async function syncArenaElo(dryRun = false): Promise<SyncResult> {
// sync while the upstream was rate-limiting; remediation 2026-09-12).
const leaderboards = await fetchArenaLeaderboards();
const entries = transformToModelIntelligence(leaderboards);
const completeRefresh = FETCH_CATEGORIES.every((category) => {
const models = leaderboards[category]?.models;
return Array.isArray(models) && models.length > 0;
});
if (!completeRefresh) {
if (!dryRun) writeLastFailedAt(Date.now());
return {
success: false,
modelCount: 0,
source: "arena_elo",
error: "Incomplete Arena refresh; preserving previous dataset",
};
}
Comment thread
khanhkit marked this conversation as resolved.

if (!dryRun && entries.length > 0) {
if (!dryRun) {
try {
// Atomic replace: upsert all + prune not-in-refreshed-set in one tx.
applyArenaEloRefresh(entries);
writeLastCompleteRefreshAt(Date.now());
clearLastFailedAt();
Comment on lines 564 to 566

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
applyArenaEloRefresh commits before this KV write. If writeLastCompleteRefreshAt fails (disk/DB error), the catch reports the sync as failed even though the dataset was replaced; the freshness marker remains stale, so every retry re-fetches/re-applies the same data and can repeatedly report failure. Make the marker update part of the same transaction, or treat marker persistence as best-effort after a successful apply.

Suggestion:

Suggested change
applyArenaEloRefresh(entries);
writeLastCompleteRefreshAt(Date.now());
clearLastFailedAt();
applyArenaEloRefresh(entries);
try {
writeLastCompleteRefreshAt(Date.now());
} catch (err) {
console.warn(`[ARENA_ELO_SYNC] Failed to persist refresh timestamp: ${getErrorMessage(err)}`);
}
clearLastFailedAt();

} catch (err) {
const message = getErrorMessage(err);
Expand Down
9 changes: 8 additions & 1 deletion src/lib/memory/embedding/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,7 @@ export async function embed(
return {
source: "remote",
model: resolution.model,
reason: "request_failed",
reason: "invalid_config",
message:
error instanceof Error
? error.message
Expand Down Expand Up @@ -318,6 +318,13 @@ export async function embedWithRetry(
for (let attempt = 1; attempt <= Math.max(1, maxAttempts); attempt++) {
const result = await embedFn(text, settings);
if ("vector" in result) return result;
if (
result.reason === "no_key" ||
result.reason === "unknown" ||
result.reason === "invalid_config"
) {
return result;
}
last = result;
}
return last as EmbeddingResult | EmbeddingError;
Expand Down
6 changes: 5 additions & 1 deletion src/lib/memory/embedding/nodeModalityListings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,11 @@ function nodeListing(
): EmbeddingProviderListing | null {
const apiType = str(node.apiType).trim().toLowerCase();
const nodeId = str(node.id);
const rows = mergeNodeRows(apiType, syncedByNodeId[nodeId], customByNodeId[nodeId]);
const rows = mergeNodeRows(
apiType,
syncedByNodeId[prefix] ?? syncedByNodeId[nodeId],
customByNodeId[prefix] ?? customByNodeId[nodeId]
);
const typed = [...rows.values()].filter((row) => row.endpoints.includes(modality));
if (typed.length === 0 && !IMPLICIT_API_TYPES[modality].has(apiType)) return null;
return {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/memory/embedding/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export async function embedRemote(
return {
source: "remote",
model,
reason: "request_failed",
reason: [400, 404, 422].includes(status) ? "invalid_config" : "request_failed",
message: sanitizeErrorMessage(`Embedding provider returned HTTP ${status}`),
};
}
Expand Down
8 changes: 7 additions & 1 deletion src/lib/memory/embedding/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ export interface EmbeddingError {
source: "remote" | "static" | "transformers";
model: string | null;
reason:
"no_key" | "model_load_failed" | "request_failed" | "rate_limited" | "timeout" | "unknown";
| "no_key"
| "invalid_config"
| "model_load_failed"
| "request_failed"
| "rate_limited"
| "timeout"
| "unknown";
message: string; // ALWAYS via sanitizeErrorMessage()
}
2 changes: 2 additions & 0 deletions src/lib/monitoring/observability.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,8 @@ export async function probeDeepHealth(
});
const response = await Promise.race([request, timeout]);
const status = response.status;
// Release the unread body/connection without waiting for upstream cancellation.
void response.body?.cancel().catch(() => {});
if (status >= 200 && status < 300) return verdict(true, false, status);
return verdict(false, status === 502 || status === 503, status);
} catch {
Expand Down
20 changes: 19 additions & 1 deletion src/lib/providers/xai/translators/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ interface OpenAiMessage {
tool_calls?: OpenAiToolCall[];
tool_call_id?: string;
function_call?: { name?: string; arguments?: string };
name?: string;
}

interface OpenAiChatRequest {
Expand Down Expand Up @@ -174,6 +175,7 @@ export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesR
const messages: OpenAiMessage[] = Array.isArray(req.messages) ? req.messages : [];
const instructionsParts: string[] = [];
const input: XaiInputItem[] = [];
const legacyCallIdsByName = new Map<string, string[]>();

for (const m of messages) {
if (!m) continue;
Expand All @@ -198,6 +200,18 @@ export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesR
});
continue;
}
if (m.role === "function") {
const ids = m.name ? legacyCallIdsByName.get(m.name) : undefined;
const callId = ids?.shift();
if (callId) {
input.push({
type: "function_call_output",
call_id: callId,
output: typeof m.content === "string" ? m.content : JSON.stringify(m.content ?? ""),
});
}
continue;
}
Comment thread
khanhkit marked this conversation as resolved.
if (m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
// Emit any pre-existing assistant text first
if (m.content) {
Expand All @@ -218,9 +232,13 @@ export function chatRequestToXaiResponses(req: OpenAiChatRequest): XaiResponsesR
if (m.content) {
input.push({ role: "assistant", content: messageContentToXaiBlocks(m.content) });
}
const callId = genId("call");
const ids = legacyCallIdsByName.get(m.function_call.name) ?? [];
ids.push(callId);
legacyCallIdsByName.set(m.function_call.name, ids);
input.push({
type: "function_call",
call_id: genId("call"),
call_id: callId,
name: m.function_call.name,
arguments: m.function_call.arguments ?? "",
});
Expand Down
2 changes: 2 additions & 0 deletions src/lib/quota/boundedMap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,11 @@ export function boundedMap<V>(
inner.clear();
},
get size(): number {
if (expires) sweepExpired(Date.now());
return inner.size;
},
keys(): IterableIterator<string> {
if (expires) sweepExpired(Date.now());
return inner.keys();
},
[Symbol.iterator](): IterableIterator<[string, V]> {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/quota/enforce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export async function enforceQuotaShare(input: EnforceInput): Promise<EnforceDec
const store = await getQuotaStore();

// 3. Resolve the provider plan from the pool's canonical primary connection.
const plan = resolvePlan(pool.connectionId, input.provider);
const plan = resolvePlan(input.connectionId, input.provider);

// 3b. Per-(key, model) model-cap pre-check (Fase 3 #7).
//
Expand Down Expand Up @@ -326,7 +326,7 @@ export async function recordConsumption(input: RecordConsumptionInput): Promise<
if (!matchedPool) return;
const poolId = matchedPool.id;

const plan = resolvePlan(matchedPool.connectionId, input.provider);
const plan = resolvePlan(input.connectionId, input.provider);
const store = await getQuotaStore();

// Pool-level dimension consumption (existing behaviour).
Expand Down
Loading
Loading