Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1c38084
feat(github): add GITHUB_MODEL env var, show resolved model, surface …
LoackyBit Apr 24, 2026
de4c1b2
feat: update GitHub provider to support GITHUB_MODEL environment vari…
LoackyBit Apr 25, 2026
db1f9f3
Merge pull request #1 from LoackyBit/feature/github-model-env-var
LoackyBit Apr 25, 2026
65c00e1
Merge branch 'Gitlawb:main' into main
LoackyBit Apr 25, 2026
c451548
feat: implement dynamic model fetching from GitHub Models API with pr…
LoackyBit Apr 25, 2026
144cc6e
Merge pull request #2 from LoackyBit/feature/github-model-env-var
LoackyBit Apr 25, 2026
b3e1a61
chore(github): remove obsolete claude pseudo-models for github
LoackyBit Apr 25, 2026
d1c1829
feat(github): integrate GitHub model fetching and caching mechanisms
LoackyBit Apr 26, 2026
ee6f94c
refactor: add support for GitHub Models Azure endpoint and implement …
LoackyBit Apr 27, 2026
20599b2
refactor: replace hardcoded Copilot models with dynamic fetching from…
LoackyBit Apr 27, 2026
3f8d867
chore(file): remove local prompt file
LoackyBit Apr 28, 2026
6d02223
feat(copilot): implement github copilot suggestions
LoackyBit Apr 28, 2026
9cc8902
chore(sync): sync PR with upstream
LoackyBit Apr 28, 2026
b717f8f
Merge branch 'main' into main
LoackyBit Apr 28, 2026
a42bf48
fix(models): fix github model list by retrieving code from commit 205…
LoackyBit Apr 29, 2026
29130a8
fix(suggestion): implement @Meetpatel006 suggestions
LoackyBit Apr 29, 2026
faaa5e6
Filter models by model_picker_enabled state
LoackyBit May 1, 2026
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
# Option 4 — GitHub Models:
# CLAUDE_CODE_USE_GITHUB=1
# GITHUB_TOKEN=ghp_your-token-here
# GITHUB_MODEL=auto (optional; default: auto for 10% discount on Copilot API)
#
Comment thread
LoackyBit marked this conversation as resolved.
# Option 5 — Ollama (local):
# CLAUDE_CODE_USE_OPENAI=1
Expand Down Expand Up @@ -188,6 +189,8 @@ ANTHROPIC_API_KEY=sk-ant-your-key-here
# -----------------------------------------------------------------------------
# CLAUDE_CODE_USE_GITHUB=1
# GITHUB_TOKEN=ghp_your-token-here
# GITHUB_MODEL=auto # optional; default: auto for 10% discount on Copilot API
# GITHUB_BASE_URL=https://models.github.ai/inference # optional; default: api.githubcopilot.com

Comment thread
LoackyBit marked this conversation as resolved.

# -----------------------------------------------------------------------------
Expand Down
6 changes: 5 additions & 1 deletion src/components/ProviderManager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,11 @@ function getGithubProviderModel(
processEnv: NodeJS.ProcessEnv = process.env,
): string {
if (isEnvTruthy(processEnv.CLAUDE_CODE_USE_GITHUB)) {
return processEnv.OPENAI_MODEL?.trim() || GITHUB_PROVIDER_DEFAULT_MODEL
return (
processEnv.GITHUB_MODEL?.trim() ||
processEnv.OPENAI_MODEL?.trim() ||
GITHUB_PROVIDER_DEFAULT_MODEL
)
}
return GITHUB_PROVIDER_DEFAULT_MODEL
}
Comment on lines 259 to 266
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

getGithubProviderModel() now always returns an empty string, so GitHub provider summaries never show the configured model (and the function becomes dead code). If the intent is to hide the model, consider inlining/removing this helper; otherwise, return the resolved model from GITHUB_MODEL/OPENAI_MODEL/settings so the summary remains informative.

Copilot uses AI. Check for mistakes.
Expand Down
15 changes: 11 additions & 4 deletions src/components/StartupScreen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,17 @@ export function detectProvider(): { name: string; model: string; baseUrl: string
}

