@michelangelo-ai/rpc 0.9.0 → 0.10.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,6 @@
1
- import { expect, it, vi } from 'vitest';
1
+ import { create } from '@bufbuild/protobuf';
2
+ import { anyPack, StringValueSchema } from '@bufbuild/protobuf/wkt';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
4
 
3
5
  import { request } from '../request';
4
6
 
@@ -40,8 +42,189 @@ it('decodes a ListPipelineRun response containing a TypedStruct Any field', asyn
40
42
  result as unknown as { pipelineRunList: { items: { status: { details: unknown[] } }[] } }
41
43
  ).pipelineRunList.items[0].status.details;
42
44
 
43
- // The Any is decoded to { typeUrl, value: Uint8Array } by the registry.
44
- // Without TypedStructSchema in the registry, fromJson throws before reaching here.
45
- expect(details[0]).toMatchObject({ typeUrl: 'type.googleapis.com/michelangelo.api.TypedStruct' });
46
- expect((details[0] as { value: unknown }).value).toBeInstanceOf(Uint8Array);
45
+ // The registry decodes the Any to a binary TypedStruct, and toPlainObject unpacks it to
46
+ // { typeUrl, value } where typeUrl names the inner config type and value is its plain
47
+ // JSON. Without TypedStructSchema in the registry, fromJson throws before reaching here.
48
+ expect(details[0]).toEqual({
49
+ typeUrl: 'type.googleapis.com/michelangelo.UniFlowConf',
50
+ value: {},
51
+ });
52
+ });
53
+
54
+ // Verifies the Any lands on the wire in the shape Envoy's grpc_json_transcoder expects.
55
+ describe('outgoing request — Any-packing through the real service client', () => {
56
+ beforeEach(() => {
57
+ vi.mocked(global.fetch).mockClear();
58
+ });
59
+
60
+ function expectCriteria(
61
+ ...expected: Array<{ fieldName: string; operator: string; matchValue: unknown }>
62
+ ) {
63
+ const calls = vi.mocked(global.fetch).mock.calls;
64
+ const [, init] = calls.at(-1) as [string, RequestInit];
65
+ // cast: this test only cares about the shape it itself constructed
66
+ const body = JSON.parse(init.body as string) as {
67
+ listOptionsExt: { operation: { criterion: Record<string, unknown>[] } };
68
+ };
69
+ const criteria = body.listOptionsExt.operation.criterion;
70
+ expect(criteria).toHaveLength(expected.length);
71
+ for (const [i, criterion] of expected.entries()) {
72
+ expect(criteria[i]).toMatchObject(criterion);
73
+ }
74
+ }
75
+
76
+ it('packs a string matchValue into a StringValue Any', async () => {
77
+ await request('ListPipelineRun', {
78
+ listOptionsExt: {
79
+ operation: {
80
+ criterion: [
81
+ { fieldName: 'pipeline_run.pipeline_name', operator: 1, matchValue: 'my-pipeline' },
82
+ ],
83
+ },
84
+ },
85
+ } as never);
86
+
87
+ expectCriteria({
88
+ fieldName: 'pipeline_run.pipeline_name',
89
+ operator: 'CRITERION_OPERATOR_EQUAL',
90
+ matchValue: {
91
+ '@type': 'type.googleapis.com/google.protobuf.StringValue',
92
+ value: 'my-pipeline',
93
+ },
94
+ });
95
+ });
96
+
97
+ it('packs a boolean matchValue into a BoolValue Any', async () => {
98
+ await request('ListPipelineRun', {
99
+ listOptionsExt: {
100
+ operation: { criterion: [{ fieldName: 'x', operator: 1, matchValue: true }] },
101
+ },
102
+ } as never);
103
+
104
+ expectCriteria({
105
+ fieldName: 'x',
106
+ operator: 'CRITERION_OPERATOR_EQUAL',
107
+ matchValue: { '@type': 'type.googleapis.com/google.protobuf.BoolValue', value: true },
108
+ });
109
+ });
110
+
111
+ it('packs an integer matchValue into an Int64Value Any', async () => {
112
+ await request('ListPipelineRun', {
113
+ listOptionsExt: {
114
+ operation: { criterion: [{ fieldName: 'x', operator: 1, matchValue: 42 }] },
115
+ },
116
+ } as never);
117
+
118
+ expectCriteria({
119
+ fieldName: 'x',
120
+ operator: 'CRITERION_OPERATOR_EQUAL',
121
+ matchValue: { '@type': 'type.googleapis.com/google.protobuf.Int64Value', value: '42' },
122
+ });
123
+ });
124
+
125
+ it('packs a float matchValue into a DoubleValue Any', async () => {
126
+ await request('ListPipelineRun', {
127
+ listOptionsExt: {
128
+ operation: { criterion: [{ fieldName: 'x', operator: 1, matchValue: 1.5 }] },
129
+ },
130
+ } as never);
131
+
132
+ expectCriteria({
133
+ fieldName: 'x',
134
+ operator: 'CRITERION_OPERATOR_EQUAL',
135
+ matchValue: { '@type': 'type.googleapis.com/google.protobuf.DoubleValue', value: 1.5 },
136
+ });
137
+ });
138
+
139
+ it('sends an already-packed Any (real typeUrl/value) through unchanged', async () => {
140
+ const realAny = anyPack(
141
+ StringValueSchema,
142
+ create(StringValueSchema, { value: 'already-packed' })
143
+ );
144
+
145
+ await request('ListPipelineRun', {
146
+ listOptionsExt: {
147
+ operation: { criterion: [{ fieldName: 'x', operator: 1, matchValue: realAny }] },
148
+ },
149
+ } as never);
150
+
151
+ expectCriteria({
152
+ fieldName: 'x',
153
+ operator: 'CRITERION_OPERATOR_EQUAL',
154
+ matchValue: {
155
+ '@type': 'type.googleapis.com/google.protobuf.StringValue',
156
+ value: 'already-packed',
157
+ },
158
+ });
159
+ });
160
+
161
+ it('packs Any values across every entry of a repeated field, and leaves fieldName untouched', async () => {
162
+ await request('ListPipelineRun', {
163
+ listOptionsExt: {
164
+ operation: {
165
+ criterion: [
166
+ { fieldName: 'a', operator: 1, matchValue: 'one' },
167
+ { fieldName: 'b', operator: 1, matchValue: 'two' },
168
+ ],
169
+ },
170
+ },
171
+ } as never);
172
+
173
+ expectCriteria(
174
+ {
175
+ fieldName: 'a',
176
+ operator: 'CRITERION_OPERATOR_EQUAL',
177
+ matchValue: { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: 'one' },
178
+ },
179
+ {
180
+ fieldName: 'b',
181
+ operator: 'CRITERION_OPERATOR_EQUAL',
182
+ matchValue: { '@type': 'type.googleapis.com/google.protobuf.StringValue', value: 'two' },
183
+ }
184
+ );
185
+ });
186
+
187
+ // Rejects rather than letting create() silently turn this into an empty, corrupted Any —
188
+ // callers already normalize thrown RPC errors, so this surfaces as a normal query error.
189
+ it('rejects when an Any field is given a value with no wrapper mapping', async () => {
190
+ await expect(
191
+ request('ListPipelineRun', {
192
+ listOptionsExt: {
193
+ operation: {
194
+ criterion: [{ fieldName: 'x', operator: 1, matchValue: { nested: 'object' } }],
195
+ },
196
+ },
197
+ } as never)
198
+ ).rejects.toThrow(/cannot auto-pack object/);
199
+ });
200
+
201
+ // A map<string, Any> field on an unrelated service/message, proving the packing is
202
+ // schema-driven rather than special-cased for Criterion.
203
+ it('packs a map<string, Any> field on CreateDeployment, an unrelated service method', async () => {
204
+ (global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
205
+ status: 200,
206
+ headers: new Headers({ 'content-type': 'application/json' }),
207
+ json: () => Promise.resolve({}),
208
+ });
209
+
210
+ await request('CreateDeployment', {
211
+ metadata: { name: 'my-deployment' },
212
+ status: { providerStatus: { foo: 'bar-value', replicas: 3 } },
213
+ } as never);
214
+
215
+ const calls = (global.fetch as ReturnType<typeof vi.fn>).mock.calls;
216
+ const [, init] = calls.at(-1) as [string, RequestInit];
217
+ const body = JSON.parse(init.body as string) as {
218
+ deployment: { status: { providerStatus: Record<string, unknown> } };
219
+ };
220
+
221
+ expect(body.deployment.status.providerStatus.foo).toEqual({
222
+ '@type': 'type.googleapis.com/google.protobuf.StringValue',
223
+ value: 'bar-value',
224
+ });
225
+ expect(body.deployment.status.providerStatus.replicas).toEqual({
226
+ '@type': 'type.googleapis.com/google.protobuf.Int64Value',
227
+ value: '3',
228
+ });
229
+ });
47
230
  });
