@@ -16,33 +16,28 @@ limitations under the License.
1616
1717// JobHandler implements the Console BFF JobService:
1818//
19- // - Calls the metadata service /v1/batches API via the official OpenAI Go
20- // SDK (openai-go v3). Talking to the metadata service through the SDK
21- // keeps it honest about being OpenAI-compatible — schema drift on the
22- // upstream side surfaces immediately as a deserialization or 4xx error.
19+ // - Routes all job lifecycle calls (Enqueue / Get / List / Cancel)
20+ // through the Planner. The Planner owns JobID -> MDS batch.ID
21+ // translation; the handler never holds an MDS batch.ID.
2322// - Persists Console-owned fields (id, display name, created_by, future:
2423// organization, tags ...) in the local store.
25- // - Aggregates both sources into the wire-level *pb.Job returned to the UI.
24+ // - Aggregates Planner Jobs into the wire-level *pb.Job returned to the UI.
2625//
27- // The AIBrix-only extension `aibrix.model_template` is passed via the SDK's
28- // `option.WithJSONSet`, which is the OpenAI-recommended `extra_body` channel.
26+ // The AIBrix-only extension `aibrix.model_template` is forwarded to MDS by the
27+ // planner's BatchClient via the OpenAI SDK's `extra_body` channel.
2928//
30- // When the metadata service is unreachable the handler propagates the error
31- // (codes.Unavailable). The frontend renders its mock fallback in that case.
29+ // When the planner / metadata service is unreachable the handler propagates
30+ // the error (codes.Unavailable). The frontend renders its mock fallback in
31+ // that case.
3232package handler
3333
3434import (
35- "bytes"
3635 "context"
37- "encoding/json"
3836 "errors"
39- "fmt"
40- "io"
4137 "net/http"
42- "strings"
4338
39+ "github.com/google/uuid"
4440 "github.com/openai/openai-go/v3"
45- "github.com/openai/openai-go/v3/option"
4641 "google.golang.org/grpc/codes"
4742 "google.golang.org/grpc/status"
4843 "k8s.io/klog/v2"
@@ -53,75 +48,6 @@ import (
5348 "github.com/vllm-project/aibrix/apps/console/api/store"
5449)
5550
56- // loggingTransport dumps every request and response between the BFF and MDS
57- // at klog -v=2 (verbose). Errors and non-2xx responses always log at info.
58- // Enable with `--v=2` (or env KLOG_V=2) when debugging the OpenAI SDK payloads.
59- type loggingTransport struct {
60- base http.RoundTripper
61- }
62-
63- func (t * loggingTransport ) RoundTrip (req * http.Request ) (* http.Response , error ) {
64- verbose := klog .V (2 ).Enabled ()
65-
66- var reqBody []byte
67- if verbose && req .Body != nil {
68- var err error
69- reqBody , err = io .ReadAll (req .Body )
70- if err != nil {
71- return nil , fmt .Errorf ("read request body for logging: %w" , err )
72- }
73- req .Body = io .NopCloser (bytes .NewReader (reqBody ))
74- }
75- if verbose {
76- klog .V (2 ).Infof ("[BFF→MDS] %s %s\n %s" , req .Method , req .URL .String (), prettyBody (reqBody ))
77- }
78-
79- resp , err := t .base .RoundTrip (req )
80- if err != nil {
81- klog .Warningf ("[BFF→MDS] %s %s ERROR %v" , req .Method , req .URL .String (), err )
82- return resp , err
83- }
84-
85- if resp .StatusCode >= 400 || verbose {
86- respBody , rerr := io .ReadAll (resp .Body )
87- _ = resp .Body .Close ()
88- if rerr != nil {
89- klog .Warningf ("[BFF→MDS] %s %s -> %d (read body failed: %v)" ,
90- req .Method , req .URL .String (), resp .StatusCode , rerr )
91- return resp , nil
92- }
93- resp .Body = io .NopCloser (bytes .NewReader (respBody ))
94- if resp .StatusCode >= 400 {
95- klog .Warningf ("[BFF→MDS] %s %s -> %d\n %s" , req .Method , req .URL .String (), resp .StatusCode , prettyBody (respBody ))
96- } else {
97- klog .V (2 ).Infof ("[BFF→MDS] %s %s -> %d\n %s" , req .Method , req .URL .String (), resp .StatusCode , prettyBody (respBody ))
98- }
99- }
100- return resp , nil
101- }
102-
103- const maxLoggedBodyBytes = 8192
104-
105- // prettyBody indents JSON for readability and truncates oversized bodies.
106- // Non-JSON bodies are returned as-is (also truncated).
107- func prettyBody (b []byte ) string {
108- if len (b ) == 0 {
109- return "(empty)"
110- }
111- var pretty bytes.Buffer
112- if err := json .Indent (& pretty , b , "" , " " ); err == nil {
113- out := pretty .Bytes ()
114- if len (out ) > maxLoggedBodyBytes {
115- return string (out [:maxLoggedBodyBytes ]) + "\n ...(truncated)"
116- }
117- return string (out )
118- }
119- if len (b ) > maxLoggedBodyBytes {
120- return string (b [:maxLoggedBodyBytes ]) + "...(truncated)"
121- }
122- return string (b )
123- }
124-
12551// Console-owned fields we stash on the OpenAI batch.metadata map. Namespaced
12652// to keep them out of user-supplied metadata's key space. The bare
12753// "display_name" key is kept for backwards compatibility with batches
@@ -141,25 +67,15 @@ type JobHandler struct {
14167
14268 store store.Store
14369 planner plannerapi.Planner
144- openai openai.Client
14570 defaultModelDeploymentTemplate string
14671 devMode bool
14772}
14873
14974// NewJobHandler creates a JobHandler.
150- func NewJobHandler (s store.Store , planner plannerapi.Planner , metadataServiceURL , defaultModelDeploymentTemplate string , devMode bool ) * JobHandler {
151-
152- baseURL := strings .TrimRight (metadataServiceURL , "/" ) + "/v1"
153- httpClient := & http.Client {Transport : & loggingTransport {base : http .DefaultTransport }}
154- client := openai .NewClient (
155- option .WithBaseURL (baseURL ),
156- option .WithAPIKey ("aibrix-console" ),
157- option .WithHTTPClient (httpClient ),
158- )
75+ func NewJobHandler (s store.Store , planner plannerapi.Planner , defaultModelDeploymentTemplate string , devMode bool ) * JobHandler {
15976 return & JobHandler {
16077 store : s ,
16178 planner : planner ,
162- openai : client ,
16379 defaultModelDeploymentTemplate : defaultModelDeploymentTemplate ,
16480 devMode : devMode ,
16581 }
@@ -193,8 +109,8 @@ func (h *JobHandler) ListJobs(ctx context.Context, req *pb.ListJobsRequest) (*pb
193109 }
194110
195111 jobs := make ([]* pb.Job , 0 , len (resp .Data ))
196- for _ , batch := range resp .Data {
197- jobs = append (jobs , mergeJob (batch , nil ))
112+ for _ , job := range resp .Data {
113+ jobs = append (jobs , mergeJob (job , nil ))
198114 }
199115 // SDK CursorPage exposes Data and HasMore. first_id / last_id ride along
200116 // in the upstream JSON but are not surfaced as named fields; the UI
@@ -212,22 +128,22 @@ func (h *JobHandler) GetJob(ctx context.Context, req *pb.GetJobRequest) (*pb.Job
212128 if req .Id == "" {
213129 return nil , status .Error (codes .InvalidArgument , "id is required" )
214130 }
215- batch , err := h .planner .GetJob (ctx , req .Id )
131+ job , err := h .planner .GetJob (ctx , req .Id )
216132 if err != nil {
217133 // Dev fallback: return the demo job if MDS is unreachable.
218134 if h .devMode {
219135 if dev , ok := h .store .(interface {
220136 GetDemoJob (id string ) (* pb.Job , bool )
221137 }); ok {
222- if job , found := dev .GetDemoJob (req .Id ); found {
138+ if demoJob , found := dev .GetDemoJob (req .Id ); found {
223139 klog .Warningf ("MDS unreachable, falling back to demo job %s: %v" , req .Id , err )
224- return job , nil
140+ return demoJob , nil
225141 }
226142 }
227143 }
228144 return nil , mapPlannerError (err , "get batch" )
229145 }
230- return mergeJob (batch , nil ), nil
146+ return mergeJob (job , nil ), nil
231147}
232148
233149// CreateJob calls POST /v1/batches with console-owned fields (display name,
@@ -253,6 +169,10 @@ func (h *JobHandler) CreateJob(ctx context.Context, req *pb.CreateJobRequest) (*
253169 completionWindow = string (openai .BatchNewParamsCompletionWindow24h )
254170 }
255171
172+ // Console-generated JobID. The queued planner will own a durable
173+ // JobID -> BatchID map; until then the planner keeps it in-memory.
174+ jobID := "job_" + uuid .NewString ()
175+
256176 // Pack console-owned fields into batch.Metadata under the aibrix.console.*
257177 // namespace. This keeps a single source of truth (MDS) for the e2e demo
258178 // loop. The store-overlay path is parked (not deleted) for the future
@@ -289,32 +209,34 @@ func (h *JobHandler) CreateJob(ctx context.Context, req *pb.CreateJobRequest) (*
289209 }
290210
291211 enqueueReq := & plannerapi.EnqueueRequest {
212+ JobID : jobID ,
292213 ModelTemplate : modelTemplate ,
293- BatchPayload : plannerapi. BatchPayload {
214+ BatchParams : openai. BatchNewParams {
294215 InputFileID : req .InputDataset ,
295- Endpoint : req .Endpoint ,
296- CompletionWindow : completionWindow ,
216+ Endpoint : openai . BatchNewParamsEndpoint ( req .Endpoint ) ,
217+ CompletionWindow : openai . BatchNewParamsCompletionWindow ( completionWindow ) ,
297218 Metadata : metadata ,
298219 },
299220 }
300221
301- result , err := h .planner .Enqueue (ctx , enqueueReq )
222+ job , err := h .planner .Enqueue (ctx , enqueueReq )
302223 if err != nil {
303224 return nil , mapPlannerError (err , "create batch" )
304225 }
305- return mergeJob (result . Batch , nil ), nil
226+ return mergeJob (job , nil ), nil
306227}
307228
308- // CancelJob proxies to POST /v1/batches/{id}/cancel and merges with store.
229+ // CancelJob routes through Planner.Cancel; the planner resolves JobID
230+ // to MDS batch.ID and forwards to /v1/batches/{id}/cancel.
309231func (h * JobHandler ) CancelJob (ctx context.Context , req * pb.CancelJobRequest ) (* pb.Job , error ) {
310232 if req .Id == "" {
311233 return nil , status .Error (codes .InvalidArgument , "id is required" )
312234 }
313- batch , err := h .openai . Batches .Cancel (ctx , req .Id )
235+ job , err := h .planner .Cancel (ctx , req .Id )
314236 if err != nil {
315- return nil , mapSDKError (err , "cancel batch" )
237+ return nil , mapPlannerError (err , "cancel batch" )
316238 }
317- return mergeJob (batch , nil ), nil
239+ return mergeJob (job , nil ), nil
318240}
319241
320242// currentUserEmail returns the authenticated user's email if available, else
@@ -372,64 +294,67 @@ func mapPlannerError(err error, op string) error {
372294 return mapSDKError (err , op )
373295}
374296
375- // mergeJob aggregates the OpenAI Batch state with optional Console overlay.
376- // Console-owned fields (display name, created_by, template binding) are read
377- // out of batch.metadata under the aibrix.console.* namespace; the overlay
378- // argument is plumbing for the future store-backed reconcile path and is
379- // expected to be nil today.
380- func mergeJob (b * openai.Batch , overlay * pb.Job ) * pb.Job {
297+ // mergeJob aggregates the planner's Job with optional Console overlay.
298+ // pb.Job.Id is set to the planner's JobID — the MDS batch.ID never reaches
299+ // this layer. Console-owned fields (display name, created_by, template
300+ // binding) are read out of batch.metadata under the aibrix.console.*
301+ // namespace; the overlay argument is plumbing for the future store-backed
302+ // reconcile path and is expected to be nil today.
303+ func mergeJob (v * plannerapi.Job , overlay * pb.Job ) * pb.Job {
381304 job := & pb.Job {}
382- if b != nil {
383- job .Id = b .ID
384- job .Object = string (b .Object )
385- job .Endpoint = b .Endpoint
386- job .Model = b .Model
387- job .InputDataset = b .InputFileID
388- job .CompletionWindow = b .CompletionWindow
389- job .Status = string (b .Status )
390- job .OutputDataset = b .OutputFileID
391- job .ErrorDataset = b .ErrorFileID
392- job .CreatedAt = b .CreatedAt
393- job .InProgressAt = b .InProgressAt
394- job .ExpiresAt = b .ExpiresAt
395- job .FinalizingAt = b .FinalizingAt
396- job .CompletedAt = b .CompletedAt
397- job .FailedAt = b .FailedAt
398- job .ExpiredAt = b .ExpiredAt
399- job .CancellingAt = b .CancellingAt
400- job .CancelledAt = b .CancelledAt
401- if len (b .Metadata ) > 0 {
402- job .Metadata = map [string ]string (b .Metadata )
403- // Console-owned fields. Prefer namespaced keys; fall back to the
404- // legacy bare "display_name" so batches created by older builds
405- // still surface their name.
406- if v := b .Metadata [metadataConsoleDisplayName ]; v != "" {
407- job .Name = v
408- } else if v := b .Metadata [metadataDisplayName ]; v != "" {
409- job .Name = v
410- }
411- if v := b .Metadata [metadataConsoleCreatedBy ]; v != "" {
412- job .CreatedBy = v
413- }
414- if v := b .Metadata [metadataConsoleTemplateName ]; v != "" {
415- job .ModelTemplateName = v
416- }
417- if v := b .Metadata [metadataConsoleTemplateVersion ]; v != "" {
418- job .ModelTemplateVersion = v
305+ if v != nil {
306+ job .Id = v .JobID
307+ if b := v .Batch ; b != nil {
308+ job .Object = string (b .Object )
309+ job .Endpoint = b .Endpoint
310+ job .Model = b .Model
311+ job .InputDataset = b .InputFileID
312+ job .CompletionWindow = b .CompletionWindow
313+ job .Status = string (b .Status )
314+ job .OutputDataset = b .OutputFileID
315+ job .ErrorDataset = b .ErrorFileID
316+ job .CreatedAt = b .CreatedAt
317+ job .InProgressAt = b .InProgressAt
318+ job .ExpiresAt = b .ExpiresAt
319+ job .FinalizingAt = b .FinalizingAt
320+ job .CompletedAt = b .CompletedAt
321+ job .FailedAt = b .FailedAt
322+ job .ExpiredAt = b .ExpiredAt
323+ job .CancellingAt = b .CancellingAt
324+ job .CancelledAt = b .CancelledAt
325+ if len (b .Metadata ) > 0 {
326+ job .Metadata = map [string ]string (b .Metadata )
327+ // Console-owned fields. Prefer namespaced keys; fall back to the
328+ // legacy bare "display_name" so batches created by older builds
329+ // still surface their name.
330+ if v := b .Metadata [metadataConsoleDisplayName ]; v != "" {
331+ job .Name = v
332+ } else if v := b .Metadata [metadataDisplayName ]; v != "" {
333+ job .Name = v
334+ }
335+ if v := b .Metadata [metadataConsoleCreatedBy ]; v != "" {
336+ job .CreatedBy = v
337+ }
338+ if v := b .Metadata [metadataConsoleTemplateName ]; v != "" {
339+ job .ModelTemplateName = v
340+ }
341+ if v := b .Metadata [metadataConsoleTemplateVersion ]; v != "" {
342+ job .ModelTemplateVersion = v
343+ }
419344 }
420- }
421- if b . JSON . RequestCounts . Valid () {
422- job . RequestCounts = & pb. JobRequestCounts {
423- Total : int32 (b .RequestCounts .Total ),
424- Completed : int32 (b .RequestCounts .Completed ),
425- Failed : int32 ( b . RequestCounts . Failed ),
345+ if b . JSON . RequestCounts . Valid () {
346+ job . RequestCounts = & pb. JobRequestCounts {
347+ Total : int32 ( b . RequestCounts . Total ),
348+ Completed : int32 (b .RequestCounts .Completed ),
349+ Failed : int32 (b .RequestCounts .Failed ),
350+ }
426351 }
427- }
428- if b . JSON . Usage . Valid () {
429- job . Usage = & pb. JobUsage {
430- InputTokens : b .Usage .InputTokens ,
431- OutputTokens : b .Usage .OutputTokens ,
432- TotalTokens : b . Usage . TotalTokens ,
352+ if b . JSON . Usage . Valid () {
353+ job . Usage = & pb. JobUsage {
354+ InputTokens : b . Usage . InputTokens ,
355+ OutputTokens : b .Usage .OutputTokens ,
356+ TotalTokens : b .Usage .TotalTokens ,
357+ }
433358 }
434359 }
435360 }
0 commit comments