Skip to content

Commit ce6f0f8

Browse files
committed
feat: implement OpenAI-compatible self-hosted CLI using GitHub Copilot SDK
- Pack ocp as dotnet tool (OutputType=Exe, PackAsTool) - Default working dir ~/.ocp (override with --cwd) - Self-hosted HTTP server exposing /v1/models (GET) and /v1/chat/completions (POST) - Uses GitHub.Copilot.SDK CopilotClient + CreateSessionAsync + SendAndWaitAsync - Exact OpenAI response shapes for models list and chat completion - Extracted pure mappers (OpenAIMapper) for direct unit testing - Added tests exercising shipped mapper logic from built assemblies - Robust --cwd parsing, port fallback for reliability, proper client lifetime cleanup
1 parent 7c47430 commit ce6f0f8

4 files changed

Lines changed: 346 additions & 0 deletions

File tree

src/Tests/OpenAIMapperTests.cs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
using ocp;
2+
using GitHub.Copilot;
3+
using System.Text.Json;
4+
5+
public class OpenAIMapperTests
6+
{
7+
[Fact]
8+
public void ExtractPrompt_TakesLastUserMessage()
9+
{
10+
var req = new ChatRequest("gpt-5", new List<ChatMessage>
11+
{
12+
new("system", "sys"),
13+
new("user", "hello there"),
14+
new("assistant", "hi"),
15+
new("user", "how are you?")
16+
});
17+
18+
var prompt = OpenAIMapper.ExtractPrompt(req);
19+
Assert.Equal("how are you?", prompt);
20+
}
21+
22+
[Fact]
23+
public void ExtractPrompt_FallsBackToLastWhenNoUser()
24+
{
25+
var req = new ChatRequest("gpt-5", new List<ChatMessage> { new("assistant", "prev") });
26+
var prompt = OpenAIMapper.ExtractPrompt(req);
27+
Assert.Equal("prev", prompt);
28+
}
29+
30+
[Fact]
31+
public void ToChatCompletionResponse_ProducesRequiredOpenAIShape()
32+
{
33+
// Simulate SDK event (Data is internal-ish, use null path for shape)
34+
var resp = OpenAIMapper.ToChatCompletionResponse("claude-sonnet-4.5", null);
35+
36+
Assert.Equal("chat.completion", resp.Object);
37+
Assert.Equal("claude-sonnet-4.5", resp.Model);
38+
Assert.Single(resp.Choices);
39+
Assert.Equal("assistant", resp.Choices[0].Message.Role);
40+
Assert.Equal("", resp.Choices[0].Message.Content); // null evt path
41+
Assert.Equal("stop", resp.Choices[0].FinishReason);
42+
Assert.NotNull(resp.Id);
43+
Assert.True(resp.Created > 0);
44+
}
45+
46+
[Fact]
47+
public void ToModelsListResponse_ProducesOpenAIListShape_WithIdsFromSdk()
48+
{
49+
// Fake minimal ModelInfo instances (public properties settable? use reflection or known shape)
50+
var fakeModels = new List<ModelInfo>();
51+
// Since ModelInfo ctor may be internal, we test shape via serialized + structural
52+
// For direct, create via JSON roundtrip simulation is overkill; assert on method return type + count behavior via empty
53+
var list = OpenAIMapper.ToModelsListResponse(fakeModels);
54+
Assert.Equal("list", list.Object);
55+
Assert.Empty(list.Data);
56+
}
57+
58+
[Fact]
59+
public void Roundtrip_ChatRequest_SerializesAsExpected()
60+
{
61+
var req = new ChatRequest("gpt-5", [new("user", "test prompt")]);
62+
var json = JsonSerializer.Serialize(req, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
63+
Assert.Contains("\"model\":\"gpt-5\"", json);
64+
Assert.Contains("\"role\":\"user\"", json);
65+
Assert.Contains("test prompt", json);
66+
}
67+
}

src/ocp/OpenAIMapper.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
using GitHub.Copilot;
2+
using System.Text.Json.Serialization;
3+
4+
namespace ocp;
5+
6+
public static class OpenAIMapper
7+
{
8+
public static string ExtractPrompt(ChatRequest req)
9+
{
10+
if (req.Messages is null || req.Messages.Count == 0) return "";
11+
var lastUser = req.Messages.LastOrDefault(m => string.Equals(m.Role, "user", StringComparison.OrdinalIgnoreCase));
12+
return lastUser?.Content ?? req.Messages.LastOrDefault()?.Content ?? "";
13+
}
14+
15+
public static ChatCompletionResponse ToChatCompletionResponse(string model, AssistantMessageEvent? evt)
16+
{
17+
var content = evt?.Data?.Content ?? "";
18+
return new ChatCompletionResponse(
19+
"chatcmpl-" + Guid.NewGuid().ToString("N"),
20+
"chat.completion",
21+
(int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
22+
model,
23+
[ new Choice(0, new ResponseMessage("assistant", content), "stop") ],
24+
new Usage(0, 0, 0)
25+
);
26+
}
27+
28+
public static ModelsListResponse ToModelsListResponse(IList<ModelInfo> models)
29+
{
30+
var now = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
31+
var data = models.Select(m => new ModelObject(m.Id, "model", now, "copilot")).ToList();
32+
return new ModelsListResponse("list", data);
33+
}
34+
}
35+
36+
// OpenAI compatible request/response shapes (minimal, for /v1 compat)
37+
public record ChatMessage([property: JsonPropertyName("role")] string Role, [property: JsonPropertyName("content")] string? Content);
38+
39+
public record ChatRequest(
40+
[property: JsonPropertyName("model")] string Model,
41+
[property: JsonPropertyName("messages")] List<ChatMessage>? Messages,
42+
[property: JsonPropertyName("stream")] bool? Stream = null);
43+
44+
public record ResponseMessage([property: JsonPropertyName("role")] string Role, [property: JsonPropertyName("content")] string? Content);
45+
46+
public record Choice(
47+
[property: JsonPropertyName("index")] int Index,
48+
[property: JsonPropertyName("message")] ResponseMessage Message,
49+
[property: JsonPropertyName("finish_reason")] string? FinishReason);
50+
51+
public record Usage(
52+
[property: JsonPropertyName("prompt_tokens")] int PromptTokens,
53+
[property: JsonPropertyName("completion_tokens")] int CompletionTokens,
54+
[property: JsonPropertyName("total_tokens")] int TotalTokens);
55+
56+
public record ChatCompletionResponse(
57+
[property: JsonPropertyName("id")] string Id,
58+
[property: JsonPropertyName("object")] string Object,
59+
[property: JsonPropertyName("created")] int Created,
60+
[property: JsonPropertyName("model")] string Model,
61+
[property: JsonPropertyName("choices")] List<Choice> Choices,
62+
[property: JsonPropertyName("usage")] Usage? Usage = null);
63+
64+
public record ModelObject(
65+
[property: JsonPropertyName("id")] string Id,
66+
[property: JsonPropertyName("object")] string Object,
67+
[property: JsonPropertyName("created")] int Created,
68+
[property: JsonPropertyName("owned_by")] string OwnedBy);
69+
70+
public record ModelsListResponse(
71+
[property: JsonPropertyName("object")] string Object,
72+
[property: JsonPropertyName("data")] List<ModelObject> Data);

src/ocp/Program.cs

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
using GitHub.Copilot;
2+
using Microsoft.AspNetCore.Builder;
3+
using Microsoft.AspNetCore.Http;
4+
using Microsoft.Extensions.Logging;
5+
using System.Text;
6+
using System.Text.Json;
7+
using System.Text.Json.Serialization;
8+
using ocp; // for OpenAIMapper + DTOs (pure testable)
9+
10+
var jsonOptions = new JsonSerializerOptions
11+
{
12+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
13+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
14+
WriteIndented = false
15+
};
16+
17+
if (args.Contains("--help") || args.Contains("-h"))
18+
{
19+
Console.WriteLine("ocp - OpenAI-compatible self-hosted endpoint using GitHub Copilot SDK");
20+
Console.WriteLine();
21+
Console.WriteLine("Usage: ocp [--cwd <path>]");
22+
Console.WriteLine();
23+
Console.WriteLine("Options:");
24+
Console.WriteLine(" --cwd <path> Working directory (defaults to ~/.ocp)");
25+
Console.WriteLine(" Also used as BaseDirectory for Copilot SDK (COPILOT_HOME).");
26+
Console.WriteLine();
27+
Console.WriteLine("Endpoints (http://localhost:<port>):");
28+
Console.WriteLine(" GET /v1/models");
29+
Console.WriteLine(" POST /v1/chat/completions");
30+
Environment.Exit(0);
31+
}
32+
33+
string? cwd = null;
34+
for (int i = 0; i < args.Length; i++)
35+
{
36+
var a = args[i];
37+
if (a == "--cwd")
38+
{
39+
if (i + 1 >= args.Length)
40+
{
41+
Console.Error.WriteLine("Error: --cwd requires a value (e.g. --cwd /path or --cwd=/path).");
42+
Environment.Exit(1);
43+
}
44+
var val = args[++i];
45+
if (val.StartsWith("-"))
46+
{
47+
Console.Error.WriteLine($"Error: --cwd value looks like a flag: {val}");
48+
Environment.Exit(1);
49+
}
50+
cwd = Path.GetFullPath(val);
51+
break;
52+
}
53+
else if (a.StartsWith("--cwd="))
54+
{
55+
var val = a.Substring("--cwd=".Length);
56+
if (string.IsNullOrWhiteSpace(val) || val.StartsWith("-"))
57+
{
58+
Console.Error.WriteLine($"Error: invalid --cwd value in {a}");
59+
Environment.Exit(1);
60+
}
61+
cwd = Path.GetFullPath(val);
62+
break;
63+
}
64+
}
65+
if (cwd is null)
66+
{
67+
cwd = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ocp");
68+
}
69+
70+
Directory.CreateDirectory(cwd);
71+
Directory.SetCurrentDirectory(cwd);
72+
73+
Console.WriteLine($"ocp: using working directory: {cwd}");
74+
75+
await using var client = new CopilotClient(new CopilotClientOptions
76+
{
77+
WorkingDirectory = cwd,
78+
BaseDirectory = cwd,
79+
// Use default (ApproveAll is per-session)
80+
});
81+
82+
try
83+
{
84+
await client.StartAsync();
85+
Console.WriteLine("ocp: Copilot client started.");
86+
}
87+
catch (Exception ex)
88+
{
89+
Console.Error.WriteLine($"ocp: Failed to start Copilot client: {ex.Message}");
90+
Environment.Exit(1);
91+
}
92+
93+
var builder = WebApplication.CreateBuilder();
94+
builder.Logging.ClearProviders();
95+
builder.Logging.AddConsole();
96+
97+
// Robust port selection (internal, no --port flag per non-goals)
98+
static int FindAvailablePort(int preferred, int maxTries = 5)
99+
{
100+
for (int p = preferred; p < preferred + maxTries; p++)
101+
{
102+
try
103+
{
104+
using var l = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, p);
105+
l.Start();
106+
l.Stop();
107+
return p;
108+
}
109+
catch { /* try next */ }
110+
}
111+
return preferred; // will fail with clear error below
112+
}
113+
114+
var preferredPort = 11434;
115+
var port = FindAvailablePort(preferredPort);
116+
var listenUrl = $"http://localhost:{port}";
117+
118+
var app = builder.Build();
119+
app.Urls.Clear();
120+
app.Urls.Add(listenUrl);
121+
122+
// Ensure client is always stopped/disposed, even on bind failure or kill
123+
app.Lifetime.ApplicationStopping.Register(() =>
124+
{
125+
try { client.StopAsync().GetAwaiter().GetResult(); } catch { }
126+
});
127+
128+
app.MapGet("/v1/models", async (HttpContext ctx) =>
129+
{
130+
try
131+
{
132+
var models = await client.ListModelsAsync();
133+
var resp = OpenAIMapper.ToModelsListResponse(models);
134+
ctx.Response.ContentType = "application/json";
135+
await ctx.Response.WriteAsync(JsonSerializer.Serialize(resp, jsonOptions));
136+
}
137+
catch (Exception ex)
138+
{
139+
ctx.Response.StatusCode = 500;
140+
await ctx.Response.WriteAsJsonAsync(new { error = new { message = ex.Message } });
141+
}
142+
});
143+
144+
app.MapPost("/v1/chat/completions", async (HttpContext ctx) =>
145+
{
146+
ChatRequest? req;
147+
try
148+
{
149+
req = await ctx.Request.ReadFromJsonAsync<ChatRequest>(jsonOptions);
150+
}
151+
catch
152+
{
153+
req = null;
154+
}
155+
156+
if (req is null || string.IsNullOrWhiteSpace(req.Model) || req.Messages is null || req.Messages.Count == 0)
157+
{
158+
ctx.Response.StatusCode = 400;
159+
await ctx.Response.WriteAsJsonAsync(new { error = new { message = "Invalid request: model and messages[] required" } }, jsonOptions);
160+
return;
161+
}
162+
163+
var prompt = OpenAIMapper.ExtractPrompt(req);
164+
165+
try
166+
{
167+
await using var session = await client.CreateSessionAsync(new SessionConfig
168+
{
169+
Model = req.Model,
170+
OnPermissionRequest = PermissionHandler.ApproveAll,
171+
});
172+
173+
var assistantEvent = await session.SendAndWaitAsync(new MessageOptions { Prompt = prompt });
174+
175+
var completion = OpenAIMapper.ToChatCompletionResponse(req.Model, assistantEvent);
176+
177+
ctx.Response.ContentType = "application/json";
178+
await ctx.Response.WriteAsync(JsonSerializer.Serialize(completion, jsonOptions));
179+
}
180+
catch (Exception ex)
181+
{
182+
ctx.Response.StatusCode = 500;
183+
await ctx.Response.WriteAsJsonAsync(new { error = new { message = ex.Message } }, jsonOptions);
184+
}
185+
});
186+
187+
app.MapGet("/", () => Results.Ok(new { name = "ocp", status = "ready", endpoints = new[] { "/v1/models", "/v1/chat/completions" } }));
188+
189+
Console.WriteLine($"ocp: listening on {listenUrl} (OpenAI compatible)");
190+
Console.WriteLine("ocp: GET /v1/models , POST /v1/chat/completions");
191+
192+
try
193+
{
194+
await app.RunAsync();
195+
}
196+
finally
197+
{
198+
try { await client.StopAsync(); } catch { }
199+
}
200+
201+
// DTOs + pure mappers in OpenAIMapper.cs (public, unit testable from Tests)

src/ocp/ocp.csproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
11
<?xml version="1.0" encoding="utf-8"?>
22
<Project Sdk="Microsoft.NET.Sdk">
33
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
45
<TargetFramework>net10.0</TargetFramework>
6+
<PackAsTool>true</PackAsTool>
7+
<ToolCommandName>ocp</ToolCommandName>
58
<PackageId>ocp</PackageId>
9+
<Description>OpenAI-compatible self-hosted endpoint for GitHub Copilot SDK chat.</Description>
610
</PropertyGroup>
711
<ItemGroup>
12+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
13+
<PackageReference Include="GitHub.Copilot.SDK" Version="1.0.3" />
814
<PackageReference Include="NuGetizer" Version="1.4.8">
915
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
1016
<PrivateAssets>all</PrivateAssets>

0 commit comments

Comments
 (0)