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
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { DIRECT_BYOK_PROVIDER_IDS } from '@kilocode/worker-utils/direct-byok-model';
import { DIRECT_BYOK_PROVIDERS_META } from './direct-byok-meta';

it('keeps the worker-utils direct BYOK provider ids in sync with the meta list', () => {
expect([...DIRECT_BYOK_PROVIDER_IDS].sort()).toEqual(
Object.keys(DIRECT_BYOK_PROVIDERS_META).sort()
);
});
3 changes: 2 additions & 1 deletion packages/worker-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"./review-agents": "./src/review-agents.ts",
"./code-review-council": "./src/code-review-council.ts",
"./scheduled-job-observability": "./src/scheduled-job-observability.ts",
"./r2-client": "./src/r2-client.ts"
"./r2-client": "./src/r2-client.ts",
"./direct-byok-model": "./src/direct-byok-model.ts"
},
"scripts": {
"test": "vitest run",
Expand Down
27 changes: 27 additions & 0 deletions packages/worker-utils/src/direct-byok-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { DIRECT_BYOK_PROVIDER_IDS, isDirectByokModelId } from './direct-byok-model';

describe('isDirectByokModelId', () => {
it('matches every listed provider id', () => {
for (const providerId of DIRECT_BYOK_PROVIDER_IDS) {
expect(isDirectByokModelId(`${providerId}/some-model`)).toBe(true);
expect(isDirectByokModelId(`${providerId.toUpperCase()}/Some-Model`)).toBe(true);
}
});

it('rejects non-BYOK and managed model ids', () => {
expect(isDirectByokModelId('anthropic/claude-sonnet-4.6')).toBe(false);
expect(isDirectByokModelId('google/gemma-4-26b-a4b-it')).toBe(false);
expect(isDirectByokModelId('kilo-auto/small')).toBe(false);
expect(isDirectByokModelId(undefined)).toBe(false);
expect(isDirectByokModelId(null)).toBe(false);
expect(isDirectByokModelId('')).toBe(false);
});

it('matches on the provider prefix only', () => {
// A bare provider id matches — routing only inspects the prefix.
expect(isDirectByokModelId('synthetic')).toBe(true);
expect(isDirectByokModelId('synthetic/hf:zai-org/GLM-5.1')).toBe(true);
expect(isDirectByokModelId('synthetic-new/whatever')).toBe(false);
});
});
38 changes: 38 additions & 0 deletions packages/worker-utils/src/direct-byok-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Provider ids whose models route to the user's own API key (direct BYOK) and
* never bill Kilo credits. A model id is `<providerId>/<model...>` — see
* `formatDirectByokModelId` in
* apps/web/src/lib/ai-gateway/providers/direct-byok/index.ts.
*
* Source of truth is `DIRECT_BYOK_PROVIDERS_META` in
* apps/web/src/lib/ai-gateway/providers/direct-byok/direct-byok-meta.ts.
* This copy exists because Cloudflare Workers cannot import from apps/web.
* A drift-guard test keeps the two lists equal.
*/
export const DIRECT_BYOK_PROVIDER_IDS = [
'alibaba-token-plan',
'byteplus-coding',
'chutes-byok',
'crofai',
'edenai',
'kimi-coding',
'inceptron-byok',
'martian',
'morph-byok',
'neuralwatt',
'nvidia-byok',
'ollama-cloud',
'opencode-go',
'orcarouter',
'synthetic',
'xiaomi-token-plan-ams',
'xiaomi-token-plan-sgp',
'zai-coding',
] as const;

const ids: ReadonlySet<string> = new Set(DIRECT_BYOK_PROVIDER_IDS);

export function isDirectByokModelId(modelId: string | undefined | null): boolean {
if (!modelId) return false;
return ids.has(modelId.toLowerCase().split('/')[0] ?? '');
}
8 changes: 6 additions & 2 deletions services/gastown/docs/local-debug-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -312,16 +312,20 @@ During testing, container restarts generate many of these. Bulk-close via admin

## 7. Auto-Merge with Workers AI Thread Classification

The auto-merge flow uses Workers AI (Gemma 4 26B) to classify unresolved PR review threads as blocking vs non-blocking. This prevents informational bot comments (status reports, code review summaries) from blocking auto-merge.
The auto-merge flow classifies unresolved PR review threads as blocking vs non-blocking. This prevents informational bot comments (status reports, code review summaries) from blocking auto-merge.

### How It Works

1. `poll_pr` runs every ~60s for MR beads with a `pr_url`
2. `checkPRFeedback` fetches review threads via GitHub GraphQL (including comment bodies)
3. If unresolved threads exist, `areThreadsBlocking()` sends them to Workers AI
3. If unresolved threads exist, `areThreadsBlocking()` classifies them:
- Direct-BYOK towns (configured model's provider prefix is a direct BYOK provider, e.g. `neuralwatt/...`) call the Kilo gateway at `/api/openrouter/chat/completions` on `role_models.refinery ?? default_model`, billing the user's own provider key
- Everyone else uses Workers AI (Gemma 4 26B)
4. The model classifies threads as BLOCKING (requires code changes, bugs, security) or NON-BLOCKING (informational, nits, bot status reports)
5. Only truly blocking threads prevent auto-merge

A rejected BYOK gateway call blocks auto-merge (`blocking=true`) rather than falling back to the Kilo-billed Workers AI path.

### Config Required

Set these on the town config (via `PATCH /debug/towns/:townId/config`):
Expand Down
185 changes: 185 additions & 0 deletions services/gastown/src/dos/town/town-scm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { areThreadsBlocking, type SCMContext } from './town-scm';
import { TownConfigSchema } from '../../types';

const THREADS = [
{
isResolved: false,
comments: { nodes: [{ body: 'LGTM', author: { login: 'reviewer' } }] },
},
];

function makeCtx(config: Record<string, unknown>) {
const aiRun = vi.fn();
const ctx = {
env: {
AI: { run: aiRun },
GASTOWN_AE: undefined,
KILO_API_URL: 'https://api.test',
},
townId: 'town-1',
getTownConfig: async () => TownConfigSchema.parse({ town_id: 'town-1', ...config }),
} as unknown as SCMContext;
return { ctx, aiRun };
}

describe('areThreadsBlocking', () => {
beforeEach(() => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
});

afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

it('uses the Kilo gateway when the configured model is direct BYOK', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ choices: [{ message: { content: '{"blocking": false}' } }] }), {
status: 200,
})
);
vi.stubGlobal('fetch', fetchMock);

