mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-26 21:36:31 +02:00
Single Go module, four top-level packages under packages/. Import
paths become github.com/patriceckhart/zot/packages/<name>; downstream
consumers can depend on individual packages without pulling the rest.
Layout:
packages/provider/ LLM clients + catalog
packages/provider/auth/ credential store + OAuth + login server
packages/core/ agent loop, sessions, cost
packages/tui/ terminal toolkit + chat view
packages/agent/ CLI wiring, system prompt
extensions/ extproto/ modes/ tools/ skills/ swarm/
sdk/ (was pkg/zotcore, package renamed zotcore -> sdk)
ext/ (was pkg/zotext, package renamed zotext -> ext)
internal/ and pkg/ removed. The internal/assets logo moved into
packages/provider/auth/assets.
Public Go SDK identifiers renamed:
pkg/zotcore (package zotcore) -> packages/agent/sdk (package sdk)
pkg/zotext (package zotext) -> packages/agent/ext (package ext)
This breaks Go-based extensions and embedders; the JSON wire protocol
for extensions and RPC is unchanged, so non-Go extensions, already-
built extension binaries, and zot rpc consumers are unaffected.
Docs, examples, and the built-in write-zot-extension skill updated
for the new paths and identifiers. Shadow-bug fixes in code samples
(ext := ext.New -> e := ext.New).
198 lines
6.8 KiB
Go
198 lines
6.8 KiB
Go
package provider
|
|
|
|
// GitHub Copilot client.
|
|
//
|
|
// Auth chain:
|
|
//
|
|
// 1. User stores a GitHub Personal Access Token (PAT) in
|
|
// COPILOT_GITHUB_TOKEN (or via zot's auth.json, when wired). The PAT
|
|
// is the long-lived credential; it never hits the inference endpoint.
|
|
// 2. We trade the PAT for a short-lived Copilot token by hitting
|
|
// `GET https://api.github.com/copilot_internal/v2/token` with the PAT
|
|
// as `Authorization: Bearer` plus the Copilot identity headers
|
|
// (Editor-Version, Editor-Plugin-Version, Copilot-Integration-Id,
|
|
// User-Agent). The response carries `{ "token": "...", "expires_at": <unix> }`.
|
|
// 3. The short-lived token's value embeds a `proxy-ep=<host>` field that
|
|
// tells us the real API host (individual users: api.individual.githubcopilot.com).
|
|
// 4. Inference requests go to `<host>/chat/completions` with the
|
|
// short-lived token in `Authorization: Bearer` plus extras:
|
|
// - X-Initiator: user|agent
|
|
// - Openai-Intent: conversation-edits
|
|
// - Copilot-Vision-Request: true (when images present; not wired
|
|
// here because the zot openai client currently sends images inline)
|
|
//
|
|
// Token caching: short-lived tokens last ~30min. We cache one per PAT in
|
|
// memory for the process lifetime and refresh on demand. No disk cache.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Copilot identity headers — these must match what the VS Code Copilot
|
|
// extension sends or the proxy rejects with 401.
|
|
var copilotIdentityHeaders = map[string]string{
|
|
"User-Agent": "GitHubCopilotChat/0.35.0",
|
|
"Editor-Version": "vscode/1.107.0",
|
|
"Editor-Plugin-Version": "copilot-chat/0.35.0",
|
|
"Copilot-Integration-Id": "vscode-chat",
|
|
}
|
|
|
|
type copilotToken struct {
|
|
value string
|
|
expiresAt time.Time
|
|
baseURL string
|
|
}
|
|
|
|
// copilotTokenCache is a process-wide cache of short-lived Copilot tokens
|
|
// keyed by the user's PAT. Concurrency-safe; safe to call from multiple
|
|
// goroutines (a single agent loop is sequential, but extension intercepts
|
|
// can run in parallel).
|
|
type copilotTokenCache struct {
|
|
mu sync.Mutex
|
|
tokens map[string]copilotToken
|
|
http *http.Client
|
|
}
|
|
|
|
var copilotCache = &copilotTokenCache{
|
|
tokens: map[string]copilotToken{},
|
|
http: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
|
|
// exchange swaps a PAT for a short-lived Copilot token. Caller is
|
|
// expected to hold no lock; we acquire/release internally.
|
|
func (c *copilotTokenCache) exchange(ctx context.Context, pat string) (copilotToken, error) {
|
|
url := "https://api.github.com/copilot_internal/v2/token"
|
|
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return copilotToken{}, err
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+pat)
|
|
req.Header.Set("Accept", "application/json")
|
|
for k, v := range copilotIdentityHeaders {
|
|
req.Header.Set(k, v)
|
|
}
|
|
resp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return copilotToken{}, fmt.Errorf("copilot token exchange: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != http.StatusOK {
|
|
return copilotToken{}, fmt.Errorf("copilot token exchange: http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
var out struct {
|
|
Token string `json:"token"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
}
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return copilotToken{}, fmt.Errorf("copilot token: parse: %w", err)
|
|
}
|
|
if out.Token == "" {
|
|
return copilotToken{}, fmt.Errorf("copilot token: empty")
|
|
}
|
|
base := parseCopilotProxyEndpoint(out.Token)
|
|
if base == "" {
|
|
base = "https://api.individual.githubcopilot.com"
|
|
}
|
|
exp := time.Unix(out.ExpiresAt, 0)
|
|
if exp.IsZero() || time.Until(exp) < 0 {
|
|
// Reference subtracts a 5min safety margin; we do the same so a
|
|
// long-running tool call doesn't blow past expiry mid-flight.
|
|
exp = time.Now().Add(25 * time.Minute)
|
|
} else {
|
|
exp = exp.Add(-5 * time.Minute)
|
|
}
|
|
return copilotToken{value: out.Token, expiresAt: exp, baseURL: base}, nil
|
|
}
|
|
|
|
// get returns a fresh-enough Copilot token, refreshing if needed.
|
|
func (c *copilotTokenCache) get(ctx context.Context, pat string) (copilotToken, error) {
|
|
c.mu.Lock()
|
|
tok, ok := c.tokens[pat]
|
|
c.mu.Unlock()
|
|
if ok && time.Now().Before(tok.expiresAt) {
|
|
return tok, nil
|
|
}
|
|
fresh, err := c.exchange(ctx, pat)
|
|
if err != nil {
|
|
return copilotToken{}, err
|
|
}
|
|
c.mu.Lock()
|
|
c.tokens[pat] = fresh
|
|
c.mu.Unlock()
|
|
return fresh, nil
|
|
}
|
|
|
|
// parseCopilotProxyEndpoint extracts `proxy-ep=<host>` from a Copilot
|
|
// short-lived token's value (it's not a JWT — it's an ad-hoc
|
|
// semicolon-separated key=value string) and converts proxy.* -> api.*.
|
|
// Returns "" if not found.
|
|
func parseCopilotProxyEndpoint(token string) string {
|
|
for _, part := range strings.Split(token, ";") {
|
|
if strings.HasPrefix(part, "proxy-ep=") {
|
|
host := strings.TrimPrefix(part, "proxy-ep=")
|
|
host = strings.TrimPrefix(host, "proxy.")
|
|
return "https://api." + host
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// copilotRefreshTransport wraps the default HTTP transport so every
|
|
// outgoing inference request gets the latest short-lived Copilot token
|
|
// in Authorization. Token exchange uses the PAT carried in the
|
|
// transport itself, not the request.
|
|
type copilotRefreshTransport struct {
|
|
inner http.RoundTripper
|
|
pat string
|
|
}
|
|
|
|
func (t *copilotRefreshTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
tok, err := copilotCache.get(req.Context(), t.pat)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
clone := req.Clone(req.Context())
|
|
clone.Header.Set("Authorization", "Bearer "+tok.value)
|
|
// Identity headers also required on inference requests.
|
|
for k, v := range copilotIdentityHeaders {
|
|
clone.Header.Set(k, v)
|
|
}
|
|
clone.Header.Set("X-Initiator", "agent")
|
|
clone.Header.Set("Openai-Intent", "conversation-edits")
|
|
// If the request URL host doesn't match the token's proxy-ep, rewrite
|
|
// it. The openaiClient pinned a static host at construction time, but
|
|
// the canonical host comes from the token.
|
|
if tok.baseURL != "" {
|
|
if u, err := url.Parse(tok.baseURL); err == nil && u.Host != clone.URL.Host {
|
|
clone.URL.Scheme = u.Scheme
|
|
clone.URL.Host = u.Host
|
|
}
|
|
}
|
|
return t.inner.RoundTrip(clone)
|
|
}
|
|
|
|
// NewGithubCopilotClient returns a Copilot-pinned OpenAI-compat client.
|
|
// The pat must be a GitHub Personal Access Token with Copilot access.
|
|
func NewGithubCopilotClient(pat string) Client {
|
|
httpClient := &http.Client{
|
|
Transport: &copilotRefreshTransport{inner: http.DefaultTransport, pat: pat},
|
|
Timeout: 0,
|
|
}
|
|
// Initial baseURL is a sane default; copilotRefreshTransport rewrites
|
|
// the host on every request based on the freshly-issued token.
|
|
return &openaiClient{
|
|
apiKey: pat, // unused at the wire level (transport overrides Auth) but kept for parity
|
|
baseURL: "https://api.individual.githubcopilot.com",
|
|
name: "github-copilot",
|
|
http: httpClient,
|
|
}
|
|
}
|