Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .qualops/.qualopsrc.configurable-agent-test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
{
"$schema": "../qualops-config.schema.json",
"ai": {
"reviewStage": {
"provider": "openai-compatible",
"model": "gpt-4.1",
"inputPerMillion": 2.0,
"outputPerMillion": 8.0,
"temperature": 0
},
"fixStage": {
"provider": "openai-compatible",
"model": "gpt-4.1",
"inputPerMillion": 2.0,
"outputPerMillion": 8.0,
"temperature": 0
},
"judgeStage": {
"provider": "openai-compatible",
"model": "gpt-4.1",
"inputPerMillion": 2.0,
"outputPerMillion": 8.0,
"temperature": 0
}
},
"performance": {
"maxFileSizeKB": 500,
"maxFilesPerBatch": 15,
"maxTokensPerFile": 8000,
"timeoutSeconds": 300,
"throttling": {
"enabled": true,
"maxRequestsPerMinute": 50
}
},
"review": {
"minConfidence": 7,
"maxConcurrentFiles": 3,
"pipeline": [
{
"name": "agenticSecurityAudit",
"enabled": true,
"mode": "agentic",
"agentic": {
"maxTurns": 15,
"enabledSubagents": [
"security-analyzer",
"dependency-tracer",
"breaking-change-detector"
],
"systemPrompt": "You are a security-focused code reviewer analyzing the QualOps codebase. Focus on:\n1. Command injection vulnerabilities in shell executions\n2. Path traversal in file operations\n3. Credential exposure in logs or error messages\n4. Unsafe deserialization or eval usage\n5. Cross-file security implications"
}
}
]
},
"fix": {
"enabled": false
},
"report": {
"outputFormat": "html",
"includedSeverities": [
"critical",
"high",
"medium",
"low"
],
"enableRootCauseExtraction": false
},
"github": {
"enabled": false,
"postComments": false,
"skipOnDraft": false,
"blockPipeline": false,
"maxInlineComments": 50
},
"skipPatterns": [
"node_modules/**",
".git/**",
"dist/**",
"build/**",
"coverage/**",
"reports/**",
".qualops-cache/**",
"*.min.js",
"*.bundle.js",
"examples/**",
"docs/**",
"evals/datasets/**",
"**/*.d.ts"
]
}
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- `baseUrl` and `apiKey` fields added to `aiStageConfig` schema for `openai-compatible` providers, with `OPENAI_BASE_URL` / `OPENAI_API_KEY` env-var fallbacks resolved in `getResolvedStageConfig`.
- `openai-compatible` provider support for agentic review mode via `@eggai/configurable-agent` (Vercel AI SDK v5 agent loop). Any provider with a custom `baseUrl` can now run the full agentic security audit without SDK-specific adapters.
- Provider-dialect smoke spec: `npm run test:smoke` runs the 4 AI caller stages migrated in PR #145 (`file-reviewer`, `validation-resolver`, `dedup-resolver`, `root-cause-extract`) against each real provider (`anthropic`, `openai`, `bedrock`, `github`) using a slice fixture as input. Validates that the structured-output dialect path returns a zod-validated response without throwing. Implemented as a Jest spec under `tests/smoke/` with its own `jest.smoke.config.ts` — not picked up by default `npm test` (whose `roots` are limited to `tests/unit/`). Provider config comes from `ConfigService` + the existing `PROVIDER_DEFAULTS` table, not a duplicated table. Providers with missing credentials are `describe.skip()`-ed; providers with malformed credentials fail loudly via the provider class's own `validateApiKey()`. Input is a slice fixture under `evals/datasets/inbox/smoke-sql-injection/`, loosely following TDR 0002. Nightly + manual CI workflow at `.github/workflows/provider-dialect-smoke.yml`. Automates the unchecked manual smoke item from PR #145's test plan; distinct from the deferred per-stage golden-evals item which validates output quality.
- `unstructured` dialect for LLM models without `response_format: {type: "json_schema"}` support (e.g. Llama, Qwen2.5, DeepSeek-V3, Phi, older o1-series). When `isUnstructured()` is true, the pipeline runs a full prose path: `ProseFileReviewer` → `ProseValidationResolver` → `ProseDeduplicationResolver` → `session/prose-report.md`. No JSON parsing, no structured schemas — the model writes free-form prose and subsequent stages refine it in-kind.
- Bundled litellm model capability snapshot (`src/ai/providers/model-capabilities.json`, 2101 chat models) for automatic `supportsResponseSchema` lookup. Unknown models default to `unstructured` (safe).
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
"generate:schema": "ts-node --transpile-only --project tsconfig.lib.json scripts/generate-config-schema.ts"
},
"dependencies": {
"@eggai/configurable-agent": "^0.1.0",
"@anthropic-ai/claude-agent-sdk": "0.3.169",
"@anthropic-ai/sdk": "^0.102.0",
"@aws-sdk/client-bedrock-runtime": "^3.1008.0",
Expand Down
12 changes: 11 additions & 1 deletion qualops-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,15 @@
"description": "Optional model output token cap.",
"type": "integer",
"minimum": 1
},
"baseUrl": {
"description": "Base URL for openai-compatible providers. Defaults to OPENAI_BASE_URL env var.",
"type": "string",
"format": "uri"
},
"apiKey": {
"description": "API key for openai-compatible providers. Defaults to OPENAI_API_KEY env var.",
"type": "string"
}
},
"passthrough": true
Expand All @@ -201,7 +210,8 @@
"anthropic",
"openai",
"bedrock",
"github"
"github",
"openai-compatible"
]
},
"nonEmptyString": {
Expand Down
2 changes: 2 additions & 0 deletions src/ai/providers/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export function detectCapabilities(provider: AIProviderName, model: string): Pro
return detectAnthropicCapabilities(model);
case 'bedrock':
return detectBedrockCapabilities(model);
case 'openai-compatible':
return detectOpenAICapabilities(model);
default: {
const _exhaustive: never = provider;
throw new Error(`Unknown provider: ${String(_exhaustive)}`);
Expand Down
3 changes: 3 additions & 0 deletions src/ai/providers/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ export class AIFactory {
case AIProviderType.GITHUB:
provider = new GitHubModelsProvider(stageConfig);
break;
case 'openai-compatible':
provider = new OpenAIProvider(stageConfig);
break;
default: {
const _exhaustiveCheck: never = stageConfig.provider;
throw new Error(`Unknown AI provider: ${_exhaustiveCheck}`);
Expand Down
8 changes: 7 additions & 1 deletion src/config/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const severityList = z.array(severity).min(1).meta({
uniqueItems: true,
});
export const aiProvider = z
.enum(['anthropic', 'openai', 'bedrock', 'github'])
.enum(['anthropic', 'openai', 'bedrock', 'github', 'openai-compatible'])
.meta({ defName: 'aiProvider', description: 'Supported AI provider names.' });
const confidenceScore = z.int().min(1).max(10).meta({
defName: 'confidenceScore',
Expand Down Expand Up @@ -73,6 +73,12 @@ const aiStageConfigSchema = z
.optional()
.meta({ description: 'Sampling temperature used by compatible providers.' }),
maxTokens: z.int().min(1).optional().meta({ description: 'Optional model output token cap.' }),
baseUrl: z.string().url().optional().meta({
description: 'Base URL for openai-compatible providers. Defaults to OPENAI_BASE_URL env var.',
}),
apiKey: z.string().optional().meta({
description: 'API key for openai-compatible providers. Defaults to OPENAI_API_KEY env var.',
}),
})
.passthrough()
.meta({
Expand Down
26 changes: 23 additions & 3 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,18 +333,38 @@ export class ConfigService {
getResolvedStageConfig(stage: string): ResolvedStageConfig {
const raw = this.getAIStageConfig(stage);
const { provider, model } = this.resolveModel({ stage: raw });
return { ...raw, provider, model };
const credentials = this.resolveCredentials(provider, raw);
return { ...raw, provider, model, ...credentials };
}

private resolveCredentials(
provider: AIProviderName,
raw: AIStageConfig,
): { baseUrl?: string; apiKey?: string } {
if (provider === 'openai-compatible') {
return {
baseUrl: raw.baseUrl ?? process.env.OPENAI_BASE_URL,
apiKey: raw.apiKey ?? process.env.OPENAI_API_KEY,
};
}
return {
...(raw.baseUrl !== undefined && { baseUrl: raw.baseUrl }),
...(raw.apiKey !== undefined && { apiKey: raw.apiKey }),
};
}

/**
* Maps a resolved provider name to the agent adapter type to use.
* Returns `undefined` for providers that do not yet support agentic mode.
*/
resolveAgentAdapterType(provider: AIProviderName): 'anthropic' | 'openai' | undefined {
const mapping: Partial<Record<AIProviderName, 'anthropic' | 'openai'>> = {
resolveAgentAdapterType(
provider: AIProviderName,
): 'anthropic' | 'openai' | 'openai-compatible' | undefined {
const mapping: Partial<Record<AIProviderName, 'anthropic' | 'openai' | 'openai-compatible'>> = {
anthropic: 'anthropic',
openai: 'openai',
github: 'openai', // GitHub Models uses an OpenAI-compatible API
'openai-compatible': 'openai-compatible',
};
return mapping[provider];
}
Expand Down
4 changes: 4 additions & 0 deletions src/shared/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export type AIStageConfig = {
outputPerMillion: number;
temperature?: number;
maxTokens?: number;
baseUrl?: string;
apiKey?: string;
[key: string]: unknown;
};

Expand All @@ -62,6 +64,8 @@ export type ResolvedStageConfig = {
outputPerMillion: number;
temperature?: number;
maxTokens?: number;
baseUrl?: string;
apiKey?: string;
[key: string]: unknown;
};

Expand Down
2 changes: 2 additions & 0 deletions src/stages/review/agentic/adapters/agent-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export interface AgentAdapterParams {
skipPatterns?: string[];
toolConfig: ToolConfig;
onToolCall?: (turn: number, name: string, input: unknown) => void;
baseUrl?: string;
apiKey?: string;
}

export interface AgentAdapterResult {
Expand Down
142 changes: 142 additions & 0 deletions src/stages/review/agentic/adapters/configurable-agent-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { runAgent, jsonSchema } from '@eggai/configurable-agent/lib';
import type { AgentConfig, AgentEvent } from '@eggai/configurable-agent/lib';
import { z } from 'zod';

import type { AgentAdapter, AgentAdapterParams, AgentAdapterResult } from './agent-adapter';
import { logger } from '../../../../shared/utils/logger';
import { createToolSet } from '../tools';

function toJsonSchema(schema: z.ZodObject<z.ZodRawShape>): Record<string, unknown> {
// z.toJSONSchema emits Draft 2020-12 with a $schema key that confuses some providers.
// Strip it and emit a plain draft-07-compatible object instead.
const { $schema: _dropped, ...rest } = z.toJSONSchema(schema) as Record<string, unknown>;
return rest;
}

function buildSystemPrompt(params: AgentAdapterParams, toolNames: string[]): string {
const parts = [params.systemPrompt];

const agentEntries = Object.entries(params.agents ?? {});
if (agentEntries.length > 0) {
parts.push('## Review perspectives\n');
for (const [name, def] of agentEntries) {
parts.push(`### ${name}\n${def.prompt}`);
}
}

if (toolNames.length > 0) {
parts.push(
`## Available tools\nThe following tools are available. Use their EXACT names when calling them — do not use aliases like Read, Grep, or Glob:\n${toolNames.map((n) => `- ${n}`).join('\n')}\n\nUse these tools to trace cross-file issues, verify patterns, and confirm findings before reporting.`,
);
}

return parts.filter(Boolean).join('\n\n');
}

function buildAgentConfig(params: AgentAdapterParams, toolNames: string[]) {
return {
systemPrompt: buildSystemPrompt(params, toolNames),
model: {
provider: 'openai-compatible' as const,
name: params.model,
...(params.baseUrl && { baseUrl: params.baseUrl }),
...(params.apiKey !== undefined && { apiKey: params.apiKey }),
},
agent: { maxSteps: params.maxTurns },
mcpTools: [],
output: { structured: false },
safety: {
compaction: { triggerTokens: 100_000, keepRecentMessages: 6 },
toolOutput: { triggerTokens: 4_000, headChars: 500, tailChars: 500 },
},
} satisfies AgentConfig;
}

export class ConfigurableAgentAdapter implements AgentAdapter {
async run(params: AgentAdapterParams): Promise<AgentAdapterResult> {
const qualopsTools = await createToolSet(params.cwd, params.toolConfig, params.skipPatterns);
const config = buildAgentConfig(
params,
qualopsTools.tools.map((t) => t.name),
);

const tools: Record<
string,
{
description: string;
inputSchema: unknown;
execute: (args: Record<string, unknown>) => Promise<string>;
}
> = {};
for (const def of qualopsTools.tools) {
tools[def.name] = {
description: def.description,
inputSchema: jsonSchema(toJsonSchema(def.schema)),
execute: (args: Record<string, unknown>) => def.execute(args),
};
}

// Maps configurable-agent error codes to qualops error subtypes.
// Codes not in this map are treated as unrecoverable and will throw.
const ERROR_SUBTYPE_MAP: Record<string, string> = {
tool_call_on_final_step: 'error_max_turns',
stream_error: 'error_provider_unavailable',
rate_limit_tokens: 'error_rate_limit_tokens',
max_tokens_reached: 'error_max_tokens',
structured_output_failed: 'error_content_filter',
};

try {
let output = '';
let inputTokens: number | undefined;
let outputTokens: number | undefined;
let errorSubtype: string | undefined;
let turnIndex = 0;

await runAgent(
config,
[{ role: 'user', content: params.userPrompt }],
async (event: AgentEvent) => {
switch (event.type) {
case 'tool_call':
turnIndex++;
logger.info(`[Agentic/ConfigurableAgent] Tool call: ${event.name}`);
params.onToolCall?.(turnIndex, event.name, event.args);
break;
case 'final':
output = event.content;
inputTokens = event.usage?.inputTokens;
outputTokens = event.usage?.outputTokens;
logger.info(
`[Agentic/ConfigurableAgent] Finished. steps=${event.steps}, stopReason=${event.stopReason}`,
);
break;
case 'error': {
logger.warn(
`[Agentic/ConfigurableAgent] Error: code=${event.code} — ${event.message}`,
);
const subtype = ERROR_SUBTYPE_MAP[event.code];
if (!subtype) throw new Error(`Agent failed: ${event.message}`);
errorSubtype = subtype;
if (event.partialContent) {
logger.warn(
`[Agentic/ConfigurableAgent] Recovering partial response (${event.partialContent.length} chars)`,
);
output = event.partialContent;
}
break;
}
default:
break;
}
},
undefined,
{ tools: tools as never },
);

return { output, inputTokens, outputTokens, errorSubtype };
} finally {
await qualopsTools.dispose();
}
}
}
Loading
Loading