from fastcore.test import test_eq, test_failcopilot
Chat API, using a Copilot subscription instead of a vendor key.
The wire
Copilot serves OpenAI chat completions at {endpoint}/chat/completions. The token exchange returns the endpoint for the account’s plan. COPILOT_API is the fallback when the response omits it.
Copilot requires headers from a recognised editor. copilot-integration-id identifies the editor. The version headers match the VS Code chat extension. x-initiator distinguishes user turns from tool-loop turns. copilot-vision-request enables image input.
Editors also send x-request-id, but Rishi omits it. fastllm caches HTTP clients by constructor arguments. A changing request id would create a new client for every request.
copilot_hdrs
def copilot_hdrs(
auth:NoneType=None, *, integration_id:str='vscode-chat', vision:bool=False, initiator:NoneType=None,
intent:str='conversation-panel'
):Build the headers Copilot requires on every request, with auth as the complete Authorization value when given.
h = copilot_hdrs()
test_eq(h['copilot-integration-id'], 'vscode-chat')
assert 'Authorization' not in h # fastllm sets it from the api key
assert 'x-request-id' not in h # would defeat fastllm's client cache
test_eq(copilot_hdrs('Bearer t')['Authorization'], 'Bearer t')
test_eq(copilot_hdrs(vision=True)['copilot-vision-request'], 'true')
test_eq(copilot_hdrs(initiator='agent')['x-initiator'], 'agent')The GitHub token
Copilot requires a GitHub OAuth token issued to a recognised application. Personal access tokens do not work. Editor sign-ins store a usable token in a configuration file. copilot_login() stores the same token shape under ~/.config/rishi.
copilot_oauth() checks Copilot-specific environment variables first, then editor files. It checks GH_TOKEN and GITHUB_TOKEN last because they often contain personal access tokens. The file reader finds a nested oauth_token key.
copilot_oauth
def copilot_oauth(
token:NoneType=None
):A GitHub OAuth token: what you passed, else COPILOT_ENVS, else an editor sign-in, else GITHUB_ENVS.
read_oauth
def read_oauth(
p
):The GitHub OAuth token in config file p, or None if it has none or is not there.
find_oauth
def find_oauth(
o, depth:int=0
):The oauth_token in a decoded config file, however deep the editor nested it.
oauth_paths
def oauth_paths():Files a Copilot sign-in may have left a GitHub OAuth token in, best first.
# every shape an editor writes, and one that holds nothing
test_eq(find_oauth({'oauth_token': 'a'}), 'a')
test_eq(find_oauth({'github.com:Iv1.b507a08c87ecfe98': {'oauth_token': 'b', 'user': 'me'}}), 'b')
test_eq(find_oauth({'hosts': {'github.com': {'oauth_token': 'c'}}}), 'c')
test_eq(find_oauth({'user': 'me'}), None)
test_eq(read_oauth('/nonexistent/apps.json'), None)
test_eq(copilot_oauth('explicit'), 'explicit')
test_eq(oauth_paths()[0].name, 'copilot.json') # rishi's own file is read before any editor's
assert any('github-copilot' in str(p) for p in oauth_paths())# an editor sign-in beats a general-purpose GitHub variable, because that one holds a personal
# access token, which Copilot refuses. Only a Copilot-specific variable beats the sign-in.
import tempfile
with tempfile.TemporaryDirectory() as d:
f = Path(d)/'apps.json'
f.write_text(json.dumps({'github.com:Iv1.b507a08c87ecfe98': {'oauth_token': 'gho_editor'}}))
saved = {k: os.environ.pop(k, None) for k in OAUTH_ENVS}
orig_paths, oauth_paths = oauth_paths, lambda: [f]
try:
os.environ['GITHUB_TOKEN'] = 'ghp_pat'
test_eq(copilot_oauth(), 'gho_editor') # the sign-in, not the PAT
os.environ['GH_COPILOT_TOKEN'] = 'gho_said_so'
test_eq(copilot_oauth(), 'gho_said_so') # names Copilot outright, so it wins
del os.environ['GH_COPILOT_TOKEN']
oauth_paths = lambda: []
test_eq(copilot_oauth(), 'ghp_pat') # with no sign-in to hide, it is all there is
del os.environ['GITHUB_TOKEN']
test_fail(copilot_oauth, contains='No GitHub OAuth token')
finally:
oauth_paths = orig_paths
for k, v in saved.items():
os.environ.pop(k, None)
if v is not None: os.environ[k] = vThe Copilot token
The GitHub OAuth token is exchanged for a Copilot token that lasts about 30 minutes. CopilotAuth holds that token and renews it before it expires, and chats that share one CopilotAuth share the exchange. api_key= accepts a Copilot token you already have, but rishi cannot renew it without the GitHub OAuth token.
The same token lets rishi ask Copilot which models the account can reach. copilot_catalog returns Copilot’s own description of each, copilot_models lists the ids (chat models only, unless you pass kind=None), and copilot_ctx reads a model’s prompt window from its catalogue entry.
copilot_ctx
def copilot_ctx(
entry
):The prompt window Copilot reports for one catalogue entry, or None where it reports none.
copilot_models
def copilot_models(
auth:NoneType=None, timeout:int=30, kind:str='chat'
):List the model ids this account can reach, as Copilot reports them, keeping embedding and completion models too when kind=None.
copilot_catalog
def copilot_catalog(
auth:NoneType=None, timeout:int=30
):Every model this account can reach, as {id: entry} the way Copilot describes them.
CopilotAuth
def CopilotAuth(
oauth:NoneType=None, *, key:NoneType=None, base_url:NoneType=None, skew:int=300
):Hold a Copilot token and mint a new one before it expires, so chats sharing one instance stay signed in.
copilot_exchange
def copilot_exchange(
oauth:NoneType=None, *, url:str='https://api.github.com/copilot_internal/v2/token', timeout:int=30
):Trade a GitHub OAuth token for a CopilotToken.
CopilotToken
def CopilotToken(
key:str, base_url:str='https://api.githubcopilot.com', expires_at:float=0.0
)->None:One minted Copilot token: the bearer, the endpoint it is good for, and when it dies.
# the catalogue carries more than ids: what each model is for, and how big its window is. An
# embedding model answering a chat request is a 400 the caller cannot read, so `kind` filters.
_payload = {'data': [
{'id': 'gpt-5.5', 'capabilities': {'type': 'chat', 'limits': {'max_prompt_tokens': 128000}}},
{'id': 'claude-opus-4.7', 'capabilities': {'type': 'chat', 'limits': {'max_context_window_tokens': 264000}}},
{'id': 'gpt-41-copilot', 'capabilities': {'type': 'completion'}},
{'id': 'text-embedding-3-small', 'capabilities': {'type': 'embeddings'}},
{'no_id': 'skipped'}]}
class _ModelsResp:
status_code = 200
def raise_for_status(self): pass
def json(self): return _payload
orig, httpx.get = httpx.get, lambda *a, **kw: _ModelsResp()
try:
auth = CopilotAuth(key='tid=abc') # a key of its own, so nothing is exchanged
cat = copilot_catalog(auth)
test_eq(list(cat), ['gpt-5.5', 'claude-opus-4.7', 'gpt-41-copilot', 'text-embedding-3-small'])
test_eq(copilot_models(auth), ['gpt-5.5', 'claude-opus-4.7']) # chat only, by default
test_eq(len(copilot_models(auth, kind=None)), 4) # everything it named
test_eq(copilot_models(auth, kind='embeddings'), ['text-embedding-3-small'])
test_eq(copilot_ctx(cat['gpt-5.5']), 128000) # max_prompt_tokens
test_eq(copilot_ctx(cat['claude-opus-4.7']), 264000) # else the whole window
test_eq(copilot_ctx(cat['gpt-41-copilot']), None) # it reports no limits
test_eq(copilot_ctx(None), None)
finally: httpx.get = orignow = time.time()
test_eq(CopilotToken('k').stale(), False) # untimed: never stale
test_eq(CopilotToken('k', expires_at=now+3600).stale(), False)
test_eq(CopilotToken('k', expires_at=now+60).stale(), True) # inside the 5 minute skew
test_eq(CopilotToken('k', expires_at=now-1).stale(), True)
# a key you brought yourself is used as-is, and never exchanged
test_eq(CopilotAuth(key='tid=abc').token().key, 'tid=abc')
test_eq(CopilotAuth(key='k').token().base_url, COPILOT_API)
test_eq(CopilotAuth(key='k', base_url='https://api.business.githubcopilot.com').token().base_url,
'https://api.business.githubcopilot.com')
# minting runs once, then not again until the token is nearly out of time
mints = []
def _fake_exchange(oauth=None, **kw):
mints.append(oauth)
return CopilotToken('tid=minted', 'https://api.individual.githubcopilot.com', time.time()+1800)
orig, copilot_exchange = copilot_exchange, _fake_exchange
try:
a = CopilotAuth('gho_x')
test_eq(a.token().key, 'tid=minted')
test_eq(a.token().base_url, 'https://api.individual.githubcopilot.com')
test_eq(len(mints), 1) # the second call reused it
a.tok = replace(a.tok, expires_at=time.time()+10)
a.token()
test_eq(len(mints), 2) # nearly expired, so re-minted
finally: copilot_exchange = orig# a personal access token gets a 404 out of the exchange, and that is a refusal like any other:
# what the caller needs told is that Copilot takes no PAT, not that some URL was not found
class _FakeResp:
def __init__(self, code): self.status_code = code
def raise_for_status(self): raise AssertionError('a refusal must not reach raise_for_status')
def _refused(code):
orig, httpx.get = httpx.get, lambda *a, **kw: _FakeResp(code)
try: copilot_exchange('gho_x')
except PermissionError as e: return str(e)
finally: httpx.get = orig
assert False, f'{code} raised no PermissionError'
for code in (401, 403, 404): assert str(code) in _refused(code)Signing in
copilot_login() runs GitHub’s device flow with the editor client id. It prints a code and waits for browser authorization. It saves the token where copilot_oauth() looks first. An existing editor sign-in makes this step unnecessary.
copilot_login
def copilot_login(
client_id:str='Iv1.b507a08c87ecfe98', *, scope:str='read:user', save:bool=True,
printer:builtin_function_or_method=print, timeout:int=30
):Run GitHub’s device flow: print a code, wait while you enter it, return the OAuth token.
save_oauth
def save_oauth(
token, path:NoneType=None
):Write a GitHub OAuth token where copilot_oauth looks first, readable only by you.
CopilotChat
CopilotChat is a RemoteChat whose _kw adds Copilot’s endpoint, a live token and the required headers to each request. Tools, streaming, approval and the tool loop are unchanged. Use Chat('copilot/gpt-4.1') or runtime='copilot'. With no model named, the chat uses copilot_default, gpt-4.1.
Only the copilot/ prefix routes here automatically. Bare gpt-... and claude-... ids still go to the hosted APIs through remote.
CopilotChat
def CopilotChat(
model:NoneType=None, *, oauth_token:NoneType=None, # a GitHub OAuth token, instead of the one on disk
auth:NoneType=None, # a `CopilotAuth` to share, instead of building one
integration_id:str='vscode-chat', hdrs:NoneType=None, # extra headers, merged over Copilot's own
**kw
):A RemoteChat pointed at GitHub Copilot, adding Copilot’s headers and its short-lived token to each request.
# nothing here reaches the network: the chat is handed a token, and what is asserted is the request
c = CopilotChat('copilot/gpt-4.1', api_key='tid=abc', sp='Be terse.', messages=['hello'])
test_eq(c.runtime, 'copilot')
test_eq(c.model_id, 'gpt-4.1') # the `copilot/` prefix is stripped, the id is not
test_eq(c.local, False)
kw = c._kw()
test_eq(kw['api_key'], 'tid=abc')
test_eq(kw['base_url'], COPILOT_API)
test_eq(kw['api_name'], 'openai_chat') # fastllm has no Copilot vendor, so the api is named outright
test_eq(kw['vendor_name'], None)
test_eq(kw['system'], 'Be terse.')
test_eq(kw['xtra_hdrs']['copilot-integration-id'], 'vscode-chat')
test_eq(kw['xtra_hdrs']['x-initiator'], 'user') # only a user message so far
assert 'copilot-vision-request' not in kw['xtra_hdrs']
assert 'Authorization' not in kw['xtra_hdrs'] # fastllm sets it, and once
# a tool loop is an agent to Copilot, and an image needs the vision header
c.hist += [{'role': 'assistant', 'content': '', 'tool_calls': [ToolCall('ls', {})]},
{'role': 'tool', 'tool_call_id': 'x', 'name': 'ls', 'content': 'a.py'}]
test_eq(c._kw()['xtra_hdrs']['x-initiator'], 'agent')
c.hist.append({'role': 'user', 'content': [{'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,aa'}}]})
test_eq(c._kw()['xtra_hdrs']['copilot-vision-request'], 'true')
# your headers win over rishi's, so an integration id or an editor version can be swapped out
test_eq(CopilotChat(api_key='k', integration_id='copilot-cli', hdrs={'editor-version': 'vim/9.1'}
)._kw()['xtra_hdrs']['editor-version'], 'vim/9.1')
test_eq(CopilotChat(api_key='k', integration_id='copilot-cli')._kw()['xtra_hdrs']['copilot-integration-id'],
'copilot-cli')
# routing, without building anything
test_eq(resolve_runtime('copilot/claude-sonnet-4.5'), ('copilot', 'claude-sonnet-4.5'))
test_eq(resolve_runtime('claude-sonnet-4.5'), ('remote', 'claude-sonnet-4.5')) # bare ids stay remote
test_eq(get_runtime('copilot').__name__, 'CopilotChat') # the exported class, not this cell's# and what fastllm builds from those arguments is the request Copilot actually wants
from fastllm.acomplete import api_registry, mk_client
kw = CopilotChat('copilot/gpt-4.1', api_key='tid=abc', sp='Be terse.', messages=['hi'])._kw()
cli, api_name, vendor = mk_client(model='gpt-4.1', vendor_name=kw['vendor_name'], api_name=kw['api_name'],
api_key=kw['api_key'], base_url=kw['base_url'], xtra_hdrs=kw['xtra_hdrs'])
test_eq(cli.base_url + api_registry[api_name].endpoint, 'https://api.githubcopilot.com/chat/completions')
hdrs = dict(cli.transport.base_headers)
test_eq([k for k in hdrs if k.lower() == 'authorization'], ['Authorization']) # one, not two
test_eq(hdrs['Authorization'], 'Bearer tid=abc')
test_eq(hdrs['copilot-integration-id'], 'vscode-chat')
test_eq(hdrs['editor-version'], EDITOR_VERSION)Against the real endpoint
These examples need a Copilot subscription, so they are marked eval: false and the test run skips them.
from rishi.copilot import copilot_models, copilot_login
# copilot_login() # only on a machine with no editor signed in
print(copilot_models()[:10])chat = Chat('copilot/gpt-4.1', sp='You are concise.')
r = chat('Reply with exactly: pong')
assert 'pong' in resp_text(r).lower()
print(chat.use)display_stream(Chat('copilot/claude-sonnet-4.5')('Write two sentences about the monsoon.', stream=True))def word_count(text: str) -> int:
"Count the words in `text`."
return len(text.split())
chat = Chat('copilot/gpt-4.1', tools=[word_count]) # no approve=, so every call runs
print(resp_text(chat('How many words are in "the quick brown fox"? Use the tool.')))