const { ctx, aiRun } = makeCtx({
default_model: 'neuralwatt/glm-5.2-short',
kilocode_token: 'kilo-token',
});

expect(await areThreadsBlocking(ctx, THREADS)).toBe(false);
expect(aiRun).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);

const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe('https://api.test/api/openrouter/chat/completions');
expect(init.method).toBe('POST');
expect(init.headers.Authorization).toBe('Bearer kilo-token');
expect(init.headers['X-KiloCode-Feature']).toBe('gastown');
const body = JSON.parse(init.body);
expect(body.model).toBe('neuralwatt/glm-5.2-short');
// Reasoning must be disabled or the model can spend the token budget on a
// thinking trace and return no JSON content.
expect(body.reasoning).toEqual({ enabled: false, effort: 'none' });
});

it('prefers the refinery role model over the town default', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ choices: [{ message: { content: '{"blocking": false}' } }] }), {
status: 200,
})
);
vi.stubGlobal('fetch', fetchMock);

const { ctx } = makeCtx({
default_model: 'anthropic/claude-sonnet-4.6',
role_models: { refinery: 'zai-coding/glm-4.7' },
kilocode_token: 'kilo-token',
});

expect(await areThreadsBlocking(ctx, THREADS)).toBe(false);
expect(JSON.parse(fetchMock.mock.calls[0][1].body).model).toBe('zai-coding/glm-4.7');
});

it('sends the organization header for org towns', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ choices: [{ message: { content: '{"blocking": false}' } }] }), {
status: 200,
})
);
vi.stubGlobal('fetch', fetchMock);

const { ctx } = makeCtx({
default_model: 'neuralwatt/glm-5.2-short',
kilocode_token: 'kilo-token',
organization_id: 'org-1',
});

await areThreadsBlocking(ctx, THREADS);
expect(fetchMock.mock.calls[0][1].headers['X-KiloCode-OrganizationId']).toBe('org-1');
});

it('falls back to Workers AI when the configured model is not direct BYOK', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

const { ctx, aiRun } = makeCtx({
default_model: 'anthropic/claude-sonnet-4.6',
kilocode_token: 'kilo-token',
});
aiRun.mockResolvedValue({ response: '{"blocking": false}' });

expect(await areThreadsBlocking(ctx, THREADS)).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
expect(aiRun).toHaveBeenCalledWith('@cf/google/gemma-4-26b-a4b-it', expect.anything());
});

it('uses Workers AI when a managed refinery model overrides a BYOK default', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

const { ctx, aiRun } = makeCtx({
default_model: 'neuralwatt/glm-5.2-short',
role_models: { refinery: 'anthropic/claude-sonnet-4.6' },
kilocode_token: 'kilo-token',
});
aiRun.mockResolvedValue({ response: '{"blocking": false}' });

expect(await areThreadsBlocking(ctx, THREADS)).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
expect(aiRun).toHaveBeenCalled();
});

it('falls back to Workers AI when no Kilo token is configured', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

const { ctx, aiRun } = makeCtx({ default_model: 'neuralwatt/glm-5.2-short' });
aiRun.mockResolvedValue({ response: '{"blocking": false}' });

expect(await areThreadsBlocking(ctx, THREADS)).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
expect(aiRun).toHaveBeenCalled();
});

it('blocks without falling back to Workers AI when the gateway rejects the call', async () => {
const fetchMock = vi.fn().mockResolvedValue(new Response('payment required', { status: 402 }));
vi.stubGlobal('fetch', fetchMock);

const { ctx, aiRun } = makeCtx({
default_model: 'neuralwatt/glm-5.2-short',
kilocode_token: 'kilo-token',
});

expect(await areThreadsBlocking(ctx, THREADS)).toBe(true);
expect(aiRun).not.toHaveBeenCalled();
});

it('blocks without falling back when the gateway request throws', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'));
vi.stubGlobal('fetch', fetchMock);

const { ctx, aiRun } = makeCtx({
default_model: 'neuralwatt/glm-5.2-short',
kilocode_token: 'kilo-token',
});

expect(await areThreadsBlocking(ctx, THREADS)).toBe(true);
expect(aiRun).not.toHaveBeenCalled();
});

it('blocks when the gateway response has no usable text', async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ choices: [] }), { status: 200 }));
vi.stubGlobal('fetch', fetchMock);

const { ctx, aiRun } = makeCtx({
default_model: 'neuralwatt/glm-5.2-short',
kilocode_token: 'kilo-token',
});

expect(await areThreadsBlocking(ctx, THREADS)).toBe(true);
expect(aiRun).not.toHaveBeenCalled();
});
});
Loading