if (useGithub) {
const model = process.env.OPENAI_MODEL || 'github:copilot'
const baseUrl =
process.env.OPENAI_BASE_URL || 'https://api.githubcopilot.com'
return { name: 'GitHub Copilot', model, baseUrl, isLocal: false }
const rawModel = process.env.GITHUB_MODEL?.trim() || process.env.OPENAI_MODEL?.trim() || 'github:copilot'
const resolvedRequest = resolveProviderRequest({
model: rawModel,
baseUrl: process.env.OPENAI_BASE_URL,
})
Comment thread
LoackyBit marked this conversation as resolved.
Comment on lines 116 to +121
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

In GitHub mode, the startup screen ignores modelOverride, settings.model, and the new GITHUB_MODEL env var, and instead reads OPENAI_MODEL directly. This can display the wrong model (and will always show the fallback when ProviderManager no longer sets OPENAI_MODEL). Prefer the same resolution order used elsewhere: modelOverride || settings.model || process.env.GITHUB_MODEL || process.env.OPENAI_MODEL || 'github:copilot', then pass that through resolveProviderRequest.

Copilot uses AI. Check for mistakes.
const baseUrl = resolvedRequest.baseUrl
let displayModel = resolvedRequest.resolvedModel
if (resolvedRequest.reasoning?.effort) {
displayModel = `${displayModel} (${resolvedRequest.reasoning.effort})`
}
return { name: 'GitHub Copilot', model: displayModel, baseUrl, isLocal: false }
}

