|
| 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) |
0 commit comments