diff --git a/AgentCompany-New/src/main/services/provider-adapters.ts b/AgentCompany-New/src/main/services/provider-adapters.ts index aa6d06e7..30355792 100644 --- a/AgentCompany-New/src/main/services/provider-adapters.ts +++ b/AgentCompany-New/src/main/services/provider-adapters.ts @@ -1,4 +1,5 @@ import { PROVIDER_AUTH_TYPE, type ProviderAuthType } from "../../shared/domain"; +import { listProviderModels, type ProviderModelEntry } from "../../shared/provider-models"; export interface ProviderExecutionInput { authProfile: ProviderAuthProfile; @@ -21,10 +22,24 @@ export interface ProviderHealthResult { checkedAt: string; } +export interface ProviderContext { + authProfile: ProviderAuthProfile; + model: string | null; +} + +export interface ProviderRateLimitState { + requestLimit: number | null; + remainingRequests: number | null; + resetAt: string | null; + rawStateJson: string; +} + export interface ProviderAdapter { providerKey: string; authType: ProviderAuthType; parseAuthProfile(secret: string): ProviderAuthProfile; + listModels(ctx: ProviderContext): Promise; + getRateLimitState(ctx: ProviderContext): Promise; checkHealth(input: ProviderHealthInput): Promise; createResponse?: (input: ProviderExecutionInput) => Promise; } @@ -48,6 +63,8 @@ export const providerAdapters: ProviderAdapterRegistry = { providerKey: "openai", authType: PROVIDER_AUTH_TYPE.API_KEY, parseAuthProfile: parseApiKey, + listModels: () => listStaticModels("openai"), + getRateLimitState: providerRateLimitUnknown, checkHealth: (input) => checkOpenAiHealth(input.authProfile, "https://api.openai.com/v1/models"), async createResponse(input) { const profile = requireApiKeyProfile(input.authProfile, "OpenAI"); @@ -66,6 +83,8 @@ export const providerAdapters: ProviderAdapterRegistry = { providerKey: "anthropic", authType: PROVIDER_AUTH_TYPE.API_KEY, parseAuthProfile: parseApiKey, + listModels: () => listStaticModels("anthropic"), + getRateLimitState: providerRateLimitUnknown, async checkHealth(input) { const profile = requireApiKeyProfile(input.authProfile, "Anthropic"); await getJson("https://api.anthropic.com/v1/models", { @@ -108,6 +127,8 @@ export const providerAdapters: ProviderAdapterRegistry = { providerKey: "azure-openai", authType: PROVIDER_AUTH_TYPE.CLOUD, parseAuthProfile: parseAzureProfile, + listModels: () => listStaticModels("azure-openai"), + 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 }); @@ -118,6 +139,8 @@ export const providerAdapters: ProviderAdapterRegistry = { providerKey: "aws-bedrock", authType: PROVIDER_AUTH_TYPE.CLOUD, parseAuthProfile: parseBedrockProfile, + listModels: () => listStaticModels("aws-bedrock"), + getRateLimitState: providerRateLimitUnknown, checkHealth: async (input) => { requireBedrockProfile(input.authProfile); return healthy(); @@ -127,6 +150,8 @@ export const providerAdapters: ProviderAdapterRegistry = { providerKey: "vertex-ai", authType: PROVIDER_AUTH_TYPE.CLOUD, parseAuthProfile: parseVertexProfile, + listModels: () => listStaticModels("vertex-ai"), + getRateLimitState: providerRateLimitUnknown, checkHealth: async (input) => { requireVertexProfile(input.authProfile); return healthy(); @@ -220,6 +245,8 @@ function openAiCompatibleAdapter({ providerKey, authType: PROVIDER_AUTH_TYPE.API_KEY, parseAuthProfile: parseApiKey, + listModels: () => listStaticModels(providerKey), + getRateLimitState: providerRateLimitUnknown, checkHealth: (input) => checkOpenAiHealth(input.authProfile, healthUrl), async createResponse(input) { const profile = requireApiKeyProfile(input.authProfile, label); @@ -250,6 +277,8 @@ function browserTokenAdapter(providerKey: string, authType: ProviderAuthType): P providerKey, authType, parseAuthProfile: authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION ? parseAccountToken : parseAccessToken, + listModels: () => listStaticModels(providerKey), + getRateLimitState: providerRateLimitUnknown, checkHealth: async (input) => { if (authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION) requireAccountTokenProfile(input.authProfile); else requireAccessTokenProfile(input.authProfile); @@ -373,3 +402,11 @@ function requireVertexProfile(profile: ProviderAuthProfile): Extract { + return Promise.resolve(listProviderModels(providerKey)); +} + +async function providerRateLimitUnknown(): Promise { + return null; +} diff --git a/AgentCompany-New/src/main/services/sdlc-service.ts b/AgentCompany-New/src/main/services/sdlc-service.ts index 9bd213bd..13dc0cc5 100644 --- a/AgentCompany-New/src/main/services/sdlc-service.ts +++ b/AgentCompany-New/src/main/services/sdlc-service.ts @@ -242,10 +242,15 @@ export class SdlcService { const catalogKeys = PROVIDER_CATALOG.map((provider) => provider.key); const adapterKeys = Object.keys(providerAdapters); const missing = catalogKeys.filter((key) => !adapterKeys.includes(key)); + const runtimeContractReady = PROVIDER_CATALOG.every((provider) => { + const adapter = providerAdapters[provider.key]; + return typeof adapter.listModels === "function" && typeof adapter.getRateLimitState === "function"; + }); const schemaVersion = count(this.db, "select coalesce(max(version), 0) as count from schema_migrations"); return [ { key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` }, - { key: "schema_version", passed: schemaVersion >= 20, value: schemaVersion }, + { key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady }, + { key: "schema_version", passed: schemaVersion >= 21, value: schemaVersion }, ]; } diff --git a/AgentCompany-New/tests/contract/provider-contracts.test.ts b/AgentCompany-New/tests/contract/provider-contracts.test.ts index 40464564..30589632 100644 --- a/AgentCompany-New/tests/contract/provider-contracts.test.ts +++ b/AgentCompany-New/tests/contract/provider-contracts.test.ts @@ -9,6 +9,30 @@ import { SdlcService } from "../../src/main/services/sdlc-service"; import { EVAL_SUITE_KEY, PROVIDER_AUTH_TYPE, SDLC_RUN_STATE } from "../../src/shared/domain"; import { PROVIDER_CATALOG } from "../../src/shared/provider-catalog"; +const PROVIDER_SECRET_SAMPLES: Record = { + openai: "sk-live-provider", + anthropic: "sk-live-provider", + xai: "sk-live-provider", + groq: "sk-live-provider", + "github-copilot": "ghu_live_provider", + "google-antigravity": "antigravity-session", + "gpt-account": "chatgpt-session", + "azure-openai": JSON.stringify({ + baseUrl: "https://acme.openai.azure.com", + deployment: "gpt-5.4", + secret: "azure-key", + }), + "aws-bedrock": JSON.stringify({ + region: "us-east-1", + secret: JSON.stringify({ accessKeyId: "AKIA123", secretAccessKey: "aws-secret" }), + }), + "vertex-ai": JSON.stringify({ + projectId: "acme-ai", + region: "us-central1", + secret: JSON.stringify({ client_email: "agent@acme.iam.gserviceaccount.com", private_key: "key" }), + }), +}; + function createConnection(): DatabaseConnection { return new DatabaseConnection(join(tmpdir(), `agentcompany-new-contract-${randomUUID()}.db`)); } @@ -33,32 +57,8 @@ describe("provider contracts", () => { }); test("parses credential profiles with required fields", () => { - const samples: Record = { - openai: "sk-live-provider", - anthropic: "sk-live-provider", - xai: "sk-live-provider", - groq: "sk-live-provider", - "github-copilot": "ghu_live_provider", - "google-antigravity": "antigravity-session", - "gpt-account": "chatgpt-session", - "azure-openai": JSON.stringify({ - baseUrl: "https://acme.openai.azure.com", - deployment: "gpt-5.4", - secret: "azure-key", - }), - "aws-bedrock": JSON.stringify({ - region: "us-east-1", - secret: JSON.stringify({ accessKeyId: "AKIA123", secretAccessKey: "aws-secret" }), - }), - "vertex-ai": JSON.stringify({ - projectId: "acme-ai", - region: "us-central1", - secret: JSON.stringify({ client_email: "agent@acme.iam.gserviceaccount.com", private_key: "key" }), - }), - }; - for (const provider of PROVIDER_CATALOG) { - const profile = providerAdapters[provider.key].parseAuthProfile(samples[provider.key]); + const profile = providerAdapters[provider.key].parseAuthProfile(PROVIDER_SECRET_SAMPLES[provider.key]); if (provider.authType === PROVIDER_AUTH_TYPE.API_KEY) expect(profile.type).toBe("api_key"); if (provider.authType === PROVIDER_AUTH_TYPE.OAUTH_PKCE) expect(profile.type).toBe("access_token"); if (provider.authType === PROVIDER_AUTH_TYPE.ACCOUNT_SUBSCRIPTION) expect(profile.type).toBe("account_token"); @@ -81,6 +81,15 @@ describe("provider contracts", () => { }))).toThrow("Azure base URL must use HTTPS."); }); + test("exposes model listing and rate limit adapter contracts", async () => { + for (const provider of PROVIDER_CATALOG) { + const adapter = providerAdapters[provider.key]; + const authProfile = adapter.parseAuthProfile(PROVIDER_SECRET_SAMPLES[provider.key]); + await expect(adapter.listModels({ authProfile, model: provider.defaultModel })).resolves.toEqual(provider.models); + await expect(adapter.getRateLimitState({ authProfile, model: provider.defaultModel })).resolves.toBeNull(); + } + }); + test("runs the seeded contract eval suite", () => { const connection = createConnection(); const organization = new OrganizationService(connection.db); @@ -100,7 +109,7 @@ describe("provider contracts", () => { .get(runId) as { state: string; failed_count: number; result_json: string }; const result = JSON.parse(run.result_json) as { checks: Array<{ key: string; passed: boolean }> }; expect(run).toMatchObject({ state: SDLC_RUN_STATE.PASSED, failed_count: 0 }); - expect(result.checks.map((check) => check.key).sort()).toEqual(["provider_adapter_coverage", "schema_version"]); + expect(result.checks.map((check) => check.key).sort()).toEqual(["provider_adapter_coverage", "provider_runtime_contract", "schema_version"]); expect(result.checks.every((check) => check.passed)).toBe(true); connection.close(); });