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
5 changes: 5 additions & 0 deletions .changeset/capability-flags-custom-providers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"manifest": minor
---

Add optional `capability_reasoning` and `capability_code` fields to custom provider models, enabling them to reach higher quality scores and be eligible for the reasoning tier
2 changes: 2 additions & 0 deletions packages/backend/src/entities/custom-provider.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ export interface CustomProviderModel {
input_price_per_million_tokens?: number;
output_price_per_million_tokens?: number;
context_window?: number;
capability_reasoning?: boolean;
capability_code?: boolean;
}

@Entity('custom_providers')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,86 @@ describe('ModelDiscoveryService', () => {
const result = await service.getModelsForAgent('agent-1');
expect(result).toHaveLength(1);
});

it('should read capability_reasoning from custom provider model data', async () => {
providerRepo.find.mockResolvedValue([]);
customProviderRepo.find.mockResolvedValue([
makeCustomProvider({
models: [
{
model_name: 'reasoning-model',
capability_reasoning: true,
capability_code: false,
},
],
}),
]);

const result = await service.getModelsForAgent('agent-1');
expect(result[0].capabilityReasoning).toBe(true);
expect(result[0].capabilityCode).toBe(false);
});

it('should read capability_code from custom provider model data', async () => {
providerRepo.find.mockResolvedValue([]);
customProviderRepo.find.mockResolvedValue([
makeCustomProvider({
models: [
{
model_name: 'code-model',
capability_reasoning: false,
capability_code: true,
},
],
}),
]);

const result = await service.getModelsForAgent('agent-1');
expect(result[0].capabilityReasoning).toBe(false);
expect(result[0].capabilityCode).toBe(true);
});

it('should default capabilities to false for legacy custom provider models', async () => {
providerRepo.find.mockResolvedValue([]);
customProviderRepo.find.mockResolvedValue([
makeCustomProvider({
models: [{ model_name: 'legacy-model' }],
}),
]);

const result = await service.getModelsForAgent('agent-1');
expect(result[0].capabilityReasoning).toBe(false);
expect(result[0].capabilityCode).toBe(false);
});

it('should compute quality score dynamically for custom provider models', async () => {
providerRepo.find.mockResolvedValue([]);
customProviderRepo.find.mockResolvedValue([
makeCustomProvider({
models: [
{
model_name: 'scored-model',
input_price_per_million_tokens: 15,
output_price_per_million_tokens: 75,
capability_reasoning: true,
capability_code: true,
},
],
}),
]);

const result = await service.getModelsForAgent('agent-1');

expect(mockComputeScore).toHaveBeenCalledWith({
model_name: 'custom:cp-1/scored-model',
input_price_per_token: 15 / 1_000_000,
output_price_per_token: 75 / 1_000_000,
capability_reasoning: true,
capability_code: true,
context_window: 128000,
});
expect(result[0].qualityScore).toBe(3); // mock returns 3
});
});

