-
Notifications
You must be signed in to change notification settings - Fork 44
feat(auto-routing): benchmark-driven decision engine and kilo-auto/efficient #3982
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
iscekic
wants to merge
85
commits into
main
Choose a base branch
from
feat/auto-routing-efficient-decision-engine
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
85 commits
Select commit
Hold shift + click to select a range
b41e58e
refactor(auto-routing): move classifier core into contracts package
iscekic 1fb85f5
feat(auto-routing): add tier, routing-table, decision and benchmark c…
iscekic 39acfdb
feat(auto-routing): add benchmark-driven decision engine and KV routi…
iscekic bd83fdc
feat(auto-routing): return routing decisions from /decide
iscekic 9621d62
fix(auto-routing): log unparseable routing table JSON before falling …
iscekic 7af1b6d
feat(auto-routing-benchmark): scaffold benchmark worker with D1 schema
iscekic 22de713
feat(auto-routing-benchmark): classifier golden dataset and grading
iscekic 878e49b
style(auto-routing-benchmark): apply oxfmt formatting
iscekic 662717c
feat(auto-routing-benchmark): decider golden dataset with determinist…
iscekic 110cbd9
fix(auto-routing-benchmark): unambiguous whitespace instruction in of…
iscekic 5ce8621
feat(auto-routing-benchmark): queue-driven benchmark runs with aggreg…
iscekic 0c763ce
feat(auto-routing-benchmark): admin config, runs and routing-table en…
iscekic c749be2
feat(admin): proxy routes for auto-routing benchmark service
iscekic 0e34c02
feat(admin): benchmark config, runs and routing table panel
iscekic fb084c3
fix(admin): stabilize benchmark runs polling interval dependencies
iscekic 9f2d876
feat(web): internal token mint endpoint for auto-routing benchmark
iscekic 7a31d4a
feat(auto-routing-benchmark): run decider cases through kilo CLI in a…
iscekic d0f13b0
feat(admin): benchmark user id config field
iscekic fdc6520
feat(gateway): add kilo-auto/efficient with blocking auto-routing dec…
iscekic 813ea0e
chore(auto-routing): drop unused import in routing-table contracts
iscekic 9b69edf
fix(auto-routing-benchmark): harden decider CLI parsing, grading and …
iscekic 5ff4b08
fix(auto-routing-benchmark): warm up CLI container before concurrent …
iscekic 06836cc
fix(auto-routing-benchmark): faster container turnover to avoid insta…
iscekic 2faee13
fix(auto-routing-benchmark): address review findings
iscekic cac57b7
style(auto-routing-benchmark): format wrangler.jsonc
iscekic ccc9c9d
fix(auto-routing-benchmark): guard against double finish on spawn fai…
iscekic ba3b3be
fix(auto-routing): break contracts module cycle and keep response sch…
iscekic 6776db0
chore(admin): drop unused import after schema move
iscekic c0320c7
feat(auto-routing): classifier model becomes an admin override over t…
iscekic 7bb5048
feat(auto-routing): manual benchmark runs, classifier override, decid…
iscekic f3c0128
refactor(auto-routing): simplification pass
iscekic 641f6ef
refactor(auto-routing-benchmark): use drizzle for all D1 access
iscekic 2d2691f
refactor(auto-routing-benchmark): normalize D1 schema and adopt drizz…
iscekic 86e2fdc
fix(auto-routing-benchmark): preserve null candidate cost and type dr…
iscekic 0241d47
refactor(auto-routing-benchmark): make candidate cost non-null to mat…
iscekic 8244676
feat(auto-routing): read-through KV cache backed by the benchmark ser…
iscekic 36f32a7
fix(auto-routing): await read-through cache writes and surface origin…
iscekic aa14657
ci(workers): run worker predeploy scripts (D1 migrations) before deploy
iscekic 82aef0b
fix(auto-routing-benchmark): reuse loaded run state in finalize and b…
iscekic 4a7478b
refactor(auto-routing): share ttl cache, single-source schemas and dr…
iscekic a449c26
docs(gateway): drop stale keep-in-sync comment on DecideBaseParams
iscekic 4caa4f8
feat(gateway): bill classifier cost to the user for kilo-auto/efficient
iscekic ec5dc3f
fix(gateway): fix type error and remove dead guard in classifier billing
iscekic 0141b71
fix(auto-routing): apply decision reasoningEffort to efficient routing
iscekic 6960e1a
feat(auto-routing): align kilo-auto/efficient catalog with balanced, …
iscekic debdd03
fix(admin): correct run-summaries colspan in benchmarks section
iscekic a016310
feat(admin): derive decider model API kinds from gateway provider def…
iscekic fc427e5
feat(auto-routing): drop default routing table; no table means no dec…
iscekic 01e4bd9
fix(auto-routing): keep classifier override when benchmark origin is …
iscekic 0828e47
docs(contracts): fix stale classifier-winner comment
iscekic 71222ca
fix(benchmark): exclude no-cost-signal summaries from routing table r…
iscekic 6f5fd38
test(benchmark): fix expected ranking order in no-cost-signal test
iscekic 2cd53f9
feat(benchmark): remove fabricated default config; runs require a sav…
iscekic 354054d
chore(benchmark): drop redundant case_results index, regenerate basel…
iscekic 6aba145
docs(benchmark): fix stale KV comment in wrangler config
iscekic 8955269
feat(auto-routing-benchmark): grade subtaskType and riskLevel, expand…
iscekic ae707f3
feat(auto-routing-benchmark): expand decider dataset to per-pair taxo…
iscekic adb49f5
feat(auto-routing): session-sticky decisions with switch-cost factor
iscekic a24dc4d
feat(auto-routing-benchmark): plumb switchCostFactor through config, …
iscekic 1d424c5
Merge remote-tracking branch 'origin/main' into feat/auto-routing-eff…
iscekic 3d50441
fix(ai-gateway): align efficient fallback with Qwen-for-all-APIs afte…
iscekic d922d92
refactor(auto-routing): drop per-candidate API-kind plumbing, validat…
iscekic 427dcc2
fix(auto-routing): review-pass fixes
iscekic 053373b
test(ai-gateway): add sticky field to decision fixture
iscekic b8a5892
feat(dev): move auto-routing workers into their own opt-in dev group
iscekic 2f39419
fix(auto-routing): make the decider benchmark runnable in local dev
iscekic ae0cec5
fix(auto-routing): kill the whole CLI process tree on decider case ti…
iscekic 4f04e0a
feat(auto-routing): benchmark repetitions, p95 latency, and classifie…
iscekic 1eae06f
fix(auto-routing): correct case_results migration backfill and close …
iscekic 7151256
feat(admin): benchmark repetitions, latency budget, and p95/timeout c…
iscekic 17a8c01
fix(admin): correct runs-table colSpan and cover config form round-trip
iscekic 1a5d858
chore(auto-routing): squash benchmark D1 migrations into one baseline
iscekic c9db589
Merge remote-tracking branch 'origin/main' into feat/auto-routing-eff…
iscekic 9eaae60
test(ai-gateway): stop depending on removed morph model in API-kind t…
iscekic 165240b
fix(auto-routing-benchmark): return 400 when starting a run without c…
iscekic 0844c48
fix(auto-routing-benchmark): slice queue fan-out under sendBatch limit
iscekic 0d9d6d2
fix(ai-gateway): suppress first-usage events for classifier overhead row
iscekic f00b619
fix(ai-gateway): bill classifier cost regardless of final-provider BYOK
iscekic a8d0cd7
fix(ai-gateway): make efficient classifier spend authenticated + exit…
iscekic 188dfe7
fix(auto-routing): reject duplicate benchmark model ids at validation
iscekic 4c0a18d
fix(auto-routing): reject model-experiment ids as decider candidates
iscekic 75c762a
fix(auto-routing-benchmark): invalidate carried summaries on identity…
iscekic 3166eff
fix(auto-routing-benchmark): one active run per kind + stale recovery
iscekic b19c57a
fix(auto-routing): harden benchmarks admin panel (a11y, overflow, dir…
iscekic 637d695
docs(auto-routing): add ADR and benchmark service README
iscekic File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
158 changes: 158 additions & 0 deletions
158
apps/web/src/app/admin/api/auto-routing/benchmark-config/route.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,158 @@ | ||
| import { NextRequest } from 'next/server'; | ||
| import type { User } from '@kilocode/db'; | ||
| import { | ||
| getBenchmarkConfig, | ||
| updateBenchmarkConfig, | ||
| } from '@/lib/ai-gateway/auto-routing-benchmark-admin-client'; | ||
| import { getUserFromAuth } from '@/lib/user/server'; | ||
| import { findExperimentReservedModelIds } from '@/lib/ai-gateway/experiments/reserved-ids'; | ||
| import type { KiloExclusiveModel } from '@/lib/ai-gateway/providers/kilo-exclusive-model'; | ||
| import type * as ModelsModule from '@/lib/ai-gateway/models'; | ||
|
|
||
| jest.mock('@/lib/user/server', () => ({ | ||
| getUserFromAuth: jest.fn(), | ||
| })); | ||
|
|
||
| jest.mock('@/lib/ai-gateway/auto-routing-benchmark-admin-client', () => ({ | ||
| getBenchmarkConfig: jest.fn(), | ||
| updateBenchmarkConfig: jest.fn(), | ||
| })); | ||
|
|
||
| jest.mock('@/lib/ai-gateway/experiments/reserved-ids', () => ({ | ||
| findExperimentReservedModelIds: jest.fn(), | ||
| })); | ||
|
|
||
| // Stub the catalog so tests don't depend on any specific provider file. | ||
| // 'test-exclusive/alibaba-only' maps to the alibaba gateway (chat_completions only). | ||
| jest.mock('@/lib/ai-gateway/models', () => { | ||
| const actual = jest.requireActual<typeof ModelsModule>('@/lib/ai-gateway/models'); | ||
| const stubModel: KiloExclusiveModel = { | ||
| public_id: 'test-exclusive/alibaba-only', | ||
| display_name: 'Test Alibaba-only', | ||
| description: 'stub for unit tests', | ||
| context_length: 8192, | ||
| max_completion_tokens: 4096, | ||
| status: 'public', | ||
| flags: [], | ||
| gateway: 'alibaba', | ||
| internal_id: 'stub-internal', | ||
| pricing: null, | ||
| exclusive_to: [], | ||
| inference_provider_restriction: [], | ||
| }; | ||
| return { | ||
| ...actual, | ||
| findKiloExclusiveModel: (id: string) => | ||
| id === 'test-exclusive/alibaba-only' ? stubModel : actual.findKiloExclusiveModel(id), | ||
| }; | ||
| }); | ||
|
|
||
| import { PUT } from './route'; | ||
|
|
||
| const mockGetUserFromAuth = jest.mocked(getUserFromAuth); | ||
| const mockGetBenchmarkConfig = jest.mocked(getBenchmarkConfig); | ||
| const mockUpdateBenchmarkConfig = jest.mocked(updateBenchmarkConfig); | ||
| const mockFindExperimentReservedModelIds = jest.mocked(findExperimentReservedModelIds); | ||
|
|
||
| // Test-fixture boundary: only the fields the route actually reads. | ||
| function adminUserFixture(): User { | ||
| return { id: 'admin_123', google_user_email: 'admin@kilocode.ai' } as Partial<User> as User; | ||
| } | ||
|
|
||
| function putRequest(body: unknown) { | ||
| return new NextRequest('http://localhost:3000/admin/api/auto-routing/benchmark-config', { | ||
| method: 'PUT', | ||
| body: JSON.stringify(body), | ||
| headers: { 'content-type': 'application/json' }, | ||
| }); | ||
| } | ||
|
|
||
| const validConfig = { | ||
| classifierModels: ['google/gemini-2.5-flash-lite'], | ||
| deciderModels: [{ id: 'openai/gpt-5-mini', reasoningEffort: null }], | ||
| minAccuracy: 0.7, | ||
| switchCostFactor: 3, | ||
| maxConcurrency: 4, | ||
| benchmarkUserId: null, | ||
| classifierRepetitions: 1, | ||
| deciderRepetitions: 1, | ||
| classifierMaxP95LatencyMs: 1000, | ||
| updatedAt: null, | ||
| updatedBy: null, | ||
| }; | ||
|
|
||
| describe('PUT /admin/api/auto-routing/benchmark-config', () => { | ||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| mockGetUserFromAuth.mockResolvedValue({ | ||
| user: adminUserFixture(), | ||
| authFailedResponse: null, | ||
| }); | ||
| mockUpdateBenchmarkConfig.mockResolvedValue({ | ||
| status: 200, | ||
| body: { config: validConfig }, | ||
| }); | ||
| mockGetBenchmarkConfig.mockResolvedValue({ status: 200, body: { config: null } }); | ||
| mockFindExperimentReservedModelIds.mockResolvedValue([]); | ||
| }); | ||
|
|
||
| it('forwards a config whose decider models all serve every gateway chat API', async () => { | ||
| const response = await PUT(putRequest(validConfig)); | ||
| expect(response.status).toBe(200); | ||
| expect(mockUpdateBenchmarkConfig).toHaveBeenCalledWith(validConfig, 'admin@kilocode.ai'); | ||
| }); | ||
|
|
||
| it('rejects with 400 listing decider models not servable on all gateway chat APIs', async () => { | ||
| const response = await PUT( | ||
| putRequest({ | ||
| ...validConfig, | ||
| deciderModels: [ | ||
| { id: 'openai/gpt-5-mini', reasoningEffort: null }, | ||
| { id: 'test-exclusive/alibaba-only', reasoningEffort: null }, | ||
| ], | ||
| }) | ||
| ); | ||
|
|
||
| expect(response.status).toBe(400); | ||
| const body = (await response.json()) as { error: string }; | ||
| expect(body.error).toContain('test-exclusive/alibaba-only'); | ||
| expect(body.error).toContain('chat_completions'); | ||
| expect(body.error).not.toContain('openai/gpt-5-mini ('); | ||
| expect(mockUpdateBenchmarkConfig).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('rejects decider models reserved by a model experiment (any status)', async () => { | ||
| // Ownership is status-independent per .specs/model-experiments.md: a public | ||
| // id with a draft/active/paused/completed experiment is reserved for | ||
| // explicit user selection and must not enter kilo-auto candidate sets. | ||
| mockFindExperimentReservedModelIds.mockResolvedValue(['preview/experimental-model']); | ||
|
|
||
| const response = await PUT( | ||
| putRequest({ | ||
| ...validConfig, | ||
| deciderModels: [ | ||
| { id: 'openai/gpt-5-mini', reasoningEffort: null }, | ||
| { id: 'preview/experimental-model', reasoningEffort: null }, | ||
| ], | ||
| }) | ||
| ); | ||
|
|
||
| expect(response.status).toBe(400); | ||
| const body = (await response.json()) as { error: string }; | ||
| expect(body.error).toContain('preview/experimental-model'); | ||
| expect(body.error).toContain('model-experiment'); | ||
| expect(mockUpdateBenchmarkConfig).not.toHaveBeenCalled(); | ||
| // The check runs against the decider model ids. | ||
| expect(mockFindExperimentReservedModelIds).toHaveBeenCalledWith([ | ||
| 'openai/gpt-5-mini', | ||
| 'preview/experimental-model', | ||
| ]); | ||
| }); | ||
|
|
||
| it('rejects a schema-invalid config with 400', async () => { | ||
| const response = await PUT(putRequest({ classifierModels: 'oops' })); | ||
| expect(response.status).toBe(400); | ||
| await expect(response.json()).resolves.toEqual({ error: 'Invalid benchmark config' }); | ||
| expect(mockUpdateBenchmarkConfig).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
74 changes: 74 additions & 0 deletions
74
apps/web/src/app/admin/api/auto-routing/benchmark-config/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| import { BenchmarkConfigSchema } from '@kilocode/auto-routing-contracts'; | ||
| import type { NextRequest } from 'next/server'; | ||
| import { NextResponse } from 'next/server'; | ||
| import { | ||
| getBenchmarkConfig, | ||
| updateBenchmarkConfig, | ||
| } from '@/lib/ai-gateway/auto-routing-benchmark-admin-client'; | ||
| import { | ||
| gatewayChatApisForModel, | ||
| modelServesAllGatewayChatApis, | ||
| } from '@/lib/ai-gateway/model-api-kinds'; | ||
| import { findExperimentReservedModelIds } from '@/lib/ai-gateway/experiments/reserved-ids'; | ||
| import { getUserFromAuth } from '@/lib/user/server'; | ||
|
|
||
| export async function GET() { | ||
| const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); | ||
| if (authFailedResponse) return authFailedResponse; | ||
|
|
||
| const result = await getBenchmarkConfig(); | ||
| return NextResponse.json(result.body, { status: result.status }); | ||
| } | ||
|
|
||
| export async function PUT(request: NextRequest) { | ||
| const { authFailedResponse, user } = await getUserFromAuth({ adminOnly: true }); | ||
| if (authFailedResponse) return authFailedResponse; | ||
|
|
||
| let rawBody: unknown; | ||
| try { | ||
| rawBody = await request.json(); | ||
| } catch { | ||
| return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); | ||
| } | ||
|
|
||
| const parsed = BenchmarkConfigSchema.safeParse(rawBody); | ||
| if (!parsed.success) { | ||
| return NextResponse.json({ error: 'Invalid benchmark config' }, { status: 400 }); | ||
| } | ||
|
|
||
| // Model-experiment public ids are dedicated preview ids that users must | ||
| // explicitly select; per .specs/model-experiments.md they must never enter | ||
| // kilo-auto candidate sets, so they can't be saved as decider candidates | ||
| // (the routing table feeds kilo-auto/efficient automatic selection). Checked | ||
| // across all experiment statuses — ownership, not just routing membership. | ||
| const deciderModelIds = parsed.data.deciderModels.map(m => m.id); | ||
| const reservedExperimentIds = await findExperimentReservedModelIds(deciderModelIds); | ||
| if (reservedExperimentIds.length > 0) { | ||
| return NextResponse.json( | ||
| { | ||
| error: `Decider models must not be model-experiment public ids (reserved for explicit user selection): ${reservedExperimentIds.join(', ')}`, | ||
| }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| // Routing-table candidates carry no per-protocol metadata, so every decider | ||
| // model must be servable on ALL gateway chat API kinds by the provider the | ||
| // gateway would route it to. | ||
| const unsupported = parsed.data.deciderModels | ||
| .map(m => m.id) | ||
| .filter(id => !modelServesAllGatewayChatApis(id)) | ||
| .map(id => `${id} (supports: ${gatewayChatApisForModel(id).join(', ') || 'none'})`); | ||
| if (unsupported.length > 0) { | ||
| return NextResponse.json( | ||
| { | ||
| error: `Decider models must support all gateway chat APIs (chat_completions, responses, messages): ${unsupported.join('; ')}`, | ||
| }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const email = user?.google_user_email ?? ''; | ||
| const result = await updateBenchmarkConfig(parsed.data, email); | ||
| return NextResponse.json(result.body, { status: result.status }); | ||
| } | ||
11 changes: 11 additions & 0 deletions
11
apps/web/src/app/admin/api/auto-routing/benchmark-routing-table/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { NextResponse } from 'next/server'; | ||
| import { getBenchmarkRoutingTable } from '@/lib/ai-gateway/auto-routing-benchmark-admin-client'; | ||
| import { getUserFromAuth } from '@/lib/user/server'; | ||
|
|
||
| export async function GET() { | ||
| const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); | ||
| if (authFailedResponse) return authFailedResponse; | ||
|
|
||
| const result = await getBenchmarkRoutingTable(); | ||
| return NextResponse.json(result.body, { status: result.status }); | ||
| } |
36 changes: 36 additions & 0 deletions
36
apps/web/src/app/admin/api/auto-routing/benchmark-runs/route.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { StartBenchmarkRunRequestSchema } from '@kilocode/auto-routing-contracts'; | ||
| import type { NextRequest } from 'next/server'; | ||
| import { NextResponse } from 'next/server'; | ||
| import { | ||
| listBenchmarkRuns, | ||
| startBenchmarkRun, | ||
| } from '@/lib/ai-gateway/auto-routing-benchmark-admin-client'; | ||
| import { getUserFromAuth } from '@/lib/user/server'; | ||
|
|
||
| export async function GET() { | ||
| const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); | ||
| if (authFailedResponse) return authFailedResponse; | ||
|
|
||
| const result = await listBenchmarkRuns(); | ||
| return NextResponse.json(result.body, { status: result.status }); | ||
| } | ||
|
|
||
| export async function POST(request: NextRequest) { | ||
| const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); | ||
| if (authFailedResponse) return authFailedResponse; | ||
|
|
||
| let rawBody: unknown; | ||
| try { | ||
| rawBody = await request.json(); | ||
| } catch { | ||
| return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); | ||
| } | ||
|
|
||
| const parsed = StartBenchmarkRunRequestSchema.safeParse(rawBody); | ||
| if (!parsed.success) { | ||
| return NextResponse.json({ error: 'Invalid start benchmark run request' }, { status: 400 }); | ||
| } | ||
|
|
||
| const result = await startBenchmarkRun(parsed.data.kind, parsed.data.force); | ||
| return NextResponse.json(result.body, { status: result.status }); | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.