diff --git a/docs/ai-runtime-internal-api-protocol.md b/docs/ai-runtime-internal-api-protocol.md new file mode 100644 index 0000000..ea0774a --- /dev/null +++ b/docs/ai-runtime-internal-api-protocol.md @@ -0,0 +1,359 @@ +# API 与 Rust Runtime 内部通信协议 + +## 1. 概述 + +本文档定义主 API 与 Rust Heavy Runtime 之间的内部 HTTP 通信协议。 + +通信方向: +- Runtime → API:拉取 Job、提交结果、提交日志 +- API → Runtime:健康检查(可选) + +## 2. 鉴权 + +所有 `/internal/runtime/*` 接口使用 `InternalAuthGuard`。 + +**请求头**: +``` +x-internal-api-key: +x-runtime-instance-id: runtime-001 +``` + +- `x-internal-api-key`:与 API 环境变量 `INTERNAL_API_KEY` 一致 +- `x-runtime-instance-id`:Runtime 实例标识,记录到日志 + +**安全约束**: +- 普通用户 JWT 不可访问 internal 接口 +- service token 不可访问普通用户 API +- Runtime 不可通过 internal 接口访问非当前 job 所需数据 + +## 3. 错误响应格式 + +所有 internal 接口失败时返回: + +```json +{ + "statusCode": 400, + "errorCode": "INVALID_SNAPSHOT", + "message": "Snapshot has expired for this job", + "timestamp": "2026-06-11T10:00:00.000Z" +} +``` + +### 错误码 + +| 错误码 | HTTP | 说明 | retryable | +|--------|------|------|-----------| +| `JOB_NOT_FOUND` | 404 | Job 不存在 | false | +| `JOB_ALREADY_LOCKED` | 409 | 已被其他 Runtime 锁定 | true | +| `SNAPSHOT_EXPIRED` | 410 | 快照已过期 | true | +| `SNAPSHOT_NOT_FOUND` | 404 | 快照不存在 | false | +| `CREDENTIAL_NOT_FOUND` | 404 | 凭证不存在 | false | +| `CREDENTIAL_INVALID` | 422 | 凭证无效 | false | +| `RESULT_ALREADY_EXISTS` | 409 | 重复提交 | false | +| `RESULT_SCHEMA_UNSUPPORTED` | 422 | schema 版本不支持 | false | +| `RUNTIME_VERSION_INCOMPATIBLE` | 422 | Runtime 版本不兼容 | false | +| `INTERNAL_ERROR` | 500 | 内部错误 | true | + +## 4. 接口详情 + +### 4.1 Poll Jobs + +``` +POST /internal/runtime/jobs/poll +``` + +Runtime 拉取待执行 job。API 根据 Runtime 的 `supportedJobTypes` 和 `capabilities` 过滤兼容的 job。 + +**请求**: +```json +{ + "runtimeInstanceId": "runtime-001", + "supportedJobTypes": ["learning_state_analysis", "quiz_generation"], + "limit": 5, + "capabilities": { + "supportedSnapshotVersions": ["ai_snapshot_v1"], + "supportedOutputSchemaVersions": ["analysis_output_v1", "quiz_output_v1"] + } +} +``` + +**响应 200**: +```json +{ + "jobs": [ + { + "id": "job-abc123", + "jobType": "learning_state_analysis", + "targetType": "material", + "targetId": "mat-xyz", + "priority": 0, + "snapshotId": "snap-001", + "promptVersion": "learning_state_v1", + "outputSchemaVersion": "analysis_output_v1" + } + ] +} +``` + +### 4.2 Lock Job + +``` +POST /internal/runtime/jobs/{jobId}/lock +``` + +Runtime 锁定一个 job,获取执行权。 + +**请求**: +```json +{ + "runtimeInstanceId": "runtime-001" +} +``` + +**响应 200**: +```json +{ + "jobId": "job-abc123", + "status": "locked", + "lockUntil": 1700000000123 +} +``` + +### 4.3 Heartbeat + +``` +POST /internal/runtime/jobs/{jobId}/heartbeat +``` + +Runtime 延长 lock 有效期。 + +**请求**: +```json +{ + "runtimeInstanceId": "runtime-001" +} +``` + +**响应 204**:空 body,仅延长 `lockUntil`。 + +### 4.4 Get Snapshot + +``` +GET /internal/runtime/jobs/{jobId}/snapshot +``` + +Runtime 获取 job 关联的 LearningAnalysisSnapshot。 + +**响应 200**: +```json +{ + "jobId": "job-abc123", + "snapshotId": "snap-001", + "snapshotVersion": "ai_snapshot_v1", + "privacyScope": { "allowDocumentContent": true }, + "userProfile": { "learningGoal": "exam", "currentLevel": "intermediate" }, + "aiSettings": { "allowAiAnalysis": true }, + "learningBehaviorSummary": { "totalActiveSeconds": 3600 }, + "materialProgressSummary": { "progress": 0.6 }, + "behaviorSignals": { "engagementSignal": "high" }, + "scoreSignals": { "masteryRiskScore": 0.3 }, + "constraints": { "dailyAvailableMinutes": 60 }, + "allowedModelFields": ["learningGoal", "currentLevel"] +} +``` + +**错误**: +- `404 SNAPSHOT_NOT_FOUND` — 快照不存在 +- `410 SNAPSHOT_EXPIRED` — 快照已过期,Runtime 应提交 retryable fail + +### 4.5 Resolve Credential + +``` +POST /internal/runtime/model-credentials/resolve +``` + +Runtime 获取模型调用凭证。platform_key 模式返回平台 key;user_deepseek_key 模式解密用户 key 后返回。 + +**请求**: +```json +{ + "jobId": "job-abc123", + "apiKeyMode": "user_deepseek_key", + "credentialId": "cred-001", + "provider": "deepseek" +} +``` + +**响应 200**: +```json +{ + "provider": "deepseek", + "model": "deepseek-chat", + "baseUrl": "https://api.deepseek.com/v1", + "apiKey": "sk-xxxx", + "apiKeyMode": "user_deepseek_key" +} +``` + +**安全要求**: +- 明文 `apiKey` 只在响应中短暂出现,不写日志 +- `apiKey` 不返回给 iOS / Admin +- 用户 key 必须属于 `job.userId` +- platform key 由 Runtime 环境变量优先使用,API 可选返回 + +**错误**: +- `404 CREDENTIAL_NOT_FOUND` +- `422 CREDENTIAL_INVALID` + +### 4.6 Submit Result + +``` +POST /internal/runtime/jobs/{jobId}/result +``` + +Runtime 提交执行成功的结果。 + +**请求**: +```json +{ + "runtimeInstanceId": "runtime-001", + "schemaVersion": "analysis_output_v1", + "status": "succeeded", + "rawOutput": { "learningState": "in_progress", "confidence": 0.85 }, + "validatedOutput": { "learningState": "in_progress", "riskLevel": "low" }, + "validationErrors": [], + "usage": { + "inputTokens": 1200, + "outputTokens": 450, + "totalTokens": 1650, + "latencyMs": 3200, + "costEstimate": 3 + }, + "attemptNo": 0, + "outputHash": "sha256-abc123" +} +``` + +**幂等规则**: +- `resultIdempotencyKey = jobId + attemptNo + outputHash` +- 相同 key 重复提交返回 200(幂等) +- 已有 succeeded result 且 outputHash 不同返回 409 `RESULT_ALREADY_EXISTS` + +**响应 201**:created + +**错误**: +- `409 RESULT_ALREADY_EXISTS` +- `422 RESULT_SCHEMA_UNSUPPORTED` + +### 4.7 Submit Failure + +``` +POST /internal/runtime/jobs/{jobId}/fail +``` + +Runtime 提交执行失败的原因。 + +**请求**: +```json +{ + "runtimeInstanceId": "runtime-001", + "errorCode": "MODEL_TIMEOUT", + "errorMessage": "DeepSeek request timed out after 30s", + "retryable": true, + "rawError": "connection timeout" +} +``` + +**处理规则**: +- `retryable=true` 且 `retryCount < maxRetryCount`:job 回到 `pending` +- `retryable=false` 或达到 maxRetryCount:job 变为 `failed` +- `rawError` 中不得包含 apiKey + +**响应 200**:acknowledged + +### 4.8 Submit Invocation Logs + +``` +POST /internal/runtime/invocation-logs +``` + +Runtime 提交模型调用日志(批量)。 + +**请求**: +```json +{ + "logs": [ + { + "jobId": "job-abc123", + "provider": "deepseek", + "model": "deepseek-chat", + "apiKeyMode": "user_deepseek_key", + "credentialId": "cred-001", + "promptName": "learning_state_analysis", + "promptVersion": "learning_state_v1", + "outputSchemaVersion": "analysis_output_v1", + "inputTokens": 1200, + "outputTokens": 450, + "totalTokens": 1650, + "latencyMs": 3200, + "costEstimate": 3, + "success": true, + "retryCount": 0, + "runtimeInstanceId": "runtime-001", + "traceId": "trace-xyz", + "correlationId": "corr-abc" + } + ] +} +``` + +**约束**: +- 不允许 `apiKey` 字段 +- 失败调用也要提交日志 +- 日志提交失败不导致主任务崩溃 + +**响应 201**:created + +### 4.9 Health(可选) + +``` +GET /internal/runtime/health +``` + +API 查询 Runtime 健康状态。此接口由 Runtime 暴露(非 API 暴露)。 + +**响应 200**: +```json +{ + "runtimeInstanceId": "runtime-001", + "status": "ok", + "version": "0.1.0", + "startedAt": 1700000000000, + "lastJobAt": 1700000000123, + "activeJobs": 2 +} +``` + +## 5. 接口总览 + +| 方法 | 路径 | 调用方 | 鉴权 | +|------|------|--------|------| +| POST | `/internal/runtime/jobs/poll` | Runtime | InternalAuthGuard | +| POST | `/internal/runtime/jobs/{jobId}/lock` | Runtime | InternalAuthGuard | +| POST | `/internal/runtime/jobs/{jobId}/heartbeat` | Runtime | InternalAuthGuard | +| GET | `/internal/runtime/jobs/{jobId}/snapshot` | Runtime | InternalAuthGuard | +| POST | `/internal/runtime/model-credentials/resolve` | Runtime | InternalAuthGuard | +| POST | `/internal/runtime/jobs/{jobId}/result` | Runtime | InternalAuthGuard | +| POST | `/internal/runtime/jobs/{jobId}/fail` | Runtime | InternalAuthGuard | +| POST | `/internal/runtime/invocation-logs` | Runtime | InternalAuthGuard | +| GET | `/internal/runtime/health` | API | —(检查外部 Runtime) | + +## 6. 验收清单 + +- [x] 所有 internal 接口有 DTO 定义(`runtime-internal.dto.ts`) +- [x] 所有 internal 接口有鉴权设计(复用 InternalAuthGuard) +- [x] 所有失败返回包含 errorCode / message +- [x] Runtime result 支持结构化 payload(validatedOutput) +- [x] Runtime failure 支持 retryable 标记 +- [x] Credential resolve 接口明确不记录明文 key +- [x] 接口命名、字段命名与 Runtime 项目可直接对齐 diff --git a/src/modules/ai-runtime/internal/dto/runtime-internal.dto.ts b/src/modules/ai-runtime/internal/dto/runtime-internal.dto.ts new file mode 100644 index 0000000..deab99d --- /dev/null +++ b/src/modules/ai-runtime/internal/dto/runtime-internal.dto.ts @@ -0,0 +1,321 @@ +import { IsArray, IsBoolean, IsInt, IsObject, IsOptional, IsString, Max, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +// ── Poll ── + +export class RuntimePollJobsRequestDto { + @IsString() + runtimeInstanceId!: string; + + @IsArray() + @IsString({ each: true }) + supportedJobTypes!: string[]; + + @IsOptional() + @IsInt() + @Min(1) + @Max(50) + limit?: number; + + @IsOptional() + @IsObject() + capabilities?: Record; +} + +export class RuntimePollJobsResponseItemDto { + @IsString() + id!: string; + + @IsString() + jobType!: string; + + @IsString() + targetType!: string; + + @IsString() + targetId!: string; + + @IsInt() + priority!: number; + + @IsString() + snapshotId!: string; + + @IsOptional() + @IsString() + promptVersion?: string; + + @IsOptional() + @IsString() + outputSchemaVersion?: string; +} + +export class RuntimePollJobsResponseDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RuntimePollJobsResponseItemDto) + jobs!: RuntimePollJobsResponseItemDto[]; +} + +// ── Lock ── + +export class RuntimeLockJobRequestDto { + @IsString() + runtimeInstanceId!: string; +} + +export class RuntimeLockJobResponseDto { + @IsString() + jobId!: string; + + @IsString() + status!: string; + + @IsInt() + lockUntil!: number; +} + +// ── Heartbeat ── + +export class RuntimeHeartbeatRequestDto { + @IsString() + runtimeInstanceId!: string; +} + +// ── Snapshot ── + +export class RuntimeSnapshotResponseDto { + @IsString() + jobId!: string; + + @IsString() + snapshotId!: string; + + @IsString() + snapshotVersion!: string; + + @IsObject() + privacyScope!: Record; + + @IsOptional() + @IsObject() + userProfile?: Record; + + @IsOptional() + @IsObject() + aiSettings?: Record; + + @IsOptional() + @IsObject() + deviceContext?: Record; + + @IsOptional() + @IsObject() + learningBehaviorSummary?: Record; + + @IsOptional() + @IsObject() + materialProgressSummary?: Record; + + @IsOptional() + @IsObject() + contentStructureSummary?: Record; + + @IsOptional() + @IsObject() + behaviorSignals?: Record; + + @IsOptional() + @IsObject() + scoreSignals?: Record; + + @IsOptional() + @IsObject() + constraints?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + allowedModelFields?: string[]; +} + +// ── Credential Resolve ── + +export class RuntimeResolveCredentialRequestDto { + @IsString() + jobId!: string; + + @IsString() + apiKeyMode!: string; + + @IsOptional() + @IsString() + credentialId?: string; + + @IsString() + provider!: string; +} + +export class RuntimeResolveCredentialResponseDto { + @IsString() + provider!: string; + + @IsString() + model!: string; + + @IsOptional() + @IsString() + baseUrl?: string; + + @IsString() + apiKey!: string; + + @IsString() + apiKeyMode!: string; +} + +// ── Submit Result ── + +export class RuntimeSubmitResultRequestDto { + @IsString() + runtimeInstanceId!: string; + + @IsString() + schemaVersion!: string; + + @IsString() + status!: string; + + @IsOptional() + @IsObject() + rawOutput?: Record; + + @IsOptional() + @IsObject() + validatedOutput?: Record; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + validationErrors?: string[]; + + @IsOptional() + @IsObject() + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + latencyMs?: number; + costEstimate?: number; + }; + + @IsInt() + @Min(0) + attemptNo!: number; + + @IsOptional() + @IsString() + outputHash?: string; +} + +// ── Submit Failure ── + +export class RuntimeSubmitFailureRequestDto { + @IsString() + runtimeInstanceId!: string; + + @IsString() + errorCode!: string; + + @IsString() + errorMessage!: string; + + @IsBoolean() + retryable!: boolean; + + @IsOptional() + @IsString() + rawError?: string; +} + +// ── Invocation Log ── + +export class RuntimeInvocationLogDto { + @IsString() + jobId!: string; + + @IsString() + provider!: string; + + @IsString() + model!: string; + + @IsString() + apiKeyMode!: string; + + @IsOptional() + @IsString() + credentialId?: string; + + @IsString() + promptName!: string; + + @IsString() + promptVersion!: string; + + @IsString() + outputSchemaVersion!: string; + + @IsInt() + @Min(0) + inputTokens!: number; + + @IsInt() + @Min(0) + outputTokens!: number; + + @IsInt() + @Min(0) + totalTokens!: number; + + @IsInt() + @Min(0) + latencyMs!: number; + + @IsOptional() + @IsInt() + costEstimate?: number; + + @IsBoolean() + success!: boolean; + + @IsOptional() + @IsString() + errorCode?: string; + + @IsOptional() + @IsString() + errorMessage?: string; + + @IsInt() + @Min(0) + retryCount!: number; + + @IsString() + runtimeInstanceId!: string; + + @IsOptional() + @IsString() + traceId?: string; + + @IsOptional() + @IsString() + correlationId?: string; +} + +export class RuntimeSubmitInvocationLogsRequestDto { + @IsArray() + @ValidateNested({ each: true }) + @Type(() => RuntimeInvocationLogDto) + logs!: RuntimeInvocationLogDto[]; +}