From de5062270e2096f1b0c2e5e9a3c2c5b367725885 Mon Sep 17 00:00:00 2001 From: Miro Date: Wed, 26 Aug 2026 14:42:57 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(evolver):=20=E5=86=BB=E7=BB=93?= =?UTF-8?q?=E6=BC=94=E5=8C=96=20LLM=20=E5=AE=A1=E6=89=B9=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=8E=E5=B9=82=E7=AD=89=E6=89=A7=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + .../internal/llm-config/[id]/route.test.ts | 89 +++++++++++ .../app/api/internal/llm-config/[id]/route.ts | 55 +++++++ apps/dashboard/src/lib/user-preferences.ts | 33 ++-- apps/dashboard/vitest.config.ts | 6 + infra/.env.prod.example | 2 + infra/.env.selfhost.example | 2 + infra/docker-compose.prod.yml | 2 + infra/migrations/tests/test_migration_0041.py | 81 ++++++++++ .../versions/0041_evolution_llm_snapshot.py | 49 ++++++ packages/orchestration/src/clients/evolver.ts | 29 ++-- .../orchestration/src/hooks/with-hooks.ts | 52 ++++-- packages/orchestration/src/mastra/identity.ts | 14 ++ .../src/mastra/llm/evolution-snapshot.ts | 142 +++++++++++++++++ .../orchestration/src/mastra/llm/provider.ts | 4 +- .../orchestration/src/permissions/pending.ts | 55 ++++++- .../orchestration/src/tools/evolver-shared.ts | 46 +++++- packages/orchestration/src/tools/evolver.ts | 18 ++- .../orchestration/tests/ask-path-e2e.test.ts | 45 ++++++ .../tests/evolution-snapshot.test.ts | 51 ++++++ .../tests/evolver-client.test.ts | 98 ++++++++++++ packages/orchestration/tests/identity.test.ts | 42 +++++ .../tests/permissions-pending.test.ts | 39 ++++- .../src/inalpha_evolver/api/approval.py | 49 ++++++ .../src/inalpha_evolver/api/run_routes.py | 18 ++- .../src/inalpha_evolver/api/schemas.py | 87 +++++++++- .../evolver/src/inalpha_evolver/config.py | 10 ++ .../evolver/src/inalpha_evolver/exceptions.py | 11 +- services/evolver/src/inalpha_evolver/main.py | 3 +- .../src/inalpha_evolver/mutator/llm_client.py | 66 ++++++-- .../evolver/src/inalpha_evolver/owner_llm.py | 80 ++++++++++ .../src/inalpha_evolver/runtime/executor.py | 26 ++- .../src/inalpha_evolver/runtime/generation.py | 2 +- .../src/inalpha_evolver/runtime/manager.py | 6 +- .../src/inalpha_evolver/runtime/slots.py | 28 +++- .../src/inalpha_evolver/storage/runs.py | 25 +-- .../evolver/tests/llm_snapshot_fixtures.py | 53 +++++++ services/evolver/tests/test_api_contract.py | 92 +++++++++-- services/evolver/tests/test_approval.py | 69 ++++++++ services/evolver/tests/test_e2e.py | 40 ++++- .../evolver/tests/test_mutator_pricing.py | 149 ++++++++++++++++++ services/evolver/tests/test_owner_llm.py | 110 +++++++++++++ .../evolver/tests/test_storage_integration.py | 4 + 43 files changed, 1761 insertions(+), 124 deletions(-) create mode 100644 apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts create mode 100644 apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts create mode 100644 infra/migrations/tests/test_migration_0041.py create mode 100644 infra/migrations/versions/0041_evolution_llm_snapshot.py create mode 100644 packages/orchestration/src/mastra/llm/evolution-snapshot.ts create mode 100644 packages/orchestration/tests/evolution-snapshot.test.ts create mode 100644 packages/orchestration/tests/evolver-client.test.ts create mode 100644 services/evolver/src/inalpha_evolver/api/approval.py create mode 100644 services/evolver/src/inalpha_evolver/owner_llm.py create mode 100644 services/evolver/tests/llm_snapshot_fixtures.py create mode 100644 services/evolver/tests/test_approval.py create mode 100644 services/evolver/tests/test_mutator_pricing.py create mode 100644 services/evolver/tests/test_owner_llm.py diff --git a/.env.example b/.env.example index 014b843a..a5d62421 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,9 @@ EVOLVER_MAX_RUNNING_RUNS=1 EVOLVER_ACCOUNT_ACTIVE_LIMIT=2 EVOLVER_JOB_TIMEOUT_S=300 EVOLVER_JOB_MEM_GB=2 +EVOLVER_LLM_TIMEOUT_S=120 +# Evolver 仅用该地址按 owner/config_id 解析既有加密凭据;不会持久化明文 key。 +DASHBOARD_SERVICE_URL=http://localhost:3001 # factor 服务可选项(ADR-0043):qlib Alpha158 风格因子纯 pandas 本地算,默认开; # 设 false 可整源关闭。snapshot top-N 去相关阈值默认 0.85(1.0 = 关闭去相关) diff --git a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts new file mode 100644 index 00000000..f85a3a23 --- /dev/null +++ b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts @@ -0,0 +1,89 @@ +import { SignJWT } from "jose"; +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { decryptUserApiKey } from "@/lib/user-preferences"; + +import { GET } from "./route"; + +vi.mock("@/lib/user-preferences", () => ({ + decryptUserApiKey: vi.fn(), +})); + +const TEST_SECRET = "dashboard-route-test-secret-at-least-32-bytes"; +const mockedDecryptUserApiKey = vi.mocked(decryptUserApiKey); + +/** Mints an isolated service credential token for this route test. */ +async function token( + overrides: Record = {}, +): Promise { + const now = Math.floor(Date.now() / 1_000); + return await new SignJWT({ + token_use: "evolver_credential", + config_id: "config-1", + ...overrides, + }) + .setProtectedHeader({ alg: "HS256" }) + .setSubject("user:alice") + .setIssuedAt(now) + .setExpirationTime(now + 300) + .sign(new TextEncoder().encode(TEST_SECRET)); +} + +/** Calls the dynamic route with a resolved Next.js params promise. */ +async function callRoute(authorization?: string, id = "config-1") { + const headers = authorization ? { Authorization: authorization } : undefined; + return await GET( + new NextRequest(`http://dashboard.test/api/internal/llm-config/${id}`, { headers }), + { params: Promise.resolve({ id }) }, + ); +} + +beforeEach(() => { + vi.stubEnv("JWT_SECRET", TEST_SECRET); + mockedDecryptUserApiKey.mockReset(); +}); + +describe("internal owner LLM credential route", () => { + it("rejects missing authentication and mismatched credential scope", async () => { + expect((await callRoute()).status).toBe(401); + expect((await callRoute(`Bearer ${await token({ config_id: "config-2" })}`)).status).toBe( + 403, + ); + expect(mockedDecryptUserApiKey).not.toHaveBeenCalled(); + }); + + it("returns only the requested owner's decrypted config without caching", async () => { + mockedDecryptUserApiKey.mockResolvedValue({ + id: "config-1", + provider: "deepseek", + model: "deepseek-v4-pro", + custom_base_url: "https://api.deepseek.com", + api_key: "owner-key", + api_key_encrypted: "encrypted", + api_key_nonce: "nonce", + api_key_tag: "tag", + created_at: "2026-08-26T00:00:00Z", + updated_at: "2026-08-26T00:00:00Z", + }); + + const response = await callRoute(`Bearer ${await token()}`); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("no-store"); + expect(await response.json()).toEqual({ + config_id: "config-1", + provider: "deepseek", + model: "deepseek-v4-pro", + base_url: "https://api.deepseek.com", + api_key: "owner-key", + }); + expect(mockedDecryptUserApiKey).toHaveBeenCalledWith("user:alice", "config-1"); + }); + + it("does not fall back to another config when the reference no longer exists", async () => { + mockedDecryptUserApiKey.mockResolvedValue(null); + + expect((await callRoute(`Bearer ${await token()}`)).status).toBe(404); + }); +}); diff --git a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts new file mode 100644 index 00000000..f088dfed --- /dev/null +++ b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts @@ -0,0 +1,55 @@ +import { jwtVerify } from "jose"; +import { NextRequest, NextResponse } from "next/server"; + +import { decryptUserApiKey } from "@/lib/user-preferences"; + +const ALG = process.env.JWT_ALGORITHM ?? "HS256"; + +function secret(): Uint8Array { + const value = process.env.JWT_SECRET; + if (!value) throw new Error("JWT_SECRET is required"); + return new TextEncoder().encode(value); +} + +/** Resolves an existing encrypted owner credential for the Evolver service only. */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const configId = (await params).id; + const raw = request.headers.get("authorization"); + const token = raw?.match(/^Bearer\s+(.+)$/i)?.[1]; + if (!token) return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + + let subject: string; + try { + const { payload } = await jwtVerify(token, secret(), { + algorithms: [ALG], + requiredClaims: ["sub", "exp"], + }); + if ( + payload.token_use !== "evolver_credential" || + payload.config_id !== configId || + typeof payload.sub !== "string" || + !payload.sub + ) { + return NextResponse.json({ error: "forbidden" }, { status: 403 }); + } + subject = payload.sub; + } catch { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const config = await decryptUserApiKey(subject, configId); + if (!config) return NextResponse.json({ error: "not_found" }, { status: 404 }); + return NextResponse.json( + { + config_id: config.id, + provider: config.provider, + model: config.model ?? null, + base_url: config.custom_base_url ?? null, + api_key: config.api_key, + }, + { headers: { "Cache-Control": "no-store" } }, + ); +} diff --git a/apps/dashboard/src/lib/user-preferences.ts b/apps/dashboard/src/lib/user-preferences.ts index 998984da..6ab3f1a3 100644 --- a/apps/dashboard/src/lib/user-preferences.ts +++ b/apps/dashboard/src/lib/user-preferences.ts @@ -400,28 +400,25 @@ export async function decryptActiveUserApiKey( subject: string, ): Promise<(UserLLMConfig & { api_key: string }) | null> { const preferences = await getUserPreferences(subject); - const configs = preferences.configs || []; const activeId = preferences.active_config_id; + return activeId ? await decryptUserApiKey(subject, activeId, preferences) : null; +} - if (!configs.length || !activeId) { - return null; - } - - const active = configs.find((c) => c.id === activeId); - if (!active) { - return null; - } - - // 解密 API key +/** Resolves one owner-scoped credential reference without exposing it to browser code. */ +export async function decryptUserApiKey( + subject: string, + configId: string, + loaded?: UserLLMPreferences, +): Promise<(UserLLMConfig & { api_key: string }) | null> { + const preferences = loaded ?? (await getUserPreferences(subject)); + const config = (preferences.configs || []).find((item) => item.id === configId); + if (!config) return null; const encrypted: EncryptedData = { - ciphertext: active.api_key_encrypted, - nonce: active.api_key_nonce, - tag: active.api_key_tag, + ciphertext: config.api_key_encrypted, + nonce: config.api_key_nonce, + tag: config.api_key_tag, }; - - const apiKey = await decryptApiKey(encrypted); - - return { ...active, api_key: apiKey }; + return { ...config, api_key: await decryptApiKey(encrypted) }; } /** diff --git a/apps/dashboard/vitest.config.ts b/apps/dashboard/vitest.config.ts index 8ace40d1..c4524b1a 100644 --- a/apps/dashboard/vitest.config.ts +++ b/apps/dashboard/vitest.config.ts @@ -1,10 +1,16 @@ import { defineConfig } from "vitest/config"; +import { fileURLToPath } from "node:url"; /** * 最小 vitest 配置 —— 目前只覆盖 server 侧 lib 的纯逻辑单测(如 mastra.ts 的 * 越权防护 ownsThread)。测试用显式 import(不开 globals),故无需改 tsconfig types。 */ export default defineConfig({ + resolve: { + alias: { + "@": fileURLToPath(new URL("./src", import.meta.url)), + }, + }, test: { environment: "node", include: ["src/**/*.test.ts"], diff --git a/infra/.env.prod.example b/infra/.env.prod.example index c508229a..a735f79f 100644 --- a/infra/.env.prod.example +++ b/infra/.env.prod.example @@ -56,6 +56,8 @@ EVOLVER_MAX_RUNNING_RUNS=1 EVOLVER_ACCOUNT_ACTIVE_LIMIT=2 EVOLVER_JOB_TIMEOUT_S=300 EVOLVER_JOB_MEM_GB=2 +EVOLVER_LLM_TIMEOUT_S=120 +DASHBOARD_SERVICE_URL=http://dashboard:3001 # ---- 行情数据源 ---- BINANCE_API_KEY= diff --git a/infra/.env.selfhost.example b/infra/.env.selfhost.example index 0a16b8b2..259144c5 100644 --- a/infra/.env.selfhost.example +++ b/infra/.env.selfhost.example @@ -35,6 +35,8 @@ EVOLVER_MAX_RUNNING_RUNS=1 EVOLVER_ACCOUNT_ACTIVE_LIMIT=2 EVOLVER_JOB_TIMEOUT_S=300 EVOLVER_JOB_MEM_GB=2 +EVOLVER_LLM_TIMEOUT_S=120 +DASHBOARD_SERVICE_URL=http://dashboard:3001 # Each authenticated user configures their own LLM API key in the dashboard. # Do not add provider API keys here as a shared fallback. diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index 6f75388d..68adc7b6 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -158,6 +158,8 @@ services: PORT: 8005 WORKERS: 1 DATA_SERVICE_URL: http://data:8001 + DASHBOARD_SERVICE_URL: http://dashboard:3001 + EVOLVER_LLM_TIMEOUT_S: ${EVOLVER_LLM_TIMEOUT_S:-120} deploy: replicas: 1 depends_on: diff --git a/infra/migrations/tests/test_migration_0041.py b/infra/migrations/tests/test_migration_0041.py new file mode 100644 index 00000000..e938c484 --- /dev/null +++ b/infra/migrations/tests/test_migration_0041.py @@ -0,0 +1,81 @@ +"""0041 owner LLM snapshot migration tests against real PostgreSQL.""" + +from __future__ import annotations + +import json + +import psycopg +import pytest +from migration_0038_support import alembic, db_url + +pytestmark = pytest.mark.integration + +_LEGACY_RUN = "30000000-0000-0000-0000-000000000001" +_NEW_RUN = "30000000-0000-0000-0000-000000000002" +_VALID_RUN = "30000000-0000-0000-0000-000000000003" +_OWNER = "00000000-0000-0000-0000-000000000099" +_DIGEST = "a4635b0c80f69b6054bdc2330b78cb98d9c81c849d476e7d01f1b8d626015c2c" +_SNAPSHOT = { + "config_id": "config-1", + "provider": "deepseek", + "model": "deepseek-v4-pro", + "base_url": "https://api.deepseek.com", + "pricing": {"version": "provider-estimate-2026-08"}, + "config_digest": _DIGEST, +} + + +def _insert_run( + conn: psycopg.Connection[tuple[object, ...]], + run_id: str, + key: str, + *, + snapshot: dict[str, object] | None = None, +) -> None: + columns = "" + values = "" + params: list[object] = [run_id, _OWNER, "user:test", key, f"hash-{key}"] + if snapshot is not None: + columns = ",llm_snapshot,llm_config_digest" + values = ",%s,%s" + params.extend([json.dumps(snapshot), snapshot["config_digest"]]) + conn.execute( + f"""INSERT INTO strategy_evo_runs + (run_id,owner_account_id,requested_by_sub,seed_strategy_id,budget,config, + status,idempotency_key,request_hash,queued_at{columns}) + VALUES (%s,%s,%s,'seed',1,'{{}}','queued',%s,%s,NOW(){values})""", + params, + ) + + +def test_0041_preserves_old_rows_and_enforces_new_snapshots( + migration_db_url: str, +) -> None: + alembic(migration_db_url, "upgrade", "0040") + with psycopg.connect(db_url(migration_db_url), autocommit=True) as conn: + _insert_run(conn, _LEGACY_RUN, "legacy-before-0041") + + alembic(migration_db_url, "upgrade", "0041") + with psycopg.connect(db_url(migration_db_url), autocommit=True) as conn: + assert conn.execute( + """SELECT llm_snapshot,llm_config_digest,llm_snapshot_required + FROM strategy_evo_runs WHERE run_id=%s""", + (_LEGACY_RUN,), + ).fetchone() == (None, None, False) + conn.execute( + "UPDATE strategy_evo_runs SET updated_at=NOW() WHERE run_id=%s", + (_LEGACY_RUN,), + ) + validated = conn.execute( + "SELECT convalidated FROM pg_constraint WHERE conname='evo_run_llm_snapshot_check'" + ).fetchone() + assert validated == (False,) + with pytest.raises(psycopg.errors.CheckViolation): + _insert_run(conn, _NEW_RUN, "new-without-snapshot") + _insert_run(conn, _VALID_RUN, "new-with-snapshot", snapshot=_SNAPSHOT) + assert conn.execute( + "SELECT llm_snapshot_required FROM strategy_evo_runs WHERE run_id=%s", + (_VALID_RUN,), + ).fetchone() == (True,) + + alembic(migration_db_url, "downgrade", "0040") diff --git a/infra/migrations/versions/0041_evolution_llm_snapshot.py b/infra/migrations/versions/0041_evolution_llm_snapshot.py new file mode 100644 index 00000000..93f74e50 --- /dev/null +++ b/infra/migrations/versions/0041_evolution_llm_snapshot.py @@ -0,0 +1,49 @@ +"""Freeze non-secret owner LLM metadata on every new evolution run.""" + +from __future__ import annotations + +from alembic import op + +revision: str = "0041" +down_revision: str | None = "0040" +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +def upgrade() -> None: + """Enforce snapshots for new writes without fabricating metadata for old rows.""" + op.execute("SET LOCAL lock_timeout = '10s'") + op.execute( + """ALTER TABLE strategy_evo_runs +ADD COLUMN llm_snapshot JSONB, +ADD COLUMN llm_config_digest TEXT, +ADD COLUMN llm_snapshot_required BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE strategy_evo_runs +ALTER COLUMN llm_snapshot_required SET DEFAULT TRUE; +ALTER TABLE strategy_evo_runs ADD CONSTRAINT evo_run_llm_snapshot_check +CHECK ( + NOT llm_snapshot_required + OR ( + llm_snapshot IS NOT NULL + AND llm_config_digest IS NOT NULL + AND llm_config_digest ~ '^[0-9a-f]{64}$' + AND llm_snapshot->>'config_digest'=llm_config_digest + AND length(COALESCE(llm_snapshot->>'config_id',''))>0 + AND length(COALESCE(llm_snapshot->>'provider',''))>0 + AND length(COALESCE(llm_snapshot->>'model',''))>0 + AND jsonb_typeof(llm_snapshot->'pricing')='object' + ) +) NOT VALID;""" + ) + + +def downgrade() -> None: + """Remove only metadata columns; no business row is changed or deleted.""" + op.execute("SET LOCAL lock_timeout = '10s'") + op.execute( + """ALTER TABLE strategy_evo_runs +DROP CONSTRAINT evo_run_llm_snapshot_check, +DROP COLUMN llm_snapshot_required, +DROP COLUMN llm_config_digest, +DROP COLUMN llm_snapshot;""" + ) diff --git a/packages/orchestration/src/clients/evolver.ts b/packages/orchestration/src/clients/evolver.ts index 66c4cbca..72f80e50 100644 --- a/packages/orchestration/src/clients/evolver.ts +++ b/packages/orchestration/src/clients/evolver.ts @@ -1,7 +1,6 @@ /** services/evolver 的 owner-scoped API 客户端。 */ -import { createHash, randomUUID } from "node:crypto"; - -import { HttpClient } from "./http.js"; +import { HttpClient, HttpClientError } from "./http.js"; +import type { EvolutionLLMSnapshot } from "../mastra/llm/evolution-snapshot.js"; export type EvolutionConfig = { venue: string; @@ -72,17 +71,26 @@ export class EvolverClient { budget?: number; seedStrategyId?: string; config: EvolutionConfig; - idempotencyKey?: string; + idempotencyKey: string; + approvalToken: string; + llmSnapshot: EvolutionLLMSnapshot; }): Promise { const body = { budget: options.budget ?? 4, seed_strategy_id: options.seedStrategyId ?? "sma_cross_v1", config: options.config, + llm: options.llmSnapshot, + }; + const headers = { + "Idempotency-Key": options.idempotencyKey, + "X-Evolution-Approval": options.approvalToken, }; - const key = options.idempotencyKey ?? operationKey(body); - return await this.http.post("/api/v1/runs", body, { - "Idempotency-Key": key, - }); + try { + return await this.http.post("/api/v1/runs", body, headers); + } catch (error) { + if (!(error instanceof HttpClientError) || ![502, 504].includes(error.status)) throw error; + return await this.http.post("/api/v1/runs", body, headers); + } } async listRuns(limit = 20): Promise { @@ -101,8 +109,3 @@ export class EvolverClient { return await this.http.post(`/api/v1/runs/${runId}/abort`, {}); } } - -function operationKey(body: unknown): string { - const digest = createHash("sha256").update(JSON.stringify(body)).digest("hex").slice(0, 24); - return `tool-${digest}-${randomUUID().slice(0, 8)}`; -} diff --git a/packages/orchestration/src/hooks/with-hooks.ts b/packages/orchestration/src/hooks/with-hooks.ts index 42ff4471..6cb54703 100644 --- a/packages/orchestration/src/hooks/with-hooks.ts +++ b/packages/orchestration/src/hooks/with-hooks.ts @@ -26,6 +26,13 @@ * 让 Mastra runtime 把它当 tool 报错处理(LLM 看到错误消息能下一轮决策)。 * - 现阶段不接 permission engine,仅留 ``permissionResolver`` 参数。task #3 接入。 */ +import { + APPROVAL_OPERATION_ID_KEY, + getRequestContextValue, + setRequestContextValue, + USER_LLM_SNAPSHOT_KEY, + type EvolutionLLMSnapshot, +} from "../mastra/llm/evolution-snapshot.js"; import { projectApprovalInput } from "../permissions/approval-identity.js"; import { type PendingApprovalsStore, @@ -193,8 +200,15 @@ export function withHooks(tool: T, opts: WithHooksOptions if (permDecision === "ask") { const store = opts.pendingApprovals ?? defaultPendingApprovals; - const approvalInput = projectApprovalInput(toolName, effectiveInput); - if (!authSub || !sessionId) { + const projectedInput = projectApprovalInput(toolName, effectiveInput); + const llmSnapshot = + toolName === "evolver.run_evolution" + ? getRequestContextValue(ctx, USER_LLM_SNAPSHOT_KEY) + : undefined; + const approvalInput = llmSnapshot + ? { request: projectedInput, llm_snapshot: llmSnapshot } + : projectedInput; + if (!authSub || !sessionId || (toolName === "evolver.run_evolution" && !llmSnapshot)) { return { isError: true, deniedBy: "permission-ask", @@ -202,25 +216,28 @@ export function withHooks(tool: T, opts: WithHooksOptions toolName, toolInput: effectiveInput, message: - `APPROVAL_UNAVAILABLE: tool "${toolName}" cannot run because a verified owner ` + - `and stable thread/session ID are required. Do not retry or infer consent from chat text. ` + - `Explain this in the user's latest language.`, + `APPROVAL_UNAVAILABLE: tool "${toolName}" requires a verified owner, stable ` + + `thread/session, and a frozen non-secret LLM configuration. Do not retry or infer ` + + `consent from chat text. Explain this in the user's latest language.`, }; } - if ( - !store.consumeApproved({ - authSub, - sessionId, - toolName, - approvalInput, - }) - ) { + const operationId = store.consumeApproved({ + authSub, + sessionId, + toolName, + approvalInput, + reuseAfterConsume: toolName === "evolver.run_evolution", + }); + if (!operationId) { + const approvalViewInput = llmSnapshot + ? { request: effectiveInput, llm_snapshot: llmSnapshot } + : effectiveInput; const pending = store.request({ authSub, sessionId, toolName, - toolInput: effectiveInput, + toolInput: approvalViewInput, approvalInput, timeoutMs: opts.askTimeoutMs && opts.askTimeoutMs > 0 ? opts.askTimeoutMs : undefined, @@ -231,14 +248,15 @@ export function withHooks(tool: T, opts: WithHooksOptions requiresApproval: true, requestId: pending.requestId, toolName, - toolInput: effectiveInput, + toolInput: approvalViewInput, message: `APPROVAL_REQUIRED: tool "${toolName}" needs an explicit decision through the ` + `trusted approval UI/API. Chat text, a new turn, or model output cannot approve it. ` + - `Show the purpose and key inputs, then wait. Reply in the user's latest language. ` + - `If denied or expired, cancel the action and do not retry automatically.`, + `Show the purpose, frozen model, estimated cost, and key inputs, then wait. Reply in ` + + `the user's latest language. If denied or expired, cancel the action.`, }; } + setRequestContextValue(ctx, APPROVAL_OPERATION_ID_KEY, operationId); } // 3. execute diff --git a/packages/orchestration/src/mastra/identity.ts b/packages/orchestration/src/mastra/identity.ts index 2b1873c1..34ce2b8e 100644 --- a/packages/orchestration/src/mastra/identity.ts +++ b/packages/orchestration/src/mastra/identity.ts @@ -2,6 +2,11 @@ import type { MiddlewareHandler } from "hono"; import { verifyToken } from "../auth.js"; import { AUTH_SUB_KEY } from "../hooks/with-hooks.js"; +import { + buildEvolutionLLMSnapshot, + USER_LLM_SNAPSHOT_KEY, + type EvolutionLLMSnapshot, +} from "./llm/evolution-snapshot.js"; import { userLLMStore, type UserLLMConfig } from "./llm/provider.js"; let warnedNoRequestContext = false; @@ -33,6 +38,7 @@ export function parseUserLLMConfigHeader(raw: string | undefined): UserLLMConfig /** Injects authenticated identity and the user-owned LLM configuration into request scope. */ export const identityMiddleware: MiddlewareHandler = async (c, next) => { let userConfig: UserLLMConfig | undefined; + let evolutionSnapshot: EvolutionLLMSnapshot | undefined; try { userConfig = parseUserLLMConfigHeader(c.req.header("X-LLM-Config")); if (userConfig) { @@ -41,6 +47,11 @@ export const identityMiddleware: MiddlewareHandler = async (c, next) => { provider: userConfig.provider, model: userConfig.model, }); + try { + evolutionSnapshot = buildEvolutionLLMSnapshot(userConfig); + } catch { + /** Chat 可继续使用原生 provider;只有 OpenAI-compatible provider 支持演化。 */ + } } } catch { // Invalid user configuration falls back to the configured model path. @@ -67,6 +78,9 @@ export const identityMiddleware: MiddlewareHandler = async (c, next) => { return c.json({ error: "identity_context_unavailable" }, 503); } requestContext.set(AUTH_SUB_KEY, sub); + if (evolutionSnapshot) { + requestContext.set(USER_LLM_SNAPSHOT_KEY, evolutionSnapshot); + } } catch (error) { const code = (error as { code?: unknown } | null)?.code; if (code === "ERR_JWS_SIGNATURE_VERIFICATION_FAILED" && !warnedAuthSignature) { diff --git a/packages/orchestration/src/mastra/llm/evolution-snapshot.ts b/packages/orchestration/src/mastra/llm/evolution-snapshot.ts new file mode 100644 index 00000000..7c681db3 --- /dev/null +++ b/packages/orchestration/src/mastra/llm/evolution-snapshot.ts @@ -0,0 +1,142 @@ +/** Immutable, non-secret LLM metadata bound to one evolution approval and run. */ +import { createHash } from "node:crypto"; + +import type { UserLLMConfig } from "./provider.js"; +import { DEFAULT_MODELS, PROVIDER_BASE_URLS } from "./provider.js"; + +export const USER_LLM_SNAPSHOT_KEY = "inalpha__evolutionLLMSnapshot"; +export const APPROVAL_OPERATION_ID_KEY = "inalpha__approvalOperationId"; + +const PRICING_VERSION = "provider-estimate-2026-08"; +const ASSUMED_INPUT_TOKENS = 24_000; +const MAX_OUTPUT_TOKENS = 8_192; +export const EVOLUTION_LLM_PROVIDERS = ["deepseek", "openai", "kimi", "zhipu"] as const; +export type EvolutionLLMProvider = (typeof EVOLUTION_LLM_PROVIDERS)[number]; + +const RATES: Record = { + deepseek: [0.56, 1.68], + openai: [5, 15], + kimi: [0.6, 2.5], + zhipu: [0.7, 2.8], +}; + +export type EvolutionPricingSnapshot = { + version: string; + currency: "USD"; + input_usd_per_million: number; + output_usd_per_million: number; + assumed_input_tokens: number; + max_output_tokens: number; + estimated_max_usd_per_candidate: number; +}; + +export type EvolutionLLMSnapshot = { + config_id: string; + provider: EvolutionLLMProvider; + model: string; + base_url: string | null; + pricing: EvolutionPricingSnapshot; + config_digest: string; +}; + +/** Builds a frozen metadata snapshot without copying the user's API key. */ +export function buildEvolutionLLMSnapshot(config: UserLLMConfig): EvolutionLLMSnapshot { + if (!isEvolutionLLMProvider(config.provider)) { + throw new Error(`evolution pricing is unavailable for provider ${config.provider}`); + } + const provider = config.provider; + const rates = RATES[provider]; + const model = + config.model?.trim() || DEFAULT_MODELS[provider as keyof typeof DEFAULT_MODELS]; + if (!model) throw new Error(`evolution model is unavailable for provider ${provider}`); + const baseUrl = sanitizeBaseUrl( + config.custom_base_url || PROVIDER_BASE_URLS[provider] || null, + ); + const pricing: EvolutionPricingSnapshot = { + version: PRICING_VERSION, + currency: "USD", + input_usd_per_million: rates[0], + output_usd_per_million: rates[1], + assumed_input_tokens: ASSUMED_INPUT_TOKENS, + max_output_tokens: MAX_OUTPUT_TOKENS, + estimated_max_usd_per_candidate: Number( + ( + (ASSUMED_INPUT_TOKENS * rates[0] + MAX_OUTPUT_TOKENS * rates[1]) / + 1_000_000 + ).toFixed(12), + ), + }; + const canonical = { + config_id: config.id, + provider, + model, + base_url: baseUrl, + pricing, + }; + return { + ...canonical, + config_digest: computeEvolutionLLMConfigDigest(canonical), + }; +} + +/** Computes the cross-language digest used by Mastra and Evolver. */ +export function computeEvolutionLLMConfigDigest( + snapshot: Omit, +): string { + const pricing = snapshot.pricing; + const canonical = [ + snapshot.config_id, + snapshot.provider, + snapshot.model, + snapshot.base_url, + pricing.version, + pricing.currency, + String(pricing.input_usd_per_million), + String(pricing.output_usd_per_million), + String(pricing.assumed_input_tokens), + String(pricing.max_output_tokens), + String(pricing.estimated_max_usd_per_candidate), + ]; + return createHash("sha256").update(JSON.stringify(canonical)).digest("hex"); +} + +export function getRequestContextValue(ctx: unknown, key: string): T | undefined { + if (!ctx || typeof ctx !== "object") return undefined; + const requestContext = (ctx as Record).requestContext; + if (!requestContext || typeof requestContext !== "object") return undefined; + const getter = (requestContext as { get?: (name: string) => unknown }).get; + const value = + typeof getter === "function" + ? getter.call(requestContext, key) + : (requestContext as Record)[key]; + return value as T | undefined; +} + +export function setRequestContextValue(ctx: unknown, key: string, value: unknown): boolean { + if (!ctx || typeof ctx !== "object") return false; + const requestContext = (ctx as Record).requestContext; + if (!requestContext || typeof requestContext !== "object") return false; + const setter = (requestContext as { set?: (name: string, next: unknown) => void }).set; + if (typeof setter === "function") { + setter.call(requestContext, key, value); + return true; + } + (requestContext as Record)[key] = value; + return true; +} + +function sanitizeBaseUrl(value: string | null): string | null { + if (!value) return null; + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol) || !url.hostname) { + throw new Error("LLM base URL must be an absolute HTTP URL"); + } + if (url.username || url.password || url.search || url.hash) { + throw new Error("LLM base URL cannot contain credentials, query, or fragment"); + } + return url.toString().replace(/\/$/, ""); +} + +function isEvolutionLLMProvider(value: string): value is EvolutionLLMProvider { + return EVOLUTION_LLM_PROVIDERS.includes(value as EvolutionLLMProvider); +} diff --git a/packages/orchestration/src/mastra/llm/provider.ts b/packages/orchestration/src/mastra/llm/provider.ts index f5776e51..03040861 100644 --- a/packages/orchestration/src/mastra/llm/provider.ts +++ b/packages/orchestration/src/mastra/llm/provider.ts @@ -76,7 +76,7 @@ export const SUPPORTED_PROVIDERS: readonly LLMProvider[] = [ * | zhipu | glm-5.2 | GLM-5 系列旗舰;轻量用 glm-4.5-air | * | ollama | llama4 | Llama 4 默认 tag = Scout(17B/109B MoE);大显存用 llama4:128x17b(Maverick)| */ -const DEFAULT_MODELS: Record = { +export const DEFAULT_MODELS: Record = { deepseek: "deepseek-v4-pro", anthropic: "claude-opus-4-8", openai: "gpt-5.5", @@ -118,7 +118,7 @@ export interface UserLLMConfig { /** * 预设供应商默认端点(与 dashboard 对齐)。 */ -const PROVIDER_BASE_URLS: Partial> = { +export const PROVIDER_BASE_URLS: Partial> = { deepseek: "https://api.deepseek.com", openai: "https://api.openai.com/v1", kimi: "https://api.moonshot.cn/v1", diff --git a/packages/orchestration/src/permissions/pending.ts b/packages/orchestration/src/permissions/pending.ts index 64cb5f53..9c33bc2a 100644 --- a/packages/orchestration/src/permissions/pending.ts +++ b/packages/orchestration/src/permissions/pending.ts @@ -28,6 +28,7 @@ export interface PendingConsumeArgs { approvalInput: unknown; sessionId: string; authSub: string; + reuseAfterConsume?: boolean; } interface PendingApprovalRecord extends PendingApprovalView { @@ -36,6 +37,12 @@ interface PendingApprovalRecord extends PendingApprovalView { timer: ReturnType; } +interface ConsumedApprovalRecord { + operationId: string; + deadline: string; + timer: ReturnType; +} + const DEFAULT_TIMEOUT_MS = 30_000; export type PendingTelemetrySink = (record: Record) => void; @@ -62,6 +69,7 @@ export function approvalInputDigest(input: unknown): string { export class PendingApprovalsStore { private readonly records = new Map(); private readonly identityIndex = new Map(); + private readonly consumedByIdentity = new Map(); private readonly telemetry: PendingTelemetrySink; private readonly persistence?: ApprovalPersistence; @@ -139,17 +147,43 @@ export class PendingApprovalsStore { return true; } - /** Atomically consumes one approved decision matching owner, thread, tool, and input digest. */ - consumeApproved(args: PendingConsumeArgs): boolean { + /** Atomically consumes one approved decision and returns its stable operation ID. */ + consumeApproved(args: PendingConsumeArgs): string | undefined { const identity = this.identityFor(args); + const consumed = args.reuseAfterConsume ? this.consumedByIdentity.get(identity) : undefined; + if (consumed) { + if (Date.now() >= Date.parse(consumed.deadline)) { + this.removeConsumed(identity); + } else { + this.telemetry({ + event: "ask_approval_operation_reused", + requestId: consumed.operationId, + toolName: args.toolName, + sessionId: args.sessionId, + authSub: args.authSub, + ts: new Date().toISOString(), + }); + return consumed.operationId; + } + } const requestId = this.identityIndex.get(identity); const record = requestId ? this.records.get(requestId) : undefined; - if (!record || record.status !== "approved") return false; + if (!record || record.status !== "approved") return undefined; if (Date.now() >= Date.parse(record.deadline)) { this.expire(record.requestId); - return false; + return undefined; } this.remove(record); + if (args.reuseAfterConsume) { + this.consumedByIdentity.set(identity, { + operationId: record.requestId, + deadline: record.deadline, + timer: setTimeout( + () => this.removeConsumed(identity), + Math.max(Date.parse(record.deadline) - Date.now(), 0), + ), + }); + } this.telemetry({ event: "ask_approval_consumed", requestId: record.requestId, @@ -159,10 +193,9 @@ export class PendingApprovalsStore { inputDigest: record.inputDigest, ts: new Date().toISOString(), }); - return true; + return record.requestId; } - /** Returns the number of active pending or approved records. */ size(): number { return this.records.size; } @@ -175,6 +208,9 @@ export class PendingApprovalsStore { this.persist((p) => p.markResolved(record.requestId, reason, "user")); } } + for (const identity of Array.from(this.consumedByIdentity.keys())) { + this.removeConsumed(identity); + } } private expire(requestId: string): void { @@ -204,6 +240,13 @@ export class PendingApprovalsStore { ); } + private removeConsumed(identity: string): void { + const record = this.consumedByIdentity.get(identity); + if (!record) return; + clearTimeout(record.timer); + this.consumedByIdentity.delete(identity); + } + private identityFor(args: PendingConsumeArgs): string { return this.identityKey( args.authSub, diff --git a/packages/orchestration/src/tools/evolver-shared.ts b/packages/orchestration/src/tools/evolver-shared.ts index 03afaf49..df8fa465 100644 --- a/packages/orchestration/src/tools/evolver-shared.ts +++ b/packages/orchestration/src/tools/evolver-shared.ts @@ -1,9 +1,16 @@ /** Evolver Mastra tools 的共享 schema 与客户端解析。 */ import { z } from "zod"; -import { resolveRequestToken } from "../auth.js"; +import { mintServiceToken, resolveRequestToken } from "../auth.js"; import { EvolverClient } from "../clients/evolver.js"; import { getSettings } from "../config.js"; +import { AUTH_SUB_KEY } from "../hooks/with-hooks.js"; +import { + APPROVAL_OPERATION_ID_KEY, + getRequestContextValue, + USER_LLM_SNAPSHOT_KEY, + type EvolutionLLMSnapshot, +} from "../mastra/llm/evolution-snapshot.js"; export type ToolRequestContext = { authToken?: string; get?: (key: string) => unknown }; @@ -15,6 +22,43 @@ export async function getEvolverClient(ctx?: ToolRequestContext): Promise { + const operationId = getRequestContextValue( + { requestContext: ctx }, + APPROVAL_OPERATION_ID_KEY, + ); + const llmSnapshot = getRequestContextValue( + { requestContext: ctx }, + USER_LLM_SNAPSHOT_KEY, + ); + const authSub = ctx?.get?.(AUTH_SUB_KEY); + if (!operationId || !llmSnapshot || typeof authSub !== "string" || !authSub) { + throw new Error("explicit evolution approval context is missing"); + } + const approvalToken = await mintServiceToken( + { + sub: authSub, + token_use: "evolution_approval", + operation_id: operationId, + llm_config_digest: llmSnapshot.config_digest, + }, + 300, + ); + return { + client: await getEvolverClient(ctx), + operationId, + approvalToken, + llmSnapshot, + }; +} + export const evolutionConfigSchema = z.object({ venue: z.string().min(1).describe("数据 venue;按标的市场选择,不预设市场"), symbol: z.string().min(1).describe("该 venue 使用的标的代码"), diff --git a/packages/orchestration/src/tools/evolver.ts b/packages/orchestration/src/tools/evolver.ts index a6275ceb..771acbd2 100644 --- a/packages/orchestration/src/tools/evolver.ts +++ b/packages/orchestration/src/tools/evolver.ts @@ -2,7 +2,12 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; -import { evolutionConfigSchema, getEvolverClient, type ToolRequestContext } from "./evolver-shared.js"; +import { + evolutionConfigSchema, + getApprovedEvolutionRunContext, + getEvolverClient, + type ToolRequestContext, +} from "./evolver-shared.js"; export const evolverRunEvolutionTool = createTool({ id: "evolver.run_evolution", @@ -16,15 +21,18 @@ export const evolverRunEvolutionTool = createTool({ budget: z.number().int().min(1).max(20).default(4), seedStrategyId: z.string().min(1).max(128).default("sma_cross_v1"), config: evolutionConfigSchema, - idempotencyKey: z.string().min(8).max(128).optional(), }), execute: async (inputData, ctx) => { - const client = await getEvolverClient(ctx?.requestContext as ToolRequestContext | undefined); - return await client.startRun({ + const approved = await getApprovedEvolutionRunContext( + ctx?.requestContext as ToolRequestContext | undefined, + ); + return await approved.client.startRun({ budget: inputData.budget, seedStrategyId: inputData.seedStrategyId, config: inputData.config, - idempotencyKey: inputData.idempotencyKey, + idempotencyKey: approved.operationId, + approvalToken: approved.approvalToken, + llmSnapshot: approved.llmSnapshot, }); }, }); diff --git a/packages/orchestration/tests/ask-path-e2e.test.ts b/packages/orchestration/tests/ask-path-e2e.test.ts index 81892984..b5da5e1d 100644 --- a/packages/orchestration/tests/ask-path-e2e.test.ts +++ b/packages/orchestration/tests/ask-path-e2e.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { AUTH_SUB_KEY, HookRunner, withHooks } from "../src/hooks/index.js"; +import { + APPROVAL_OPERATION_ID_KEY, + buildEvolutionLLMSnapshot, + USER_LLM_SNAPSHOT_KEY, +} from "../src/mastra/llm/evolution-snapshot.js"; import { PendingApprovalsStore } from "../src/permissions/pending.js"; function toolContext(owner: string, thread: string, runId = "turn-1") { @@ -114,4 +119,44 @@ describe("explicit approval path", () => { expect(store.size()).toBe(0); expect(execute).not.toHaveBeenCalled(); }); + + it("演化审批冻结 LLM 快照,并在响应丢失后的同范围调用中复用 operation ID", async () => { + const store = new PendingApprovalsStore(() => {}); + const observedOperations: unknown[] = []; + const execute = vi.fn().mockImplementation((_input, context) => { + observedOperations.push(context.requestContext.get(APPROVAL_OPERATION_ID_KEY)); + return { status: "executed" }; + }); + const wrapped = withHooks( + { id: "evolver.run_evolution", execute }, + { + runner: new HookRunner(), + permissionResolver: () => "ask", + pendingApprovals: store, + askTimeoutMs: 5_000, + }, + ); + const input = { budget: 1, config: { symbol: "BTCUSDT" } }; + const context = toolContext("user:alice", "thread-A"); + context.requestContext.set( + USER_LLM_SNAPSHOT_KEY, + buildEvolutionLLMSnapshot({ + id: "config-1", + provider: "deepseek", + api_key: "must-not-enter-approval", + }), + ); + + const pending = (await wrapped.execute!(input, context)) as { + requestId: string; + toolInput: unknown; + }; + expect(JSON.stringify(pending.toolInput)).not.toContain("must-not-enter-approval"); + expect(store.respond(pending.requestId, "allow", "user:alice")).toBe(true); + + expect(await wrapped.execute!(input, context)).toEqual({ status: "executed" }); + expect(await wrapped.execute!(input, context)).toEqual({ status: "executed" }); + expect(observedOperations).toEqual([pending.requestId, pending.requestId]); + store.clearAll(); + }); }); diff --git a/packages/orchestration/tests/evolution-snapshot.test.ts b/packages/orchestration/tests/evolution-snapshot.test.ts new file mode 100644 index 00000000..2312e1e7 --- /dev/null +++ b/packages/orchestration/tests/evolution-snapshot.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + buildEvolutionLLMSnapshot, + computeEvolutionLLMConfigDigest, +} from "../src/mastra/llm/evolution-snapshot.js"; + +describe("evolution LLM snapshot", () => { + it("matches the Python cross-language digest contract without copying the API key", () => { + const snapshot = buildEvolutionLLMSnapshot({ + id: "config-1", + provider: "deepseek", + model: "deepseek-v4-pro", + api_key: "must-not-be-copied", + custom_base_url: "https://api.deepseek.com/", + }); + + expect(snapshot.config_digest).toBe( + "a4635b0c80f69b6054bdc2330b78cb98d9c81c849d476e7d01f1b8d626015c2c", + ); + expect(JSON.stringify(snapshot)).not.toContain("must-not-be-copied"); + expect(computeEvolutionLLMConfigDigest(snapshot)).toBe(snapshot.config_digest); + }); + + it("fails closed for providers the Python runtime cannot execute", () => { + expect(() => + buildEvolutionLLMSnapshot({ + id: "config-2", + provider: "anthropic", + api_key: "test-key", + }), + ).toThrow("pricing is unavailable"); + }); + + it("rejects credentials, query strings, and non-HTTP base URLs", () => { + for (const custom_base_url of [ + "https://user:pass@example.com/v1", + "https://example.com/v1?token=x", + "ftp://example.com/v1", + ]) { + expect(() => + buildEvolutionLLMSnapshot({ + id: "config-3", + provider: "openai", + api_key: "test-key", + custom_base_url, + }), + ).toThrow(); + } + }); +}); diff --git a/packages/orchestration/tests/evolver-client.test.ts b/packages/orchestration/tests/evolver-client.test.ts new file mode 100644 index 00000000..4140909e --- /dev/null +++ b/packages/orchestration/tests/evolver-client.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { verifyToken } from "../src/auth.js"; +import { EvolverClient } from "../src/clients/evolver.js"; +import { AUTH_SUB_KEY } from "../src/hooks/with-hooks.js"; +import { + APPROVAL_OPERATION_ID_KEY, + buildEvolutionLLMSnapshot, + USER_LLM_SNAPSHOT_KEY, +} from "../src/mastra/llm/evolution-snapshot.js"; +import { getApprovedEvolutionRunContext } from "../src/tools/evolver-shared.js"; + +const snapshot = buildEvolutionLLMSnapshot({ + id: "config-1", + provider: "deepseek", + model: "deepseek-v4-pro", + api_key: "not-forwarded", +}); + +function response(status: number): Response { + return new Response( + JSON.stringify( + status === 200 + ? { run_id: "run-1", status: "queued" } + : { code: `HTTP_${status}`, message: "temporary upstream failure" }, + ), + { status, headers: { "Content-Type": "application/json" } }, + ); +} + +function options() { + return { + budget: 1, + seedStrategyId: "sma_cross_v1", + config: { + venue: "binance", + symbol: "BTCUSDT", + timeframe: "1h", + from_ts: "2026-08-01T00:00:00Z", + as_of: "2026-08-02T00:00:00Z", + initial_cash: 10_000, + }, + idempotencyKey: "approval-operation-1", + approvalToken: "approval-token", + llmSnapshot: snapshot, + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("EvolverClient", () => { + it("mints a short-lived approval JWT bound to owner, operation, and snapshot", async () => { + const requestContext = new Map([ + [AUTH_SUB_KEY, "user:alice"], + [APPROVAL_OPERATION_ID_KEY, "approval-operation-1"], + [USER_LLM_SNAPSHOT_KEY, snapshot], + ]); + + const approved = await getApprovedEvolutionRunContext(requestContext); + const payload = await verifyToken(approved.approvalToken); + + expect(payload).toMatchObject({ + sub: "user:alice", + token_use: "evolution_approval", + operation_id: "approval-operation-1", + llm_config_digest: snapshot.config_digest, + }); + expect(Number(payload.exp) - Number(payload.iat)).toBe(300); + }); + + it("retries 502/504 with the same approval-derived operation ID", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(response(504)) + .mockResolvedValueOnce(response(200)); + vi.stubGlobal("fetch", fetchMock); + + const result = await new EvolverClient({ + baseUrl: "http://evolver.test", + token: "owner-token", + }).startRun(options()); + + expect(result.status).toBe("queued"); + expect(fetchMock).toHaveBeenCalledTimes(2); + for (const call of fetchMock.mock.calls) { + const init = call[1] as RequestInit; + expect((init.headers as Record)["Idempotency-Key"]).toBe( + "approval-operation-1", + ); + expect((init.headers as Record)["X-Evolution-Approval"]).toBe( + "approval-token", + ); + expect(init.body).not.toContain("not-forwarded"); + } + }); +}); diff --git a/packages/orchestration/tests/identity.test.ts b/packages/orchestration/tests/identity.test.ts index 34079b16..794aa25e 100644 --- a/packages/orchestration/tests/identity.test.ts +++ b/packages/orchestration/tests/identity.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { mintServiceToken } from "../src/auth.js"; import { AUTH_SUB_KEY } from "../src/hooks/with-hooks.js"; import { identityMiddleware } from "../src/mastra/identity.js"; +import { USER_LLM_SNAPSHOT_KEY } from "../src/mastra/llm/evolution-snapshot.js"; const TEST_KEY = "unique-test-key-must-never-reach-logs"; @@ -63,6 +64,47 @@ describe("identityMiddleware", () => { expect(requestContext.get(AUTH_SUB_KEY)).toBe("user:alice"); }); + it("injects a non-secret evolution snapshot for a supported owner config", async () => { + const requestContext = new Map(); + const response = await appWithRequestContext(requestContext).request("/", { + headers: { + Authorization: `Bearer ${await mintServiceToken({ sub: "user:alice" })}`, + "X-LLM-Config": JSON.stringify({ + id: "config-1", + provider: "deepseek", + model: "deepseek-v4-pro", + api_key: TEST_KEY, + }), + }, + }); + + expect(response.status).toBe(200); + const snapshot = requestContext.get(USER_LLM_SNAPSHOT_KEY); + expect(snapshot).toMatchObject({ + config_id: "config-1", + provider: "deepseek", + model: "deepseek-v4-pro", + }); + expect(JSON.stringify(snapshot)).not.toContain(TEST_KEY); + }); + + it("keeps chat available but omits evolution snapshot for an unsupported provider", async () => { + const requestContext = new Map(); + const response = await appWithRequestContext(requestContext).request("/", { + headers: { + Authorization: `Bearer ${await mintServiceToken({ sub: "user:alice" })}`, + "X-LLM-Config": JSON.stringify({ + id: "config-1", + provider: "anthropic", + api_key: TEST_KEY, + }), + }, + }); + + expect(response.status).toBe(200); + expect(requestContext.has(USER_LLM_SNAPSHOT_KEY)).toBe(false); + }); + it("returns 401 for an invalid Bearer token", async () => { const requestContext = new Map(); const response = await appWithRequestContext(requestContext).request("/", { diff --git a/packages/orchestration/tests/permissions-pending.test.ts b/packages/orchestration/tests/permissions-pending.test.ts index 477d88e3..5f6cd5c8 100644 --- a/packages/orchestration/tests/permissions-pending.test.ts +++ b/packages/orchestration/tests/permissions-pending.test.ts @@ -57,7 +57,7 @@ describe("PendingApprovalsStore", () => { toolName: "paper.promote_candidate", approvalInput: { candidateId: "c-42" }, }), - ).toBe(false); + ).toBeUndefined(); expect( store.consumeApproved({ authSub: "user:alice", @@ -65,7 +65,7 @@ describe("PendingApprovalsStore", () => { toolName: "paper.promote_candidate", approvalInput: { candidateId: "c-42" }, }), - ).toBe(true); + ).toBe(view.requestId); expect( store.consumeApproved({ authSub: "user:alice", @@ -73,7 +73,7 @@ describe("PendingApprovalsStore", () => { toolName: "paper.promote_candidate", approvalInput: { candidateId: "c-42" }, }), - ).toBe(false); + ).toBeUndefined(); }); it("deny and timeout revoke the record", async () => { @@ -100,6 +100,37 @@ describe("PendingApprovalsStore", () => { expect(store.size()).toBe(1); store.clearAll(); }); + + it("reuses one evolution operation ID for a same-scope transport retry", () => { + const store = new PendingApprovalsStore(() => {}); + const args = { + authSub: "user:alice", + sessionId: "thread-A", + toolName: "evolver.run_evolution", + toolInput: { budget: 1 }, + approvalInput: { request: { budget: 1 }, llm_snapshot: { config_digest: "digest" } }, + timeoutMs: 5_000, + }; + const view = store.request(args); + expect(store.respond(view.requestId, "allow", "user:alice")).toBe(true); + + const consume = { + authSub: args.authSub, + sessionId: args.sessionId, + toolName: args.toolName, + approvalInput: args.approvalInput, + reuseAfterConsume: true, + }; + expect(store.consumeApproved(consume)).toBe(view.requestId); + expect(store.consumeApproved(consume)).toBe(view.requestId); + expect( + store.consumeApproved({ + ...consume, + approvalInput: { request: { budget: 2 }, llm_snapshot: { config_digest: "digest" } }, + }), + ).toBeUndefined(); + store.clearAll(); + }); }); describe("permissions approval HTTP API", () => { @@ -145,6 +176,6 @@ describe("permissions approval HTTP API", () => { toolName: "paper.promote_candidate", approvalInput: { candidateId: "c-42" }, }), - ).toBe(true); + ).toBe(view.requestId); }); }); diff --git a/services/evolver/src/inalpha_evolver/api/approval.py b/services/evolver/src/inalpha_evolver/api/approval.py new file mode 100644 index 00000000..c94185be --- /dev/null +++ b/services/evolver/src/inalpha_evolver/api/approval.py @@ -0,0 +1,49 @@ +"""Mastra 显式审批断言验证。""" + +from __future__ import annotations + +import jwt +from fastapi import HTTPException + +from ..config import EvolverSettings + +_MAX_APPROVAL_TTL_SECONDS = 300 + + +def verify_evolution_approval( + token: str, + *, + owner_sub: str, + operation_id: str, + llm_config_digest: str, + settings: EvolverSettings, +) -> None: + """验证短效审批 JWT,并绑定 owner、幂等操作和 LLM 快照。""" + try: + payload = jwt.decode( + token, + settings.jwt_secret, + algorithms=[settings.jwt_algorithm], + options={"require": ["sub", "exp", "iat"]}, + ) + except jwt.PyJWTError as exc: + raise HTTPException(status_code=401, detail="invalid evolution approval") from exc + expected = { + "token_use": "evolution_approval", + "sub": owner_sub, + "operation_id": operation_id, + "llm_config_digest": llm_config_digest, + } + issued_at = payload.get("iat") + expires_at = payload.get("exp") + invalid_ttl = ( + not isinstance(issued_at, int) + or not isinstance(expires_at, int) + or expires_at - issued_at > _MAX_APPROVAL_TTL_SECONDS + or expires_at <= issued_at + ) + if invalid_ttl or any(payload.get(key) != value for key, value in expected.items()): + raise HTTPException(status_code=403, detail="evolution approval scope mismatch") + + +__all__ = ["verify_evolution_approval"] diff --git a/services/evolver/src/inalpha_evolver/api/run_routes.py b/services/evolver/src/inalpha_evolver/api/run_routes.py index f7e5c08d..9be4cff7 100644 --- a/services/evolver/src/inalpha_evolver/api/run_routes.py +++ b/services/evolver/src/inalpha_evolver/api/run_routes.py @@ -1,4 +1,5 @@ """Evolver run 创建与列表端点。""" + from __future__ import annotations from datetime import UTC, datetime @@ -13,6 +14,7 @@ from ..config import get_evolver_settings from ..governor.seed_resolver import resolve_seed from ..storage import run_queries, runs +from .approval import verify_evolution_approval from .cursor import decode_cursor, encode_cursor from .presenters import run_response from .request_hash import normalized_request @@ -29,9 +31,20 @@ async def start_run( db: DBConn, user: Annotated[User, Depends(get_current_user)], idempotency_key: Annotated[str, Header(alias="Idempotency-Key", min_length=8, max_length=128)], + evolution_approval: Annotated[ + str, + Header(alias="X-Evolution-Approval", min_length=20, max_length=4096), + ], ) -> RunStatusResponse: owner = account_id_from_user(user) settings = get_evolver_settings() + verify_evolution_approval( + evolution_approval, + owner_sub=user.user_id, + operation_id=idempotency_key, + llm_config_digest=body.llm.config_digest, + settings=settings, + ) config, request_hash = normalized_request(body) async with db.transaction(): await db.execute("SELECT pg_advisory_xact_lock(hashtext(%s))", (str(owner),)) @@ -48,6 +61,7 @@ async def start_run( seed_hash=seed.source_hash, budget=body.budget, config=config, + llm_snapshot=body.llm.model_dump(mode="json"), queued_at=datetime.now(UTC), ) if not created and row["request_hash"] != request_hash: @@ -76,9 +90,7 @@ async def list_runs( has_more = len(rows) > page_limit items = rows[:page_limit] next_cursor = ( - encode_cursor(items[-1]["queued_at"], items[-1]["run_id"]) - if has_more and items - else None + encode_cursor(items[-1]["queued_at"], items[-1]["run_id"]) if has_more and items else None ) return RunListResponse( items=[run_response(row, summary=row) for row in items], diff --git a/services/evolver/src/inalpha_evolver/api/schemas.py b/services/evolver/src/inalpha_evolver/api/schemas.py index e4047253..8f9b25ee 100644 --- a/services/evolver/src/inalpha_evolver/api/schemas.py +++ b/services/evolver/src/inalpha_evolver/api/schemas.py @@ -1,11 +1,18 @@ """Evolver API 请求与响应模型。""" + from __future__ import annotations +import hashlib +import hmac +import json from datetime import UTC, datetime, timedelta -from typing import Any +from decimal import Decimal +from typing import Any, Literal +from urllib.parse import urlsplit from uuid import UUID -from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator +from pydantic_core import PydanticCustomError from ..data.datetime_policy import MAX_AS_OF_CLOCK_SKEW from ..data.manifest import DatasetManifest @@ -42,10 +49,84 @@ def validate_window(self) -> EvolutionConfig: return self +class EvolutionPricingSnapshot(BaseModel): + model_config = ConfigDict(extra="forbid") + + version: str = Field(min_length=1, max_length=80) + currency: Literal["USD"] + input_usd_per_million: float = Field(gt=0) + output_usd_per_million: float = Field(gt=0) + assumed_input_tokens: int = Field(gt=0, le=1_000_000) + max_output_tokens: int = Field(gt=0, le=100_000) + estimated_max_usd_per_candidate: float = Field(gt=0) + + +class EvolutionLLMSnapshot(BaseModel): + model_config = ConfigDict(extra="forbid") + + config_id: str = Field(min_length=1, max_length=128) + provider: Literal["deepseek", "openai", "kimi", "zhipu"] + model: str = Field(min_length=1, max_length=160) + base_url: str | None = Field(default=None, max_length=500) + pricing: EvolutionPricingSnapshot + config_digest: str = Field(pattern=r"^[0-9a-f]{64}$") + + @field_validator("base_url") + @classmethod + def validate_base_url(cls, value: str | None) -> str | None: + if value is None: + return None + parsed = urlsplit(value) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + raise ValueError("LLM base_url must be an absolute HTTP URL") + if parsed.username or parsed.password or parsed.query or parsed.fragment: + raise ValueError("LLM base_url cannot contain credentials, query, or fragment") + return value.rstrip("/") + + @model_validator(mode="after") + def verify_config_digest(self) -> EvolutionLLMSnapshot: + """拒绝任何未被 Mastra 审批摘要覆盖的快照字段变更。""" + expected = compute_llm_config_digest(self) + if not hmac.compare_digest(self.config_digest, expected): + raise PydanticCustomError( + "llm_snapshot_digest", + "LLM config_digest does not match the frozen snapshot", + ) + return self + + +def compute_llm_config_digest(snapshot: EvolutionLLMSnapshot) -> str: + """按与 TypeScript 相同的字段顺序和数字文本计算跨语言摘要。""" + pricing = snapshot.pricing + canonical = [ + snapshot.config_id, + snapshot.provider, + snapshot.model, + snapshot.base_url, + pricing.version, + pricing.currency, + _number_text(pricing.input_usd_per_million), + _number_text(pricing.output_usd_per_million), + _number_text(pricing.assumed_input_tokens), + _number_text(pricing.max_output_tokens), + _number_text(pricing.estimated_max_usd_per_candidate), + ] + encoded = json.dumps(canonical, ensure_ascii=False, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _number_text(value: int | float) -> str: + text = format(Decimal(str(value)), "f") + if "." in text: + text = text.rstrip("0").rstrip(".") + return text or "0" + + class StartRunRequest(BaseModel): seed_strategy_id: str = Field(default="sma_cross_v1", max_length=128) budget: int = Field(default=4, ge=1, le=20) config: EvolutionConfig + llm: EvolutionLLMSnapshot class CandidateResponse(BaseModel): @@ -77,6 +158,8 @@ class RunStatusResponse(BaseModel): seed_strategy_id: str budget: int config: dict[str, Any] + llm_snapshot: EvolutionLLMSnapshot | None = None + llm_config_digest: str | None = None status: str active_stage: str | None = None llm_cost_usd: float = 0.0 diff --git a/services/evolver/src/inalpha_evolver/config.py b/services/evolver/src/inalpha_evolver/config.py index c31cbe5b..24bff9cf 100644 --- a/services/evolver/src/inalpha_evolver/config.py +++ b/services/evolver/src/inalpha_evolver/config.py @@ -67,6 +67,16 @@ class EvolverSettings(BaseSettings): default="http://127.0.0.1:8001", alias="DATA_SERVICE_URL", ) + dashboard_service_url: str = Field( + default="http://127.0.0.1:3001", + alias="DASHBOARD_SERVICE_URL", + ) + evolver_llm_timeout_s: int = Field( + default=120, + alias="EVOLVER_LLM_TIMEOUT_S", + ge=1, + le=600, + ) jwt_secret: str = Field(default="dev-secret", alias="JWT_SECRET") jwt_algorithm: str = Field(default="HS256", alias="JWT_ALGORITHM") service_token_ttl_s: int = Field(default=3600, ge=60, le=86400) diff --git a/services/evolver/src/inalpha_evolver/exceptions.py b/services/evolver/src/inalpha_evolver/exceptions.py index 02a47845..1ad9e914 100644 --- a/services/evolver/src/inalpha_evolver/exceptions.py +++ b/services/evolver/src/inalpha_evolver/exceptions.py @@ -9,9 +9,18 @@ def __init__( message: str, original: str | None = None, failed_diff: str | None = None, + *, + llm_cost_usd: float | None = None, + cache_hit_tokens: int | None = None, + input_tokens: int | None = None, + output_tokens: int | None = None, ) -> None: self.original = original self.failed_diff = failed_diff + self.llm_cost_usd = llm_cost_usd + self.cache_hit_tokens = cache_hit_tokens + self.input_tokens = input_tokens + self.output_tokens = output_tokens super().__init__(message) @@ -32,4 +41,4 @@ class StoreError(RuntimeError): class LLMError(RuntimeError): - """LLM 调用失败。""" \ No newline at end of file + """LLM 调用失败。""" diff --git a/services/evolver/src/inalpha_evolver/main.py b/services/evolver/src/inalpha_evolver/main.py index 7c660372..0d29dbbd 100644 --- a/services/evolver/src/inalpha_evolver/main.py +++ b/services/evolver/src/inalpha_evolver/main.py @@ -18,7 +18,6 @@ from .api.routes import router from .config import get_evolver_settings -from .mutator import Mutator from .runtime import EvolutionRunManager logger = logging.getLogger(__name__) @@ -38,7 +37,7 @@ async def lifespan(app: FastAPI): min_size=2, max_size=settings.evolver_pool_size, ) - manager = EvolutionRunManager(mutator=Mutator(), settings=settings) + manager = EvolutionRunManager(mutator=None, settings=settings) app.state.evolution_manager = manager await manager.start() try: diff --git a/services/evolver/src/inalpha_evolver/mutator/llm_client.py b/services/evolver/src/inalpha_evolver/mutator/llm_client.py index ee2bcefd..fe5339b2 100644 --- a/services/evolver/src/inalpha_evolver/mutator/llm_client.py +++ b/services/evolver/src/inalpha_evolver/mutator/llm_client.py @@ -1,12 +1,15 @@ """LLM 变异客户端 —— 包装 ``_shared/llm`` 的 LLMClient,组装 prompt 模板。""" + from __future__ import annotations from dataclasses import dataclass, field from hashlib import sha256 -from inalpha_shared_llm import LLMClient as SharedLLMClient -from inalpha_shared_llm.client import MockLLMClient as SharedMockLLMClient -from inalpha_shared_llm.types import MutationRequest +from inalpha_shared_llm import LLMClient as SharedLLMClient # type: ignore[import-untyped] +from inalpha_shared_llm.client import ( # type: ignore[import-untyped] + MockLLMClient as SharedMockLLMClient, +) +from inalpha_shared_llm.types import CacheMetrics, MutationRequest # type: ignore[import-untyped] from ..exceptions import DiffApplyError, LLMError from .diff_applier import apply_diff @@ -56,6 +59,10 @@ class MutationResult: """本次 LLM 调用的估算费用(美元)。""" cache_hit_tokens: int """本次 LLM 调用的缓存命中 tokens(用于 cache 效率统计)。""" + input_tokens: int = 0 + """本次 LLM 调用的输入 tokens。""" + output_tokens: int = 0 + """本次 LLM 调用的输出 tokens。""" @dataclass(slots=True) @@ -66,10 +73,20 @@ class Mutator: 测试时可换 ``MockLLMClient``。 """ - llm_client: SharedLLMClient | SharedMockLLMClient = field( - default_factory=SharedLLMClient - ) + llm_client: SharedLLMClient | SharedMockLLMClient = field(default_factory=SharedLLMClient) max_fuzz: int = 3 + input_usd_per_million: float | None = None + output_usd_per_million: float | None = None + max_output_tokens: int = 8192 + + def __post_init__(self) -> None: + rates = (self.input_usd_per_million, self.output_usd_per_million) + if (rates[0] is None) != (rates[1] is None): + raise ValueError("input and output pricing rates must be configured together") + if any(rate is not None and rate <= 0 for rate in rates): + raise ValueError("pricing rates must be positive") + if self.max_output_tokens <= 0: + raise ValueError("max_output_tokens must be positive") async def mutate( self, @@ -95,7 +112,7 @@ async def mutate( request = MutationRequest( system_prompt=SYSTEM_PROMPT, user_prompt=user_prompt, - max_tokens=8192, # diff 可能很长,DeepSeek 需要足够输出空间 + max_tokens=self.max_output_tokens, ) try: @@ -104,6 +121,8 @@ async def mutate( raise LLMError(f"LLM 变异调用失败:{exc}") from exc raw_diff = _clean_llm_diff(response.content) + metrics = response.cache_metrics + llm_cost_usd = self._cost_usd(metrics) # 空 diff = LLM 认为无需改动 if not raw_diff or not raw_diff.startswith("---"): @@ -111,8 +130,10 @@ async def mutate( new_source=current_source, unified_diff=None, source_hash=sha256(current_source.encode()).hexdigest(), - llm_cost_usd=response.cache_metrics.cost_usd, - cache_hit_tokens=response.cache_metrics.cache_read_tokens, + llm_cost_usd=llm_cost_usd, + cache_hit_tokens=metrics.cache_read_tokens, + input_tokens=metrics.input_tokens, + output_tokens=metrics.output_tokens, ) try: @@ -123,12 +144,33 @@ async def mutate( str(exc), original=current_source, failed_diff=raw_diff, + llm_cost_usd=llm_cost_usd, + cache_hit_tokens=metrics.cache_read_tokens, + input_tokens=metrics.input_tokens, + output_tokens=metrics.output_tokens, ) from exc return MutationResult( new_source=new_source, unified_diff=raw_diff, source_hash=sha256(new_source.encode()).hexdigest(), - llm_cost_usd=response.cache_metrics.cost_usd, - cache_hit_tokens=response.cache_metrics.cache_read_tokens, - ) \ No newline at end of file + llm_cost_usd=llm_cost_usd, + cache_hit_tokens=metrics.cache_read_tokens, + input_tokens=metrics.input_tokens, + output_tokens=metrics.output_tokens, + ) + + async def close(self) -> None: + """关闭该 run 独占的底层 LLM HTTP client。""" + await self.llm_client.close() + + def _cost_usd(self, metrics: CacheMetrics) -> float: + if self.input_usd_per_million is None or self.output_usd_per_million is None: + return float(metrics.cost_usd) + return float( + ( + metrics.input_tokens * self.input_usd_per_million + + metrics.output_tokens * self.output_usd_per_million + ) + / 1_000_000 + ) diff --git a/services/evolver/src/inalpha_evolver/owner_llm.py b/services/evolver/src/inalpha_evolver/owner_llm.py new file mode 100644 index 00000000..ef24628a --- /dev/null +++ b/services/evolver/src/inalpha_evolver/owner_llm.py @@ -0,0 +1,80 @@ +"""按 run 冻结快照解析 owner 的既有加密 LLM 凭据。""" + +from __future__ import annotations + +import time +from typing import Any +from urllib.parse import quote + +import httpx +import jwt +from inalpha_shared_llm import LLMClient # type: ignore[import-untyped] +from inalpha_shared_llm.config import LLMSettings # type: ignore[import-untyped] + +from .config import EvolverSettings +from .mutator import Mutator + +_BASE_URLS = { + "deepseek": "https://api.deepseek.com/v1", + "openai": "https://api.openai.com/v1", + "kimi": "https://api.moonshot.cn/v1", + "zhipu": "https://open.bigmodel.cn/api/paas/v4", +} + + +async def build_owner_mutator( + run: dict[str, Any], + settings: EvolverSettings, +) -> Mutator: + """只把明文 key 留在当前进程内;run/日志均仅保存 config reference。""" + snapshot = run.get("llm_snapshot") + if not isinstance(snapshot, dict): + raise RuntimeError("run is missing frozen LLM snapshot") + config_id = str(snapshot["config_id"]) + issued_at = int(time.time()) + token = jwt.encode( + { + "sub": run["requested_by_sub"], + "token_use": "evolver_credential", + "config_id": config_id, + "iat": issued_at, + "exp": issued_at + min(settings.service_token_ttl_s, 600), + }, + settings.jwt_secret, + algorithm=settings.jwt_algorithm, + ) + url = ( + f"{settings.dashboard_service_url.rstrip('/')}/api/internal/llm-config/" + f"{quote(config_id, safe='')}" + ) + async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: + response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) + if response.status_code != 200: + raise RuntimeError(f"owner LLM credential unavailable: HTTP {response.status_code}") + credential = response.json() + if ( + credential.get("config_id") != config_id + or credential.get("provider") != snapshot["provider"] + ): + raise RuntimeError("owner LLM credential no longer matches frozen snapshot") + api_key = credential.get("api_key") + if not isinstance(api_key, str) or not api_key: + raise RuntimeError("owner LLM credential response omitted api_key") + pricing = snapshot["pricing"] + llm_settings = LLMSettings( + LLM_API_KEY=api_key, + DEEPSEEK_API_KEY="", + LLM_BASE_URL=snapshot.get("base_url") or _BASE_URLS[snapshot["provider"]], + LLM_MODEL=snapshot["model"], + LLM_TIMEOUT_S=settings.evolver_llm_timeout_s, + LLM_MAX_TOKENS=int(pricing["max_output_tokens"]), + ) + return Mutator( + llm_client=LLMClient(settings=llm_settings), + input_usd_per_million=float(pricing["input_usd_per_million"]), + output_usd_per_million=float(pricing["output_usd_per_million"]), + max_output_tokens=int(pricing["max_output_tokens"]), + ) + + +__all__ = ["build_owner_mutator"] diff --git a/services/evolver/src/inalpha_evolver/runtime/executor.py b/services/evolver/src/inalpha_evolver/runtime/executor.py index 652cd853..389a1d23 100644 --- a/services/evolver/src/inalpha_evolver/runtime/executor.py +++ b/services/evolver/src/inalpha_evolver/runtime/executor.py @@ -1,8 +1,11 @@ """queued run 的数据加载、评估器构建与执行。""" + from __future__ import annotations import asyncio import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from datetime import datetime from typing import Any from uuid import UUID @@ -15,6 +18,7 @@ from ..config import EvolverSettings from ..data import FrozenBarsLoader from ..evaluator import FrozenDatasetEvaluator +from ..owner_llm import build_owner_mutator from ..storage import runs from .generation import execute_generation @@ -22,7 +26,7 @@ async def execute_run( run: dict[str, Any], *, - mutator: Any, + mutator: Any | None, settings: EvolverSettings, ) -> None: config = _parse_config(run["config"]) @@ -61,7 +65,25 @@ async def execute_run( fee_rate=float(config.get("fee_rate", 0.001)), validation_split=float(config.get("validation_split", 0.3)), ) - await execute_generation(run, mutator=mutator, evaluator=evaluator) + async with _run_mutator(run, mutator, settings) as active_mutator: + await execute_generation(run, mutator=active_mutator, evaluator=evaluator) + + +@asynccontextmanager +async def _run_mutator( + run: dict[str, Any], + injected: Any | None, + settings: EvolverSettings, +) -> AsyncIterator[Any]: + """生产按 run 解析 owner 凭据;测试注入路径不接管其生命周期。""" + if injected is not None: + yield injected + return + owner_mutator = await build_owner_mutator(run, settings) + try: + yield owner_mutator + finally: + await owner_mutator.close() def _parse_config(config: dict[str, Any]) -> dict[str, Any]: diff --git a/services/evolver/src/inalpha_evolver/runtime/generation.py b/services/evolver/src/inalpha_evolver/runtime/generation.py index 182d7413..dda1f9e9 100644 --- a/services/evolver/src/inalpha_evolver/runtime/generation.py +++ b/services/evolver/src/inalpha_evolver/runtime/generation.py @@ -50,7 +50,7 @@ async def execute_generation( await reject_slot(run["run_id"], slot, "mutation_failed", exc) continue except DiffApplyError as exc: - await reject_slot(run["run_id"], slot, "diff_failed", exc) + await reject_slot(run["run_id"], slot, "diff_failed", exc, usage=exc) continue candidate_source = await persist_mutation(run["run_id"], slot, mutation) if candidate_source is not None: diff --git a/services/evolver/src/inalpha_evolver/runtime/manager.py b/services/evolver/src/inalpha_evolver/runtime/manager.py index 445a0225..d025cfbb 100644 --- a/services/evolver/src/inalpha_evolver/runtime/manager.py +++ b/services/evolver/src/inalpha_evolver/runtime/manager.py @@ -15,7 +15,7 @@ class EvolutionRunManager: - def __init__(self, *, mutator: Any, settings: EvolverSettings) -> None: + def __init__(self, *, mutator: Any | None, settings: EvolverSettings) -> None: self.mutator = mutator self.settings = settings self.tasks: dict[UUID, asyncio.Task[None]] = {} @@ -56,7 +56,9 @@ async def close(self) -> None: task.cancel() pending = tasks + ([self.dispatcher] if self.dispatcher else []) await asyncio.gather(*pending, return_exceptions=True) - close = getattr(getattr(self.mutator, "llm_client", None), "close", None) + close = getattr(self.mutator, "close", None) + if close is None: + close = getattr(getattr(self.mutator, "llm_client", None), "close", None) if close is not None: await close() diff --git a/services/evolver/src/inalpha_evolver/runtime/slots.py b/services/evolver/src/inalpha_evolver/runtime/slots.py index ee146571..5c6fa7ae 100644 --- a/services/evolver/src/inalpha_evolver/runtime/slots.py +++ b/services/evolver/src/inalpha_evolver/runtime/slots.py @@ -21,12 +21,12 @@ async def persist_mutation( ) -> str | None: """校验变异;失败落终态,成功落源码并返回。""" if mutation.unified_diff is None: - await reject_slot(run_id, slot, "no_change", None) + await reject_slot(run_id, slot, "no_change", None, usage=mutation) return None try: audited_source = audit_strategy_source(mutation.new_source) except ValidationError as exc: - await reject_slot(run_id, slot, "ast_rejected", exc) + await reject_slot(run_id, slot, "ast_rejected", exc, usage=mutation) return None async with get_conn() as conn: if await candidates.source_exists(conn, run_id, mutation.source_hash): @@ -39,6 +39,8 @@ async def persist_mutation( unified_diff=mutation.unified_diff, llm_cost_usd=mutation.llm_cost_usd, cache_hit_tokens=mutation.cache_hit_tokens, + input_tokens=mutation.input_tokens, + output_tokens=mutation.output_tokens, ) return None await candidates.update_slot( @@ -51,6 +53,8 @@ async def persist_mutation( unified_diff=mutation.unified_diff, llm_cost_usd=mutation.llm_cost_usd, cache_hit_tokens=mutation.cache_hit_tokens, + input_tokens=mutation.input_tokens, + output_tokens=mutation.output_tokens, audit_snapshot={"ok": True, "mode": "static_ast"}, contract_snapshot={"ok": False, "status": "pending_worker"}, ) @@ -97,14 +101,26 @@ async def reject_slot( slot: int, outcome: str, error: BaseException | None, + *, + usage: Any | None = None, ) -> None: + values: dict[str, Any] = { + "stage": "completed", + "outcome": outcome, + "error_code": getattr(error, "code", None), + "error_message": str(error)[:1000] if error else None, + } + if usage is not None: + values.update( + llm_cost_usd=usage.llm_cost_usd, + cache_hit_tokens=usage.cache_hit_tokens, + input_tokens=usage.input_tokens, + output_tokens=usage.output_tokens, + ) async with get_conn() as conn: await candidates.update_slot( conn, run_id, slot, - stage="completed", - outcome=outcome, - error_code=getattr(error, "code", None), - error_message=str(error)[:1000] if error else None, + **values, ) diff --git a/services/evolver/src/inalpha_evolver/storage/runs.py b/services/evolver/src/inalpha_evolver/storage/runs.py index 7eef8a9e..169dc1a2 100644 --- a/services/evolver/src/inalpha_evolver/storage/runs.py +++ b/services/evolver/src/inalpha_evolver/storage/runs.py @@ -7,10 +7,11 @@ from psycopg import AsyncConnection -_COLUMNS = """run_id,owner_account_id,requested_by_sub,seed_strategy_id,budget,config,status, -llm_cost_usd,queued_at,started_at,updated_at,finished_at,venue,symbol,request_timeframe, -data_timeframe,engine_timeframe,requested_as_of,seed_source_snapshot,seed_source_hash, -seed_report_snapshot,baseline_snapshot,dataset_manifest,active_stage,failure_code,failure_message""" +_COLUMNS = """run_id,owner_account_id,requested_by_sub,seed_strategy_id,budget,config, +llm_snapshot,llm_config_digest,status,llm_cost_usd,queued_at,started_at,updated_at,finished_at, +venue,symbol,request_timeframe,data_timeframe,engine_timeframe,requested_as_of, +seed_source_snapshot,seed_source_hash,seed_report_snapshot,baseline_snapshot,dataset_manifest, +active_stage,failure_code,failure_message""" async def insert_run( @@ -25,16 +26,17 @@ async def insert_run( seed_hash: str, budget: int, config: dict[str, Any], + llm_snapshot: dict[str, Any], queued_at: datetime, ) -> tuple[dict[str, Any], bool]: run_id = uuid4() async with conn.cursor() as cur: await cur.execute( f"""INSERT INTO strategy_evo_runs(run_id,owner_account_id,requested_by_sub, -seed_strategy_id,budget,config,status,idempotency_key,request_hash,queued_at,started_at, -venue,symbol,request_timeframe,data_timeframe,engine_timeframe,requested_as_of, -seed_source_snapshot,seed_source_hash) VALUES -(%s,%s,%s,%s,%s,%s,'queued',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) +seed_strategy_id,budget,config,llm_snapshot,llm_config_digest,status,idempotency_key, +request_hash,queued_at,venue,symbol,request_timeframe,data_timeframe,engine_timeframe, +requested_as_of,seed_source_snapshot,seed_source_hash) VALUES +(%s,%s,%s,%s,%s,%s,%s,%s,'queued',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(owner_account_id,idempotency_key) DO NOTHING RETURNING {_COLUMNS},request_hash""", ( run_id, @@ -43,10 +45,11 @@ async def insert_run( seed_strategy_id, budget, json.dumps(config, default=str), + json.dumps(llm_snapshot), + llm_snapshot["config_digest"], idempotency_key, request_hash, queued_at, - queued_at, config["venue"], config["symbol"], config["timeframe"], @@ -97,7 +100,9 @@ async def transition( updates: dict[str, Any] = {"status": to_status, "updated_at": datetime.now().astimezone()} updates.update(values or {}) assignments = ",".join(f"{key}=%s" for key in updates) - params: list[Any] = [json.dumps(value) if isinstance(value, dict) else value for value in updates.values()] + params: list[Any] = [ + json.dumps(value) if isinstance(value, dict) else value for value in updates.values() + ] params.extend([run_id, list(from_statuses)]) async with conn.cursor() as cur: await cur.execute( diff --git a/services/evolver/tests/llm_snapshot_fixtures.py b/services/evolver/tests/llm_snapshot_fixtures.py new file mode 100644 index 00000000..6b46c5b4 --- /dev/null +++ b/services/evolver/tests/llm_snapshot_fixtures.py @@ -0,0 +1,53 @@ +"""共享的冻结 LLM 快照与审批断言测试夹具。""" + +from __future__ import annotations + +import copy +import time + +import jwt + +VALID_LLM_SNAPSHOT = { + "config_id": "config-1", + "provider": "deepseek", + "model": "deepseek-v4-pro", + "base_url": "https://api.deepseek.com", + "pricing": { + "version": "provider-estimate-2026-08", + "currency": "USD", + "input_usd_per_million": 0.56, + "output_usd_per_million": 1.68, + "assumed_input_tokens": 24_000, + "max_output_tokens": 8_192, + "estimated_max_usd_per_candidate": 0.02720256, + }, + "config_digest": "a4635b0c80f69b6054bdc2330b78cb98d9c81c849d476e7d01f1b8d626015c2c", +} + + +def llm_snapshot() -> dict: + """返回可安全修改的有效快照副本。""" + return copy.deepcopy(VALID_LLM_SNAPSHOT) + + +def approval_token( + *, + subject: str, + operation_id: str, + secret: str, + digest: str = VALID_LLM_SNAPSHOT["config_digest"], +) -> str: + """签发与 orchestration 相同 scope 的短效审批断言。""" + now = int(time.time()) + return jwt.encode( + { + "sub": subject, + "token_use": "evolution_approval", + "operation_id": operation_id, + "llm_config_digest": digest, + "iat": now, + "exp": now + 300, + }, + secret, + algorithm="HS256", + ) diff --git a/services/evolver/tests/test_api_contract.py b/services/evolver/tests/test_api_contract.py index c876bc7c..b349e9b6 100644 --- a/services/evolver/tests/test_api_contract.py +++ b/services/evolver/tests/test_api_contract.py @@ -1,4 +1,6 @@ """Evolver API schema 与 presenter 单测。""" + +import copy from datetime import UTC, datetime, timedelta, timezone from uuid import uuid4 @@ -10,10 +12,13 @@ from inalpha_evolver.api.request_hash import normalized_request from inalpha_evolver.api.schemas import ( EvolutionConfig, + EvolutionLLMSnapshot, RunStatusResponse, StartRunRequest, ) +from .llm_snapshot_fixtures import VALID_LLM_SNAPSHOT, llm_snapshot + def _request(symbol: str = "BTCUSDT") -> StartRunRequest: now = datetime(2026, 8, 12, 12, tzinfo=UTC) @@ -24,7 +29,8 @@ def _request(symbol: str = "BTCUSDT") -> StartRunRequest: timeframe="1h", from_ts=now - timedelta(days=30), as_of=now, - ) + ), + llm=EvolutionLLMSnapshot.model_validate(llm_snapshot()), ) @@ -38,6 +44,40 @@ def test_request_hash_is_stable_and_payload_sensitive() -> None: assert hash_a != hash_c +@pytest.mark.parametrize( + ("path", "value"), + [ + (("model",), "tampered-model"), + (("base_url",), "https://evil.example/v1"), + (("pricing", "input_usd_per_million"), 0.01), + ], +) +def test_llm_snapshot_digest_rejects_tampering( + path: tuple[str, ...], + value: str | float, +) -> None: + payload = copy.deepcopy(VALID_LLM_SNAPSHOT) + target = payload + for key in path[:-1]: + target = target[key] + target[path[-1]] = value + + with pytest.raises(ValueError, match="config_digest"): + EvolutionLLMSnapshot.model_validate(payload) + + +def test_llm_snapshot_digest_matches_typescript_contract() -> None: + snapshot = EvolutionLLMSnapshot.model_validate(llm_snapshot()) + assert snapshot.config_digest == VALID_LLM_SNAPSHOT["config_digest"] + + +def test_non_openai_compatible_provider_is_rejected() -> None: + payload = llm_snapshot() + payload["provider"] = "anthropic" + with pytest.raises(ValueError): + EvolutionLLMSnapshot.model_validate(payload) + + def test_invalid_window_is_rejected() -> None: now = datetime(2026, 8, 12, 12, tzinfo=UTC) with pytest.raises(ValueError): @@ -71,7 +111,9 @@ def test_run_presenter_converts_numeric_cost() -> None: def test_datetime_inputs_normalize_or_fail_without_type_error() -> None: now = datetime.now(UTC) - timedelta(minutes=1) config = EvolutionConfig( - venue="binance", symbol="BTCUSDT", timeframe="1h", + venue="binance", + symbol="BTCUSDT", + timeframe="1h", from_ts=(now - timedelta(days=1)).replace(tzinfo=None), as_of=now.astimezone(timezone(timedelta(hours=9))), ) @@ -79,8 +121,12 @@ def test_datetime_inputs_normalize_or_fail_without_type_error() -> None: assert config.as_of.tzinfo == UTC with pytest.raises(ValueError, match="timezone-aware"): EvolutionConfig( - venue="binance", symbol="BTCUSDT", timeframe="1h", - from_ts=now - timedelta(days=1), as_of=now.replace(tzinfo=None)) + venue="binance", + symbol="BTCUSDT", + timeframe="1h", + from_ts=now - timedelta(days=1), + as_of=now.replace(tzinfo=None), + ) def test_future_as_of_returns_http_422() -> None: @@ -93,10 +139,15 @@ async def validate(body: StartRunRequest) -> dict[str, bool]: future = datetime.now(UTC) + timedelta(minutes=1) response = TestClient(app).post( "/validate", - json={"config": {"venue": "binance", "symbol": "BTCUSDT", - "timeframe": "1h", - "from_ts": (future - timedelta(days=1)).isoformat(), - "as_of": future.isoformat()}}, + json={ + "config": { + "venue": "binance", + "symbol": "BTCUSDT", + "timeframe": "1h", + "from_ts": (future - timedelta(days=1)).isoformat(), + "as_of": future.isoformat(), + } + }, ) assert response.status_code == 422 assert "trusted current time" in response.text @@ -104,16 +155,27 @@ async def validate(body: StartRunRequest) -> dict[str, bool]: def test_candidate_response_exposes_data_epoch() -> None: candidate_id, run_id = uuid4(), uuid4() - response = candidate_response({ - "candidate_id": candidate_id, "run_id": run_id, "slot": 1, - "generation": 1, "stage": "evaluation", "outcome": "succeeded", - "data_epoch": 1_786_000_000_000, - }) + response = candidate_response( + { + "candidate_id": candidate_id, + "run_id": run_id, + "slot": 1, + "generation": 1, + "stage": "evaluation", + "outcome": "succeeded", + "data_epoch": 1_786_000_000_000, + } + ) assert response.data_epoch == 1_786_000_000_000 def test_run_dto_exposes_manifest_cutoff_and_lag() -> None: manifest = RunStatusResponse.model_json_schema()["$defs"]["DatasetManifest"] required = set(manifest["required"]) - assert {"latest_bar_ts", "cutoff_bar_ts", "freshness_lag_seconds", - "data_epoch", "backfill"} <= required + assert { + "latest_bar_ts", + "cutoff_bar_ts", + "freshness_lag_seconds", + "data_epoch", + "backfill", + } <= required diff --git a/services/evolver/tests/test_approval.py b/services/evolver/tests/test_approval.py new file mode 100644 index 00000000..92963645 --- /dev/null +++ b/services/evolver/tests/test_approval.py @@ -0,0 +1,69 @@ +"""短效演化审批断言测试。""" + +from __future__ import annotations + +import time +from types import SimpleNamespace + +import jwt +import pytest +from fastapi import HTTPException + +from inalpha_evolver.api.approval import verify_evolution_approval + +_SECRET = "approval-unit-test-secret-at-least-32-bytes" +_DIGEST = "a" * 64 + + +def _token(ttl_seconds: int) -> str: + now = int(time.time()) + return jwt.encode( + { + "sub": "user:alice", + "token_use": "evolution_approval", + "operation_id": "approval-operation-1", + "llm_config_digest": _DIGEST, + "iat": now, + "exp": now + ttl_seconds, + }, + _SECRET, + algorithm="HS256", + ) + + +def _verify(token: str) -> None: + verify_evolution_approval( + token, + owner_sub="user:alice", + operation_id="approval-operation-1", + llm_config_digest=_DIGEST, + settings=SimpleNamespace(jwt_secret=_SECRET, jwt_algorithm="HS256"), # type: ignore[arg-type] + ) + + +def test_approval_accepts_only_short_lived_matching_scope() -> None: + _verify(_token(300)) + + with pytest.raises(HTTPException) as error: + _verify(_token(301)) + assert error.value.status_code == 403 + + +def test_approval_rejects_another_owner() -> None: + now = int(time.time()) + token = jwt.encode( + { + "sub": "user:bob", + "token_use": "evolution_approval", + "operation_id": "approval-operation-1", + "llm_config_digest": _DIGEST, + "iat": now, + "exp": now + 300, + }, + _SECRET, + algorithm="HS256", + ) + + with pytest.raises(HTTPException) as error: + _verify(token) + assert error.value.status_code == 403 diff --git a/services/evolver/tests/test_e2e.py b/services/evolver/tests/test_e2e.py index e0597508..88a82fe1 100644 --- a/services/evolver/tests/test_e2e.py +++ b/services/evolver/tests/test_e2e.py @@ -1,4 +1,5 @@ """Evolver API DB/auth 契约测试。""" + from __future__ import annotations import os @@ -13,18 +14,27 @@ from inalpha_evolver.config import get_evolver_settings from inalpha_evolver.main import app +from .llm_snapshot_fixtures import approval_token, llm_snapshot + _SECRET = "evolver-test-secret-at-least-32-bytes-long" -def _headers(key: str | None = None) -> dict[str, str]: +def _headers(key: str | None = None, *, include_approval: bool = True) -> dict[str, str]: + subject = f"test:{uuid4()}" token = jwt.encode( - {"sub": f"test:{uuid4()}", "exp": int(time.time()) + 3600}, + {"sub": subject, "exp": int(time.time()) + 3600}, _SECRET, algorithm="HS256", ) headers = {"Authorization": f"Bearer {token}"} if key: headers["Idempotency-Key"] = key + if include_approval: + headers["X-Evolution-Approval"] = approval_token( + subject=subject, + operation_id=key, + secret=_SECRET, + ) return headers @@ -54,6 +64,7 @@ def _payload() -> dict: "as_of": now.isoformat(), "initial_cash": 10_000, }, + "llm": llm_snapshot(), } @@ -65,6 +76,31 @@ def test_start_requires_idempotency_key(client: TestClient) -> None: assert client.post("/api/v1/runs", json=_payload(), headers=_headers()).status_code == 400 +def test_start_requires_explicit_approval_assertion(client: TestClient) -> None: + headers = _headers(f"api-test-{uuid4()}", include_approval=False) + assert client.post("/api/v1/runs", json=_payload(), headers=headers).status_code == 400 + + +def test_start_rejects_approval_for_another_operation(client: TestClient) -> None: + key = f"api-test-{uuid4()}" + headers = _headers(key) + token = headers["X-Evolution-Approval"] + payload = jwt.decode(token, _SECRET, algorithms=["HS256"]) + headers["X-Evolution-Approval"] = approval_token( + subject=payload["sub"], + operation_id=f"other-{uuid4()}", + secret=_SECRET, + ) + assert client.post("/api/v1/runs", json=_payload(), headers=headers).status_code == 403 + + +def test_start_rejects_tampered_snapshot_before_approval(client: TestClient) -> None: + key = f"api-test-{uuid4()}" + payload = _payload() + payload["llm"]["model"] = "tampered-model" + assert client.post("/api/v1/runs", json=payload, headers=_headers(key)).status_code == 400 + + def test_start_returns_queued_and_is_idempotent(client: TestClient) -> None: key = f"api-test-{uuid4()}" headers = _headers(key) diff --git a/services/evolver/tests/test_mutator_pricing.py b/services/evolver/tests/test_mutator_pricing.py new file mode 100644 index 00000000..16b869f3 --- /dev/null +++ b/services/evolver/tests/test_mutator_pricing.py @@ -0,0 +1,149 @@ +"""冻结定价与 token 统计测试。""" + +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from inalpha_shared_llm.types import CacheMetrics, MutationResponse + +from inalpha_evolver.exceptions import DiffApplyError +from inalpha_evolver.mutator import Mutator +from inalpha_evolver.runtime.slots import persist_mutation + +_SOURCE = """class Strategy:\n value = 1\n""" +_DIFF = """--- a/strategy.py ++++ b/strategy.py +@@ -1,2 +1,2 @@ + class Strategy: +- value = 1 ++ value = 2 +""" + + +class _PricedClient: + def __init__(self) -> None: + self.max_tokens = 0 + + async def mutate(self, request): + self.max_tokens = request.max_tokens + return MutationResponse( + content=_DIFF, + cache_metrics=CacheMetrics(input_tokens=1_000, output_tokens=200), + ) + + async def close(self) -> None: + return None + + +class _InvalidDiffClient(_PricedClient): + async def mutate(self, request): + self.max_tokens = request.max_tokens + return MutationResponse( + content="""--- a/strategy.py ++++ b/strategy.py +@@ -99,1 +99,1 @@ +-missing = 1 ++missing = 2 +""", + cache_metrics=CacheMetrics(input_tokens=1_000, output_tokens=200), + ) + + +@pytest.mark.asyncio +async def test_mutator_uses_frozen_rates_and_returns_usage() -> None: + client = _PricedClient() + mutator = Mutator( + llm_client=client, # type: ignore[arg-type] + input_usd_per_million=2.0, + output_usd_per_million=10.0, + max_output_tokens=4_096, + ) + + result = await mutator.mutate(_SOURCE) + + assert result.input_tokens == 1_000 + assert result.output_tokens == 200 + assert result.llm_cost_usd == pytest.approx(0.004) + assert client.max_tokens == 4_096 + + +@pytest.mark.asyncio +async def test_diff_failure_keeps_frozen_cost_and_usage() -> None: + mutator = Mutator( + llm_client=_InvalidDiffClient(), # type: ignore[arg-type] + input_usd_per_million=2.0, + output_usd_per_million=10.0, + ) + + with pytest.raises(DiffApplyError) as error: + await mutator.mutate(_SOURCE) + + assert error.value.llm_cost_usd == pytest.approx(0.004) + assert error.value.input_tokens == 1_000 + assert error.value.output_tokens == 200 + + +class _ConnectionContext: + async def __aenter__(self) -> object: + return object() + + async def __aexit__(self, *_args: object) -> None: + return None + + +@pytest.mark.asyncio +async def test_persist_mutation_writes_input_and_output_tokens( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + mutation = await Mutator( + llm_client=_PricedClient(), # type: ignore[arg-type] + input_usd_per_million=2.0, + output_usd_per_million=10.0, + ).mutate(_SOURCE) + + async def source_exists(*_args: object) -> bool: + return False + + async def update_slot(*_args: object, **values: object) -> dict[str, object]: + captured.update(values) + return values + + monkeypatch.setattr("inalpha_evolver.runtime.slots.get_conn", _ConnectionContext) + monkeypatch.setattr( + "inalpha_evolver.runtime.slots.audit_strategy_source", lambda source: source + ) + monkeypatch.setattr("inalpha_evolver.runtime.slots.candidates.source_exists", source_exists) + monkeypatch.setattr("inalpha_evolver.runtime.slots.candidates.update_slot", update_slot) + + await persist_mutation(uuid4(), 0, mutation) + + assert captured["input_tokens"] == 1_000 + assert captured["output_tokens"] == 200 + + +@pytest.mark.asyncio +async def test_no_change_outcome_still_persists_usage( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + mutation = await Mutator( + llm_client=_PricedClient(), # type: ignore[arg-type] + input_usd_per_million=2.0, + output_usd_per_million=10.0, + ).mutate(_SOURCE) + mutation.unified_diff = None + + async def update_slot(*_args: object, **values: object) -> dict[str, object]: + captured.update(values) + return values + + monkeypatch.setattr("inalpha_evolver.runtime.slots.get_conn", _ConnectionContext) + monkeypatch.setattr("inalpha_evolver.runtime.slots.candidates.update_slot", update_slot) + + assert await persist_mutation(uuid4(), 0, mutation) is None + assert captured["outcome"] == "no_change" + assert captured["llm_cost_usd"] == pytest.approx(0.004) + assert captured["input_tokens"] == 1_000 + assert captured["output_tokens"] == 200 diff --git a/services/evolver/tests/test_owner_llm.py b/services/evolver/tests/test_owner_llm.py new file mode 100644 index 00000000..6f7e8c98 --- /dev/null +++ b/services/evolver/tests/test_owner_llm.py @@ -0,0 +1,110 @@ +"""Owner-scoped LLM credential 与 per-run 生命周期测试。""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import ClassVar + +import jwt +import pytest + +from inalpha_evolver.owner_llm import build_owner_mutator +from inalpha_evolver.runtime.executor import _run_mutator + +from .llm_snapshot_fixtures import llm_snapshot + + +class _Response: + status_code = 200 + + @staticmethod + def json() -> dict[str, str]: + return { + "config_id": "config-1", + "provider": "deepseek", + "api_key": "owner-test-key", + } + + +class _CredentialClient: + kwargs: ClassVar[dict[str, object]] = {} + requested_url: ClassVar[str] = "" + requested_headers: ClassVar[dict[str, str]] = {} + + def __init__(self, **kwargs: object) -> None: + type(self).kwargs = kwargs + + async def __aenter__(self): + return self + + async def __aexit__(self, *_args: object) -> None: + return None + + async def get(self, url: str, **kwargs: object) -> _Response: + type(self).requested_url = url + type(self).requested_headers = kwargs["headers"] # type: ignore[assignment] + return _Response() + + +@pytest.mark.asyncio +async def test_owner_mutator_uses_frozen_snapshot_and_credential_reference( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("inalpha_evolver.owner_llm.httpx.AsyncClient", _CredentialClient) + run = { + "requested_by_sub": "user:alice", + "llm_snapshot": llm_snapshot(), + } + settings = SimpleNamespace( + dashboard_service_url="http://dashboard:3001", + service_token_ttl_s=3600, + jwt_secret="test-secret-at-least-32-bytes-long", + jwt_algorithm="HS256", + evolver_llm_timeout_s=45, + ) + + mutator = await build_owner_mutator(run, settings) # type: ignore[arg-type] + + assert _CredentialClient.kwargs["trust_env"] is False + assert _CredentialClient.requested_url.endswith("/api/internal/llm-config/config-1") + credential_token = _CredentialClient.requested_headers["Authorization"].removeprefix("Bearer ") + credential_scope = jwt.decode( + credential_token, + settings.jwt_secret, + algorithms=[settings.jwt_algorithm], + ) + assert credential_scope["sub"] == "user:alice" + assert credential_scope["token_use"] == "evolver_credential" + assert credential_scope["config_id"] == "config-1" + assert mutator.llm_client.settings.effective_api_key == "owner-test-key" + assert mutator.llm_client.settings.llm_model == "deepseek-v4-pro" + assert mutator.max_output_tokens == 8_192 + assert "api_key" not in run["llm_snapshot"] + + +class _ClosableMutator: + def __init__(self) -> None: + self.closed = False + + async def close(self) -> None: + self.closed = True + + +@pytest.mark.asyncio +async def test_production_mutator_is_closed_but_injected_test_mutator_is_not( + monkeypatch: pytest.MonkeyPatch, +) -> None: + owner = _ClosableMutator() + + async def build(*_args: object) -> _ClosableMutator: + return owner + + monkeypatch.setattr("inalpha_evolver.runtime.executor.build_owner_mutator", build) + async with _run_mutator({}, None, SimpleNamespace()): # type: ignore[arg-type] + pass + assert owner.closed is True + + injected = _ClosableMutator() + async with _run_mutator({}, injected, SimpleNamespace()): # type: ignore[arg-type] + pass + assert injected.closed is False diff --git a/services/evolver/tests/test_storage_integration.py b/services/evolver/tests/test_storage_integration.py index fc75e46a..74cea9be 100644 --- a/services/evolver/tests/test_storage_integration.py +++ b/services/evolver/tests/test_storage_integration.py @@ -1,4 +1,5 @@ """Evolver DB storage 集成测试。""" + from __future__ import annotations import hashlib @@ -12,6 +13,8 @@ from inalpha_evolver.governor.seed import SEED_STRATEGY_CODE from inalpha_evolver.storage import candidates, run_queries, runs +from .llm_snapshot_fixtures import llm_snapshot + @pytest.mark.asyncio async def test_run_idempotency_owner_scope_and_slot() -> None: @@ -42,6 +45,7 @@ async def test_run_idempotency_owner_scope_and_slot() -> None: "seed_hash": hashlib.sha256(SEED_STRATEGY_CODE.encode()).hexdigest(), "budget": 2, "config": config, + "llm_snapshot": llm_snapshot(), "queued_at": now, } async with get_conn() as conn: From 58641a68c0b27ebf4cbb44ae18dc40be582eea94 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 27 Aug 2026 16:45:14 +0800 Subject: [PATCH 2/5] =?UTF-8?q?docs(project):=20=E5=90=8C=E6=AD=A5=20D-12?= =?UTF-8?q?=20=E4=B8=8E=20E1=20=E5=BD=93=E5=89=8D=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 +- AGENTS.md | 42 +++-- CLAUDE.md | 17 +- CONTRIBUTING.md | 32 ++-- README.md | 65 +++++--- README.zh-CN.md | 64 +++++--- apps/dashboard/.env.local.example | 7 +- apps/dashboard/README.md | 91 ++++++----- apps/dashboard/design.md | 3 +- apps/dashboard/messages/en.json | 2 +- apps/dashboard/messages/zh.json | 2 +- apps/dashboard/package.json | 2 +- docs/00-context.md | 16 +- docs/01-architecture-overview.md | 67 ++++---- docs/03-kernel-design.md | 50 +++--- docs/04-current-state.md | 62 ++++++-- docs/designs/e1-production-closure.md | 19 ++- infra/.env.example | 9 +- infra/.env.selfhost.example | 11 +- infra/README.md | 68 ++++---- packages/orchestration/README.md | 101 ++++++------ scripts/dev.sh | 6 +- services/data/README.md | 81 ++++------ services/evolver/README.md | 147 ++++++++++-------- .../evolver/src/inalpha_evolver/__init__.py | 4 +- services/factor/README.md | 28 ++++ services/paper/README.md | 69 ++++---- services/research/README.md | 69 ++++---- 28 files changed, 645 insertions(+), 493 deletions(-) create mode 100644 services/factor/README.md diff --git a/.env.example b/.env.example index a5d62421..8f027942 100644 --- a/.env.example +++ b/.env.example @@ -17,7 +17,7 @@ # ───── 1. 共享基础(所有 Python service 都读) ────────────── -# Postgres 连接串;services/data 与 services/paper 使用 +# Postgres 连接串;本地默认值与 infra/.env.example 一致。修改连接信息时须同步两处。 DATABASE_URL=postgresql+psycopg://quant:devpass@localhost:5433/inalpha # JWT 跨 service 共享密钥(≥32 字节强随机) @@ -46,9 +46,11 @@ EVOLVER_SERVICE_PORT=8005 EVOLVER_ENABLED=true # Evolver E1 单 worker / 单副本;每个 run 只跑一代候选。 +EVOLVER_POOL_SIZE=5 EVOLVER_MAX_RUNNING_RUNS=1 EVOLVER_ACCOUNT_ACTIVE_LIMIT=2 EVOLVER_JOB_TIMEOUT_S=300 +EVOLVER_RUN_TIMEOUT_S=1200 EVOLVER_JOB_MEM_GB=2 EVOLVER_LLM_TIMEOUT_S=120 # Evolver 仅用该地址按 owner/config_id 解析既有加密凭据;不会持久化明文 key。 diff --git a/AGENTS.md b/AGENTS.md index e0ac6e69..5f24f706 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,8 @@ Inalpha = AI agent 编排 + 多 Python kernel 的**量化实验框架**:agent ## 3. 协作硬约束(任何 AI 工具必须遵守) - **品牌名**:始终大写 **Inalpha**(不写 inalpha / InAlpha / inAlpha) (元用法) -- **市场约束**:仅 crypto,**不**涉及 A 股 / 美股盘前盘后逻辑 +- **市场覆盖**:crypto + 美股 + A股 + 港股 + 全球单股 / 指数 + FRED 宏观; + orchestration 按市场类型路由 venue,交易时段由市场日历处理 - **命名约定**: - Python 包:`inalpha_`(snake_case) (占位符不匹配白名单) - tools:`.` 或 `mcp____` @@ -39,32 +40,38 @@ Inalpha = AI agent 编排 + 多 Python kernel 的**量化实验框架**:agent ## 4. 起步(clone 之后) ```bash -pnpm i # Node 包(packages/orchestration) -uv sync # Python 包(services/*) +cd packages/orchestration && pnpm i && cd ../.. +for service in data paper research factor evolver; do + (cd "services/$service" && uv sync) +done # 配置统一 .env(所有 service 共享根目录一份 .env) cp .env.example .env # 在 .env 里填 LLM_PROVIDER + 对应 *_API_KEY # 详见 README.md §Quick Start 的 provider/model 表 -# DB schema 升到最新(dev.sh 不会自动跑;漏跑会导致 paper 服务 500:表不存在) -cd infra/migrations && uv run alembic upgrade head && cd ../.. +# 启动开发 DB 并把 schema 升到最新(dev.sh 不会自动做这两步) +cp infra/.env.example infra/.env # 与根 .env.example 的 DB 默认值一致 +(cd infra && docker compose up -d) +(cd infra/migrations && uv sync && uv run alembic upgrade head) # 一键起所有 service(推荐) -bash scripts/dev.sh # data:8001 + paper:8002 + research:8003 + mastra:4111 +bash scripts/dev.sh # data:8001 + paper:8002 + research:8003 + factor:8004 + evolver:8005 + mastra:4111 bash scripts/dev.sh logs # 跟随日志 bash scripts/dev.sh stop # 停止全部 -# 手动起(如果想要 4 个独立 terminal) +# 手动起(如果想让各进程占用独立 terminal) cd services/data && uv run uvicorn inalpha_data.main:app --port 8001 --reload cd services/paper && uv run uvicorn inalpha_paper.main:app --port 8002 --reload cd services/research && uv run uvicorn inalpha_research.main:app --port 8003 --reload +cd services/factor && uv run uvicorn inalpha_factor.main:app --port 8004 --reload +cd services/evolver && uv run uvicorn inalpha_evolver.main:app --port 8005 --reload cd packages/orchestration && pnpm dev -# 操作者控制台(apps/dashboard)—— 推荐的功能主入口,只读运行时看板 -# 组合 / Live Runner / Agent 活动 / 策略实验室 / 因子库 / 风控;黑白双主题 + en/中 +# 操作者控制台(apps/dashboard)—— 推荐的功能主入口(认证 + BFF + 运行时看板) +# 对话 / 组合 / Live Runner / 演化 / Agent 活动 / 策略实验室 / 因子库 / 风控 # 直接读根 .env(service URL + JWT_SECRET 继承),后端起着即可连 cd apps/dashboard && pnpm i && pnpm dev # → http://localhost:3001 -# 设计语言见 apps/dashboard/design.md;agent 对话功能后续也会并入控制台 +# 设计语言见 apps/dashboard/design.md # 跨文件一致性检验(提交前跑一次) bash scripts/check-consistency.sh @@ -95,13 +102,17 @@ pnpm scheduler:trigger daily_btc_deep_dive # 手动触发一次 ## 6. 当前 Phase 状态 -Phase **D-11**(多市场模拟盘)已落地:单 orchestrator + plan/exec 三件套 +Phase **D-12 + E1 生产闭环**已落地:单 orchestrator + plan/exec 三件套 (create_plan / approve_plan / execute_plan)+ hooks + permissions deny + approval_token 状态机(D-8/D-9)→ LLM 自创策略沙盒 + 风控引擎 + 多市场数据 (D-9/D-10)→ 跨币种 cash + **live runner**(promoted 候选按行情自动跑,机器审批 走护栏内 plan/exec)。D-11.1 收口了 live runner 的信任边界与健壮性 -(candidate 归属校验 / per-account run 上限 / 错误可重试分类);D-11.2 收口运维(PnL 净口径扣手续费 / 运行时长 TTL auto-stop / build 退避 + 错误分类)。factor 库(services/factor:8004,pandas-ta/Alpha101/qlib + IC 有效性)已落地、策略族扩到 6。 -下一里程碑:research-hub 嵌套 supervisor(issue #6)/ E2 多代演化(issue #7)。 +(candidate 归属校验 / per-account run 上限 / 错误可重试分类);D-11.2 收口运维; +D-12 完成 factor 血缘、衰减巡检与因子发现。research-hub 三方辩论已收口。 +E1 已拆出 `services/evolver:8005`:真实 frozen bars、单代 unified-diff 变异、异步 +owner-scoped 状态、显式逐次审批与可复现实验元数据;演化链路不会自动 promote、启动策略或下单。 +当前收口项是冻结 LLM/定价快照、owner key 即时获取与 token/cost 审计;下一里程碑为 +E2 best-parent 多代选择与 early stopping(issue #7),MAP-Elites / Island Model 后置。 详见 [`docs/04-current-state.md`](docs/04-current-state.md) / `CLAUDE.md` §3 / 仓库根 `README.md`。 @@ -112,6 +123,8 @@ approval_token 状态机(D-8/D-9)→ LLM 自创策略沙盒 + 风控引擎 + | 想做的事 | 去哪里 | |---|---| | 加新策略 | `services/paper/src/inalpha_paper/strategies/` | +| 调整策略演化 | `services/evolver/src/inalpha_evolver/` | +| 调整因子库 | `services/factor/src/inalpha_factor/` | | 加新 tool | `packages/orchestration/src/tools/` | | 调整内核 | `services/_shared/` 之外的 services 模块 | | 不确定 | 先开 issue 讨论,再动 | @@ -123,7 +136,8 @@ approval_token 状态机(D-8/D-9)→ LLM 自创策略沙盒 + 风控引擎 + - ❌ 不在 `services/_shared/` 加项目特有逻辑(破坏复用) - ❌ 不写跳过测试 / 跳过 hook 的 commit(`--no-verify` 等)——遇阻先 ask user - ❌ 不在不公开源码的前提下把 Inalpha(或其修改版)当作网络服务对外提供(LICENSE: AGPL-3.0;需闭源 / 托管 SaaS 请提 issue 谈双重许可) -- ❌ 多租户上线前不给每用户发各自 JWT:promote 审批的 askCache 已按**已认证 sub** 隔离(mastra identity middleware 从 Bearer 注入 → `with-hooks` 读),但 dashboard 现给所有人发同一个 `CONSOLE_SUBJECT` 常量 token;要真隔离须让 dashboard 按登录用户发各自 JWT,否则所有人共享一个 sub scope(A 的审批被 B 复用)。详 #91 / `mastra/index.ts` identityMiddleware +- ❌ 不绕过逐用户 JWT、owner scope 或 `LLM_CONFIG_ENCRYPTION_KEY`:Evolver 只能用短时、 + 用途限定且绑定 `config_id` 的 service JWT 即时获取当前 owner 的模型密钥,严禁把明文 API key 写入运行记录或日志 --- diff --git a/CLAUDE.md b/CLAUDE.md index a33741fa..3196a80f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,11 +9,11 @@ - **不是**开箱即用策略平台 / LangChain / AutoGen 包装 - **三层**:Next.js + CopilotKit → Mastra(TS)→ Python services。详 `docs/01-architecture-overview.md` -## 2. 文档入口 & 当前 Phase(D-12) +## 2. 文档入口 & 当前 Phase(D-12 + E1) - `README.md` / `README.zh-CN.md` 首页;`AGENTS.md` 多工具入口;`docs/00-context.md` 背景 / `01-architecture-overview.md` 架构 / `03-kernel-design.md` services / `04-current-state.md` 进度 - 内部 ADR 在 `docs/miro/`(gitignored,公开文档勿引用) -- D-8~D-9 闭环(Plan/Exec + LLM 自创策略 + 风控);D-10 多市场数据(web/基本面);D-11 多市场模拟盘(跨币种 cash + live runner);D-12 因子库闭环(血缘+衰减巡检+monthly 宏观+因子发现 L1);下一 E2 演化 #7 +- D-8~D-12 已完成 Plan/Exec、策略创作、风控、多市场模拟盘与因子闭环;research-hub 已收口;E1 独立 Evolver 已落地真实 frozen bars、显式审批、owner 隔离与可复现实验元数据。当前收口冻结 LLM/定价快照与费用审计;下一 E2 best-parent 多代演化 #7 ## 3. 协作硬约束 @@ -43,7 +43,7 @@ Inalpha 是**金融 agent**——任何"看起来很新但其实 stale"的输出 ## 4. CI 红线(push 前本地必跑,缺一不可) -- `pnpm typecheck && pnpm vitest run`(orchestration)+ `uv run ruff check .`(data/paper/research)+ `bash scripts/check-consistency.sh` +- `(cd packages/orchestration && pnpm typecheck && pnpm vitest run)` + 各 Python service 目录执行 `uv run ruff check .` + paper/evolver 目录执行 `uv run pytest` + `bash scripts/check-consistency.sh` - **加 import 必同步 `git add`**——`grid-size-cap.ts` / `scheduler/` / `_base.py` 漏 add 反复让 CI 挂;commit 前 `git status` 看 untracked - 公开文档(README / AGENTS / `docs/00-04`)禁引用 `docs/miro/` 私有路径 - 模块顶层 eager 调 `getSettings()` 的入口,测试靠 vitest `setupFiles`(`tests/setup.ts`)注入默认 env @@ -51,13 +51,18 @@ Inalpha 是**金融 agent**——任何"看起来很新但其实 stale"的输出 ## 5. 起步 + Active TODO ```bash -pnpm i && uv sync && bash scripts/dev.sh # data:8001 + paper:8002 + mastra:4111 +cd packages/orchestration && pnpm i && cd ../.. +for s in data paper research factor evolver; do (cd "services/$s" && uv sync); done +cp .env.example .env && cp infra/.env.example infra/.env +(cd infra && docker compose up -d) +(cd infra/migrations && uv sync && uv run alembic upgrade head) +bash scripts/dev.sh # data:8001…evolver:8005 + mastra:4111 ``` D-9/D-9.1a 收口 + D-10 多市场数据(web/基本面)+ D-11 多市场模拟盘 (跨币种 cash + live runner #1)+ D-12 因子库闭环(血缘 + 衰减巡检 + -monthly 宏观 + 因子发现 L1 + 三方研究辩论)已落地。 -下一:E2 演化 #7 +monthly 宏观 + 因子发现 L1 + 三方研究辩论)和 E1 独立 Evolver 已落地。 +当前:冻结 LLM/定价快照、owner key 即时获取、token/cost 审计;下一:E2 best-parent 多代演化 #7 --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c25573be..6920de1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,9 @@ # Contributing to Inalpha / 贡献指南 -> Inalpha is an experimental research framework in **alpha**. Phase D-11 has landed (multi-market paper trading: cross-currency cash + live runner); next up are research-hub (#6) and E2 strategy evolution (#7). +> Inalpha is an experimental research framework in **alpha**. Phase D-12 and the E1 production evolution loop have landed; the next milestone is E2 best-parent multi-generation evolution (#7). > Before writing code, we strongly recommend reading: [`AGENTS.md`](AGENTS.md) · [`docs/00-context.md`](docs/00-context.md) · [`docs/01-architecture-overview.md`](docs/01-architecture-overview.md) · [`docs/04-current-state.md`](docs/04-current-state.md) > -> Inalpha 是实验性研究框架,处于 **alpha** 阶段。Phase D-11 已落地(多市场模拟盘:跨币种 cash + live runner);下一步是 research-hub(#6)与 E2 策略演化(#7)。 +> Inalpha 是实验性研究框架,处于 **alpha** 阶段。Phase D-12 与 E1 策略演化生产闭环已落地;下一里程碑是 E2 best-parent 多代演化(#7)。 > 动手之前,强烈建议先读上述四份文档。 ## 1. Before you start / 开始之前 @@ -30,16 +30,26 @@ ## 3. Local setup / 本地起步 ```bash -pnpm i && uv sync -bash scripts/dev.sh # data:8001 + paper:8002 + mastra:4111 +cd packages/orchestration && pnpm i && cd ../.. +for service in data paper research factor evolver; do + (cd "services/$service" && uv sync) +done +cp .env.example .env && cp infra/.env.example infra/.env +(cd infra && docker compose up -d) +(cd infra/migrations && uv sync && uv run alembic upgrade head) +bash scripts/dev.sh # data:8001 … evolver:8005 + mastra:4111 bash scripts/check-consistency.sh # must pass before committing / 提交前必须 pass ``` CI red lines — run locally before every push / CI 红线,push 前本地必跑(缺一不可): ```bash -pnpm typecheck && pnpm vitest run # packages/orchestration -uv run ruff check . # services/data | paper | research +(cd packages/orchestration && pnpm typecheck && pnpm vitest run) +for service in data paper research factor evolver; do + (cd "services/$service" && uv run ruff check .) +done +(cd services/paper && uv run pytest) +(cd services/evolver && uv run pytest) bash scripts/check-consistency.sh ``` @@ -57,8 +67,8 @@ Use short kebab-case names / 任务分支建议短命名 + kebab-case:`feature **Must go through staging (high-risk)** / **必经 staging(高风险)**: -- New strategy family, or large changes to `services/research/` / `services/paper/` / `packages/orchestration/` - 新策略族,或上述三个核心模块的大改 +- New strategy family, or large changes to `services/research/` / `services/paper/` / `services/evolver/` / `packages/orchestration/` + 新策略族,或上述核心模块的大改 - New connector / venue / broker 新 connector / 新 venue / 新 broker - Large orchestrator schema or agent prompt changes @@ -94,7 +104,7 @@ feature/* → PR → main Every PR automatically triggers / 每个 PR 自动触发: -1. **CI** — 6 required status checks / 6 个必填 status check(详 [`.github/workflows/ci.yml`](.github/workflows/ci.yml)) +1. **CI** — required checks defined by branch protection / 分支保护配置的必填检查(详 [`.github/workflows/ci.yml`](.github/workflows/ci.yml)) 2. **Claude PR Review** — non-blocking auto review / 非阻塞自动审查(详 [`.github/workflows/claude-review.yml`](.github/workflows/claude-review.yml)) 3. **Cloudflare Pages preview deploy** — a unique URL per PR, posted in a comment / 每 PR 独立 URL,评论里贴出 @@ -105,7 +115,7 @@ Mentioning `@claude` in a PR / issue / review comment starts a conversation with `main` and `staging` are configured identically / `main` 与 `staging` 配置一致: -- **6 required CI status checks** / **必填 6 个 CI status check**:`orchestration · typecheck + test` / `web · typecheck + build` / `Cross-file consistency check / 跨文件一致性检验` / `python services · ruff + mypy (data|paper|research)` +- **Required CI checks** / **必填 CI 检查**:跨文件一致性、自托管 smoke、orchestration typecheck/test + agent eval、web/dashboard build、Python service lint/typecheck,以及 paper/evolver E1 pytest。具体 context 以仓库 branch protection 为准。 - PR required, but `required_approving_review_count: 0` — solo project, avoids self-approval deadlock. 必走 PR,但**不**强制 approval(单人项目,避免自批死锁)。 - `allow_force_pushes: false` · `allow_deletions: false` @@ -121,7 +131,7 @@ CI workflow 改 job 名时必须同步更新 protection 的 `contexts`,否则 - **Commit message**: Chinese, `(): `; one logical change per commit — don't mix unrelated modules in one commit. **Commit message**:中文 + `(): `;一次 commit 只做一件事,不要把不相关模块揉进同一个 commit。 - **type**: `feat` / `fix` / `refactor` / `docs` / `test` / `chore` / `style` / `perf` / `ci` -- **scope**: `data` / `paper` / `research` / `orchestration` / `web` / `docs` / `infra`, or a concrete module name / 或具体模块名 +- **scope**: `data` / `paper` / `research` / `factor` / `evolver` / `orchestration` / `dashboard` / `web` / `docs` / `infra`, or a concrete module name / 或具体模块名 - A Phase tag is welcome / 可标 Phase D-N(例:`feat(paper): 跨币种 cash 账本 (D-11)`) - Check untracked files before committing — a missing `git add` for a newly imported file breaks CI. commit 前 `git status` 检查 untracked——新 import 的实现文件漏 add 会让 CI 挂。 diff --git a/README.md b/README.md index 79750903..c76bc755 100644 --- a/README.md +++ b/README.md @@ -41,13 +41,13 @@ Several capability lines sit on top of that harness: - **Factor lab + factor timing** — agents formalize, compute, IC-test, multiple-testing-check, and register factors, and rank them by time-series Rank IC to time entries; every hypothesis is logged with author, timestamp, and the economic-story gate decision. - **Multi-perspective research** — a deep dive convenes technical / fundamental / sentiment analysts, plus an optional panel of investing legends (Buffett / Lynch / Wood / Burry / Druckenmiller / Marks) for opposing views that feed a synthesis. - **Risk engine** — declarative rules (notional caps, price deviation, drawdown veto) enforced at the HTTP boundary, not in prompts. -- **Strategy evolution** — LLMs mutate full Python source; three sandbox gates (AST audit, subprocess isolation, `Strategy` protocol contract) precede any candidate run; multi-objective fitness (Sharpe + Calmar − turnover − drawdown) so no metric can be gamed alone. +- **Strategy evolution** — LLMs mutate full Python source through auditable unified diffs; AST audit, restricted loading, subprocess execution, and the `Strategy` protocol contract precede evaluation; multi-objective fitness (Sharpe + Calmar − turnover − drawdown) keeps one metric from dominating. - **Machine-approved orders (no direct LLM path)** — order intents go `trade.create_plan → approve → execute_plan` with a single-use, TTL-bound `approval_token`; the LLM has no direct path to placing an order, and every step is logged into the audit trail. - **Inari Omikuji — a shrine fortune draw (playful easter egg)** — undecided on direction? Cast a hexagram or draw a tarot card for a vantage outside the data; **hard-walled from decisions**, it can't touch risk, orders, or factors (see Core Capabilities §7). The name combines **Ina**ri (the Japanese fox deity of prosperity) with **alpha** (the quant term for excess return) — a companion that reads your direction and keeps every step on the record. -> **Status:** Inalpha is in **alpha** — 79 factors with lineage & decay watch (alert-only, no auto-trim), a restricted-DSL factor-discovery L1, and a three-party research debate, on top of multi-market paper trading (cross-currency cash + a live runner that auto-runs promoted strategies on live bars), multi-market data, and LLM-authored strategies + risk engine. Read the code, weigh in on design — **do not run this against real money** (real-money trading is out of scope). +> **Status:** Inalpha is in **alpha** — 79 factors with lineage & decay watch, restricted-DSL factor discovery, a three-party research debate, multi-market paper trading, and an E1 strategy-evolution service. Each evolution run requires explicit approval, freezes its dataset and non-secret LLM/pricing snapshot, and never auto-promotes or starts a candidate. Read the code, weigh in on design — **do not run this against real money** (real-money trading is out of scope). --- @@ -118,9 +118,9 @@ Three software layers over one data layer. A request flows down; results flow ba **L1 · User entry.** The Operator Console (`apps/dashboard`) is the home base, with a docked agent chat. The `mastra dev` playground is there for live trace; direct CLI tool calls still work. -**L2 · Orchestration** — `packages/orchestration` (Mastra · TypeScript). The one layer LLMs run in: a single orchestrator agent, wrapped in its harness — tools, hook/permission middleware, the in-memory plan store, conversation memory, and telemetry. +**L2 · Orchestration** — `packages/orchestration` (Mastra · TypeScript). The one layer LLMs run in: a single orchestrator agent wrapped in tools, hook/permission middleware, DB-backed plan/approval state, conversation memory, and telemetry. -**L3 · Kernel services** — Python · FastAPI. Four independent, stateful processes, each owning one job: +**L3 · Kernel services** — Python · FastAPI. Five independent services, each owning one job: | Service | Owns | |---|---| @@ -128,10 +128,11 @@ Three software layers over one data layer. A request flows down; results flow ba | `services/paper` | The event-driven kernel — backtest + paper on the **same code** — plus the LLM-authored-strategy sandbox and the live runner. | | `services/research` | Multi-agent deep dive: 6 analysts in parallel, then a bull / bear / risk debate (triggered only when they disagree, with a soft early-stop and the decision chain persisted for replay). | | `services/factor` | The factor library (pandas-ta / Alpha101 / qlib + FRED macro): IC screening, current-effective factor timing, lineage & decay watch, DSL factor discovery. **Signals only — never places an order.** | +| `services/evolver` | Owner-scoped E1 strategy evolution: unified-diff mutation, frozen-data evaluation, candidate/run lineage, cost accounting, and an explicit approval boundary. **Never auto-promotes, starts, or trades a candidate.** | **L4 · Persistence & external.** Postgres + TimescaleDB holds all time-series and business state. External venues span crypto, US / A-share / HK and other Asian & European single-name equities, global indices, and FRED macro — the orchestrator routes each venue automatically by market type. -The strategy-evolution loop runs asynchronously alongside the runtime; winners are promoted back into `services/paper` for backtest evaluation (sandbox gates, fitness function, and the E1 → E4 ramp are in [Core Capabilities §3](#3-strategy-evolution--let-strategies-write-better-versions-of-themselves)). See [`docs/04-current-state.md`](docs/04-current-state.md) for the live module inventory and what's still in flight. +The strategy-evolution loop runs asynchronously alongside the runtime. It persists and ranks candidates inside `services/evolver`; moving a selected candidate into the Paper strategy lifecycle remains a separate, explicitly approved action — there is no automatic promotion (sandbox gates, fitness function, and the E1 → E4 ramp are in [Core Capabilities §3](#3-strategy-evolution--let-strategies-write-better-versions-of-themselves)). See [`docs/04-current-state.md`](docs/04-current-state.md) for the live module inventory and what's still in flight. --- @@ -165,13 +166,14 @@ Letting an LLM call `submit_order` directly is how you lose money fast. Telling Human-written strategies hit a velocity ceiling, and parameter tuning can only adjust dials — it cannot discover a structural change like "add an RSI filter to the SMA cross." Inalpha lets an LLM rewrite the strategy's Python source, then puts every candidate through hard gates before it ever touches a backtest. -- **Full source, optionally from a vetted archetype.** The LLM authors the strategy's complete Python source — it can start from a pre-validated archetype skeleton to cut protocol errors — then iterates against the last backtest report. (Small-diff / unified-diff mutation arrives with E2.) -- **Three sandbox gates.** A static code audit, an isolated subprocess run, and a final check that the result still satisfies the `Strategy` interface. Malicious or malformed code never reaches the backtest. -- **Balanced fitness, baseline-checked.** Candidates are scored on a balanced fitness (return + risk-adjusted return − turnover − drawdown veto), and each is auto-raced against a buy-and-hold baseline — a high score has to beat just holding, not a single Sharpe number. (The MAP-Elites behavioral grid that keeps the population diverse is part of E2.) +- **Full source with auditable mutations.** The run starts from a vetted built-in or owner-owned promoted strategy, asks the LLM for a unified diff, applies it with bounded fuzz, and persists both the parent snapshot and diff. +- **Layered execution gates.** A static AST audit, restricted loader, subprocess evaluation, and final `Strategy` contract check reject malformed candidates. The subprocess boundary keeps CPU work away from the API loop; it is not presented as a hardened container or VM sandbox. +- **Balanced fitness, baseline-checked.** Candidates are scored on a balanced fitness (return + risk-adjusted return − turnover − drawdown veto), and each is auto-raced against a buy-and-hold baseline — a high score has to beat just holding, not a single Sharpe number. MAP-Elites-style diversity control is deferred until after the narrower E2 loop and only if real runs justify it. - **Cross-validated, not a single lucky split.** A candidate can run time-series cross-validation — WalkForward / Purged K-Fold / Combinatorial Purged CV with a Deflated Sharpe — so an edge has to hold across many out-of-sample paths instead of one window, with the test fold always reaching the latest bar. -- **Reproducible end to end.** Each candidate's parent, prompt, sandbox verdict, and scores are versioned — the entire lineage can be replayed later. +- **Reproducible end to end.** One run freezes `as_of`, the closed-bar dataset manifest/hash, seed source, baseline, candidate source/diff, evaluation snapshots, and non-secret LLM/provider/pricing metadata. The user's encrypted API key is resolved only for that owner and never stored in the run. +- **Explicitly authorized and non-promoting.** Starting a run requires a trusted approval bound to the owner, operation ID, request, estimated cost, and frozen LLM snapshot. Completion never promotes, starts, or routes a candidate into the order path. -> Ships as E1 (single-generation closed loop) in D-9 and ramps to E4 (loop exposed to the orchestrator as a single MCP tool), with two weeks of stable operation required between tiers. +> E1 now runs as the separate `services/evolver` service on port 8005. E2 is intentionally narrower than the original research plan: best-parent multi-generation selection plus early stopping first; MAP-Elites and Island Model wait for real run data to justify the complexity. ### 4. Swarm — run dozens of backtests in parallel @@ -230,6 +232,8 @@ Where each capability stands today. Live module inventory and the end-to-end dec | ✅ Shipped | Plan/Exec audit trail + Hooks + Permission Engine | D-8a | three-step orders · one-shot signing token · 5 lifecycle hook events · allow / ask / deny tri-state | | ✅ Shipped | Research → strategy → backtest lineage | D-8c | `deep_dive → compose_strategy → run_backtest` with `research_id` / `backtest_id` threaded through | | ✅ Shipped | LLM-authored strategies — E1 MVP | D-9 | three sandbox gates (AST · subprocess · `Strategy` contract) + multi-objective fitness + baseline auto-run | +| ✅ Shipped | Strategy evolution — E1 production loop | E1 | `services/evolver:8005` · explicit cost-bearing approval · unified-diff mutation · frozen dataset/hash · seed/baseline/candidates evaluated on the same bars · owner-scoped async run/slot state | +| ⏭️ In flight | Frozen LLM approval snapshot | E1 closure | approval binds owner + operation ID + model/pricing digest · encrypted owner credential resolved just-in-time · per-slot token/cost accounting, including rejected mutations | | ✅ Shipped | Risk engine at the HTTP boundary | D-9 | declarative `risk_rules.toml` · pre-trade `enforce` · `risk_locks` table with independent commit | | ✅ Shipped | Bull / bear researcher debate | D-9 | opposing-stance researchers under `services/research` | | ✅ Shipped | Scheduler / cron agent mode | D-9 | `scheduler_jobs` + advisory lock + `/api/scheduler/*` management plane | @@ -251,7 +255,7 @@ Where each capability stands today. Live module inventory and the end-to-end dec | ✅ Shipped | Cross-sectional factor scoring | D-12 | `factor.panel_score` · `POST /panel/score` · cross-sectional Rank IC (rank the pool each period vs forward cross-sectional return) · native Alpha101 a1/a3 · orthogonal to single-name timing | | ✅ Shipped | Time-series cross-validation — anti-overfitting | D-12 | WalkForward / PurgedKFold / Combinatorial Purged CV + Deflated Sharpe · `POST /backtest/cv` · test fold always includes the latest bar · auto-fallback to walk-forward when samples are short | | ✅ Shipped | Point-in-time fundamentals | D-12 | Baostock financials filtered by actual publication date · `GET /fundamentals?as_of=` · prevents look-ahead (yfinance v1 not yet PIT, explicitly flagged) | -| 🗓️ Planned | Strategy evolution — E2 | E2 | multi-generation loop + MAP-Elites + Island Model + `unified-diff` mutations (E1 single-generation closed loop already shipped in D-9) | +| 🗓️ Planned | Strategy evolution — E2 | E2 | best-parent multi-generation loop + early stopping; MAP-Elites / Island Model deferred until real run data shows a diversity problem | | 🗓️ Planned | Factor discovery — L2 / L3 | L2 / L3 | multi-agent factor crew (L2) + weekly automated scans (L3), on top of the L1 DSL pipeline already shipped | | 🗓️ Planned | Automated decay handling | TBD | reflection-driven backtest + auto-trim of decaying factors — today the decay patrol only alerts, never moves the book | | 🔬 Exploring | Alpha Zoo cold start | E1+ | seed factor library with public alphas (Qlib / Kakushadze / GTJA) | @@ -278,7 +282,7 @@ Where each capability stands today. Live module inventory and the end-to-end dec ### Docker self-hosting (recommended) -This is the supported Docker path for a personal server or local machine. It builds the complete stack locally: PostgreSQL/TimescaleDB, Redis, migrations, data, paper, research, factor, Mastra, and the Operator Console. +This is the supported Docker path for a personal server or local machine. It builds the complete stack locally: PostgreSQL/TimescaleDB, Redis, migrations, data, paper, research, factor, evolver, Mastra, and the Operator Console. Prerequisites: Docker Engine with Docker Compose v2, Git, and OpenSSL. Clone the repository, then initialize the local-only environment file and start the stack: @@ -295,7 +299,7 @@ The console is available only on the host at . Wait until bash scripts/selfhost.sh create-user --email you@example.com ``` -Sign in at , open **LLM Settings**, and add your provider, model, and personal API key. Every authenticated user supplies their own key; it is encrypted in the database with `LLM_CONFIG_ENCRYPTION_KEY`. Do not add provider API keys to `infra/.env.selfhost`: authenticated production mode deliberately has no shared system-key fallback. +Sign in at , open **LLM Settings**, and add your provider, model, and personal API key. The Dashboard encrypts it with `LLM_CONFIG_ENCRYPTION_KEY`; the orchestrator and Evolver resolve that owner-scoped credential. The standalone Research service still uses the deployment-level `LLM_PROVIDER` / `LLM_MODEL` and matching provider key in `infra/.env.selfhost`, so configure that block if you need deep dives and treat it as a shared-credential boundary until per-owner propagation lands. Useful operations: @@ -314,8 +318,10 @@ The self-host Compose file intentionally exposes only `127.0.0.1:3001`. For remo ### 1 · Install dependencies ```bash -pnpm i # Node packages (packages/orchestration) -uv sync # Python packages (services/_shared, data, paper, research, factor) +cd packages/orchestration && pnpm i && cd ../.. +for service in data paper research factor evolver; do + (cd "services/$service" && uv sync) +done ``` ### 2 · Configure your LLM key (required) @@ -346,15 +352,26 @@ Override the default model by setting `LLM_MODEL=...` in the same file. Mastra a **Optional · FRED key for macro factors.** The factor library's macro factors (`macro.*` — rates, term & credit spreads, CPI, payrolls, real-economy, sentiment) read FRED data via `venue=fred`. Set `FRED_API_KEY` in `.env` to enable them — it's [free and instant](https://fred.stlouisfed.org/docs/api/api_key.html). Without a key the connector simply isn't registered and macro factors degrade gracefully (price/volume factors are unaffected). Note: macro factors are computed **only at `timeframe=1d/1wk`** — they're filtered out on intraday bars (monthly series would be a step function), so request `1d` to see them. -### 3 · Start everything +### 3 · Start PostgreSQL / Redis and migrate ```bash -bash scripts/dev.sh # one shot — data (8001) + paper (8002) + research (8003) + factor (8004) + mastra (4111) +cp infra/.env.example infra/.env +(cd infra && docker compose up -d) +(cd infra/migrations && uv sync && uv run alembic upgrade head) +``` + +The local defaults in `infra/.env.example` and the repository-root `.env.example` are intentionally +identical. If you change the database password or port, update both files before starting services. + +### 4 · Start all services + +```bash +bash scripts/dev.sh # one shot — data (8001) + paper (8002) + research (8003) + factor (8004) + evolver (8005) + mastra (4111) bash scripts/dev.sh logs # follow service logs bash scripts/dev.sh stop # stop everything ``` -### 4 · Open the Operator Console — your home base +### 5 · Open the Operator Console — your home base The **Operator Console** is the recommended way to use Inalpha — your home base. A runtime dashboard surfaces everything you'd otherwise have to ask the agent for, at a glance: @@ -370,18 +387,21 @@ pnpm dev # → http://localhost:3001 ``` No extra config — the console reads the repo-root `.env` directly (backend URLs + `JWT_SECRET` -are inherited), so as long as the services from step 3 are up, it just connects. It ships with +are inherited), so as long as the services from step 4 are up, it just connects. It ships with **dark / light themes** (a terminal "Vermilion" aesthetic — see [`apps/dashboard/design.md`](apps/dashboard/design.md)) and an `en / 中` switcher in the sidebar. > The console is the single front door: data, research, backtests, live runners, and the > conversation with the orchestrator now all live in one place. -> Only the orchestrator (Mastra) and `services/research` consume your LLM key; `services/paper` -> never calls an LLM directly. Prefer the manual three-terminal flow, or want the low-level live +> The orchestrator and an explicitly approved `services/evolver` run can consume your owner-scoped +> LLM key; `services/research` currently uses the deployment-level provider/key, and +> `services/paper` never calls an LLM directly. Evolver resolves the encrypted credential just in +> time and stores only the frozen non-secret config/pricing snapshot. +> Prefer the manual multi-terminal flow, or want the low-level live > trace (the `mastra dev` playground at )? See [`AGENTS.md §4`](AGENTS.md). -### 5 · Try asking +### 6 · Try asking With the console up, talk to the orchestrator in the docked chat on the right — it replies in the language of your message. Each prompt below shows off a different part of the system: @@ -390,6 +410,7 @@ With the console up, talk to the orchestrator in the docked chat on the right - `Research TSLA with a Buffett and a Cathie Wood take.` — **investing-legends panel**: opt-in master personas argue in their own styles. - `Trace the AI-compute supply chain to its tightest bottleneck and surface the names worth researching first.` — **research-methodology skills**: auto-loads an external playbook (e.g. `serenity` supply-chain bottleneck, or `cn-equity-research` for A-shares). - `Design a mean-reversion strategy for ETH, backtest the last 6 months, and show its fitness vs buy-and-hold.` — **LLM-authored strategy**: the model writes the full source, it clears three sandbox gates, then auto-races a baseline. +- `Evolve my promoted BTC strategy with one candidate and stop after this run.` — **E1 evolution**: shows the frozen model and estimated cost for approval, evaluates seed/baseline/candidate on one dataset hash, and leaves promotion as a separate human decision. - `Backtest momentum / mean-reversion / breakout across BTC, ETH, SOL for the last year and give me the Pareto frontier.` — **swarm**: dozens of backtests fanned out in parallel. - `Open a small NVDA position.` — **machine-approved orders**: watch it route through propose → approve → execute; the LLM has no direct path to placing an order. diff --git a/README.zh-CN.md b/README.zh-CN.md index 2119da77..c53c7cf5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -39,13 +39,13 @@ Inalpha 是一个**用工程纪律驱动的专业量化 agent 框架**。它不 - **因子实验室 + 有效因子择时。** Agent 负责 formalize、compute、IC 检验、多重检验校正、register;并按时序 Rank IC 挑出当前有效的因子来择时。每个假设都带作者、时间戳与经济故事门的判定记录。 - **多视角研究。** 一次深度研究叫上技术 / 基本面 / 情绪 analyst,外加可选的"投资大师团"(巴菲特 / 林奇 / 伍德 / 伯里 / 德鲁肯米勒 / 马克斯)形成对立视角,再汇成综合判断。 - **风控引擎。** 仓位上限、价格偏离、回撤一票否决等规则在 HTTP 边界声明式生效,不写在 prompt 里。 -- **策略进化。** LLM 变异完整 Python 源码,三道沙盒(AST 审计 / 子进程隔离 / `Strategy` 协议契约)先于任何候选回测;多目标 fitness(Sharpe + Calmar − turnover − drawdown 一票否决),单一指标卷不了。 +- **策略进化。** LLM 通过可审计的 unified diff 变异完整 Python 源码;AST 审计、受限加载、子进程执行与 `Strategy` 协议契约先于评估;多目标 fitness(Sharpe + Calmar − turnover − drawdown 一票否决),单一指标卷不了。 - **机器审批下单(LLM 不直连)。** 下单意图走 `trade.create_plan → approve → execute_plan`,配一次性、短 TTL 的 `approval_token`;LLM **没有**直接下单路径,每一步留痕成审计链。 - **狐神签(趣味彩蛋)。** 方向犹豫时求一卦 / 抽张塔罗,给个数据之外的参照视角;**硬隔离于决策**,风控 / 下单 / 因子都碰不到(详见下方核心能力 §7)。 项目命名取自日本稻荷狐神 **Ina**ri 与量化术语 **alpha**:一个会替你问卜方向、又把每一步记上账的量化伙伴。 -> **当前状态:** Inalpha 处于 **alpha** 阶段——79 因子配血缘 + 衰减巡检(只告警、不自动剔)、受限 DSL 的因子发现 L1,以及三方制研究辩论;建立在多市场模拟盘(跨币种 cash + live runner 让 promoted 策略按行情自动跑)、多市场数据、LLM 自创策略 + 风控引擎之上。欢迎读代码、参与设计——**请勿用真实资金跑**(真钱实盘不在当前计划)。 +> **当前状态:** Inalpha 处于 **alpha** 阶段——79 因子配血缘 + 衰减巡检、受限 DSL 因子发现、三方制研究辩论、多市场模拟盘,以及独立的 E1 策略演化服务。每次演化都要显式审批,冻结数据集与非敏感 LLM/计价快照,且绝不自动 promote 或启动候选。欢迎读代码、参与设计——**请勿用真实资金跑**(真钱实盘不在当前计划)。 --- @@ -116,9 +116,9 @@ Inalpha 是一个**用工程纪律驱动的专业量化 agent 框架**。它不 **L1 · 用户入口。** 操作者控制台(`apps/dashboard`)是主入口,右侧内嵌 agent 对话栏;也可用 `mastra dev` playground 看 live trace,或直接调 tool CLI。 -**L2 · 编排层** —— `packages/orchestration`(Mastra · TypeScript)。**LLM 唯一运行的一层**:一个 orchestrator agent,外裹一整套护栏——tools、hook/permission 中间件、in-memory plan store、对话 memory、telemetry。 +**L2 · 编排层** —— `packages/orchestration`(Mastra · TypeScript)。**LLM 唯一运行的一层**:一个 orchestrator agent,外裹 tools、hook/permission 中间件、DB 持久化的 plan/审批状态、对话 memory 与 telemetry。 -**L3 · 内核服务** —— Python · FastAPI。四个独立的有状态进程,各管一摊: +**L3 · 内核服务** —— Python · FastAPI。五个独立服务,各管一摊: | 服务 | 负责 | |---|---| @@ -126,10 +126,11 @@ Inalpha 是一个**用工程纪律驱动的专业量化 agent 框架**。它不 | `services/paper` | 事件驱动内核——回测 + 模拟盘**共用同一份代码**——外加 LLM 自创策略沙盒与 live runner。 | | `services/research` | 多 agent 深度研究:6 analyst 并行,再走 bull / bear / risk 辩论(只在分歧时才触发,配软早停,决策链路全程落盘可复盘)。 | | `services/factor` | 因子库(pandas-ta / Alpha101 / qlib + FRED 宏观):IC 检验、当前有效因子择时、血缘衰减巡检、DSL 因子发现。**只产出信号——绝不下单。** | +| `services/evolver` | owner 隔离的 E1 策略演化:unified-diff 变异、冻结数据评估、run/candidate 血缘、费用记账与显式审批边界。**绝不自动 promote、启动或下单。** | **L4 · 持久化 + 外部依赖。** Postgres + TimescaleDB 承载全部时序与业务状态。外部行情覆盖 crypto、美股 / A股 / 港股 及日欧等单股市场、全球指数、FRED 宏观——orchestrator 按市场类型自动路由每个 venue。 -进化循环与 runtime 并行异步运行,胜出策略推回 `services/paper` 跑回测评估(沙盒门、fitness 函数、E1 → E4 渐进路径见下方[核心能力 §3](#3-策略进化--让策略写出更好的下一代))。当前已落地模块清单、未完成项、决策链路 sequence diagram 见 [`docs/04-current-state.md`](docs/04-current-state.md)。 +进化循环与 runtime 并行异步运行,候选在 `services/evolver` 内持久化并排序;把用户选中的候选纳入 Paper 策略生命周期仍是另一项需要显式审批的操作,系统不会自动 promote(沙盒门、fitness 函数、E1 → E4 渐进路径见下方[核心能力 §3](#3-策略进化--让策略写出更好的下一代))。当前已落地模块清单、未完成项、决策链路 sequence diagram 见 [`docs/04-current-state.md`](docs/04-current-state.md)。 --- @@ -163,13 +164,14 @@ Inalpha 是一个**用工程纪律驱动的专业量化 agent 框架**。它不 人工写策略有速度上限;传统调参只能转旋钮,发现不了"在 SMA 交叉里加一个 RSI 过滤"这种结构性创新。Inalpha 让 LLM 直接改 Python 源码,但每个候选都必须先过沙盒,才能跑回测。 -- **写的是整段源码,可从已验证原型起步。** LLM 直接产出策略的完整 Python 源码——可从一个预先验证过的 archetype 骨架起步、降低协议踩坑——再对着上一次回测报告迭代。(小 diff / unified-diff 变异随 E2 到来。) -- **回测之前三道沙盒。** 静态代码审查、子进程隔离运行、`Strategy` 接口契约校验——恶意或残缺代码根本到不了回测引擎。 -- **均衡 fitness,且对照基线。** 候选按"收益 + 风险调整后收益 − 换手率 − 回撤一票否决"综合打分,并各自与 buy-and-hold 基线自动并跑——高分必须真的跑赢"光是持有",不是只卷一个 Sharpe 数。(保留种群多样性的 MAP-Elites 行为网格属 E2。) +- **完整源码 + 可审计变异。** Run 从内置或 owner 自己已 promoted 的策略起步,LLM 只返回 unified diff;系统以有界 fuzz 应用,并同时保存父源码快照与 diff。 +- **分层执行门。** 静态 AST 审计、受限 loader、子进程评估、最终 `Strategy` 协议检查共同拒绝畸形候选。子进程负责把 CPU 重活与 API loop 隔开,不把它宣传成容器 / VM 级硬沙盒。 +- **均衡 fitness,且对照基线。** 候选按"收益 + 风险调整后收益 − 换手率 − 回撤一票否决"综合打分,并各自与 buy-and-hold 基线自动并跑——高分必须真的跑赢"光是持有",不是只卷一个 Sharpe 数。MAP-Elites 一类多样性控制后置到收窄版 E2 之后,且只有真实 run 证明有必要才引入。 - **交叉验证,不靠单段走运。** 候选可跑时序交叉验证——WalkForward / Purged K-Fold / Combinatorial Purged CV 配 Deflated Sharpe——让 edge 必须在多条样本外路径上都站得住,而非靠一段窗口走运,且 test 段始终覆盖到最新 bar。 -- **端到端可复现。** 每个候选的父策略、prompt、沙盒判定、得分都有版本——整条进化链可重放。 +- **端到端可复现。** 每个 run 冻结 `as_of`、已收盘 bar 的 manifest/hash、种子源码、基线、候选源码/diff、评估快照与非敏感 LLM/provider/计价元数据。用户加密 API key 只按 owner 临时解析,不写入 run。 +- **显式授权且绝不自动晋级。** Run 启动审批绑定 owner、operation ID、请求、预估费用和冻结 LLM 快照;完成后不会自动 promote、start,更不会进入下单路径。 -> D-9 上线 E1(单代闭环),目标渐进到 E4(进化循环作为单个 MCP tool 暴露给 orchestrator);每一级要稳定运行 2 周才升下一级。 +> E1 已拆成独立的 `services/evolver:8005`。E2 先只做 best-parent 多代选择 + early stopping;MAP-Elites / Island Model 等真实 run 数据证明存在多样性问题后再引入。 ### 4. Swarm · 一次跑几十个回测 @@ -228,6 +230,8 @@ Inalpha 把*调度*和*算力*分开:agent runtime 负责扇出网格、聚合 | ✅ 已上线 | Plan/Exec 审计链 + Hooks + Permission Engine | D-8a | 三步下单 · 一次性签名 token · 5 类生命周期 hook · allow / ask / deny 三态 | | ✅ 已上线 | 研究 → 策略 → 回测 lineage | D-8c | `deep_dive → compose_strategy → run_backtest` 全链路串 `research_id` / `backtest_id` | | ✅ 已上线 | LLM 自创策略 — E1 MVP | D-9 | 三道沙盒(AST 审计 / 子进程 / `Strategy` 协议契约) + 多目标 fitness + baseline 自动并跑 | +| ✅ 已上线 | 策略演化 — E1 生产闭环 | E1 | `services/evolver:8005` · 计费动作显式审批 · unified-diff 变异 · 冻结数据集/hash · seed/baseline/candidate 同 bars 评估 · owner 隔离异步 run/slot 状态机 | +| ⏭️ 进行中 | 冻结 LLM 审批快照 | E1 收口 | 审批绑定 owner + operation ID + 模型/计价摘要 · 加密 owner 凭据按需解析 · 被拒变异也记 token/费用 | | ✅ 已上线 | 风控引擎落到 HTTP 边界 | D-9 | 声明式 `risk_rules.toml` · 撮合前 `enforce` · `risk_locks` 表(独立 commit) | | ✅ 已上线 | Bull / Bear 研究员辩论 | D-9 | `services/research` 立场对抗研究员 | | ✅ 已上线 | Scheduler / cron agent 模式 | D-9 | `scheduler_jobs` + advisory lock + `/api/scheduler/*` 管理面 | @@ -249,7 +253,7 @@ Inalpha 把*调度*和*算力*分开:agent runtime 负责扇出网格、聚合 | ✅ 已上线 | 横截面因子打分 | D-12 | `factor.panel_score` · `POST /panel/score` · 横截面 Rank IC(每期对全池按因子排序 vs 跨标的前瞻收益)· Alpha101 a1/a3 原生 · 与单标的择时口径正交 | | ✅ 已上线 | 时序交叉验证 — 防过拟合 | D-12 | WalkForward / PurgedKFold / Combinatorial Purged CV + Deflated Sharpe · `POST /backtest/cv` · test 段始终含最新 bar · 样本不足自动回落 walk-forward | | ✅ 已上线 | 财报 point-in-time | D-12 | Baostock 财报按实际披露日期过滤 · `GET /fundamentals?as_of=` · 防前视(yfinance v1 尚未 PIT,已显式标注) | -| 🗓️ 已规划 | 策略进化 — E2 | E2 | 多代演化 + MAP-Elites + Island Model + `unified-diff` 变异(E1 单代闭环已在 D-9 上线) | +| 🗓️ 已规划 | 策略进化 — E2 | E2 | best-parent 多代循环 + early stopping;MAP-Elites / Island Model 延后到真实 run 数据证明需要多样性控制时再做 | | 🗓️ 已规划 | 因子发现 — L2 / L3 | L2 / L3 | 多 agent 因子小组(L2)+ 每周自动扫描(L3),建立在已上线的 L1 DSL pipeline 之上 | | 🗓️ 已规划 | 因子衰减自动处置 | 待定 | 反思驱动回测 + 衰减因子自动剔除——当前衰减巡检只告警、绝不替你动仓 | | 🔬 探索中 | Alpha Zoo 冷启动 | E1+ | 公开 alpha 库播种(Qlib / Kakushadze / GTJA) | @@ -276,7 +280,7 @@ Inalpha 把*调度*和*算力*分开:agent runtime 负责扇出网格、聚合 ### Docker 自托管(推荐) -这是个人电脑或个人服务器的完整 Docker 启动路径:本地构建 PostgreSQL/TimescaleDB、Redis、迁移、data、paper、research、factor、Mastra 和操作者控制台。 +这是个人电脑或个人服务器的完整 Docker 启动路径:本地构建 PostgreSQL/TimescaleDB、Redis、迁移、data、paper、research、factor、evolver、Mastra 和操作者控制台。 前置条件:Docker Engine(含 Compose v2)、Git 与 OpenSSL。克隆仓库后,初始化仅保留在本机的环境文件,再启动全栈: @@ -293,7 +297,7 @@ bash scripts/selfhost.sh up bash scripts/selfhost.sh create-user --email you@example.com ``` -在 登录,打开 **LLM Settings**,填写你的 provider、model 与个人 API key。每位已认证用户各自提供 key,控制台用 `LLM_CONFIG_ENCRYPTION_KEY` 加密后写入数据库。不要把 provider API key 填进 `infra/.env.selfhost`:认证生产模式刻意不支持共享系统 key fallback。 +在 登录,打开 **LLM Settings**,填写你的 provider、model 与个人 API key。控制台用 `LLM_CONFIG_ENCRYPTION_KEY` 加密后写入数据库,orchestrator 与 Evolver 按 owner 临时解析。独立 Research service 当前仍读取 `infra/.env.selfhost` 中部署级的 `LLM_PROVIDER` / `LLM_MODEL` 与对应 provider key;需要 deep dive 时须配置该段,并在 per-owner 透传落地前把它视为共享凭据边界。 常用操作: @@ -312,8 +316,10 @@ self-host Compose 故意只暴露 `127.0.0.1:3001`。需要远程访问时,用 ### 1 · 安装依赖 ```bash -pnpm i # Node 包(packages/orchestration) -uv sync # Python 包(services/_shared, data, paper, research, factor) +cd packages/orchestration && pnpm i && cd ../.. +for service in data paper research factor evolver; do + (cd "services/$service" && uv sync) +done ``` ### 2 · 配置 LLM Key(必须) @@ -344,15 +350,26 @@ cp .env.example .env **可选 · 宏观因子需要 FRED key。** 因子库的宏观因子(`macro.*`——利率、期限/信用利差、CPI、就业、实体经济、情绪)通过 `venue=fred` 读 FRED 数据。在 `.env` 里设 `FRED_API_KEY` 即可启用,[免费、秒发](https://fred.stlouisfed.org/docs/api/api_key.html)。不配 key 时 connector 不注册,宏观因子优雅降级(价量因子不受影响)。注意:宏观因子**仅在 `timeframe=1d/1wk` 计算**——intraday bar 上会被过滤(月频序列会变成阶梯函数),所以要看宏观因子请用 `1d`。 -### 3 · 启动全部 service +### 3 · 启动 PostgreSQL / Redis 并执行迁移 ```bash -bash scripts/dev.sh # 一键起 data(8001) + paper(8002) + research(8003) + factor(8004) + mastra(4111) +cp infra/.env.example infra/.env +(cd infra && docker compose up -d) +(cd infra/migrations && uv sync && uv run alembic upgrade head) +``` + +`infra/.env.example` 与仓库根 `.env.example` 的本地数据库默认值刻意保持一致。若修改数据库 +密码或端口,启动 service 前必须同步修改两份文件。 + +### 4 · 启动全部 service + +```bash +bash scripts/dev.sh # 一键起 data(8001) + paper(8002) + research(8003) + factor(8004) + evolver(8005) + mastra(4111) bash scripts/dev.sh logs # 跟随日志 bash scripts/dev.sh stop # 停止全部 ``` -### 4 · 打开操作者控制台 —— 你的主入口 +### 5 · 打开操作者控制台 —— 你的主入口 **操作者控制台**是使用 Inalpha 的推荐方式,也是你的主入口。一块运行时看板把原本要问 agent 才知道的 状态一眼铺开:组合与持仓、Live Runner 及其逐 bar 决策、跨模块的 agent 活动时间线、策略实验室、 @@ -365,16 +382,18 @@ pnpm i # 仅首次 pnpm dev # → http://localhost:3001 ``` -无需额外配置 —— 控制台直接读仓库根的 `.env`(后端 URL + `JWT_SECRET` 都继承),只要第 3 步的 +无需额外配置 —— 控制台直接读仓库根的 `.env`(后端 URL + `JWT_SECRET` 都继承),只要第 4 步的 service 起着,它就能连上。内置**黑白双主题**(终端「印章 / Vermilion」美学,详见 [`apps/dashboard/design.md`](apps/dashboard/design.md))与侧栏 `en / 中` 切换。 > 控制台就是统一入口:数据、研究、回测、Live Runner,以及与 orchestrator 的对话,现在都在同一个地方。 -> 只有 orchestrator(Mastra)和 `services/research` 会消耗你的 LLM key;`services/paper` 不直接调 LLM。 -> 想用 3 个独立 terminal 手动起,或看底层 live trace(`mastra dev` playground )?见 [`AGENTS.md §4`](AGENTS.md)。 +> orchestrator 与经显式审批的 `services/evolver` run 会消耗 owner 自己的 LLM key; +> `services/research` 当前使用部署级 provider/key,`services/paper` 从不直接调用 LLM。Evolver +> 只在执行时临时解析加密凭据,run 内只存冻结的非敏感配置/计价快照。想用多个独立 terminal 手动起,或看底层 live trace(`mastra dev` +> playground )?见 [`AGENTS.md §4`](AGENTS.md)。 -### 5 · 试着问几句 +### 6 · 试着问几句 控制台起好后,在右下角内嵌对话栏直接和 orchestrator 聊——它会用**你提问的语言**回复。下面每条各展示系统的一项能力: @@ -384,6 +403,7 @@ service 起着,它就能连上。内置**黑白双主题**(终端「印章 / - `用 A股系统化调研方法论评估寒武纪(sh.688256):壁垒 → 增速 → 估值消化 → 资金 → 风险逐层验证。` —— **研究方法论 skill**(cn-equity-research):按需加载外部投研框架。 - `顺着 AI 算力这个热点,用供应链瓶颈方法拆产业链,筛出值得优先研究的 A股环节。` —— **研究方法论 skill**(serenity):从叙事拆到稀缺环节。 - `给招商银行(sh.600036)设计一个均值回归策略,回测最近一年,给出 fitness 与买入持有对比。` —— **LLM 自创策略**:模型写完整源码,过三道沙盒,再自动并跑基线。 +- `把我已晋级的 BTC 策略演化 1 个候选,这次跑完就停。` —— **E1 演化**:先展示冻结模型与费用预估供审批,再让 seed/baseline/candidate 共用一个 dataset hash 评估,晋级仍是独立人工决策。 - `用动量 / 均值回归 / 突破在寒武纪、宁德时代、招商银行上回测最近一年,给我帕累托前沿。` —— **Swarm**:几十个回测并行扇出。 - `给寒武纪(sh.688256)开一个小仓位。` —— **机器审批下单**:看它走 提议 → 审批 → 执行;LLM 没有直接下单路径。 diff --git a/apps/dashboard/.env.local.example b/apps/dashboard/.env.local.example index f48b3192..ec7e2ee3 100644 --- a/apps/dashboard/.env.local.example +++ b/apps/dashboard/.env.local.example @@ -5,7 +5,7 @@ # # 只在想局部覆盖时才把本文件复制为 .env.local(优先级高于根 .env)。常见用途: -# ── 切换控制台身份(后端按 sub 派生稳定 account_id)── +# ── 未启用登录时的开发身份(后端按 sub 派生稳定 account_id)── # CONSOLE_SUBJECT=console:dev # CONSOLE_EMAIL=console@inalpha.dev @@ -16,8 +16,13 @@ # PAPER_SERVICE_URL=http://127.0.0.1:8002 # DATA_SERVICE_URL=http://127.0.0.1:8001 # RESEARCH_SERVICE_URL=http://127.0.0.1:8003 +# FACTOR_SERVICE_URL=http://127.0.0.1:8004 +# EVOLVER_SERVICE_URL=http://127.0.0.1:8005 # MASTRA_URL=http://127.0.0.1:4111 # ── 覆盖鉴权(必须与后端一致;后端只接受 HS256/384/512)── # JWT_SECRET= # JWT_ALGORITHM=HS256 + +# ── 用户 LLM API key 加密(推荐独立于 JWT_SECRET)── +# LLM_CONFIG_ENCRYPTION_KEY= diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index b8dde4a4..b306f636 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -1,63 +1,70 @@ -# @inalpha/dashboard · 操作者控制台 +# @inalpha/dashboard · 认证操作者控制台 -Inalpha 的**只读运行时看板**——把"原本要问 agent 才知道的状态"(账户/持仓/Live Runner/Agent 活动/回测史)变成一眼可见的盘面。 +Inalpha 的动态 Next.js 控制台(`:3001`):提供 agent 对话,以及组合、持仓、Live Runner、 +回测、策略实验室、Evolver、因子、风控和活动追踪等操作者视图。它不是静态官网,也不再是 +单用户只读 MVP。 -> 定位:开发/操作者控制台(单用户 · dev token),**不是**多租户产品。当前已落地:**组合总览 MVP**。 +## 与 `apps/web` 的关系 -## 与官网(apps/web)的关系 +- `apps/web`:纯静态官网(`output: "export"` → Cloudflare Pages)。 +- `apps/dashboard`:Node 运行时应用,负责登录/session、BFF、对话和操作界面。 +- 两者独立构建与部署(`inalpha.dev` / `app.inalpha.dev`),共享品牌设计语言。 -- `apps/web` = 纯静态官网(`output: "export"` → Cloudflare Pages)。 -- `apps/dashboard` = **动态** Next 应用(Node 运行时),用 Route Handler 当 BFF。 -- 两者独立工程、独立依赖、独立部署(`inalpha.dev` / `app.inalpha.dev`)。视觉语言共用同一套 token。 +## BFF 与认证边界 -## 为什么要 BFF +浏览器只访问同源 `/api/*`。Dashboard Route Handlers 校验登录 session,再按用户签发后端 +JWT 并访问 data/paper/research/factor/evolver/Mastra;Python services 不直接暴露给浏览器。 -python service(8001/8002/8003)**没配 CORS**,浏览器不能直连;且后端要 JWT。 -所以浏览器只调同源 `/api/*` → server 侧 BFF 用 dev token 转发到后端。token 不进浏览器。 +用户级 LLM provider/model/key 在服务端管理,key 以 `LLM_CONFIG_ENCRYPTION_KEY`(未配置时 +兼容回退 `JWT_SECRET`)加密保存。Evolver 执行时只可凭短时、用途限定且绑定 `config_id` 的 service JWT, +经 `/api/internal/llm-config/{id}` 按 owner 即时读取;明文 key 不进入 run/candidate 记录。 -## 本地起 +## 本地启动 ```bash -# 1. 先把后端 + mastra 拉起(在仓库根) -bash scripts/dev.sh up # data:8001 paper:8002 research:8003 mastra:4111 +# 仓库根:开发数据库 + migration + 五个 Python services + Mastra +cp infra/.env.example infra/.env +(cd infra && docker compose up -d) +(cd infra/migrations && uv sync && uv run alembic upgrade head) +bash scripts/dev.sh -# 2. 装依赖 + 起控制台 +# 控制台 cd apps/dashboard pnpm install -pnpm dev # http://localhost:3001 +pnpm dev # http://localhost:3001 ``` -打开 `http://localhost:3001/zh`(或 `/en`)。 +打开 `http://localhost:3001/zh` 或 `/en`。启用认证的环境需先运行 +`bash scripts/selfhost.sh create-user --email you@example.com` 创建用户。 -### 环境变量 +## 环境变量 -控制台**默认直接读仓库根的 `.env`**(后端 service URL + `JWT_SECRET` 都在那), -不用单独维护一份(逻辑在 `next.config.ts` 的 `loadRootEnv`)。 +控制台通过 `next.config.ts` 默认读取仓库根 `.env`。常用配置: -只在需要**局部覆盖**时,才在 `apps/dashboard/` 建 `.env.local`(见 -`.env.local.example`)——比如切换控制台身份 `CONSOLE_SUBJECT`、或指向远端后端。 -`.env.local` 优先级高于根 `.env`。 +| 变量 | 用途 | +|---|---| +| `AUTH_ENABLED` | 是否启用登录闸门 | +| `JWT_SECRET` / `JWT_ALGORITHM` | 用户/服务 JWT | +| `LLM_CONFIG_ENCRYPTION_KEY` | 用户 LLM API key 的独立加密密钥 | +| `DATA_SERVICE_URL` … `EVOLVER_SERVICE_URL` | 五个 Python service 地址 | +| `MASTRA_URL` | agent 编排地址 | +| `EVOLVER_ENABLED` | 是否显示并开放演化能力 | -## 结构 +只有局部覆盖时才复制 `.env.local.example` 为 `.env.local`;该文件优先级高于根 `.env`,且 +不得提交任何 secret。 -``` -src/ -├── app/ -│ ├── [locale]/ # 看板页面(next-intl: en/zh) -│ │ ├── layout.tsx # 控制台外壳(导航 + 状态条) -│ │ └── page.tsx # ① 组合总览 -│ └── api/ # BFF —— server 侧聚合后端接口 -│ └── overview/route.ts -├── components/ -│ ├── primitives/ # 移植自官网的设计原子 -│ ├── shell/ # 导航 / 状态条 -│ └── overview/ # 组合总览的 KPI/持仓/订单 -└── lib/ - ├── backend.ts # getServiceToken() + 带鉴权 fetch + 后端 base url - ├── types.ts # 后端 schema 的 TS 镜像 - └── format.ts # 数字/货币/时间格式化 -``` +## 主要页面与 API + +`src/app/[locale]/` 包含 overview、runners、activity、backtests、lab、evolution、factors、risk +等页面;`src/app/api/` 提供相应 BFF routes,并包含 `auth`、`chat`、`copilotkit`、`user/settings` +和 owner-scoped internal LLM config route。 -## 路线图(后续看板) +```bash +cd apps/dashboard +pnpm typecheck +pnpm test +pnpm build +``` -② Live Runner 监控 → ③ Agent 运行日志/可观测性 → ④ 策略实验室+回测史 → 风控面板。 +认证、演化与部署边界也见仓库根 README 和 +[`docs/01-architecture-overview.md`](../../docs/01-architecture-overview.md)。 diff --git a/apps/dashboard/design.md b/apps/dashboard/design.md index 396b893e..f2d94f18 100644 --- a/apps/dashboard/design.md +++ b/apps/dashboard/design.md @@ -9,7 +9,8 @@ 一台**戴着朱红印章的交易终端**。 -控制台是只读运行时看板——账户、持仓、Live Runner、Agent 活动、因子、风控。它要像 +控制台是认证后的操作与运行时治理入口——Agent 对话、账户、持仓、Live Runner、回测、 +策略演化、活动、因子和风控。它要像 专业金融终端那样**信息密集、指标直观、一眼可读**,但拒绝千篇一律的「SaaS 仪表盘」气质: 用一枚朱红印章(`assets/11-logo-stamp-style.png`,狐狸 + α)作签名标记,把工程图纸的 冷静和报刊编辑体的格调揉进同一块盘面。 diff --git a/apps/dashboard/messages/en.json b/apps/dashboard/messages/en.json index 8b098784..5b219f0f 100644 --- a/apps/dashboard/messages/en.json +++ b/apps/dashboard/messages/en.json @@ -1,7 +1,7 @@ { "meta": { "title": "Inalpha · Operator Console", - "description": "Read-only runtime dashboard for Inalpha paper trading." + "description": "Authenticated Inalpha operator console for chat, paper trading, backtests, evolution, and runtime governance." }, "nav": { "console": "Operator Console", diff --git a/apps/dashboard/messages/zh.json b/apps/dashboard/messages/zh.json index 3e986f1a..f7c7f0df 100644 --- a/apps/dashboard/messages/zh.json +++ b/apps/dashboard/messages/zh.json @@ -1,7 +1,7 @@ { "meta": { "title": "Inalpha · 操作者控制台", - "description": "Inalpha 模拟盘只读运行时看板。" + "description": "Inalpha 认证操作者控制台:对话、模拟盘、回测、演化与运行时治理。" }, "nav": { "console": "操作者控制台", diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index eda8d5af..336e40d7 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -3,7 +3,7 @@ "version": "0.2.0", "private": true, "license": "AGPL-3.0-only", - "description": "Inalpha 操作者控制台 / operator console — 只读运行时看板(账户/持仓/Live Runner/Agent 活动)", + "description": "Inalpha 认证操作者控制台 / operator console — Agent 对话、组合、回测、演化与运行时治理", "type": "module", "packageManager": "pnpm@11.1.2", "engines": { diff --git a/docs/00-context.md b/docs/00-context.md index 65395f40..0639c11f 100644 --- a/docs/00-context.md +++ b/docs/00-context.md @@ -24,7 +24,7 @@ - **不 fork** 任何单一开源项目,拆 4 个最具代表性的 repo 学各自最强的设计 - **三层架构**:Next.js + CopilotKit(入口)→ Mastra / TypeScript(编排)→ Python services(内核) -- **核心服务用 Python**:data / paper(回测+模拟盘内核)/ research / factor,跨服务走 HTTP / MCP +- **核心服务用 Python**:data / paper(回测+模拟盘内核)/ research / factor / evolver,跨服务走 HTTP / MCP - **护栏借鉴 Claude Code**:hooks / permissions / plan-exec / 审计签名——数据层强制 > prompt 自律 详见 [`01-architecture-overview.md`](./01-architecture-overview.md)。 @@ -38,7 +38,7 @@ | **qlib** | DatasetH / Handler / Alpha / Model pipeline | ✅ `services/factor`(Alpha101 / IC 有效性) | | **TradingAgents** | 多 agent 角色分工 / 辩论 / 决策合成 | ✅ `services/research`(多 analyst + bull/bear 辩论) | -## 当前完成度快照(2026-06-05) +## 当前完成度快照(2026-08-27) > 完整逐项见 [`04-current-state.md`](./04-current-state.md)。 @@ -49,7 +49,10 @@ | D-10 | 多市场数据:web 搜索 + 财报基本面 + 相对估值 analyst + MCP 生态兼容 | ✅ | | D-11 | 多市场模拟盘:跨币种 cash + live runner(按行情自动跑 + 机器审批 + 决策复盘) | ✅ | | D-11.1 / .2 | live runner 信任边界加固 + 运维收口(PnL 净口径 / TTL / build 退避) | ✅ | -| 下一 | research-hub 嵌套 supervisor(#6)/ E2 多代演化 MAP-Elites(#7) | 🔲 | +| D-12 | 因子血缘 + 衰减巡检 + monthly 宏观 + 因子发现 L1;research-hub 三方辩论收口 | ✅ | +| E1 生产闭环 | 独立 Evolver:真实 frozen bars、单代 unified-diff 变异、显式审批、owner 隔离、异步持久化与可复现实验元数据 | ✅ | +| E1 收口 | 冻结 LLM/定价快照、owner key 即时获取、token/cost 审计 | 🚧 当前分支 | +| 下一 | E2 best-parent 多代选择 + early stopping(#7);MAP-Elites / Island Model 后置 | 🔲 | ## 不做的事(边界) @@ -66,6 +69,7 @@ |---|---|---| | Phase A–B | 文档骨架 + 4 份 repo 深度拆解 | ✅ | | Phase C | Inalpha 自建内核架构(设计文档锁定 2026-05-21) | ✅ | -| Phase D-8~D-11.2 | Plan/Exec 护栏 → LLM 自创策略 → 多市场数据 → 多市场模拟盘 + live runner | ✅ | -| Phase E1 | LLM 自创策略 MVP(沙盒 + fitness) | ✅ | -| Phase E2+ | 多代演化(MAP-Elites / Island Model)/ research-hub | 🔲 规划中 | +| Phase D-8~D-12 | Plan/Exec 护栏 → 策略创作 → 多市场数据/模拟盘 → factor/research 闭环 | ✅ | +| Phase E1 | 独立策略演化生产闭环(frozen dataset + unified diff + 显式审批 + 审计) | ✅,收口中 | +| Phase E2 | best-parent 多代选择 + early stopping | 🔲 规划中 | +| Phase E3+ | MAP-Elites / Island Model 等更复杂搜索 | 🔲 后置 | diff --git a/docs/01-architecture-overview.md b/docs/01-architecture-overview.md index d96dccc1..138de87c 100644 --- a/docs/01-architecture-overview.md +++ b/docs/01-architecture-overview.md @@ -1,6 +1,6 @@ # 01 · 架构总览 -> 状态:**现行架构总览**(2026-06-05)。 +> 状态:**现行架构总览**(2026-08-27)。 > 本文给"整体形态 + 各层职责 + 关键不变量"的高层视图;内核事件循环 / Clock / > MessageBus / 撮合 / 风控的详细设计见 [`03-kernel-design.md`](./03-kernel-design.md); > 逐里程碑的落地状态见 [`04-current-state.md`](./04-current-state.md)。 @@ -10,62 +10,62 @@ ``` ┌──────────────────────────────────────────────────────────────────────┐ │ 入口层(当前) │ -│ mastra :4111 对话 + 实时 trace(主入口:tool call / hook / token) │ -│ apps/dashboard 运营控制台(app.inalpha.dev · :3001 · 只读看板 + BFF) │ -│ apps/web 静态官网(inalpha.dev);CopilotKit 对话 UI 规划 Phase E+│ +│ mastra :4111 Agent 编排 API + trace │ +│ apps/dashboard 认证控制台(app.inalpha.dev · :3001 · 对话 + BFF + 看板)│ +│ apps/web 静态官网(inalpha.dev) │ └───────────────────────────────┬──────────────────────────────────────┘ - dashboard: 同源 /api/* → BFF(dev token 转发,token 不进浏览器) + dashboard: 同源 /api/* → BFF(逐用户 JWT;模型密钥服务端加密保存) ┌───────────────────────────────▼──────────────────────────────────────┐ │ 编排层 · packages/orchestration · Mastra (TypeScript) │ │ │ │ agents/ orchestrator → trader / risk(按市场分类自动路由 venue)│ -│ tools/ data.* web.* factor.* research.* paper.* trade.* swarm.*│ +│ tools/ data.* web.* factor.* research.* paper.* trade.* evolver.*│ +│ swarm.* │ │ + mcp____(可插拔外部 MCP) │ │ hooks/ 5 类生命周期事件 + Stop(PreToolUse / PostToolUse / …) │ │ permissions/ allow / ask / deny 三态(deny > allow > ask > default) │ │ plan/exec create_plan → approve_plan → execute_plan(一次性 token)│ -│ memory/ PostgresStore · 用户偏好 / 历史会话 / plan 状态 │ +│ memory/ PostgresStore · 用户偏好 / 历史会话;plan 由 paper DB 持久化│ └───────────────────────────────┬──────────────────────────────────────┘ │ HTTP / MCP(每个 tool 调对应服务) - ┌───────────────┬───────┴───────┬───────────────┐ - ▼ ▼ ▼ ▼ - ┌──────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ - │ data │ │ paper │ │ research │ │ factor │ - │ :8001 │ │ :8002 │ │ :8003 │ │ :8004 │ - │ 行情/财报 │ │ 内核+回测+ │ │ 多 analyst │ │ 因子库 + IC │ - │ /web/fx │ │ 模拟盘+沙盒 │ │ + bull/bear │ │ 有效性 │ - └─────┬────┘ └──────┬───────┘ └──────────────┘ └──────────────┘ + ┌──────────┬──────────┬──────────┬──────────┬──────────┐ + ▼ ▼ ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ data │ │ paper │ │ research │ │ factor │ │ evolver │ + │ :8001 │ │ :8002 │ │ :8003 │ │ :8004 │ │ :8005 │ + │行情/财报 │ │内核/回测 │ │多分析师 │ │因子/IC │ │策略演化 │ + │/web/fx │ │/模拟盘 │ │与辩论 │ │与衰减 │ │与审计 │ + └─────┬────┘ └─────┬────┘ └──────────┘ └──────────┘ └─────┬────┘ │ │ (services/_shared:跨服务基础设施,改前评估) ▼ ▼ ┌──────────────────────────────────┐ - │ Postgres 17 + TimescaleDB │ hypertable: bars / ticks / orders - │ │ 常规表: accounts / positions / runs / plans + │ Postgres 17 + TimescaleDB │ hypertable: bars / ticks + │ │ 常规表: orders / accounts / positions / runs / plans └──────────────────────────────────┘ ▲ │ 外部数据源 / 经纪商 └── CCXT(crypto) · akshare(A股/港股) · yfinance(美股/全球) · FRED · DDGS(web) ``` -> 启动后端 + 编排:`pnpm i && uv sync && bash scripts/dev.sh up` -> (data:8001 + paper:8002 + research:8003 + factor:8004 + mastra:4111,各带 `/health`)。 +> 安装依赖后,先按根 README 复制根 `.env` 与 `infra/.env`、启动开发 Compose 并执行 Alembic +> migration;随后从仓库根运行 `bash scripts/dev.sh up`。 +> (data:8001 + paper:8002 + research:8003 + factor:8004 + evolver:8005 + mastra:4111,各 service 带 `/health`)。 > 运营控制台另起:`cd apps/dashboard && pnpm dev`(:3001,BFF 连后端)。 ## 各层职责 ### 入口层 -当前用户入口两个,面向不同用途: +当前主要入口两个,面向不同用途: - **`mastra dev` playground(:4111)** — 跟 orchestrator agent 对话,并在 live trace UI 里 看每个 tool call / hook 事件 / approval token。当前主要的"对话 + 操作"入口。 -- **`apps/dashboard`**(`:3001` · `app.inalpha.dev`)— **只读运营控制台**:把"原本要问 - agent 才知道的运行时状态"(账户 / 持仓 / live runner / agent 活动 / 回测史)变成一眼可见 - 的盘面,让对话回归"决策 / 操作"本职。动态 Next(Node 运行时)用 Route Handler 当 **BFF**—— - 浏览器只调同源 `/api/*`,server 侧用 dev token 转发到后端(Python service 未配 CORS + 需 - JWT,token 不进浏览器)。单用户 dev token、非多租户产品;当前已落地**组合总览 MVP**。 +- **`apps/dashboard`**(`:3001` · `app.inalpha.dev`)— **认证后的操作者控制台**:提供 + agent 对话、组合 / 持仓 / live runner / 演化 / 回测 / 因子 / 风控等页面。动态 Next + Route Handler 作为 **BFF**,浏览器只访问同源 `/api/*`;服务端按登录用户签发 JWT, + 用户级 LLM 配置加密保存,明文密钥不写入演化记录或浏览器持久状态。 -`apps/web`(`inalpha.dev`)当前是静态官网(`output:"export"` → Cloudflare Pages,品牌 / -文档);面向终端用户的 CopilotKit 对话 UI 规划在 Phase E+,尚未接后端。 +`apps/web`(`inalpha.dev`)是静态官网(`output:"export"` → Cloudflare Pages,品牌 / 文档)。 ### 编排层 · `packages/orchestration`(Mastra / TypeScript) @@ -75,9 +75,10 @@ Inalpha 的"大脑 + 护栏"。三件事: 2. **把 LLM 关在交易路径外**——四层防御(详 `03` / 博客篇 1): - **tool 集分桶**:orchestrator 看不到直下单 tool - **permissions deny-list**:`live.*` / 直下单恒 deny,不可被 hook 覆盖 - - **plan/exec 两阶段**:`create_plan → approve_plan → execute_plan`,approval_token - 一次性 + 5min TTL,LLM 永不持有 token - - **审计签名**:PostToolUse hook 强制写脱敏 + 签名的审计日志 + - **plan/exec 两阶段**:`create_plan → approve_plan → execute_plan`;模型只能把审批 tool + 返回的一次性、5min TTL `approval_token` 传给 execute,不能访问或绕过底层下单 tool + - **审计分层**:交易计划、审批、演化 run/candidate 等领域记录持久化到 Postgres;hook + telemetry 脱敏后 best-effort 写出,不替代领域审计事实 3. **可插拔 MCP**:`mcp____` 走同一套 hooks + permissions;默认只启用零密钥 公开端点,付费连接器以 `disabled:true` 作模板。 @@ -89,6 +90,7 @@ Inalpha 的"大脑 + 护栏"。三件事: | **paper** | 8002 | 事件驱动内核(Clock / MessageBus / 撮合 / 风控)+ 回测引擎 + **live runner**(模拟盘按行情自动跑)+ **strategy_authoring**(LLM 自创策略三道沙盒 + fitness) | | **research** | 8003 | LLM 多 analyst(fundamental / sentiment / technical / valuation …)+ bull/bear 辩论 → `StrategyHint`,不直接下单 | | **factor** | 8004 | 因子库(pandas-ta / Alpha101 / qlib)+ IC 有效性检验;`factor.timing / .score / .catalog`,只产出信号 | +| **evolver** | 8005 | E1 策略演化:冻结数据集与 LLM/定价快照、单代 unified-diff 变异、候选评估、owner-scoped 异步状态;显式审批后才可产生费用,且不会自动 promote / 启动策略 / 下单 | | **_shared** | — | 跨服务基础设施(DataClient / 错误类型 / auth …),改前评估 | **核心不变量:回测 = 模拟盘 同代码(架构上可延伸到实盘,但真钱实盘不在当前计划)。** 同一份 `Strategy` 文件,只换 Clock @@ -102,6 +104,7 @@ Inalpha 的"大脑 + 护栏"。三件事: paper ✗ import research (内核不依赖 LLM) factor ✗ import paper (因子只产出信号) data ✗ import 任何其他服务 (最底层) +evolver ✗ 绕过 data / owner auth(bars 由 data 获取;用户密钥只可按 owner 即时读取) ``` 协作只走 HTTP / MCP:research → paper 传 `StrategyHint`;paper ← data 拉 bars/fx/ @@ -113,9 +116,11 @@ fundamentals;paper → risk 同进程前置守门(所有 Order 撮合前过 2. **数据中心化**:所有服务只从 data-service 取数据,不私自爬交易所 3. **策略不直接下单**:策略产出 `Order`,由 Execution Engine + 风控决定怎么发 4. **风控前置**:所有 Order 进 Execution 前先过 RiskGuard(HTTP 路径强制) -5. **LLM 无直下单路径**:tool 分桶 + permissions deny + plan/exec token + 审计签名 +5. **LLM 无直下单路径**:tool 分桶 + permissions deny + plan/exec token + 领域审计 6. **金融时效性**:读行情/新闻默认 `fresh=True`;freshness 看 `bars[-1].ts` 距 as_of 的间隔,不看 bar 数量;数据不可用时显式降级 + 标低 confidence,不静默用过时数据 +7. **演化逐次授权且可复现**:产生 LLM 费用前冻结非密钥配置、定价与数据 manifest;审批绑定 + owner + operation,5 分钟后过期;候选永不自动 promote、启动或下单 ## 延伸阅读 diff --git a/docs/03-kernel-design.md b/docs/03-kernel-design.md index 7b6f1c6d..e26a307e 100644 --- a/docs/03-kernel-design.md +++ b/docs/03-kernel-design.md @@ -1,7 +1,9 @@ -# 03 · Inalpha 内核架构(正式版) +# 03 · Inalpha 内核设计(Phase C 基线 + 现行差异) -> 状态:**Phase C 正式设计**,基于 Phase B 4 份 repo 拆解结论 + 用户锁定决策。 -> 取代 `01-architecture-overview.md` 中的 high-level 草图;01 保留作快照。 +> 状态:本文保留 **Phase C 内核基线设计**,并在关键接口处标出现行差异。 +> [`01-architecture-overview.md`](./01-architecture-overview.md) 才是现行高层架构, +> [`04-current-state.md`](./04-current-state.md) 是实现进度权威来源;本文中的 MVP、目录树和 +> 启动清单属于历史设计,不应用来判断当前能力是否已落地。 ## 锁定决策(Phase C·2026-05-21) @@ -17,7 +19,7 @@ --- -## MVP 范围(Phase E 目标) +## MVP 范围(Phase E 原始目标 · 历史) **端到端能跑通的最小闭环**: @@ -60,7 +62,7 @@ paper-engine(同代码 = backtest=live)下模拟单 - ❌ Swarm 跑批回测(仅做单 strategy) - ❌ Mastra workflow 长任务 suspend-resume -### D-8a 已完成项(2026-05-21) +### D-8a 已完成项(2026-05-21 · 历史快照) > 详细模块清单与代码入口见 [`docs/04-current-state.md`](./04-current-state.md)。 @@ -70,14 +72,17 @@ paper-engine(同代码 = backtest=live)下模拟单 - 三 agent(`orchestrator` / `trader` / `risk`)拆分(Mastra supervisor 模式) - Hooks runner(`PreToolUse` / `PostToolUse` / `PostToolUseFailure` / `SessionStart` / Stop) - Permission Engine(allow / ask / deny 三态 + 参数 predicate) - - Plan/Exec 三 tool(`createTradePlan` / `approveTradePlan` / `executeTradePlan`)+ Plan Store(in-memory,含 `approval_token` 派发) + - Plan/Exec 三 tool(`createTradePlan` / `approveTradePlan` / `executeTradePlan`);当前 plan 与 approval token 已由 paper PostgreSQL 持久化,不再使用进程内 store -**D-8b / D-9 在做**:`trade_plans` / `approval_tokens` Postgres 表 + Alembic migration; -RiskEngine 规则化(max notional / 价格偏离 / 日损上限)+ paper-service 真接入。 +**后续实况**:D-8b / D-9 的 `trade_plans`、`approval_tokens`、RiskEngine HTTP 守门均已落地; +详见 [`04-current-state.md`](./04-current-state.md)。 --- -## 三层架构 +## 三层架构(Phase C 原图 · 现行总图见 01) + +现行入口是 `apps/dashboard`(认证 + agent 对话 + BFF + 操作者看板),`apps/web` 是静态官网; +Python 服务已扩展为 data/paper/research/factor/evolver 五个,Evolver 固定端口 `8005`。 ``` ┌────────────────────────────────────────────────────────────────┐ @@ -131,10 +136,12 @@ RiskEngine 规则化(max notional / 价格偏离 / 日损上限)+ paper-serv | 服务 | 端口(建议) | 核心职责 | 主依赖 | |---|---|---|---| | `apps/web` | 3000 | UI + 认证 + Mastra 挂载点 | Next.js 16 / CopilotKit / better-auth | +| `apps/dashboard` | 3001 | 现行认证控制台:对话 + BFF + 组合/回测/演化/风控看板 | Next.js / next-intl | | `services/data` | 8001 | 行情接入 / 历史回放 / 实时订阅 | CCXT / akshare(后期) | | `services/paper` | 8002 | 内核:Clock / MessageBus / Strategy / Gateway / Engine;回测 + 模拟盘(同代码可延伸实盘,不在当前计划) | Python kernel(自研) | | `services/research` | 8003 | LLM 多 agent 决策(TradingAgents 风格,Mastra 重写) | OpenAI/Anthropic SDK | | `services/factor` | 8004 | 因子库(pandas-ta / Alpha101 / qlib)+ IC 有效性检验,只产出信号(已落地 D-11) | qlib + 自研 | +| `services/evolver` | 8005 | frozen dataset 上的单代策略演化、显式审批、owner 隔离、候选与费用审计 | FastAPI + paper evaluator | --- @@ -410,10 +417,11 @@ export const strategyLifecycle = createWorkflow({ id: 'strategy-lifecycle' }) .commit() ``` -### Tools 清单(当前实现) +### 代表性 Tools(当前实现) -> 本节原为 Phase C 的 MVP 设想,已更新为 **当前实际暴露的 tool 族**(2026-06-05)。 -> 权威清单以代码为准:`packages/orchestration/src/tools/` + `agents/orchestrator.ts`。 +> 本节原为 Phase C 的 MVP 设想,现只列 **当前代表性 tool 族与入口**(2026-08-27), +> 不逐项穷举 risk / scheduler / divination / sandbox 等运维或辅助 tools。权威清单以代码为准: +> `packages/orchestration/src/tools/` + `agents/orchestrator.ts`。 | Tool 族 | 代表 tool | 服务 / 路径 | 用途 | |---|---|---|---| @@ -425,9 +433,10 @@ export const strategyLifecycle = createWorkflow({ id: 'strategy-lifecycle' }) | **paper.\*(模拟盘)** | `paper.start_strategy` `.stop_strategy` `.list_strategy_runs` `.list_strategy_run_decisions` `.list_orders` `.list_positions` | paper:8002 | live runner 起停 / 运行状态 / 决策复盘 / 持仓 | | **trade.\***(下单护栏三件套) | `trade.create_plan` → `.approve_plan` → `.execute_plan`(+ `.reject_plan` `.get_plan`) | orchestration + paper `/orders/submit` | 两阶段批准,approval_token 一次性 + 5min TTL,`execute_plan` 是**唯一**有 side-effect 的下单 tool | | **swarm.\*** | `swarm.run_backtest_grid` | paper:8002 | 参数网格批量回测(grid-size-cap 守门) | +| **evolver.\*** | `evolver.run_evolution` `.get_evolution` `.get_candidate` `.abort_evolution` | evolver:8005 | 显式批准后启动 owner-scoped 演化,查询候选或取消;不自动 promote/start/order | | **mcp__\__\*** | `mcp__coingecko__*` … | 外部 MCP | 可插拔外部源,走同一套 hooks + permissions;默认只启零密钥端点 | -> **执行链路**:`trade.* → Hooks (PreToolUse) → Permission Engine → Plan Store → /orders/submit`。 +> **执行链路**:`trade.* → Hooks (PreToolUse) → Permission Engine → paper DB plan/approval → /orders/submit`。 > LLM 视野里**没有**直接 `submit_order` 路径——旧 `paper.submit_order_intent` / > `live.submit_order` 全部 `deny` 或 `modelInvocable:false`。详见 > [`docs/04-current-state.md`](./04-current-state.md)。 @@ -449,7 +458,9 @@ shared-py (internal Python lib: kernel / model / utils) data-service ◄─── paper-service(用于历史回放和实盘数据订阅) data-service ◄─── research-service(取行情给 analyst) data-service ◄─── factor-service(取行情给因子计算) -research-service ──► paper-service.submit_order_intent(决策落地) +data-service ◄─── evolver-service(冻结真实行情) +evolver-service ──► paper evaluator(复用审计、加载、回测与 fitness;不进入下单链路) +research-service ──► orchestration plan/exec(决策落地) ``` **禁止**的依赖(防止循环): @@ -457,10 +468,11 @@ research-service ──► paper-service.submit_order_intent(决策落地) - paper-service **不** import research-service(避免内核依赖 LLM) - factor-service **不** import paper-service(因子只产出信号) - data-service **不** import 任何其他服务(最底层) +- evolver-service **不**持久化 owner 明文 LLM key,也**不**自动调用 promote/start/order --- -## MVP 端到端流程 +## MVP 端到端流程(Phase C 历史示例) ``` 用户:"帮我研究 BTC 这周,建议好的话上模拟盘" @@ -504,7 +516,10 @@ research-service ──► paper-service.submit_order_intent(决策落地) --- -## 目录结构(Phase D 起 mkdir) +## 目录结构(Phase C 规划快照) + +该树不再代表当前仓库;现行目录以代码为准,新增了 `apps/dashboard`、`services/evolver`、 +完整 factor/research 以及自托管部署文件。 ``` inalpha/ @@ -561,7 +576,7 @@ inalpha/ --- -## Phase D 启动清单(下一轮工作) +## Phase D 启动清单(历史 · 已完成) 按这个顺序起 packages: @@ -598,4 +613,3 @@ inalpha/ - `services/paper` 内核单测覆盖 ≥70%(Clock / MessageBus / 状态机) - 一份 e2e 测试:跑通 backtest → start paper → wait fill → assert position - diff --git a/docs/04-current-state.md b/docs/04-current-state.md index 09184c70..6cce0ae8 100644 --- a/docs/04-current-state.md +++ b/docs/04-current-state.md @@ -1,10 +1,10 @@ -# 04 · 当前状态:Plan/Exec 闭环 + 工程护栏 + 因子库闭环 +# 04 · 当前状态:D-12 + E1 策略演化生产闭环 -> 状态:**D-12 因子库闭环完成(2026-06-11)**——因子血缘 + 衰减巡检 + monthly +> 状态:**D-12 因子库闭环 + E1 独立 Evolver 已落地(更新至 2026-08-27)**——因子血缘 + 衰减巡检 + monthly > 宏观 + 因子发现 L1,在 D-11(多市场模拟盘)/ D-10(web 搜索 + 财报基本面 + > 多市场数据)/ D-9(Plan/Exec 闭环 + LLM 自创策略 + 风控引擎)/ D-9.1a 基础上落地。 -> 下一里程碑:E2 多代演化(issue #7);E1 单代生产闭环已落地(见下), -> research-hub(issue #6)已于 2026-06-12 收口。 +> research-hub(issue #6)已于 2026-06-12 收口;E1 生产代码由 PR #159 合入 main, +> 当前分支继续冻结 LLM/定价审批快照与费用审计。下一里程碑:E2 best-parent 多代演化(issue #7)。 > > 本文回答的问题:**clone 仓库后,"现在到底做到哪里、决策链路长什么样"。** > 详细架构与设计取舍见 [`docs/03-kernel-design.md`](./03-kernel-design.md); @@ -30,7 +30,7 @@ sequenceDiagram participant R as Risk participant H as Hooks participant P as Permission - participant PS as Plan Store + participant PS as Paper DB Plan Store participant Paper as services/paper U->>O: "Open 0.01 BTC long" @@ -75,11 +75,14 @@ sequenceDiagram | Plan/Exec 三 tool | `packages/orchestration/src/tools/` | `trade-plan.ts`(`createTradePlan` / `approveTradePlan` / `executeTradePlan`) | | Hooks runner(5 类事件) | `packages/orchestration/src/hooks/` | `runner.ts` · `with-hooks.ts` · `matcher.ts`(`SessionStart` / `UserPromptSubmit` / `PreToolUse` / `PostToolUse` / `PostToolUseFailure` + Stop) | | Permission Engine(三态) | `packages/orchestration/src/permissions/` | `engine.ts` · `predicate.ts` · `defaults.ts`(YAML 化在 D-8b) | -| Plan Store(in-memory) | `packages/orchestration/src/plans/` | `store.ts`(含 approval_token 派发,一次性 + expire_at) | +| Plan/approval 持久化 | `services/paper/src/inalpha_paper/api/` · `storage/` | `trade_plans.py` 与 owner-scoped DB 查询;approval_token 一次性 + expire_at | | paper 单笔下单 endpoint | `services/paper/src/inalpha_paper/api/` | `orders.py` → `POST /orders/submit` | -| 3 个回测策略 | `services/paper/src/inalpha_paper/strategies/` | `buy_and_hold.py` · `sma_cross.py` · `mean_reversion.py` | +| 策略与评估内核 | `services/paper/src/inalpha_paper/strategies/` · `strategy_evaluation.py` | 内置 baseline/教学/adapter + 候选审计、回测与市场网格评估 | | paper 内核 | `services/paper/src/inalpha_paper/kernel/` · `execution/` | `clock.py` · `msgbus.py` · `risk_engine.py` · `execution_engine.py` · `order_executor.py` · `gateway.py` | -| data 服务(Binance) | `services/data/` | CCXT Binance → Postgres + TimescaleDB | +| data 多市场服务 | `services/data/` | CCXT / akshare / yfinance / FRED / web → Postgres + TimescaleDB | +| research + factor | `services/research/` · `services/factor/` | 三方研究辩论;因子血缘、IC、衰减与发现工作流 | +| E1 Evolver | `services/evolver/` | frozen bars + unified diff + owner-scoped 异步 run/slot + PostgreSQL 候选审计 | +| 认证控制台 | `apps/dashboard/` | 登录/session、agent 对话、逐用户 LLM 配置、演化/回测/runner/因子/风控看板 | --- @@ -145,14 +148,13 @@ sequenceDiagram - **baseline 自动并跑**:`runner.run_backtest` candidate 分支用 `asyncio.gather` 同时跑 candidate + `buy_and_hold` 同 bars/cash/fee;`BacktestResponse` 加 `baseline` 字段; alpha 判定 = `candidate.fitness` 显著高于 `baseline.fitness` -- **审批门**:`POST /strategy_candidates/{id}/promote` 端点 + orchestration 端 - `paper.promote_candidate` tool。MVP 阶段 permission 默认 `allow`,agent 自助闭环 - (前端 askUserChoice 还没接通,`ask` 会让 agent 撞墙);审批责任改由两道防御替代: - (1) orchestrator prompt 强制 agent 调前自检 `fitness > baseline` + 等用户明确指令; - (2) 后端硬校验 `fitness IS NOT NULL` + 当前 `status='candidate'`,并把 - `reason / promoted_by / promoted_at` 写到候选 `audit.promotion`。promote 仅做状态 - 切换;按行情 tick 调 `on_bar` 的 live runner 在 **D-11 已接入**(见下方 D-11 小节)。 - ADR-0018 askUserChoice 接通后回归 `ask` +- **审批门(D-9 当时 → 当前)**:`POST /strategy_candidates/{id}/promote` 端点 + + orchestration 端 `paper.promote_candidate` tool。D-9 MVP 曾因审批交互未接通临时使用 + permission `allow`;D-9.1b 起已经恢复 `ask`,首次调用登记 owner-scoped 待审批项,用户 + 明确同意后以相同操作身份重调才会执行。后端仍硬校验 `fitness IS NOT NULL` + 当前 + `status='candidate'`,并把 `reason / promoted_by / promoted_at` 写到候选 + `audit.promotion`。promote 仅做状态切换;按行情 tick 调 `on_bar` 的 live runner 在 + **D-11 已接入**(见下方 D-11 小节)。 - **RiskEngine 真接入 paper HTTP 层**(ADR-0006 / issue #3):lifespan 加载 `configs/risk_rules.toml` → 构造 async `RiskGuard`(独立于 backtest 的 sync `RiskEngine`)→ `POST /orders/submit` 与 `POST /plans/{id}/execute` 撮合前过 @@ -178,6 +180,32 @@ seed / buy-and-hold / 全候选同数据哈希评估、owner 隔离、数据库 演化需用户显式授权,不会在候选采纳后自动产生额外 LLM 费用。 (注:promoted 候选的 live runner 原列在此处,已在 D-11 落地,见下方 D-11 小节。) +### E1 生产闭环(2026-08-25 · PR #159) + +- **独立服务与部署**:`services/evolver:8005` 已接入 dev/selfhost/prod Compose、镜像构建、 + health check 和 CI;Paper/Evolver pytest 与 migration 测试进入主 CI。 +- **真实、可复现评估**:run 先按 `requested_as_of` 拉取并冻结已收盘 bars,保存 manifest 与 + dataset hash;seed、buy-and-hold baseline、候选共享同一数据和年化口径,数据错误 fail closed。 +- **异步持久化**:run/slot/candidate 全部落 PostgreSQL,支持 owner-scoped 列表/详情、幂等 + 创建、全局并发与账户 active 限制、abort、超时和终态收口。 +- **候选边界**:LLM 只返回 unified diff;应用后继续经过 AST audit、受限 loader、Strategy + contract 与独立回测子进程。该子进程有超时/内存限制,但不是 hardened container/VM。 +- **显式审批**:`evolver.run_evolution` 是 costful ask;审批按认证 owner 隔离,聊天文字、 + model output 或另一 turn 不能代替可信审批。Evolver 不自动 promote、start 或 order。 +- **Dashboard**:已有演化列表、run 详情、候选详情、取消、能力开关与多租户错误隔离。 + +### 当前分支收口:冻结 LLM 审批快照(migration 0041) + +- 编排层在展示审批前冻结 `config_id/provider/model/base_url/pricing/version/最大单候选估算`, + 生成稳定 `config_digest` 和 operation id;批准后 5 分钟 JWT 同时绑定 owner、operation 与 digest。 +- Evolver 创建 run 时校验审批 JWT 与 snapshot digest,再持久化非密钥 `llm_snapshot`;数据库 + check constraint 要求新写入具备完整快照,历史行不伪造元数据。 +- 执行时用短时、`token_use=evolver_credential` 且绑定 `config_id` 的 service JWT 调 Dashboard internal route,按 owner + config_id + 即时解密 API key;key 不写入 snapshot、run/candidate、异常或日志。 +- LLM 成功、diff 被拒和其它已发生调用的路径都记录 input/output/cache-hit tokens 与实际或 + 按冻结单价计算的 `llm_cost_usd`;`DASHBOARD_SERVICE_URL` 与 + `EVOLVER_LLM_TIMEOUT_S` 已进入环境模板和生产 Compose。 + --- ## D-10(2026-06-01)web 搜索 + 财报基本面 + 多市场数据源扩展 @@ -499,7 +527,7 @@ spot 仍严格 long-only(裸空 / 超卖翻空被守门拒),做空 / 杠 live runner follow-up(非阻塞,待开):轮询不感知交易时段(#48,休市空轮询浪费)、 LIMIT 单不跨 bar 挂单(#47,当前即时 IOC 语义);session 持仓 resume(#37.2)、 -#38 Phase F(沙盒子进程隔离 / service token audience)。 +#38 Phase F(沙盒子进程强化隔离;Evolver owner-key 凭证已按用途、owner 与 `config_id` 收窄)。 多市场日历 follow-up(非阻塞):盘前 / 盘后时段、指数映射表补全、深交所 XSHE / 印度 XNSE 精确化(当前分别复用 XSHG / XBOM)。 diff --git a/docs/designs/e1-production-closure.md b/docs/designs/e1-production-closure.md index a100eab4..40917399 100644 --- a/docs/designs/e1-production-closure.md +++ b/docs/designs/e1-production-closure.md @@ -3,13 +3,18 @@ Generated by /office-hours on 2026-08-18 Branch: feat/e1-evolution-production Repo: mirror29/inalpha -Status: APPROVED +Status: IMPLEMENTED IN MAIN (PR #159, 2026-08-25); deployment evidence tracked separately Mode: Builder ## Problem Statement E1 已验证真实异步状态机、失败路径、显式审批、幂等、owner 隔离和 Dashboard,但尚未在生产式环境产出一个成功候选。本地 Binance 上游不可达,当前分支还有 121 个文件差异,未经过完整代码审查、PR CI 和镜像实构建。此时进入 E2 会把基础链路问题和多代算法问题混在一起。 +> **实施更新(2026-08-27)**:审查与合并门已完成,PR #159 已进入 main;Evolver 镜像、 +> Compose、migration、Paper/Evolver pytest 和 Dashboard 演化界面均已接线。后续分支进一步 +> 实现冻结 LLM/定价审批快照、owner key 即时获取与 token/cost 审计。本文保留为当时的关闭门 +> 设计记录;新加坡部署与成功 smoke 证据仍须按下述标准单独留存,不能仅凭代码合并推定完成。 + ## What Makes This Cool 用户通过自然语言明确授权一次演化,系统冻结同一份 fresh 已收盘行情,在同一 dataset hash 上评估 seed、市场基准和候选,并留下可复核的源码、diff、审计、指标与费用链路。成功不是“生成了代码”,而是候选能解释自己从哪里来、用什么数据得分、为何没有自动进入交易。 @@ -74,17 +79,15 @@ E1 已验证真实异步状态机、失败路径、显式审批、幂等、owner GitHub Actions 在 main 合并后构建 Evolver 镜像;等待 merge SHA 构建成功,再钉该 SHA 经现有腾讯云 Docker Compose 链路发布。Dashboard 与 Mastra 通过容器内 `EVOLVER_SERVICE_URL` 使用服务,不新增分发渠道。 -## Next Steps +## Next Steps(实施后) -1. 立即对当前分支运行 `/review`,不是继续写 E2。 -2. 按 finding 拆分修复提交并复跑红线。 -3. 使用 `/ship` 创建 PR,检查 CI 与 auto-review。 -4. 合并部署后申请一次新的演化授权,完成线上成功冒烟。 -5. 记录 3–5 个真实 run 的成功率、拒绝分布、耗时和费用,再写 E2 计划。 +1. 合并冻结 LLM/定价快照与费用审计收口,复跑 migration、Paper/Evolver pytest、TS 测试和一致性检查。 +2. 部署 merge SHA 后申请一次新的演化授权,完成线上成功冒烟并保存证据包。 +3. 记录 3–5 个真实 run 的成功率、拒绝分布、耗时和费用,再写 E2 计划。 ## The Assignment -下一步只做一件事:对 `main...feat/e1-evolution-production` 运行高强度 `/review`,把 review 结论作为是否进入 PR 的门。 +当前只做 E1 收口与证据采集;在成功 smoke 和真实费用样本前,不扩展到复杂 E2 搜索。 ## What I noticed about how you think diff --git a/infra/.env.example b/infra/.env.example index 560c27be..9bd23e73 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -1,15 +1,16 @@ -# Copy to infra/.env and fill real values before `docker compose up`. +# Copy to infra/.env before `docker compose up`. These local defaults intentionally match +# the repository-root `.env.example`; change both files together if you override them. # All services 共用同一个 Postgres 实例(含 Mastra PostgresStore)。 # Postgres POSTGRES_USER=quant -POSTGRES_PASSWORD=changeme-in-real-env +POSTGRES_PASSWORD=devpass POSTGRES_DB=inalpha -POSTGRES_PORT=5432 +POSTGRES_PORT=5433 # Redis REDIS_PORT=6379 # Database URL —— alembic + 各 Python service 共用 # 注意:从宿主机连用 localhost;从其它 docker container 连改 host 为 postgres -DATABASE_URL=postgresql+psycopg://quant:changeme-in-real-env@localhost:5432/inalpha +DATABASE_URL=postgresql+psycopg://quant:devpass@localhost:5433/inalpha diff --git a/infra/.env.selfhost.example b/infra/.env.selfhost.example index 259144c5..4e784f0a 100644 --- a/infra/.env.selfhost.example +++ b/infra/.env.selfhost.example @@ -38,8 +38,15 @@ EVOLVER_JOB_MEM_GB=2 EVOLVER_LLM_TIMEOUT_S=120 DASHBOARD_SERVICE_URL=http://dashboard:3001 -# Each authenticated user configures their own LLM API key in the dashboard. -# Do not add provider API keys here as a shared fallback. +# Dashboard settings power the orchestrator and Evolver with per-owner credentials. +# Research still uses one deployment-level provider/key; configure this block only if deep_dive +# is needed, and treat it as a shared credential until per-owner propagation lands. +LLM_PROVIDER=deepseek +LLM_MODEL=deepseek-chat +DEEPSEEK_API_KEY= +ANTHROPIC_API_KEY= +OPENAI_API_KEY= +GEMINI_API_KEY= # Optional market-data credentials BINANCE_API_KEY= diff --git a/infra/README.md b/infra/README.md index 407989ea..9d442d25 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,73 +1,69 @@ -# infra +# Inalpha 基础设施 -容器与数据库的基础设施。 +本目录维护开发数据库、缓存和 Alembic migrations;完整自托管编排在仓库根目录。 ## Docker 自托管全栈 -完整自托管入口在仓库根目录运行,不使用本目录的开发数据库 Compose: - ```bash bash scripts/selfhost.sh init bash scripts/selfhost.sh up bash scripts/selfhost.sh create-user --email you@example.com ``` -它启动 PostgreSQL、Redis、迁移、四个 Python service、Mastra 与 Dashboard。Dashboard 仅绑定宿主机 `127.0.0.1:3001`;若要远程访问,应由部署者的 Caddy、Nginx 或 Tunnel 提供 HTTPS,并且只能代理 Dashboard。详细的用户级 LLM API key 配置与安全边界见根目录 README。 +完整栈包含 PostgreSQL、Redis、migration、五个 Python services(data / paper / research / +factor / evolver)、Mastra 与 Dashboard。Dashboard 只绑定宿主机 `127.0.0.1:3001`;远程访问 +应由部署者的 Caddy、Nginx 或 Tunnel 提供 HTTPS,并只代理 Dashboard。用户级 LLM key、 +逐用户 JWT 与 Evolver 即时凭证边界见根目录 README。 ## 开发数据库与缓存 ```bash cd infra -cp .env.example .env # 改 POSTGRES_PASSWORD +cp .env.example .env # 设置开发数据库密码 docker compose up -d -docker compose ps # 应看到 postgres / redis 都 healthy +docker compose ps # postgres / redis 应为 healthy ``` -## 跑数据库迁移 +模板的本地密码与端口和仓库根 `.env.example` 一致;如果修改 `POSTGRES_PASSWORD` 或 +`POSTGRES_PORT`,也要同步更新根 `.env` 中的 `DATABASE_URL`。 + +## 数据库迁移 -第一次: +首次安装与任何拉取新 migration 后都升级到当前 head;`scripts/dev.sh` 不会自动迁移。 ```bash cd infra/migrations -uv sync # 创建 .venv,安装 alembic + psycopg -uv run alembic upgrade head # 应用 0001_initial_schema +uv sync +uv run alembic upgrade head +uv run alembic current ``` -后续新增表 / 改字段: +新增 schema: ```bash -cd infra/migrations -uv run alembic revision -m "add foo column" # 生成新 version 文件 -# 编辑 versions/_add_foo_column.py 的 upgrade() / downgrade() +uv run alembic revision -m "add foo column" +# 编辑 versions/_add_foo_column.py 的 upgrade() / downgrade() uv run alembic upgrade head ``` +当前 schema 除 bars/ticks 等时序数据外,还承载账户/持仓/订单、trade plans 与审批、回测和 +live runner、factor/research 记录、用户与加密 LLM 配置,以及 evolution runs/candidates。 +不要在文档或脚本中假定固定 migration 编号或完整表清单,以 `alembic current` 与 `\dt` 为准。 + ## 验证 ```bash -# 进 postgres 看 timescaledb 装好了没 -docker compose exec postgres psql -U quant -d Inalpha -c "\dx" -# 应有 timescaledb 行 - -# 看表都建了没 -docker compose exec postgres psql -U quant -d Inalpha -c "\dt" -# 应看到 bars / ticks / strategies / backtest_runs / strategy_instances / -# orders / research_memory + alembic_version - -# 看时序表是不是 hypertable -docker compose exec postgres psql -U quant -d Inalpha -c \ +docker compose exec postgres psql -U quant -d inalpha -c "\dx" +docker compose exec postgres psql -U quant -d inalpha -c "\dt" +docker compose exec postgres psql -U quant -d inalpha -c \ "SELECT hypertable_name FROM timescaledb_information.hypertables" -# 应有 bars / ticks ``` -## 清理(小心,会删数据) - -```bash -docker compose down # 停容器但保留数据 volume -docker compose down -v # 连数据 volume 一起删(开发期重置可用) -``` +TimescaleDB extension 应存在;hypertable 至少包括 bars/ticks。架构和当前阶段分别见 +[`docs/01-architecture-overview.md`](../docs/01-architecture-overview.md) 与 +[`docs/04-current-state.md`](../docs/04-current-state.md)。 -## 参考 +## 清理 -- 表结构详细说明:`docs/decisions/0003-timeseries-db.md` -- 整体架构:`docs/03-kernel-design.md` +`docker compose down` 只停止容器并保留 volume。`docker compose down -v` 会永久删除开发 +数据库 volume,只能在明确要重建本地数据时使用。 diff --git a/packages/orchestration/README.md b/packages/orchestration/README.md index 190bcfd9..34d10de1 100644 --- a/packages/orchestration/README.md +++ b/packages/orchestration/README.md @@ -1,72 +1,71 @@ # @inalpha/orchestration -Mastra 编排层 —— 把后端 service 包装成 LLM agent 能调用的 tool。 +Mastra / TypeScript 编排层:把 Python services 和外部 MCP 包装成 agent tools,并统一执行 +身份传递、permissions、hooks、plan/exec、审批、调度、skills 和可观测性。 -## D-7 范围(当前轮) +## 当前职责 -- ✅ HTTP client 封装(调 `services/data` + `services/paper`) -- ✅ Tool 层(5 个:`data.get_bars` / `data.backfill_bars` / `paper.list_strategies` / - `paper.run_backtest` / `paper.health`) -- ✅ JWT 工具(mint / verify) -- ✅ Vitest 单测 + CLI smoke test(真服务 e2e) +- **Agent 路由**:orchestrator 协调 trader、risk、research-hub,并按市场分类选择数据源。 +- **Tool 族**:`data.*` / `web.*` / `factor.*` / `research.*` / `paper.*` / `trade.*` / + `evolver.*` / `swarm.*` / `skill.*` / `risk.*` / `scheduler.*` / `divination.*` / + `sandbox.*`,以及可插拔 `mcp____`。 +- **交易护栏**:`create_plan → approve_plan → execute_plan`;计划和审批事实由 paper DB + 持久化;模型只能转交审批 tool 返回的一次性 approval token,没有直下单 tool。 +- **演化入口**:`evolver.run_evolution` 在产生 LLM 费用前要求逐次授权;查询和取消 tools + 始终透传当前用户 JWT。Evolver 只产出候选,不自动 promote / start / order。 +- **运行时治理**:Pre/Post tool hooks、allow/ask/deny permissions、scheduler、agent eval、 + prompt cache、trace 和领域错误码透传。 -后续: +## 本地开发 -- D-8:起 Mastra `Agent` 实例,挂载 tool;接 CopilotKit / AG-UI 给前端用 -- D-8+:[ADR-0010 hooks](../../docs/decisions/0010-orchestration-hooks.md) / - [ADR-0011 permissions](../../docs/decisions/0011-permission-rules.md) / - [ADR-0012 plan-exec](../../docs/decisions/0012-plan-exec-separation.md) 落地 -- D-9+:[ADR-0014 prompt cache](../../docs/decisions/0014-prompt-cache-engineering.md) / - [ADR-0015 telemetry](../../docs/decisions/0015-agent-telemetry-standard.md) -- D-10+:[ADR-0009 MCP](../../docs/decisions/0009-mcp-as-tool-protocol.md) 接 broker - -## 开发 - -前置: +推荐从仓库根启动完整依赖: ```bash -# 1. 起 docker + 跑 alembic(D-1) -cd infra && docker compose up -d && cd migrations && uv sync && uv run alembic upgrade head - -# 2. 起两个 Python service -cd services/data && uv sync && uv run uvicorn inalpha_data.main:app --port 8001 & -cd services/paper && uv sync && uv run uvicorn inalpha_paper.main:app --port 8002 & +cd packages/orchestration && pnpm i && cd ../.. +for service in data paper research factor evolver; do + (cd "services/$service" && uv sync) +done +cp .env.example .env && cp infra/.env.example infra/.env +(cd infra && docker compose up -d) +(cd infra/migrations && uv sync && uv run alembic upgrade head) +bash scripts/dev.sh ``` -然后: +只开发编排层时: ```bash cd packages/orchestration -cp .env.example .env # JWT_SECRET 必须和服务端一致 pnpm install -pnpm test # vitest 单测(mock fetch) -pnpm typecheck # tsc --noEmit -pnpm smoke # 真服务 e2e:backfill → run_backtest → 打印报告 +pnpm dev +pnpm typecheck +pnpm vitest run ``` -## Skills(投研方法论按需加载) +服务地址、`JWT_SECRET`、LLM provider/model 与 `EVOLVER_SERVICE_URL` 统一从仓库根 `.env` +读取。用户对话转发用户 JWT;后台用途的 token 必须短时、用途限定并绑定必要 scope。 + +## Tool 设计约束 + +- tool 是 HTTP/MCP 的薄适配层:Zod 校验、身份传递、错误标准化,不复制 Python 业务逻辑。 +- description 必须写清“功能 + 何时用 + 何时不用 + 坑”。 +- 用户可见错误保留稳定 `code`,让 agent 可以分辨重试、降级与需人工处理的状态。 +- 新增高风险或有费用的操作时,先设计 owner scope、幂等键、approval 与领域审计事实。 +- 任何新增直下单路径、绕过 plan/exec,或让模型绕过 approve 获取 / 复用审批 token 的改动 + 都不接受。 -`skills//` 下放 AgentSkills 格式的方法论包(`SKILL.md` + YAML frontmatter + -`references/`)。frontmatter 的 `name`(必须 = 目录名,kebab-case)+ `description` -(≤1024 字符,意图模式描述,**不写死触发短语**)会进 orchestrator system prompt 的 -`` 清单;正文经 `skill.read` tool 按需加载。 +## Skills -新增 skill 检查单: +`skills//` 使用 AgentSkills 结构(`SKILL.md` + YAML frontmatter + 可选 +`references/`)。frontmatter 的 `name` 必须等于 kebab-case 目录名,`description` 按意图模式 +描述且不写死触发短语。 -1. 只放 `.md/.json/.txt` 文本;`scripts/` 不会被加载(信任边界) -2. 外来 skill 全文改写:市场无关化、"查数据"步骤映射到本仓库 tool(web.* / data.* / - factor.* / research.*)、保留上游 LICENSE + 写 ATTRIBUTION.md -3. 不引用仓库私有路径;`pnpm test`(tests/skills.test.ts 体检)+ - `bash ../../scripts/check-consistency.sh`(C7)必须过 -4. **新增/修改 skill 后必须重启 orchestration 进程**——清单进程内 memoize - (`getSkillManifestsCached`),mastra dev 只 watch 代码不 watch skills/*.md, - 不重启的话新 skill 不会出现在 `` 清单里 +新增或修改 skill 时: -## 设计原则 +1. 只加载 `.md/.json/.txt`;外来 skill 的 `scripts/` 不执行。 +2. 改写为市场无关的方法论,数据步骤映射到现有 tools,并保留 LICENSE/ATTRIBUTION。 +3. 运行 `pnpm vitest run` 与 `bash ../../scripts/check-consistency.sh`。 +4. 重启 orchestration;skill manifest 在进程内缓存,热更新不会刷新清单。 -- **薄包装**:tool = HTTP client 调用 + Zod schema 校验,**不带业务逻辑** -- **JWT 透传 / 服务签名两种模式**:用户对话场景 forward 用户 token;后台任务 / cron - 用 `mintServiceToken()` 自签 -- **错误码透传**:上游 `{code, message, details}` 原样回给 LLM,让模型基于错误码决策 -- **Tool description 写"何时用 / 何时不用 / 坑"**(详见 - [docs/05-tool-skill-discipline.md](../../docs/05-tool-skill-discipline.md)) +更完整的架构和当前阶段见 +[`docs/01-architecture-overview.md`](../../docs/01-architecture-overview.md) 与 +[`docs/04-current-state.md`](../../docs/04-current-state.md)。 diff --git a/scripts/dev.sh b/scripts/dev.sh index be088eb2..5439ae20 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -20,9 +20,9 @@ # 4111 mastra dev (默认端口) TCP connect # # 前置条件: -# - 已跑 `pnpm i` 和 `uv sync` -# - services/data 需要可达的 Postgres + .env 配置 (DATABASE_URL / BINANCE_*) -# - services/paper 在 D-8a 不强依赖 DB +# - packages/orchestration 已跑 `pnpm i`,五个 Python service 目录分别跑过 `uv sync` +# - 先用 infra/docker-compose.yml 启动 Postgres/Redis,并把 Alembic migration 升到 head +# - data / paper / factor / evolver 都依赖可达的 Postgres 与根 .env DATABASE_URL # - services/research 需要 LLM_API_KEY(默认 deepseek;LLM_PROVIDER=fake 时可空) # - services/evolver 强制 DATABASE_URL,E1 只允许单 worker diff --git a/services/data/README.md b/services/data/README.md index 203f07d0..2f1867c8 100644 --- a/services/data/README.md +++ b/services/data/README.md @@ -1,68 +1,41 @@ -# services/data +# Inalpha Data · 多市场数据服务 -行情数据接入 + 时序存储 + 历史查询。 +`services/data` 是 FastAPI 数据接入层(`:8001`):统一查询与缓存行情、财报、新闻、市场 +概览、成分股、汇率和永续合约数据,并把 bars/ticks 写入 PostgreSQL + TimescaleDB。 -## 当前能力(D-3 起步) +## 当前能力 -| Endpoint | 用途 | -|---|---| -| `GET /health` | 存活探活 + DB ping,无 auth | -| `GET /bars` | 从 TimescaleDB 查 K 线,需 JWT | -| `POST /backfill/bars` | 从 Binance 拉历史 K 线落库,需 JWT | +- **多市场路由**:Binance / CCXT(crypto)、akshare / BaoStock(A股、港股)、 + yfinance / Alpaca(美股与全球单股、指数)、FRED(宏观)。 +- **行情与时序**:`/bars`、`/ticker`、`/backfill/bars`、`/symbols/search`,支持 freshness + 与本地缓存策略。 +- **基本面与市场情报**:`/fundamentals`、`/news`、`/market/*`、`/constituents`。 +- **Web 与跨资产辅助数据**:`/web/search`、`/web/news`、`/web/fetch`、`/fx`、 + `/perp/funding`。 +- 除 `/health` 外的业务端点均要求用户 JWT;连接器失败按稳定错误码返回,不静默伪造数据。 + +关键目录: -后续(D-3+):WebSocket `/ticks/{symbol}` 实时 quote 推送。 +| 目录 | 职责 | +|---|---| +| `connectors/` | 市场、新闻、搜索与基本面连接器 | +| `api/` | HTTP 路由、输入校验与错误映射 | +| `storage/` | bars 与指数成分等持久化 | +| `venues.py` | venue / symbol 能力与市场路由 | +| `scheduler.py` | 数据侧周期任务 | -## 开发 +## 本地开发 -前置:先把 `infra/` 的 docker compose + alembic migration 跑起来。 +先按根 README 启动 `infra` Compose 并执行 Alembic migration,然后: ```bash cd services/data -cp .env.example .env # 至少改 DATABASE_URL(如果本机 postgres 不在 5433) uv sync --group dev -uv run pytest # 25 个左右测试 uv run uvicorn inalpha_data.main:app --reload --port 8001 -``` - -然后另开终端测: - -```bash -# 健康检查 -curl http://localhost:8001/health -# 回填最近 7 天的 BTC/USDT 1 小时 K 线 -JWT="$(...)" # 后续 D-7 起前端会自动管 token;现在手动签个测试 token -curl -X POST http://localhost:8001/backfill/bars \ - -H "Authorization: Bearer $JWT" \ - -H "Content-Type: application/json" \ - -d '{ - "venue": "binance", - "symbol": "BTC/USDT", - "timeframe": "1h", - "from_ts": "2026-05-14T00:00:00Z", - "to_ts": "2026-05-21T00:00:00Z" - }' - -# 查 K 线 -curl -G "http://localhost:8001/bars" \ - -H "Authorization: Bearer $JWT" \ - --data-urlencode "symbol=BTC/USDT" \ - --data-urlencode "from_ts=2026-05-14T00:00:00Z" \ - --data-urlencode "to_ts=2026-05-21T00:00:00Z" +uv run ruff check . +uv run pytest ``` -## 架构 - -- `connectors/binance.py` —— CCXT async 包装,公开接口(OHLCV)免 key -- `storage/bars.py` —— bars 表读写,psycopg 异步,ON CONFLICT 幂等 -- `api/{health,bars,backfill}.py` —— FastAPI 路由 -- 全部 middleware(请求日志 / 错误处理 / JWT 验签)走 `inalpha_shared` - -## 质量门 - -```bash -uv run pytest # 单元 + 集成(集成要 docker 起着) -uv run pytest -m "not integration" # 跳过集成 -uv run ruff check -uv run mypy src -``` +配置统一从仓库根 `.env` 读取。`DATABASE_URL` 与 `JWT_SECRET` 必填;Binance 公共行情无需 +交易 key,FRED 宏观因子需要 `FRED_API_KEY`,其余付费或认证连接器只在配置存在时启用。 diff --git a/services/evolver/README.md b/services/evolver/README.md index 46db6168..ed174a53 100644 --- a/services/evolver/README.md +++ b/services/evolver/README.md @@ -1,68 +1,93 @@ -# Inalpha 策略演化引擎 —— E1 单代闭环 +# Inalpha Evolver · E1 策略演化生产闭环 -## 架构 +`services/evolver` 是独立的 FastAPI 策略演化服务(`:8005`)。它冻结实验输入,调用当前 +owner 授权的 LLM 生成 unified diff,在受限加载器和独立回测子进程中评估候选,并把 run、 +slot、候选、费用与可复现元数据持久化到 PostgreSQL。 -``` -services/evolver/ -├── pyproject.toml -├── src/inalpha_evolver/ -│ ├── __init__.py # 版本 -│ ├── main.py # FastAPI 入口(port 8003) -│ ├── config.py # pydantic-settings 配置 -│ ├── exceptions.py # 统一异常定义 -│ ├── api/ -│ │ ├── routes.py # POST /runs / GET /runs/{id} / GET /candidates/{id} -│ │ └── schemas.py # Pydantic 请求/响应模型 -│ ├── governor/ -│ │ ├── loop.py # run_one_generation 主循环 -│ │ ├── seed.py # SMACrossStrategy 种子策略源码 -│ │ └── hint_generator.py # 4 条硬编码 hint 轮流 -│ ├── mutator/ -│ │ ├── diff_applier.py # unified diff 应用(带 fuzz match) -│ │ ├── llm_client.py # LLM 变异算子(包装 _shared/llm) -│ │ ├── mock_client.py # Mock 变异客户端(测试用) -│ │ └── prompt_templates.py # ~5KB 静态 system prompt + user prompt 构建 -│ ├── evaluator/ -│ │ ├── runner.py # 子进程回测评估器 -│ │ └── fitness.py # fitness 合成(薄封装 paper.compose_fitness) -│ ├── population/ -│ │ ├── candidate.py # 数据类(Candidate, EvolutionRun, EvaluationResult) -│ │ └── store.py # DB 持久化(E1 占位,E2 实现) -│ └── sandbox/ -│ ├── ast_audit.py # 薄封装 paper.audit_strategy_code -│ └── contract_check.py # 薄封装 paper.verify_strategy_contract -└── tests/ - ├── test_mutator.py - ├── test_evaluator.py - ├── test_sandbox.py - ├── test_population.py - ├── test_governor.py - └── test_e2e.py -``` +## 安全与产品边界 -## 依赖关系 +- 每次会产生 LLM 费用的 run 都需要绑定 `owner + operation_id + llm_config_digest` 的显式 + 审批;审批 JWT 有效期 5 分钟,幂等键稳定标识同一操作。 +- bars、数据 manifest/hash、种子源码、非密钥 LLM 配置和定价摘要在执行前冻结;baseline、 + seed 与候选使用同一份 frozen bars。 +- 用户 LLM API key 只在执行时通过 Dashboard 内部路由按 owner/config_id 获取,不进入 run + 配置、候选记录或日志。 +- 当前演化费用审批只为 `deepseek`、`openai`、`kimi`、`zhipu` 维护冻结计价表;其他 + provider 仍可用于普通对话,但启动演化会因缺少可审计定价而 fail closed。 +- 所有 run/candidate 查询都按认证 owner 隔离;全局并发与单账户 active run 数均有限制。 +- Evolver 只生成和评估候选,绝不会自动 promote、启动策略或下单。 +- AST 审计、受限动态加载、契约检查和回测子进程是当前防线;子进程并非 hardened container + 或 VM,不应把未知代码当作已完成强隔离。 +## 执行链路 + +```text +Dashboard / orchestration + → 冻结 LLM + pricing snapshot,取得逐次审批 + → POST /api/v1/runs(Idempotency-Key + X-Evolution-Approval) + → 冻结真实 bars + manifest/hash + → 解析 seed,跑同数据 baseline + → LLM 生成 unified diff + → diff 应用 → AST/loader/contract 校验 → 子进程回测 + → fitness 排序,持久化 token/cost/失败原因 + → 用户显式选择后,另走 paper promote / start / plan-exec ``` -services/_shared/llm/ (零项目内依赖) - └── services/evolver/ (依赖 _shared + _shared/llm + paper) + +核心目录: + +| 目录 | 职责 | +|---|---| +| `api/` | run 创建/列表/详情、candidate 详情、abort、审批与幂等校验 | +| `data/` | bars 拉取、质量校验、冻结文件、manifest 与 hash | +| `governor/` | seed 解析和单代演化流程 | +| `mutator/` | owner-scoped LLM 客户端、prompt 与 unified-diff 应用 | +| `sandbox/` | 复用 paper 的 AST 审计、受限加载与 Strategy 契约检查 | +| `evaluator/` | frozen dataset 回测、子进程资源限制与 fitness | +| `runtime/` | 异步 dispatcher、slot 并发、取消、超时与终态收口 | +| `storage/` | PostgreSQL run/candidate 持久化与 owner-scoped 查询 | +| `owner_llm.py` | 用短时、用途限定且绑定 `config_id` 的 service JWT 即时读取 owner 模型配置 | + +## HTTP API + +所有 `/api/v1/*` 端点都要求用户 JWT。 + +| 方法 | 路径 | 说明 | +|---|---|---| +| `POST` | `/api/v1/runs` | 创建或幂等复用 run,返回 `202`;额外要求审批与幂等 header | +| `GET` | `/api/v1/runs` | 分页列出当前 owner 的 run | +| `GET` | `/api/v1/runs/{run_id}` | 查看 run、slot 与候选摘要 | +| `GET` | `/api/v1/candidates/{candidate_id}` | 查看当前 owner 的候选代码、指标和审计结果 | +| `POST` | `/api/v1/runs/{run_id}/abort` | 请求取消 queued/running run | + +编排层对应暴露 `evolver.run_evolution`、`evolver.get_evolution`、 +`evolver.get_candidate`、`evolver.abort_evolution` 四个 tools。 + +## 配置与启动 + +主要环境变量统一放在仓库根 `.env`: + +| 变量 | 用途 | +|---|---| +| `DATABASE_URL` | run/candidate 持久化 | +| `DATA_SERVICE_URL` | 获取并冻结真实 bars | +| `DASHBOARD_SERVICE_URL` | 即时读取 owner LLM 配置的内部服务地址 | +| `JWT_SECRET` / `JWT_ALGORITHM` | 用户与 service JWT 验证 | +| `EVOLVER_POOL_SIZE` | PostgreSQL 连接池大小 | +| `EVOLVER_MAX_RUNNING_RUNS` | 服务级同时运行上限 | +| `EVOLVER_ACCOUNT_ACTIVE_LIMIT` | 单 owner active run 上限 | +| `EVOLVER_JOB_TIMEOUT_S` / `EVOLVER_RUN_TIMEOUT_S` | 单候选与整次 run 超时 | +| `EVOLVER_JOB_MEM_GB` | 回测子进程内存上限 | +| `EVOLVER_LLM_TIMEOUT_S` | 单次 LLM 变异超时 | + +```bash +cd infra/migrations && uv run alembic upgrade head && cd ../.. +cd services/evolver +uv sync +uv run uvicorn inalpha_evolver.main:app --port 8005 --reload + +uv run ruff check . +uv run pytest ``` -## 复用 paper 模块 - -| 复用模块 | paper 路径 | evolver 中位置 | -|----------|-----------|----------------| -| audit_strategy_code | paper.strategy_authoring.ast_audit | sandbox/ast_audit.py | -| verify_strategy_contract | paper.strategy_authoring.contract_check | sandbox/contract_check.py | -| load_strategy_class | paper.strategy_authoring.dynamic_loader | sandbox/contract_check.py | -| compose_fitness | paper.strategy_authoring.fitness | evaluator/fitness.py | -| run_engine_in_subprocess | paper.runner | evaluator/runner.py | -| BacktestReport | paper.engine.report | evaluator/fitness.py | -| periods_per_year | paper.engine.metrics | evaluator/fitness.py | - -## E1 验收标准 - -1. **闭环**:MockMutator + MockEvaluator → budget=4 → >=1 fitness>0 candidate -2. **沙盒有效**:5 种不安全场景全被拒 -3. **fitness 多目标**:公式与手算一致 -4. **回撤 veto**:DD>30% → -1e9 -5. **FastAPI 可达**:3 端点返回正确状态码 \ No newline at end of file +仓库级开发推荐直接运行 `bash scripts/dev.sh`。下一阶段 E2 只先增加 best-parent 多代选择与 +early stopping;MAP-Elites / Island Model 在拿到真实成功率、拒绝分布和费用样本后再评估。 diff --git a/services/evolver/src/inalpha_evolver/__init__.py b/services/evolver/src/inalpha_evolver/__init__.py index 94653f1e..912781d3 100644 --- a/services/evolver/src/inalpha_evolver/__init__.py +++ b/services/evolver/src/inalpha_evolver/__init__.py @@ -1,7 +1,7 @@ """Inalpha 策略演化引擎。 E1 范围:单代闭环 —— LLM 变异种子策略 → 三道沙盒 → 回测评估 → 落演化候选表。 -E2 扩展:多代 + MAP-Elites 网格 + 跨代 lineage。 +E2 先扩展 best-parent 多代选择 + early stopping;MAP-Elites / Island Model 后置。 """ -__version__ = "0.1.0" \ No newline at end of file +__version__ = "0.1.0" diff --git a/services/factor/README.md b/services/factor/README.md new file mode 100644 index 00000000..45cc374b --- /dev/null +++ b/services/factor/README.md @@ -0,0 +1,28 @@ +# Inalpha Factor · 因子库与有效性闭环 + +`services/factor` 是独立 FastAPI 因子服务(`:8004`)。它只计算、验证和版本化信号,不下单。 + +## 当前能力 + +- 79 个系统因子,来源覆盖 pandas-ta、Alpha101、qlib Alpha158 风格与 FRED 宏观。 +- `/compute`、`/score`、`/snapshot`:计算因子、IC/Rank IC 有效性与当前时点快照。 +- `/panel/score`、`/backtest/score`:截面与回测样本评分。 +- `/custom/score`:在受限表达式 DSL 中验证自定义因子。 +- `/candidates`:owner-scoped 因子提案、列表和人工 review;不会自动注册候选。 +- 血缘、去相关、衰减状态与 freshness 进入快照,供 Research 与 Paper 消费。 + +核心目录:`adapters/` 负责因子源,`engine.py` / `effectiveness.py` 负责计算与有效性, +`expression.py` 负责受限 DSL,`storage/` 负责候选持久化。 + +## 本地开发 + +```bash +cd services/factor +uv sync +uv run uvicorn inalpha_factor.main:app --reload --port 8004 + +uv run ruff check . +uv run pytest +``` + +配置统一读取仓库根 `.env`。宏观因子需要 `FRED_API_KEY`,不配置时价量因子仍正常工作。 diff --git a/services/paper/README.md b/services/paper/README.md index 3f2c1ff2..cbfc5456 100644 --- a/services/paper/README.md +++ b/services/paper/README.md @@ -1,47 +1,48 @@ -# services/paper +# Inalpha Paper · 回测、模拟盘与交易护栏 -回测 / 模拟盘 / 实盘三合一引擎。 +`services/paper` 是事件驱动量化内核(`:8002`)。回测与 live runner 共用 Strategy / Clock / +MessageBus / execution 模型;账户、订单、持仓、交易计划、候选、回测与运行记录统一写入 +PostgreSQL。当前只做模拟执行,不连接真钱经纪商。 -## D-4 范围(本轮):纯内存内核 +## 当前能力 -本轮**只**有内核抽象,**没有** Gateway / Engine / HTTP / 数据库接入。可单元测试, -无外部依赖。 +- **回测与稳健性**:单次回测、buy-and-hold baseline、时序 CV、参数邻域敏感性、多市场 + annualization、交易与 fitness 明细持久化。 +- **策略生命周期**:内置策略、research hint compose、LLM authored strategy 三道沙盒、 + candidate leaderboard 与显式 promote。 +- **Live runner**:只有 promoted 且 owner 匹配的候选可启动;按新 bar 自动运行,保存逐 bar + 决策,支持运行 TTL、单账户上限、错误分类与退避。 +- **Plan/Exec 下单护栏**:`/plans` create / approve / execute,审批 token 一次性且短 TTL; + `/orders/submit` 仍经过机器 RiskGuard,LLM 没有可绕过 plan 的直下单 tool。 +- **多市场账户与风控**:跨币种 cash / FX、spot/perp 约束、交易时段日历、cooldown、 + low-profit、max-drawdown、stop-loss 与 risk lock 审计。 -``` -src/inalpha_paper/ -├── kernel/ -│ ├── clock.py Clock (ABC) + LiveClock + TestClock -│ ├── msgbus.py MessageBus (pub/sub + endpoint,wildcard 匹配) -│ └── identifiers.py InstrumentId / ClientOrderId / VenueOrderId / StrategyId -├── model/ -│ ├── data.py QuoteTick / TradeTick / Bar (含 data_epoch,ADR-0013) -│ ├── orders.py Order + 7 状态机 (NEW → ... → FILLED/CANCELED/REJECTED) -│ ├── positions.py Position (含 generation,ADR-0013 CAS) -│ ├── events.py OrderEvent / PositionEvent 不可变事件 -│ └── commands.py SubmitOrderCommand / CancelOrderCommand -└── strategy/ - ├── actor.py 数据订阅 + 生命周期回调 - └── base.py Strategy (extends Actor) 加下单接口 -``` - -## 设计来源 +## 代表性 API -- **借鉴 Nautilus**:Clock 抽象 + MessageBus pub/sub + endpoint 双形态 + - `ts_event` / `ts_init` 双时间戳(见 [refs/nautilus.md §3 §4 §5](../../docs/refs/nautilus.md)) -- **借鉴 vnpy**:Gateway 抽象(后续 D-5 起)+ 全局拼接 ID 约定 -- **ADR-0013 落地**:`QuoteTick`/`Bar` 带 `data_epoch`,`Position` 带 `generation` +| 路径 | 用途 | +|---|---| +| `POST /backtest` · `/backtest/cv` · `/backtest/sensitivity` | 回测与稳健性评估 | +| `POST /strategy_candidates` · `GET /strategy_candidates/*` | 创作、查询与审计候选 | +| `POST /strategy_runs` · `/strategy_runs/{id}/stop` | 启停 live runner | +| `GET /strategy_runs/{id}/decisions` | 回放逐 bar 决策 | +| `POST /plans` · `/plans/{id}/approve` · `/plans/{id}/execute` | 交易计划与一次性审批 | +| `GET /accounts/me` · `/positions` · `/orders` | 账户、持仓与订单 | +| `GET /risk/rules` · `/risk/locks` | 风控配置与锁记录 | -## 后续 D-5 / D-6 +除 `/health` 与登录入口外,领域 API 都按认证 owner 隔离。核心实现分布在 `engine/`、 +`execution/`、`strategy_authoring/`、`storage/` 与 `api/`。 -- **D-5**:Gateway 抽象 + SimulatedExchange + Engine (Backtest/Live) + FastAPI 入口 -- **D-6**:第一个 SMA cross 策略 + 端到端:data → backtest → fill → position +## 本地开发 -## 开发 +先按根 README 启动开发数据库并把 migration 升到 head,然后: ```bash cd services/paper uv sync --group dev -uv run pytest # 应全部通过(纯内存,不需要 DB) -uv run ruff check src tests -uv run mypy src +uv run uvicorn inalpha_paper.main:app --reload --port 8002 + +uv run ruff check . +uv run pytest ``` + +完整交易信任边界与当前阶段见 [`docs/04-current-state.md`](../../docs/04-current-state.md)。 diff --git a/services/research/README.md b/services/research/README.md index 8d87c582..7244e9f6 100644 --- a/services/research/README.md +++ b/services/research/README.md @@ -1,60 +1,43 @@ -# services/research +# Inalpha Research · 多视角研究与三方辩论 -LLM 多 agent 决策(TradingAgents 风格)—— D-8b 起步。 +`services/research` 是 FastAPI 研究服务(`:8003`)。`POST /deep_dive` 先并行运行核心 +analysts,再在观点存在分歧时触发 bull / bear / risk 辩论,最后由 manager 生成结构化、 +可回放的 `ResearchPlan`。 -## 当前能力 +## 当前链路 -| Endpoint | 用途 | -|---|---| -| `GET /health` | 存活探活,返当前 LLM provider | -| `POST /deep_dive` | 跑一次完整研究链路:2 个 analyst 并行 + research manager 综合 → `ResearchPlan` | - -## 架构 - -``` +```text DeepDiveRequest - ↓ -analysts/{technical,fundamental} (asyncio.gather 并行) - ↓ AnalystBrief x N -ResearchManager.synthesize() - ↓ -ResearchPlan + → 预取 bars + factor snapshot + → technical / fundamental / sentiment / risk / macro / valuation 并行 + → 可选 Buffett / Lynch / Wood / Burry / Druckenmiller / Marks 人格 + → 有分歧时 bull → bear → risk,支持软早停与总超时 + → manager 综合 briefs + debate log + → ResearchPlan(factors / signals / strategy_hint / trigger / stop_reason) ``` -- `technical`:吃 K 线 + 简单指标(SMA / RSI / 涨跌幅),LLM 出短期立场 -- `fundamental`:D-8b LLM-only(无外部数据),出中长期 thesis;D-9+ 接 sentiment / news -- `manager`:LLM 综合 → rating / thesis / risks / suggested_action / horizon +- 单个 analyst 失败不会抹掉其他视角,失败 brief 会明确标记后交给 manager 综合。 +- `as_of` 是严格研究截止点;返回值保留 briefs、辩论轮次、触发与停止原因供审计。 +- 数据来自 `services/data`,当前有效因子来自 `services/factor`;JWT 沿调用链透传。 +- 当前 research service 读取部署级 `LLM_PROVIDER` / `LLM_MODEL` 与对应 provider key; + per-owner Dashboard key 尚未透传到本服务,部署者需把这一限制视为当前多租户边界。 -LLM 抽象: +## HTTP API -- `DeepSeekLLMClient`:走 OpenAI 兼容 API(DeepSeek 同协议) -- `FakeLLMClient`:测试 mock,按 system prompt 子串选预设响应 +| 方法 | 路径 | 用途 | +|---|---|---| +| `GET` | `/health` | 服务与 provider 探活 | +| `POST` | `/deep_dive` | 执行完整研究链路,返回 `ResearchPlan` | -## 开发 +## 本地开发 ```bash cd services/research -cp .env.example .env # 至少配 LLM_API_KEY(DeepSeek key) uv sync --group dev -uv run pytest # 全部 fake LLM,不烧 token uv run uvicorn inalpha_research.main:app --reload --port 8003 -``` - -测真 LLM(烧 token): -```bash -LLM_PROVIDER=deepseek LLM_API_KEY=sk-xxx \ - uv run pytest -m integration # D-9 起加 integration mark +uv run ruff check . +uv run pytest # 默认 fake LLM,不产生调用费用 ``` -## 接 orchestration - -`packages/orchestration` 通过 `research.deep_dive` tool 调本服务的 POST /deep_dive。 -JWT 透传走 `inalpha_shared.auth`,跟 data / paper 同套机制。 - -## 后续 D-9+ - -- 加 sentiment / news analyst(接 X / Reddit / RSS) -- bull vs bear 辩论(Mastra workflow `.dowhile`) -- LLM 调用缓存(ADR-0014 prompt cache) -- 真实成本 / token 计数 telemetry(ADR-0015) +真实模型测试会产生费用,只应在显式设置 provider/key 并主动运行 integration 标记时执行。 From d629241ad23cf8c40d5988b22928b1818f788b50 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 27 Aug 2026 19:00:24 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(evolver):=20=E5=8A=A0=E5=9B=BA=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E6=8E=88=E6=9D=83=E3=80=81=E8=AE=A1=E4=BB=B7=E4=B8=8E?= =?UTF-8?q?=E5=AE=A1=E6=89=B9=E9=97=AD=E7=8E=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + AGENTS.md | 2 + README.md | 13 +- README.zh-CN.md | 13 +- apps/dashboard/messages/en.json | 8 +- apps/dashboard/messages/zh.json | 8 +- apps/dashboard/src/app/api/activity/route.ts | 47 ++++- .../internal/llm-config/[id]/route.test.ts | 72 ++++++- .../app/api/internal/llm-config/[id]/route.ts | 58 +++++- .../permissions/[id]/respond/route.test.ts | 45 +++++ .../app/api/permissions/[id]/respond/route.ts | 33 ++++ .../src/components/activity/ActivityFeed.tsx | 57 +++++- apps/dashboard/src/lib/types.ts | 3 + .../src/lib/user-preferences.test.ts | 68 +++++++ docs/04-current-state.md | 16 +- infra/.env.prod.example | 3 + infra/.env.selfhost.example | 2 + infra/README.md | 4 + infra/docker-compose.prod.yml | 3 + infra/migrations/tests/test_migration_0041.py | 100 +++++++++- .../versions/0041_evolution_llm_snapshot.py | 30 ++- packages/orchestration/src/clients/evolver.ts | 2 + .../orchestration/src/hooks/with-hooks.ts | 6 +- .../mastra/llm/evolution-credential-grant.ts | 50 +++++ .../src/mastra/llm/evolution-snapshot.ts | 47 ++++- .../orchestration/src/tools/evolver-shared.ts | 8 + packages/orchestration/src/tools/evolver.ts | 1 + .../tests/evolution-snapshot.test.ts | 67 +++++++ .../tests/evolver-client.test.ts | 49 +++++ .../tests/permissions-pending.test.ts | 34 +++- services/evolver/README.md | 32 +++- services/evolver/pyproject.toml | 5 +- .../evolver/src/inalpha_evolver/__init__.py | 2 +- .../src/inalpha_evolver/api/run_routes.py | 5 + .../src/inalpha_evolver/api/schemas.py | 40 ++++ .../src/inalpha_evolver/mutator/llm_client.py | 6 + .../evolver/src/inalpha_evolver/owner_llm.py | 47 +++-- .../src/inalpha_evolver/runtime/executor.py | 2 + .../src/inalpha_evolver/runtime/finalizer.py | 16 ++ .../src/inalpha_evolver/storage/runs.py | 18 +- services/evolver/tests/test_api_contract.py | 24 ++- services/evolver/tests/test_approval.py | 55 +++++- services/evolver/tests/test_e2e.py | 1 + .../evolver/tests/test_mutator_pricing.py | 58 +++++- services/evolver/tests/test_owner_llm.py | 177 +++++++++++++++--- .../evolver/tests/test_runtime_finalizer.py | 41 ++++ .../evolver/tests/test_storage_integration.py | 1 + services/evolver/uv.lock | 69 ++++++- 48 files changed, 1322 insertions(+), 130 deletions(-) create mode 100644 apps/dashboard/src/app/api/permissions/[id]/respond/route.test.ts create mode 100644 apps/dashboard/src/app/api/permissions/[id]/respond/route.ts create mode 100644 apps/dashboard/src/lib/user-preferences.test.ts create mode 100644 packages/orchestration/src/mastra/llm/evolution-credential-grant.ts diff --git a/.env.example b/.env.example index 8f027942..ae529dcc 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,10 @@ EVOLVER_JOB_MEM_GB=2 EVOLVER_LLM_TIMEOUT_S=120 # Evolver 仅用该地址按 owner/config_id 解析既有加密凭据;不会持久化明文 key。 DASHBOARD_SERVICE_URL=http://localhost:3001 +# Ed25519 DER 的 base64:私钥仅给 orchestration,公钥给 Dashboard 验证一次性 grant。 +# 生成方式见 services/evolver/README.md;生产 compose 会显式从 Evolver 环境移除私钥。 +EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64= +EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64= # factor 服务可选项(ADR-0043):qlib Alpha158 风格因子纯 pandas 本地算,默认开; # 设 false 可整源关闭。snapshot top-N 去相关阈值默认 0.85(1.0 = 关闭去相关) diff --git a/AGENTS.md b/AGENTS.md index 5f24f706..1cab3c15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,8 @@ done # 配置统一 .env(所有 service 共享根目录一份 .env) cp .env.example .env # 在 .env 里填 LLM_PROVIDER + 对应 *_API_KEY # 详见 README.md §Quick Start 的 provider/model 表 + # 运行 Evolver 还需生成 Ed25519 grant 公私钥 + # 见 services/evolver/README.md §配置与启动 # 启动开发 DB 并把 schema 升到最新(dev.sh 不会自动做这两步) cp infra/.env.example infra/.env # 与根 .env.example 的 DB 默认值一致 diff --git a/README.md b/README.md index c76bc755..680a3480 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ Where each capability stands today. Live module inventory and the end-to-end dec | ✅ Shipped | Research → strategy → backtest lineage | D-8c | `deep_dive → compose_strategy → run_backtest` with `research_id` / `backtest_id` threaded through | | ✅ Shipped | LLM-authored strategies — E1 MVP | D-9 | three sandbox gates (AST · subprocess · `Strategy` contract) + multi-objective fitness + baseline auto-run | | ✅ Shipped | Strategy evolution — E1 production loop | E1 | `services/evolver:8005` · explicit cost-bearing approval · unified-diff mutation · frozen dataset/hash · seed/baseline/candidates evaluated on the same bars · owner-scoped async run/slot state | -| ⏭️ In flight | Frozen LLM approval snapshot | E1 closure | approval binds owner + operation ID + model/pricing digest · encrypted owner credential resolved just-in-time · per-slot token/cost accounting, including rejected mutations | +| ✅ Shipped | Frozen LLM approval snapshot | E1 closure | Dashboard approve/deny · owner/operation/model/pricing binding · Ed25519 one-time credential grant · per-slot token/cost accounting, including rejected mutations | | ✅ Shipped | Risk engine at the HTTP boundary | D-9 | declarative `risk_rules.toml` · pre-trade `enforce` · `risk_locks` table with independent commit | | ✅ Shipped | Bull / bear researcher debate | D-9 | opposing-stance researchers under `services/research` | | ✅ Shipped | Scheduler / cron agent mode | D-9 | `scheduler_jobs` + advisory lock + `/api/scheduler/*` management plane | @@ -348,6 +348,12 @@ Defaults pick each vendor's **current flagship** as of 2026-05. Override with `L Override the default model by setting `LLM_MODEL=...` in the same file. Mastra and `services/research` both read this one file — no per-service config to juggle. +If you want to run Evolver, also generate its Ed25519 credential-grant keypair and place the DER +base64 values in `EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64` / `EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64`. +The exact commands are in [`services/evolver/README.md`](services/evolver/README.md). Evolution is +fail-closed without these keys and currently accepts only the priced default model for each of +DeepSeek, OpenAI, Kimi, and Zhipu; ordinary chat can still use other models or custom proxies. + > Already have keys in `services/*/.env` or `packages/orchestration/.env` from earlier? Those still work as cwd-level overrides while you migrate. Once you copy them up into the root `.env`, the per-service files can be deleted. **Optional · FRED key for macro factors.** The factor library's macro factors (`macro.*` — rates, term & credit spreads, CPI, payrolls, real-economy, sentiment) read FRED data via `venue=fred`. Set `FRED_API_KEY` in `.env` to enable them — it's [free and instant](https://fred.stlouisfed.org/docs/api/api_key.html). Without a key the connector simply isn't registered and macro factors degrade gracefully (price/volume factors are unaffected). Note: macro factors are computed **only at `timeframe=1d/1wk`** — they're filtered out on intraday bars (monthly series would be a step function), so request `1d` to see them. @@ -386,7 +392,7 @@ pnpm i # first time only pnpm dev # → http://localhost:3001 ``` -No extra config — the console reads the repo-root `.env` directly (backend URLs + `JWT_SECRET` +No per-app config — the console reads the repo-root `.env` directly (backend URLs + `JWT_SECRET` are inherited), so as long as the services from step 4 are up, it just connects. It ships with **dark / light themes** (a terminal "Vermilion" aesthetic — see [`apps/dashboard/design.md`](apps/dashboard/design.md)) and an `en / 中` switcher in the sidebar. @@ -397,7 +403,8 @@ and an `en / 中` switcher in the sidebar. > The orchestrator and an explicitly approved `services/evolver` run can consume your owner-scoped > LLM key; `services/research` currently uses the deployment-level provider/key, and > `services/paper` never calls an LLM directly. Evolver resolves the encrypted credential just in -> time and stores only the frozen non-secret config/pricing snapshot. +> time through an owner/operation-bound, one-time credential grant and stores only the frozen +> non-secret config/pricing snapshot after that grant is consumed. > Prefer the manual multi-terminal flow, or want the low-level live > trace (the `mastra dev` playground at )? See [`AGENTS.md §4`](AGENTS.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index c53c7cf5..07ee71e9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -231,7 +231,7 @@ Inalpha 把*调度*和*算力*分开:agent runtime 负责扇出网格、聚合 | ✅ 已上线 | 研究 → 策略 → 回测 lineage | D-8c | `deep_dive → compose_strategy → run_backtest` 全链路串 `research_id` / `backtest_id` | | ✅ 已上线 | LLM 自创策略 — E1 MVP | D-9 | 三道沙盒(AST 审计 / 子进程 / `Strategy` 协议契约) + 多目标 fitness + baseline 自动并跑 | | ✅ 已上线 | 策略演化 — E1 生产闭环 | E1 | `services/evolver:8005` · 计费动作显式审批 · unified-diff 变异 · 冻结数据集/hash · seed/baseline/candidate 同 bars 评估 · owner 隔离异步 run/slot 状态机 | -| ⏭️ 进行中 | 冻结 LLM 审批快照 | E1 收口 | 审批绑定 owner + operation ID + 模型/计价摘要 · 加密 owner 凭据按需解析 · 被拒变异也记 token/费用 | +| ✅ 已上线 | 冻结 LLM 审批快照 | E1 收口 | Dashboard 批准/拒绝 · owner/operation/模型/计价绑定 · Ed25519 一次性凭据 grant · 被拒变异也记 token/费用 | | ✅ 已上线 | 风控引擎落到 HTTP 边界 | D-9 | 声明式 `risk_rules.toml` · 撮合前 `enforce` · `risk_locks` 表(独立 commit) | | ✅ 已上线 | Bull / Bear 研究员辩论 | D-9 | `services/research` 立场对抗研究员 | | ✅ 已上线 | Scheduler / cron agent 模式 | D-9 | `scheduler_jobs` + advisory lock + `/api/scheduler/*` 管理面 | @@ -346,6 +346,12 @@ cp .env.example .env 要换模型?把 `LLM_MODEL=...` 一起填。Mastra 和 `services/research` 共读这一份配置——不再需要在每个 service 各自维护 .env。 +如需运行 Evolver,还要生成 Ed25519 凭据授权密钥对,把 DER base64 分别填入 +`EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64` / `EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64`;命令见 +[`services/evolver/README.md`](services/evolver/README.md)。缺少密钥时演化会 fail closed。 +当前演化只接受 DeepSeek、OpenAI、Kimi、智谱各自已有冻结计价的默认模型;普通对话仍可使用 +其他模型或自定义代理。 + > 旧用户 `services/*/.env` / `packages/orchestration/.env` 里已填的值仍作为 cwd-level fallback 生效(迁移期友好)。把它们合并到根 `.env` 后即可删掉。 **可选 · 宏观因子需要 FRED key。** 因子库的宏观因子(`macro.*`——利率、期限/信用利差、CPI、就业、实体经济、情绪)通过 `venue=fred` 读 FRED 数据。在 `.env` 里设 `FRED_API_KEY` 即可启用,[免费、秒发](https://fred.stlouisfed.org/docs/api/api_key.html)。不配 key 时 connector 不注册,宏观因子优雅降级(价量因子不受影响)。注意:宏观因子**仅在 `timeframe=1d/1wk` 计算**——intraday bar 上会被过滤(月频序列会变成阶梯函数),所以要看宏观因子请用 `1d`。 @@ -382,7 +388,7 @@ pnpm i # 仅首次 pnpm dev # → http://localhost:3001 ``` -无需额外配置 —— 控制台直接读仓库根的 `.env`(后端 URL + `JWT_SECRET` 都继承),只要第 4 步的 +无需单独配置控制台 —— 它直接读仓库根的 `.env`(后端 URL + `JWT_SECRET` 都继承),只要第 4 步的 service 起着,它就能连上。内置**黑白双主题**(终端「印章 / Vermilion」美学,详见 [`apps/dashboard/design.md`](apps/dashboard/design.md))与侧栏 `en / 中` 切换。 @@ -390,7 +396,8 @@ service 起着,它就能连上。内置**黑白双主题**(终端「印章 / > orchestrator 与经显式审批的 `services/evolver` run 会消耗 owner 自己的 LLM key; > `services/research` 当前使用部署级 provider/key,`services/paper` 从不直接调用 LLM。Evolver -> 只在执行时临时解析加密凭据,run 内只存冻结的非敏感配置/计价快照。想用多个独立 terminal 手动起,或看底层 live trace(`mastra dev` +> 通过绑定 owner/operation 且一次性消费的短效 grant 临时解析加密凭据;grant 兑换后清除, +> run 内只保留冻结的非敏感配置/计价快照。想用多个独立 terminal 手动起,或看底层 live trace(`mastra dev` > playground )?见 [`AGENTS.md §4`](AGENTS.md)。 ### 6 · 试着问几句 diff --git a/apps/dashboard/messages/en.json b/apps/dashboard/messages/en.json index 5b219f0f..effd80f8 100644 --- a/apps/dashboard/messages/en.json +++ b/apps/dashboard/messages/en.json @@ -249,7 +249,13 @@ }, "empty": "No activity yet.", "emptyFiltered": "No {kind} events.", - "sourceDown": "{sources} unavailable — those events are missing from the feed.", + "sourceDown": "{sources} unavailable — those events are missing from the feed.", + "approval": { + "allow": "Approve", + "deny": "Deny", + "failed": "Retry", + "working": "Saving…" + }, "source": { "scheduler": "Scheduler", "permissions": "Approvals", diff --git a/apps/dashboard/messages/zh.json b/apps/dashboard/messages/zh.json index f7c7f0df..1174327c 100644 --- a/apps/dashboard/messages/zh.json +++ b/apps/dashboard/messages/zh.json @@ -249,7 +249,13 @@ }, "empty": "暂无活动。", "emptyFiltered": "没有「{kind}」类事件。", - "sourceDown": "{sources} 不可用 —— 这些事件没进时间线。", + "sourceDown": "{sources} 不可用 —— 这些事件没进时间线。", + "approval": { + "allow": "批准", + "deny": "拒绝", + "failed": "请重试", + "working": "提交中…" + }, "source": { "scheduler": "定时任务", "permissions": "审批", diff --git a/apps/dashboard/src/app/api/activity/route.ts b/apps/dashboard/src/app/api/activity/route.ts index d03ca730..84e63166 100644 --- a/apps/dashboard/src/app/api/activity/route.ts +++ b/apps/dashboard/src/app/api/activity/route.ts @@ -28,7 +28,13 @@ interface SchedulerJobsResp { schedulerRunning: boolean; } interface PendingResp { - pending: Array<{ requestId: string; toolName: string; createdAt: string }>; + pending: Array<{ + requestId: string; + toolName: string; + toolInput: unknown; + createdAt: string; + deadline: string; + }>; } /** /permissions/history 一行 —— 审批审计终态(mastra Postgres,重启不丢)。 */ interface ApprovalHistoryResp { @@ -148,15 +154,19 @@ export async function GET() { if (pendingR.status === "fulfilled") { pendingCount = pendingR.value.pending.length; for (const p of pendingR.value.pending) { + const approval = summarizeApprovalInput(p.toolInput); events.push({ id: `perm:${p.requestId}`, kind: "permission", ts: p.createdAt, title: p.toolName, - detail: "awaiting approval", + detail: approval.detail, outcome: "pending", tone: "gold", - href: null, + href: "/activity", + stats: approval.stats, + approvalRequestId: p.requestId, + approvalDeadline: p.deadline, }); } } else { @@ -439,6 +449,37 @@ export async function GET() { }); } +/** Extracts only non-secret, user-decision fields from a pending tool input. */ +function summarizeApprovalInput(input: unknown): { + detail: string; + stats?: Array<{ text: string; tone: "gold" }>; +} { + if (!input || typeof input !== "object") return { detail: "awaiting approval" }; + const record = input as Record; + const request = + record.request && typeof record.request === "object" + ? (record.request as Record) + : record; + const snapshot = + record.llm_snapshot && typeof record.llm_snapshot === "object" + ? (record.llm_snapshot as Record) + : undefined; + const pricing = + snapshot?.pricing && typeof snapshot.pricing === "object" + ? (snapshot.pricing as Record) + : undefined; + const budget = typeof request.budget === "number" ? request.budget : undefined; + const unitCost = + typeof pricing?.estimated_max_usd_per_candidate === "number" + ? pricing.estimated_max_usd_per_candidate + : undefined; + const model = typeof snapshot?.model === "string" ? snapshot.model : undefined; + const detail = ["awaiting explicit approval", model].filter(Boolean).join(" · "); + return budget && unitCost + ? { detail, stats: [{ text: `≤ $${(budget * unitCost).toFixed(4)}`, tone: "gold" }] } + : { detail }; +} + function orderTone(status: string): ActivityTone { const s = status.toUpperCase(); if (s === "FILLED") return "bull"; diff --git a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts index f85a3a23..26309bde 100644 --- a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts +++ b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts @@ -1,33 +1,48 @@ +import { generateKeyPairSync } from "node:crypto"; + import { SignJWT } from "jose"; import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { decryptUserApiKey } from "@/lib/user-preferences"; +import { getPool } from "@/lib/db"; import { GET } from "./route"; vi.mock("@/lib/user-preferences", () => ({ decryptUserApiKey: vi.fn(), })); +vi.mock("@/lib/db", () => ({ getPool: vi.fn() })); -const TEST_SECRET = "dashboard-route-test-secret-at-least-32-bytes"; +const TEST_KEYS = generateKeyPairSync("ed25519"); +const OTHER_KEYS = generateKeyPairSync("ed25519"); +const PUBLIC_KEY_B64 = TEST_KEYS.publicKey.export({ format: "der", type: "spki" }).toString("base64"); const mockedDecryptUserApiKey = vi.mocked(decryptUserApiKey); +const mockedGetPool = vi.mocked(getPool); /** Mints an isolated service credential token for this route test. */ async function token( overrides: Record = {}, + options: { otherKey?: boolean; issuedAt?: number | null; expiresAt?: number } = {}, ): Promise { const now = Math.floor(Date.now() / 1_000); - return await new SignJWT({ + let builder = new SignJWT({ token_use: "evolver_credential", config_id: "config-1", + provider: "deepseek", + operation_id: "operation-1", + llm_config_digest: "a".repeat(64), ...overrides, }) - .setProtectedHeader({ alg: "HS256" }) + .setProtectedHeader({ alg: "EdDSA" }) .setSubject("user:alice") - .setIssuedAt(now) - .setExpirationTime(now + 300) - .sign(new TextEncoder().encode(TEST_SECRET)); + .setAudience("inalpha-dashboard-credential") + .setJti("11111111-1111-4111-8111-111111111111"); + if (options.issuedAt !== null) { + builder = builder.setIssuedAt(options.issuedAt ?? now); + } + builder = builder.setExpirationTime(options.expiresAt ?? now + 300); + return await builder.sign(options.otherKey ? OTHER_KEYS.privateKey : TEST_KEYS.privateKey); } /** Calls the dynamic route with a resolved Next.js params promise. */ @@ -40,8 +55,11 @@ async function callRoute(authorization?: string, id = "config-1") { } beforeEach(() => { - vi.stubEnv("JWT_SECRET", TEST_SECRET); + vi.stubEnv("EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64", PUBLIC_KEY_B64); mockedDecryptUserApiKey.mockReset(); + mockedGetPool.mockReturnValue({ + query: vi.fn().mockResolvedValue({ rowCount: 1 }), + } as never); }); describe("internal owner LLM credential route", () => { @@ -53,6 +71,46 @@ describe("internal owner LLM credential route", () => { expect(mockedDecryptUserApiKey).not.toHaveBeenCalled(); }); + it("rejects invalid, expired, or overlong service credentials", async () => { + const now = Math.floor(Date.now() / 1_000); + const requests = [ + callRoute(`Bearer ${await token({ token_use: "session" })}`), + callRoute(`Bearer ${await token({}, { issuedAt: null })}`), + callRoute(`Bearer ${await token({}, { issuedAt: now - 3_700, expiresAt: now + 1 })}`), + callRoute(`Bearer ${await token({}, { issuedAt: now + 60, expiresAt: now + 120 })}`), + callRoute(`Bearer ${await token({}, { issuedAt: now - 20, expiresAt: now - 10 })}`), + callRoute(`Bearer ${await token({}, { otherKey: true })}`), + ]; + + expect((await Promise.all(requests)).map((response) => response.status)).toEqual([ + 403, + 401, + 403, + 403, + 401, + 401, + ]); + expect(mockedDecryptUserApiKey).not.toHaveBeenCalled(); + }); + + it("consumes each signed credential grant only once", async () => { + const query = vi + .fn() + .mockResolvedValueOnce({ rowCount: 1 }) + .mockResolvedValueOnce({ rowCount: 0 }); + mockedGetPool.mockReturnValue({ query } as never); + mockedDecryptUserApiKey.mockResolvedValue({ + id: "config-1", + provider: "deepseek", + api_key: "owner-key", + } as never); + const grant = await token(); + + expect((await callRoute(`Bearer ${grant}`)).status).toBe(200); + expect((await callRoute(`Bearer ${grant}`)).status).toBe(409); + expect(mockedDecryptUserApiKey).toHaveBeenCalledTimes(1); + }); + it("returns only the requested owner's decrypted config without caching", async () => { mockedDecryptUserApiKey.mockResolvedValue({ id: "config-1", diff --git a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts index f088dfed..12180346 100644 --- a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts +++ b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts @@ -1,14 +1,18 @@ +import { createPublicKey } from "node:crypto"; + import { jwtVerify } from "jose"; import { NextRequest, NextResponse } from "next/server"; import { decryptUserApiKey } from "@/lib/user-preferences"; +import { getPool } from "@/lib/db"; -const ALG = process.env.JWT_ALGORITHM ?? "HS256"; +const MAX_CREDENTIAL_TTL_SECONDS = 3_600; +const GRANT_AUDIENCE = "inalpha-dashboard-credential"; -function secret(): Uint8Array { - const value = process.env.JWT_SECRET; - if (!value) throw new Error("JWT_SECRET is required"); - return new TextEncoder().encode(value); +function publicKey(): ReturnType { + const value = process.env.EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64?.trim(); + if (!value) throw new Error("EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64 is required"); + return createPublicKey({ key: Buffer.from(value, "base64"), format: "der", type: "spki" }); } /** Resolves an existing encrypted owner credential for the Evolver service only. */ @@ -23,19 +27,55 @@ export async function GET( let subject: string; try { - const { payload } = await jwtVerify(token, secret(), { - algorithms: [ALG], - requiredClaims: ["sub", "exp"], + const { payload } = await jwtVerify(token, publicKey(), { + algorithms: ["EdDSA"], + audience: GRANT_AUDIENCE, + requiredClaims: ["sub", "jti", "iat", "exp", "aud"], }); + const issuedAt = payload.iat; + const expiresAt = payload.exp; + const now = Math.floor(Date.now() / 1_000); if ( payload.token_use !== "evolver_credential" || payload.config_id !== configId || + typeof payload.operation_id !== "string" || + payload.operation_id.length < 8 || + typeof payload.llm_config_digest !== "string" || + !/^[0-9a-f]{64}$/.test(payload.llm_config_digest) || + typeof payload.jti !== "string" || + !payload.jti || typeof payload.sub !== "string" || - !payload.sub + !payload.sub || + typeof issuedAt !== "number" || + typeof expiresAt !== "number" || + expiresAt <= issuedAt || + expiresAt - issuedAt > MAX_CREDENTIAL_TTL_SECONDS || + issuedAt > now ) { return NextResponse.json({ error: "forbidden" }, { status: 403 }); } subject = payload.sub; + + let consumed; + try { + consumed = await getPool().query( + `INSERT INTO evolution_credential_grant_uses + (jti,owner_sub,config_id,operation_id,config_digest,consumed_at) + VALUES ($1,$2,$3,$4,$5,NOW()) ON CONFLICT (jti) DO NOTHING RETURNING jti`, + [ + payload.jti, + subject, + configId, + payload.operation_id, + payload.llm_config_digest, + ], + ); + } catch { + return NextResponse.json({ error: "credential_ledger_unavailable" }, { status: 503 }); + } + if (consumed.rowCount !== 1) { + return NextResponse.json({ error: "credential_grant_consumed" }, { status: 409 }); + } } catch { return NextResponse.json({ error: "unauthorized" }, { status: 401 }); } diff --git a/apps/dashboard/src/app/api/permissions/[id]/respond/route.test.ts b/apps/dashboard/src/app/api/permissions/[id]/respond/route.test.ts new file mode 100644 index 00000000..21d22691 --- /dev/null +++ b/apps/dashboard/src/app/api/permissions/[id]/respond/route.test.ts @@ -0,0 +1,45 @@ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { backendFetch } from "@/lib/backend"; + +import { POST } from "./route"; + +vi.mock("@/lib/backend", () => ({ backendFetch: vi.fn() })); + +const mockedBackendFetch = vi.mocked(backendFetch); + +/** Calls the dynamic approval route with an isolated JSON request. */ +async function callRoute(body: string) { + return await POST( + new NextRequest("http://dashboard.test/api/permissions/request-1/respond", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }), + { params: Promise.resolve({ id: "request-1" }) }, + ); +} + +beforeEach(() => mockedBackendFetch.mockReset()); + +describe("approval response BFF", () => { + it("forwards an explicit owner decision to the private Mastra API", async () => { + mockedBackendFetch.mockResolvedValue({ ok: true, decision: "allow" }); + + const response = await callRoute(JSON.stringify({ decision: "allow" })); + + expect(response.status).toBe(200); + expect(mockedBackendFetch).toHaveBeenCalledWith( + "mastra", + "/permissions/request-1/respond", + expect.objectContaining({ method: "POST", body: { decision: "allow" } }), + ); + }); + + it("rejects malformed decisions before contacting Mastra", async () => { + expect((await callRoute("not-json")).status).toBe(400); + expect((await callRoute(JSON.stringify({ decision: "yes" }))).status).toBe(400); + expect(mockedBackendFetch).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/dashboard/src/app/api/permissions/[id]/respond/route.ts b/apps/dashboard/src/app/api/permissions/[id]/respond/route.ts new file mode 100644 index 00000000..b3691067 --- /dev/null +++ b/apps/dashboard/src/app/api/permissions/[id]/respond/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { backendFetch } from "@/lib/backend"; + +/** Proxies one owner-authenticated approval decision to the private Mastra API. */ +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const requestId = (await params).id; + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: "bad_request" }, { status: 400 }); + } + const decision = (body as { decision?: unknown } | null)?.decision; + if (decision !== "allow" && decision !== "deny") { + return NextResponse.json({ error: "bad_request" }, { status: 400 }); + } + try { + const result = await backendFetch( + "mastra", + `/permissions/${encodeURIComponent(requestId)}/respond`, + { method: "POST", body: { decision }, timeoutMs: 5_000 }, + ); + return NextResponse.json(result); + } catch (error) { + const candidate = (error as { status?: unknown } | null)?.status; + const status = typeof candidate === "number" ? candidate : 502; + return NextResponse.json({ error: "approval_failed" }, { status }); + } +} diff --git a/apps/dashboard/src/components/activity/ActivityFeed.tsx b/apps/dashboard/src/components/activity/ActivityFeed.tsx index 9ba669b3..9a861366 100644 --- a/apps/dashboard/src/components/activity/ActivityFeed.tsx +++ b/apps/dashboard/src/components/activity/ActivityFeed.tsx @@ -1,7 +1,10 @@ "use client"; +import { useState } from "react"; + import { useLocale, useNow, useTranslations } from "next-intl"; import { ChevronRight, MessageSquare } from "lucide-react"; +import { useSWRConfig } from "swr"; import type { ActivityEvent, ActivityTone } from "@/lib/types"; import { Link } from "@/i18n/navigation"; @@ -31,7 +34,8 @@ export function ActivityFeed({ events }: { events: ActivityEvent[] }) { {events.map((e) => { // 会话事件可点 → 打开右侧对话栏并切到该会话(与底部日志同款交互)。 const isConversation = e.kind === "conversation"; - const clickable = isConversation || Boolean(e.href); + const hasApproval = Boolean(e.approvalRequestId); + const clickable = isConversation || (Boolean(e.href) && !hasApproval); const row = (
+ ) : hasApproval ? ( + ) : ( e.href && ( @@ -107,7 +113,7 @@ export function ActivityFeed({ events }: { events: ActivityEvent[] }) { > {row} - ) : e.href ? ( + ) : e.href && !hasApproval ? ( {row} @@ -120,3 +126,50 @@ export function ActivityFeed({ events }: { events: ActivityEvent[] }) { ); } + +function ApprovalActions({ requestId }: { requestId: string }) { + const t = useTranslations("activity.approval"); + const { mutate } = useSWRConfig(); + const [submitting, setSubmitting] = useState<"allow" | "deny" | null>(null); + const [failed, setFailed] = useState(false); + + const respond = async (decision: "allow" | "deny") => { + setSubmitting(decision); + setFailed(false); + try { + const response = await fetch(`/api/permissions/${encodeURIComponent(requestId)}/respond`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ decision }), + }); + if (!response.ok) throw new Error(`approval failed (${response.status})`); + await mutate("/api/activity"); + } catch { + setFailed(true); + } finally { + setSubmitting(null); + } + }; + + return ( +
+ {failed && {t("failed")}} + + +
+ ); +} diff --git a/apps/dashboard/src/lib/types.ts b/apps/dashboard/src/lib/types.ts index a6d45dc6..8395056a 100644 --- a/apps/dashboard/src/lib/types.ts +++ b/apps/dashboard/src/lib/types.ts @@ -500,6 +500,9 @@ export interface ActivityEvent { href: string | null; /** 行内迷你指标(可选):紧跟标题,带语调染色,扫一眼能定位关键数字。 */ stats?: ActivityStat[]; + /** 仅待审批事件存在;浏览器通过同源 BFF 提交 allow / deny。 */ + approvalRequestId?: string; + approvalDeadline?: string; } /** GET /api/activity —— Agent 活动流聚合负载。 */ diff --git a/apps/dashboard/src/lib/user-preferences.test.ts b/apps/dashboard/src/lib/user-preferences.test.ts new file mode 100644 index 00000000..b1ea9b37 --- /dev/null +++ b/apps/dashboard/src/lib/user-preferences.test.ts @@ -0,0 +1,68 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { encryptApiKey } from "./encryption"; +import { decryptUserApiKey, type UserLLMConfig } from "./user-preferences"; + +vi.mock("server-only", () => ({})); + +beforeAll(() => { + process.env.JWT_SECRET = "user-preferences-test-secret-at-least-32-bytes"; +}); + +describe("decryptUserApiKey", () => { + it("decrypts only the exact owner-scoped config reference", async () => { + const aliceEncrypted = await encryptApiKey("alice-owner-key"); + const otherEncrypted = await encryptApiKey("other-config-key"); + const timestamp = "2026-08-27T00:00:00Z"; + const configs: UserLLMConfig[] = [ + { + id: "config-alice", + provider: "deepseek", + api_key_encrypted: aliceEncrypted.ciphertext, + api_key_nonce: aliceEncrypted.nonce, + api_key_tag: aliceEncrypted.tag, + created_at: timestamp, + updated_at: timestamp, + }, + { + id: "config-other", + provider: "openai", + api_key_encrypted: otherEncrypted.ciphertext, + api_key_nonce: otherEncrypted.nonce, + api_key_tag: otherEncrypted.tag, + created_at: timestamp, + updated_at: timestamp, + }, + ]; + + await expect( + decryptUserApiKey("user:alice", "config-alice", { + configs, + active_config_id: "config-other", + }), + ).resolves.toMatchObject({ id: "config-alice", api_key: "alice-owner-key" }); + await expect( + decryptUserApiKey("user:alice", "missing", { + configs, + active_config_id: "config-other", + }), + ).resolves.toBeNull(); + }); + + it("fails closed when the referenced ciphertext is damaged", async () => { + const encrypted = await encryptApiKey("alice-owner-key"); + const config: UserLLMConfig = { + id: "config-alice", + provider: "deepseek", + api_key_encrypted: `${encrypted.ciphertext}broken`, + api_key_nonce: encrypted.nonce, + api_key_tag: encrypted.tag, + created_at: "2026-08-27T00:00:00Z", + updated_at: "2026-08-27T00:00:00Z", + }; + + await expect( + decryptUserApiKey("user:alice", "config-alice", { configs: [config] }), + ).rejects.toThrow("Decryption failed"); + }); +}); diff --git a/docs/04-current-state.md b/docs/04-current-state.md index 6cce0ae8..6d39b5ea 100644 --- a/docs/04-current-state.md +++ b/docs/04-current-state.md @@ -192,7 +192,8 @@ seed / buy-and-hold / 全候选同数据哈希评估、owner 隔离、数据库 contract 与独立回测子进程。该子进程有超时/内存限制,但不是 hardened container/VM。 - **显式审批**:`evolver.run_evolution` 是 costful ask;审批按认证 owner 隔离,聊天文字、 model output 或另一 turn 不能代替可信审批。Evolver 不自动 promote、start 或 order。 -- **Dashboard**:已有演化列表、run 详情、候选详情、取消、能力开关与多租户错误隔离。 +- **Dashboard**:已有演化列表、run 详情、候选详情、取消、能力开关、多租户错误隔离,以及 + 活动页内的显式批准/拒绝入口;审批内容展示冻结模型与本次预算费用上限估算。 ### 当前分支收口:冻结 LLM 审批快照(migration 0041) @@ -200,11 +201,18 @@ seed / buy-and-hold / 全候选同数据哈希评估、owner 隔离、数据库 生成稳定 `config_digest` 和 operation id;批准后 5 分钟 JWT 同时绑定 owner、operation 与 digest。 - Evolver 创建 run 时校验审批 JWT 与 snapshot digest,再持久化非密钥 `llm_snapshot`;数据库 check constraint 要求新写入具备完整快照,历史行不伪造元数据。 -- 执行时用短时、`token_use=evolver_credential` 且绑定 `config_id` 的 service JWT 调 Dashboard internal route,按 owner + config_id - 即时解密 API key;key 不写入 snapshot、run/candidate、异常或日志。 +- 批准后由 orchestration 用独立 Ed25519 私钥签发短时 credential grant,绑定 owner、operation、 + config 与 digest;Evolver 只转交、不能签发。Dashboard 用公钥验签并以 `jti` 在 PostgreSQL + 一次性消费,再按 owner + config_id 即时解密 API key;队列中的 grant 兑换后立即清除, + 明文 key 不写入 snapshot、run/candidate、异常或日志。 +- 演化只接受每家已有冻结价格条目的精确模型;未知模型 fail closed。DeepSeek 官方 + `https://api.deepseek.com` 与 `/v1` alias 统一规范化,不会误判为自定义代理。 +- 输入 prompt 以 UTF-8 字节数保守约束在冻结 input token 上限内,输出 token 同样硬限制; + 因此 Dashboard 展示的 `budget × 最大单候选估算` 不只是提示,也是本次执行的费用硬边界。 - LLM 成功、diff 被拒和其它已发生调用的路径都记录 input/output/cache-hit tokens 与实际或 按冻结单价计算的 `llm_cost_usd`;`DASHBOARD_SERVICE_URL` 与 - `EVOLVER_LLM_TIMEOUT_S` 已进入环境模板和生产 Compose。 + `EVOLVER_LLM_TIMEOUT_S`、凭据签名公私钥已进入环境模板和生产 Compose;Dashboard 暂时 + 不可用或返回 5xx 时 run 回到队列重试,不会在依赖启动窗口直接进入失败终态。 --- diff --git a/infra/.env.prod.example b/infra/.env.prod.example index a735f79f..bf0ea46c 100644 --- a/infra/.env.prod.example +++ b/infra/.env.prod.example @@ -58,6 +58,9 @@ EVOLVER_JOB_TIMEOUT_S=300 EVOLVER_JOB_MEM_GB=2 EVOLVER_LLM_TIMEOUT_S=120 DASHBOARD_SERVICE_URL=http://dashboard:3001 +# Evolver owner 凭据 grant(Ed25519 DER base64;生成命令见 services/evolver/README.md) +EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64= +EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64= # ---- 行情数据源 ---- BINANCE_API_KEY= diff --git a/infra/.env.selfhost.example b/infra/.env.selfhost.example index 4e784f0a..3fd67b57 100644 --- a/infra/.env.selfhost.example +++ b/infra/.env.selfhost.example @@ -37,6 +37,8 @@ EVOLVER_JOB_TIMEOUT_S=300 EVOLVER_JOB_MEM_GB=2 EVOLVER_LLM_TIMEOUT_S=120 DASHBOARD_SERVICE_URL=http://dashboard:3001 +EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64= +EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64= # Dashboard settings power the orchestrator and Evolver with per-owner credentials. # Research still uses one deployment-level provider/key; configure this block only if deep_dive diff --git a/infra/README.md b/infra/README.md index 9d442d25..f3350c3d 100644 --- a/infra/README.md +++ b/infra/README.md @@ -31,6 +31,10 @@ docker compose ps # postgres / redis 应为 healthy 首次安装与任何拉取新 migration 后都升级到当前 head;`scripts/dev.sh` 不会自动迁移。 +生产或长期运行的自建实例应采用协调停机升级:先停止旧版应用 service,再执行 migration, +最后统一启动同一版本的 Dashboard、orchestration 与 Python services。尤其从 0041 起,新的 +Evolver run 必须携带冻结 LLM 快照,旧 writer 与新 schema 不支持滚动混跑。 + ```bash cd infra/migrations uv sync diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index 68adc7b6..7b571ed3 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -160,6 +160,8 @@ services: DATA_SERVICE_URL: http://data:8001 DASHBOARD_SERVICE_URL: http://dashboard:3001 EVOLVER_LLM_TIMEOUT_S: ${EVOLVER_LLM_TIMEOUT_S:-120} + # 签名私钥只属于 orchestration;覆盖 svc-common env_file,防 Evolver 自签 owner grant。 + EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64: "" deploy: replicas: 1 depends_on: @@ -228,6 +230,7 @@ services: # 加密密钥 —— 解密 users 表 preferences 中存储的 LLM API key。 # 未配置时从 JWT_SECRET 派生(encryption.ts 降级)。 LLM_CONFIG_ENCRYPTION_KEY: ${LLM_CONFIG_ENCRYPTION_KEY:-} + EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64: ${EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64:?required} MASTRA_URL: http://mastra:4111 PAPER_SERVICE_URL: http://paper:8002 DATA_SERVICE_URL: http://data:8001 diff --git a/infra/migrations/tests/test_migration_0041.py b/infra/migrations/tests/test_migration_0041.py index e938c484..58b5ffdb 100644 --- a/infra/migrations/tests/test_migration_0041.py +++ b/infra/migrations/tests/test_migration_0041.py @@ -31,14 +31,22 @@ def _insert_run( key: str, *, snapshot: dict[str, object] | None = None, + digest: str | None = None, + grant: str | None = None, ) -> None: columns = "" values = "" params: list[object] = [run_id, _OWNER, "user:test", key, f"hash-{key}"] if snapshot is not None: - columns = ",llm_snapshot,llm_config_digest" - values = ",%s,%s" - params.extend([json.dumps(snapshot), snapshot["config_digest"]]) + columns = ",llm_snapshot,llm_config_digest,llm_credential_grant" + values = ",%s,%s,%s" + params.extend( + [ + json.dumps(snapshot), + digest or snapshot.get("config_digest"), + grant if grant is not None else "g" * 100, + ] + ) conn.execute( f"""INSERT INTO strategy_evo_runs (run_id,owner_account_id,requested_by_sub,seed_strategy_id,budget,config, @@ -58,10 +66,11 @@ def test_0041_preserves_old_rows_and_enforces_new_snapshots( alembic(migration_db_url, "upgrade", "0041") with psycopg.connect(db_url(migration_db_url), autocommit=True) as conn: assert conn.execute( - """SELECT llm_snapshot,llm_config_digest,llm_snapshot_required + """SELECT llm_snapshot,llm_config_digest,llm_snapshot_required, + llm_credential_grant_required FROM strategy_evo_runs WHERE run_id=%s""", (_LEGACY_RUN,), - ).fetchone() == (None, None, False) + ).fetchone() == (None, None, False, False) conn.execute( "UPDATE strategy_evo_runs SET updated_at=NOW() WHERE run_id=%s", (_LEGACY_RUN,), @@ -72,10 +81,87 @@ def test_0041_preserves_old_rows_and_enforces_new_snapshots( assert validated == (False,) with pytest.raises(psycopg.errors.CheckViolation): _insert_run(conn, _NEW_RUN, "new-without-snapshot") + with pytest.raises(psycopg.errors.CheckViolation): + _insert_run( + conn, + "30000000-0000-0000-0000-000000000009", + "new-without-grant", + snapshot=_SNAPSHOT, + grant="", + ) _insert_run(conn, _VALID_RUN, "new-with-snapshot", snapshot=_SNAPSHOT) assert conn.execute( - "SELECT llm_snapshot_required FROM strategy_evo_runs WHERE run_id=%s", + """SELECT llm_snapshot_required,llm_credential_grant_required + FROM strategy_evo_runs WHERE run_id=%s""", (_VALID_RUN,), - ).fetchone() == (True,) + ).fetchone() == (True, True) + conn.execute( + """UPDATE strategy_evo_runs + SET llm_credential_grant=NULL,llm_credential_grant_required=FALSE + WHERE run_id=%s""", + (_VALID_RUN,), + ) + malformed_snapshots = [ + ( + { + key: value + for key, value in _SNAPSHOT.items() + if key != "config_digest" + }, + _DIGEST, + ), + ( + {key: value for key, value in _SNAPSHOT.items() if key != "pricing"}, + _DIGEST, + ), + ({**_SNAPSHOT, "config_digest": "b" * 64}, _DIGEST), + ({**_SNAPSHOT, "model": ""}, _DIGEST), + ] + for index, (snapshot, digest) in enumerate(malformed_snapshots, start=4): + run_id = f"30000000-0000-0000-0000-{index:012d}" + with pytest.raises(psycopg.errors.CheckViolation): + _insert_run( + conn, + run_id, + f"invalid-snapshot-{index}", + snapshot=snapshot, + digest=digest, + ) + + grant_values = ( + "11111111-1111-4111-8111-111111111111", + "user:test", + "config-1", + "operation-1", + _DIGEST, + ) + conn.execute( + """INSERT INTO evolution_credential_grant_uses + (jti,owner_sub,config_id,operation_id,config_digest) VALUES (%s,%s,%s,%s,%s)""", + grant_values, + ) + with pytest.raises(psycopg.errors.UniqueViolation): + conn.execute( + """INSERT INTO evolution_credential_grant_uses + (jti,owner_sub,config_id,operation_id,config_digest) + VALUES (%s,%s,%s,%s,%s)""", + grant_values, + ) alembic(migration_db_url, "downgrade", "0040") + with psycopg.connect(db_url(migration_db_url), autocommit=True) as conn: + columns = conn.execute( + """SELECT column_name FROM information_schema.columns + WHERE table_name='strategy_evo_runs' + AND column_name IN + ('llm_snapshot','llm_config_digest','llm_credential_grant', + 'llm_credential_grant_required','llm_snapshot_required')""" + ).fetchall() + constraint = conn.execute( + "SELECT 1 FROM pg_constraint WHERE conname='evo_run_llm_snapshot_check'" + ).fetchone() + assert columns == [] + assert constraint is None + assert conn.execute( + "SELECT to_regclass('evolution_credential_grant_uses')" + ).fetchone() == (None,) diff --git a/infra/migrations/versions/0041_evolution_llm_snapshot.py b/infra/migrations/versions/0041_evolution_llm_snapshot.py index 93f74e50..1f9c1666 100644 --- a/infra/migrations/versions/0041_evolution_llm_snapshot.py +++ b/infra/migrations/versions/0041_evolution_llm_snapshot.py @@ -17,23 +17,40 @@ def upgrade() -> None: """ALTER TABLE strategy_evo_runs ADD COLUMN llm_snapshot JSONB, ADD COLUMN llm_config_digest TEXT, +ADD COLUMN llm_credential_grant TEXT, +ADD COLUMN llm_credential_grant_required BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN llm_snapshot_required BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE strategy_evo_runs -ALTER COLUMN llm_snapshot_required SET DEFAULT TRUE; +ALTER COLUMN llm_snapshot_required SET DEFAULT TRUE, +ALTER COLUMN llm_credential_grant_required SET DEFAULT TRUE; ALTER TABLE strategy_evo_runs ADD CONSTRAINT evo_run_llm_snapshot_check CHECK ( NOT llm_snapshot_required OR ( llm_snapshot IS NOT NULL AND llm_config_digest IS NOT NULL + AND ( + NOT llm_credential_grant_required + OR length(COALESCE(llm_credential_grant,''))>=100 + ) AND llm_config_digest ~ '^[0-9a-f]{64}$' - AND llm_snapshot->>'config_digest'=llm_config_digest + AND COALESCE(llm_snapshot->>'config_digest'=llm_config_digest,FALSE) AND length(COALESCE(llm_snapshot->>'config_id',''))>0 AND length(COALESCE(llm_snapshot->>'provider',''))>0 AND length(COALESCE(llm_snapshot->>'model',''))>0 - AND jsonb_typeof(llm_snapshot->'pricing')='object' + AND COALESCE(jsonb_typeof(llm_snapshot->'pricing')='object',FALSE) ) -) NOT VALID;""" +) NOT VALID; +CREATE TABLE evolution_credential_grant_uses ( + jti UUID PRIMARY KEY, + owner_sub TEXT NOT NULL, + config_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + config_digest TEXT NOT NULL CHECK (config_digest ~ '^[0-9a-f]{64}$'), + consumed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX ix_evolution_credential_grant_uses_consumed_at +ON evolution_credential_grant_uses(consumed_at);""" ) @@ -41,9 +58,12 @@ def downgrade() -> None: """Remove only metadata columns; no business row is changed or deleted.""" op.execute("SET LOCAL lock_timeout = '10s'") op.execute( - """ALTER TABLE strategy_evo_runs + """DROP TABLE evolution_credential_grant_uses; +ALTER TABLE strategy_evo_runs DROP CONSTRAINT evo_run_llm_snapshot_check, DROP COLUMN llm_snapshot_required, +DROP COLUMN llm_credential_grant_required, +DROP COLUMN llm_credential_grant, DROP COLUMN llm_config_digest, DROP COLUMN llm_snapshot;""" ) diff --git a/packages/orchestration/src/clients/evolver.ts b/packages/orchestration/src/clients/evolver.ts index 72f80e50..bc7c1229 100644 --- a/packages/orchestration/src/clients/evolver.ts +++ b/packages/orchestration/src/clients/evolver.ts @@ -73,6 +73,7 @@ export class EvolverClient { config: EvolutionConfig; idempotencyKey: string; approvalToken: string; + credentialGrant: string; llmSnapshot: EvolutionLLMSnapshot; }): Promise { const body = { @@ -84,6 +85,7 @@ export class EvolverClient { const headers = { "Idempotency-Key": options.idempotencyKey, "X-Evolution-Approval": options.approvalToken, + "X-Evolution-Credential": options.credentialGrant, }; try { return await this.http.post("/api/v1/runs", body, headers); diff --git a/packages/orchestration/src/hooks/with-hooks.ts b/packages/orchestration/src/hooks/with-hooks.ts index 6cb54703..4d0920a7 100644 --- a/packages/orchestration/src/hooks/with-hooks.ts +++ b/packages/orchestration/src/hooks/with-hooks.ts @@ -240,7 +240,11 @@ export function withHooks(tool: T, opts: WithHooksOptions toolInput: approvalViewInput, approvalInput, timeoutMs: - opts.askTimeoutMs && opts.askTimeoutMs > 0 ? opts.askTimeoutMs : undefined, + opts.askTimeoutMs && opts.askTimeoutMs > 0 + ? opts.askTimeoutMs + : toolName === "evolver.run_evolution" + ? 300_000 + : undefined, }); return { isError: true, diff --git a/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts b/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts new file mode 100644 index 00000000..e64e7f45 --- /dev/null +++ b/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts @@ -0,0 +1,50 @@ +/** Evolver 可转交、但自身不能签发的 owner 凭据授权。 */ +import { createPrivateKey, randomUUID } from "node:crypto"; + +import { SignJWT } from "jose"; + +import type { EvolutionLLMSnapshot } from "./evolution-snapshot.js"; + +const GRANT_AUDIENCE = "inalpha-dashboard-credential"; +const GRANT_TTL_SECONDS = 3_600; + +/** + * 为一次已审批的演化操作签发短效凭据 capability。 + * + * 私钥只应注入 orchestration;Evolver 仅转交 token,不能伪造任意 owner/config。 + */ +export async function mintEvolutionCredentialGrant(args: { + authSub: string; + operationId: string; + snapshot: EvolutionLLMSnapshot; +}): Promise { + const encoded = process.env.EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64?.trim(); + if (!encoded) { + throw new Error("EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64 is required for evolution"); + } + let privateKey: ReturnType; + try { + privateKey = createPrivateKey({ + key: Buffer.from(encoded, "base64"), + format: "der", + type: "pkcs8", + }); + } catch { + throw new Error("EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64 is not a valid PKCS8 key"); + } + const now = Math.floor(Date.now() / 1_000); + return await new SignJWT({ + token_use: "evolution_credential", + config_id: args.snapshot.config_id, + provider: args.snapshot.provider, + operation_id: args.operationId, + llm_config_digest: args.snapshot.config_digest, + }) + .setProtectedHeader({ alg: "EdDSA", typ: "JWT" }) + .setSubject(args.authSub) + .setAudience(GRANT_AUDIENCE) + .setJti(randomUUID()) + .setIssuedAt(now) + .setExpirationTime(now + GRANT_TTL_SECONDS) + .sign(privateKey); +} diff --git a/packages/orchestration/src/mastra/llm/evolution-snapshot.ts b/packages/orchestration/src/mastra/llm/evolution-snapshot.ts index 7c681db3..315608d1 100644 --- a/packages/orchestration/src/mastra/llm/evolution-snapshot.ts +++ b/packages/orchestration/src/mastra/llm/evolution-snapshot.ts @@ -13,11 +13,14 @@ const MAX_OUTPUT_TOKENS = 8_192; export const EVOLUTION_LLM_PROVIDERS = ["deepseek", "openai", "kimi", "zhipu"] as const; export type EvolutionLLMProvider = (typeof EVOLUTION_LLM_PROVIDERS)[number]; -const RATES: Record = { - deepseek: [0.56, 1.68], - openai: [5, 15], - kimi: [0.6, 2.5], - zhipu: [0.7, 2.8], +const PRICED_MODELS: Record< + EvolutionLLMProvider, + Readonly<{ model: string; rates: readonly [number, number] }> +> = { + deepseek: { model: "deepseek-v4-pro", rates: [0.56, 1.68] }, + openai: { model: "gpt-5.5", rates: [5, 15] }, + kimi: { model: "kimi-k2.6", rates: [0.6, 2.5] }, + zhipu: { model: "glm-5.2", rates: [0.7, 2.8] }, }; export type EvolutionPricingSnapshot = { @@ -45,23 +48,35 @@ export function buildEvolutionLLMSnapshot(config: UserLLMConfig): EvolutionLLMSn throw new Error(`evolution pricing is unavailable for provider ${config.provider}`); } const provider = config.provider; - const rates = RATES[provider]; + const priced = PRICED_MODELS[provider]; const model = config.model?.trim() || DEFAULT_MODELS[provider as keyof typeof DEFAULT_MODELS]; if (!model) throw new Error(`evolution model is unavailable for provider ${provider}`); - const baseUrl = sanitizeBaseUrl( + if (model !== priced.model) { + throw new Error(`evolution pricing is unavailable for model ${provider}/${model}`); + } + const baseUrl = normalizeProviderBaseUrl( + provider, config.custom_base_url || PROVIDER_BASE_URLS[provider] || null, ); + const officialBaseUrl = normalizeProviderBaseUrl( + provider, + PROVIDER_BASE_URLS[provider] || null, + ); + if (baseUrl !== officialBaseUrl) { + throw new Error(`evolution requires the official ${provider} API endpoint`); + } const pricing: EvolutionPricingSnapshot = { version: PRICING_VERSION, currency: "USD", - input_usd_per_million: rates[0], - output_usd_per_million: rates[1], + input_usd_per_million: priced.rates[0], + output_usd_per_million: priced.rates[1], assumed_input_tokens: ASSUMED_INPUT_TOKENS, max_output_tokens: MAX_OUTPUT_TOKENS, estimated_max_usd_per_candidate: Number( ( - (ASSUMED_INPUT_TOKENS * rates[0] + MAX_OUTPUT_TOKENS * rates[1]) / + (ASSUMED_INPUT_TOKENS * priced.rates[0] + + MAX_OUTPUT_TOKENS * priced.rates[1]) / 1_000_000 ).toFixed(12), ), @@ -137,6 +152,18 @@ function sanitizeBaseUrl(value: string | null): string | null { return url.toString().replace(/\/$/, ""); } +/** Canonicalizes documented aliases without accepting arbitrary proxy paths. */ +function normalizeProviderBaseUrl( + provider: EvolutionLLMProvider, + value: string | null, +): string | null { + const sanitized = sanitizeBaseUrl(value); + if (provider === "deepseek" && sanitized === "https://api.deepseek.com/v1") { + return "https://api.deepseek.com"; + } + return sanitized; +} + function isEvolutionLLMProvider(value: string): value is EvolutionLLMProvider { return EVOLUTION_LLM_PROVIDERS.includes(value as EvolutionLLMProvider); } diff --git a/packages/orchestration/src/tools/evolver-shared.ts b/packages/orchestration/src/tools/evolver-shared.ts index df8fa465..dd2218f2 100644 --- a/packages/orchestration/src/tools/evolver-shared.ts +++ b/packages/orchestration/src/tools/evolver-shared.ts @@ -11,6 +11,7 @@ import { USER_LLM_SNAPSHOT_KEY, type EvolutionLLMSnapshot, } from "../mastra/llm/evolution-snapshot.js"; +import { mintEvolutionCredentialGrant } from "../mastra/llm/evolution-credential-grant.js"; export type ToolRequestContext = { authToken?: string; get?: (key: string) => unknown }; @@ -28,6 +29,7 @@ export async function getApprovedEvolutionRunContext( client: EvolverClient; operationId: string; approvalToken: string; + credentialGrant: string; llmSnapshot: EvolutionLLMSnapshot; }> { const operationId = getRequestContextValue( @@ -51,10 +53,16 @@ export async function getApprovedEvolutionRunContext( }, 300, ); + const credentialGrant = await mintEvolutionCredentialGrant({ + authSub, + operationId, + snapshot: llmSnapshot, + }); return { client: await getEvolverClient(ctx), operationId, approvalToken, + credentialGrant, llmSnapshot, }; } diff --git a/packages/orchestration/src/tools/evolver.ts b/packages/orchestration/src/tools/evolver.ts index 771acbd2..1405783a 100644 --- a/packages/orchestration/src/tools/evolver.ts +++ b/packages/orchestration/src/tools/evolver.ts @@ -32,6 +32,7 @@ export const evolverRunEvolutionTool = createTool({ config: inputData.config, idempotencyKey: approved.operationId, approvalToken: approved.approvalToken, + credentialGrant: approved.credentialGrant, llmSnapshot: approved.llmSnapshot, }); }, diff --git a/packages/orchestration/tests/evolution-snapshot.test.ts b/packages/orchestration/tests/evolution-snapshot.test.ts index 2312e1e7..ce8e9289 100644 --- a/packages/orchestration/tests/evolution-snapshot.test.ts +++ b/packages/orchestration/tests/evolution-snapshot.test.ts @@ -22,6 +22,33 @@ describe("evolution LLM snapshot", () => { expect(computeEvolutionLLMConfigDigest(snapshot)).toBe(snapshot.config_digest); }); + it.each([ + ["openai", "gpt-5.5", 5, 15], + ["kimi", "kimi-k2.6", 0.6, 2.5], + ["zhipu", "glm-5.2", 0.7, 2.8], + ] as const)( + "freezes the default model, pricing, and digest for %s", + (provider, model, inputRate, outputRate) => { + const snapshot = buildEvolutionLLMSnapshot({ + id: `config-${provider}`, + provider, + api_key: "must-not-be-copied", + }); + + expect(snapshot).toMatchObject({ + provider, + model, + pricing: { + input_usd_per_million: inputRate, + output_usd_per_million: outputRate, + }, + }); + expect(snapshot.config_digest).toMatch(/^[a-f0-9]{64}$/); + expect(computeEvolutionLLMConfigDigest(snapshot)).toBe(snapshot.config_digest); + expect(JSON.stringify(snapshot)).not.toContain("must-not-be-copied"); + }, + ); + it("fails closed for providers the Python runtime cannot execute", () => { expect(() => buildEvolutionLLMSnapshot({ @@ -48,4 +75,44 @@ describe("evolution LLM snapshot", () => { ).toThrow(); } }); + + it("rejects custom proxy and private-network endpoints for evolution", () => { + for (const custom_base_url of [ + "https://proxy.example.com/v1", + "http://127.0.0.1:8080/v1", + "http://169.254.169.254/latest/meta-data", + ]) { + expect(() => + buildEvolutionLLMSnapshot({ + id: "config-4", + provider: "openai", + api_key: "test-key", + custom_base_url, + }), + ).toThrow("official openai API endpoint"); + } + }); + + it("canonicalizes DeepSeek's documented /v1 alias", () => { + const snapshot = buildEvolutionLLMSnapshot({ + id: "config-deepseek-v1", + provider: "deepseek", + model: "deepseek-v4-pro", + api_key: "test-key", + custom_base_url: "https://api.deepseek.com/v1", + }); + + expect(snapshot.base_url).toBe("https://api.deepseek.com"); + }); + + it("rejects models without a frozen, model-specific pricing entry", () => { + expect(() => + buildEvolutionLLMSnapshot({ + id: "config-unpriced", + provider: "openai", + model: "gpt-unknown-expensive", + api_key: "test-key", + }), + ).toThrow("pricing is unavailable for model"); + }); }); diff --git a/packages/orchestration/tests/evolver-client.test.ts b/packages/orchestration/tests/evolver-client.test.ts index 4140909e..c03ccb5f 100644 --- a/packages/orchestration/tests/evolver-client.test.ts +++ b/packages/orchestration/tests/evolver-client.test.ts @@ -1,3 +1,6 @@ +import { generateKeyPairSync } from "node:crypto"; + +import { jwtVerify } from "jose"; import { afterEach, describe, expect, it, vi } from "vitest"; import { verifyToken } from "../src/auth.js"; @@ -42,16 +45,23 @@ function options() { }, idempotencyKey: "approval-operation-1", approvalToken: "approval-token", + credentialGrant: "credential-grant", llmSnapshot: snapshot, }; } afterEach(() => { vi.unstubAllGlobals(); + vi.unstubAllEnvs(); }); describe("EvolverClient", () => { it("mints a short-lived approval JWT bound to owner, operation, and snapshot", async () => { + const keys = generateKeyPairSync("ed25519"); + vi.stubEnv( + "EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64", + keys.privateKey.export({ format: "der", type: "pkcs8" }).toString("base64"), + ); const requestContext = new Map([ [AUTH_SUB_KEY, "user:alice"], [APPROVAL_OPERATION_ID_KEY, "approval-operation-1"], @@ -60,6 +70,11 @@ describe("EvolverClient", () => { const approved = await getApprovedEvolutionRunContext(requestContext); const payload = await verifyToken(approved.approvalToken); + const { payload: credential } = await jwtVerify( + approved.credentialGrant, + keys.publicKey, + { algorithms: ["EdDSA"], audience: "inalpha-dashboard-credential" }, + ); expect(payload).toMatchObject({ sub: "user:alice", @@ -68,6 +83,14 @@ describe("EvolverClient", () => { llm_config_digest: snapshot.config_digest, }); expect(Number(payload.exp) - Number(payload.iat)).toBe(300); + expect(credential).toMatchObject({ + sub: "user:alice", + token_use: "evolution_credential", + config_id: "config-1", + operation_id: "approval-operation-1", + llm_config_digest: snapshot.config_digest, + }); + expect(Number(credential.exp) - Number(credential.iat)).toBe(3_600); }); it("retries 502/504 with the same approval-derived operation ID", async () => { @@ -92,7 +115,33 @@ describe("EvolverClient", () => { expect((init.headers as Record)["X-Evolution-Approval"]).toBe( "approval-token", ); + expect((init.headers as Record)["X-Evolution-Credential"]).toBe( + "credential-grant", + ); expect(init.body).not.toContain("not-forwarded"); } }); + + it("retries a 502 once but does not retry other client errors", async () => { + const retryable = vi + .fn() + .mockResolvedValueOnce(response(502)) + .mockResolvedValueOnce(response(200)); + vi.stubGlobal("fetch", retryable); + await expect( + new EvolverClient({ baseUrl: "http://evolver.test", token: "owner-token" }).startRun( + options(), + ), + ).resolves.toMatchObject({ status: "queued" }); + expect(retryable).toHaveBeenCalledTimes(2); + + const nonRetryable = vi.fn().mockResolvedValue(response(403)); + vi.stubGlobal("fetch", nonRetryable); + await expect( + new EvolverClient({ baseUrl: "http://evolver.test", token: "owner-token" }).startRun( + options(), + ), + ).rejects.toThrow(); + expect(nonRetryable).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/orchestration/tests/permissions-pending.test.ts b/packages/orchestration/tests/permissions-pending.test.ts index 5f6cd5c8..cc25c3c4 100644 --- a/packages/orchestration/tests/permissions-pending.test.ts +++ b/packages/orchestration/tests/permissions-pending.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { AUTH_SUB_KEY } from "../src/hooks/with-hooks.js"; import { permissionsApiRoutes } from "../src/permissions/api.js"; @@ -41,6 +41,10 @@ function fakeContext(owner: string | undefined, id: string, decision: "allow" | }; } +afterEach(() => { + vi.useRealTimers(); +}); + describe("PendingApprovalsStore", () => { it("binds approval to owner, thread, tool and deterministic input digest", () => { const store = new PendingApprovalsStore(() => {}); @@ -131,6 +135,34 @@ describe("PendingApprovalsStore", () => { ).toBeUndefined(); store.clearAll(); }); + + it("does not reuse a consumed evolution operation after its deadline", () => { + vi.useFakeTimers(); + const store = new PendingApprovalsStore(() => {}); + const args = { + authSub: "user:alice", + sessionId: "thread-A", + toolName: "evolver.run_evolution", + toolInput: { budget: 1 }, + approvalInput: { request: { budget: 1 }, llm_snapshot: { config_digest: "digest" } }, + timeoutMs: 50, + }; + const view = store.request(args); + expect(store.respond(view.requestId, "allow", args.authSub)).toBe(true); + const consume = { + authSub: args.authSub, + sessionId: args.sessionId, + toolName: args.toolName, + approvalInput: args.approvalInput, + reuseAfterConsume: true, + }; + expect(store.consumeApproved(consume)).toBe(view.requestId); + + vi.advanceTimersByTime(51); + + expect(store.consumeApproved(consume)).toBeUndefined(); + store.clearAll(); + }); }); describe("permissions approval HTTP API", () => { diff --git a/services/evolver/README.md b/services/evolver/README.md index ed174a53..d10b979e 100644 --- a/services/evolver/README.md +++ b/services/evolver/README.md @@ -11,9 +11,12 @@ slot、候选、费用与可复现元数据持久化到 PostgreSQL。 - bars、数据 manifest/hash、种子源码、非密钥 LLM 配置和定价摘要在执行前冻结;baseline、 seed 与候选使用同一份 frozen bars。 - 用户 LLM API key 只在执行时通过 Dashboard 内部路由按 owner/config_id 获取,不进入 run - 配置、候选记录或日志。 -- 当前演化费用审批只为 `deepseek`、`openai`、`kimi`、`zhipu` 维护冻结计价表;其他 - provider 仍可用于普通对话,但启动演化会因缺少可审计定价而 fail closed。 + 配置、候选记录或日志;队列只短暂保存 orchestration 签发的 capability,兑换后立即清除。 +- 当前演化费用审批只为 `deepseek`、`openai`、`kimi`、`zhipu` 各自的默认模型维护冻结计价 + 表;未知模型与其他 provider 均 fail closed。输入 UTF-8 字节数与输出 token 数也受冻结 + 上限约束,因此 `budget × 最大单候选估算` 是执行硬边界。它们仍可用于普通对话。 +- 演化运行只连接上述 provider 的官方 HTTPS API 端点;普通对话可用的自定义代理地址不会 + 进入 Evolver,避免服务端凭据解析链路被用来访问内网地址。 - 所有 run/candidate 查询都按认证 owner 隔离;全局并发与单账户 active run 数均有限制。 - Evolver 只生成和评估候选,绝不会自动 promote、启动策略或下单。 - AST 审计、受限动态加载、契约检查和回测子进程是当前防线;子进程并非 hardened container @@ -24,7 +27,8 @@ slot、候选、费用与可复现元数据持久化到 PostgreSQL。 ```text Dashboard / orchestration → 冻结 LLM + pricing snapshot,取得逐次审批 - → POST /api/v1/runs(Idempotency-Key + X-Evolution-Approval) + → 签发 owner/operation/config/digest 绑定的 Ed25519 credential grant + → POST /api/v1/runs(Idempotency-Key + 两个审批/凭据 header) → 冻结真实 bars + manifest/hash → 解析 seed,跑同数据 baseline → LLM 生成 unified diff @@ -45,12 +49,18 @@ Dashboard / orchestration | `evaluator/` | frozen dataset 回测、子进程资源限制与 fitness | | `runtime/` | 异步 dispatcher、slot 并发、取消、超时与终态收口 | | `storage/` | PostgreSQL run/candidate 持久化与 owner-scoped 查询 | -| `owner_llm.py` | 用短时、用途限定且绑定 `config_id` 的 service JWT 即时读取 owner 模型配置 | +| `owner_llm.py` | 转交短时、逐操作且一次性消费的 credential grant,读取 owner 模型配置 | ## HTTP API 所有 `/api/v1/*` 端点都要求用户 JWT。 +> `inalpha-evolver 0.2.0` 收紧了创建 run 的契约:`POST /api/v1/runs` 现在必须同时提供 +> 冻结的 `llm` 快照、`Idempotency-Key`、`X-Evolution-Approval` 与 +> `X-Evolution-Credential`。这是 0.x 阶段的安全性 +> breaking change;自建部署应将 Dashboard、orchestration、migration 0041 与 Evolver +> 作为同一次升级发布,直接调用旧接口的客户端必须同步更新。 + | 方法 | 路径 | 说明 | |---|---|---| | `POST` | `/api/v1/runs` | 创建或幂等复用 run,返回 `202`;额外要求审批与幂等 header | @@ -79,6 +89,18 @@ Dashboard / orchestration | `EVOLVER_JOB_MEM_GB` | 回测子进程内存上限 | | `EVOLVER_LLM_TIMEOUT_S` | 单次 LLM 变异超时 | +owner 凭据 capability 使用 Ed25519。生成 DER/base64 密钥: + +```bash +openssl genpkey -algorithm ED25519 -out /tmp/inalpha-evolution-private.pem +openssl pkey -in /tmp/inalpha-evolution-private.pem -outform DER | base64 | tr -d '\n' +openssl pkey -in /tmp/inalpha-evolution-private.pem -pubout -outform DER | base64 | tr -d '\n' +``` + +依次填入 `EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64` 与 +`EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64`。生产私钥只注入 orchestration;Dashboard 只持有公钥, +Evolver 显式移除私钥,因此不能自行签发任意 owner/config 的明文凭据读取授权。 + ```bash cd infra/migrations && uv run alembic upgrade head && cd ../.. cd services/evolver diff --git a/services/evolver/pyproject.toml b/services/evolver/pyproject.toml index f3165106..36c3ca08 100644 --- a/services/evolver/pyproject.toml +++ b/services/evolver/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "inalpha-evolver" -version = "0.1.0" +version = "0.2.0" description = "Inalpha 策略演化引擎 —— LLM-as-mutation-operator 单代/多代演化闭环" license = "AGPL-3.0-only" requires-python = ">=3.12" @@ -13,6 +13,7 @@ dependencies = [ "pydantic>=2.13.0", "pydantic-settings>=2.14.0", "psycopg[binary,pool]>=3.3.0", + "openai>=2.38.0", "unidiff>=0.7.0", ] @@ -47,4 +48,4 @@ ignore = ["E501", "RUF001", "RUF002", "RUF003"] [tool.mypy] python_version = "3.12" -strict = true \ No newline at end of file +strict = true diff --git a/services/evolver/src/inalpha_evolver/__init__.py b/services/evolver/src/inalpha_evolver/__init__.py index 912781d3..eefc66a0 100644 --- a/services/evolver/src/inalpha_evolver/__init__.py +++ b/services/evolver/src/inalpha_evolver/__init__.py @@ -4,4 +4,4 @@ E2 先扩展 best-parent 多代选择 + early stopping;MAP-Elites / Island Model 后置。 """ -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/services/evolver/src/inalpha_evolver/api/run_routes.py b/services/evolver/src/inalpha_evolver/api/run_routes.py index 9be4cff7..9b658889 100644 --- a/services/evolver/src/inalpha_evolver/api/run_routes.py +++ b/services/evolver/src/inalpha_evolver/api/run_routes.py @@ -35,6 +35,10 @@ async def start_run( str, Header(alias="X-Evolution-Approval", min_length=20, max_length=4096), ], + evolution_credential: Annotated[ + str, + Header(alias="X-Evolution-Credential", min_length=100, max_length=4096), + ], ) -> RunStatusResponse: owner = account_id_from_user(user) settings = get_evolver_settings() @@ -62,6 +66,7 @@ async def start_run( budget=body.budget, config=config, llm_snapshot=body.llm.model_dump(mode="json"), + llm_credential_grant=evolution_credential, queued_at=datetime.now(UTC), ) if not created and row["request_hash"] != request_hash: diff --git a/services/evolver/src/inalpha_evolver/api/schemas.py b/services/evolver/src/inalpha_evolver/api/schemas.py index 8f9b25ee..9a175074 100644 --- a/services/evolver/src/inalpha_evolver/api/schemas.py +++ b/services/evolver/src/inalpha_evolver/api/schemas.py @@ -86,6 +86,46 @@ def validate_base_url(cls, value: str | None) -> str | None: @model_validator(mode="after") def verify_config_digest(self) -> EvolutionLLMSnapshot: """拒绝任何未被 Mastra 审批摘要覆盖的快照字段变更。""" + official_base_urls = { + "deepseek": "https://api.deepseek.com", + "openai": "https://api.openai.com/v1", + "kimi": "https://api.moonshot.cn/v1", + "zhipu": "https://open.bigmodel.cn/api/paas/v4", + } + if self.provider == "deepseek" and self.base_url == "https://api.deepseek.com/v1": + self.base_url = "https://api.deepseek.com" + if self.base_url != official_base_urls[self.provider]: + raise PydanticCustomError( + "llm_endpoint_unavailable", + "evolution requires the official {provider} API endpoint", + {"provider": self.provider}, + ) + priced_models = { + "deepseek": ("deepseek-v4-pro", 0.56, 1.68), + "openai": ("gpt-5.5", 5.0, 15.0), + "kimi": ("kimi-k2.6", 0.6, 2.5), + "zhipu": ("glm-5.2", 0.7, 2.8), + } + expected_model, expected_input_rate, expected_output_rate = priced_models[self.provider] + pricing = self.pricing + expected_max = ( + pricing.assumed_input_tokens * expected_input_rate + + pricing.max_output_tokens * expected_output_rate + ) / 1_000_000 + if ( + self.model != expected_model + or pricing.version != "provider-estimate-2026-08" + or pricing.assumed_input_tokens != 24_000 + or pricing.max_output_tokens != 8_192 + or pricing.input_usd_per_million != expected_input_rate + or pricing.output_usd_per_million != expected_output_rate + or abs(pricing.estimated_max_usd_per_candidate - expected_max) > 1e-12 + ): + raise PydanticCustomError( + "llm_pricing_unavailable", + "evolution pricing is unavailable for model {provider}/{model}", + {"provider": self.provider, "model": self.model}, + ) expected = compute_llm_config_digest(self) if not hmac.compare_digest(self.config_digest, expected): raise PydanticCustomError( diff --git a/services/evolver/src/inalpha_evolver/mutator/llm_client.py b/services/evolver/src/inalpha_evolver/mutator/llm_client.py index fe5339b2..f68a4b5a 100644 --- a/services/evolver/src/inalpha_evolver/mutator/llm_client.py +++ b/services/evolver/src/inalpha_evolver/mutator/llm_client.py @@ -77,6 +77,7 @@ class Mutator: max_fuzz: int = 3 input_usd_per_million: float | None = None output_usd_per_million: float | None = None + max_input_tokens: int = 24_000 max_output_tokens: int = 8192 def __post_init__(self) -> None: @@ -87,6 +88,8 @@ def __post_init__(self) -> None: raise ValueError("pricing rates must be positive") if self.max_output_tokens <= 0: raise ValueError("max_output_tokens must be positive") + if self.max_input_tokens <= 0: + raise ValueError("max_input_tokens must be positive") async def mutate( self, @@ -109,6 +112,9 @@ async def mutate( DiffApplyError: diff 无法应用。 """ user_prompt = build_user_prompt(current_source, report, hint) + prompt_bytes = len(SYSTEM_PROMPT.encode()) + len(user_prompt.encode()) + if prompt_bytes > self.max_input_tokens: + raise LLMError("LLM 变异输入超过已审批上限;请缩短种子策略或回测摘要后重新审批") request = MutationRequest( system_prompt=SYSTEM_PROMPT, user_prompt=user_prompt, diff --git a/services/evolver/src/inalpha_evolver/owner_llm.py b/services/evolver/src/inalpha_evolver/owner_llm.py index ef24628a..a338012e 100644 --- a/services/evolver/src/inalpha_evolver/owner_llm.py +++ b/services/evolver/src/inalpha_evolver/owner_llm.py @@ -2,12 +2,10 @@ from __future__ import annotations -import time from typing import Any from urllib.parse import quote import httpx -import jwt from inalpha_shared_llm import LLMClient # type: ignore[import-untyped] from inalpha_shared_llm.config import LLMSettings # type: ignore[import-untyped] @@ -15,13 +13,17 @@ from .mutator import Mutator _BASE_URLS = { - "deepseek": "https://api.deepseek.com/v1", + "deepseek": "https://api.deepseek.com", "openai": "https://api.openai.com/v1", "kimi": "https://api.moonshot.cn/v1", "zhipu": "https://open.bigmodel.cn/api/paas/v4", } +class CredentialTemporarilyUnavailable(RuntimeError): + """Dashboard 暂时不可达;run 应回到队列而不是进入失败终态。""" + + async def build_owner_mutator( run: dict[str, Any], settings: EvolverSettings, @@ -31,24 +33,24 @@ async def build_owner_mutator( if not isinstance(snapshot, dict): raise RuntimeError("run is missing frozen LLM snapshot") config_id = str(snapshot["config_id"]) - issued_at = int(time.time()) - token = jwt.encode( - { - "sub": run["requested_by_sub"], - "token_use": "evolver_credential", - "config_id": config_id, - "iat": issued_at, - "exp": issued_at + min(settings.service_token_ttl_s, 600), - }, - settings.jwt_secret, - algorithm=settings.jwt_algorithm, - ) + token = run.get("llm_credential_grant") + if not isinstance(token, str) or not token: + raise RuntimeError("run is missing its approved credential grant") url = ( f"{settings.dashboard_service_url.rstrip('/')}/api/internal/llm-config/" f"{quote(config_id, safe='')}" ) - async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: - response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) + try: + async with httpx.AsyncClient(timeout=10.0, trust_env=False) as client: + response = await client.get(url, headers={"Authorization": f"Bearer {token}"}) + except httpx.HTTPError as exc: + raise CredentialTemporarilyUnavailable( + f"owner LLM credential service unavailable: {type(exc).__name__}" + ) from exc + if response.status_code >= 500: + raise CredentialTemporarilyUnavailable( + f"owner LLM credential service unavailable: HTTP {response.status_code}" + ) if response.status_code != 200: raise RuntimeError(f"owner LLM credential unavailable: HTTP {response.status_code}") credential = response.json() @@ -60,11 +62,17 @@ async def build_owner_mutator( api_key = credential.get("api_key") if not isinstance(api_key, str) or not api_key: raise RuntimeError("owner LLM credential response omitted api_key") + official_base_url = _BASE_URLS[snapshot["provider"]] + snapshot_base_url = snapshot.get("base_url") + if snapshot["provider"] == "deepseek" and snapshot_base_url == f"{official_base_url}/v1": + snapshot_base_url = official_base_url + if snapshot_base_url != official_base_url: + raise RuntimeError("frozen LLM snapshot must use the official provider endpoint") pricing = snapshot["pricing"] llm_settings = LLMSettings( LLM_API_KEY=api_key, DEEPSEEK_API_KEY="", - LLM_BASE_URL=snapshot.get("base_url") or _BASE_URLS[snapshot["provider"]], + LLM_BASE_URL=official_base_url, LLM_MODEL=snapshot["model"], LLM_TIMEOUT_S=settings.evolver_llm_timeout_s, LLM_MAX_TOKENS=int(pricing["max_output_tokens"]), @@ -73,8 +81,9 @@ async def build_owner_mutator( llm_client=LLMClient(settings=llm_settings), input_usd_per_million=float(pricing["input_usd_per_million"]), output_usd_per_million=float(pricing["output_usd_per_million"]), + max_input_tokens=int(pricing["assumed_input_tokens"]), max_output_tokens=int(pricing["max_output_tokens"]), ) -__all__ = ["build_owner_mutator"] +__all__ = ["CredentialTemporarilyUnavailable", "build_owner_mutator"] diff --git a/services/evolver/src/inalpha_evolver/runtime/executor.py b/services/evolver/src/inalpha_evolver/runtime/executor.py index 389a1d23..19162628 100644 --- a/services/evolver/src/inalpha_evolver/runtime/executor.py +++ b/services/evolver/src/inalpha_evolver/runtime/executor.py @@ -80,6 +80,8 @@ async def _run_mutator( yield injected return owner_mutator = await build_owner_mutator(run, settings) + async with get_conn() as conn: + await runs.clear_credential_grant(conn, run["run_id"]) try: yield owner_mutator finally: diff --git a/services/evolver/src/inalpha_evolver/runtime/finalizer.py b/services/evolver/src/inalpha_evolver/runtime/finalizer.py index 07c8432f..7b1a84b4 100644 --- a/services/evolver/src/inalpha_evolver/runtime/finalizer.py +++ b/services/evolver/src/inalpha_evolver/runtime/finalizer.py @@ -10,6 +10,7 @@ from inalpha_shared.db import get_conn +from ..owner_llm import CredentialTemporarilyUnavailable from ..storage import candidates, runs from .executor import execute_run @@ -52,6 +53,21 @@ async def execute_managed( on_error=on_error, on_success=on_success, ) + except CredentialTemporarilyUnavailable: + await asyncio.sleep(2.0) + async with get_conn() as conn: + await runs.transition( + conn, + run["run_id"], + from_statuses=("running",), + to_status="queued", + values={ + "active_stage": None, + "started_at": None, + "failure_code": None, + "failure_message": None, + }, + ) except Exception as exc: await _finalize( run["run_id"], diff --git a/services/evolver/src/inalpha_evolver/storage/runs.py b/services/evolver/src/inalpha_evolver/storage/runs.py index 169dc1a2..40242853 100644 --- a/services/evolver/src/inalpha_evolver/storage/runs.py +++ b/services/evolver/src/inalpha_evolver/storage/runs.py @@ -8,7 +8,7 @@ from psycopg import AsyncConnection _COLUMNS = """run_id,owner_account_id,requested_by_sub,seed_strategy_id,budget,config, -llm_snapshot,llm_config_digest,status,llm_cost_usd,queued_at,started_at,updated_at,finished_at, +llm_snapshot,llm_config_digest,llm_credential_grant,status,llm_cost_usd,queued_at,started_at,updated_at,finished_at, venue,symbol,request_timeframe,data_timeframe,engine_timeframe,requested_as_of, seed_source_snapshot,seed_source_hash,seed_report_snapshot,baseline_snapshot,dataset_manifest, active_stage,failure_code,failure_message""" @@ -27,16 +27,17 @@ async def insert_run( budget: int, config: dict[str, Any], llm_snapshot: dict[str, Any], + llm_credential_grant: str, queued_at: datetime, ) -> tuple[dict[str, Any], bool]: run_id = uuid4() async with conn.cursor() as cur: await cur.execute( f"""INSERT INTO strategy_evo_runs(run_id,owner_account_id,requested_by_sub, -seed_strategy_id,budget,config,llm_snapshot,llm_config_digest,status,idempotency_key, +seed_strategy_id,budget,config,llm_snapshot,llm_config_digest,llm_credential_grant,status,idempotency_key, request_hash,queued_at,venue,symbol,request_timeframe,data_timeframe,engine_timeframe, requested_as_of,seed_source_snapshot,seed_source_hash) VALUES -(%s,%s,%s,%s,%s,%s,%s,%s,'queued',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) +(%s,%s,%s,%s,%s,%s,%s,%s,%s,'queued',%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) ON CONFLICT(owner_account_id,idempotency_key) DO NOTHING RETURNING {_COLUMNS},request_hash""", ( run_id, @@ -47,6 +48,7 @@ async def insert_run( json.dumps(config, default=str), json.dumps(llm_snapshot), llm_snapshot["config_digest"], + llm_credential_grant, idempotency_key, request_hash, queued_at, @@ -89,6 +91,16 @@ async def get_run( return dict(row) if row else None +async def clear_credential_grant(conn: AsyncConnection, run_id: UUID) -> None: + """凭据 capability 兑换成功后立即从持久化队列清除。""" + await conn.execute( + """UPDATE strategy_evo_runs + SET llm_credential_grant=NULL,llm_credential_grant_required=FALSE + WHERE run_id=%s""", + (run_id,), + ) + + async def transition( conn: AsyncConnection, run_id: UUID, diff --git a/services/evolver/tests/test_api_contract.py b/services/evolver/tests/test_api_contract.py index b349e9b6..396021ed 100644 --- a/services/evolver/tests/test_api_contract.py +++ b/services/evolver/tests/test_api_contract.py @@ -15,6 +15,7 @@ EvolutionLLMSnapshot, RunStatusResponse, StartRunRequest, + compute_llm_config_digest, ) from .llm_snapshot_fixtures import VALID_LLM_SNAPSHOT, llm_snapshot @@ -48,7 +49,6 @@ def test_request_hash_is_stable_and_payload_sensitive() -> None: ("path", "value"), [ (("model",), "tampered-model"), - (("base_url",), "https://evil.example/v1"), (("pricing", "input_usd_per_million"), 0.01), ], ) @@ -62,10 +62,19 @@ def test_llm_snapshot_digest_rejects_tampering( target = target[key] target[path[-1]] = value - with pytest.raises(ValueError, match="config_digest"): + with pytest.raises(ValueError, match="pricing is unavailable"): EvolutionLLMSnapshot.model_validate(payload) +def test_deepseek_official_v1_alias_is_canonicalized() -> None: + payload = llm_snapshot() + payload["base_url"] = "https://api.deepseek.com/v1" + + snapshot = EvolutionLLMSnapshot.model_validate(payload) + + assert snapshot.base_url == "https://api.deepseek.com" + + def test_llm_snapshot_digest_matches_typescript_contract() -> None: snapshot = EvolutionLLMSnapshot.model_validate(llm_snapshot()) assert snapshot.config_digest == VALID_LLM_SNAPSHOT["config_digest"] @@ -78,6 +87,17 @@ def test_non_openai_compatible_provider_is_rejected() -> None: EvolutionLLMSnapshot.model_validate(payload) +def test_custom_or_private_llm_endpoint_is_rejected_even_with_matching_digest() -> None: + snapshot = EvolutionLLMSnapshot.model_validate(llm_snapshot()).model_copy( + update={"base_url": "http://169.254.169.254/latest/meta-data"} + ) + payload = snapshot.model_dump() + payload["config_digest"] = compute_llm_config_digest(snapshot) + + with pytest.raises(ValueError, match="official deepseek API endpoint"): + EvolutionLLMSnapshot.model_validate(payload) + + def test_invalid_window_is_rejected() -> None: now = datetime(2026, 8, 12, 12, tzinfo=UTC) with pytest.raises(ValueError): diff --git a/services/evolver/tests/test_approval.py b/services/evolver/tests/test_approval.py index 92963645..b15f3652 100644 --- a/services/evolver/tests/test_approval.py +++ b/services/evolver/tests/test_approval.py @@ -15,18 +15,25 @@ _DIGEST = "a" * 64 -def _token(ttl_seconds: int) -> str: +def _token( + ttl_seconds: int, + *, + overrides: dict[str, object] | None = None, + secret: str = _SECRET, +) -> str: now = int(time.time()) + payload: dict[str, object] = { + "sub": "user:alice", + "token_use": "evolution_approval", + "operation_id": "approval-operation-1", + "llm_config_digest": _DIGEST, + "iat": now, + "exp": now + ttl_seconds, + } + payload.update(overrides or {}) return jwt.encode( - { - "sub": "user:alice", - "token_use": "evolution_approval", - "operation_id": "approval-operation-1", - "llm_config_digest": _DIGEST, - "iat": now, - "exp": now + ttl_seconds, - }, - _SECRET, + payload, + secret, algorithm="HS256", ) @@ -67,3 +74,31 @@ def test_approval_rejects_another_owner() -> None: with pytest.raises(HTTPException) as error: _verify(token) assert error.value.status_code == 403 + + +@pytest.mark.parametrize( + "overrides", + [ + {"token_use": "session"}, + {"operation_id": "another-operation"}, + {"llm_config_digest": "b" * 64}, + {"iat": None}, + {"exp": None}, + ], +) +def test_approval_rejects_invalid_claims(overrides: dict[str, object]) -> None: + with pytest.raises(HTTPException) as error: + _verify(_token(300, overrides=overrides)) + assert error.value.status_code in {401, 403} + + +def test_approval_rejects_expired_bad_signature_and_non_positive_ttl() -> None: + tokens = [ + _token(-1), + _token(300, secret="different-secret-at-least-32-bytes"), + _token(300, overrides={"iat": int(time.time()) + 300}), + ] + for token in tokens: + with pytest.raises(HTTPException) as error: + _verify(token) + assert error.value.status_code in {401, 403} diff --git a/services/evolver/tests/test_e2e.py b/services/evolver/tests/test_e2e.py index 88a82fe1..13541e27 100644 --- a/services/evolver/tests/test_e2e.py +++ b/services/evolver/tests/test_e2e.py @@ -29,6 +29,7 @@ def _headers(key: str | None = None, *, include_approval: bool = True) -> dict[s headers = {"Authorization": f"Bearer {token}"} if key: headers["Idempotency-Key"] = key + headers["X-Evolution-Credential"] = "signed-grant-" + "x" * 120 if include_approval: headers["X-Evolution-Approval"] = approval_token( subject=subject, diff --git a/services/evolver/tests/test_mutator_pricing.py b/services/evolver/tests/test_mutator_pricing.py index 16b869f3..a38dd652 100644 --- a/services/evolver/tests/test_mutator_pricing.py +++ b/services/evolver/tests/test_mutator_pricing.py @@ -5,9 +5,10 @@ from uuid import uuid4 import pytest +from inalpha_shared.errors import ValidationError from inalpha_shared_llm.types import CacheMetrics, MutationResponse -from inalpha_evolver.exceptions import DiffApplyError +from inalpha_evolver.exceptions import DiffApplyError, LLMError from inalpha_evolver.mutator import Mutator from inalpha_evolver.runtime.slots import persist_mutation @@ -24,8 +25,10 @@ class Strategy: class _PricedClient: def __init__(self) -> None: self.max_tokens = 0 + self.calls = 0 async def mutate(self, request): + self.calls += 1 self.max_tokens = request.max_tokens return MutationResponse( content=_DIFF, @@ -68,6 +71,22 @@ async def test_mutator_uses_frozen_rates_and_returns_usage() -> None: assert client.max_tokens == 4_096 +@pytest.mark.asyncio +async def test_mutator_rejects_input_above_approved_budget_before_calling_provider() -> None: + client = _PricedClient() + mutator = Mutator( + llm_client=client, # type: ignore[arg-type] + input_usd_per_million=2.0, + output_usd_per_million=10.0, + max_input_tokens=100, + ) + + with pytest.raises(LLMError, match="超过已审批上限"): + await mutator.mutate(_SOURCE) + + assert client.calls == 0 + + @pytest.mark.asyncio async def test_diff_failure_keeps_frozen_cost_and_usage() -> None: mutator = Mutator( @@ -147,3 +166,40 @@ async def update_slot(*_args: object, **values: object) -> dict[str, object]: assert captured["llm_cost_usd"] == pytest.approx(0.004) assert captured["input_tokens"] == 1_000 assert captured["output_tokens"] == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("outcome", ["ast_rejected", "duplicate"]) +async def test_rejected_or_duplicate_mutation_still_persists_usage( + monkeypatch: pytest.MonkeyPatch, + outcome: str, +) -> None: + captured: dict[str, object] = {} + mutation = await Mutator( + llm_client=_PricedClient(), # type: ignore[arg-type] + input_usd_per_million=2.0, + output_usd_per_million=10.0, + ).mutate(_SOURCE) + + async def source_exists(*_args: object) -> bool: + return outcome == "duplicate" + + async def update_slot(*_args: object, **values: object) -> dict[str, object]: + captured.update(values) + return values + + def audit(source: str) -> str: + if outcome == "ast_rejected": + raise ValidationError("unsafe source", code="CANDIDATE_AST_REJECTED") + return source + + monkeypatch.setattr("inalpha_evolver.runtime.slots.get_conn", _ConnectionContext) + monkeypatch.setattr("inalpha_evolver.runtime.slots.audit_strategy_source", audit) + monkeypatch.setattr("inalpha_evolver.runtime.slots.candidates.source_exists", source_exists) + monkeypatch.setattr("inalpha_evolver.runtime.slots.candidates.update_slot", update_slot) + + assert await persist_mutation(uuid4(), 0, mutation) is None + assert captured["outcome"] == outcome + assert captured["llm_cost_usd"] == pytest.approx(0.004) + assert captured["input_tokens"] == 1_000 + assert captured["output_tokens"] == 200 diff --git a/services/evolver/tests/test_owner_llm.py b/services/evolver/tests/test_owner_llm.py index 6f7e8c98..c6072694 100644 --- a/services/evolver/tests/test_owner_llm.py +++ b/services/evolver/tests/test_owner_llm.py @@ -5,10 +5,10 @@ from types import SimpleNamespace from typing import ClassVar -import jwt +import httpx import pytest -from inalpha_evolver.owner_llm import build_owner_mutator +from inalpha_evolver.owner_llm import CredentialTemporarilyUnavailable, build_owner_mutator from inalpha_evolver.runtime.executor import _run_mutator from .llm_snapshot_fixtures import llm_snapshot @@ -16,14 +16,15 @@ class _Response: status_code = 200 + payload: ClassVar[dict[str, str]] = { + "config_id": "config-1", + "provider": "deepseek", + "api_key": "owner-test-key", + } - @staticmethod - def json() -> dict[str, str]: - return { - "config_id": "config-1", - "provider": "deepseek", - "api_key": "owner-test-key", - } + @classmethod + def json(cls) -> dict[str, str]: + return cls.payload class _CredentialClient: @@ -46,16 +47,8 @@ async def get(self, url: str, **kwargs: object) -> _Response: return _Response() -@pytest.mark.asyncio -async def test_owner_mutator_uses_frozen_snapshot_and_credential_reference( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr("inalpha_evolver.owner_llm.httpx.AsyncClient", _CredentialClient) - run = { - "requested_by_sub": "user:alice", - "llm_snapshot": llm_snapshot(), - } - settings = SimpleNamespace( +def _settings() -> SimpleNamespace: + return SimpleNamespace( dashboard_service_url="http://dashboard:3001", service_token_ttl_s=3600, jwt_secret="test-secret-at-least-32-bytes-long", @@ -63,23 +56,134 @@ async def test_owner_mutator_uses_frozen_snapshot_and_credential_reference( evolver_llm_timeout_s=45, ) + +def _run(snapshot: dict | None = None) -> dict: + return { + "requested_by_sub": "user:alice", + "llm_snapshot": snapshot or llm_snapshot(), + "llm_credential_grant": "signed-credential-grant", + } + + +@pytest.mark.asyncio +async def test_owner_mutator_uses_frozen_snapshot_and_credential_reference( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("inalpha_evolver.owner_llm.httpx.AsyncClient", _CredentialClient) + run = _run() + settings = _settings() + mutator = await build_owner_mutator(run, settings) # type: ignore[arg-type] assert _CredentialClient.kwargs["trust_env"] is False assert _CredentialClient.requested_url.endswith("/api/internal/llm-config/config-1") - credential_token = _CredentialClient.requested_headers["Authorization"].removeprefix("Bearer ") - credential_scope = jwt.decode( - credential_token, - settings.jwt_secret, - algorithms=[settings.jwt_algorithm], - ) - assert credential_scope["sub"] == "user:alice" - assert credential_scope["token_use"] == "evolver_credential" - assert credential_scope["config_id"] == "config-1" + assert _CredentialClient.requested_headers["Authorization"] == "Bearer signed-credential-grant" assert mutator.llm_client.settings.effective_api_key == "owner-test-key" assert mutator.llm_client.settings.llm_model == "deepseek-v4-pro" assert mutator.max_output_tokens == 8_192 assert "api_key" not in run["llm_snapshot"] + client = await mutator.llm_client._ensure_client() + assert type(client).__name__ == "AsyncOpenAI" + await mutator.close() + + +@pytest.mark.asyncio +async def test_owner_mutator_rejects_missing_snapshot_without_credential_fallback() -> None: + with pytest.raises(RuntimeError, match="missing frozen LLM snapshot"): + await build_owner_mutator( + {"requested_by_sub": "user:alice"}, + _settings(), # type: ignore[arg-type] + ) + + +@pytest.mark.asyncio +async def test_owner_mutator_rejects_non_official_frozen_endpoint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("inalpha_evolver.owner_llm.httpx.AsyncClient", _CredentialClient) + snapshot = llm_snapshot() + snapshot["base_url"] = "http://127.0.0.1:8080/v1" + + with pytest.raises(RuntimeError, match="official provider endpoint"): + await build_owner_mutator( + _run(snapshot), + _settings(), # type: ignore[arg-type] + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("status_code", [401, 404]) +async def test_owner_mutator_fails_closed_when_credential_service_rejects_request( + monkeypatch: pytest.MonkeyPatch, + status_code: int, +) -> None: + monkeypatch.setattr("inalpha_evolver.owner_llm.httpx.AsyncClient", _CredentialClient) + monkeypatch.setattr(_Response, "status_code", status_code) + + with pytest.raises(RuntimeError, match=f"HTTP {status_code}"): + await build_owner_mutator( + _run(), + _settings(), # type: ignore[arg-type] + ) + + +@pytest.mark.asyncio +async def test_owner_mutator_requeues_when_credential_service_is_unavailable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("inalpha_evolver.owner_llm.httpx.AsyncClient", _CredentialClient) + monkeypatch.setattr(_Response, "status_code", 503) + + with pytest.raises(CredentialTemporarilyUnavailable, match="HTTP 503"): + await build_owner_mutator(_run(), _settings()) # type: ignore[arg-type] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload, message", + [ + ( + {"config_id": "config-2", "provider": "deepseek", "api_key": "key"}, + "no longer matches frozen snapshot", + ), + ( + {"config_id": "config-1", "provider": "openai", "api_key": "key"}, + "no longer matches frozen snapshot", + ), + ( + {"config_id": "config-1", "provider": "deepseek", "api_key": ""}, + "omitted api_key", + ), + ], +) +async def test_owner_mutator_rejects_mismatched_or_empty_credentials( + monkeypatch: pytest.MonkeyPatch, + payload: dict[str, str], + message: str, +) -> None: + monkeypatch.setattr("inalpha_evolver.owner_llm.httpx.AsyncClient", _CredentialClient) + monkeypatch.setattr(_Response, "status_code", 200) + monkeypatch.setattr(_Response, "payload", payload) + + with pytest.raises(RuntimeError, match=message): + await build_owner_mutator( + _run(), + _settings(), # type: ignore[arg-type] + ) + + +@pytest.mark.asyncio +async def test_owner_mutator_propagates_credential_network_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + async def fail_get(*_args: object, **_kwargs: object) -> _Response: + raise httpx.ReadTimeout("credential lookup timed out") + + monkeypatch.setattr("inalpha_evolver.owner_llm.httpx.AsyncClient", _CredentialClient) + monkeypatch.setattr(_CredentialClient, "get", fail_get) + + with pytest.raises(CredentialTemporarilyUnavailable, match="ReadTimeout"): + await build_owner_mutator(_run(), _settings()) # type: ignore[arg-type] class _ClosableMutator: @@ -99,10 +203,25 @@ async def test_production_mutator_is_closed_but_injected_test_mutator_is_not( async def build(*_args: object) -> _ClosableMutator: return owner + class ConnectionContext: + async def __aenter__(self) -> object: + return object() + + async def __aexit__(self, *_args: object) -> None: + return None + + cleared: list[object] = [] + + async def clear(_conn: object, run_id: object) -> None: + cleared.append(run_id) + monkeypatch.setattr("inalpha_evolver.runtime.executor.build_owner_mutator", build) - async with _run_mutator({}, None, SimpleNamespace()): # type: ignore[arg-type] + monkeypatch.setattr("inalpha_evolver.runtime.executor.get_conn", ConnectionContext) + monkeypatch.setattr("inalpha_evolver.runtime.executor.runs.clear_credential_grant", clear) + async with _run_mutator({"run_id": "run-1"}, None, SimpleNamespace()): # type: ignore[arg-type] pass assert owner.closed is True + assert cleared == ["run-1"] injected = _ClosableMutator() async with _run_mutator({}, injected, SimpleNamespace()): # type: ignore[arg-type] diff --git a/services/evolver/tests/test_runtime_finalizer.py b/services/evolver/tests/test_runtime_finalizer.py index 28558493..165ee5a6 100644 --- a/services/evolver/tests/test_runtime_finalizer.py +++ b/services/evolver/tests/test_runtime_finalizer.py @@ -7,6 +7,7 @@ import pytest +from inalpha_evolver.owner_llm import CredentialTemporarilyUnavailable from inalpha_evolver.runtime.finalizer import execute_managed @@ -58,3 +59,43 @@ async def no_delay(_seconds): assert attempts == 2 assert unhealthy and "db unavailable" in unhealthy[0] + + +@pytest.mark.asyncio +async def test_temporary_credential_failure_requeues_without_terminal_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transitions: list[dict[str, object]] = [] + + async def unavailable(*_args, **_kwargs): + raise CredentialTemporarilyUnavailable("dashboard starting") + + async def transition(*_args, **kwargs): + transitions.append(kwargs) + return {} + + async def no_delay(_seconds): + return None + + monkeypatch.setattr("inalpha_evolver.runtime.finalizer.execute_run", unavailable) + monkeypatch.setattr("inalpha_evolver.runtime.finalizer.get_conn", lambda: _Connection()) + monkeypatch.setattr("inalpha_evolver.runtime.finalizer.runs.transition", transition) + monkeypatch.setattr("inalpha_evolver.runtime.finalizer.asyncio.sleep", no_delay) + + await execute_managed( + {"run_id": uuid4()}, + mutator=None, + settings=SimpleNamespace(evolver_run_timeout_s=10), + should_stop=lambda: False, + on_error=lambda _reason: None, + on_success=lambda: None, + ) + + assert len(transitions) == 1 + assert transitions[0]["to_status"] == "queued" + assert transitions[0]["values"] == { + "active_stage": None, + "started_at": None, + "failure_code": None, + "failure_message": None, + } diff --git a/services/evolver/tests/test_storage_integration.py b/services/evolver/tests/test_storage_integration.py index 74cea9be..f993466b 100644 --- a/services/evolver/tests/test_storage_integration.py +++ b/services/evolver/tests/test_storage_integration.py @@ -46,6 +46,7 @@ async def test_run_idempotency_owner_scope_and_slot() -> None: "budget": 2, "config": config, "llm_snapshot": llm_snapshot(), + "llm_credential_grant": "signed-grant-" + "x" * 120, "queued_at": now, } async with get_conn() as conn: diff --git a/services/evolver/uv.lock b/services/evolver/uv.lock index d73ab7da..8ef26925 100644 --- a/services/evolver/uv.lock +++ b/services/evolver/uv.lock @@ -486,6 +486,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httpcore2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "truststore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, +] + [[package]] name = "httptools" version = "0.8.0" @@ -537,6 +550,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx2" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "sys_platform == 'emscripten'" }, + { name = "idna" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, +] + [[package]] name = "idna" version = "3.18" @@ -548,13 +587,14 @@ wheels = [ [[package]] name = "inalpha-evolver" -version = "0.1.0" +version = "0.2.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, { name = "inalpha-paper" }, { name = "inalpha-shared" }, { name = "inalpha-shared-llm" }, + { name = "openai" }, { name = "psycopg", extra = ["binary", "pool"] }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -577,6 +617,7 @@ requires-dist = [ { name = "inalpha-paper", editable = "../paper" }, { name = "inalpha-shared", editable = "../_shared" }, { name = "inalpha-shared-llm", editable = "../_shared/llm" }, + { name = "openai", specifier = ">=2.38.0" }, { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.3.0" }, { name = "pydantic", specifier = ">=2.13.0" }, { name = "pydantic-settings", specifier = ">=2.14.0" }, @@ -1134,6 +1175,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b4/07/458c344f0f0c178f4481dad5cca790626ffe4c34eabf9467069d06ee4999/numpy-2.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:5f8e00be2ec6f45f4e8a41a527f68d44a7d96fee92a650e4d8b1326f77f61e6e", size = 10748103, upload-time = "2026-08-09T13:48:24.21Z" }, ] +[[package]] +name = "openai" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx2" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/d3/50ffb9a7bce5097ffeb476905c0661f4468a3ca7bb489b152542f14fdd8e/openai-3.5.0.tar.gz", hash = "sha256:743738bb458a586d0d02d173bf398d29d7d7a80d182d167aa74f1c08814ecc78", size = 1355486, upload-time = "2026-08-27T01:00:49.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/9f/03bba05ade050f306c9b9e8b99e3681791dbb30148cd76a793c277fed494/openai-3.5.0-py3-none-any.whl", hash = "sha256:7c0873d379655f65fa515c5692c8620e73bf6a6dce3e63f37589bc99bda3fdfd", size = 1697968, upload-time = "2026-08-27T01:00:47.131Z" }, +] + [[package]] name = "optuna" version = "4.9.0" @@ -1742,6 +1800,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, ] +[[package]] +name = "truststore" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" From 351f17f163c9ed7e00dca0a38576dc35143da32b Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 27 Aug 2026 20:02:49 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(evolver):=20=E6=94=B6=E7=B4=A7=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E9=87=8D=E8=AF=95=E4=B8=8E=E9=98=9F=E5=88=97=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 +- README.md | 7 ++- README.zh-CN.md | 6 +- .../internal/llm-config/[id]/route.test.ts | 31 +++++++++-- .../app/api/internal/llm-config/[id]/route.ts | 55 ++++++++++++------- docs/04-current-state.md | 9 ++- infra/.env.prod.example | 1 + infra/.env.selfhost.example | 1 + infra/docker-compose.prod.yml | 14 ++++- infra/migrations/tests/test_migration_0041.py | 39 ++++++++++++- .../versions/0041_evolution_llm_snapshot.py | 47 ++++++++++------ .../mastra/llm/evolution-credential-grant.ts | 2 +- .../tests/evolution-snapshot.test.ts | 5 +- .../tests/evolver-client.test.ts | 2 +- scripts/dev.sh | 7 ++- services/evolver/README.md | 3 +- .../evolver/src/inalpha_evolver/config.py | 7 +++ .../src/inalpha_evolver/mutator/llm_client.py | 10 ++-- .../evolver/src/inalpha_evolver/owner_llm.py | 2 +- .../src/inalpha_evolver/runtime/dispatcher.py | 7 ++- .../src/inalpha_evolver/runtime/finalizer.py | 55 ++++++++++++++----- .../inalpha_evolver/storage/run_queries.py | 15 ++++- .../src/inalpha_evolver/storage/runs.py | 6 +- .../evolver/tests/test_mutator_pricing.py | 2 +- .../evolver/tests/test_runtime_finalizer.py | 13 +++-- .../evolver/tests/test_storage_integration.py | 14 +++++ 26 files changed, 276 insertions(+), 87 deletions(-) diff --git a/.env.example b/.env.example index ae529dcc..132638ab 100644 --- a/.env.example +++ b/.env.example @@ -49,13 +49,14 @@ EVOLVER_ENABLED=true EVOLVER_POOL_SIZE=5 EVOLVER_MAX_RUNNING_RUNS=1 EVOLVER_ACCOUNT_ACTIVE_LIMIT=2 +EVOLVER_QUEUE_TIMEOUT_S=86400 EVOLVER_JOB_TIMEOUT_S=300 EVOLVER_RUN_TIMEOUT_S=1200 EVOLVER_JOB_MEM_GB=2 EVOLVER_LLM_TIMEOUT_S=120 # Evolver 仅用该地址按 owner/config_id 解析既有加密凭据;不会持久化明文 key。 DASHBOARD_SERVICE_URL=http://localhost:3001 -# Ed25519 DER 的 base64:私钥仅给 orchestration,公钥给 Dashboard 验证一次性 grant。 +# Ed25519 DER 的 base64:私钥仅给 orchestration,公钥给 Dashboard 验证逐操作 grant。 # 生成方式见 services/evolver/README.md;生产 compose 会显式从 Evolver 环境移除私钥。 EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64= EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64= diff --git a/README.md b/README.md index 680a3480..5303d071 100644 --- a/README.md +++ b/README.md @@ -233,7 +233,7 @@ Where each capability stands today. Live module inventory and the end-to-end dec | ✅ Shipped | Research → strategy → backtest lineage | D-8c | `deep_dive → compose_strategy → run_backtest` with `research_id` / `backtest_id` threaded through | | ✅ Shipped | LLM-authored strategies — E1 MVP | D-9 | three sandbox gates (AST · subprocess · `Strategy` contract) + multi-objective fitness + baseline auto-run | | ✅ Shipped | Strategy evolution — E1 production loop | E1 | `services/evolver:8005` · explicit cost-bearing approval · unified-diff mutation · frozen dataset/hash · seed/baseline/candidates evaluated on the same bars · owner-scoped async run/slot state | -| ✅ Shipped | Frozen LLM approval snapshot | E1 closure | Dashboard approve/deny · owner/operation/model/pricing binding · Ed25519 one-time credential grant · per-slot token/cost accounting, including rejected mutations | +| ✅ Shipped | Frozen LLM approval snapshot | E1 closure | Dashboard approve/deny · owner/operation/model/pricing binding · Ed25519 replay-safe credential grant · per-slot token/cost accounting, including rejected mutations | | ✅ Shipped | Risk engine at the HTTP boundary | D-9 | declarative `risk_rules.toml` · pre-trade `enforce` · `risk_locks` table with independent commit | | ✅ Shipped | Bull / bear researcher debate | D-9 | opposing-stance researchers under `services/research` | | ✅ Shipped | Scheduler / cron agent mode | D-9 | `scheduler_jobs` + advisory lock + `/api/scheduler/*` management plane | @@ -403,8 +403,9 @@ and an `en / 中` switcher in the sidebar. > The orchestrator and an explicitly approved `services/evolver` run can consume your owner-scoped > LLM key; `services/research` currently uses the deployment-level provider/key, and > `services/paper` never calls an LLM directly. Evolver resolves the encrypted credential just in -> time through an owner/operation-bound, one-time credential grant and stores only the frozen -> non-secret config/pricing snapshot after that grant is consumed. +> time through an owner/operation-bound credential grant. A lost response permits one exact-scope +> retry within two minutes; the queued grant is cleared after a successful exchange, and only the frozen +> non-secret config/pricing snapshot remains. > Prefer the manual multi-terminal flow, or want the low-level live > trace (the `mastra dev` playground at )? See [`AGENTS.md §4`](AGENTS.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 07ee71e9..614b4898 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -231,7 +231,7 @@ Inalpha 把*调度*和*算力*分开:agent runtime 负责扇出网格、聚合 | ✅ 已上线 | 研究 → 策略 → 回测 lineage | D-8c | `deep_dive → compose_strategy → run_backtest` 全链路串 `research_id` / `backtest_id` | | ✅ 已上线 | LLM 自创策略 — E1 MVP | D-9 | 三道沙盒(AST 审计 / 子进程 / `Strategy` 协议契约) + 多目标 fitness + baseline 自动并跑 | | ✅ 已上线 | 策略演化 — E1 生产闭环 | E1 | `services/evolver:8005` · 计费动作显式审批 · unified-diff 变异 · 冻结数据集/hash · seed/baseline/candidate 同 bars 评估 · owner 隔离异步 run/slot 状态机 | -| ✅ 已上线 | 冻结 LLM 审批快照 | E1 收口 | Dashboard 批准/拒绝 · owner/operation/模型/计价绑定 · Ed25519 一次性凭据 grant · 被拒变异也记 token/费用 | +| ✅ 已上线 | 冻结 LLM 审批快照 | E1 收口 | Dashboard 批准/拒绝 · owner/operation/模型/计价绑定 · Ed25519 可重试凭据 grant · 被拒变异也记 token/费用 | | ✅ 已上线 | 风控引擎落到 HTTP 边界 | D-9 | 声明式 `risk_rules.toml` · 撮合前 `enforce` · `risk_locks` 表(独立 commit) | | ✅ 已上线 | Bull / Bear 研究员辩论 | D-9 | `services/research` 立场对抗研究员 | | ✅ 已上线 | Scheduler / cron agent 模式 | D-9 | `scheduler_jobs` + advisory lock + `/api/scheduler/*` 管理面 | @@ -396,7 +396,9 @@ service 起着,它就能连上。内置**黑白双主题**(终端「印章 / > orchestrator 与经显式审批的 `services/evolver` run 会消耗 owner 自己的 LLM key; > `services/research` 当前使用部署级 provider/key,`services/paper` 从不直接调用 LLM。Evolver -> 通过绑定 owner/operation 且一次性消费的短效 grant 临时解析加密凭据;grant 兑换后清除, +> 通过绑定 owner/operation/config/digest 的短效 grant 临时解析加密凭据;首次响应丢失时, +> 同 scope 仅可在两分钟内补偿重试一次, +> 成功后从队列清除 grant, > run 内只保留冻结的非敏感配置/计价快照。想用多个独立 terminal 手动起,或看底层 live trace(`mastra dev` > playground )?见 [`AGENTS.md §4`](AGENTS.md)。 diff --git a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts index 26309bde..dcfd4746 100644 --- a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts +++ b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.test.ts @@ -27,7 +27,7 @@ async function token( ): Promise { const now = Math.floor(Date.now() / 1_000); let builder = new SignJWT({ - token_use: "evolver_credential", + token_use: "evolution_credential", config_id: "config-1", provider: "deepseek", operation_id: "operation-1", @@ -57,6 +57,7 @@ async function callRoute(authorization?: string, id = "config-1") { beforeEach(() => { vi.stubEnv("EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64", PUBLIC_KEY_B64); mockedDecryptUserApiKey.mockReset(); + mockedGetPool.mockReset(); mockedGetPool.mockReturnValue({ query: vi.fn().mockResolvedValue({ rowCount: 1 }), } as never); @@ -76,7 +77,7 @@ describe("internal owner LLM credential route", () => { const requests = [ callRoute(`Bearer ${await token({ token_use: "session" })}`), callRoute(`Bearer ${await token({}, { issuedAt: null })}`), - callRoute(`Bearer ${await token({}, { issuedAt: now - 3_700, expiresAt: now + 1 })}`), + callRoute(`Bearer ${await token({}, { issuedAt: now - 108_100, expiresAt: now + 1 })}`), callRoute(`Bearer ${await token({}, { issuedAt: now + 60, expiresAt: now + 120 })}`), callRoute(`Bearer ${await token({}, { issuedAt: now - 20, expiresAt: now - 10 })}`), callRoute(`Bearer ${await token({}, { otherKey: true })}`), @@ -93,10 +94,11 @@ describe("internal owner LLM credential route", () => { expect(mockedDecryptUserApiKey).not.toHaveBeenCalled(); }); - it("consumes each signed credential grant only once", async () => { + it("allows one exact-scope replay for a lost response, then rejects the grant", async () => { const query = vi .fn() .mockResolvedValueOnce({ rowCount: 1 }) + .mockResolvedValueOnce({ rowCount: 1 }) .mockResolvedValueOnce({ rowCount: 0 }); mockedGetPool.mockReturnValue({ query } as never); mockedDecryptUserApiKey.mockResolvedValue({ @@ -106,9 +108,23 @@ describe("internal owner LLM credential route", () => { } as never); const grant = await token(); + expect((await callRoute(`Bearer ${grant}`)).status).toBe(200); expect((await callRoute(`Bearer ${grant}`)).status).toBe(200); expect((await callRoute(`Bearer ${grant}`)).status).toBe(409); - expect(mockedDecryptUserApiKey).toHaveBeenCalledTimes(1); + expect(mockedDecryptUserApiKey).toHaveBeenCalledTimes(3); + }); + + it("rejects a reused jti whose recorded scope differs", async () => { + mockedGetPool.mockReturnValue({ + query: vi.fn().mockResolvedValue({ rowCount: 0 }), + } as never); + mockedDecryptUserApiKey.mockResolvedValue({ + id: "config-1", + provider: "deepseek", + api_key: "owner-key", + } as never); + + expect((await callRoute(`Bearer ${await token()}`)).status).toBe(409); }); it("returns only the requested owner's decrypted config without caching", async () => { @@ -144,4 +160,11 @@ describe("internal owner LLM credential route", () => { expect((await callRoute(`Bearer ${await token()}`)).status).toBe(404); }); + + it("returns a retryable error when the encrypted credential store is unavailable", async () => { + mockedDecryptUserApiKey.mockRejectedValue(new Error("database unavailable")); + + expect((await callRoute(`Bearer ${await token()}`)).status).toBe(503); + expect(mockedGetPool).not.toHaveBeenCalled(); + }); }); diff --git a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts index 12180346..3ec62a40 100644 --- a/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts +++ b/apps/dashboard/src/app/api/internal/llm-config/[id]/route.ts @@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from "next/server"; import { decryptUserApiKey } from "@/lib/user-preferences"; import { getPool } from "@/lib/db"; -const MAX_CREDENTIAL_TTL_SECONDS = 3_600; +const MAX_CREDENTIAL_TTL_SECONDS = 30 * 60 * 60; const GRANT_AUDIENCE = "inalpha-dashboard-credential"; function publicKey(): ReturnType { @@ -36,7 +36,7 @@ export async function GET( const expiresAt = payload.exp; const now = Math.floor(Date.now() / 1_000); if ( - payload.token_use !== "evolver_credential" || + payload.token_use !== "evolution_credential" || payload.config_id !== configId || typeof payload.operation_id !== "string" || payload.operation_id.length < 8 || @@ -56,12 +56,30 @@ export async function GET( } subject = payload.sub; - let consumed; + let config; try { - consumed = await getPool().query( + config = await decryptUserApiKey(subject, configId); + } catch { + return NextResponse.json({ error: "credential_store_unavailable" }, { status: 503 }); + } + if (!config) return NextResponse.json({ error: "not_found" }, { status: 404 }); + + let recorded; + try { + recorded = await getPool().query( `INSERT INTO evolution_credential_grant_uses (jti,owner_sub,config_id,operation_id,config_digest,consumed_at) - VALUES ($1,$2,$3,$4,$5,NOW()) ON CONFLICT (jti) DO NOTHING RETURNING jti`, + VALUES ($1,$2,$3,$4,$5,NOW()) + ON CONFLICT (jti) DO UPDATE SET + redemption_count=evolution_credential_grant_uses.redemption_count+1, + last_redeemed_at=NOW() + WHERE evolution_credential_grant_uses.owner_sub=EXCLUDED.owner_sub + AND evolution_credential_grant_uses.config_id=EXCLUDED.config_id + AND evolution_credential_grant_uses.operation_id=EXCLUDED.operation_id + AND evolution_credential_grant_uses.config_digest=EXCLUDED.config_digest + AND evolution_credential_grant_uses.redemption_count<2 + AND evolution_credential_grant_uses.consumed_at>=NOW()-INTERVAL '2 minutes' + RETURNING jti`, [ payload.jti, subject, @@ -73,23 +91,20 @@ export async function GET( } catch { return NextResponse.json({ error: "credential_ledger_unavailable" }, { status: 503 }); } - if (consumed.rowCount !== 1) { - return NextResponse.json({ error: "credential_grant_consumed" }, { status: 409 }); + if (recorded.rowCount !== 1) { + return NextResponse.json({ error: "credential_grant_scope_conflict" }, { status: 409 }); } + return NextResponse.json( + { + config_id: config.id, + provider: config.provider, + model: config.model ?? null, + base_url: config.custom_base_url ?? null, + api_key: config.api_key, + }, + { headers: { "Cache-Control": "no-store" } }, + ); } catch { return NextResponse.json({ error: "unauthorized" }, { status: 401 }); } - - const config = await decryptUserApiKey(subject, configId); - if (!config) return NextResponse.json({ error: "not_found" }, { status: 404 }); - return NextResponse.json( - { - config_id: config.id, - provider: config.provider, - model: config.model ?? null, - base_url: config.custom_base_url ?? null, - api_key: config.api_key, - }, - { headers: { "Cache-Control": "no-store" } }, - ); } diff --git a/docs/04-current-state.md b/docs/04-current-state.md index 6d39b5ea..cd2a57d5 100644 --- a/docs/04-current-state.md +++ b/docs/04-current-state.md @@ -200,11 +200,16 @@ seed / buy-and-hold / 全候选同数据哈希评估、owner 隔离、数据库 - 编排层在展示审批前冻结 `config_id/provider/model/base_url/pricing/version/最大单候选估算`, 生成稳定 `config_digest` 和 operation id;批准后 5 分钟 JWT 同时绑定 owner、operation 与 digest。 - Evolver 创建 run 时校验审批 JWT 与 snapshot digest,再持久化非密钥 `llm_snapshot`;数据库 - check constraint 要求新写入具备完整快照,历史行不伪造元数据。 + check constraint 要求新写入具备完整快照,升级前仍在 queued/running 的历史任务会显式 + abort,避免在缺少冻结授权的情况下继续执行。 - 批准后由 orchestration 用独立 Ed25519 私钥签发短时 credential grant,绑定 owner、operation、 config 与 digest;Evolver 只转交、不能签发。Dashboard 用公钥验签并以 `jti` 在 PostgreSQL - 一次性消费,再按 owner + config_id 即时解密 API key;队列中的 grant 兑换后立即清除, + 记录并校验同 scope 的兑换;首次响应丢失时仅允许 2 分钟内补偿重试一次,再按 owner + + config_id 即时解密 API key;队列中的 grant + 在成功兑换后立即清除, 明文 key 不写入 snapshot、run/candidate、异常或日志。 +- credential grant 有效期 30 小时,queued 最长保留 24 小时;超时任务以 + `EVOLUTION_QUEUE_TIMEOUT` 显式 abort,不会等到执行时才因凭据过期失败。 - 演化只接受每家已有冻结价格条目的精确模型;未知模型 fail closed。DeepSeek 官方 `https://api.deepseek.com` 与 `/v1` alias 统一规范化,不会误判为自定义代理。 - 输入 prompt 以 UTF-8 字节数保守约束在冻结 input token 上限内,输出 token 同样硬限制; diff --git a/infra/.env.prod.example b/infra/.env.prod.example index bf0ea46c..4eba010d 100644 --- a/infra/.env.prod.example +++ b/infra/.env.prod.example @@ -54,6 +54,7 @@ GEMINI_API_KEY= # ---- E1 演化运行时 ---- EVOLVER_MAX_RUNNING_RUNS=1 EVOLVER_ACCOUNT_ACTIVE_LIMIT=2 +EVOLVER_QUEUE_TIMEOUT_S=86400 EVOLVER_JOB_TIMEOUT_S=300 EVOLVER_JOB_MEM_GB=2 EVOLVER_LLM_TIMEOUT_S=120 diff --git a/infra/.env.selfhost.example b/infra/.env.selfhost.example index 3fd67b57..3a8bb590 100644 --- a/infra/.env.selfhost.example +++ b/infra/.env.selfhost.example @@ -33,6 +33,7 @@ EVOLVER_ENABLED=true # E1 evolution runtime (single worker / replica) EVOLVER_MAX_RUNNING_RUNS=1 EVOLVER_ACCOUNT_ACTIVE_LIMIT=2 +EVOLVER_QUEUE_TIMEOUT_S=86400 EVOLVER_JOB_TIMEOUT_S=300 EVOLVER_JOB_MEM_GB=2 EVOLVER_LLM_TIMEOUT_S=120 diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index 7b571ed3..c714a9f8 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -77,6 +77,8 @@ services: container_name: inalpha-migrate restart: "no" env_file: ["../infra/${ENV_FILE:-.env.prod}"] + environment: + EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64: "" depends_on: postgres: condition: service_healthy @@ -95,6 +97,7 @@ services: build: { <<: *python-build, args: { SERVICE: data } } container_name: inalpha-data environment: + EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64: "" PORT: 8001 WORKERS: 2 # web-search 堵事件循环 → 多 worker 缓解 healthcheck: @@ -110,6 +113,7 @@ services: build: { <<: *python-build, args: { SERVICE: paper } } container_name: inalpha-paper environment: + EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64: "" PORT: 8002 WORKERS: 1 # 硬约束:in-process live runner,必须单进程/单副本 deploy: @@ -126,7 +130,10 @@ services: image: ${IMAGE_PREFIX:-ghcr.io/__CHANGE_ME__}/inalpha-research:${IMAGE_TAG:-latest} build: { <<: *python-build, args: { SERVICE: research } } container_name: inalpha-research - environment: { PORT: 8003, WORKERS: 1 } + environment: + EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64: "" + PORT: 8003 + WORKERS: 1 healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8003/health', timeout=2)"] interval: 10s @@ -139,7 +146,10 @@ services: image: ${IMAGE_PREFIX:-ghcr.io/__CHANGE_ME__}/inalpha-factor:${IMAGE_TAG:-latest} build: { <<: *python-build, args: { SERVICE: factor } } container_name: inalpha-factor - environment: { PORT: 8004, WORKERS: 1 } + environment: + EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64: "" + PORT: 8004 + WORKERS: 1 healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8004/health', timeout=2)"] interval: 10s diff --git a/infra/migrations/tests/test_migration_0041.py b/infra/migrations/tests/test_migration_0041.py index 58b5ffdb..dedfc372 100644 --- a/infra/migrations/tests/test_migration_0041.py +++ b/infra/migrations/tests/test_migration_0041.py @@ -56,25 +56,47 @@ def _insert_run( ) -def test_0041_preserves_old_rows_and_enforces_new_snapshots( +def test_0041_aborts_legacy_work_and_enforces_new_snapshots( migration_db_url: str, ) -> None: alembic(migration_db_url, "upgrade", "0040") with psycopg.connect(db_url(migration_db_url), autocommit=True) as conn: _insert_run(conn, _LEGACY_RUN, "legacy-before-0041") + conn.execute( + """INSERT INTO strategy_evo_candidates + (candidate_id,run_id,generation,slot,stage,outcome,updated_at) + VALUES ('40000000-0000-0000-0000-000000000001',%s,0,0,'queued','pending',NOW())""", + (_LEGACY_RUN,), + ) alembic(migration_db_url, "upgrade", "0041") with psycopg.connect(db_url(migration_db_url), autocommit=True) as conn: assert conn.execute( - """SELECT llm_snapshot,llm_config_digest,llm_snapshot_required, + """SELECT status,failure_code,llm_snapshot,llm_config_digest,llm_snapshot_required, llm_credential_grant_required FROM strategy_evo_runs WHERE run_id=%s""", (_LEGACY_RUN,), - ).fetchone() == (None, None, False, False) + ).fetchone() == ( + "aborted", + "EVOLUTION_UPGRADE_ABORTED", + None, + None, + False, + False, + ) + assert conn.execute( + """SELECT stage,outcome,error_code FROM strategy_evo_candidates + WHERE candidate_id='40000000-0000-0000-0000-000000000001'""" + ).fetchone() == ("completed", "cancelled", "EVOLUTION_UPGRADE_ABORTED") conn.execute( "UPDATE strategy_evo_runs SET updated_at=NOW() WHERE run_id=%s", (_LEGACY_RUN,), ) + with pytest.raises(psycopg.errors.CheckViolation): + conn.execute( + "UPDATE strategy_evo_runs SET status='queued' WHERE run_id=%s", + (_LEGACY_RUN,), + ) validated = conn.execute( "SELECT convalidated FROM pg_constraint WHERE conname='evo_run_llm_snapshot_check'" ).fetchone() @@ -95,6 +117,17 @@ def test_0041_preserves_old_rows_and_enforces_new_snapshots( FROM strategy_evo_runs WHERE run_id=%s""", (_VALID_RUN,), ).fetchone() == (True, True) + with pytest.raises(psycopg.errors.CheckViolation): + conn.execute( + """UPDATE strategy_evo_runs + SET llm_credential_grant=NULL,llm_credential_grant_required=FALSE + WHERE run_id=%s""", + (_VALID_RUN,), + ) + conn.execute( + """UPDATE strategy_evo_runs SET status='running' WHERE run_id=%s""", + (_VALID_RUN,), + ) conn.execute( """UPDATE strategy_evo_runs SET llm_credential_grant=NULL,llm_credential_grant_required=FALSE diff --git a/infra/migrations/versions/0041_evolution_llm_snapshot.py b/infra/migrations/versions/0041_evolution_llm_snapshot.py index 1f9c1666..0bc3a56e 100644 --- a/infra/migrations/versions/0041_evolution_llm_snapshot.py +++ b/infra/migrations/versions/0041_evolution_llm_snapshot.py @@ -11,7 +11,7 @@ def upgrade() -> None: - """Enforce snapshots for new writes without fabricating metadata for old rows.""" + """Abort unapproved legacy work, then enforce snapshots for every new queued run.""" op.execute("SET LOCAL lock_timeout = '10s'") op.execute( """ALTER TABLE strategy_evo_runs @@ -20,25 +20,38 @@ def upgrade() -> None: ADD COLUMN llm_credential_grant TEXT, ADD COLUMN llm_credential_grant_required BOOLEAN NOT NULL DEFAULT FALSE, ADD COLUMN llm_snapshot_required BOOLEAN NOT NULL DEFAULT FALSE; +UPDATE strategy_evo_candidates AS candidate SET stage='completed',outcome='cancelled', +error_code='EVOLUTION_UPGRADE_ABORTED', +error_message='run queued before owner LLM snapshot migration',updated_at=NOW() +FROM strategy_evo_runs AS run +WHERE candidate.run_id=run.run_id AND candidate.outcome='pending' + AND run.status IN ('queued','running','cancelling'); +UPDATE strategy_evo_runs SET status='aborted',active_stage='aborted', +finished_at=NOW(),updated_at=NOW(),failure_code='EVOLUTION_UPGRADE_ABORTED', +failure_message='run queued before owner LLM snapshot migration' +WHERE status IN ('queued','running','cancelling'); ALTER TABLE strategy_evo_runs ALTER COLUMN llm_snapshot_required SET DEFAULT TRUE, ALTER COLUMN llm_credential_grant_required SET DEFAULT TRUE; ALTER TABLE strategy_evo_runs ADD CONSTRAINT evo_run_llm_snapshot_check CHECK ( - NOT llm_snapshot_required - OR ( - llm_snapshot IS NOT NULL - AND llm_config_digest IS NOT NULL - AND ( - NOT llm_credential_grant_required - OR length(COALESCE(llm_credential_grant,''))>=100 + (status <> 'queued' OR (llm_snapshot_required AND llm_credential_grant_required)) + AND ( + NOT llm_snapshot_required + OR ( + llm_snapshot IS NOT NULL + AND llm_config_digest IS NOT NULL + AND ( + NOT llm_credential_grant_required + OR length(COALESCE(llm_credential_grant,''))>=100 + ) + AND llm_config_digest ~ '^[0-9a-f]{64}$' + AND COALESCE(llm_snapshot->>'config_digest'=llm_config_digest,FALSE) + AND length(COALESCE(llm_snapshot->>'config_id',''))>0 + AND length(COALESCE(llm_snapshot->>'provider',''))>0 + AND length(COALESCE(llm_snapshot->>'model',''))>0 + AND COALESCE(jsonb_typeof(llm_snapshot->'pricing')='object',FALSE) ) - AND llm_config_digest ~ '^[0-9a-f]{64}$' - AND COALESCE(llm_snapshot->>'config_digest'=llm_config_digest,FALSE) - AND length(COALESCE(llm_snapshot->>'config_id',''))>0 - AND length(COALESCE(llm_snapshot->>'provider',''))>0 - AND length(COALESCE(llm_snapshot->>'model',''))>0 - AND COALESCE(jsonb_typeof(llm_snapshot->'pricing')='object',FALSE) ) ) NOT VALID; CREATE TABLE evolution_credential_grant_uses ( @@ -47,7 +60,9 @@ def upgrade() -> None: config_id TEXT NOT NULL, operation_id TEXT NOT NULL, config_digest TEXT NOT NULL CHECK (config_digest ~ '^[0-9a-f]{64}$'), - consumed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + consumed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + redemption_count SMALLINT NOT NULL DEFAULT 1 CHECK (redemption_count BETWEEN 1 AND 2) ); CREATE INDEX ix_evolution_credential_grant_uses_consumed_at ON evolution_credential_grant_uses(consumed_at);""" @@ -55,7 +70,7 @@ def upgrade() -> None: def downgrade() -> None: - """Remove only metadata columns; no business row is changed or deleted.""" + """Remove metadata columns; intentionally do not resurrect upgrade-aborted work.""" op.execute("SET LOCAL lock_timeout = '10s'") op.execute( """DROP TABLE evolution_credential_grant_uses; diff --git a/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts b/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts index e64e7f45..b0374e41 100644 --- a/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts +++ b/packages/orchestration/src/mastra/llm/evolution-credential-grant.ts @@ -6,7 +6,7 @@ import { SignJWT } from "jose"; import type { EvolutionLLMSnapshot } from "./evolution-snapshot.js"; const GRANT_AUDIENCE = "inalpha-dashboard-credential"; -const GRANT_TTL_SECONDS = 3_600; +const GRANT_TTL_SECONDS = 30 * 60 * 60; /** * 为一次已审批的演化操作签发短效凭据 capability。 diff --git a/packages/orchestration/tests/evolution-snapshot.test.ts b/packages/orchestration/tests/evolution-snapshot.test.ts index ce8e9289..6230aaba 100644 --- a/packages/orchestration/tests/evolution-snapshot.test.ts +++ b/packages/orchestration/tests/evolution-snapshot.test.ts @@ -60,8 +60,11 @@ describe("evolution LLM snapshot", () => { }); it("rejects credentials, query strings, and non-HTTP base URLs", () => { + const credentialUrl = new URL("https://example.com/v1"); + credentialUrl.username = "user"; + credentialUrl.password = "pass"; for (const custom_base_url of [ - "https://user:pass@example.com/v1", + credentialUrl.toString(), "https://example.com/v1?token=x", "ftp://example.com/v1", ]) { diff --git a/packages/orchestration/tests/evolver-client.test.ts b/packages/orchestration/tests/evolver-client.test.ts index c03ccb5f..f0825ee9 100644 --- a/packages/orchestration/tests/evolver-client.test.ts +++ b/packages/orchestration/tests/evolver-client.test.ts @@ -90,7 +90,7 @@ describe("EvolverClient", () => { operation_id: "approval-operation-1", llm_config_digest: snapshot.config_digest, }); - expect(Number(credential.exp) - Number(credential.iat)).toBe(3_600); + expect(Number(credential.exp) - Number(credential.iat)).toBe(108_000); }); it("retries 502/504 with the same approval-derived operation ID", async () => { diff --git a/scripts/dev.sh b/scripts/dev.sh index 5439ae20..1e0781d4 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -139,6 +139,7 @@ start_service() { local name="$1" local cwd="$2" local cmd="$3" + local allow_credential_signing="${4:-0}" local log="${LOG_DIR}/${name}.log" local pid_file="${PID_DIR}/${name}.pid" @@ -150,6 +151,9 @@ start_service() { echo "[up] $name → $log" ( cd "$cwd" + if [[ "$allow_credential_signing" != "1" ]]; then + unset EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64 + fi # shellcheck disable=SC2086 nohup $cmd >"$log" 2>&1 & echo $! > "$pid_file" @@ -318,7 +322,8 @@ case "$CMD" in "uv run uvicorn inalpha_evolver.main:app --host 127.0.0.1 --port 8005 --reload" start_service "orchestration" \ "${ROOT}/packages/orchestration" \ - "pnpm dev" + "pnpm dev" \ + "1" if (( NO_WAIT == 1 )); then echo "" diff --git a/services/evolver/README.md b/services/evolver/README.md index d10b979e..f6af223d 100644 --- a/services/evolver/README.md +++ b/services/evolver/README.md @@ -49,7 +49,7 @@ Dashboard / orchestration | `evaluator/` | frozen dataset 回测、子进程资源限制与 fitness | | `runtime/` | 异步 dispatcher、slot 并发、取消、超时与终态收口 | | `storage/` | PostgreSQL run/candidate 持久化与 owner-scoped 查询 | -| `owner_llm.py` | 转交短时、逐操作且一次性消费的 credential grant,读取 owner 模型配置 | +| `owner_llm.py` | 转交短时、逐操作且同 scope 可重试的 credential grant,读取 owner 模型配置 | ## HTTP API @@ -85,6 +85,7 @@ Dashboard / orchestration | `EVOLVER_POOL_SIZE` | PostgreSQL 连接池大小 | | `EVOLVER_MAX_RUNNING_RUNS` | 服务级同时运行上限 | | `EVOLVER_ACCOUNT_ACTIVE_LIMIT` | 单 owner active run 上限 | +| `EVOLVER_QUEUE_TIMEOUT_S` | queued 最长等待(默认 24 小时,超时显式 abort;短于 grant 的 30 小时 TTL) | | `EVOLVER_JOB_TIMEOUT_S` / `EVOLVER_RUN_TIMEOUT_S` | 单候选与整次 run 超时 | | `EVOLVER_JOB_MEM_GB` | 回测子进程内存上限 | | `EVOLVER_LLM_TIMEOUT_S` | 单次 LLM 变异超时 | diff --git a/services/evolver/src/inalpha_evolver/config.py b/services/evolver/src/inalpha_evolver/config.py index 24bff9cf..4e881f5f 100644 --- a/services/evolver/src/inalpha_evolver/config.py +++ b/services/evolver/src/inalpha_evolver/config.py @@ -57,6 +57,13 @@ class EvolverSettings(BaseSettings): ge=1, le=8, ) + evolver_queue_timeout_s: int = Field( + default=86400, + alias="EVOLVER_QUEUE_TIMEOUT_S", + ge=60, + le=86400, + description="queued run 最长等待时间;必须短于凭据 grant 的 48 小时有效期。", + ) evolver_account_active_limit: int = Field( default=2, alias="EVOLVER_ACCOUNT_ACTIVE_LIMIT", diff --git a/services/evolver/src/inalpha_evolver/mutator/llm_client.py b/services/evolver/src/inalpha_evolver/mutator/llm_client.py index f68a4b5a..948d2ad5 100644 --- a/services/evolver/src/inalpha_evolver/mutator/llm_client.py +++ b/services/evolver/src/inalpha_evolver/mutator/llm_client.py @@ -77,7 +77,7 @@ class Mutator: max_fuzz: int = 3 input_usd_per_million: float | None = None output_usd_per_million: float | None = None - max_input_tokens: int = 24_000 + max_input_utf8_bytes: int = 24_000 max_output_tokens: int = 8192 def __post_init__(self) -> None: @@ -88,8 +88,8 @@ def __post_init__(self) -> None: raise ValueError("pricing rates must be positive") if self.max_output_tokens <= 0: raise ValueError("max_output_tokens must be positive") - if self.max_input_tokens <= 0: - raise ValueError("max_input_tokens must be positive") + if self.max_input_utf8_bytes <= 0: + raise ValueError("max_input_utf8_bytes must be positive") async def mutate( self, @@ -113,8 +113,8 @@ async def mutate( """ user_prompt = build_user_prompt(current_source, report, hint) prompt_bytes = len(SYSTEM_PROMPT.encode()) + len(user_prompt.encode()) - if prompt_bytes > self.max_input_tokens: - raise LLMError("LLM 变异输入超过已审批上限;请缩短种子策略或回测摘要后重新审批") + if prompt_bytes > self.max_input_utf8_bytes: + raise LLMError("LLM 变异输入超过已审批上限(按 UTF-8 字节保守校验);请缩短输入") request = MutationRequest( system_prompt=SYSTEM_PROMPT, user_prompt=user_prompt, diff --git a/services/evolver/src/inalpha_evolver/owner_llm.py b/services/evolver/src/inalpha_evolver/owner_llm.py index a338012e..6606a75f 100644 --- a/services/evolver/src/inalpha_evolver/owner_llm.py +++ b/services/evolver/src/inalpha_evolver/owner_llm.py @@ -81,7 +81,7 @@ async def build_owner_mutator( llm_client=LLMClient(settings=llm_settings), input_usd_per_million=float(pricing["input_usd_per_million"]), output_usd_per_million=float(pricing["output_usd_per_million"]), - max_input_tokens=int(pricing["assumed_input_tokens"]), + max_input_utf8_bytes=int(pricing["assumed_input_tokens"]), max_output_tokens=int(pricing["max_output_tokens"]), ) diff --git a/services/evolver/src/inalpha_evolver/runtime/dispatcher.py b/services/evolver/src/inalpha_evolver/runtime/dispatcher.py index 05fc9fb5..79286964 100644 --- a/services/evolver/src/inalpha_evolver/runtime/dispatcher.py +++ b/services/evolver/src/inalpha_evolver/runtime/dispatcher.py @@ -19,7 +19,12 @@ async def dispatch_runs(manager: Any) -> None: await manager.semaphore.acquire() acquired = True async with get_conn() as conn: - run = await run_queries.claim_next(conn) + queue_timeout_s = getattr(manager.settings, "evolver_queue_timeout_s", None) + run = ( + await run_queries.claim_next(conn, queue_timeout_s=queue_timeout_s) + if queue_timeout_s is not None + else await run_queries.claim_next(conn) + ) manager.unhealthy_reason = None delay = 0.1 if run is None: diff --git a/services/evolver/src/inalpha_evolver/runtime/finalizer.py b/services/evolver/src/inalpha_evolver/runtime/finalizer.py index 7b1a84b4..63c89a0c 100644 --- a/services/evolver/src/inalpha_evolver/runtime/finalizer.py +++ b/services/evolver/src/inalpha_evolver/runtime/finalizer.py @@ -55,19 +55,12 @@ async def execute_managed( ) except CredentialTemporarilyUnavailable: await asyncio.sleep(2.0) - async with get_conn() as conn: - await runs.transition( - conn, - run["run_id"], - from_statuses=("running",), - to_status="queued", - values={ - "active_stage": None, - "started_at": None, - "failure_code": None, - "failure_message": None, - }, - ) + await _requeue( + run["run_id"], + should_stop=should_stop, + on_error=on_error, + on_success=on_success, + ) except Exception as exc: await _finalize( run["run_id"], @@ -132,4 +125,40 @@ class _RunTimeoutError(TimeoutError): code = "EVOLUTION_RUN_TIMEOUT" +async def _requeue( + run_id: UUID, + *, + should_stop: Callable[[], bool], + on_error: Callable[[str], None], + on_success: Callable[[], None], +) -> None: + """凭据依赖暂不可用时重试状态写入,避免 run 永久卡在 running。""" + delay = 0.1 + while True: + try: + async with get_conn() as conn: + await runs.transition( + conn, + run_id, + from_statuses=("running",), + to_status="queued", + values={ + "active_stage": None, + "started_at": None, + "failure_code": None, + "failure_message": None, + }, + ) + on_success() + return + except asyncio.CancelledError: + raise + except Exception as exc: + on_error(f"requeue {run_id} failed: {type(exc).__name__}: {exc}") + if should_stop(): + return + await asyncio.sleep(delay) + delay = min(delay * 2, 5.0) + + __all__ = ["execute_managed"] diff --git a/services/evolver/src/inalpha_evolver/storage/run_queries.py b/services/evolver/src/inalpha_evolver/storage/run_queries.py index 34a17144..d823afb6 100644 --- a/services/evolver/src/inalpha_evolver/storage/run_queries.py +++ b/services/evolver/src/inalpha_evolver/storage/run_queries.py @@ -40,10 +40,21 @@ async def list_runs( return [dict(row) for row in rows] -async def claim_next(conn: AsyncConnection) -> dict[str, Any] | None: - """锁定最早 queued run 并原子切换为 running。""" +async def claim_next( + conn: AsyncConnection, + *, + queue_timeout_s: int = 86400, +) -> dict[str, Any] | None: + """先收口过期队列项,再锁定最早 queued run 并原子切换为 running。""" now = datetime.now(UTC) async with conn.cursor() as cur: + await cur.execute( + """UPDATE strategy_evo_runs SET status='aborted',active_stage='aborted', +finished_at=%s,updated_at=%s,failure_code='EVOLUTION_QUEUE_TIMEOUT', +failure_message='run exceeded its queue deadline' +WHERE status='queued' AND queued_at < %s - make_interval(secs => %s)""", + (now, now, now, queue_timeout_s), + ) await cur.execute( f"""WITH picked AS(SELECT run_id FROM strategy_evo_runs WHERE status='queued' ORDER BY queued_at FOR UPDATE SKIP LOCKED LIMIT 1)UPDATE strategy_evo_runs r SET diff --git a/services/evolver/src/inalpha_evolver/storage/runs.py b/services/evolver/src/inalpha_evolver/storage/runs.py index 40242853..e38f9eab 100644 --- a/services/evolver/src/inalpha_evolver/storage/runs.py +++ b/services/evolver/src/inalpha_evolver/storage/runs.py @@ -93,12 +93,14 @@ async def get_run( async def clear_credential_grant(conn: AsyncConnection, run_id: UUID) -> None: """凭据 capability 兑换成功后立即从持久化队列清除。""" - await conn.execute( + result = await conn.execute( """UPDATE strategy_evo_runs SET llm_credential_grant=NULL,llm_credential_grant_required=FALSE - WHERE run_id=%s""", + WHERE run_id=%s AND status='running'""", (run_id,), ) + if result.rowcount != 1: + raise RuntimeError("credential grant can only be cleared for a running run") async def transition( diff --git a/services/evolver/tests/test_mutator_pricing.py b/services/evolver/tests/test_mutator_pricing.py index a38dd652..8845e6d3 100644 --- a/services/evolver/tests/test_mutator_pricing.py +++ b/services/evolver/tests/test_mutator_pricing.py @@ -78,7 +78,7 @@ async def test_mutator_rejects_input_above_approved_budget_before_calling_provid llm_client=client, # type: ignore[arg-type] input_usd_per_million=2.0, output_usd_per_million=10.0, - max_input_tokens=100, + max_input_utf8_bytes=100, ) with pytest.raises(LLMError, match="超过已审批上限"): diff --git a/services/evolver/tests/test_runtime_finalizer.py b/services/evolver/tests/test_runtime_finalizer.py index 165ee5a6..50a1a3a9 100644 --- a/services/evolver/tests/test_runtime_finalizer.py +++ b/services/evolver/tests/test_runtime_finalizer.py @@ -66,11 +66,15 @@ async def test_temporary_credential_failure_requeues_without_terminal_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: transitions: list[dict[str, object]] = [] + unhealthy: list[str] = [] async def unavailable(*_args, **_kwargs): raise CredentialTemporarilyUnavailable("dashboard starting") async def transition(*_args, **kwargs): + if not transitions: + transitions.append({"failed_attempt": True}) + raise RuntimeError("db temporarily unavailable") transitions.append(kwargs) return {} @@ -87,15 +91,16 @@ async def no_delay(_seconds): mutator=None, settings=SimpleNamespace(evolver_run_timeout_s=10), should_stop=lambda: False, - on_error=lambda _reason: None, + on_error=unhealthy.append, on_success=lambda: None, ) - assert len(transitions) == 1 - assert transitions[0]["to_status"] == "queued" - assert transitions[0]["values"] == { + assert len(transitions) == 2 + assert transitions[1]["to_status"] == "queued" + assert transitions[1]["values"] == { "active_stage": None, "started_at": None, "failure_code": None, "failure_message": None, } + assert unhealthy and "db temporarily unavailable" in unhealthy[0] diff --git a/services/evolver/tests/test_storage_integration.py b/services/evolver/tests/test_storage_integration.py index f993466b..150e9637 100644 --- a/services/evolver/tests/test_storage_integration.py +++ b/services/evolver/tests/test_storage_integration.py @@ -52,12 +52,26 @@ async def test_run_idempotency_owner_scope_and_slot() -> None: async with get_conn() as conn: row, created = await runs.insert_run(conn, **kwargs) repeated, created_again = await runs.insert_run(conn, **kwargs) + stale_kwargs = { + **kwargs, + "idempotency_key": f"stale-{uuid4()}", + "request_hash": "hash-stale", + "queued_at": now - timedelta(days=2), + } + stale, _ = await runs.insert_run(conn, **stale_kwargs) assert created is True assert created_again is False assert repeated["run_id"] == row["run_id"] assert await runs.get_run(conn, row["run_id"], other) is None claimed = await run_queries.claim_next(conn) assert claimed and claimed["status"] == "running" + assert claimed["run_id"] == row["run_id"] + stale_after = await runs.get_run(conn, stale["run_id"], owner) + assert stale_after and stale_after["status"] == "aborted" + assert stale_after["failure_code"] == "EVOLUTION_QUEUE_TIMEOUT" + await runs.clear_credential_grant(conn, claimed["run_id"]) + claimed_after = await runs.get_run(conn, claimed["run_id"], owner) + assert claimed_after and claimed_after["llm_credential_grant"] is None slot = await candidates.insert_slot(conn, row["run_id"], 0, "hint") assert slot["slot"] == 0 assert await candidates.list_candidates(conn, row["run_id"], other) == [] From 92c6d2b14f14eda3c30511bcd255ca4f88e216c8 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 27 Aug 2026 20:18:39 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(selfhost):=20=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=E6=BC=94=E5=8C=96=E5=87=AD=E6=8D=AE=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E5=AF=86=E9=92=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 ++++-- apps/dashboard/README.md | 7 +++++-- infra/README.md | 3 +++ scripts/selfhost.sh | 14 ++++++++++++-- 4 files changed, 24 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1cab3c15..432528e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,8 +138,10 @@ E2 best-parent 多代选择与 early stopping(issue #7),MAP-Elites / Islan - ❌ 不在 `services/_shared/` 加项目特有逻辑(破坏复用) - ❌ 不写跳过测试 / 跳过 hook 的 commit(`--no-verify` 等)——遇阻先 ask user - ❌ 不在不公开源码的前提下把 Inalpha(或其修改版)当作网络服务对外提供(LICENSE: AGPL-3.0;需闭源 / 托管 SaaS 请提 issue 谈双重许可) -- ❌ 不绕过逐用户 JWT、owner scope 或 `LLM_CONFIG_ENCRYPTION_KEY`:Evolver 只能用短时、 - 用途限定且绑定 `config_id` 的 service JWT 即时获取当前 owner 的模型密钥,严禁把明文 API key 写入运行记录或日志 +- ❌ 不绕过逐用户 JWT、owner scope 或 `LLM_CONFIG_ENCRYPTION_KEY`:Evolver 只能转交由 + orchestration 签发、绑定 owner / operation / `config_id` / digest 的短时 Ed25519 grant, + 由 Dashboard 公钥验签并兑换当前 owner 的模型密钥;首次响应丢失时只允许两分钟内补偿 + 重试一次。严禁把明文 API key 写入运行记录或日志 --- diff --git a/apps/dashboard/README.md b/apps/dashboard/README.md index b306f636..c2d1c38a 100644 --- a/apps/dashboard/README.md +++ b/apps/dashboard/README.md @@ -16,8 +16,10 @@ Inalpha 的动态 Next.js 控制台(`:3001`):提供 agent 对话,以及 JWT 并访问 data/paper/research/factor/evolver/Mastra;Python services 不直接暴露给浏览器。 用户级 LLM provider/model/key 在服务端管理,key 以 `LLM_CONFIG_ENCRYPTION_KEY`(未配置时 -兼容回退 `JWT_SECRET`)加密保存。Evolver 执行时只可凭短时、用途限定且绑定 `config_id` 的 service JWT, -经 `/api/internal/llm-config/{id}` 按 owner 即时读取;明文 key 不进入 run/candidate 记录。 +兼容回退 `JWT_SECRET`)加密保存。Evolver 执行时只能转交由 orchestration 签发、绑定 +owner / operation / `config_id` / digest 的短时 Ed25519 grant;Dashboard 用公钥验签并通过 +PostgreSQL `jti` 兑换记录限制重放,再经 `/api/internal/llm-config/{id}` 按 owner 即时读取。 +明文 key 不进入 run/candidate 记录,grant 成功兑换后从运行队列清除。 ## 本地启动 @@ -46,6 +48,7 @@ pnpm dev # http://localhost:3001 | `AUTH_ENABLED` | 是否启用登录闸门 | | `JWT_SECRET` / `JWT_ALGORITHM` | 用户/服务 JWT | | `LLM_CONFIG_ENCRYPTION_KEY` | 用户 LLM API key 的独立加密密钥 | +| `EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64` | 验证 Evolver owner 凭据 grant 的 Ed25519 公钥 | | `DATA_SERVICE_URL` … `EVOLVER_SERVICE_URL` | 五个 Python service 地址 | | `MASTRA_URL` | agent 编排地址 | | `EVOLVER_ENABLED` | 是否显示并开放演化能力 | diff --git a/infra/README.md b/infra/README.md index f3350c3d..212ba407 100644 --- a/infra/README.md +++ b/infra/README.md @@ -10,6 +10,9 @@ bash scripts/selfhost.sh up bash scripts/selfhost.sh create-user --email you@example.com ``` +`init` 会生成数据库、JWT、配置加密密钥,以及 Evolver credential grant 所需的 +Ed25519 公私钥。生成后的 `infra/.env.selfhost` 权限为 `600`,不要提交到仓库。 + 完整栈包含 PostgreSQL、Redis、migration、五个 Python services(data / paper / research / factor / evolver)、Mastra 与 Dashboard。Dashboard 只绑定宿主机 `127.0.0.1:3001`;远程访问 应由部署者的 Caddy、Nginx 或 Tunnel 提供 HTTPS,并只代理 Dashboard。用户级 LLM key、 diff --git a/scripts/selfhost.sh b/scripts/selfhost.sh index 89f780b5..fe74ca0c 100755 --- a/scripts/selfhost.sh +++ b/scripts/selfhost.sh @@ -39,21 +39,31 @@ init() { fi cp "$EXAMPLE_FILE" "$SELFHOST_ENV_FILE" local postgres_password redis_password jwt_secret encryption_key + local credential_private_pem credential_private_key credential_public_key postgres_password="$(generate_secret)" redis_password="$(generate_secret)" jwt_secret="$(generate_secret)" encryption_key="$(generate_secret)" - python3 - "$SELFHOST_ENV_FILE" "$postgres_password" "$redis_password" "$jwt_secret" "$encryption_key" <<'PY' + credential_private_pem="$(mktemp)" + trap '[[ -z "${credential_private_pem:-}" ]] || rm -f "$credential_private_pem"' EXIT + openssl genpkey -algorithm ED25519 -out "$credential_private_pem" 2>/dev/null + credential_private_key="$(openssl pkey -in "$credential_private_pem" -outform DER | base64 | tr -d '\n')" + credential_public_key="$(openssl pkey -in "$credential_private_pem" -pubout -outform DER | base64 | tr -d '\n')" + rm -f "$credential_private_pem" + credential_private_pem="" + python3 - "$SELFHOST_ENV_FILE" "$postgres_password" "$redis_password" "$jwt_secret" "$encryption_key" "$credential_private_key" "$credential_public_key" <<'PY' from pathlib import Path import sys path = Path(sys.argv[1]) -postgres_password, redis_password, jwt_secret, encryption_key = sys.argv[2:] +postgres_password, redis_password, jwt_secret, encryption_key, credential_private_key, credential_public_key = sys.argv[2:] content = path.read_text() content = content.replace("POSTGRES_PASSWORD=\n", f"POSTGRES_PASSWORD={postgres_password}\n") content = content.replace("REDIS_PASSWORD=\n", f"REDIS_PASSWORD={redis_password}\n") content = content.replace("JWT_SECRET=\n", f"JWT_SECRET={jwt_secret}\n") content = content.replace("LLM_CONFIG_ENCRYPTION_KEY=\n", f"LLM_CONFIG_ENCRYPTION_KEY={encryption_key}\n") +content = content.replace("EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64=\n", f"EVOLUTION_CREDENTIAL_PRIVATE_KEY_B64={credential_private_key}\n") +content = content.replace("EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64=\n", f"EVOLUTION_CREDENTIAL_PUBLIC_KEY_B64={credential_public_key}\n") content = content.replace("__POSTGRES_PASSWORD__", postgres_password) content = content.replace("__REDIS_PASSWORD__", redis_password) path.write_text(content)