Skip to content

Commit 1b63cef

Browse files
committed
feat(inference): BYOM support
1 parent 53da29d commit 1b63cef

File tree

12 files changed

+863
-10
lines changed

12 files changed

+863
-10
lines changed

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ require (
2727
github.com/nats-io/jwt/v2 v2.7.3
2828
github.com/nats-io/nats.go v1.38.0
2929
github.com/robfig/cron/v3 v3.0.1
30-
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33.0.20250423163640-c26c02ea0239
30+
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33.0.20250425085959-ea0a849e0b26
3131
github.com/stretchr/testify v1.10.0
3232
golang.org/x/crypto v0.36.0
3333
gopkg.in/dnaeon/go-vcr.v3 v3.2.0

go.sum

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,10 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
298298
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
299299
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
300300
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
301-
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33.0.20250423163640-c26c02ea0239 h1:RsGfflmNcbUUUOIg1HxbFAhgXbA0rayja1QGVSYvksY=
302-
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33.0.20250423163640-c26c02ea0239/go.mod h1:w4o02EHpO0CBGy2nehzWRaFQKd62G9HIf+Q07PDaUcE=
301+
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33.0.20250424152954-b4babe8f214c h1:sjbNFhI3o5ecQuxLZv54Gm/YlqP55Ot5l7ShneWeNg8=
302+
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33.0.20250424152954-b4babe8f214c/go.mod h1:w4o02EHpO0CBGy2nehzWRaFQKd62G9HIf+Q07PDaUcE=
303+
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33.0.20250425085959-ea0a849e0b26 h1:6KJ16mZbrP/ahxkbJGTCjHdJJdCJF1Hfwnw92Q5sf3I=
304+
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.33.0.20250425085959-ea0a849e0b26/go.mod h1:w4o02EHpO0CBGy2nehzWRaFQKd62G9HIf+Q07PDaUcE=
303305
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
304306
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
305307
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=

internal/provider/provider.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ func Provider(config *Config) plugin.ProviderFunc {
167167
"scaleway_iam_ssh_key": iam.ResourceSSKKey(),
168168
"scaleway_iam_user": iam.ResourceUser(),
169169
"scaleway_inference_deployment": inference.ResourceDeployment(),
170+
"scaleway_inference_custom_model": inference.ResourceCustomModel(),
170171
"scaleway_instance_image": instance.ResourceImage(),
171172
"scaleway_instance_ip": instance.ResourceIP(),
172173
"scaleway_instance_ip_reverse_dns": instance.ResourceIPReverseDNS(),
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package inference
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
7+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
8+
"github.com/scaleway/scaleway-sdk-go/api/inference/v1"
9+
"github.com/scaleway/scaleway-sdk-go/scw"
10+
"github.com/scaleway/terraform-provider-scaleway/v2/internal/httperrors"
11+
"github.com/scaleway/terraform-provider-scaleway/v2/internal/locality/regional"
12+
"github.com/scaleway/terraform-provider-scaleway/v2/internal/services/account"
13+
"github.com/scaleway/terraform-provider-scaleway/v2/internal/types"
14+
)
15+
16+
func ResourceCustomModel() *schema.Resource {
17+
return &schema.Resource{
18+
CreateContext: ResourceCustomModelCreate,
19+
ReadContext: ResourceCustomModelRead,
20+
DeleteContext: ResourceCustomModelDelete,
21+
Importer: &schema.ResourceImporter{
22+
StateContext: schema.ImportStatePassthroughContext,
23+
},
24+
Timeouts: &schema.ResourceTimeout{
25+
Default: schema.DefaultTimeout(defaultCustomModelTimeout),
26+
Create: schema.DefaultTimeout(defaultCustomModelTimeout),
27+
Update: schema.DefaultTimeout(defaultCustomModelTimeout),
28+
Delete: schema.DefaultTimeout(defaultCustomModelTimeout),
29+
},
30+
SchemaVersion: 0,
31+
Schema: map[string]*schema.Schema{
32+
"name": {
33+
Type: schema.TypeString,
34+
Required: true,
35+
ForceNew: true,
36+
Description: "The name of the model",
37+
},
38+
"url": {
39+
Type: schema.TypeString,
40+
Required: true,
41+
ForceNew: true,
42+
Description: "The URL of the model",
43+
},
44+
"secret": {
45+
Type: schema.TypeString,
46+
Optional: true,
47+
Sensitive: true,
48+
ForceNew: true,
49+
Description: "The secret to pull a model",
50+
},
51+
"tags": {
52+
Type: schema.TypeList,
53+
Elem: &schema.Schema{Type: schema.TypeString},
54+
Computed: true,
55+
Description: "The tags associated with the deployment",
56+
},
57+
"project_id": account.ProjectIDSchema(),
58+
"status": {
59+
Type: schema.TypeString,
60+
Computed: true,
61+
Description: "The status of the model",
62+
},
63+
"description": {
64+
Type: schema.TypeString,
65+
Computed: true,
66+
Description: "The description of the model",
67+
},
68+
"error_message": {
69+
Type: schema.TypeString,
70+
Computed: true,
71+
Description: "Displays information if your model is in error state",
72+
},
73+
"created_at": {
74+
Type: schema.TypeString,
75+
Computed: true,
76+
Description: "The date and time of the creation of the model",
77+
},
78+
"updated_at": {
79+
Type: schema.TypeString,
80+
Computed: true,
81+
Description: "The date and time of the last update of the model",
82+
},
83+
"has_eula": {
84+
Type: schema.TypeBool,
85+
Computed: true,
86+
Description: "Defines whether the model has an end user license agreement",
87+
},
88+
"nodes_support": {
89+
Type: schema.TypeList,
90+
Computed: true,
91+
Description: "Supported node types with quantization options and context lengths.",
92+
Elem: &schema.Resource{
93+
Schema: map[string]*schema.Schema{
94+
"node_type_name": {
95+
Type: schema.TypeString,
96+
Computed: true,
97+
Description: "Supported node type.",
98+
},
99+
"quantization": {
100+
Type: schema.TypeList,
101+
Computed: true,
102+
Description: "Supported quantization options.",
103+
Elem: &schema.Resource{
104+
Schema: map[string]*schema.Schema{
105+
"quantization_bits": {
106+
Type: schema.TypeInt,
107+
Computed: true,
108+
Description: "Number of bits used for quantization.",
109+
},
110+
"allowed": {
111+
Type: schema.TypeBool,
112+
Computed: true,
113+
Description: "Whether this quantization is allowed for the model.",
114+
},
115+
"max_context_size": {
116+
Type: schema.TypeInt,
117+
Computed: true,
118+
Description: "Maximum inference context size for this quantization and node type.",
119+
},
120+
},
121+
},
122+
},
123+
},
124+
},
125+
},
126+
"parameter_size_bits": {
127+
Type: schema.TypeInt,
128+
Computed: true,
129+
Description: "Size, in bits, of the model parameters",
130+
},
131+
"size_bits": {
132+
Type: schema.TypeInt,
133+
Computed: true,
134+
Description: "Total size, in bytes, of the model files",
135+
},
136+
"region": regional.Schema(),
137+
},
138+
}
139+
}
140+
141+
func ResourceCustomModelCreate(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
142+
api, region, err := NewAPIWithRegion(d, m)
143+
if err != nil {
144+
return diag.FromErr(err)
145+
}
146+
147+
modelSource := &inference.ModelSource{
148+
URL: d.Get("url").(string),
149+
}
150+
if secret, ok := d.GetOk("secret"); ok {
151+
secretStr := secret.(string)
152+
modelSource.Secret = &secretStr
153+
}
154+
155+
reqCreateModel := &inference.CreateModelRequest{
156+
Region: region,
157+
Name: d.Get("name").(string),
158+
ProjectID: d.Get("project_id").(string),
159+
Source: modelSource,
160+
}
161+
162+
model, err := api.CreateModel(reqCreateModel)
163+
if err != nil {
164+
return diag.FromErr(err)
165+
}
166+
167+
d.SetId(regional.NewIDString(region, model.ID))
168+
169+
model, err = waitForModel(ctx, api, region, model.ID, d.Timeout(schema.TimeoutCreate))
170+
if err != nil {
171+
return diag.FromErr(err)
172+
}
173+
174+
if model.Status == inference.ModelStatusError {
175+
errMsg := *model.ErrorMessage
176+
return diag.FromErr(fmt.Errorf("model '%s' is in status '%s'", model.ID, errMsg))
177+
}
178+
179+
return ResourceCustomModelRead(ctx, d, m)
180+
}
181+
182+
func ResourceCustomModelRead(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
183+
api, region, id, err := NewAPIWithRegionAndID(m, d.Id())
184+
if err != nil {
185+
return diag.FromErr(err)
186+
}
187+
188+
model, err := waitForModel(ctx, api, region, id, d.Timeout(schema.TimeoutRead))
189+
if err != nil {
190+
if httperrors.Is404(err) {
191+
d.SetId("")
192+
193+
return nil
194+
}
195+
196+
return diag.FromErr(err)
197+
}
198+
199+
_ = d.Set("parameter_size_bits", model.ParameterSizeBits)
200+
_ = d.Set("size_bits", model.SizeBytes)
201+
_ = d.Set("name", model.Name)
202+
_ = d.Set("status", model.Status)
203+
_ = d.Set("description", model.Description)
204+
_ = d.Set("tags", types.ExpandUpdatedStringsPtr(model.Tags))
205+
_ = d.Set("created_at", types.FlattenTime(model.CreatedAt))
206+
_ = d.Set("updated_at", types.FlattenTime(model.UpdatedAt))
207+
_ = d.Set("has_eula", model.HasEula)
208+
_ = d.Set("nodes_support", flattenNodeSupport(model.NodesSupport))
209+
_ = d.Set("error_message", model.ErrorMessage)
210+
211+
return nil
212+
}
213+
214+
func ResourceCustomModelDelete(ctx context.Context, d *schema.ResourceData, m interface{}) diag.Diagnostics {
215+
api, region, id, err := NewAPIWithRegionAndID(m, d.Id())
216+
if err != nil {
217+
return diag.FromErr(err)
218+
}
219+
220+
_, err = waitForModel(ctx, api, region, id, d.Timeout(schema.TimeoutDelete))
221+
if err != nil {
222+
return diag.FromErr(err)
223+
}
224+
225+
err = api.DeleteModel(&inference.DeleteModelRequest{
226+
Region: region,
227+
ModelID: id,
228+
}, scw.WithContext(ctx))
229+
if err != nil {
230+
return diag.FromErr(err)
231+
}
232+
233+
return nil
234+
}

internal/services/inference/custom_model_data_source.go

Lines changed: 0 additions & 7 deletions
This file was deleted.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
package inference_test
2+
3+
import (
4+
"fmt"
5+
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
6+
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
7+
inferenceSDK "github.com/scaleway/scaleway-sdk-go/api/inference/v1"
8+
"github.com/scaleway/terraform-provider-scaleway/v2/internal/acctest"
9+
"github.com/scaleway/terraform-provider-scaleway/v2/internal/services/inference"
10+
inferencetestfuncs "github.com/scaleway/terraform-provider-scaleway/v2/internal/services/inference/testfuncs"
11+
"regexp"
12+
"testing"
13+
)
14+
15+
const (
16+
modelURLCompatible = "https://huggingface.co/agentica-org/DeepCoder-14B-Preview"
17+
modelURLNotCompatible = "https://huggingface.co/google/gemma-3-4b-it"
18+
)
19+
20+
func TestAccCustomModel_Basic(t *testing.T) {
21+
tt := acctest.NewTestTools(t)
22+
defer tt.Cleanup()
23+
24+
modelName := "TestAccCustomModel_Basic"
25+
26+
resource.ParallelTest(t, resource.TestCase{
27+
PreCheck: func() { acctest.PreCheck(t) },
28+
ProviderFactories: tt.ProviderFactories,
29+
CheckDestroy: inferencetestfuncs.IsCustomModelDestroyed(tt),
30+
Steps: []resource.TestStep{
31+
{
32+
Config: fmt.Sprintf(`
33+
resource "scaleway_inference_custom_model" "test" {
34+
name = "%s"
35+
url = "%s"
36+
}`, modelName, modelURLCompatible),
37+
Check: resource.ComposeTestCheckFunc(
38+
testAccCheckCustomModelExists(tt, "scaleway_inference_custom_model.test"),
39+
resource.TestCheckResourceAttr("scaleway_inference_custom_model.test", "name", modelName),
40+
),
41+
},
42+
},
43+
})
44+
}
45+
46+
func TestAccCustomModel_NotCompatible(t *testing.T) {
47+
tt := acctest.NewTestTools(t)
48+
defer tt.Cleanup()
49+
50+
modelName := "TestAccCustomModel_NotCompatible"
51+
52+
resource.ParallelTest(t, resource.TestCase{
53+
PreCheck: func() { acctest.PreCheck(t) },
54+
ProviderFactories: tt.ProviderFactories,
55+
CheckDestroy: inferencetestfuncs.IsCustomModelDestroyed(tt),
56+
Steps: []resource.TestStep{
57+
{
58+
Config: fmt.Sprintf(`
59+
resource "scaleway_inference_custom_model" "test" {
60+
name = "%s"
61+
url = "%s"
62+
}`, modelName, modelURLNotCompatible),
63+
ExpectError: regexp.MustCompile("scaleway-sdk-go: precondition failed: , the model with ID 'google/gemma-3-4b-it' is not supported. access to model google/gemma-3-4b-it is restricted. Check your permissions to access the repository at https://huggingface.co/google/gemma-3-4b-it and ensure your credentials are valid Please visit https://www.scaleway.com/en/docs/ai-data/managed-inference/reference-content/supported-models for more details about the supported models."),
64+
},
65+
},
66+
})
67+
}
68+
69+
func testAccCheckCustomModelExists(tt *acctest.TestTools, n string) resource.TestCheckFunc {
70+
return func(state *terraform.State) error {
71+
rs, ok := state.RootModule().Resources[n]
72+
if !ok {
73+
return fmt.Errorf("can't find custom model resource name: %s", n)
74+
}
75+
76+
api, region, id, err := inference.NewAPIWithRegionAndID(tt.Meta, rs.Primary.ID)
77+
if err != nil {
78+
return err
79+
}
80+
81+
_, err = api.GetModel(&inferenceSDK.GetModelRequest{
82+
Region: region,
83+
ModelID: id,
84+
})
85+
if err != nil {
86+
return err
87+
}
88+
return nil
89+
}
90+
}

internal/services/inference/helpers_inference.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import (
1313
const (
1414
defaultInferenceDeploymentTimeout = 80 * time.Minute
1515
defaultDeploymentRetryInterval = 1 * time.Minute
16+
defaultCustomModelTimeout = 40 * time.Minute
17+
defaultCustomModelRetryInterval = 1 * time.Minute
1618
)
1719

1820
// NewAPIWithRegion returns a new inference API and the region for a Create request

0 commit comments

Comments
 (0)