Skip to content

Commit ca3a1cf

Browse files
author
privapps
committed
Merge: Skip TestHeaderForwardingProxy in CI environments
2 parents 377eea3 + a12a39c commit ca3a1cf

3 files changed

Lines changed: 102 additions & 37 deletions

File tree

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ This project provides a reverse proxy for GitHub Copilot, exposing OpenAI-compat
55
## Features
66

77
- **OAuth Device Flow Authentication**: Secure authentication with GitHub Copilot using the same flow as OpenCode
8+
- **Vision Support**: Full support for image/vision requests with base64-encoded images in OpenAI-compatible format
89
- **Advanced Token Management**:
910
- Proactive token refresh (refreshes at 20% of token lifetime, minimum 5 minutes)
1011
- Exponential backoff retry logic for failed token refreshes
@@ -452,6 +453,40 @@ curl -X POST http://localhost:8081/v1/chat/completions \
452453
}'
453454
```
454455

456+
### Vision/Image Requests
457+
458+
The proxy fully supports vision capabilities with base64-encoded images in OpenAI-compatible format:
459+
460+
```bash
461+
# Example with base64-encoded image
462+
curl -X POST http://localhost:8081/v1/chat/completions \
463+
-H "Content-Type: application/json" \
464+
-d '{
465+
"model": "gpt-4o",
466+
"messages": [{
467+
"role": "user",
468+
"content": [
469+
{"type": "text", "text": "What is in this image?"},
470+
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,/9j/4AAQ..."}}
471+
]
472+
}],
473+
"max_tokens": 300
474+
}'
475+
```
476+
477+
**Vision Features:**
478+
- Supports multi-part message content (text + images)
479+
- Accepts base64-encoded images as data URIs
480+
- Supports `detail` parameter (`auto`, `low`, `high`)
481+
- Compatible with vision-capable models (gpt-4o, gpt-4-vision, etc.)
482+
- Backward compatible with text-only requests
483+
484+
**Example Script:**
485+
The repository includes `test_vision_proxy.sh` that demonstrates vision capabilities:
486+
```bash
487+
./test_vision_proxy.sh dog.jpeg "Describe this image in detail"
488+
```
489+
455490
### Using with OpenAI Python Client
456491
```python
457492
import openai

internal/proxy.go

Lines changed: 46 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import (
1515
"time"
1616
)
1717