if (useOpenAI) {
Expand Down
10 changes: 9 additions & 1 deletion src/cost-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ import {
getContextWindowForModel,
getModelMaxOutputTokens,
} from './utils/context.js'
import { isEnvTruthy } from './utils/envUtils.js'
import { isFastModeEnabled } from './utils/fastMode.js'
import { formatDuration, formatNumber } from './utils/format.js'
import type { FpsMetrics } from './utils/fpsTracker.js'
import { getCanonicalName } from './utils/model/model.js'
import { calculateUSDCost } from './utils/modelCost.js'
import { formatGithubRateLimitSummary } from './utils/githubRateLimit.js'
export {
getTotalCostUSD as getTotalCost,
getTotalDuration,
Expand Down Expand Up @@ -238,12 +240,18 @@ export function formatTotalCost(): string {

const modelUsageDisplay = formatModelUsage()

// Append GitHub rate-limit summary if available
const rateLimitLine = isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
? formatGithubRateLimitSummary()
: null
const rateLimitDisplay = rateLimitLine ? `\n${rateLimitLine}` : ''

return chalk.dim(
`Total cost: ${costDisplay}\n` +
`Total duration (API): ${formatDuration(getTotalAPIDuration())}
Total duration (wall): ${formatDuration(getTotalDuration())}
Total code changes: ${getTotalLinesAdded()} ${getTotalLinesAdded() === 1 ? 'line' : 'lines'} added, ${getTotalLinesRemoved()} ${getTotalLinesRemoved() === 1 ? 'line' : 'lines'} removed
${modelUsageDisplay}`,
${modelUsageDisplay}${rateLimitDisplay}`,
)
}

Expand Down
9 changes: 9 additions & 0 deletions src/services/api/openaiShim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
} from './toolArgumentNormalization.js'
import { logApiCallStart, logApiCallEnd } from '../../utils/requestLogging.js'
import { createStreamState, processStreamChunk, getStreamStats } from '../../utils/streamingOptimizer.js'
import { updateGithubRateLimit } from '../../utils/githubRateLimit.js'

type SecretValueSource = Partial<{
OPENAI_API_KEY: string
Expand Down Expand Up @@ -1343,6 +1344,11 @@ class OpenAIShimMessages {
const response = await self._doRequest(request, params, options)
httpResponse = response

// Capture GitHub rate-limit headers from every response
if (isGithubModelsMode()) {
updateGithubRateLimit(response.headers as unknown as Headers)
Comment on lines 1351 to +1359
Copy link

Copilot AI Apr 29, 2026

Choose a reason for hiding this comment

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

isGithub is declared but never used; the rate-limit capture block re-calls isGithubModelsMode() instead. This will trip unused-variable checks in stricter TS/lint configs and is easy to fix by either removing the variable or using it in the conditional.

Copilot uses AI. Check for mistakes.
}

if (params.stream) {
const isResponsesStream = response.url?.includes('/responses')
return new OpenAIShimStream(
Expand Down Expand Up @@ -2200,6 +2206,9 @@ export function createOpenAIShimClient(options: {
process.env.OPENAI_BASE_URL ??= GITHUB_COPILOT_BASE
process.env.OPENAI_API_KEY ??=
process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? ''
if (process.env.GITHUB_MODEL && !process.env.OPENAI_MODEL) {
process.env.OPENAI_MODEL = process.env.GITHUB_MODEL
}
}

// Map Bankr env vars to OpenAI-compatible ones when present
Expand Down
56 changes: 56 additions & 0 deletions src/services/api/providerConfig.github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,39 @@ import { afterEach, expect, test } from 'bun:test'

import {
DEFAULT_GITHUB_MODELS_API_MODEL,
normalizeGithubCopilotModel,
normalizeGithubModelsApiModel,
resolveProviderRequest,
} from './providerConfig.js'

const originalUseGithub = process.env.CLAUDE_CODE_USE_GITHUB
const originalGithubModel = process.env.GITHUB_MODEL
const originalOpenaiModel = process.env.OPENAI_MODEL

afterEach(() => {
if (originalUseGithub === undefined) {
delete process.env.CLAUDE_CODE_USE_GITHUB
} else {
process.env.CLAUDE_CODE_USE_GITHUB = originalUseGithub
}
if (originalGithubModel === undefined) {
delete process.env.GITHUB_MODEL
} else {
process.env.GITHUB_MODEL = originalGithubModel
}
if (originalOpenaiModel === undefined) {
delete process.env.OPENAI_MODEL
} else {
process.env.OPENAI_MODEL = originalOpenaiModel
}
})

test.each([
['copilot', DEFAULT_GITHUB_MODELS_API_MODEL],
['github:copilot', DEFAULT_GITHUB_MODELS_API_MODEL],
['', DEFAULT_GITHUB_MODELS_API_MODEL],
['auto', DEFAULT_GITHUB_MODELS_API_MODEL],
['github:auto', DEFAULT_GITHUB_MODELS_API_MODEL],
['github:gpt-4o', 'gpt-4o'],
['gpt-4o', 'gpt-4o'],
['github:copilot?reasoning=high', DEFAULT_GITHUB_MODELS_API_MODEL],
Expand All @@ -30,6 +45,20 @@ test.each([
expect(normalizeGithubModelsApiModel(input)).toBe(expected)
})

// normalizeGithubCopilotModel maps 'copilot'/'auto'/empty to 'auto' for the 10% discount
test.each([
['copilot', 'auto'],
['github:copilot', 'auto'],
['', 'auto'],
['auto', 'auto'],
['github:auto', 'auto'],
['gpt-4o', 'gpt-4o'],
['github:gpt-4.1', 'gpt-4.1'],
['openai/gpt-4o', 'gpt-4o'],
] as const)('normalizeGithubCopilotModel(%s) -> %s', (input, expected) => {
expect(normalizeGithubCopilotModel(input)).toBe(expected)
})

test('resolveProviderRequest applies GitHub normalization when CLAUDE_CODE_USE_GITHUB=1', () => {
process.env.CLAUDE_CODE_USE_GITHUB = '1'
const r = resolveProviderRequest({ model: 'github:gpt-4o' })
Expand All @@ -56,3 +85,30 @@ test('resolveProviderRequest leaves model unchanged without GitHub flag', () =>
const r = resolveProviderRequest({ model: 'github:gpt-4o' })
expect(r.resolvedModel).toBe('github:gpt-4o')
})

// GITHUB_MODEL env var tests
test('GITHUB_MODEL takes priority over OPENAI_MODEL', () => {
process.env.CLAUDE_CODE_USE_GITHUB = '1'
process.env.GITHUB_MODEL = 'gpt-4.1'
process.env.OPENAI_MODEL = 'gpt-4o'
const r = resolveProviderRequest()
expect(r.resolvedModel).toBe('gpt-4.1')
})

test('OPENAI_MODEL works as fallback when GITHUB_MODEL is unset', () => {
process.env.CLAUDE_CODE_USE_GITHUB = '1'
delete process.env.GITHUB_MODEL
process.env.OPENAI_MODEL = 'gpt-4.1'
const r = resolveProviderRequest()
expect(r.resolvedModel).toBe('gpt-4.1')
})

test('default model resolves to auto on Copilot API when no env vars set', () => {
process.env.CLAUDE_CODE_USE_GITHUB = '1'
delete process.env.GITHUB_MODEL
delete process.env.OPENAI_MODEL
const r = resolveProviderRequest()
// Default 'github:copilot' on Copilot endpoint resolves to 'auto'
expect(r.resolvedModel).toBe('auto')
})

25 changes: 15 additions & 10 deletions src/services/api/providerConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -429,8 +429,10 @@ export function normalizeGithubCopilotModel(requestedModel: string): string {
const noQuery = requestedModel.split('?', 1)[0] ?? requestedModel
const segment =
noQuery.includes(':') ? noQuery.split(':', 2)[1]!.trim() : noQuery.trim()
if (!segment || segment.toLowerCase() === 'copilot') {
return DEFAULT_GITHUB_MODELS_API_MODEL
// Map empty, 'copilot', and 'auto' to the literal 'auto' model ID.
// GitHub Copilot API offers a ~10% discount when using 'auto' model selection.
if (!segment || segment.toLowerCase() === 'copilot' || segment.toLowerCase() === 'auto') {
return 'auto'
}
// Strip provider prefix if present (e.g., "openai/gpt-4o" -> "gpt-4o")
const slashIndex = segment.indexOf('/')
Expand All @@ -448,8 +450,10 @@ export function normalizeGithubModelsApiModel(requestedModel: string): string {
const noQuery = requestedModel.split('?', 1)[0] ?? requestedModel
const segment =
noQuery.includes(':') ? noQuery.split(':', 2)[1]!.trim() : noQuery.trim()
// Only normalize the default alias for GitHub Models
if (!segment || segment.toLowerCase() === 'copilot') {
// Only normalize the default alias for GitHub Models.
// The Models API does not support a native 'auto' model ID,
// so we map it to the default model (gpt-4o) instead.
if (!segment || segment.toLowerCase() === 'copilot' || segment.toLowerCase() === 'auto') {
return DEFAULT_GITHUB_MODELS_API_MODEL
}
// Preserve provider prefix for GitHub Models (e.g., "openai/gpt-4.1" stays as-is)
Expand Down Expand Up @@ -488,12 +492,13 @@ export function resolveProviderRequest(options?: {
const isGeminiMode = isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)
const requestedModel =
options?.model?.trim() ||
(isMistralMode
? process.env.MISTRAL_MODEL?.trim()
: process.env.OPENAI_MODEL?.trim()) ||
(isGeminiMode
? process.env.GEMINI_MODEL?.trim()
: process.env.OPENAI_MODEL?.trim()) ||
(isGithubMode
? (process.env.GITHUB_MODEL?.trim() || process.env.OPENAI_MODEL?.trim())
: isMistralMode
? process.env.MISTRAL_MODEL?.trim()
: isGeminiMode
? process.env.GEMINI_MODEL?.trim()
: process.env.OPENAI_MODEL?.trim()) ||
options?.fallbackModel?.trim() ||
(isGithubMode ? 'github:copilot' : 'gpt-4o')
const descriptor = parseModelDescriptor(requestedModel)
Expand Down
102 changes: 102 additions & 0 deletions src/utils/githubRateLimit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { afterEach, expect, test } from 'bun:test'

import {
updateGithubRateLimit,
getGithubRateLimitState,
formatGithubRateLimitSummary,
resetGithubRateLimitState,
} from './githubRateLimit.js'

afterEach(() => {
resetGithubRateLimitState()
})

test('updateGithubRateLimit parses request and token limits', () => {
const headers = new Headers({
'x-ratelimit-limit-requests': '100',
'x-ratelimit-remaining-requests': '42',
'x-ratelimit-limit-tokens': '500000',
'x-ratelimit-remaining-tokens': '123456',
})

updateGithubRateLimit(headers)
const state = getGithubRateLimitState()

expect(state.limitRequests).toBe(100)
expect(state.remainingRequests).toBe(42)
expect(state.limitTokens).toBe(500000)
expect(state.remainingTokens).toBe(123456)
})

test('updateGithubRateLimit parses epoch-seconds reset timestamp', () => {
const epoch = Math.floor(Date.now() / 1000) + 3600
const headers = new Headers({
'x-ratelimit-reset-requests': String(epoch),
'x-ratelimit-remaining-requests': '10',
})

updateGithubRateLimit(headers)
const state = getGithubRateLimitState()

expect(state.resetRequestsAt).not.toBeNull()
expect(state.resetRequestsAt!.getTime()).toBe(epoch * 1000)
})

test('updateGithubRateLimit only updates present headers', () => {
const headers = new Headers({
'x-ratelimit-remaining-requests': '5',
})

updateGithubRateLimit(headers)
const state = getGithubRateLimitState()

expect(state.remainingRequests).toBe(5)
expect(state.limitRequests).toBeNull()
expect(state.limitTokens).toBeNull()
expect(state.remainingTokens).toBeNull()
})

test('formatGithubRateLimitSummary returns null when no data', () => {
expect(formatGithubRateLimitSummary()).toBeNull()
})

test('formatGithubRateLimitSummary formats requests and tokens', () => {
const headers = new Headers({
'x-ratelimit-limit-requests': '100',
'x-ratelimit-remaining-requests': '42',
'x-ratelimit-limit-tokens': '500000',
'x-ratelimit-remaining-tokens': '123456',
})

updateGithubRateLimit(headers)
const summary = formatGithubRateLimitSummary()

expect(summary).toContain('requests: 42/100 remaining')
expect(summary).toContain('tokens: 123456/500000 remaining')
})

test('formatGithubRateLimitSummary handles partial data', () => {
const headers = new Headers({
'x-ratelimit-remaining-requests': '7',
})

updateGithubRateLimit(headers)
const summary = formatGithubRateLimitSummary()

expect(summary).toContain('requests remaining: 7')
expect(summary).not.toContain('tokens')
})

test('resetGithubRateLimitState clears all data', () => {
const headers = new Headers({
'x-ratelimit-limit-requests': '100',
'x-ratelimit-remaining-requests': '42',
})

updateGithubRateLimit(headers)
resetGithubRateLimitState()

expect(getGithubRateLimitState().limitRequests).toBeNull()
expect(getGithubRateLimitState().remainingRequests).toBeNull()
expect(formatGithubRateLimitSummary()).toBeNull()
})
Loading