/* ── getModelForAgent ── */
Expand Down
15 changes: 12 additions & 3 deletions packages/backend/src/model-discovery/model-discovery.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,16 +224,25 @@ export class ModelDiscoveryService {
m.output_price_per_million_tokens != null
? m.output_price_per_million_tokens / 1_000_000
: null;
const capReasoning = m.capability_reasoning ?? false;
const capCode = m.capability_code ?? false;
models.push({
id: modelKey,
displayName: m.model_name,
provider: cpKey,
contextWindow: m.context_window ?? 128000,
inputPricePerToken: inputPerToken,
outputPricePerToken: outputPerToken,
capabilityReasoning: false,
capabilityCode: false,
qualityScore: 2,
capabilityReasoning: capReasoning,
capabilityCode: capCode,
qualityScore: computeQualityScore({
model_name: modelKey,
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai bot Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Pass the raw custom model name into computeQualityScore; using the prefixed modelKey adds an extra slash segment that prevents QUALITY_OVERRIDES from matching vendor-prefixed names (e.g., anthropic/claude-sonnet-4-20250514), so those models get the wrong quality tier.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/backend/src/model-discovery/model-discovery.service.ts, line 239:

<comment>Pass the raw custom model name into computeQualityScore; using the prefixed `modelKey` adds an extra slash segment that prevents QUALITY_OVERRIDES from matching vendor-prefixed names (e.g., `anthropic/claude-sonnet-4-20250514`), so those models get the wrong quality tier.</comment>

<file context>
@@ -224,16 +224,25 @@ export class ModelDiscoveryService {
+          capabilityReasoning: capReasoning,
+          capabilityCode: capCode,
+          qualityScore: computeQualityScore({
+            model_name: modelKey,
+            input_price_per_token: inputPerToken,
+            output_price_per_token: outputPerToken,
</file context>
Suggested change
model_name: modelKey,
model_name: m.model_name,
Fix with Cubic

input_price_per_token: inputPerToken,
output_price_per_token: outputPerToken,
capability_reasoning: capReasoning,
capability_code: capCode,
context_window: m.context_window ?? 128000,
}),
});
}
}
Expand Down
45 changes: 45 additions & 0 deletions packages/backend/src/routing/custom-provider.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,36 @@ describe('CustomProviderService (with mocks)', () => {
expect(result.models[0].output_price_per_million_tokens).toBe(0);
});

it('passes through capability flags to stored models', async () => {
const dto = {
name: 'Gateway',
base_url: 'https://proxy.example.com/v1',
models: [
{
model_name: 'claude-opus',
capability_reasoning: true,
capability_code: true,
},
],
};
const result = await service.create('agent-1', 'user-1', dto as never);

expect(result.models[0].capability_reasoning).toBe(true);
expect(result.models[0].capability_code).toBe(true);
});

it('stores undefined capabilities when not provided (backward compat)', async () => {
const dto = {
name: 'Legacy',
base_url: 'https://api.example.com/v1',
models: [{ model_name: 'old-model' }],
};
const result = await service.create('agent-1', 'user-1', dto as never);

expect(result.models[0].capability_reasoning).toBeUndefined();
expect(result.models[0].capability_code).toBeUndefined();
});

it('creates multiple models in the custom provider', async () => {
const dto = {
name: 'Multi',
Expand Down Expand Up @@ -395,6 +425,21 @@ describe('CustomProviderService (with mocks)', () => {
expect(mockRoutingCache.invalidateAgent).toHaveBeenCalledWith('agent-1');
});

it('passes through capability flags when models are updated', async () => {
const result = await service.update('agent-1', 'cp-1', 'user-1', {
models: [
{
model_name: 'new-model',
capability_reasoning: true,
capability_code: false,
},
],
} as never);

expect(result.models[0].capability_reasoning).toBe(true);
expect(result.models[0].capability_code).toBe(false);
});

it('does not double-recalculate when both models and apiKey change', async () => {
await service.update('agent-1', 'cp-1', 'user-1', {
models: [{ model_name: 'new-model' }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ export class CustomProviderService {
input_price_per_million_tokens: m.input_price_per_million_tokens,
output_price_per_million_tokens: m.output_price_per_million_tokens,
context_window: m.context_window ?? 128000,
capability_reasoning: m.capability_reasoning,
capability_code: m.capability_code,
})),
created_at: new Date().toISOString(),
});
Expand Down Expand Up @@ -138,6 +140,8 @@ export class CustomProviderService {
input_price_per_million_tokens: m.input_price_per_million_tokens,
output_price_per_million_tokens: m.output_price_per_million_tokens,
context_window: m.context_window ?? 128000,
capability_reasoning: m.capability_reasoning,
capability_code: m.capability_code,
}));
}

Expand Down
51 changes: 51 additions & 0 deletions packages/backend/src/routing/dto/custom-provider.dto.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,57 @@ describe('CreateCustomProviderDto', () => {
expect(errors.length).toBeGreaterThan(0);
});

it('accepts models with capability flags', async () => {
const dto = toDto({
name: 'Gateway',
base_url: 'https://proxy.example.com/v1',
models: [
{
model_name: 'claude-opus',
capability_reasoning: true,
capability_code: true,
},
],
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});

it('accepts models with only one capability flag', async () => {
const dto = toDto({
name: 'Gateway',
base_url: 'https://proxy.example.com/v1',
models: [
{
model_name: 'deepseek-r1',
capability_reasoning: true,
},
],
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});

it('rejects non-boolean capability_reasoning', async () => {
const dto = toDto({
name: 'Test',
base_url: 'https://api.example.com/v1',
models: [{ model_name: 'model-a', capability_reasoning: 'yes' }],
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});

it('rejects non-boolean capability_code', async () => {
const dto = toDto({
name: 'Test',
base_url: 'https://api.example.com/v1',
models: [{ model_name: 'model-a', capability_code: 42 }],
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});

it('rejects negative pricing', async () => {
const dto = toDto({
name: 'Test',
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/src/routing/dto/custom-provider.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
IsNotEmpty,
IsOptional,
IsNumber,
IsBoolean,
IsArray,
ValidateNested,
Matches,
Expand Down Expand Up @@ -37,6 +38,14 @@ export class CustomProviderModelDto {
@Min(1)
@Type(() => Number)
context_window?: number;

@IsOptional()
@IsBoolean()
capability_reasoning?: boolean;

@IsOptional()
@IsBoolean()
capability_code?: boolean;
}

export class CreateCustomProviderDto {
Expand Down
85 changes: 85 additions & 0 deletions packages/backend/test/custom-providers.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,91 @@ describe('Custom Providers (e2e)', () => {
.expect(200);
});

it('creates custom provider with capability flags', async () => {
const res = await request(app.getHttpServer())
.post(`/api/v1/routing/${agentName}/custom-providers`)
.set(headers)
.send({
name: 'Gateway Provider',
base_url: 'https://proxy.example.com/v1',
models: [
{
model_name: 'frontier-model',
input_price_per_million_tokens: 15,
output_price_per_million_tokens: 75,
capability_reasoning: true,
capability_code: true,
},
],
})
.expect(201);

expect(res.body.models[0].capability_reasoning).toBe(true);
expect(res.body.models[0].capability_code).toBe(true);

// Verify available-models reflects capability flags and quality score
const modelsRes = await request(app.getHttpServer())
.get(`/api/v1/routing/${agentName}/available-models`)
.set(headers)
.expect(200);

const model = modelsRes.body.find(
(m: { display_name?: string }) => m.display_name === 'frontier-model',
);
expect(model).toBeDefined();
expect(model.capability_reasoning).toBe(true);
expect(model.capability_code).toBe(true);
// Expensive + dual capabilities = frontier tier (score 5)
expect(model.quality_score).toBe(5);

// Cleanup
await request(app.getHttpServer())
.delete(`/api/v1/routing/${agentName}/custom-providers/${res.body.id}`)
.set(headers)
.expect(200);
});

it('custom provider models without capability flags default to false', async () => {
const res = await request(app.getHttpServer())
.post(`/api/v1/routing/${agentName}/custom-providers`)
.set(headers)
.send({
name: 'Legacy Provider',
base_url: 'https://api.example.com/v1',
models: [
{
model_name: 'legacy-model',
input_price_per_million_tokens: 1.0,
output_price_per_million_tokens: 2.0,
},
],
})
.expect(201);

// Should not have capability fields in stored models
expect(res.body.models[0].capability_reasoning).toBeUndefined();
expect(res.body.models[0].capability_code).toBeUndefined();

// Verify available-models defaults to false
const modelsRes = await request(app.getHttpServer())
.get(`/api/v1/routing/${agentName}/available-models`)
.set(headers)
.expect(200);

const model = modelsRes.body.find(
(m: { display_name?: string }) => m.display_name === 'legacy-model',
);
expect(model).toBeDefined();
expect(model.capability_reasoning).toBe(false);
expect(model.capability_code).toBe(false);

// Cleanup
await request(app.getHttpServer())
.delete(`/api/v1/routing/${agentName}/custom-providers/${res.body.id}`)
.set(headers)
.expect(200);
});

it('stores explicit 0 prices as 0 (not null)', async () => {
const res = await request(app.getHttpServer())
.post(`/api/v1/routing/${agentName}/custom-providers`)
Expand Down
2 changes: 2 additions & 0 deletions packages/frontend/src/services/api/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,8 @@ export interface CustomProviderModel {
input_price_per_million_tokens?: number;
output_price_per_million_tokens?: number;
context_window?: number;
capability_reasoning?: boolean;
capability_code?: boolean;
}

export interface CustomProviderData {
Expand Down
Loading