18-
const (
19-
copilotAPIBase = "https://api.githubcopilot.com"
20-
chatCompletionsPath = "/chat/completions"
18+
var copilotAPIBase = "https://api.githubcopilot.com"
19+
var completionsPath = "/completions"
20+
var chatCompletionsPath = "/chat/completions"
2121

22-
// Retry configuration for chat completions
22+
const (
2323
maxChatRetries = 3
2424
baseChatRetryDelay = 1 // seconds
2525

@@ -323,41 +323,41 @@ func (s *ProxyService) processProxyRequest(ctx context.Context, w http.ResponseW
323323
return fmt.Errorf("bad request: empty request body")
324324
}
325325

326+
var input struct {
327+
Model string `json:"model"`
328+
}
329+
if jsonErr := json.Unmarshal(body, &input); jsonErr != nil {
330+
return fmt.Errorf("bad request: invalid JSON: %w", jsonErr)
331+
}
326332

327-
var input struct {
328-
Model string `json:"model"`
329-
}
330-
if jsonErr := json.Unmarshal(body, &input); jsonErr != nil {
331-
return fmt.Errorf("bad request: invalid JSON: %w", jsonErr)
332-
}
333-
334-
// AllowedModels validation
335-
if len(s.config.AllowedModels) > 0 {
336-
allowed := false
337-
for _, m := range s.config.AllowedModels {
338-
if input.Model == m {
339-
allowed = true
340-
break
341-
}
342-
}
343-
if !allowed {
344-
return fmt.Errorf("bad request: model '%s' is not allowed by allowed_models in config", input.Model)
345-
}
346-
}
347-
348-
// Ensure we have a valid token before making the request
349-
if tokenErr := s.authService.EnsureValidToken(s.config); tokenErr != nil {
350-
Error("Failed to ensure valid token", "error", tokenErr)
351-
return NewAuthError("token validation failed", tokenErr)
352-
}
333+
// AllowedModels validation
334+
if len(s.config.AllowedModels) > 0 {
335+
allowed := false
336+
for _, m := range s.config.AllowedModels {
337+
if input.Model == m {
338+
allowed = true
339+
break
340+
}
341+
}
342+
if !allowed {
343+
return fmt.Errorf("bad request: model '%s' is not allowed by allowed_models in config", input.Model)
344+
}
345+
}
346+
347+
// Ensure we have a valid token before making the request
348+
if tokenErr := s.authService.EnsureValidToken(s.config); tokenErr != nil {
349+
Error("Failed to ensure valid token", "error", tokenErr)
350+
return NewAuthError("token validation failed", tokenErr)
351+
}
353352

354353
// Create new request to GitHub Copilot
355354
var targetURL string
355+
base := copilotAPIBase
356356
switch r.URL.Path {
357357
case "/v1/completions":
358-
targetURL = copilotAPIBase + "/completions"
358+
targetURL = base + completionsPath
359359
case "/v1/chat/completions":
360-
targetURL = copilotAPIBase + chatCompletionsPath
360+
targetURL = base + chatCompletionsPath
361361
default:
362362
return fmt.Errorf("unsupported proxy path: %s", r.URL.Path)
363363
}
@@ -370,9 +370,21 @@ func (s *ProxyService) processProxyRequest(ctx context.Context, w http.ResponseW
370370
}
371371

372372
// Set headers
373+
// Forward content/negotiation headers from client if present; use defaults if missing
374+
headersToProxy := []string{"Content-Type", "Accept", "Accept-Encoding", "TE"}
375+
defaults := map[string]string{
376+
"Content-Type": "application/json",
377+
"Accept": "application/json",
378+
}
379+
for _, h := range headersToProxy {
380+
if v := r.Header.Get(h); v != "" {
381+
req.Header.Set(h, v)
382+
} else if def, ok := defaults[h]; ok {
383+
req.Header.Set(h, def)
384+
}
385+
}
386+
373387
req.Header.Set("Authorization", "Bearer "+s.config.CopilotToken)
374-
req.Header.Set("Content-Type", "application/json")
375-
req.Header.Set("Accept", "application/json")
376388
req.Header.Set("User-Agent", s.config.Headers.UserAgent)
377389
req.Header.Set("Editor-Version", s.config.Headers.EditorVersion)
378390
req.Header.Set("Editor-Plugin-Version", s.config.Headers.EditorPluginVersion)

pkg/transform/transform.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// Package transform provides OpenAI-compatible request/response structures for github-copilot-svcs.
22
package transform
33

4+
import "encoding/json"
5+
46
// ChatCompletionRequest ...
57
type ChatCompletionRequest struct {
68
Model string `json:"model"`
@@ -10,10 +12,26 @@ type ChatCompletionRequest struct {
1012
Stream bool `json:"stream,omitempty"`
1113
}
1214

13-
// ChatCompletionMessage ...
15+
// ChatCompletionMessage supports both text-only content (string) and multi-part content (array)
16+
// for vision/image requests. Content can be either:
17+
// - A string for simple text messages
18+
// - An array of ContentPart objects for messages with images
1419
type ChatCompletionMessage struct {
15-
Role string `json:"role"`
16-
Content string `json:"content"`
20+
Role string `json:"role"`
21+
Content json.RawMessage `json:"content"` // Can be string or []ContentPart
22+
}
23+
24+
// ContentPart represents a part of a multi-part message (text or image)
25+
type ContentPart struct {
26+
Type string `json:"type"` // "text" or "image_url"
27+
Text string `json:"text,omitempty"` // For type="text"
28+
ImageURL *ImageURL `json:"image_url,omitempty"` // For type="image_url"
29+
}
30+
31+
// ImageURL contains the image URL (can be http(s):// or data: URI with base64)
32+
type ImageURL struct {
33+
URL string `json:"url"`
34+
Detail string `json:"detail,omitempty"` // "auto", "low", or "high"
1735
}
1836

1937
// ChatCompletionResponse ...

0 commit comments

Comments
 (0)