package/handlers.ts CHANGED
@@ -3,6 +3,8 @@ import { create } from '@bufbuild/protobuf';
3
3
  import { UserInfoSchema } from './gen/michelangelo/api/v2/user_pb';
4
4
  import { getServices } from './services';
5
5
 
6
+ import type { Deployment } from './gen/michelangelo/api/v2/deployment_pb';
7
+ import type { InferenceServer } from './gen/michelangelo/api/v2/inference_server_pb';
6
8
  import type { PipelineRun } from './gen/michelangelo/api/v2/pipeline_run_pb';
7
9
  import type { TriggerRun } from './gen/michelangelo/api/v2/trigger_run_pb';
8
10
  import type { ExtractUnaryRpc } from './types';
@@ -36,8 +38,17 @@ async function createHandlers() {
36
38
  return {
37
39
  ListDeployment: unary(services.DeploymentService.listDeployment),
38
40
  GetDeployment: unary(services.DeploymentService.getDeployment),
41
+ CreateDeployment: (record: Deployment, headers?: Record<string, string>) => {
42
+ const actorName = headers?.['x-user-name'];
43
+ if (actorName && record.spec) {
44
+ record.spec.owner = create(UserInfoSchema, { name: actorName });
45
+ }
46
+ return services.DeploymentService.createDeployment({ deployment: record }, headers);
47
+ },
39
48
  ListInferenceServer: unary(services.InferenceServerService.listInferenceServer),
40
49
  GetInferenceServer: unary(services.InferenceServerService.getInferenceServer),
50
+ CreateInferenceServer: (record: InferenceServer, headers?: Record<string, string>) =>
51
+ services.InferenceServerService.createInferenceServer({ inferenceServer: record }, headers),
41
52
  ListProject: unary(services.ProjectService.listProject),
42
53
  GetProject: unary(services.ProjectService.getProject),
43
54
  GetPipeline: unary(services.PipelineService.getPipeline),
@@ -47,6 +58,13 @@ async function createHandlers() {
47
58
  GetPipelineRun: unary(services.PipelineRunService.getPipelineRun),
48
59
  ListTriggerRun: unary(services.TriggerRunService.listTriggerRun),
49
60
  GetTriggerRun: unary(services.TriggerRunService.getTriggerRun),
61
+ CreateTriggerRun: (record: TriggerRun, headers?: Record<string, string>) => {
62
+ const actorName = headers?.['x-user-name'];
63
+ if (actorName && record.spec) {
64
+ record.spec.actor = create(UserInfoSchema, { name: actorName });
65
+ }
66
+ return services.TriggerRunService.createTriggerRun({ triggerRun: record }, headers);
67
+ },
50
68
  UpdateTriggerRun: (record: TriggerRun, headers?: Record<string, string>) =>
51
69
  services.TriggerRunService.updateTriggerRun({ triggerRun: record }, headers),
52
70
  CreatePipelineRun: (record: PipelineRun, headers?: Record<string, string>) => {
@@ -59,6 +77,8 @@ async function createHandlers() {
59
77
  UpdatePipelineRun: (record: PipelineRun, headers?: Record<string, string>) =>
60
78
  services.PipelineRunService.updatePipelineRun({ pipelineRun: record }, headers),
61
79
  ListModel: unary(services.ModelService.listModel),
80
+ GetModel: unary(services.ModelService.getModel),
81
+ ListModelFamily: unary(services.ModelFamilyService.listModelFamily),
62
82
  } as const;
63
83
  }
64
84
 
@@ -0,0 +1,92 @@
1
+ import { create } from '@bufbuild/protobuf';
2
+ import {
3
+ anyPack,
4
+ BoolValueSchema,
5
+ DoubleValueSchema,
6
+ Int64ValueSchema,
7
+ StringValueSchema,
8
+ } from '@bufbuild/protobuf/wkt';
9
+
10
+ import type { DescField, DescMessage } from '@bufbuild/protobuf';
11
+
12
+ const ANY_TYPE_NAME = 'google.protobuf.Any';
13
+
14
+ /**
15
+ * Walks a request object against its proto descriptor, packing JS primitives into well-known
16
+ * wrapper types wherever the schema has a `google.protobuf.Any` field.
17
+ *
18
+ * @example
19
+ * packAnyFields(CriterionSchema, { fieldName: "x", matchValue: "my-pipeline" })
20
+ * // matchValue -> anyPack(StringValueSchema, create(StringValueSchema, { value: "my-pipeline" }))
21
+ */
22
+ export function packAnyFields(desc: DescMessage, value: unknown): unknown {
23
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
24
+
25
+ const result: Record<string, unknown> = {};
26
+ for (const [key, val] of Object.entries(value)) {
27
+ const field: DescField | undefined = desc.field[key];
28
+ result[key] = field ? packField(field, val) : val;
29
+ }
30
+ return result;
31
+ }
32
+
33
+ function packField(field: DescField, value: unknown): unknown {
34
+ if (value === null || value === undefined) return value;
35
+
36
+ switch (field.fieldKind) {
37
+ case 'message':
38
+ return packMessageValue(field.message, value);
39
+ case 'list':
40
+ if (field.listKind === 'message') {
41
+ // cast: repeated fields are always arrays at runtime
42
+ return (value as unknown[]).map((item) => packMessageValue(field.message, item));
43
+ }
44
+ return value;
45
+ case 'map': {
46
+ const mapValueMessage = field.message;
47
+ if (!mapValueMessage) return value;
48
+ // cast: map fields are always plain objects at runtime, keyed by the (stringified)
49
+ // map key regardless of its declared scalar type
50
+ const mapValue = value as Record<string, unknown>;
51
+ return Object.fromEntries(
52
+ Object.entries(mapValue).map(([key, item]) => [
53
+ key,
54
+ packMessageValue(mapValueMessage, item),
55
+ ])
56
+ );
57
+ }
58
+ default:
59
+ return value;
60
+ }
61
+ }
62
+
63
+ function packMessageValue(desc: DescMessage, value: unknown): unknown {
64
+ if (desc.typeName !== ANY_TYPE_NAME) {
65
+ return packAnyFields(desc, value);
66
+ }
67
+
68
+ // Already a packed Any object (has typeUrl) — pass through
69
+ if (typeof value === 'object' && value !== null && 'typeUrl' in value) {
70
+ return value;
71
+ }
72
+
73
+ if (typeof value === 'string') {
74
+ return anyPack(StringValueSchema, create(StringValueSchema, { value }));
75
+ }
76
+ if (typeof value === 'boolean') {
77
+ return anyPack(BoolValueSchema, create(BoolValueSchema, { value }));
78
+ }
79
+ if (typeof value === 'number') {
80
+ if (Number.isInteger(value)) {
81
+ return anyPack(Int64ValueSchema, create(Int64ValueSchema, { value: BigInt(value) }));
82
+ }
83
+ return anyPack(DoubleValueSchema, create(DoubleValueSchema, { value }));
84
+ }
85
+
86
+ // create() doesn't validate an Any's shape, so passing this through would silently produce
87
+ // an empty Any (typeUrl: '') instead of a visible error.
88
+ throw new Error(
89
+ `packAnyFields: cannot auto-pack ${typeof value} into google.protobuf.Any — ` +
90
+ `expected a string, number, or boolean primitive`
91
+ );
92
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@michelangelo-ai/rpc",
3
3
  "license": "Apache-2.0",
4
4
  "type": "module",
5
- "version": "0.9.0",
5
+ "version": "0.10.0-rc.1",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "https://github.com/michelangelo-ai/michelangelo"
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@bufbuild/protobuf": "^2.2.5",
20
- "@michelangelo-ai/core": "0.9.0"
20
+ "@michelangelo-ai/core": "0.10.0-rc.1"
21
21
  },
22
22
  "exports": {
23
23
  ".": {
package/request.ts CHANGED
@@ -1,5 +1,9 @@
1
+ import { fromBinary, toJson } from '@bufbuild/protobuf';
2
+
3
+ import { TypedStructSchema } from './gen/michelangelo/api/typed_struct_pb';
1
4
  import { getRpcHandlers } from './handlers';
2
5
 
6
+ import type { Any } from '@bufbuild/protobuf/wkt';
3
7
  import type { OmitTypeName, RpcHandlerType } from './types';
4
8
 
5
9
  /**
@@ -43,6 +47,9 @@ function toPlainObject(value: unknown): unknown {
43
47
  if (value instanceof Uint8Array) return value; // preserve bytes fields (e.g. google.protobuf.Any.value)
44
48
  if (Array.isArray(value)) return value.map(toPlainObject);
45
49
 
50
+ const typedStruct = unpackTypedStructAny(value);
51
+ if (typedStruct) return typedStruct;
52
+
46
53
  const result: Record<string, unknown> = {};
47
54
  for (const [key, val] of Object.entries(value)) {
48
55
  if (key === '$typeName' || key === '$unknown') continue;
@@ -50,3 +57,17 @@ function toPlainObject(value: unknown): unknown {
50
57
  }
51
58
  return result;
52
59
  }
60
+
61
+ const TYPED_STRUCT_TYPE_URL = `type.googleapis.com/${TypedStructSchema.typeName}`;
62
+
63
+ // google.protobuf.Any fields survive fromJson as { typeUrl, value: Uint8Array } — the binary
64
+ // payload is useless to consumers. When the payload is a michelangelo.api.TypedStruct
65
+ // (e.g. PipelineManifest.content), expand it to { typeUrl, value } where typeUrl names the
66
+ // inner config type and value is its plain JSON. Other Any payloads are left untouched.
67
+ function unpackTypedStructAny(value: object): unknown {
68
+ if (!('$typeName' in value) || value.$typeName !== 'google.protobuf.Any') return undefined;
69
+ // cast: the $typeName check above identifies this as a google.protobuf.Any message
70
+ const any = value as Any;
71
+ if (any.typeUrl !== TYPED_STRUCT_TYPE_URL) return undefined;
72
+ return toJson(TypedStructSchema, fromBinary(TypedStructSchema, any.value));
73
+ }
package/services.ts CHANGED
@@ -1,20 +1,38 @@
1
1
  import { create, createRegistry, fromJson, toJson } from '@bufbuild/protobuf';
2
+ import {
3
+ BoolValueSchema,
4
+ DoubleValueSchema,
5
+ Int64ValueSchema,
6
+ StringValueSchema,
7
+ } from '@bufbuild/protobuf/wkt';
2
8
 
3
9
  import { createFetchTransport } from './create-fetch-transport';
4
10
  import { TypedStructSchema } from './gen/michelangelo/api/typed_struct_pb';
5
11
  import { DeploymentService } from './gen/michelangelo/api/v2/deployment_svc_pb';
6
12
  import { InferenceServerService } from './gen/michelangelo/api/v2/inference_server_svc_pb';
13
+ import { ModelFamilyService } from './gen/michelangelo/api/v2/model_family_svc_pb';
7
14
  import { ModelService } from './gen/michelangelo/api/v2/model_svc_pb';
8
15
  import { PipelineRunService } from './gen/michelangelo/api/v2/pipeline_run_svc_pb';
9
16
  import { PipelineService } from './gen/michelangelo/api/v2/pipeline_svc_pb';
10
17
  import { ProjectService } from './gen/michelangelo/api/v2/project_svc_pb';
11
18
  import { TriggerRunService } from './gen/michelangelo/api/v2/trigger_run_svc_pb';
19
+ import { packAnyFields } from './pack-any-fields';
12
20
  import { getRuntimeConfig } from './runtime-config';
13
21
 
14
22
  import type { DescService } from '@bufbuild/protobuf';
15
23
  import type { FetchTransport, ServiceClient, Services } from './types';
16
24
 
17
- const typeRegistry = createRegistry(TypedStructSchema);
25
+ // These wrapper schemas are registered so criteria that wrap a matchValue in a
26
+ // google.protobuf.Any (e.g. ListOptionsExt filters, packed by packAnyFields) can be
27
+ // JSON-encoded — protobuf-es resolves an Any's typeUrl against this registry to serialize
28
+ // it as a well-known type.
29
+ const typeRegistry = createRegistry(
30
+ TypedStructSchema,
31
+ StringValueSchema,
32
+ BoolValueSchema,
33
+ Int64ValueSchema,
34
+ DoubleValueSchema
35
+ );
18
36
 
19
37
  /**
20
38
  * Builds a service client whose methods JSON-encode the request, POST it
@@ -35,7 +53,10 @@ function createServiceClient<T extends DescService>(
35
53
  if (method.methodKind !== 'unary') continue;
36
54
 
37
55
  client[method.localName] = async (request, headers) => {
38
- const message = create(method.input, request);
56
+ // cast: packAnyFields recurses generically over `unknown`; called with a Record it
57
+ // returns one, just with Any fields packed into the shape create() expects
58
+ const packedRequest = packAnyFields(method.input, request) as Record<string, unknown>;
59
+ const message = create(method.input, packedRequest);
39
60
  const requestJson = toJson(method.input, message, { registry: typeRegistry });
40
61
  const responseJson = await transport.callUnary(
41
62
  service.typeName,
@@ -66,6 +87,7 @@ async function createServices(): Promise<Services> {
66
87
  PipelineRunService: createServiceClient(PipelineRunService, transport),
67
88
  TriggerRunService: createServiceClient(TriggerRunService, transport),
68
89
  ModelService: createServiceClient(ModelService, transport),
90
+ ModelFamilyService: createServiceClient(ModelFamilyService, transport),
69
91
  } as const;
70
92
  }
71
93
 
package/types.ts CHANGED
@@ -8,6 +8,7 @@ import type {
8
8
  } from '@bufbuild/protobuf';
9
9
  import type { DeploymentService } from './gen/michelangelo/api/v2/deployment_svc_pb';
10
10
  import type { InferenceServerService } from './gen/michelangelo/api/v2/inference_server_svc_pb';
11
+ import type { ModelFamilyService } from './gen/michelangelo/api/v2/model_family_svc_pb';
11
12
  import type { ModelService } from './gen/michelangelo/api/v2/model_svc_pb';
12
13
  import type { PipelineRunService } from './gen/michelangelo/api/v2/pipeline_run_svc_pb';
13
14
  import type { PipelineService } from './gen/michelangelo/api/v2/pipeline_svc_pb';
@@ -67,6 +68,7 @@ export type Services = {
67
68
  PipelineRunService: ServiceClient<typeof PipelineRunService>;
68
69
  TriggerRunService: ServiceClient<typeof TriggerRunService>;
69
70
  ModelService: ServiceClient<typeof ModelService>;
71
+ ModelFamilyService: ServiceClient<typeof ModelFamilyService>;
70
72
  };
71
73
 
72
74
  /**