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
8 changes: 2 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,9 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- name: Typecheck(tsc --noEmit,strict)
run: pnpm typecheck
- name: Lint(oxlint,0 警告基线)
run: pnpm lint
- name: Check(typecheck + lint + architectureCheck)
run: pnpm check
- name: Format check(oxfmt)
run: pnpm format:check
- name: Architecture check(目录依赖方向硬约束)
run: node scripts/architectureCheck.ts
- name: Tests
run: pnpm test
35 changes: 35 additions & 0 deletions apps/gateway/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"name": "qoder-proxy-api",
"version": "0.2.2",
"private": true,
"description": "Local protocol gateway exposing OpenAI/Anthropic-compatible APIs over three Qoder providers (gateway / cli / cloud-agents).",
"license": "MIT",
"bin": {
"qoder-proxy-api": "./src/index.ts"
},
"type": "module",
"scripts": {
"start": "node ./src/index.ts --provider=gateway",
"start:cli": "node ./src/index.ts --provider=cli",
"start:cloud-agents": "node ./src/index.ts --provider=cloud-agents",
"test": "node --test",
"demo": "node ./scripts/demo.ts",
"typecheck": "tsc --noEmit",
"check": "pnpm typecheck && pnpm lint && node ./scripts/architectureCheck.ts",
"lint": "oxlint .",
"format": "oxfmt --write .",
"format:check": "oxfmt --check .",
"setup:cloudAgents": "node ./scripts/setupCloudAgents.ts",
"doctor": "node ./scripts/doctor.ts"
},
"devDependencies": {
"@types/node": "^24.19.0",
"oxfmt": "^0.70.0",
"oxlint": "^1.85.0",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=24.21.0"
},
"packageManager": "pnpm@12.6.0"
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env node
// 架构消融检查:目录依赖方向硬约束(一次性脚本,不入 npm scripts)。
// 架构检查:目录依赖方向硬约束,违规时退出码非 0(pnpm check 的一部分)。
import { readFileSync, readdirSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
Expand All @@ -22,14 +22,28 @@ function zoneOf(absPath: string): string {
if (norm.startsWith("providers")) return "providers";
if (norm.startsWith("routing")) return "routing";
if (norm.startsWith("server")) return "server";
if (norm.startsWith("admin")) return "admin";
return "root";
}

const violations = [];

// ADR-0007:数据面零运行时依赖。src 只能 import node: 内置模块和相对路径(zod、@qpa/contracts 同样被拒)。
const { dependencies = {} } = JSON.parse(
readFileSync(path.join(root, "..", "package.json"), "utf8"),
);
for (const name of Object.keys(dependencies))
violations.push(`[gateway dependencies] package.json declares "${name}"`);

for (const file of walk(root)) {
const from = zoneOf(file);
const text = readFileSync(file, "utf8");
for (const m of text.matchAll(/from\s+["'](\.[^"']+)["']/g)) {
for (const m of text.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+)["']/g)) {
if (m[1].startsWith("node:")) continue;
if (!m[1].startsWith(".")) {
violations.push(`[gateway→${m[1]}] ${path.relative(root, file)}`);
continue;
}
const target = path.resolve(path.dirname(file), m[1]);
const to = zoneOf(target);
if (from === "protocols" && to === "providers")
Expand All @@ -40,6 +54,12 @@ for (const file of walk(root)) {
violations.push(`[core→${to}] ${path.relative(root, file)} -> ${m[1]}`);
if (from === "routing" && ["providers", "protocols"].includes(to))
violations.push(`[routing→${to}] ${path.relative(root, file)} -> ${m[1]}`);
if (
from === "admin" &&
to === "providers" &&
!/^providers\/[^/]+\/index\.ts$/.test(path.relative(root, target).split(path.sep).join("/"))
)
violations.push(`[admin→providers internals] ${path.relative(root, file)} -> ${m[1]}`);
}
}

Expand All @@ -64,3 +84,4 @@ for (const rel of ["server/server.ts", "server/http.ts", "server/sse.ts"]) {
}
}
console.log(leaks.length ? leaks.join("\n") : "NO PROTOCOL DETAIL LEAKS IN SERVER");
if (violations.length || leaks.length) process.exitCode = 1;
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
7 changes: 6 additions & 1 deletion src/core/errors.ts → apps/gateway/src/core/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export const ERROR_CODES = Object.freeze({
PROVIDER_UNAVAILABLE: "PROVIDER_UNAVAILABLE",
PROVIDER_PROTOCOL_ERROR: "PROVIDER_PROTOCOL_ERROR",
PROVIDER_ABORTED: "PROVIDER_ABORTED",
// 上游 400 / 内容拒答:问题在请求本身,不改道、不歇息。
PROVIDER_REJECTED: "PROVIDER_REJECTED",
UNSUPPORTED_CAPABILITY: "UNSUPPORTED_CAPABILITY",
MODEL_NOT_FOUND: "MODEL_NOT_FOUND",
});
Expand All @@ -16,21 +18,24 @@ export type ProviderErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES];
export interface ProviderErrorOptions {
cause?: unknown;
status?: number;
retryAfterMs?: number;
}

export class ProviderError extends Error {
code: ProviderErrorCode;
status: number | undefined;
retryAfterMs: number | undefined;

constructor(
code: ProviderErrorCode,
message: string,
{ cause, status }: ProviderErrorOptions = {},
{ cause, status, retryAfterMs }: ProviderErrorOptions = {},
) {
super(message);
this.name = "ProviderError";
this.code = code;
this.status = status;
this.retryAfterMs = retryAfterMs;
if (cause !== undefined) this.cause = cause;
}
}
Expand Down
File renamed without changes.
15 changes: 13 additions & 2 deletions src/core/provider.ts → apps/gateway/src/core/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,25 @@ export interface ProviderModel {
label?: string;
}

export interface QoderProvider {
id: "gateway" | "cli" | "cloudAgents";
export type ProviderKind =
| "qoder-gateway"
| "qoder-cli"
| "qoder-cloudAgents"
| "openai-compatible"
| "anthropic-compatible";

export interface GatewayProvider {
id: string;
kind: ProviderKind;
label: string;
capabilities: () => ProviderCapabilities | Promise<ProviderCapabilities>;
listModels: () => Promise<ProviderModel[]>;
stream: (request: CanonicalRequest, context: ProviderContext) => AsyncIterable<ProviderEvent>;
dispose?: () => Promise<void>;
}

export type QoderProvider = GatewayProvider;

// 注册期校验:缺方法或缺能力键直接 fail fast,不让坏 Provider 进入路由。
export function assertProvider(provider: QoderProvider): QoderProvider {
if (!provider || typeof provider !== "object") {
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import { parseJsonlOutput, runCliProcess } from "./process.ts";

export class CliProvider {
id = "cli";
kind = "qoder-cli" as const;
label = "Qoder CLI";

config: CliConfig;
// 模型别名 → CLI model level(如 ultimate);未配置时原样传递。
Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {

export class CloudAgentsProvider {
id = "cloudAgents";
kind = "qoder-cloudAgents" as const;
label = "Qoder Cloud Agents";

client: CloudAgentsClient;
sessions: SessionStore;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ import { extractEnvelopeDelta, extractEnvelopeUsage, parseEnvelope } from "./nor

export class GatewayProvider {
id = "gateway";
kind = "qoder-gateway" as const;
label = "Qoder Gateway";

defaultModel: string;
auth: GatewayAuth;
Expand Down
File renamed without changes.
65 changes: 65 additions & 0 deletions apps/gateway/src/routing/routeEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 路由事件:网关"正在做的决定"。控制台路由页、用量统计、日志都只消费这一种语言。
// 事件里只放路由元数据:不放 prompt、回复正文、工具参数、Key、上游原始错误正文。
// 契约见 02-接口契约 §2;实现(环形缓冲 + 订阅)由 WP6 提供。

export type MemberRef = { providerId: string; model: string };
export type CandidateState = "ready" | "resting" | "skipped";

export type RouteEvent =
| {
type: "request.start";
requestId: string;
at: number;
agent: string;
alias: string;
groupId: string | null;
protocol: string;
}
| {
type: "route.decision";
requestId: string;
at: number;
strategy: "ordered" | "smart" | "direct";
candidates: Array<MemberRef & { state: CandidateState; reason: string; restUntil?: number }>;
}
| { type: "upstream.attempt"; requestId: string; at: number; member: MemberRef; attempt: number }
| { type: "upstream.firstByte"; requestId: string; at: number; member: MemberRef; ms: number }
| {
type: "upstream.failed";
requestId: string;
at: number;
member: MemberRef;
code: string;
status?: number;
restUntil?: number;
reroutable: boolean;
}
| {
type: "reroute";
requestId: string;
at: number;
from: MemberRef;
to: MemberRef;
reason: string;
}
| {
type: "request.end";
requestId: string;
at: number;
outcome: "ok" | "error" | "cancelled";
member: MemberRef | null;
ms: number;
reroutes: number;
errorCode?: string;
usage?: { inputTokens?: number; outputTokens?: number };
};

export type SequencedRouteEvent = RouteEvent & { seq: number };
export type RouteEventListener = (event: SequencedRouteEvent) => void;

// 总线给每条事件加进程内单调递增的 seq;订阅方异常不得影响数据面。
export interface RouteEventBus {
emit(event: RouteEvent): void;
since(seq: number): SequencedRouteEvent[];
subscribe(listener: RouteEventListener): () => void;
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
10 changes: 10 additions & 0 deletions test/core/errors.test.ts → apps/gateway/test/core/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,23 @@ test("errors: ProviderError carries machine-readable code", () => {
assert.equal(error instanceof Error, true);
});

test("errors: ProviderError carries retryAfterMs for rate limits", () => {
const error = new ProviderError("PROVIDER_RATE_LIMITED", "upstream 429", {
status: 429,
retryAfterMs: 2500,
});
assert.equal(error.retryAfterMs, 2500);
assert.equal(new ProviderError("PROVIDER_UNAVAILABLE", "down").retryAfterMs, undefined);
});

test("errors: canonical code set is closed", () => {
assert.deepEqual(Object.keys(ERROR_CODES).sort(), [
"MODEL_NOT_FOUND",
"PROVIDER_ABORTED",
"PROVIDER_AUTH_ERROR",
"PROVIDER_PROTOCOL_ERROR",
"PROVIDER_RATE_LIMITED",
"PROVIDER_REJECTED",
"PROVIDER_TIMEOUT",
"PROVIDER_UNAVAILABLE",
"UNSUPPORTED_CAPABILITY",
Expand Down
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { QoderProvider } from "../../src/core/provider.ts";
function fakeProvider(id: QoderProvider["id"]): QoderProvider {
return {
id,
kind: "openai-compatible",
label: id,
capabilities() {
return {
streaming: true,
Expand Down
File renamed without changes.
31 changes: 4 additions & 27 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,34 +1,11 @@
{
"name": "qoder-proxy-api",
"version": "0.2.2",
"name": "qoder-proxy-api-workspace",
"private": true,
"description": "Local protocol gateway exposing OpenAI/Anthropic-compatible APIs over three Qoder providers (gateway / cli / cloud-agents).",
"license": "MIT",
"bin": {
"qoder-proxy-api": "./src/index.ts"
},
"type": "module",
"scripts": {
"start": "node ./src/index.ts --provider=gateway",
"start:cli": "node ./src/index.ts --provider=cli",
"start:cloud-agents": "node ./src/index.ts --provider=cloud-agents",
"test": "node --test",
"demo": "node ./scripts/demo.ts",
"typecheck": "tsc --noEmit",
"lint": "oxlint .",
"format": "oxfmt --write .",
"format:check": "oxfmt --check .",
"setup:cloudAgents": "node ./scripts/setupCloudAgents.ts",
"doctor": "node ./scripts/doctor.ts"
},
"devDependencies": {
"@types/node": "^24.19.0",
"oxfmt": "^0.70.0",
"oxlint": "^1.85.0",
"typescript": "^5.9.3"
},
"engines": {
"node": ">=24.21.0"
"test": "pnpm -r test",
"check": "pnpm -r check",
"format:check": "pnpm -r format:check"
},
"packageManager": "pnpm@12.6.0"
}
23 changes: 23 additions & 0 deletions packages/contracts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@qpa/contracts",
"version": "0.0.0",
"private": true,
"description": "Admin API 与路由事件的 zod schema。只给控制台、playground 和测试用,网关不得引用(ADR-0007)。",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "node --test",
"check": "tsc --noEmit",
"format:check": "oxfmt --check ."
},
"dependencies": {
"zod": "^4.6.7"
},
"devDependencies": {
"@types/node": "^24.19.0",
"oxfmt": "^0.70.0",
"typescript": "^5.9.3"
}
}
Loading
Loading