Add provider rate limit registry
This commit is contained in:
1 parent
9e44cbb31b
commit
d6e797e2b9
16 files changed
+525
-38
No files matched your search
@@ -50,6 +50,7 @@ const LEGACY_TABLES = [
|
||||
"providers",
|
||||
"provider_requests",
|
||||
"provider_usage_windows",
|
||||
"provider_rate_limit_observations",
|
||||
"credentials",
|
||||
"skill_definitions",
|
||||
"skill_revisions",
|
||||
@@ -185,6 +186,8 @@ const RELATION_CHECKS: RelationCheck[] = [
|
||||
{ table: "provider_requests", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" },
|
||||
{ table: "provider_usage_windows", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "provider_usage_windows", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" },
|
||||
{ table: "provider_rate_limit_observations", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "provider_rate_limit_observations", column: "provider_id", referencedTable: "providers", referencedColumn: "id", reason: "dangling_provider_id" },
|
||||
{ table: "credentials", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "skill_definitions", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||
{ table: "skill_revisions", column: "skill_id", referencedTable: "skill_definitions", referencedColumn: "id", reason: "dangling_skill_id" },
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { PROVIDER_AUTH_TYPE, type ProviderAuthType } from "../../shared/domain";
|
||||
import { listProviderModels, type ProviderModelEntry } from "../../shared/provider-models";
|
||||
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
import { parseProviderRateLimitHeaders } from "./provider-rate-limit-registry";
|
||||
|
||||
export interface ProviderExecutionInput {
|
||||
authProfile: ProviderAuthProfile;
|
||||
@@ -11,6 +13,7 @@ export interface ProviderExecutionInput {
|
||||
export interface ProviderExecutionResult {
|
||||
text: string;
|
||||
rawResponseJson: string;
|
||||
rateLimitState: ProviderObservedRateLimitState | null;
|
||||
}
|
||||
|
||||
export interface ProviderHealthInput {
|
||||
@@ -20,6 +23,7 @@ export interface ProviderHealthInput {
|
||||
|
||||
export interface ProviderHealthResult {
|
||||
checkedAt: string;
|
||||
rateLimitState: ProviderObservedRateLimitState | null;
|
||||
}
|
||||
|
||||
export interface ProviderContext {
|
||||
@@ -31,6 +35,7 @@ export interface ProviderRateLimitState {
|
||||
requestLimit: number | null;
|
||||
remainingRequests: number | null;
|
||||
resetAt: string | null;
|
||||
retryAfterMs: number | null;
|
||||
rawStateJson: string;
|
||||
}
|
||||
|
||||
@@ -58,6 +63,25 @@ type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const PROVIDER_REQUEST_TIMEOUT_MS = 60_000;
|
||||
|
||||
interface ProviderJsonResponse {
|
||||
data: JsonRecord;
|
||||
rateLimitState: ProviderObservedRateLimitState | null;
|
||||
}
|
||||
|
||||
export class ProviderHttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly rateLimitState: ProviderObservedRateLimitState | null,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ProviderHttpError";
|
||||
}
|
||||
}
|
||||
|
||||
export function providerRateLimitStateFromError(error: unknown): ProviderObservedRateLimitState | null {
|
||||
return error instanceof ProviderHttpError ? error.rateLimitState : null;
|
||||
}
|
||||
|
||||
export const providerAdapters: ProviderAdapterRegistry = {
|
||||
openai: {
|
||||
providerKey: "openai",
|
||||
@@ -68,15 +92,16 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
||||
checkHealth: (input) => checkOpenAiHealth(input.authProfile, "https://api.openai.com/v1/models"),
|
||||
async createResponse(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, "OpenAI");
|
||||
const data = await postJson("https://api.openai.com/v1/responses", {
|
||||
const response = await postJson("https://api.openai.com/v1/responses", {
|
||||
authorization: `Bearer ${profile.apiKey}`,
|
||||
}, {
|
||||
model: input.model,
|
||||
input: input.prompt,
|
||||
...(input.instructions ? { instructions: input.instructions } : {}),
|
||||
});
|
||||
const data = response.data;
|
||||
const text = requireText(collectText(data.output_text ?? data.output), "OpenAI");
|
||||
return { text, rawResponseJson: JSON.stringify(data) };
|
||||
return { text, rawResponseJson: JSON.stringify(data), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
},
|
||||
anthropic: {
|
||||
@@ -87,15 +112,15 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
async checkHealth(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, "Anthropic");
|
||||
await getJson("https://api.anthropic.com/v1/models", {
|
||||
const response = await getJson("https://api.anthropic.com/v1/models", {
|
||||
"x-api-key": profile.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
});
|
||||
return healthy();
|
||||
return healthy(response.rateLimitState);
|
||||
},
|
||||
async createResponse(input) {
|
||||
const profile = requireApiKeyProfile(input.authProfile, "Anthropic");
|
||||
const data = await postJson("https://api.anthropic.com/v1/messages", {
|
||||
const response = await postJson("https://api.anthropic.com/v1/messages", {
|
||||
"x-api-key": profile.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}, {
|
||||
@@ -104,8 +129,9 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
||||
...(input.instructions ? { system: input.instructions } : {}),
|
||||
messages: [{ role: "user", content: input.prompt }],
|
||||
});
|
||||
const data = response.data;
|
||||
const text = requireText(collectText(data.content), "Anthropic");
|
||||
return { text, rawResponseJson: JSON.stringify(data) };
|
||||
return { text, rawResponseJson: JSON.stringify(data), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
},
|
||||
xai: openAiCompatibleAdapter({
|
||||
@@ -131,8 +157,8 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
async checkHealth(input) {
|
||||
const profile = requireAzureProfile(input.authProfile);
|
||||
await getJson(`${profile.baseUrl}/openai/deployments?api-version=2024-10-21`, { "api-key": profile.apiKey });
|
||||
return healthy();
|
||||
const response = await getJson(`${profile.baseUrl}/openai/deployments?api-version=2024-10-21`, { "api-key": profile.apiKey });
|
||||
return healthy(response.rateLimitState);
|
||||
},
|
||||
},
|
||||
"aws-bedrock": {
|
||||
@@ -143,7 +169,7 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
checkHealth: async (input) => {
|
||||
requireBedrockProfile(input.authProfile);
|
||||
return healthy();
|
||||
return healthy(null);
|
||||
},
|
||||
},
|
||||
"vertex-ai": {
|
||||
@@ -154,12 +180,12 @@ export const providerAdapters: ProviderAdapterRegistry = {
|
||||
getRateLimitState: providerRateLimitUnknown,
|
||||
checkHealth: async (input) => {
|
||||
requireVertexProfile(input.authProfile);
|
||||
return healthy();
|
||||
return healthy(null);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function postJson(endpoint: string, headers: Record<string, string>, body: JsonRecord): Promise<JsonRecord> {
|
||||
async function postJson(endpoint: string, headers: Record<string, string>, body: JsonRecord): Promise<ProviderJsonResponse> {
|
||||
return requestJson(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -170,11 +196,11 @@ async function postJson(endpoint: string, headers: Record<string, string>, body:
|
||||
});
|
||||
}
|
||||
|
||||
async function getJson(endpoint: string, headers: Record<string, string>): Promise<JsonRecord> {
|
||||
async function getJson(endpoint: string, headers: Record<string, string>): Promise<ProviderJsonResponse> {
|
||||
return requestJson(endpoint, { method: "GET", headers });
|
||||
}
|
||||
|
||||
async function requestJson(endpoint: string, init: RequestInit): Promise<JsonRecord> {
|
||||
async function requestJson(endpoint: string, init: RequestInit): Promise<ProviderJsonResponse> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), PROVIDER_REQUEST_TIMEOUT_MS);
|
||||
let response: Response;
|
||||
@@ -183,13 +209,14 @@ async function requestJson(endpoint: string, init: RequestInit): Promise<JsonRec
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const rateLimitState = parseProviderRateLimitHeaders(response.headers);
|
||||
const raw = await response.text();
|
||||
const data = parseProviderJson(raw, response.ok);
|
||||
if (!response.ok) {
|
||||
const message = readProviderError(data) ?? (raw.trim() || `Provider request failed with HTTP ${response.status}.`);
|
||||
throw new Error(message);
|
||||
throw new ProviderHttpError(message, rateLimitState);
|
||||
}
|
||||
return data;
|
||||
return { data, rateLimitState };
|
||||
}
|
||||
|
||||
function parseProviderJson(raw: string, requireJson: boolean): JsonRecord {
|
||||
@@ -254,22 +281,23 @@ function openAiCompatibleAdapter({
|
||||
...(input.instructions ? [{ role: "system", content: input.instructions }] : []),
|
||||
{ role: "user", content: input.prompt },
|
||||
];
|
||||
const data = await postJson(endpoint, {
|
||||
const response = await postJson(endpoint, {
|
||||
authorization: `Bearer ${profile.apiKey}`,
|
||||
}, {
|
||||
model: input.model,
|
||||
messages,
|
||||
});
|
||||
const data = response.data;
|
||||
const text = requireText(collectText(data.choices), label);
|
||||
return { text, rawResponseJson: JSON.stringify(data) };
|
||||
return { text, rawResponseJson: JSON.stringify(data), rateLimitState: response.rateLimitState };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function checkOpenAiHealth(authProfile: ProviderAuthProfile, healthUrl: string): Promise<ProviderHealthResult> {
|
||||
const profile = requireApiKeyProfile(authProfile, "OpenAI-compatible provider");
|
||||
await getJson(healthUrl, { authorization: `Bearer ${profile.apiKey}` });
|
||||
return healthy();
|
||||
const response = await getJson(healthUrl, { authorization: `Bearer ${profile.apiKey}` });
|
||||
return healthy(response.rateLimitState);
|
||||
}
|
||||
|
||||
function browserTokenAdapter(providerKey: string, authType: ProviderAuthType): ProviderAdapter {
|
||||
@@ -282,7 +310,7 @@ function browserTokenAdapter(providerKey: string, authType: ProviderAuthType): P
|
||||
checkHealth: async (input) => {
|
||||
if (authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION) requireAccountTokenProfile(input.authProfile);
|
||||
else requireAccessTokenProfile(input.authProfile);
|
||||
return healthy();
|
||||
return healthy(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -399,8 +427,8 @@ function requireVertexProfile(profile: ProviderAuthProfile): Extract<ProviderAut
|
||||
return profile;
|
||||
}
|
||||
|
||||
function healthy(): ProviderHealthResult {
|
||||
return { checkedAt: new Date().toISOString() };
|
||||
function healthy(rateLimitState: ProviderObservedRateLimitState | null): ProviderHealthResult {
|
||||
return { checkedAt: new Date().toISOString(), rateLimitState };
|
||||
}
|
||||
|
||||
function listStaticModels(providerKey: string): Promise<ProviderModelEntry[]> {
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
|
||||
const REQUEST_LIMIT_HEADERS = [
|
||||
"x-ratelimit-limit-requests",
|
||||
"x-ratelimit-limit",
|
||||
"anthropic-ratelimit-requests-limit",
|
||||
"ratelimit-limit",
|
||||
] as const;
|
||||
|
||||
const REMAINING_REQUEST_HEADERS = [
|
||||
"x-ratelimit-remaining-requests",
|
||||
"x-ratelimit-remaining",
|
||||
"anthropic-ratelimit-requests-remaining",
|
||||
"ratelimit-remaining",
|
||||
] as const;
|
||||
|
||||
const RESET_HEADERS = [
|
||||
"x-ratelimit-reset-requests",
|
||||
"x-ratelimit-reset",
|
||||
"anthropic-ratelimit-requests-reset",
|
||||
"ratelimit-reset",
|
||||
] as const;
|
||||
|
||||
const RETRY_AFTER_HEADERS = ["retry-after"] as const;
|
||||
const OBSERVED_HEADERS = [
|
||||
...REQUEST_LIMIT_HEADERS,
|
||||
...REMAINING_REQUEST_HEADERS,
|
||||
...RESET_HEADERS,
|
||||
...RETRY_AFTER_HEADERS,
|
||||
] as const;
|
||||
|
||||
export interface RecordProviderRateLimitObservationInput {
|
||||
orgId: string;
|
||||
providerId: string;
|
||||
providerKey: string;
|
||||
model: string | null;
|
||||
state: ProviderObservedRateLimitState;
|
||||
}
|
||||
|
||||
export class ProviderRateLimitRegistry {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
recordObservation(input: RecordProviderRateLimitObservationInput): void {
|
||||
this.db.prepare(`
|
||||
insert into provider_rate_limit_observations(
|
||||
id, org_id, provider_id, provider_key, model, request_limit,
|
||||
remaining_requests, reset_at, retry_after_ms, raw_state_json, observed_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
createId("prlo"),
|
||||
input.orgId,
|
||||
input.providerId,
|
||||
input.providerKey,
|
||||
input.model,
|
||||
input.state.requestLimit,
|
||||
input.state.remainingRequests,
|
||||
input.state.resetAt,
|
||||
input.state.retryAfterMs,
|
||||
input.state.rawStateJson,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseProviderRateLimitHeaders(headers: Headers, now = new Date()): ProviderObservedRateLimitState | null {
|
||||
const rawHeaders = collectObservedHeaders(headers);
|
||||
const requestLimit = readIntegerHeader(headers, REQUEST_LIMIT_HEADERS);
|
||||
const remainingRequests = readIntegerHeader(headers, REMAINING_REQUEST_HEADERS);
|
||||
const resetAt = readResetAt(headers, RESET_HEADERS, now);
|
||||
const retryAfterMs = readRetryAfterMs(headers, now);
|
||||
if (
|
||||
requestLimit === null
|
||||
&& remainingRequests === null
|
||||
&& resetAt === null
|
||||
&& retryAfterMs === null
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
requestLimit,
|
||||
remainingRequests,
|
||||
resetAt,
|
||||
retryAfterMs,
|
||||
rawStateJson: JSON.stringify(rawHeaders),
|
||||
};
|
||||
}
|
||||
|
||||
function collectObservedHeaders(headers: Headers): Record<string, string> {
|
||||
const rawHeaders: Record<string, string> = {};
|
||||
for (const key of OBSERVED_HEADERS) {
|
||||
const value = headers.get(key);
|
||||
if (value?.trim()) rawHeaders[key] = value.trim();
|
||||
}
|
||||
return rawHeaders;
|
||||
}
|
||||
|
||||
function readIntegerHeader(headers: Headers, keys: readonly string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = parseInteger(headers.get(key));
|
||||
if (value !== null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readResetAt(headers: Headers, keys: readonly string[], now: Date): string | null {
|
||||
for (const key of keys) {
|
||||
const value = parseResetValue(headers.get(key), now);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readRetryAfterMs(headers: Headers, now: Date): number | null {
|
||||
for (const key of RETRY_AFTER_HEADERS) {
|
||||
const value = parseRetryAfter(headers.get(key), now);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseInteger(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const normalized = value.trim();
|
||||
if (!/^\d+$/.test(normalized)) return null;
|
||||
const parsed = Number(normalized);
|
||||
if (!Number.isSafeInteger(parsed)) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseResetValue(value: string | null, now: Date): string | null {
|
||||
if (!value?.trim()) return null;
|
||||
const normalized = value.trim();
|
||||
const durationMs = parseDurationMs(normalized);
|
||||
if (durationMs !== null) return new Date(now.getTime() + durationMs).toISOString();
|
||||
const numeric = Number(normalized);
|
||||
if (Number.isFinite(numeric) && numeric >= 0) {
|
||||
if (numeric > 1_000_000_000_000) return new Date(numeric).toISOString();
|
||||
if (numeric > 1_000_000_000) return new Date(numeric * 1000).toISOString();
|
||||
return new Date(now.getTime() + numeric * 1000).toISOString();
|
||||
}
|
||||
const dateMs = Date.parse(normalized);
|
||||
if (!Number.isNaN(dateMs)) return new Date(dateMs).toISOString();
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRetryAfter(value: string | null, now: Date): number | null {
|
||||
if (!value?.trim()) return null;
|
||||
const normalized = value.trim();
|
||||
const seconds = Number(normalized);
|
||||
if (Number.isFinite(seconds) && seconds >= 0) return Math.round(seconds * 1000);
|
||||
const dateMs = Date.parse(normalized);
|
||||
if (Number.isNaN(dateMs)) return null;
|
||||
return Math.max(0, dateMs - now.getTime());
|
||||
}
|
||||
|
||||
function parseDurationMs(value: string): number | null {
|
||||
const match = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/.exec(value);
|
||||
if (!match) return null;
|
||||
const amount = Number(match[1]);
|
||||
if (!Number.isFinite(amount)) return null;
|
||||
const unit = match[2];
|
||||
if (unit === "ms") return Math.round(amount);
|
||||
if (unit === "s") return Math.round(amount * 1000);
|
||||
if (unit === "m") return Math.round(amount * 60_000);
|
||||
return Math.round(amount * 3_600_000);
|
||||
}
|
||||
@@ -10,7 +10,9 @@ import { AuditService } from "./audit-service";
|
||||
import { AuthBrokerService, type ProviderSecretVault } from "./auth-broker-service";
|
||||
import { OperationsService } from "./operations-service";
|
||||
import { SecretVault } from "./secret-vault";
|
||||
import { providerAdapters, type ProviderAdapterRegistry } from "./provider-adapters";
|
||||
import { providerAdapters, providerRateLimitStateFromError, type ProviderAdapterRegistry } from "./provider-adapters";
|
||||
import { ProviderRateLimitRegistry } from "./provider-rate-limit-registry";
|
||||
import type { ProviderObservedRateLimitState } from "../../shared/provider-runtime-domain";
|
||||
|
||||
const AUTH_STATE_CONFIGURED = "configured";
|
||||
const AUTH_STATE_REVOKED = "revoked";
|
||||
@@ -23,6 +25,7 @@ export class ProviderService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly authBroker: AuthBrokerService;
|
||||
private readonly operations: OperationsService;
|
||||
private readonly rateLimitRegistry: ProviderRateLimitRegistry;
|
||||
|
||||
constructor(
|
||||
private readonly db: DatabaseSync,
|
||||
@@ -32,6 +35,7 @@ export class ProviderService {
|
||||
this.audit = new AuditService(db);
|
||||
this.authBroker = new AuthBrokerService(db, vault);
|
||||
this.operations = new OperationsService(db);
|
||||
this.rateLimitRegistry = new ProviderRateLimitRegistry(db);
|
||||
}
|
||||
|
||||
saveCredential(input: SaveProviderCredentialInput): void {
|
||||
@@ -104,7 +108,8 @@ export class ProviderService {
|
||||
const credential = this.loadCredential(orgId, provider);
|
||||
try {
|
||||
const profile = adapter.parseAuthProfile(credential.secret);
|
||||
await adapter.checkHealth({ authProfile: profile, model: provider.model });
|
||||
const result = await adapter.checkHealth({ authProfile: profile, model: provider.model });
|
||||
this.recordObservedRateLimit(orgId, provider, provider.model, result.rateLimitState);
|
||||
this.recordProviderHealth(provider.id, orgId, HEALTH_STATE_HEALTHY, null);
|
||||
this.audit.record({
|
||||
orgId,
|
||||
@@ -117,6 +122,7 @@ export class ProviderService {
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.recordObservedRateLimit(orgId, provider, provider.model, providerRateLimitStateFromError(error));
|
||||
this.recordProviderHealth(provider.id, orgId, HEALTH_STATE_FAILED, message);
|
||||
throw error;
|
||||
}
|
||||
@@ -245,6 +251,7 @@ export class ProviderService {
|
||||
prompt,
|
||||
instructions: input.instructions?.trim() || null,
|
||||
});
|
||||
this.recordObservedRateLimit(input.orgId, provider, provider.model, result.rateLimitState);
|
||||
this.db.prepare(`
|
||||
update provider_requests
|
||||
set state = ?, response_text = ?, raw_response_json = ?, output_char_count = ?, completed_at = ?
|
||||
@@ -271,6 +278,7 @@ export class ProviderService {
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.recordObservedRateLimit(input.orgId, provider, provider.model, providerRateLimitStateFromError(error));
|
||||
this.db.prepare(`
|
||||
update provider_requests
|
||||
set state = ?, error_message = ?, completed_at = ?
|
||||
@@ -385,6 +393,22 @@ export class ProviderService {
|
||||
updated_at = excluded.updated_at
|
||||
`).run(createId("puw"), orgId, providerId, window.start, window.end, inputCharCount, outputCharCount, now, now);
|
||||
}
|
||||
|
||||
private recordObservedRateLimit(
|
||||
orgId: string,
|
||||
provider: Pick<ProviderRow, "id" | "provider_key">,
|
||||
model: string | null,
|
||||
state: ProviderObservedRateLimitState | null,
|
||||
): void {
|
||||
if (!state) return;
|
||||
this.rateLimitRegistry.recordObservation({
|
||||
orgId,
|
||||
providerId: provider.id,
|
||||
providerKey: provider.provider_key,
|
||||
model,
|
||||
state,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface ProviderRow {
|
||||
|
||||
@@ -247,10 +247,16 @@ export class SdlcService {
|
||||
return typeof adapter.listModels === "function" && typeof adapter.getRateLimitState === "function";
|
||||
});
|
||||
const schemaVersion = count(this.db, "select coalesce(max(version), 0) as count from schema_migrations");
|
||||
const rateLimitRegistry = count(this.db, `
|
||||
select count(*) as count
|
||||
from sqlite_master
|
||||
where type = 'table' and name = 'provider_rate_limit_observations'
|
||||
`);
|
||||
return [
|
||||
{ key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` },
|
||||
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
|
||||
{ key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady },
|
||||
{ key: "schema_version", passed: schemaVersion >= 21, value: schemaVersion },
|
||||
{ key: "schema_version", passed: schemaVersion >= 22, value: schemaVersion },
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,11 @@ export class SnapshotService {
|
||||
.map((provider) => ({ ...provider, enabled: toBool(provider.enabled), backgroundEnabled: toBool(provider.backgroundEnabled) }));
|
||||
const providerAuthProfiles = list<AppSnapshot["providerAuthProfiles"][number]>(this.db, "provider_auth_profiles", "created_at desc")
|
||||
.map((profile) => ({ ...profile, active: toBool(profile.active) }));
|
||||
const providerRateLimitObservations = list<AppSnapshot["providerRateLimitObservations"][number]>(
|
||||
this.db,
|
||||
"provider_rate_limit_observations",
|
||||
"observed_at desc",
|
||||
);
|
||||
const providerUsageWindows = list<ProviderUsageWindowRow>(this.db, "provider_usage_windows", "window_start desc");
|
||||
const providerRateLimits = buildProviderRateLimits(providers, providerUsageWindows);
|
||||
const skillBindings = list<AppSnapshot["skillBindings"][number]>(this.db, "skill_bindings", "priority asc")
|
||||
@@ -103,6 +108,7 @@ export class SnapshotService {
|
||||
providerCatalog: PROVIDER_CATALOG,
|
||||
providerRequests: list(this.db, "provider_requests", "created_at desc"),
|
||||
providerRateLimits,
|
||||
providerRateLimitObservations,
|
||||
skillDefinitions: list(this.db, "skill_definitions", "created_at asc"),
|
||||
skillRevisions: list(this.db, "skill_revisions", "published_at desc"),
|
||||
skillBindings,
|
||||
|
||||
Reference in new issue
Block a user